diff --git a/backend/app/bodylimit.py b/backend/app/bodylimit.py index 37090fb7..0008ee62 100644 --- a/backend/app/bodylimit.py +++ b/backend/app/bodylimit.py @@ -64,11 +64,20 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024 # A route belongs here ONLY IF ITS OWN CAP IS LARGER THAN THE DEFAULT. That is why # `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the # default, so listing it could only widen what it accepts. +# What a multipart envelope costs on top of the file itself: the boundary lines, the part headers +# and the sibling `display_name` field. The edge counts RAW BODY bytes while the upload route counts +# the DECODED file, so without this allowance a file of exactly the configured maximum — the number +# the admin page advertises, and the first thing anyone testing the limit reaches for — is refused +# one layer before the route that would have accepted it. Generous on purpose: its job is to keep +# the two layers from disagreeing at the boundary, not to be a second limit. +MULTIPART_ENVELOPE_ALLOWANCE = 64 * 1024 + ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = { - # Ingesting a local video file. `sysconfig`'s `download_upload_max_bytes` is bounded by this - # same setting (see sysconfig.SPECS), so the admin knob can never ask for more than the edge - # will pass — the two numbers cannot drift into refusing a legitimate upload. - ("POST", "/api/downloads/upload"): settings.download_upload_max_bytes, + # Ingesting a local video file. `sysconfig`'s `download_upload_max_bytes` is bounded so it can + # never exceed this (see sysconfig.SPECS), which is what stops an admin from raising the route's + # own cap past the edge and collecting 413s from a layer the admin page never mentions. + ("POST", "/api/downloads/upload"): settings.download_upload_max_bytes + + MULTIPART_ENVELOPE_ALLOWANCE, ("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES, } diff --git a/backend/app/config.py b/backend/app/config.py index 8be0a0bd..1ae64b41 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Literal, get_args, get_origin -from pydantic import ValidationInfo, field_validator, model_validator +from pydantic import Field, ValidationInfo, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Shipped placeholder; production must override it. Used by the startup guard below. @@ -247,7 +247,11 @@ class Settings(BaseSettings): # Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker # finishes), an upload's size is known at ingest, so this bounds a single file directly; the # user's overall byte footprint is still governed by download_default_max_bytes. - download_upload_max_bytes: int = 524_288_000 # 500 MiB + # ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a + # real limit of zero rather than the route's "no per-file cap" sentinel — an instance set to 0 + # would refuse every upload, including the admin page's own way back. To lift the cap in + # practice, set a large number. + download_upload_max_bytes: int = Field(default=524_288_000, ge=1) # 500 MiB # Retention: an asset expires this many days after its last access; the GC job then # deletes it (once no job references it) after an additional grace window. download_retention_days: int = 30 diff --git a/backend/app/main.py b/backend/app/main.py index caba3f7f..68b6ddb0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -190,28 +190,6 @@ app.add_middleware( https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod) ) -# SEC-20 — the global request-body cap (app/bodylimit.py). FastAPI reads a declared body IN FULL -# before the first dependency, so without this the anonymous login route is a free memory/time sink. -# -# WHERE it sits is the whole design. Registered directly after SessionMiddleware, so — last -# registered being outermost — it ends up just INSIDE CORS and just OUTSIDE the session: the 413 is -# decorated by CORS and the security headers like every other response (a refusal a browser cannot -# read is no better than a hang, and `capped_body`'s 413 one layer down is decorated), while the -# session cookie, the setup gate and every auth dependency under the router still never run for a -# body that is already too large. Registering it outside CORS instead would answer a split-origin -# caller with a response the browser drops — two shapes for one fact, which is exactly what -# bodylimit.py exists to avoid. -app.add_middleware(bodylimit.BodyLimitMiddleware) - -if settings.frontend_origin: - app.add_middleware( - CORSMiddleware, - allow_origins=[settings.frontend_origin], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - # Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or # /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render. _SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version") @@ -231,6 +209,34 @@ async def setup_gate(request, call_next): return await call_next(request) +# SEC-20 — the global request-body cap (app/bodylimit.py). FastAPI reads a declared body IN FULL +# before the first dependency, so without this the anonymous login route is a free memory/time sink. +# +# WHERE it sits is the whole design, and it is pinned by `test_body_limit.py` rather than by this +# comment, because `add_middleware` inverts the reading order (last registered = OUTERMOST) and a +# stack described in prose drifts. Registered here it lands directly INSIDE CORS and directly +# OUTSIDE the setup gate, which is the narrowest position that satisfies both constraints: +# +# * inside CORS and the security headers, so the 413 is decorated like every other response. A +# refusal the browser drops for a missing Access-Control-Allow-Origin is no better than a hang, +# and `capped_body`'s 413 one layer down IS decorated — two shapes for one fact is exactly what +# bodylimit.py exists to avoid. +# * outside the setup gate, the session and every auth dependency under the router — none of which +# run for a body that is already too large. The setup gate matters specifically: on an instance +# that is not yet configured it opens a DB session per request, which is precisely the anonymous +# pre-auth work SEC-20 exists to remove. +app.add_middleware(bodylimit.BodyLimitMiddleware) + +if settings.frontend_origin: + app.add_middleware( + CORSMiddleware, + allow_origins=[settings.frontend_origin], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.middleware("http") async def static_cache_headers(request, call_next): """Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 9b3d40fc..0b91f355 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -140,10 +140,12 @@ SPECS: tuple[ConfigSpec, ...] = ( # min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read # as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at # 1 so the cap is always active; the overall footprint is separately bounded by the byte quota. - # `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over this same - # number at the edge, before the route's own check ever sees it. An admin allowed to raise the - # knob past the edge would get 413s from a layer the admin page never mentions, so the two are - # pinned together here and `tests/test_body_limit.py` holds them that way. + # `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over roughly + # this number at the edge, before the route's own check ever sees it. An admin allowed to raise + # the knob past the edge would get 413s from a layer the admin page never mentions, so the two + # are pinned together here and `tests/test_body_limit.py` holds them that way. `_coerce` clamps + # a stored value to this on READ too — this bound did not always exist, so a legal-when-written + # override can be sitting in the database already. ConfigSpec( "download_upload_max_bytes", "int", @@ -249,7 +251,18 @@ def _choice(s: ConfigSpec, raw: object) -> str: def _coerce(s: ConfigSpec, raw: str): if s.type == "int": - return int(raw) + # CLAMPED, not merely validated. `set_value` rejects an out-of-range int, but bounds get + # ADDED to a spec (download_upload_max_bytes gained a max when SEC-20 started enforcing the + # same number at the edge) and env defaults get LOWERED, and either leaves a stored value + # that was legal when it was written. Serving it unclamped is how one layer ends up acting + # on a number another layer refuses — with the admin page happily showing a value outside + # the range it renders. Clamping keeps the promise the bounds make rather than restating it. + v = int(raw) + if s.min is not None: + v = max(v, s.min) + if s.max is not None: + v = min(v, s.max) + return v if s.type == "bool": return raw == "true" if s.type == "choice": diff --git a/backend/tests/test_body_limit.py b/backend/tests/test_body_limit.py index bd9b00de..f46421a0 100644 --- a/backend/tests/test_body_limit.py +++ b/backend/tests/test_body_limit.py @@ -12,12 +12,15 @@ gets through": the first alone would pass just as well if the cap refused *every """ import json +import pathlib +import re import pytest from fastapi.testclient import TestClient from starlette.requests import Request -from app import bodylimit, sysconfig +from app import bodylimit, debugsession, sysconfig +from app.models import SystemConfig from app.bodylimit import DEFAULT_MAX_BODY_BYTES, BodyLimitMiddleware from app.routes import diagnostics as diagnostics_routes @@ -186,27 +189,99 @@ def test_every_listed_template_still_resolves_to_a_real_route(method, template): def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes(): - """The headroom the module's docblock claims, held as a ratio rather than as a figure in prose. + """The headroom the module's docblock claims, held as a RATIO rather than as a figure in prose. The client log sink caps itself at 512 KiB — below the default, which is why it is not listed: - the global ceiling never fires on it and raising it could only widen what it accepts.""" - assert diagnostics_routes.BODY_MAX_BYTES <= DEFAULT_MAX_BODY_BYTES + the global ceiling never fires on it and raising it could only widen what it accepts. + + `<=` alone would not hold the claim. The docblock calls the 2x margin load-bearing precisely + because a ceiling that refuses a legitimate request is a worse bug than the one this module + fixes, and a margin of 1x is no margin at all. + """ + assert diagnostics_routes.BODY_MAX_BYTES * 2 <= DEFAULT_MAX_BODY_BYTES assert not any( path.startswith("/api/diagnostics") for _, path in bodylimit.ROUTE_MAX_BODY_BYTES ) +# Every `capped_body` call site in the routers, as (module, limit constant). A route that reads its +# own body declares the size it MEANS to accept; if that is above the global default and its route +# is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have taken — +# silently, in production, on a path nobody tested. The point of the list is the third entry. +CAPPED_BODY_SITES = { + ("diagnostics.py", "BODY_MAX_BYTES"), + ("debug_sessions.py", "debugsession.SCREENSHOT_MAX_BYTES"), +} + + +def test_every_capped_body_site_is_known_and_fits_under_its_ceiling(): + routes_dir = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes" + found = { + (py.name, m.group(1)) + for py in routes_dir.rglob("*.py") + for m in re.finditer( + r"capped_body\(\s*request\s*,\s*([A-Za-z_][\w.]*)", py.read_text("utf-8") + ) + } + assert found == CAPPED_BODY_SITES, ( + "a capped_body call site appeared or moved. Each one declares a body size; check it against " + "bodylimit.DEFAULT_MAX_BODY_BYTES and, if it is larger, add its route to " + "bodylimit.ROUTE_MAX_BODY_BYTES — then update this list." + ) + # And the two known ones really do satisfy the rule. + assert diagnostics_routes.BODY_MAX_BYTES <= DEFAULT_MAX_BODY_BYTES + assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES + assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES + + def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart(): - """The upload route's in-handler cap is a `sysconfig` value an admin can change; this middleware - is a constant. If the knob could be set above the ceiling, an admin raising it would silently - start getting 413s from a layer the admin page never mentions. The spec's `max` is what stops - that, and it is the same number.""" + """The upload route's in-handler cap is a `sysconfig` value an admin can change; the edge is a + constant. If the knob could be set above the edge, an admin raising it would silently start + getting 413s from a layer the admin page never mentions. + + The relation is `<=`, not `==`, and the gap is the point: the edge counts RAW body bytes while + the route counts the DECODED file, so the edge has to allow for the multipart envelope or a file + of exactly the advertised maximum is refused one layer before the route that would take it. + """ spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes") - assert spec.max == bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")] + edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")] + assert spec.max is not None and spec.max <= edge + assert edge - spec.max == bodylimit.MULTIPART_ENVELOPE_ALLOWANCE + + +def test_a_stored_override_above_the_max_is_clamped_on_READ(db): + """`set_value` refuses an out-of-range int, but this bound did not always exist — before SEC-20 + the upload knob had no `max` at all — so a value that was legal when it was written can be + sitting in the database. Served unclamped it is exactly the drift the test above forbids: the + route would accept bytes the edge refuses.""" + spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes") + assert spec.max is not None + db.add(SystemConfig(key="download_upload_max_bytes", value=str(spec.max * 4))) + db.commit() + assert sysconfig.get_int(db, "download_upload_max_bytes") == spec.max + + +def test_the_cap_sits_inside_the_response_decorators_and_outside_the_setup_gate(): + """The registration order, held in code rather than in the comment beside it. `add_middleware` + inverts the reading order (last registered = OUTERMOST), which is exactly the kind of thing a + prose description gets backwards — it already did once. + + Outside the cap: the response decorators, so the 413 is dressed like any other answer. Inside + it: everything that does work on the caller's behalf — the setup gate opens a DB session per + request while an instance is unconfigured, and that is the anonymous pre-auth work SEC-20 exists + to remove. + """ + from app.main import app + + names = [getattr(m.cls, "__name__", str(m.cls)) for m in app.user_middleware] + i = names.index("BodyLimitMiddleware") + # Outer→inner, so a smaller index is further out. + assert i > 0, "the cap is the outermost middleware — the access log must stay outside it" + assert names[i + 1 :] and "SessionMiddleware" in names[i + 1 :] + # The setup gate and every auth dependency are inside; only decorators are outside. + assert all(n == "BaseHTTPMiddleware" or n == "CORSMiddleware" for n in names[:i]) def test_the_screenshot_ceiling_matches_the_cap_the_route_itself_enforces(): - from app import debugsession - assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES assert ( bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/debug-sessions/{sid}/screenshots")] @@ -217,12 +292,22 @@ def test_the_screenshot_ceiling_matches_the_cap_the_route_itself_enforces(): # --- the real application ----------------------------------------------------------------------- -def test_the_global_refusal_carries_the_baseline_security_headers(client): +def test_the_global_refusal_carries_the_baseline_security_headers(): """The 413 is a response like any other, so it has to be dressed like one. The middleware sits INSIDE the security-header and CORS layers for exactly this reason: a refusal a browser drops for a missing header is indistinguishable from a network failure, and the user would be shown a - generic error instead of the translated `too_large`.""" - r = client.post("/auth/password-login", content=OVER, headers={"content-type": "text/plain"}) + generic error instead of the translated `too_large`. + + A bare TestClient on purpose, not the `client` fixture: this is the only test that exercises the + middleware ORDER, and that fixture is DB-gated — so on a host without Postgres the one assertion + holding the move would have skipped while the file still reported green. The refusal never + reaches a database; it is answered above the router. + """ + from app.main import app + + r = TestClient(app).post( + "/auth/password-login", content=OVER, headers={"content-type": "text/plain"} + ) assert r.status_code == 413 assert r.headers.get("x-content-type-options") == "nosniff" assert "referrer-policy" in r.headers diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo deleted file mode 100644 index 4f1dfb39..00000000 --- a/frontend/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/about.tsx","./src/components/aboutcell.tsx","./src/components/accountpanel.tsx","./src/components/activefilterchips.tsx","./src/components/addtoplaylist.tsx","./src/components/adminlogs.dom.test.tsx","./src/components/adminlogs.tsx","./src/components/adminusers.dom.test.tsx","./src/components/adminusers.tsx","./src/components/appcommandpalette.dom.test.tsx","./src/components/appcommandpalette.tsx","./src/components/auditlog.tsx","./src/components/bootgate.dom.test.tsx","./src/components/bootgate.tsx","./src/components/channelabout.tsx","./src/components/channeldiscovery.tsx","./src/components/channellayoutgrid.dom.test.tsx","./src/components/channellayoutgrid.tsx","./src/components/channellink.tsx","./src/components/channelnamelink.dom.test.tsx","./src/components/channelnamelink.tsx","./src/components/channelpage.backfill.dom.test.tsx","./src/components/channelpage.busyfocus.dom.test.tsx","./src/components/channelpage.tsx","./src/components/channelpicker.tsx","./src/components/channelstatspopover.dom.test.tsx","./src/components/channelstatspopover.tsx","./src/components/channeltags.suggest.dom.test.tsx","./src/components/channeltags.tsx","./src/components/channels.tsx","./src/components/channelsprovider.dom.test.tsx","./src/components/channelsprovider.tsx","./src/components/chatdock.tsx","./src/components/chatthread.tsx","./src/components/comfortpinmenu.tsx","./src/components/configpanel.choice.dom.test.tsx","./src/components/configpanel.field.dom.test.tsx","./src/components/configpanel.tsx","./src/components/confirmprovider.tsx","./src/components/datefilterselect.tsx","./src/components/debugrecorder.dom.test.tsx","./src/components/debugrecorder.tsx","./src/components/demobanner.tsx","./src/components/diagnosticssettings.dom.test.tsx","./src/components/diagnosticssettings.tsx","./src/components/downloadbutton.tsx","./src/components/downloadcenter.confirm.dom.test.tsx","./src/components/downloadcenter.tsx","./src/components/downloaddialog.tsx","./src/components/errorboundary.stalebuild.dom.test.tsx","./src/components/errorboundary.tsx","./src/components/errordialog.tsx","./src/components/excludedchannellist.dom.test.tsx","./src/components/excludedchannellist.tsx","./src/components/feed.tsx","./src/components/feed.ytsort.dom.test.tsx","./src/components/feedfiltersprovider.dom.test.ts","./src/components/feedfiltersprovider.seed.dom.test.tsx","./src/components/feedfiltersprovider.tsx","./src/components/feedscopetoggle.tsx","./src/components/feedvideomenu.dom.test.tsx","./src/components/feedviewprovider.tsx","./src/components/filterrail.tsx","./src/components/glasstuner.tsx","./src/components/header.tsx","./src/components/home.dom.test.tsx","./src/components/home.pin.dom.test.tsx","./src/components/home.tsx","./src/components/issuecomposer.dom.test.tsx","./src/components/issuecomposer.tsx","./src/components/keygate.tsx","./src/components/languageswitcher.tsx","./src/components/libraryplayer.dom.test.tsx","./src/components/libraryplayer.tsx","./src/components/logline.tsx","./src/components/messages.keyboard.dom.test.tsx","./src/components/messages.tsx","./src/components/mylogviewer.dom.test.tsx","./src/components/mylogviewer.tsx","./src/components/navsidebar.tsx","./src/components/navigationprovider.dom.test.tsx","./src/components/navigationprovider.tsx","./src/components/notificationspanel.tsx","./src/components/onboardingwizard.dialog.dom.test.tsx","./src/components/onboardingwizard.escape.dom.test.tsx","./src/components/onboardingwizard.tsx","./src/components/palettelauncher.dom.test.tsx","./src/components/palettelauncher.tsx","./src/components/personalviewsmodal.dom.test.tsx","./src/components/personalviewsmodal.tsx","./src/components/playermodal.dom.test.tsx","./src/components/playermodal.tsx","./src/components/playlisteditfailures.dom.test.tsx","./src/components/playlistlimitshint.dom.test.tsx","./src/components/playlistlimitshint.tsx","./src/components/playlists.dom.test.tsx","./src/components/playlists.tsx","./src/components/playlistsprovider.tsx","./src/components/playlistsrail.rename.dom.test.tsx","./src/components/playlistsrail.tsx","./src/components/plexbrowse.tsx","./src/components/plexcollectioneditor.tsx","./src/components/plexcollectionview.dom.test.tsx","./src/components/plexcollectionview.tsx","./src/components/plexdiscover.dom.test.tsx","./src/components/plexdiscover.tsx","./src/components/plexinfo.dom.test.tsx","./src/components/plexinfo.tsx","./src/components/plexlibrarypicker.dom.test.tsx","./src/components/plexlibrarypicker.tsx","./src/components/plexpersonview.dom.test.tsx","./src/components/plexplayer.backspace.dom.test.tsx","./src/components/plexplayer.hls.dom.test.tsx","./src/components/plexplayer.session.dom.test.tsx","./src/components/plexplayer.strictmode.dom.test.tsx","./src/components/plexplayer.tabtrap.dom.test.tsx","./src/components/plexplayer.tsx","./src/components/plexplayersettings.tsx","./src/components/plexplaylistadd.tsx","./src/components/plexplaylistreorder.dom.test.tsx","./src/components/plexplaylistview.header.dom.test.tsx","./src/components/plexplaylistview.tsx","./src/components/plexprovider.dom.test.tsx","./src/components/plexprovider.tsx","./src/components/plexscopetoggle.dom.test.tsx","./src/components/plexscopetoggle.tsx","./src/components/plexsearchbox.dom.test.tsx","./src/components/plexsearchbox.tsx","./src/components/plexsidebar.tsx","./src/components/plexstatsoverlay.tsx","./src/components/plexviewbridge.tsx","./src/components/plexviewtoggle.dom.test.tsx","./src/components/plexviewtoggle.tsx","./src/components/postertile.tsx","./src/components/prefsprovider.dom.test.tsx","./src/components/prefsprovider.tsx","./src/components/profileeditor.tsx","./src/components/releasenotes.tsx","./src/components/savedviewspopover.dom.test.tsx","./src/components/savedviewspopover.tsx","./src/components/savedviewsprovider.dom.test.tsx","./src/components/savedviewsprovider.tsx","./src/components/scheduler.dom.test.tsx","./src/components/scheduler.tsx","./src/components/searchbar.dom.test.tsx","./src/components/searchbar.keys.dom.test.tsx","./src/components/searchbar.tsx","./src/components/selectedviewchip.tsx","./src/components/settingspanel.account.dom.test.tsx","./src/components/settingspanel.downloadauth.dom.test.tsx","./src/components/settingspanel.tsx","./src/components/setupwizard.tsx","./src/components/sharedialog.dom.test.tsx","./src/components/sharedialog.tsx","./src/components/sidebar.tsx","./src/components/stats.tsx","./src/components/syncstatus.tsx","./src/components/tagfilterchips.dom.test.tsx","./src/components/tagfilterchips.tsx","./src/components/tagmanager.tsx","./src/components/upnextcard.dom.test.tsx","./src/components/upnextcard.tsx","./src/components/uploaddialog.dom.test.tsx","./src/components/uploaddialog.tsx","./src/components/verifybanner.tsx","./src/components/versionbanner.tsx","./src/components/videocard.channel.dom.test.tsx","./src/components/videocard.nav.dom.test.tsx","./src/components/videocard.tsx","./src/components/videoeditor.partial.dom.test.tsx","./src/components/videoeditor.tsx","./src/components/videoinfocard.dom.test.tsx","./src/components/videoinfocard.tsx","./src/components/viewswitcher.tsx","./src/components/virtualfeed.tsx","./src/components/watchpage.dom.test.tsx","./src/components/watchpage.tsx","./src/components/welcome.tsx","./src/components/wizardprovider.tsx","./src/components/writefailurereporting.dom.test.tsx","./src/components/yearfilter.tsx","./src/components/accountpanel.dom.test.tsx","./src/components/admininvites.dom.test.tsx","./src/components/adminpendingaccess.dom.test.tsx","./src/components/auditcolumns.tsx","./src/components/authcardstates.dom.test.tsx","./src/components/channelcells.tsx","./src/components/channelcolumns.dom.test.tsx","./src/components/channelcolumns.tsx","./src/components/channelsync.ts","./src/components/channelscontext.ts","./src/components/clientlogwiring.dom.test.tsx","./src/components/comfortpin.dom.test.ts","./src/components/comfortpin.tsx","./src/components/confirmcontext.ts","./src/components/downloadadmin.tsx","./src/components/downloadmodals.tsx","./src/components/downloadrow.dom.test.tsx","./src/components/downloadrow.tsx","./src/components/downloadtitle.ts","./src/components/feedfilterscontext.ts","./src/components/feedfiltersinit.ts","./src/components/feedviewcontext.ts","./src/components/formalerts.dom.test.tsx","./src/components/listnav.wiring.contract.test.ts","./src/components/loglineparts.ts","./src/components/modulepages.tsx","./src/components/moduleregistry.tsx","./src/components/navigation.dom.test.ts","./src/components/navigationcontext.ts","./src/components/navigationinit.ts","./src/components/playermodalcontrols.tsx","./src/components/playlistcolumns.tsx","./src/components/playlistscontext.ts","./src/components/playlistsinit.test.ts","./src/components/playlistsinit.ts","./src/components/plexbrowselists.tsx","./src/components/plexcards.dom.test.tsx","./src/components/plexcards.tsx","./src/components/plexcontext.ts","./src/components/plexdetailui.tsx","./src/components/plexplayable.ts","./src/components/plexplayercontrols.tsx","./src/components/plexsuburl.test.ts","./src/components/plexsuburl.ts","./src/components/plexviewcontext.ts","./src/components/plexviews.tsx","./src/components/prefscontext.ts","./src/components/savedviewscontext.ts","./src/components/usedebugrecording.ts","./src/components/useplaylistactions.dom.test.tsx","./src/components/useplaylistactions.ts","./src/components/useplexactivefilters.dom.test.tsx","./src/components/useplexactivefilters.ts","./src/components/useplexplaylistedits.dom.test.tsx","./src/components/useplexplaylistedits.ts","./src/components/usetagsuggestions.dom.test.tsx","./src/components/usetagsuggestions.ts","./src/components/welcomemode.dom.test.ts","./src/components/welcomemode.render.dom.test.tsx","./src/components/welcomemode.ts","./src/components/wizardcontext.ts","./src/components/legal/legallayout.tsx","./src/components/legal/privacypolicy.tsx","./src/components/legal/terms.tsx","./src/components/legal/legalbodies.contract.dom.test.tsx","./src/components/legal/usehungarian.ts","./src/components/ui/actionmenu.dom.test.tsx","./src/components/ui/actionmenu.tsx","./src/components/ui/anchoredpopover.dom.test.tsx","./src/components/ui/anchoredpopover.returnfocus.dom.test.tsx","./src/components/ui/anchoredpopover.tsx","./src/components/ui/avatar.dom.test.tsx","./src/components/ui/avatar.tsx","./src/components/ui/backtotop.tsx","./src/components/ui/banner.dom.test.tsx","./src/components/ui/banner.tsx","./src/components/ui/button.dom.test.tsx","./src/components/ui/button.tsx","./src/components/ui/carousel.dom.test.tsx","./src/components/ui/carousel.tsx","./src/components/ui/columnsortcontrol.tsx","./src/components/ui/combobox.dom.test.tsx","./src/components/ui/combobox.tsx","./src/components/ui/commandpalette.dom.test.tsx","./src/components/ui/commandpalette.ordering.dom.test.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/contextmenu.dom.test.tsx","./src/components/ui/contextmenu.tsx","./src/components/ui/countbadge.tsx","./src/components/ui/datatable.fixedlayout.dom.test.tsx","./src/components/ui/datatable.keyboard.dom.test.tsx","./src/components/ui/datatable.tsx","./src/components/ui/draftsavebar.tsx","./src/components/ui/filterchipgroup.dom.test.tsx","./src/components/ui/filterchipgroup.tsx","./src/components/ui/filterselect.dom.test.tsx","./src/components/ui/filterselect.tsx","./src/components/ui/focussafebutton.tsx","./src/components/ui/inlinenotice.tsx","./src/components/ui/itemcount.dom.test.tsx","./src/components/ui/itemcount.tsx","./src/components/ui/keyboardhelp.dom.test.tsx","./src/components/ui/keyboardhelp.tsx","./src/components/ui/modal.dom.test.tsx","./src/components/ui/modal.fullscreen.dom.test.tsx","./src/components/ui/modal.strictmode.dom.test.tsx","./src/components/ui/modal.tsx","./src/components/ui/navgrid.tsx","./src/components/ui/overlay.tsx","./src/components/ui/pagescroller.dom.test.tsx","./src/components/ui/pagescroller.tsx","./src/components/ui/pageshell.dom.test.tsx","./src/components/ui/pageshell.tsx","./src/components/ui/pager.dom.test.tsx","./src/components/ui/pager.tsx","./src/components/ui/panelgroup.tsx","./src/components/ui/panelgroups.tsx","./src/components/ui/prioritystepper.dom.test.tsx","./src/components/ui/prioritystepper.tsx","./src/components/ui/querystate.dom.test.tsx","./src/components/ui/querystate.tsx","./src/components/ui/segmentedtoggle.dom.test.tsx","./src/components/ui/segmentedtoggle.tsx","./src/components/ui/sidepanel.tsx","./src/components/ui/sortcontrol.dom.test.tsx","./src/components/ui/sortcontrol.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toaster.dom.test.tsx","./src/components/ui/toaster.hosts.test.ts","./src/components/ui/toaster.strictmode.dom.test.tsx","./src/components/ui/toaster.tsx","./src/components/ui/tooltip.layer.dom.test.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/undotoolbar.tsx","./src/components/ui/virtualgrid.tsx","./src/components/ui/anchoredpopoverhooks.ts","./src/components/ui/dialogfocus.dom.test.tsx","./src/components/ui/dialogfocus.ts","./src/components/ui/form.tsx","./src/components/ui/gridcolumns.test.ts","./src/components/ui/gridcolumns.ts","./src/components/ui/overlayorder.ts","./src/components/ui/pagetoolbarslot.ts","./src/components/ui/pagercontent.ts","./src/components/ui/readahead.test.ts","./src/components/ui/readahead.ts","./src/components/ui/sortablea11y.dom.test.tsx","./src/components/ui/sortablea11y.ts","./src/components/ui/stickytop.dom.test.tsx","./src/components/ui/stickytop.ts","./src/components/ui/tablealign.ts","./src/components/ui/tableheader.tsx","./src/components/ui/toastlevels.ts","./src/components/ui/uii18n.contract.test.ts","./src/components/ui/usecontextmenu.ts","./src/components/ui/useitemtriggers.dom.test.ts","./src/i18n/appcopy.dom.test.ts","./src/i18n/appcopy.ts","./src/i18n/backendids.contract.test.ts","./src/i18n/index.ts","./src/i18n/parity.test.ts","./src/i18n/unusedkeys.contract.test.ts","./src/lib/accountpinnedstores.dom.test.ts","./src/lib/actionerrors.test.ts","./src/lib/actionerrors.ts","./src/lib/activeaccount.dom.test.ts","./src/lib/activeaccount.ts","./src/lib/adminusers.test.ts","./src/lib/adminusers.ts","./src/lib/adminuserstab.ts","./src/lib/api.ts","./src/lib/browserbackgroundplay.dom.test.ts","./src/lib/browserbackgroundplay.ts","./src/lib/capturetab.dom.test.ts","./src/lib/capturetab.ts","./src/lib/channeldetailpoll.backfill.dom.test.tsx","./src/lib/channeldetailpoll.test.ts","./src/lib/channeldetailpoll.ts","./src/lib/channellayout.ts","./src/lib/channelwritefailures.dom.test.tsx","./src/lib/channelsurlstate.dom.test.ts","./src/lib/channelsurlstate.ts","./src/lib/chatdock.ts","./src/lib/chiplane.contract.test.ts","./src/lib/chipoverflow.test.ts","./src/lib/chipoverflow.ts","./src/lib/clipboard.ts","./src/lib/columnsort.test.ts","./src/lib/columnsort.ts","./src/lib/controlrules.contract.test.ts","./src/lib/cropgeometry.test.ts","./src/lib/cropgeometry.ts","./src/lib/datefilter.test.ts","./src/lib/datefilter.ts","./src/lib/debugrecorder.ts","./src/lib/descriptionlinks.tsx","./src/lib/detaillog.dom.test.ts","./src/lib/diagnostics.dom.test.ts","./src/lib/diagnostics.session.dom.test.ts","./src/lib/diagnostics.ts","./src/lib/downloadactions.test.ts","./src/lib/downloadactions.ts","./src/lib/downloadfailure.test.ts","./src/lib/downloadfailure.ts","./src/lib/downloads.test.ts","./src/lib/downloads.ts","./src/lib/e2ee.ts","./src/lib/entryurl.ts","./src/lib/errordialog.ts","./src/lib/feedsort.ts","./src/lib/feedsort.yt.test.ts","./src/lib/feedview.ts","./src/lib/filterlog.ts","./src/lib/focussafe.contract.test.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/fullscreen.ts","./src/lib/globalkeymap.dom.test.tsx","./src/lib/hints.ts","./src/lib/history.dom.test.ts","./src/lib/history.nav.dom.test.ts","./src/lib/history.navposition.dom.test.tsx","./src/lib/history.navrestore.reload.dom.test.tsx","./src/lib/history.subview.dom.test.ts","./src/lib/history.ts","./src/lib/homeintent.ts","./src/lib/http.test.ts","./src/lib/http.ts","./src/lib/huetile.ts","./src/lib/issuedraft.test.ts","./src/lib/issuedraft.ts","./src/lib/jsxcommenttext.contract.test.ts","./src/lib/keymap.sequence.test.ts","./src/lib/keymap.test.ts","./src/lib/keymap.ts","./src/lib/layerstack.dom.test.ts","./src/lib/layerstack.integration.dom.test.tsx","./src/lib/layerstack.ts","./src/lib/lazywithpreload.dom.test.tsx","./src/lib/lazywithpreload.ts","./src/lib/linkify.test.ts","./src/lib/linkify.tsx","./src/lib/listnav.dom.test.tsx","./src/lib/listnav.scope.dom.test.tsx","./src/lib/listnav.ts","./src/lib/listpaging.test.ts","./src/lib/listpaging.ts","./src/lib/literala11ytext.contract.test.ts","./src/lib/log.contract.test.ts","./src/lib/log.test.ts","./src/lib/log.ts","./src/lib/loghooks.dom.test.ts","./src/lib/loghooks.ts","./src/lib/logsurlstate.test.ts","./src/lib/logsurlstate.ts","./src/lib/medialog.ts","./src/lib/mediasource.ts","./src/lib/messagessocket.test.ts","./src/lib/messagessocket.ts","./src/lib/messaging.ts","./src/lib/modules.ts","./src/lib/mutationfailures.contract.test.ts","./src/lib/nav.ts","./src/lib/navposition.wiring.contract.test.ts","./src/lib/networkboundary.contract.test.ts","./src/lib/nodeengine.contract.test.ts","./src/lib/notifications.ts","./src/lib/onboarding.ts","./src/lib/pagekeys.dom.test.tsx","./src/lib/pagemeta.test.ts","./src/lib/pagemeta.ts","./src/lib/panellayout.dom.test.ts","./src/lib/panellayout.ts","./src/lib/parseid.test.ts","./src/lib/parseid.ts","./src/lib/playermodalhooks.progress.dom.test.tsx","./src/lib/playermodalhooks.ts","./src/lib/playermodes.ts","./src/lib/playerqueue.test.ts","./src/lib/playerqueue.ts","./src/lib/playervolume.test.ts","./src/lib/playervolume.ts","./src/lib/playlistname.ts","./src/lib/playlistview.test.ts","./src/lib/playlistview.ts","./src/lib/plexbackdrop.dom.test.tsx","./src/lib/plexdetail.ts","./src/lib/plexfinishmargin.contract.test.ts","./src/lib/plexplayerhooks.subtitle.dom.test.tsx","./src/lib/plexplayerhooks.ts","./src/lib/plexplayerprefs.ts","./src/lib/plexqueue.test.ts","./src/lib/plexqueue.ts","./src/lib/plexrating.test.ts","./src/lib/plexrating.ts","./src/lib/plexsearch.ts","./src/lib/plexsort.ts","./src/lib/plexstats.test.ts","./src/lib/plexstats.ts","./src/lib/plexurlstate.dom.test.ts","./src/lib/plexurlstate.ts","./src/lib/plexwatch.ts","./src/lib/progresscache.ts","./src/lib/pxbox.contract.test.ts","./src/lib/pythonengine.contract.test.ts","./src/lib/queryclient.dom.test.ts","./src/lib/queryclient.ts","./src/lib/querykeys.test.ts","./src/lib/querykeys.ts","./src/lib/releasenotes.test.ts","./src/lib/releasenotes.ts","./src/lib/savedviews.test.ts","./src/lib/savedviews.ts","./src/lib/sortoptions.ts","./src/lib/sortablea11y.contract.test.ts","./src/lib/stalebuild.dom.test.ts","./src/lib/stalebuild.ts","./src/lib/storage.ts","./src/lib/store.ts","./src/lib/tablecolumns.ts","./src/lib/theme.ts","./src/lib/toastdock.ts","./src/lib/uiboundary.contract.test.ts","./src/lib/urlstate.dom.test.ts","./src/lib/urlstate.ts","./src/lib/useactivefilters.ts","./src/lib/usecardpager.dom.test.tsx","./src/lib/usecardpager.ts","./src/lib/usechannelpriority.ts","./src/lib/usechanneltagactions.ts","./src/lib/usechipoverflowfit.dom.test.tsx","./src/lib/usechipoverflowfit.ts","./src/lib/usedebounced.dom.test.ts","./src/lib/usedebounced.ts","./src/lib/usedismiss.dom.test.tsx","./src/lib/usedismiss.ts","./src/lib/usefilterstate.dom.test.ts","./src/lib/usefilterstate.ts","./src/lib/usefocuskeeper.dom.test.tsx","./src/lib/usefocuskeeper.restore.dom.test.tsx","./src/lib/usefocuskeeper.ts","./src/lib/usehoverfocuswithin.dom.test.tsx","./src/lib/usehoverfocuswithin.ts","./src/lib/uselivequery.ts","./src/lib/useme.ts","./src/lib/usemediaquery.ts","./src/lib/useplexcollectiontitles.dom.test.tsx","./src/lib/useplexcollectiontitles.ts","./src/lib/usepref.dom.test.ts","./src/lib/usepref.savefailure.dom.test.ts","./src/lib/usepref.ts","./src/lib/usescrolledges.ts","./src/lib/usescrollfade.ts","./src/lib/usesortstate.dom.test.ts","./src/lib/usesortstate.ts","./src/lib/useundoable.ts","./src/lib/useurlmirror.dom.test.ts","./src/lib/useurlmirror.ts","./src/lib/useverifyentry.ts","./src/lib/verifyentry.dom.test.tsx","./src/lib/version.ts","./src/lib/youtube.ts","./src/lib/youtubeerrors.ts","./src/lib/api/account.ts","./src/lib/api/admin.ts","./src/lib/api/adminlogs.dom.test.ts","./src/lib/api/auth.ts","./src/lib/api/channels.ts","./src/lib/api/core.test.ts","./src/lib/api/core.ts","./src/lib/api/diagnostics.dom.test.ts","./src/lib/api/diagnostics.ts","./src/lib/api/discover.dom.test.ts","./src/lib/api/discover.ts","./src/lib/api/downloads.ts","./src/lib/api/facade.test.ts","./src/lib/api/feed.ts","./src/lib/api/messaging.ts","./src/lib/api/notifications.ts","./src/lib/api/playlists.ts","./src/lib/api/plex.dom.test.ts","./src/lib/api/plex.ts","./src/lib/api/quota.ts","./src/lib/api/savedviews.ts","./src/lib/api/setup.ts","./src/lib/api/sync.ts","./src/lib/api/tagmodes.test.ts","./src/lib/api/tagmodes.ts","./src/lib/api/tags.ts"],"version":"5.9.3"} \ No newline at end of file