diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8cd3f9..66aea3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.117.0 — 2026-11-06 + +### New + +- A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working. +- The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once. +- The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size. + +### Fixed + +- An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed. +- A Plex auto-skip waits 3 seconds into the marker before its countdown starts. + ## 0.116.3 — 2026-11-04 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index d81043fe..4b6b62a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ only along a declared edge and writes them only through that unit's service; cor cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in [`backend/app/README.md`](backend/app/README.md) with the conventions. Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette, -boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held +boot; a share link (`/watch`, `/file`, `/folder`) boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held app-free by a contract test) → **`modules//`** (one folder per page: the page, its rail, its store, its tests, and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by `features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in diff --git a/VERSION b/VERSION index ed3a5734..a38b3bd3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.116.3 +0.117.0 diff --git a/backend/app/downloads/clip.py b/backend/app/downloads/clip.py index 4b4b1021..686e8506 100644 --- a/backend/app/downloads/clip.py +++ b/backend/app/downloads/clip.py @@ -201,6 +201,16 @@ def _ratio(v: object) -> float | None: return n / d if n > 0 and d > 0 else None +def quarter_turned(probe: dict) -> bool | None: + """Whether the first video stream of an ffprobe `-show_streams` JSON is stored turned a quarter + (ffmpeg stands it upright before any filter, so its filters see width and height swapped). None + when there is no video stream.""" + for s in probe.get("streams") or []: + if s.get("codec_type") == "video": + return _quarter_turned(s) + return None + + def _quarter_turned(stream: dict) -> bool: rotation: object = (stream.get("tags") or {}).get("rotate") for sd in stream.get("side_data_list") or []: diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index 2ce485b1..7e9a6242 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -11,7 +11,9 @@ with the worker branching on the asset's `source_kind` to run ffmpeg instead of (the `-ss` seeks first), so a short clip is cheap even from a long source * crop (± trim) → always re-encode (a filter can't be stream-copied), frame-accurate -`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h}, accurate?: bool}`. `accurate` is the +`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h, frame_w?, frame_h?}, accurate?: bool}`. A +crop with `frame_w`/`frame_h` counts the picture as it is SHOWN, measured on a frame of that size +(`crop_filter`); one without them is in the file's stored pixels (its older form). `accurate` is the per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split` is a frontend concept: the UI fans a split out into N trim jobs, so there is one output file per job here. @@ -60,6 +62,46 @@ class EditAborted(Exception): _MAX_SEGMENTS = 100 +def _crop_frame(crop: dict) -> dict | None: + """The picture size a crop was measured on, or None when it has none it could be scaled by (a + spec from before it was sent, which `crop_filter` cuts in the file's own pixels).""" + try: + w, h = int(crop["frame_w"]), int(crop["frame_h"]) + # silent-ok: no frame is a crop's older form, and it is still the crop that was asked for. An + # infinite number (JSON `1e400`) is OverflowError, and it is no more a frame than "x" is. + except (KeyError, TypeError, ValueError, OverflowError): + return None + return {"frame_w": w, "frame_h": h} if w > 0 and h > 0 else None + + +def crop_filter(crop: dict) -> str: + """The ffmpeg `crop` of an edit. Its numbers count the picture as the browser reports it, which + is its SHOWN size: a video with wide pixels (320 stored, 640 shown) would be cut off to one side + in ffmpeg's stored ones, so they are scaled to `iw` / `ih` by the size they were measured on. A + rotated file needs nothing more: both the browser and ffmpeg's filters see it upright.""" + frame_w, frame_h = crop.get("frame_w"), crop.get("frame_h") + if not frame_w or not frame_h: + return f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}" + return ( + f"crop=iw*{crop['w']}/{frame_w}:ih*{crop['h']}/{frame_h}" + f":iw*{crop['x']}/{frame_w}:ih*{crop['y']}/{frame_h}" + ) + + +def crop_stored_size(crop: dict, src_w: int | None, src_h: int | None) -> tuple[int, int] | None: + """The size `crop_filter` cuts out of a `src_w` x `src_h` frame, evened as `EVEN_FRAME` evens it — + the clip's size when ffprobe cannot tell. The frame is the one ffmpeg's filters see: a rotated + file's stood upright, its stored size turned (the caller's to turn). A crop counted in the shown + picture is a fraction of that frame, not its own pixel count; None when the size is unknown.""" + if crop.get("frame_w") and crop.get("frame_h"): + if not src_w or not src_h: + return None + w, h = src_w * crop["w"] // crop["frame_w"], src_h * crop["h"] // crop["frame_h"] + else: + w, h = crop["w"], crop["h"] + return w // 2 * 2, h // 2 * 2 + + def normalize_edit_spec(spec: dict | None) -> dict: """Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties. @@ -72,7 +114,7 @@ def normalize_edit_spec(spec: dict | None) -> dict: if crop: try: c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")} - except (KeyError, TypeError, ValueError) as exc: + except (KeyError, TypeError, ValueError, OverflowError) as exc: # The clip still renders — uncropped. The user asked for a crop, got a whole frame, # and nothing anywhere called it a failure; this line is the only trace there is. log.warning( @@ -81,7 +123,8 @@ def normalize_edit_spec(spec: dict | None) -> dict: ) c = None if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0: - out["crop"] = c + frame = _crop_frame(crop) + out["crop"] = {**c, **frame} if frame else c # Multi-segment JOIN (cut-list → one concatenated file). Takes precedence over `trim`. segs = spec.get("segments") @@ -268,7 +311,7 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str] if reencode: # Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to # the exact cut). A crop adds the filter; an accurate trim only evens the frame. - cropf = f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}," if crop else "" + cropf = f"{crop_filter(crop)}," if crop else "" cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS] else: cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"] @@ -313,7 +356,7 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging: ] if needs_reencode(spec): - cropf = f",crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}" if crop else "" + cropf = f",{crop_filter(crop)}" if crop else "" parts, labels = [], [] for i, s in enumerate(segs): st, en = s["start_s"], s["end_s"] diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py index 54546761..f7c48724 100644 --- a/backend/app/downloads/links.py +++ b/backend/app/downloads/links.py @@ -127,14 +127,35 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool: # --- serialization ------------------------------------------------------------------------- -def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict: +def opens(db: Session, link: DownloadLink) -> str: + """What a link opens, in the words its address and its preview use: "folder", "video" or + "file". A file still being made reads as the video it mostly is.""" + from app.downloads import mediakind + + if link.folder_id is not None: + return "folder" + job = db.get(DownloadJob, link.job_id) + asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None + kind = asset.media_kind if asset else None + return "file" if kind and kind != mediakind.VIDEO else "video" + + +def share_path(db: Session, link: DownloadLink, token: str) -> str: + """The address a link is given out on: its path says what it opens (`opens`). Only a word for the + reader, the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so + the links already sent on /watch/ work for good.""" + prefix = {"folder": "folder", "file": "file", "video": "watch"}[opens(db, link)] + return f"/{prefix}/{token}" + + +def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict: """What the file owner sees when managing their links. `token` is the one just made (it is in hand even where no copy is kept); else it is read back, and `url` is None when it cannot be.""" token = token or recover(link) return { "id": link.id, "token": token, - "url": f"{base_url}/watch/{token}" if token else None, + "url": share_path(db, link, token) if token else None, "allow_download": link.allow_download, "has_password": link.password_hash is not None, "expires_at": link.expires_at.isoformat() if link.expires_at else None, diff --git a/backend/app/downloads/models.py b/backend/app/downloads/models.py index 79661895..00e592ae 100644 --- a/backend/app/downloads/models.py +++ b/backend/app/downloads/models.py @@ -234,7 +234,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin): source_asset_id: Mapped[int | None] = mapped_column( ForeignKey("media_assets.id", ondelete="SET NULL") ) - # For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip + # For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h,frame_w?,frame_h?}}, its + # crop in the SHOWN picture's pixels when it carries the frame it was measured on (`edit.crop_filter`), + # else in the file's stored ones. For clip # jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop # in fractions of the box it was drawn in (`downloads.clip.normalize`). edit_spec: Mapped[dict | None] = mapped_column(JSON) diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py index f2d4d29c..ba287231 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -1,4 +1,4 @@ -"""Server-rendered Open Graph tags for the public /watch/{token} page. +"""Server-rendered Open Graph tags for the public page of a share link (/watch/, /file/, /folder/). The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card. @@ -76,16 +76,21 @@ def _watch_tags(token: str, db: Session) -> str | None: link = linksmod.find(db, token) if link is None or linksmod.is_expired(link): return None - url = f"{settings.app_base}/watch/{token}" - folder = link.folder_id is not None + url = f"{settings.app_base}{linksmod.share_path(db, link, token)}" + # What the card calls it, as its address does. + kind = linksmod.opens(db, link) + folder, video = kind == "folder", kind == "video" if link.password_hash: # Password-gated: don't leak the title/thumbnail in the unfurl. return "\n ".join( [ "Siftlode", - _tag("og:title", "Password-protected " + ("folder" if folder else "video")), + _tag( + "og:title", + "Password-protected " + ("folder" if folder else "video" if video else "file"), + ), _tag("og:description", "Shared via Siftlode"), - _tag("og:type", "website" if folder else "video.other"), + _tag("og:type", "video.other" if video else "website"), _tag("og:url", url), _tag("og:site_name", "Siftlode"), ] @@ -96,7 +101,7 @@ def _watch_tags(token: str, db: Session) -> str | None: asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None if job is None or asset is None or asset.status != "ready": return None - title = (job.display_name or asset.title or "Video").strip() + title = (job.display_name or asset.title or ("Video" if video else "File")).strip() channel = (job.display_uploader or asset.uploader or "").strip() # Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the # Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's @@ -118,7 +123,7 @@ def _watch_tags(token: str, db: Session) -> str | None: f"{html.escape(title)}", _tag("og:title", title), _tag("og:description", channel or "Shared via Siftlode"), - _tag("og:type", "video.other"), + _tag("og:type", "video.other" if video else "website"), _tag("og:url", url), _tag("og:site_name", "Siftlode"), _tag("twitter:title", title, attr="name"), diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index 1ccbf85a..236b162d 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -837,6 +837,18 @@ def _fail_edit( ) +def _filter_frame( + path: Path, width: int | None, height: int | None +) -> tuple[int | None, int | None]: + """A file's frame as ffmpeg's filters see it: the stored size, turned when the stream is stored + turned a quarter (ffmpeg stands it upright first). Unknown when the file cannot be read.""" + data = probe.probe_json(path) + turned = clipmod.quarter_turned(data) if data else None + if turned is None: + return None, None + return (height, width) if turned else (width, height) + + def _process_edit(job_id: int, asset_id: int) -> None: """Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg.""" with SessionLocal() as db: @@ -905,6 +917,12 @@ def _process_edit(job_id: int, asset_id: int) -> None: size = final.stat().st_size if final.exists() else None # The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`). _, _, out_w, out_h, _ = probe.probe_media(final) + # Unsized, a crop's own size stands in, cut from the frame the filters saw. + cut = ( + editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h)) + if crop and not (out_w and out_h) + else None + ) with SessionLocal() as db: asset = db.get(MediaAsset, asset_id) @@ -916,11 +934,10 @@ def _process_edit(job_id: int, asset_id: int) -> None: asset.duration_s = total_s or asset.duration_s if out_w and out_h: asset.width, asset.height = out_w, out_h - elif crop: - asset.width = edit_spec["crop"]["w"] - asset.height = edit_spec["crop"]["h"] + elif cut: + asset.width, asset.height = cut else: - asset.width, asset.height = src_w, src_h + asset.width, asset.height = (None, None) if crop else (src_w, src_h) asset.error = None asset.last_access_at = datetime.now(timezone.utc) job = db.get(DownloadJob, job_id) diff --git a/backend/app/downloads/quota.py b/backend/app/downloads/quota.py index 179b3b92..3843613d 100644 --- a/backend/app/downloads/quota.py +++ b/backend/app/downloads/quota.py @@ -16,7 +16,8 @@ That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user 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. CLIP jobs consult it before their fetch or encode and again with -their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19). +their output's real size before it is placed. Edit jobs consult it with their output's real size +before it is placed (`test_edit_cap.py`). """ from dataclasses import dataclass diff --git a/backend/app/main.py b/backend/app/main.py index 30905c8d..1f1702a4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -368,6 +368,10 @@ INDEX_HTML = STATIC_DIR / "index.html" # A public share link's own page: the player alone, without the app's bundle (frontend/watch.html). WATCH_HTML = STATIC_DIR / "watch.html" INDEX_HEADERS = {"Cache-Control": "no-cache"} +# The paths a share link opens on, by what it opens (`links.share_path`). Any of them opens any link. +# A new one also goes in frontend/public/robots.txt, the Vite dev entry and WatchPage's token parsing +# (watchEntry.contract.test.ts). +SHARE_PREFIXES = ("watch/", "file/", "folder/") app.mount( "/assets", StaticFiles(directory=STATIC_DIR / "assets", check_dir=False), @@ -381,8 +385,10 @@ async def index() -> FileResponse: @app.get("/watch/{token}") -async def watch_page(token: str): - """Serve the public player page for a share link, with per-video Open Graph tags injected so the +@app.get("/file/{token}") +@app.get("/folder/{token}") +async def share_page(token: str): + """Serve the public page of a share link, with per-link Open Graph tags injected so the link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the generic card.""" @@ -409,8 +415,8 @@ async def spa_fallback(full_path: str) -> FileResponse: # Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have # stable names, so a moderate cache is safe and satisfies the cache-lifetime audit. return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"}) - # A /watch/ path the route above does not match (a trailing segment, no token) is still the + # A share path the route above does not match (a trailing segment, no token) is still the # player's: WatchPage reads the token off the path and says when a link is gone. - if full_path.startswith("watch/"): + if full_path.startswith(SHARE_PREFIXES): return FileResponse(WATCH_HTML, headers=INDEX_HEADERS) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) diff --git a/backend/app/reader/queries.py b/backend/app/reader/queries.py index ad3c5353..b5e9c026 100644 --- a/backend/app/reader/queries.py +++ b/backend/app/reader/queries.py @@ -41,11 +41,25 @@ def video(db: Session, video_id: str) -> Video | None: # --- tags ------------------------------------------------------------------------------------ -def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int]]: - """The tags this user sees (the shared system ones and their own), each with how many channel - links it has, by category then name.""" +def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int, int]]: + """The tags this user sees (the shared system ones and their own), by category then name, each + with how many channel links it has and how many of the user's own links sit on a channel they + subscribe to and have not hidden — the rows the Channels page's tag filter shows (a hidden + channel has its own tab). A link outlives an unsubscribe and a hide, so the two differ.""" + subscribed = select(Subscription.channel_id).where( + owned_by(Subscription.user_id, user), Subscription.hidden.is_(False) + ) + on_subscribed = dict( + db.execute( + select(ChannelTag.tag_id, func.count(ChannelTag.id)) + .where(owned_by(ChannelTag.user_id, user), ChannelTag.channel_id.in_(subscribed)) + .group_by(ChannelTag.tag_id) + ) + .tuples() + .all() + ) return [ - (tag, count) + (tag, count, on_subscribed.get(tag.id, 0)) for tag, count in db.execute( select(Tag, func.count(ChannelTag.id)) .outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id) diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 380bb337..4730f9d0 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -611,7 +611,7 @@ def list_links( job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> list[dict]: own_job(db, user, job_id) - return [linksmod.owner_view(link) for link in queries.links_to(db, job_id=job_id)] + return [linksmod.owner_view(db, link) for link in queries.links_to(db, job_id=job_id)] class CreateLinkIn(BaseModel): @@ -653,7 +653,7 @@ def create_link( raise refused(409, "share_unfinished") link, token = _new_link(db, user, body, job_id=job_id) db.commit() - return linksmod.owner_view(link, token=token) + return linksmod.owner_view(db, link, token=token) def _shareable_job(db: Session, user: User, job_id: int) -> "DownloadJob": @@ -684,7 +684,7 @@ def list_folder_links( folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> list[dict]: own_folder(db, user, folder_id) - return [linksmod.owner_view(link) for link in queries.links_to(db, folder_id=folder_id)] + return [linksmod.owner_view(db, link) for link in queries.links_to(db, folder_id=folder_id)] @router.post("/folders/{folder_id}/links") @@ -697,7 +697,7 @@ def create_folder_link( _shareable_folder(db, user, folder_id) link, token = _new_link(db, user, body, folder_id=folder_id) db.commit() - return linksmod.owner_view(link, token=token) + return linksmod.owner_view(db, link, token=token) class UpdateLinkIn(BaseModel): @@ -724,7 +724,7 @@ def update_link( sent=frozenset(body.model_fields_set), ) db.commit() - return linksmod.owner_view(link) + return linksmod.owner_view(db, link) @router.delete("/links/{link_id}") diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index ac981005..76aa0b02 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -432,6 +432,7 @@ def item_detail( "summary": (it.summary_hu or it.summary) if hu else it.summary, "year": it.year, "duration_seconds": it.duration_s, + "size_bytes": it.size_bytes, "playable": it.playable, # Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows. "transcode_hw": it.transcode_hw, diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index f0312c15..54fd0246 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -176,8 +176,15 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict: returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak).""" link = _link_or_404(db, token) if link.password_hash: - # Which kind it opens, for the password prompt's title — never what is in it. - return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"} + # Which kind it opens, for the password prompt's words — never what is in it. `video` tells + # a video (watched) from any other file (opened); the address is only a hint, every + # prefix opens every link. + opens = linksmod.opens(db, link) + return { + "needs_password": True, + "kind": "folder" if opens == "folder" else "file", + "video": opens == "video", + } meta = _opened(db, link, token) db.commit() return meta diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 250206ec..4dcb4146 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -48,8 +48,9 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) "category": tag.category, "system": tag.user_id is None, "channel_count": count, + "subscribed_count": subscribed, } - for tag, count in queries.tags_with_counts(db, user) + for tag, count, subscribed in queries.tags_with_counts(db, user) ] diff --git a/backend/tests/test_edit_crop_frame.py b/backend/tests/test_edit_crop_frame.py new file mode 100644 index 00000000..c0242171 --- /dev/null +++ b/backend/tests/test_edit_crop_frame.py @@ -0,0 +1,209 @@ +"""The editor's crop lands on the part of the picture the frame was drawn on, also when a video's +pixels are not square. The browser reports such a video by its shown size (320 stored, 640 shown), +and ffmpeg's `crop` counts stored pixels: measured on a real clip, a frame over the middle half kept +half the frame's red surroundings. So a crop carries the size it was measured on. + +Real ffmpeg: the pixels of the output are the oracle. +""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +from app.config import settings +from app.downloads import edit, pipeline, service, storage +from app.downloads import upload as uploadmod +from app.downloads.models import DownloadJob, MediaAsset + +pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available") + + +def _source(path: Path, size: str, box: str, sar: int, rotate: int = 0) -> None: + """Red, with a green box: a crop of the box holds no red. `rotate` tags the file to be shown + turned by that many degrees counter-clockwise, as a phone records a portrait video.""" + tag = ["-display_rotation:v:0", str(rotate)] if rotate else [] + subprocess.run( + [ + "ffmpeg", "-y", "-loglevel", "error", + "-f", "lavfi", "-i", f"color=c=red:s={size}:d=2:r=10", + "-f", "lavfi", "-i", "sine=duration=2", + "-vf", f"drawbox={box}:c=lime:t=fill,setsar={sar}", + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(path), + ], + check=True, + ) # fmt: skip + if tag: + tagged = path.with_name("tagged-" + path.name) + subprocess.run( + ["ffmpeg", "-y", "-loglevel", "error", *tag, "-i", str(path), "-c", "copy", str(tagged)], + check=True, + ) # fmt: skip + tagged.replace(path) + + +def _red_and_green(path: Path) -> tuple[int, int]: + raw = subprocess.run( + ["ffmpeg", "-loglevel", "error", "-i", str(path), "-frames:v", "1", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], + check=True, + capture_output=True, + ).stdout # fmt: skip + red = green = 0 + for i in range(0, len(raw) - 2, 3): + if raw[i] > 180 and raw[i + 1] < 80: + red += 1 + elif raw[i + 1] > 180 and raw[i] < 80: + green += 1 + return red, green + + +def _cut_asset(db, make_user, src: Path, spec: dict, then=lambda: None) -> MediaAsset: + user = make_user() + source = uploadmod.ingest_upload( + db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size + ) + assert source.status == "done" + then() + job = service.enqueue_edit(db, user.id, source, spec) + asset = db.get(MediaAsset, job.asset_id) + job.status, asset.status = "running", "downloading" # the worker's claim + db.commit() + + pipeline._process_edit(job.id, asset.id) + + db.expire_all() + job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id) + assert (job.status, job.error, asset.status) == ("done", None, "ready") + return asset + + +def _cut(db, make_user, tmp_path, src: Path, crop: dict, **spec) -> Path: + asset = _cut_asset(db, make_user, src, {"crop": crop, **spec}) + return storage.abs_path(str(tmp_path), asset.rel_path) + + +def test_a_crop_of_a_video_with_wide_pixels_keeps_what_the_frame_showed( + db, make_user, tmp_path, monkeypatch +): + monkeypatch.setattr(settings, "files_root", str(tmp_path)) + src = tmp_path / "in.mp4" + _source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2) + + # What the editor sends for the middle half of this video: the browser shows it at 640 x 240. + final = _cut( + db, make_user, tmp_path, src, + {"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240}, + ) # fmt: skip + + red, green = _red_and_green(final) + assert (red, green) == (0, 160 * 120) + + +def test_a_crop_of_square_pixels_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch): + monkeypatch.setattr(settings, "files_root", str(tmp_path)) + src = tmp_path / "in.mp4" + _source(src, "480x640", "x=120:y=160:w=240:h=320", sar=1) + + final = _cut( + db, make_user, tmp_path, src, + {"x": 120, "y": 160, "w": 240, "h": 320, "frame_w": 480, "frame_h": 640}, + ) # fmt: skip + + assert _red_and_green(final) == (0, 240 * 320) + + +def test_a_crop_stored_before_it_carried_its_frame_is_cut_as_it_always_was(): + spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50}}) + assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50} + cmd = edit.build_edit_cmd(Path("in.mp4"), Path("out.mp4"), spec, "mp4") + assert cmd[cmd.index("-vf") + 1].startswith("crop=100:50:4:6,") + + +@pytest.mark.parametrize("frame", [{"frame_w": 0, "frame_h": 240}, {"frame_w": "x", "frame_h": 2}]) +def test_a_frame_that_cannot_be_read_is_not_divided_by(frame): + spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50, **frame}}) + assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50} + + +def test_a_crop_of_a_join_keeps_what_the_frame_showed(db, make_user, tmp_path, monkeypatch): + """A cut-list joins through `filter_complex`, a crop path of its own.""" + monkeypatch.setattr(settings, "files_root", str(tmp_path)) + src = tmp_path / "in.mp4" + _source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2) + + final = _cut( + db, make_user, tmp_path, src, + {"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240}, + segments=[{"start_s": 0, "end_s": 0.5}, {"start_s": 1, "end_s": 1.5}], accurate=True, + ) # fmt: skip + + assert _red_and_green(final) == (0, 160 * 120) + + +def test_a_crop_of_a_rotated_video_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch): + """The browser shows a rotated file upright, so the frame was drawn on the upright picture.""" + monkeypatch.setattr(settings, "files_root", str(tmp_path)) + src = tmp_path / "in.mp4" + # Stored 320 x 240, green on its left half; shown turned a quarter, 240 x 320. + _source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90) + + # Turned counter-clockwise, the stored left half is the shown bottom half. + final = _cut( + db, make_user, tmp_path, src, + {"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320}, + ) # fmt: skip + + assert _red_and_green(final) == (0, 240 * 160) + + +def test_a_clip_ffprobe_cannot_size_gets_the_size_its_crop_cut( + db, make_user, tmp_path, monkeypatch +): + monkeypatch.setattr(settings, "files_root", str(tmp_path)) + src = tmp_path / "in.mp4" + _source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2) + + def probe_fails(): + monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5) + + asset = _cut_asset( + db, make_user, src, + {"crop": {"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240}}, + then=probe_fails, + ) # fmt: skip + + # Half the shown width of a 320-wide file: 160 stored, not the 320 the frame counted. + assert (asset.width, asset.height) == (160, 120) + + +def test_a_rotated_clip_ffprobe_cannot_size_gets_the_upright_size_its_crop_cut( + db, make_user, tmp_path, monkeypatch +): + """The frame was drawn on the upright picture, which is the stored one turned: so is its cut.""" + monkeypatch.setattr(settings, "files_root", str(tmp_path)) + src = tmp_path / "in.mp4" + _source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90) + + def probe_fails(): + monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5) + + asset = _cut_asset( + db, make_user, src, + {"crop": {"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320}}, + then=probe_fails, + ) # fmt: skip + + # What ffmpeg cut (the rotated test above): 240 x 160, not the stored 320 x 120. + assert (asset.width, asset.height) == (240, 160) + + +@pytest.mark.parametrize("key", ["frame_w", "x"]) +def test_an_infinite_number_in_a_crop_is_dropped_not_a_crash(key): + crop = {"x": 4, "y": 6, "w": 100, "h": 50, "frame_w": 640, "frame_h": 240} + spec = edit.normalize_edit_spec({"crop": {**crop, key: float("inf")}}) + if key == "frame_w": + assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50} + else: + assert "crop" not in spec diff --git a/backend/tests/test_folder_links.py b/backend/tests/test_folder_links.py index dde751bf..b0bcbb7c 100644 --- a/backend/tests/test_folder_links.py +++ b/backend/tests/test_folder_links.py @@ -150,6 +150,7 @@ def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client): assert client.get(f"/api/public/watch/{token}").json() == { "needs_password": True, "kind": "folder", + "video": False, } bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file" assert client.get(bare).status_code == 403 @@ -312,7 +313,7 @@ def test_a_password_file_link_says_it_opens_a_file(shelf, client): .json() ) meta = client.get(f"/api/public/watch/{made['token']}").json() - assert meta == {"needs_password": True, "kind": "file"} + assert meta == {"needs_password": True, "kind": "file", "video": True} def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db): diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index b8e2ec3d..19772e9f 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -219,6 +219,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, library_id=lib.id, kind="movie", title="The Godfather", + size_bytes=15_000_000_000, genres=["Crime", "Drama"], directors=["Francis Ford Coppola"], studio="Paramount", @@ -238,6 +239,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1") assert resp.status_code == 200, resp.text body = resp.json() + assert body["size_bytes"] == 15_000_000_000 assert body["imdb_rating"] == 8.7 assert body["imdb_id"] == "tt0068646" assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/" diff --git a/backend/tests/test_reader_service.py b/backend/tests/test_reader_service.py index fa8b540e..647c6825 100644 --- a/backend/tests/test_reader_service.py +++ b/backend/tests/test_reader_service.py @@ -14,7 +14,7 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from app.discover import pins -from app.catalog.models import Channel, Video +from app.catalog.models import Channel, Subscription, Video from app.reader import queries, service from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState @@ -60,12 +60,44 @@ def test_a_system_tag_is_nobodys_to_edit(db, make_user): service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs db.commit() assert queries.own_tag(db, user, system.id) is None - assert [t.id for t, _ in queries.tags_with_counts(db, user)] == [system.id] + assert [t.id for t, *_ in queries.tags_with_counts(db, user)] == [system.id] # --- tags ------------------------------------------------------------------------------------ +def _music_on_four(db, make_user): + """A tag on four channels: two followed, one followed but hidden, one no longer followed.""" + user, other = make_user(), make_user() + tag = service.create_tag(db, user, "Music", "other", None) + for cid in ("UCa", "UCb", "UChid", "UCgone"): + db.add(Channel(id=cid, title=cid)) + db.flush() + for cid in ("UCa", "UCb"): + db.add(Subscription(user_id=user.id, channel_id=cid)) + db.add(Subscription(user_id=user.id, channel_id="UChid", hidden=True)) + db.add(Subscription(user_id=other.id, channel_id="UCgone")) + for cid in ("UCa", "UCb", "UChid", "UCgone"): + db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user")) + db.commit() + return user, tag + + +def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user): + """An unsubscribe and a hide keep the channel's tag links; the Channels page's filter lists only + the followed channels not hidden, so its count must not hold the others.""" + user, tag = _music_on_four(db, make_user) + [(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id] + assert (count, subscribed) == (4, 2) + + +def test_the_tag_list_sends_each_count_under_its_own_name(db, make_user, as_user): + """Both counts are ints, so a swap would type-check: the answer itself pins which is which.""" + user, tag = _music_on_four(db, make_user) + [row] = [t for t in as_user(user).get("/api/tags").json() if t["id"] == tag.id] + assert (row["channel_count"], row["subscribed_count"]) == (4, 2) + + def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user): user = make_user() tag = service.create_tag(db, user, "Later", "nonsense", "#fff") diff --git a/backend/tests/test_share_paths.py b/backend/tests/test_share_paths.py new file mode 100644 index 00000000..0d347fc9 --- /dev/null +++ b/backend/tests/test_share_paths.py @@ -0,0 +1,148 @@ +"""A share link's address says what it opens: /watch/ a video, /file/ anything else, /folder/ a folder. + +Only a word for the reader: the token is the key, so every path opens every link, and the links +already sent out on /watch/ keep working for good. DB-lane. +""" + +import pytest +from cryptography.fernet import Fernet + +from app import main, security +from app.config import settings +from app.downloads import og +from app.downloads import probe as probemod + +_PAGE = """ + +""" + +_VIDEO = { + "streams": [ + {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}, + {"codec_type": "audio", "codec_name": "aac"}, + ], + "format": {"format_name": "mov,mp4", "duration": "5"}, +} +_AUDIO = { + "streams": [{"codec_type": "audio", "codec_name": "aac"}], + "format": {"format_name": "mov,mp4", "duration": "5"}, +} + + +@pytest.fixture +def pages(tmp_path, monkeypatch): + index, watch = tmp_path / "index.html", tmp_path / "watch.html" + index.write_text(_PAGE.format(entry="app"), encoding="utf-8") + watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8") + monkeypatch.setattr(main, "INDEX_HTML", index) + monkeypatch.setattr(main, "WATCH_HTML", watch) + + +@pytest.fixture +def key(monkeypatch): + """A link's address is read back from its encrypted token.""" + monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key())) + + +@pytest.fixture +def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers): + """Uploads a file ffprobe reads as `data` (None: it cannot read it) and shares it.""" + monkeypatch.setattr(settings, "files_root", str(tmp_path / "media")) + owner = as_user(make_user()) + + def share(name: str, data: dict | None, **link: object) -> dict: + monkeypatch.setattr(probemod, "probe_json", lambda _p: data) + job = owner.post( + "/api/files/upload", + content=b"some bytes", + headers=upload_headers(name, display_name=name), + ) + assert job.status_code == 200, job.text + made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link) + assert made.status_code == 200, made.text + return made.json() + + share.owner = owner # type: ignore[attr-defined] + return share + + +@pytest.mark.parametrize( + ("name", "data", "prefix"), + [ + ("clip.mp4", _VIDEO, "/watch/"), + ("song.m4a", _AUDIO, "/file/"), + ("backup.tar.gz", None, "/file/"), + ], +) +def test_a_link_is_given_out_on_the_path_of_what_it_opens(shared, name, data, prefix): + made = shared(name, data) + assert made["url"] == f"{prefix}{made['token']}" + + +def test_a_folder_link_is_given_out_on_folder(shared, key): + owner = shared.owner + folder = owner.post("/api/files/folders", json={"name": "Friends"}).json() + made = owner.post(f"/api/downloads/folders/{folder['id']}/links", json={}).json() + assert made["url"] == f"/folder/{made['token']}" + listed = owner.get(f"/api/downloads/folders/{folder['id']}/links").json() + assert [entry["url"] for entry in listed] == [made["url"]] + + +def test_the_list_gives_the_same_address_as_the_making(shared, key): + made = shared("backup.tar.gz", None) + owner = shared.owner + files = owner.get("/api/files").json() + job_id = next(f["id"] for f in files if f["display_name"] == "backup.tar.gz") + listed = owner.get(f"/api/downloads/{job_id}/links").json() + assert [entry["url"] for entry in listed] == [made["url"]] + + +@pytest.mark.parametrize("path", ["watch", "file", "folder"]) +@pytest.mark.parametrize( + ("name", "data"), [("clip.mp4", _VIDEO), ("backup.tar.gz", None)], ids=["video", "file"] +) +def test_every_path_opens_every_link(pages, shared, client, path, name, data): + """The links already sent on /watch/ open whatever they open, for good.""" + token = shared(name, data)["token"] + r = client.get(f"/{path}/{token}") + assert r.status_code == 200 + assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text + assert f'content="{name}"' in r.text + + +@pytest.mark.parametrize("path", ["/file/no-such-token", "/folder/no-such-token", "/file/a/b"]) +def test_a_new_path_without_a_live_link_still_gets_the_player(pages, client, path): + r = client.get(path) + assert r.status_code == 200 + assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text + + +def test_a_card_names_a_file_as_a_file(shared, db): + made = shared("backup.tar.gz", None) + tags = og._watch_tags(made["token"], db) + assert f'{made["url"]}"' in tags + assert "video.other" not in tags + + locked = shared("notes.tar.gz", None, password="pw") + hidden = og._watch_tags(locked["token"], db) + assert "Password-protected file" in hidden and "notes" not in hidden + assert "video.other" not in hidden + + +def test_a_card_of_a_video_stays_a_video(shared, db): + made = shared("clip.mp4", _VIDEO) + tags = og._watch_tags(made["token"], db) + assert f"/watch/{made['token']}" in tags and "video.other" in tags + locked = shared("other.mp4", _VIDEO, password="pw") + assert "Password-protected video" in og._watch_tags(locked["token"], db) + + +@pytest.mark.parametrize( + ("name", "data", "video"), + [("clip.mp4", _VIDEO, True), ("song.m4a", _AUDIO, False), ("backup.tar.gz", None, False)], +) +def test_a_password_link_says_whether_it_opens_a_video(shared, client, name, data, video): + """The lock screen asks to watch a video and to open anything else, whichever path it is on.""" + token = shared(name, data, password="pw")["token"] + meta = client.get(f"/api/public/watch/{token}").json() + assert meta == {"needs_password": True, "kind": "file", "video": video} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 281e8f77..29015542 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -301,7 +301,8 @@ Also in force: `fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than - scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned + scroll, so no row has clipped ends, and a filter row puts its picked chips first so the fold never + holds the only sign that a filter is on; the carousels fade theirs. The dialog edges come with C4's pinned footer (S3f). - **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested). @@ -717,148 +718,155 @@ of a full-width panel and nothing on the right. ## Decision log -| Date | Decision | Chosen | Over | Why | -| ---------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized | -| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | -| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | -| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function | -| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows | -| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" | -| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags | -| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers | -| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass | -| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context | -| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites | -| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 | -| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 | -| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card | -| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content | -| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift | -| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device | -| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost | -| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck | -| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls | -| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function | -| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid | -| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap | -| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up | -| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip | -| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) | -| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 | -| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw ``" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere | -| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role | -| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing | -| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill | -| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names | -| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down | -| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light | -| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call | -| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing | -| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint | -| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page | -| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge | -| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar | -| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons | -| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line | -| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width | -| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs | -| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video | -| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback | -| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift | -| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has | -| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else | -| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) | -| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet | -| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px | -| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete | -| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title | -| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing | -| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something | -| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say | -| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row | -| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box | -| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap | -| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere | -| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row | -| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip | -| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start | -| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw ` diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index 92cbfc71..718502a6 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,17 @@ [ + { + "version": "0.117.0", + "date": "2026-11-06", + "new": [ + "A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.", + "The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.", + "The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size." + ], + "fixed": [ + "An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.", + "A Plex auto-skip waits 3 seconds into the marker before its countdown starts." + ] + }, { "version": "0.116.3", "date": "2026-11-04", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index 5b3fa82c..50ef1889 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,17 @@ [ + { + "version": "0.117.0", + "date": "2026-11-06", + "new": [ + "A megosztási link címe most megmondja, mit nyit meg: /file/ egy fájlt, /folder/ egy mappát, /watch/ egy videót. A már elküldött linkek továbbra is működnek.", + "A feltöltési ablak mutatja, melyik mappába kerül a fájl, és ott választhatsz másikat vagy új mappát hozhatsz létre. A Megosztás megnyitáskor rögtön kimásol egy linket.", + "A Csatornák címkéi mutatják, hány követett csatorna tartozik hozzájuk. A Plex Folytatás sávjának is van Összes gombja, és az elem adatlapja mutatja a fájl méretét." + ], + "fixed": [ + "A lejárt megosztási link lejártként látszik, és sosem kerül a vágólapra. A széles képpontú videó vágása pontosan azt tartja meg, amit a keret mutatott.", + "A Plex automatikus átugrása a marker után 3 másodpercet vár, mielőtt a visszaszámlálás indul." + ] + }, { "version": "0.116.3", "date": "2026-11-04", diff --git a/frontend/src/features/channel/ChannelTags.suggest.dom.test.tsx b/frontend/src/features/channel/ChannelTags.suggest.dom.test.tsx index 9be0af7e..6c414b1e 100644 --- a/frontend/src/features/channel/ChannelTags.suggest.dom.test.tsx +++ b/frontend/src/features/channel/ChannelTags.suggest.dom.test.tsx @@ -45,6 +45,7 @@ const tag = (id: number, name: string): Tag => ({ category: "other", system: false, channel_count: 0, + subscribed_count: 0, }); const docu = tag(1, "docu"); const history = tag(2, "history"); diff --git a/frontend/src/features/channel/TagManager.count.dom.test.tsx b/frontend/src/features/channel/TagManager.count.dom.test.tsx new file mode 100644 index 00000000..31fb0d91 --- /dev/null +++ b/frontend/src/features/channel/TagManager.count.dom.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +// +// A tag's row in the manager says how many channels it is on, beside a hover list of them, over the +// Channels page whose chip names the same tag with a number: all three count the same channels — +// the followed ones the Subscriptions tab shows, hidden ones left out. Every link (the number a +// delete removes) is only the delete's confirm. +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + t: (key: string, o?: { count?: number }) => (o?.count != null ? `${key}:${o.count}` : key), + i18n: { language: "en" }, + }), +})); + +import TagManager from "./TagManager"; +import { api, type ManagedChannel, type Tag } from "../../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const tag = { + id: 5, + name: "Music", + color: null, + category: "other", + system: false, + channel_count: 4, + subscribed_count: 2, +} as Tag; +const ch = (id: string, hidden: boolean) => + ({ id, title: id, hidden, tag_ids: [5] }) as unknown as ManagedChannel; + +describe("TagManager · a tag's count", () => { + it("counts the followed channels the Subscriptions tab shows, not every link", async () => { + vi.spyOn(api, "tags").mockResolvedValue([tag]); + vi.spyOn(api, "channels").mockResolvedValue([ + ch("UCa", false), + ch("UCb", false), + ch("UChid", true), + ]); + render( + + {}} onFocusChannel={() => {}} /> + , + ); + expect(await screen.findByText("tagManager.channels:2")).toBeTruthy(); + expect(screen.queryByText("tagManager.channels:4")).toBeNull(); + fireEvent.mouseEnter(screen.getByText("tagManager.channels:2")); + expect(await screen.findByText("UCa")).toBeTruthy(); + expect(screen.queryByText("UChid")).toBeNull(); + }); +}); diff --git a/frontend/src/features/channel/TagManager.tsx b/frontend/src/features/channel/TagManager.tsx index c0aad6ed..752856f8 100644 --- a/frontend/src/features/channel/TagManager.tsx +++ b/frontend/src/features/channel/TagManager.tsx @@ -71,7 +71,7 @@ function TagRow({ : "" }`} > - {t("tagManager.channels", { count: tag.channel_count })} + {t("tagManager.channels", { count: tag.subscribed_count })} @@ -119,7 +119,7 @@ export default function TagManager({ const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const channelsForTag = (tagId: number) => (channelsQuery.data ?? []) - .filter((c) => c.tag_ids.includes(tagId)) + .filter((c) => !c.hidden && c.tag_ids.includes(tagId)) .map((c) => ({ id: c.id, title: c.title ?? c.id })); const invalidate = () => { diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index c91b8720..d192bad3 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -28,6 +28,7 @@ "loadError": "Couldn't load this — check your connection and try again", "deletedTag": "Deleted tag", "seeAll": "See all", + "seeAllOf": "{{action}}: {{title}}", "scrollLeft": "Scroll left", "scrollRight": "Scroll right", "search": "Search", diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 3e924f73..14fd3482 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -32,6 +32,9 @@ "uploadProgress": "Upload progress", "added": "File uploaded", "addedConverting": "File uploaded — converting it so it plays on any phone", + "targetLabel": "Upload to", + "makingFolder": "Making “{{name}}”…", + "targetFailed": "The folders could not be loaded", "failed": "Couldn't upload the file" }, "status": { @@ -90,7 +93,7 @@ "pickPlaceholder": "Search a user…", "noUsers": "No other users yet.", "linkSection": "Share a link", - "linkHint": "Anyone with the link can watch — no account needed.", + "linkHint": "Anyone with the link can open it — no account needed.", "linkCopied": "Share link copied to clipboard", "openNewTab": "Open in new tab", "linkFailed": "Couldn’t create the share link", @@ -111,6 +114,7 @@ "revokeConfirm": "This link will stop working immediately.", "views_one": "{{count}} view", "views_other": "{{count}} views", + "expiredOn": "expired {{date}}", "expiresOn": "expires {{date}}", "hasPassword": "password", "downloadable": "downloadable", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index cc20e9db..a4f05cfe 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -28,6 +28,7 @@ "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra", "deletedTag": "Törölt címke", "seeAll": "Összes", + "seeAllOf": "{{action}}: {{title}}", "scrollLeft": "Görgetés balra", "scrollRight": "Görgetés jobbra", "search": "Keresés", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index ff26192c..6457f66b 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -32,6 +32,9 @@ "uploadProgress": "Feltöltés folyamata", "added": "Fájl feltöltve", "addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen", + "targetLabel": "Feltöltés ide", + "makingFolder": "„{{name}}” létrehozása…", + "targetFailed": "A mappákat nem sikerült betölteni", "failed": "Nem sikerült feltölteni a fájlt" }, "status": { @@ -90,7 +93,7 @@ "pickPlaceholder": "Keress egy felhasználót…", "noUsers": "Nincs más felhasználó.", "linkSection": "Megosztás linkkel", - "linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.", + "linkHint": "A link birtokában bárki megnyithatja — fiók nélkül is.", "linkCopied": "Megosztási link a vágólapra másolva", "openNewTab": "Megnyitás új lapon", "linkFailed": "Nem sikerült a megosztási link létrehozása", @@ -111,6 +114,7 @@ "revokeConfirm": "A link azonnal használhatatlanná válik.", "views_one": "{{count}} megtekintés", "views_other": "{{count}} megtekintés", + "expiredOn": "lejárt: {{date}}", "expiresOn": "lejár: {{date}}", "hasPassword": "jelszó", "downloadable": "letölthető", diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index c959c63d..93d4ee68 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -70,7 +70,9 @@ export interface ClipRequest { export interface EditSpec { trim?: { start_s: number; end_s?: number }; segments?: { start_s: number; end_s: number }[]; - crop?: { x: number; y: number; w: number; h: number }; + // In the picture's size as the browser reports it, which `frame_w` x `frame_h` says: the server + // scales them to the file's own pixels (a video's stored and shown sizes can differ). + crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number }; accurate?: boolean; } @@ -143,7 +145,7 @@ export interface ShareLink { // Null when the server cannot show this link's address again (no encrypted copy its key reads): the // link still opens for whoever has it; to share it anew, make a new link. token: string | null; - url: string | null; // path like "/watch/" — prepend window.location.origin to share + url: string | null; // path like "/watch/" or "/file/" — prepend window.location.origin to share allow_download: boolean; has_password: boolean; expires_at: string | null; diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 5ca6e261..7524ccd0 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -154,6 +154,7 @@ export interface PlexItemDetail { summary?: string | null; year?: number | null; duration_seconds: number | null; + size_bytes?: number | null; // the file's, null while unknown playable: string; // direct | remux | transcode transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle) diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts index fc1db31b..568206b3 100644 --- a/frontend/src/lib/api/tags.ts +++ b/frontend/src/lib/api/tags.ts @@ -7,7 +7,12 @@ export interface Tag { color: string | null; category: string; system: boolean; + /** Every channel link it has, a channel the user no longer follows included (an unsubscribe keeps + * the link) — what deleting the tag removes. */ channel_count: number; + /** The user's own links on channels they follow and have not hidden: the rows the Channels page's + * filter shows (a hidden channel has its own tab). */ + subscribed_count: number; } // One suggested user-tag for a channel, learned from the user's own manual-tag pattern. diff --git a/frontend/src/lib/channelWriteFailures.dom.test.tsx b/frontend/src/lib/channelWriteFailures.dom.test.tsx index 7f705701..f3bbad75 100644 --- a/frontend/src/lib/channelWriteFailures.dom.test.tsx +++ b/frontend/src/lib/channelWriteFailures.dom.test.tsx @@ -18,6 +18,7 @@ import { api, HttpError } from "./api"; import { msg, notify } from "./notifications"; import { useChannelTagActions } from "./useChannelTagActions"; import { useChannelPriority } from "./useChannelPriority"; +import { qk } from "./queryKeys"; const notifyMock = vi.mocked(notify); beforeEach(() => notifyMock.mockClear()); @@ -44,6 +45,23 @@ describe("useChannelTagActions", () => { }); }); + // A tag counts the followed channels it is on: a toggle that lands changes that number. + it("refreshes the tags once a toggle lands", async () => { + vi.spyOn(api, "attachChannelTag").mockResolvedValue({ ok: true } as never); + const qc = new QueryClient(); + const spy = vi.spyOn(qc, "invalidateQueries"); + const { result } = renderHook( + () => useChannelTagActions({ optimisticToggle: () => {}, onError: () => {} }), + { + wrapper: ({ children }) => ( + {children} + ), + }, + ); + act(() => result.current.toggleTag("UC1", 7, false)); + await waitFor(() => expect(spy.mock.calls.map(([f]) => f?.queryKey)).toContainEqual(qk.tags())); + }); + it("names a refused create-and-attach", async () => { vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow")); const { result } = renderHook( diff --git a/frontend/src/lib/downloads.test.ts b/frontend/src/lib/downloads.test.ts index 579f3963..78accf77 100644 --- a/frontend/src/lib/downloads.test.ts +++ b/frontend/src/lib/downloads.test.ts @@ -94,6 +94,22 @@ describe("quickShareLink", () => { expect(createDownloadLink).not.toHaveBeenCalled(); }); + // An expired link stays listed for its owner, but its recipient would land on "link gone". + it("passes over an expired link, and mints one when every link has expired", async () => { + const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString(); + const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString(); + downloadLinks.mockResolvedValue([ + { id: 5, url: "/watch/gone", expires_at: lastWeek }, + { id: 3, url: "/watch/live", expires_at: nextWeek }, + ]); + expect(await quickShareLink(qc(), { kind: "file", id: 1 })).toBe(`${origin}/watch/live`); + expect(createDownloadLink).not.toHaveBeenCalled(); + + downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/gone", expires_at: lastWeek }]); + createDownloadLink.mockResolvedValue({ id: 9, url: "/watch/fresh" }); + expect(await quickShareLink(qc(), { kind: "file", id: 2 })).toBe(`${origin}/watch/fresh`); + }); + it("mints a new link when none it has can be shown, keeping the old ones listed", async () => { const hidden = { id: 4, url: null }; downloadLinks.mockResolvedValue([hidden]); diff --git a/frontend/src/lib/downloads.ts b/frontend/src/lib/downloads.ts index 173ad223..6daae878 100644 --- a/frontend/src/lib/downloads.ts +++ b/frontend/src/lib/downloads.ts @@ -1,6 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { qk } from "./queryKeys"; -import { api, type NewShareLink } from "./api"; +import { api, type NewShareLink, type ShareLink } from "./api"; import { copyText } from "./clipboard"; import { msg, notify } from "./notifications"; import { notifyActionError } from "./actionErrors"; @@ -38,6 +38,16 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) { if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() }); } +/** Has a link stopped opening? The list still holds an expired link (its owner may want to see or + * revoke it), but it is no link to hand out. */ +export const linkExpired = (link: ShareLink, now = Date.now()) => + link.expires_at !== null && Date.parse(link.expires_at) <= now; + +/** The link to hand out of a list (newest first): the newest that still opens and whose address can + * be shown — an expired one sends the recipient to "link gone", one without an address is no use + * to copy. None: make one. */ +export const liveLink = (links: ShareLink[]) => links.find((l) => l.url && !linkExpired(l)); + // One-click share: the row's Share button turns "share this" into a single action. // Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so // the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in @@ -51,8 +61,7 @@ export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promi queryKey: linksKey(target), queryFn: () => fetchLinks(target), }); - // The newest link whose address can still be shown; one that cannot is no use to copy. - path = existing.find((l) => l.url)?.url; + path = liveLink(existing)?.url; if (!path) { const made = await makeLink(target, {}); // omit allow_download → backend default (true) qc.setQueryData(linksKey(target), [made, ...existing]); diff --git a/frontend/src/lib/plexPlayerHooks.skip.dom.test.ts b/frontend/src/lib/plexPlayerHooks.skip.dom.test.ts new file mode 100644 index 00000000..c67f140a --- /dev/null +++ b/frontend/src/lib/plexPlayerHooks.skip.dom.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment jsdom +// +// An auto-skip's countdown starts below zero: the wait before it, while the Skip button already +// shows. The bar it draws must read empty through the wait, not a negative width. +import { describe, expect, it } from "vitest"; + +import { skipCountdown } from "./plexPlayerHooks"; + +describe("skipCountdown", () => { + it("stays empty through the wait, runs over the delay, and stops at full", () => { + expect(skipCountdown(-3, 5)).toBe(0); + expect(skipCountdown(-0.1, 5)).toBe(0); + expect(skipCountdown(2.5, 5)).toBe(0.5); + expect(skipCountdown(7, 5)).toBe(1); + }); +}); diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 13a5a166..63e7e303 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -434,6 +434,19 @@ export function usePlayerVolume(opts: { return { setVol, nudgeVolume, toggleMute, onWheel }; } +// How long a marker plays before an auto-skip's countdown starts (seconds), at most: on a marker +// too short for both, the wait gives way, so the countdown still ends inside it (`useAutoSkip`). +const AUTO_SKIP_WAIT_S = 3; +// What the countdown keeps in hand before the marker's end: it ticks on wall time, the playhead on +// the media's, and the marker stops counting a second before its end. +const AUTO_SKIP_SLACK_S = 0.5; + +/** How far an auto-skip's countdown has run (0→1) after `elapsed` playing seconds of it. Through + * the wait `elapsed` is below zero, and the bar it draws stays empty: a negative width is invalid + * CSS, which the browser drops and keeps whatever it drew before. */ +export const skipCountdown = (elapsed: number, delay: number) => + Math.max(0, Math.min(1, elapsed / delay)); + // Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress // 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or // for the credits that end the item to the next item (binge). Which credits those are, and which the @@ -481,6 +494,7 @@ export function useAutoSkip(opts: { (m) => abs >= m.start_s && abs < m.end_s - 1, ); const activeMarkerRef = useLatest(activeMarker); + const absRef = useLatest(abs); // A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new // list and must not restart a countdown under way (see below); doSkip reads the list from a ref. const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto; @@ -538,14 +552,19 @@ export function useAutoSkip(opts: { const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto; if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null); if (autoSkipDelay <= 0) return doSkip(m); // immediate - let elapsed = 0; + // Below zero is the wait: time to see where the marker has landed and press the button, which + // shows from the start, before anything counts down to leaving. A delay of 0 asked for none. + // It is cut to what the marker has left over the countdown (a short intro, or one resumed near + // its end): a wait the countdown cannot finish after is an auto-skip that never fires. + const left = m.end_s - 1 - absRef.current - autoSkipDelay - AUTO_SKIP_SLACK_S; + let elapsed = -Math.max(0, Math.min(AUTO_SKIP_WAIT_S, left)); setSkipProgress(0); const iv = window.setInterval(() => { // Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a // deliberately-paused video. It resumes from where it stopped when playback resumes. if (videoRef.current?.paused) return; elapsed += 0.1; - const p = Math.min(1, elapsed / autoSkipDelay); + const p = skipCountdown(elapsed, autoSkipDelay); setSkipProgress(p); if (p >= 1) { window.clearInterval(iv); @@ -568,6 +587,7 @@ export function useAutoSkip(opts: { doSkip, videoRef, activeMarkerRef, + absRef, activeAuto, ]); diff --git a/frontend/src/lib/subscriptionCache.test.ts b/frontend/src/lib/subscriptionCache.test.ts index bc5efd25..a541dbbf 100644 --- a/frontend/src/lib/subscriptionCache.test.ts +++ b/frontend/src/lib/subscriptionCache.test.ts @@ -21,9 +21,23 @@ describe("a subscription change", () => { qk.feedSummary(), qk.discoveredChannels(), qk.watchedChannels(), + qk.tags(), ]); }); + // A tag counts the followed channels it is on, not hidden: a hide changes the number as an + // unsubscribe does, and both places that hide a channel must refresh it. + it("a hide refreshes the tags wherever it is made", () => { + const root = new URL("../", import.meta.url); + const sites = readdirSync(root, { recursive: true, encoding: "utf8" }) + .map((f) => f.split("\\").join("/")) + .filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab/")) + .map((f) => [f, readFileSync(new URL(f, root), "utf8")] as const) + .filter(([, src]) => /api\.updateChannel\([^)]*hidden/.test(src)); + expect(sites.length).toBeGreaterThanOrEqual(2); + for (const [f, src] of sites) expect(src, f).toContain("queryKey: qk.tags()"); + }); + it("is refreshed through that one function wherever it is made", () => { const root = new URL("../", import.meta.url); const sites = readdirSync(root, { recursive: true, encoding: "utf8" }) diff --git a/frontend/src/lib/subscriptionCache.ts b/frontend/src/lib/subscriptionCache.ts index dff6d221..12b344a5 100644 --- a/frontend/src/lib/subscriptionCache.ts +++ b/frontend/src/lib/subscriptionCache.ts @@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query"; import { qk } from "./queryKeys"; /** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its - * counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a - * subscribed channel leaves them). ONE set for every site that makes the change: each used to keep + * counts, the feed and its facets (the channel's videos come or go), the two discovery lists (a + * subscribed channel leaves them), and the tags (a tag counts the followed channels it is on). ONE set for every site that makes the change: each used to keep * its own list, and each list missed something. */ export function invalidateSubscription(qc: QueryClient, channelId: string): void { for (const queryKey of [ @@ -15,6 +15,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void qk.feedSummary(), qk.discoveredChannels(), qk.watchedChannels(), + qk.tags(), ]) void qc.invalidateQueries({ queryKey }); } diff --git a/frontend/src/lib/useChannelTagActions.ts b/frontend/src/lib/useChannelTagActions.ts index a85e152c..079517a1 100644 --- a/frontend/src/lib/useChannelTagActions.ts +++ b/frontend/src/lib/useChannelTagActions.ts @@ -28,7 +28,11 @@ export function useChannelTagActions(opts: { ? api.detachChannelTag(channelId, tagId) : api.attachChannelTag(channelId, tagId); call - .then(() => opts.onSuccess?.(channelId)) + .then(() => { + // A tag counts the followed channels it is on: the toggle changed that number. + void qc.invalidateQueries({ queryKey: qk.tags() }); + opts.onSuccess?.(channelId); + }) .catch((err: unknown) => { notifyActionError(err, msg("channels.tags.updateFailed")); opts.onError(channelId); diff --git a/frontend/src/lib/useTags.dom.test.tsx b/frontend/src/lib/useTags.dom.test.tsx index 3a3e2fab..aa8be835 100644 --- a/frontend/src/lib/useTags.dom.test.tsx +++ b/frontend/src/lib/useTags.dom.test.tsx @@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({ color: null, category: "topic", channel_count: 1, + subscribed_count: 1, }); function wrapper({ children }: { children: ReactNode }) { diff --git a/frontend/src/modules/channels/TagFilterChips.dom.test.tsx b/frontend/src/modules/channels/TagFilterChips.dom.test.tsx index 198fe12c..e4aa7161 100644 --- a/frontend/src/modules/channels/TagFilterChips.dom.test.tsx +++ b/frontend/src/modules/channels/TagFilterChips.dom.test.tsx @@ -11,7 +11,8 @@ // The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real // measurement can only ever produce one answer. import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { useState } from "react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -37,7 +38,7 @@ const chips = [chip("alpha"), chip("beta"), chip("gamma")]; // The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never // happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a // `hidden: true` query can see it. Presence is the whole question here, not visibility. -const menu = () => screen.queryByRole("menu", { hidden: true }); +const menu = () => screen.queryByRole("dialog", { hidden: true }); const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ }); afterEach(() => { @@ -100,3 +101,114 @@ describe("TagFilterChips — the overflow menu", () => { expect(menu()).toBeNull(); }); }); + +describe("TagFilterChips counts", () => { + it("draws a chip's count after its name, and none when it has none", () => { + fit.visible = 2; + render( + , + ); + const name = (label: string) => + screen.getAllByRole("button").find((b) => b.textContent?.startsWith(label)); + expect(name("alpha")?.textContent).toBe("alpha · 42"); + expect(name("beta")?.textContent).toBe("beta · 0"); + // In the chip's own colour: a dimmed count falls under the text contrast floor. + expect(name("alpha")?.querySelector('[class*="opacity"]')).toBeNull(); + }); + + it("draws nothing after the name of a chip that has no count", () => { + fit.visible = 3; + render(); + const gamma = screen.getAllByRole("button").find((b) => b.textContent?.startsWith("gamma")); + expect(gamma?.textContent).toBe("gamma"); + expect(gamma?.querySelector("span")).toBeNull(); + }); +}); + +// The channel manager puts the picked chips first, so a toggle moves its chip: out of the "+N" fold, +// into it, or out of the list. The keyboard's focus goes with the chip, not to . +describe("TagFilterChips focus on a toggle that moves the chip", () => { + function Row({ start }: { start: string[] }) { + const [picked, setPicked] = useState(start); + const all = ["alpha", "beta", "gamma"].map((k) => ({ + ...chip(k), + active: picked.includes(k), + onToggle: () => setPicked((p) => (p.includes(k) ? p.filter((x) => x !== k) : [...p, k])), + })); + return ( + c.active), ...all.filter((c) => !c.active)]} /> + ); + } + const press = (el: HTMLElement) => { + el.focus(); + fireEvent.click(el); + }; + const named = (label: string) => + screen.getAllByRole("button", { hidden: true }).find((b) => b.textContent === label)!; + + // Along the row needs no test of its own: a keyed move within one parent keeps its node, and + // React gives the focus back to it after the commit. Only a remount (row ↔ fold) or a chip that + // leaves the list loses it. + + it("out of the fold: picked in the popover, it is focused on the row", async () => { + fit.visible = 1; + render(); + fireEvent.click(moreBtn()!); + const inFold = named("beta"); + press(inFold); + // The closing popover hands the focus back to "+N" a microtask later: past that too. + await act(async () => {}); + const onRow = named("beta"); + expect(onRow).not.toBe(inFold); + expect(document.activeElement).toBe(onRow); + // …and the fold is shut: the focus is not left outside a popover still open. + expect(menu()).toBeNull(); + expect(moreBtn()).toHaveProperty("ariaExpanded", "false"); + }); + + // A stored pick of a tag that no longer exists is an active chip that an un-pick removes for good. + function WithGhost() { + const [ghost, setGhost] = useState(true); + const rest = ["alpha", "beta"].map(chip); + return ( + setGhost(false) }, ...rest] + : rest + } + /> + ); + } + + it.each([ + ["with room for all", 3], + ["with a fold", 1], + ])("a chip that leaves the list hands the focus to the one now in its place (%s)", (_, n) => { + fit.visible = n; + render(); + press(named("ghost")); + expect(document.activeElement).toBe(named("alpha")); + }); + + it("into a closed fold: the focus goes to the +N that now holds it", () => { + fit.visible = 1; + render(); + press(named("gamma")); + expect(named("alpha").closest('[role="dialog"]')).toBeNull(); // alpha leads the row now + expect(document.activeElement).toBe(moreBtn()); + }); + + it("a mouse press that leaves the focus elsewhere takes nothing", () => { + fit.visible = 3; + render(); + const outside = document.body.appendChild(document.createElement("input")); + outside.focus(); + fireEvent.click(named("gamma")); + act(() => {}); + expect(document.activeElement).toBe(outside); + outside.remove(); + }); +}); diff --git a/frontend/src/modules/channels/TagFilterChips.tsx b/frontend/src/modules/channels/TagFilterChips.tsx index 9eb2ea4a..d7af8ba4 100644 --- a/frontend/src/modules/channels/TagFilterChips.tsx +++ b/frontend/src/modules/channels/TagFilterChips.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { fitChipsWithPill } from "../../lib/chipOverflow"; import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit"; @@ -19,6 +19,8 @@ import { ChipLabelPending } from "../../components/ui/Skeleton"; export interface FilterChip { key: string; label: string; + /** How many it holds, drawn muted after the name. */ + count?: number; /** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */ pending?: boolean; active: boolean; @@ -28,12 +30,40 @@ export interface FilterChip { // A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror // measures what shows (ActiveFilterChips' ChipText, for this row's chips). function ChipText({ chip }: { chip: FilterChip }) { - return chip.pending ? : <>{chip.label}; + if (chip.pending) return ; + return ( + <> + {chip.label} + {/* The count in the chip's own colour, set off by a dot rather than dimmed: a dimmed count + falls under the text contrast floor, on a resting chip and on a pressed one. */} + {chip.count != null && ( + <> + {" "} + {chip.count} + + )} + + ); } -function TagChip({ chip }: { chip: FilterChip }) { +function TagChip({ + chip, + onToggled, +}: { + chip: FilterChip; + onToggled: (key: string, from: HTMLElement) => void; +}) { return ( - + { + // Before the toggle: a pressed chip may move (the caller's order can put picks first), and + // the focus must go where it went. + if (e.currentTarget === document.activeElement) onToggled(chip.key, e.currentTarget); + chip.onToggle(); + }} + pressed={chip.active} + > ); @@ -53,7 +83,7 @@ export default function TagFilterChips({ // Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's // width via the `.pill` recipe, so an aria-pressed flip must re-measure too). const key = chips - .map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`) + .map((c) => `${c.key}:${c.label}:${c.count ?? ""}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`) .join("|"); const visible = useChipOverflowFit({ @@ -90,12 +120,54 @@ export default function TagFilterChips({ const pop = useAnchoredPopover({ open, onOpenChange: setOpen, - mode: "menu", + // A fold of toggle buttons, not a menu of items: each chip is a pressed/unpressed button in the + // Tab order, which a role=menu with no menu items would hide from a screen reader. + mode: "dialog", + role: "dialog", placement: "bottom-end", }); const { setReference } = pop.refs; const moreLabel = t("channels.tags.moreCount", { count: hidden }); + // A toggle may move its chip between the row and the "+N" fold (two parents: the button is + // remounted and the focus falls to ), or take it out of the list altogether (a stored pick of + // a tag that no longer exists). A move along the row keeps its node, and React gives it the focus + // back. The focus follows the chip: onto it where it now stands, onto "+N" when it went into a + // closed fold, or onto the chip now in its old place when it is gone. A toggle in the open fold + // closes it, so the focus never sits outside a popover left open. Held for the frame, since the fit + // re-decides in layout effects after the toggle's render. + const follow = useRef<{ key: string; at: number; closed: boolean } | null>(null); + const foldRef = useRef(null); + const moreRef = useRef(null); + const onToggled = (chipKey: string, from: HTMLElement) => { + const closed = !!foldRef.current?.contains(from); + follow.current = { key: chipKey, at: chips.findIndex((c) => c.key === chipKey), closed }; + requestAnimationFrame(() => (follow.current = null)); + if (closed) setOpen(false); + }; + useLayoutEffect(() => { + const target = follow.current; + if (!target) return; + const inBox = (box: HTMLElement | null) => [ + ...(box?.querySelectorAll("[data-chip-key]") ?? []), + ]; + const row = inBox(rowRef.current); + const find = (box: HTMLElement[]) => box.find((b) => b.dataset.chipKey === target.key); + const gone = !chips.some((c) => c.key === target.key); + const el = + find(row) ?? + find(inBox(foldRef.current)) ?? + (gone ? (row[Math.min(target.at, row.length - 1)] ?? moreRef.current) : moreRef.current); + if (el && document.activeElement !== el) el.focus(); + // The closing popover gives the focus back to "+N" in a microtask it queued as it unmounted + // (seen in Chrome): this one, queued after it, has the last word. + if (el && target.closed) queueMicrotask(() => el.isConnected && el.focus()); + }); + const setMore = (el: HTMLButtonElement | null) => { + moreRef.current = el; + setReference(el); + }; + return (
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can @@ -124,10 +196,10 @@ export default function TagFilterChips({
{chips.slice(0, visible).map((c) => ( - + ))} {hidden > 0 && ( - + +{hidden} )} @@ -138,9 +210,11 @@ export default function TagFilterChips({ ariaLabel={moreLabel} className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`} > - {chips.slice(visible).map((c) => ( - - ))} +
+ {chips.slice(visible).map((c) => ( + + ))} +
); diff --git a/frontend/src/modules/channels/channelColumns.dom.test.tsx b/frontend/src/modules/channels/channelColumns.dom.test.tsx index 977f10b5..80104292 100644 --- a/frontend/src/modules/channels/channelColumns.dom.test.tsx +++ b/frontend/src/modules/channels/channelColumns.dom.test.tsx @@ -186,3 +186,58 @@ describe("buildChannelColumns — Added", () => { expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy(); }); }); + +describe("tagFilterChips", () => { + // An unsubscribe keeps a channel's tag links: the chip counts the followed ones, the rows the + // filter shows, not every link the tag has. + it("counts a tag's followed channels, and only a tag's", () => { + const tag = { + id: 3, + name: "Tech", + color: null, + category: "topic", + system: false, + channel_count: 17, + subscribed_count: 15, + }; + const chips = tagFilterChips({ + userTags: [tag], + tags: { isPending: false } as never, + picked: ["99"], + noTagOn: false, + toggle: () => {}, + toggleNoTag: () => {}, + t: ((k: string) => k) as never, + }); + expect(chips.find((c) => c.label === "Tech")?.count).toBe(15); + expect(chips.find((c) => c.key === "t99")?.count).toBeUndefined(); + expect(chips[chips.length - 1]?.count).toBeUndefined(); + }); + + // The row folds from its end into "+N", so a picked tag late in the list was folded away: at 1024 px + // "music" sat behind "+11" with nothing on the row saying a filter was on. The picks lead the row. + it("puts the picked tags first, keeping their order, then the rest in theirs", () => { + const tag = (id: number, name: string) => ({ id, name, system: false }) as Tag; + const row = (picked: string[], noTagOn = false) => + tagFilterChips({ + userTags: [tag(1, "ai"), tag(2, "docu"), tag(3, "music"), tag(4, "nature")], + tags: { isPending: false } as never, + picked, + noTagOn, + toggle: () => {}, + toggleNoTag: () => {}, + t: ((k: string) => k) as never, + }).map((c) => c.label + (c.active ? "*" : "")); + expect(row(["4", "3"])).toEqual(["music*", "nature*", "ai", "docu", "channels.tags.noTag"]); + expect(row(["99", "3"])).toEqual([ + "99*", + "music*", + "ai", + "docu", + "nature", + "channels.tags.noTag", + ]); + expect(row([], true)).toEqual(["channels.tags.noTag*", "ai", "docu", "music", "nature"]); + expect(row([])).toEqual(["ai", "docu", "music", "nature", "channels.tags.noTag"]); + }); +}); diff --git a/frontend/src/modules/channels/channelColumns.tsx b/frontend/src/modules/channels/channelColumns.tsx index 1c8899c0..ab2b9abc 100644 --- a/frontend/src/modules/channels/channelColumns.tsx +++ b/frontend/src/modules/channels/channelColumns.tsx @@ -51,11 +51,12 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean { } /** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one - * of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its + * of the user's tags, then the picked ones of the user's tags and "No tag", then the rest, each group + * in the tags' order with "No tag" last. The row folds from its end into "+N", so every pick leads it: + * a filter that is on stays in sight, never only inside the fold. A stored pick can outlive its * tag (deleted here or on another device) and still narrows the table to nothing, so it stays a - * visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away — - * and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule - * (a placeholder, then the name, "Deleted tag" or the id), not hidden. */ + * visible, removable chip, and so does every pick while the tag list loads or after it failed, named + * by lib/tagPicks' rule (a placeholder, then the name, "Deleted tag" or the id), not hidden. */ export function tagFilterChips(o: { userTags: Tag[]; tags: TagList; @@ -67,17 +68,11 @@ export function tagFilterChips(o: { }): FilterChip[] { const mine = new Set(o.userTags.map((tg) => String(tg.id))); const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id)); - return [ - ...others.map((id) => ({ - key: `t${id}`, - label: tagPickLabel(Number(id), o.tags, o.t), - pending: o.tags.isPending, - active: true, - onToggle: () => o.toggle(Number(id)), - })), + const known: FilterChip[] = [ ...o.userTags.map((tg) => ({ key: `t${tg.id}`, label: tg.name, + count: tg.subscribed_count, active: o.picked.includes(String(tg.id)), onToggle: () => o.toggle(tg.id), })), @@ -88,6 +83,17 @@ export function tagFilterChips(o: { onToggle: o.toggleNoTag, }, ]; + return [ + ...others.map((id) => ({ + key: `t${id}`, + label: tagPickLabel(Number(id), o.tags, o.t), + pending: o.tags.isPending, + active: true, + onToggle: () => o.toggle(Number(id)), + })), + ...known.filter((c) => c.active), + ...known.filter((c) => !c.active), + ]; } /** Everything the Channels-manager column set closes over. The manager owns the state + mutations; diff --git a/frontend/src/modules/channels/useChannelActions.tsx b/frontend/src/modules/channels/useChannelActions.tsx index b9e24365..bfb2fc61 100644 --- a/frontend/src/modules/channels/useChannelActions.tsx +++ b/frontend/src/modules/channels/useChannelActions.tsx @@ -50,8 +50,10 @@ export function useChannelActions({ const nameOf = (c: ManagedChannel) => c.title == null ? msg("channels.row.thisChannel") : raw(c.title); + // A hide also changes the tags' counts: a tag counts only the channels the Subscriptions tab shows. const refresh = () => { qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.tags() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feedSummary() }); diff --git a/frontend/src/modules/channels/useTagSuggestions.dom.test.tsx b/frontend/src/modules/channels/useTagSuggestions.dom.test.tsx index 74470aef..d54ee306 100644 --- a/frontend/src/modules/channels/useTagSuggestions.dom.test.tsx +++ b/frontend/src/modules/channels/useTagSuggestions.dom.test.tsx @@ -36,6 +36,7 @@ const tag = (id: number, name: string): Tag => ({ category: "other", system: false, channel_count: 0, + subscribed_count: 0, }); const docu = tag(1, "docu"); const history = tag(2, "history"); diff --git a/frontend/src/modules/feed/ChannelPage.tsx b/frontend/src/modules/feed/ChannelPage.tsx index 57e4895d..6eb1f0e6 100644 --- a/frontend/src/modules/feed/ChannelPage.tsx +++ b/frontend/src/modules/feed/ChannelPage.tsx @@ -194,6 +194,8 @@ export default function ChannelPage({ qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.channels() }); + // A tag counts only the channels the Subscriptions tab shows, which a hide leaves out. + qc.invalidateQueries({ queryKey: qk.tags() }); }, }); diff --git a/frontend/src/modules/files/FileCard.tsx b/frontend/src/modules/files/FileCard.tsx index 73a20ecb..ed60f881 100644 --- a/frontend/src/modules/files/FileCard.tsx +++ b/frontend/src/modules/files/FileCard.tsx @@ -6,6 +6,7 @@ import { navItemAttrs } from "../../lib/listNav"; import { JobThumb } from "../../features/downloads/downloadRow"; import { jobTitle } from "../../features/downloads/downloadTitle"; import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges"; +import { fileMeta } from "./fileMeta"; import { MediaCard, MediaPlayGlyph } from "../../components/ui/MediaCard"; import { MEDIA_BADGE, @@ -46,7 +47,7 @@ export function FileCard({ }) { const { t } = useTranslation(); const title = jobTitle(file); - const channel = file.display_uploader || file.asset?.uploader || null; + const meta = fileMeta(file); const clip = file.job_kind === "edit" || file.job_kind === "clip"; const media = file.asset?.media_kind !== "file"; const thumb = ; @@ -102,7 +103,7 @@ export function FileCard({ - {channel && {channel}} + {meta && {meta}} diff --git a/frontend/src/modules/files/Files.tsx b/frontend/src/modules/files/Files.tsx index 5c09474d..ffa54259 100644 --- a/frontend/src/modules/files/Files.tsx +++ b/frontend/src/modules/files/Files.tsx @@ -41,6 +41,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle"; import { FileCard } from "./FileCard"; import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges"; import { folderLabel, takesUploads } from "../../features/folders/fileFolders"; +import { fileMeta } from "./fileMeta"; import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext"; import { useFileActions } from "./useFileActions"; import { useFolderActions } from "./useFolderActions"; @@ -157,7 +158,6 @@ function NameCell({ onRenamed: (name: string | null, byKey: boolean) => void; }) { const { t } = useTranslation(); - const channel = file.display_uploader || file.asset?.uploader || null; const clip = file.job_kind === "edit" || file.job_kind === "clip"; if (renaming) return ( @@ -180,7 +180,7 @@ function NameCell({ {title} )} - {channel ?? "—"} + {fileMeta(file) || "—"} {clip && ( diff --git a/frontend/src/modules/files/ShareDialog.dom.test.tsx b/frontend/src/modules/files/ShareDialog.dom.test.tsx index 37530a6c..d13531eb 100644 --- a/frontend/src/modules/files/ShareDialog.dom.test.tsx +++ b/frontend/src/modules/files/ShareDialog.dom.test.tsx @@ -24,7 +24,9 @@ vi.mock("../../components/ui/Modal", () => ({ ), })); vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => h.confirm })); +vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText })); const h = vi.hoisted(() => ({ + copyText: vi.fn(async (_t: string) => true), confirm: false, revokeDownloadLink: vi.fn(), links: null as unknown[] | null, @@ -72,6 +74,9 @@ afterEach(cleanup); beforeEach(() => { h.links = null; h.confirm = false; + h.createDownloadLink.mockReset(); + h.copyText.mockReset(); + h.copyText.mockResolvedValue(true); }); const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? ""; @@ -96,6 +101,157 @@ describe("ShareDialog · a link row's state colours", () => { }); }); +describe("ShareDialog · opened to share", () => { + const open = () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} /> + , + ); + }; + const row = (id: number, url: string | null, expires_at: string | null = null) => ({ + id, + token: "t", + url, + allow_download: true, + has_password: false, + expires_at, + view_count: 0, + created_at: null, + }); + const origin = window.location.origin; + const status = () => screen.getByRole("status").textContent; + const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString(); + const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString(); + + it("copies the newest link it can show, and says so", async () => { + h.links = [row(1, null), row(2, "/file/b"), row(3, "/file/c")]; + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`)); + expect(h.copyText).toHaveBeenCalledTimes(1); + await waitFor(() => expect(status()).toBe("downloads.share.linkCopied")); + }); + + // The list keeps an expired link for its owner to see and revoke; the recipient of one lands on + // "link gone". + it("copies the newest link that still opens, and says an expired one has expired", async () => { + h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)]; + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`)); + expect(h.copyText).toHaveBeenCalledTimes(1); + expect(screen.getByText("downloads.share.expiredOn", { exact: false })).toBeTruthy(); + expect(screen.getByText("downloads.share.expiresOn", { exact: false })).toBeTruthy(); + }); + + it("makes a link when every one it has expired, and copies that", async () => { + h.links = [row(1, "/file/old", lastWeek)]; + h.createDownloadLink.mockResolvedValue(row(4, "/file/new")); + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`)); + expect(h.copyText).toHaveBeenCalledTimes(1); + }); + + // An expired link opens nothing: it is no link to hand out, and reads as dead at a glance. + it("offers no Copy on an expired link, and marks it with a warning", async () => { + h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)]; + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`)); + expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1); + expect(screen.getByText("downloads.share.expiredOn", { exact: false }).className).toContain( + "text-warn", + ); + expect(screen.getAllByRole("link", { name: "downloads.share.openNewTab" })).toHaveLength(2); + }); + + // Closed before the link it made came back, the dialog must not open next time on the list it + // had and make another. + it("makes one link, closed before it came back and opened again", async () => { + h.links = []; + let made: (l: unknown) => void = () => {}; + h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res))); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const dialog = () => ( + + {}} /> + + ); + const first = render(dialog()); + await waitFor(() => expect(h.createDownloadLink).toHaveBeenCalledTimes(1)); + first.unmount(); + made(row(4, "/file/new")); + await new Promise((r) => setTimeout(r, 20)); + h.links = [row(4, "/file/new")]; + render(dialog()); + await screen.findByDisplayValue(`${origin}/file/new`); + await new Promise((r) => setTimeout(r, 20)); + expect(h.createDownloadLink).toHaveBeenCalledTimes(1); + }); + + it("leaves a New link form the user opened while the first link was on its way", async () => { + h.links = []; + let made: (l: unknown) => void = () => {}; + h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res))); + open(); + fireEvent.click(await screen.findByText("downloads.share.newLink")); + fireEvent.change(screen.getByPlaceholderText("downloads.share.password"), { + target: { value: "s3cret" }, + }); + h.links = [row(4, "/file/new")]; + made(row(4, "/file/new")); + await screen.findByDisplayValue(`${origin}/file/new`); + expect(screen.getByPlaceholderText("downloads.share.password").value).toBe( + "s3cret", + ); + }); + + // The clipboard holds what was copied last: the link copied on opening stops saying so. + it("stops claiming the opening copy once another link is copied", async () => { + h.links = [row(2, "/file/b"), row(3, "/file/c")]; + open(); + await waitFor(() => expect(status()).toBe("downloads.share.linkCopied")); + expect(screen.getAllByText("downloads.share.linkCopied")).toHaveLength(2); // the row + the region + fireEvent.click(screen.getAllByRole("button", { name: "downloads.share.copy" })[1]!); + await waitFor(() => expect(h.copyText).toHaveBeenLastCalledWith(`${origin}/file/c`)); + expect(status()).toBe(""); + expect(screen.queryByText("downloads.share.linkCopied")).toBeNull(); + }); + + it("copies the link it made when there was none", async () => { + h.links = []; + h.createDownloadLink.mockResolvedValue(row(4, "/file/new")); + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`)); + }); + + it("claims nothing when the browser refused the copy", async () => { + h.links = [row(2, "/file/b")]; + h.copyText.mockResolvedValue(false); + open(); + await waitFor(() => expect(h.copyText).toHaveBeenCalled()); + await screen.findByRole("button", { name: "downloads.share.copy" }); + expect(status()).toBe(""); + expect(screen.queryByText("downloads.share.linkCopied")).toBeNull(); + }); + + it("offers the link in a new tab, beside Copy", async () => { + h.links = [row(2, "/file/b")]; + open(); + const openLink = await screen.findByRole("link", { name: "downloads.share.openNewTab" }); + expect(openLink.getAttribute("href")).toBe(`${origin}/file/b`); + expect(openLink.getAttribute("target")).toBe("_blank"); + expect(openLink.getAttribute("rel")).toContain("noopener"); + }); + + it("has no Open for a link whose address cannot be shown", async () => { + h.links = [row(1, null)]; + open(); + await screen.findByText("downloads.share.urlHidden"); + expect(screen.queryByRole("link", { name: "downloads.share.openNewTab" })).toBeNull(); + expect(h.copyText).not.toHaveBeenCalled(); + }); +}); + describe("ShareDialog · a link whose address cannot be shown again", () => { it("says so in place of the address, with nothing to copy", async () => { const link = (id: number, url: string | null) => ({ diff --git a/frontend/src/modules/files/ShareDialog.tsx b/frontend/src/modules/files/ShareDialog.tsx index 1d00c40e..b350a5a9 100644 --- a/frontend/src/modules/files/ShareDialog.tsx +++ b/frontend/src/modules/files/ShareDialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { qk } from "../../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; +import { Check, Copy, ExternalLink, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; import Modal from "../../components/ui/Modal"; import { useConfirm } from "../../components/ui/confirmContext"; import { msg, notify, raw } from "../../lib/notifications"; @@ -12,15 +12,20 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../. import { fetchLinks, invalidateLinks, + linkExpired, linksKey, + liveLink, makeLink, type LinkTarget, } from "../../lib/downloads"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; +import { IconLink } from "../../components/ui/LinkButton"; +import { copyText } from "../../lib/clipboard"; import { Input, InnerInput } from "../../components/ui/Input"; import { CheckRow } from "../../components/ui/NativeControls"; import { SURFACE } from "../../components/ui/surfaces"; +import { Badge } from "../../components/ui/Badge"; import UserPicker from "./UserPicker"; import { useCopyFeedback } from "../../components/ui/useCopyFeedback"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; @@ -75,7 +80,19 @@ function UserShare({ job }: { job: DownloadJob }) { // --- share by public link ------------------------------------------------------------------- -function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) { +function LinkRow({ + link, + copiedOnOpen, + onCopied, + onChanged, +}: { + link: ShareLink; + /** The dialog put this link on the clipboard when it opened, and nothing was copied since. */ + copiedOnOpen: boolean; + /** Its Copy was pressed: the clipboard holds this link now, not the one the dialog copied. */ + onCopied: () => void; + onChanged: () => void; +}) { const { t, i18n } = useTranslation(); const confirm = useConfirm(); const { copied, copy: copyValue } = useCopyFeedback(); @@ -84,7 +101,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } const fullUrl = link.url && window.location.origin + link.url; const copy = () => { - if (fullUrl) void copyValue(fullUrl); + if (!fullUrl) return; + onCopied(); + void copyValue(fullUrl); }; const toggle = useMutation({ mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }), @@ -108,10 +127,13 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } }, }); + // An expired link opens nothing, so it is no link to hand out: no Copy, and it reads as dead at a + // glance (a warn badge, its address struck through). Open stays, to check it; Revoke, to clear it. + const expired = linkExpired(link); const badges: string[] = []; if (link.has_password) badges.push(t("downloads.share.hasPassword")); if (link.allow_download) badges.push(t("downloads.share.downloadable")); - if (link.expires_at) + if (link.expires_at && !expired) badges.push( t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }), ); @@ -122,10 +144,33 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } {fullUrl ? ( <> - - - {copied ? : } - + + {!expired && ( + + {copied || copiedOnOpen ? ( + + ) : ( + + )} + + )} + + + ) : ( // The server keeps no copy of this link's address it can read: the link still opens for @@ -175,10 +220,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
+ {/* Seen here, heard from the dialog's own live region (`LinkShare`): one mounted already + filled may never be announced. */} + {copiedOnOpen &&

{t("downloads.share.linkCopied")}

}
{t("downloads.share.views", { count: link.view_count })} + {expired && link.expires_at && ( + + {t("downloads.share.expiredOn", { date: formatDate(link.expires_at, i18n.language) })} + + )} {badges.map((b) => ( void } ); } +/** Puts a link on the clipboard for the dialog that just opened, and answers whether it got there: + * a browser may refuse a copy no click asked for, and then nothing may claim it. */ +async function copiedLink(link: ShareLink | undefined): Promise { + if (!link?.url) return null; + return (await copyText(window.location.origin + link.url)) ? link.id : null; +} + function LinkShare({ target }: { target: LinkTarget }) { const { t } = useTranslation(); const qc = useQueryClient(); + // The link the dialog copied when it opened: sharing is almost always why it was opened. + const [copiedOnOpen, setCopiedOnOpen] = useState(null); const links = useQuery({ queryKey: linksKey(target), queryFn: () => fetchLinks(target), @@ -265,19 +327,29 @@ function LinkShare({ target }: { target: LinkTarget }) { expires_days: expiryDays, password: password.trim() || undefined, }), - onSuccess: () => { + // In the list at once, as the one-click Share puts it: closed before the refetch, the dialog + // would open next time on the cached list without it and make another. + onSuccess: (made) => { + qc.setQueryData(linksKey(target), (old) => [made, ...(old ?? [])]); invalidate(); - setCreating(false); - // Back to the form's own default for the next link: downloadable. - setAllowDownload(true); - setExpiryDays(null); - setPassword(""); }, meta: { failTitle: "downloads.share.linkFailed" }, }); + // The form's own success, not the mutation's: the link made on open goes through the same + // mutation, and must not close and clear a form the user opened while it was on its way. + const createFromForm = () => + create.mutate(undefined, { + onSuccess: () => { + setCreating(false); + // Back to the form's own default for the next link: downloadable. + setAllowDownload(true); + setExpiryDays(null); + setPassword(""); + }, + }); // Open with a link already generated: sharing is almost always the goal, so mint a default - // (no password, no expiry, downloadable) link the moment the dialog finds none — as if the + // (no password, no expiry, downloadable) link the moment the dialog finds none that opens — as if the // user had pressed "New link" → "Create". All further controls (password, download toggle, expiry, // extra links, revoke) stay available on the created link and via the "New link" button. const autoTried = useRef(false); @@ -290,11 +362,19 @@ function LinkShare({ target }: { target: LinkTarget }) { useEffect(() => { if (!links.data || autoTried.current) return; autoTried.current = true; - if (links.data.length === 0) createLink(); + // The link to hand out is one that still opens (`liveLink`, as the one-click Share picks it): + // with only expired ones, or none whose address can be shown, a new one is made as for none. + const live = liveLink(links.data); + if (!live) + createLink(undefined, { onSuccess: (made) => void copiedLink(made).then(setCopiedOnOpen) }); + else void copiedLink(live).then(setCopiedOnOpen); }, [links.data, createLink]); return (
+

+ {copiedOnOpen !== null ? t("downloads.share.linkCopied") : ""} +

{t("downloads.share.linkSection")}
@@ -317,7 +397,13 @@ function LinkShare({ target }: { target: LinkTarget }) { ) : (
{links.data.map((l) => ( - + setCopiedOnOpen(null)} + onChanged={invalidate} + /> ))} {links.data.length === 0 && !creating && !create.isPending && (

{t("downloads.share.noLinks")}

@@ -361,7 +447,7 @@ function LinkShare({ target }: { target: LinkTarget }) { + +
+ ), +})); + +import PlexBrowse from "./PlexBrowse"; +import { api, EMPTY_PLEX_FILTERS } from "../../lib/api"; +import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot"; +import { + PlexActionsContext, + PlexStateContext, + type PlexActions, + type PlexState, +} from "./plexContext"; +import type { SortState } from "../../lib/columnSort"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +beforeEach(() => window.history.replaceState({}, "", "/")); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const TITLE: SortState = { key: "title", dir: "asc" }; +const slot = document.body.appendChild(document.createElement("div")); + +// The store's show and sort, real enough for PlexBrowse's own effect to act on them. +function mount(start: { show: string; sort: SortState }) { + vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [], total: 0, offset: 0 } as never); + const now = { state: null as PlexState | null, actions: null as PlexActions | null }; + function Store() { + const [show, setShow] = useState(start.show); + const [sort, setSort] = useState(start.sort); + const state: PlexState = { + scope: "both", + show, + sort, + filters: EMPTY_PLEX_FILTERS, + q: "", + field: "all", + playlistOpen: null, + jumpTo: null, + } as PlexState; + const actions = { + setShow, + setSort, + setScope: () => {}, + setFilters: () => {}, + setQ: () => {}, + setField: () => {}, + setPlaylistOpen: () => {}, + setJumpTo: () => {}, + } as PlexActions; + now.state = state; + now.actions = actions; + return ( + + + + + + ); + } + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + return now; +} + +describe("PlexBrowse · Discover's See all", () => { + it("Recently added opens every title, newest first, over a sort picked before", async () => { + const store = mount({ show: "in_progress", sort: TITLE }); + fireEvent.click(await screen.findByText("recent")); + expect(store.state!.show).toBe("all"); + expect(store.state!.sort).toEqual({ key: "added", dir: "desc" }); + }); + + // Entering in-progress keeps the sort from before it, to give back on the way out: leaving for + // Recently added must not hand it back over newest first. + it("Recently added from in progress does not give back the sort from before it", async () => { + const store = mount({ show: "all", sort: TITLE }); + act(() => store.actions!.setShow("in_progress")); + expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" }); + fireEvent.click(await screen.findByText("recent")); + expect(store.state!.show).toBe("all"); + expect(store.state!.sort).toEqual({ key: "added", dir: "desc" }); + }); + + it("Continue watching opens in progress, last watched first", async () => { + const store = mount({ show: "all", sort: TITLE }); + fireEvent.click(await screen.findByText("continue")); + expect(store.state!.show).toBe("in_progress"); + expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" }); + }); + + // Already in progress, with another sort picked there: no entering edge brings the sort. + it("Continue watching sorts by last watched when in progress was already on", async () => { + const store = mount({ show: "all", sort: TITLE }); + act(() => store.actions!.setShow("in_progress")); + act(() => store.actions!.setSort(TITLE)); + fireEvent.click(await screen.findByText("continue")); + expect(store.state!.show).toBe("in_progress"); + expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" }); + // …and the sort from before in-progress still comes back on the way out. + act(() => store.actions!.setShow("all")); + expect(store.state!.sort).toEqual(TITLE); + }); +}); diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx index 86f31603..9ca2e8a2 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -80,6 +80,9 @@ const LASTWATCHED_SORT: NonNullable = Object.freeze({ dir: "desc" as const, }); +// What the Recently added rail's See all opens on. +const ADDED_SORT: NonNullable = Object.freeze({ key: "added", dir: "desc" as const }); + // Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am // I looking at" control now, like the feed's Watch-state pill, not a rail widget). const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; @@ -579,7 +582,22 @@ function PlexBrowseView({ sub }: { sub: ReturnType onRestartCard={onRestartCard} onInfoCard={onInfo} onGenre={(g) => applyFilter({ genres: [g] })} - onSeeAllRecent={() => sub.open({ kind: "grid" })} + // Each opens the list its rail is the start of: the watch state goes with it, and + // entering in-progress brings the last-watched sort (the effect above). + onSeeAllRecent={() => { + // Newest first is the list's point, so no earlier sort comes back as it leaves in-progress. + preInProgressSort.current = null; + setSort(ADDED_SORT); + setShow("all"); + sub.open({ kind: "grid" }); + }} + onSeeAllContinue={() => { + // Already in progress (with a sort picked since), no edge brings the sort: set it here, + // keeping the sort from before in-progress for the way out. + if (show === "in_progress") setSort(LASTWATCHED_SORT); + else setShow("in_progress"); + sub.open({ kind: "grid" }); + }} />
); diff --git a/frontend/src/modules/plex/PlexDiscover.dom.test.tsx b/frontend/src/modules/plex/PlexDiscover.dom.test.tsx index fdc257cc..525f7a5c 100644 --- a/frontend/src/modules/plex/PlexDiscover.dom.test.tsx +++ b/frontend/src/modules/plex/PlexDiscover.dom.test.tsx @@ -105,6 +105,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); await screen.findByText("plex.discover.continueWatching"); @@ -144,6 +145,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} /> , ); @@ -179,12 +181,38 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={onGenre} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); fireEvent.click(await screen.findByText("Drama")); expect(onGenre).toHaveBeenCalledWith("Drama"); }); + it("fires onSeeAllContinue from the Continue watching rail's see-all", async () => { + const onSeeAllContinue = vi.fn(); + const onSeeAllRecent = vi.fn(); + plexDiscover.mockResolvedValue({ ...fullPayload, recently_added: [] }); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + wrap( + {}} + onInfoCard={noop} + onGenre={noop} + onSeeAllRecent={onSeeAllRecent} + onSeeAllContinue={onSeeAllContinue} + />, + ); + fireEvent.click( + await screen.findByRole("button", { + name: "common.seeAllOf:plex.discover.continueWatching", + }), + ); + expect(onSeeAllContinue).toHaveBeenCalledOnce(); + expect(onSeeAllRecent).not.toHaveBeenCalled(); + }); + it("fires onSeeAllRecent from the recently-added rail's see-all", async () => { plexDiscover.mockResolvedValue(fullPayload); plexSurprise.mockResolvedValue({ scope: "both", items: [] }); @@ -197,9 +225,13 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={onSeeAllRecent} + onSeeAllContinue={noop} />, ); - fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" })); + // Each See all is named with its rail: Continue watching, above it, has its own. + fireEvent.click( + await screen.findByRole("button", { name: "common.seeAllOf:plex.discover.recentlyAdded" }), + ); expect(onSeeAllRecent).toHaveBeenCalledOnce(); }); @@ -214,6 +246,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); await screen.findByText("Random Pick"); @@ -250,6 +283,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} /> ); @@ -297,6 +331,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} /> , ); @@ -338,6 +373,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); await screen.findByText("plex.discover.empty"); @@ -363,6 +399,7 @@ describe("PlexDiscover", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); fireEvent.click(await screen.findByLabelText("plex.markWatched")); @@ -386,6 +423,7 @@ describe("PlexDiscover · keyboard", () => { onInfoCard={noop} onGenre={noop} onSeeAllRecent={noop} + onSeeAllContinue={noop} />, ); await screen.findAllByText("Random Pick"); diff --git a/frontend/src/modules/plex/PlexDiscover.tsx b/frontend/src/modules/plex/PlexDiscover.tsx index 19a8d3d1..0908dfd6 100644 --- a/frontend/src/modules/plex/PlexDiscover.tsx +++ b/frontend/src/modules/plex/PlexDiscover.tsx @@ -101,6 +101,7 @@ export default function PlexDiscover({ onInfoCard, onGenre, onSeeAllRecent, + onSeeAllContinue, }: { scope: string; onOpenCard: (c: PlexCard) => void; @@ -108,6 +109,7 @@ export default function PlexDiscover({ onInfoCard: (c: PlexCard) => void; onGenre: (genre: string) => void; onSeeAllRecent: () => void; + onSeeAllContinue: () => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); @@ -233,6 +235,7 @@ export default function PlexDiscover({ diff --git a/frontend/src/modules/plex/PlexInfo.dom.test.tsx b/frontend/src/modules/plex/PlexInfo.dom.test.tsx index eb2a9f0e..88a2f5b1 100644 --- a/frontend/src/modules/plex/PlexInfo.dom.test.tsx +++ b/frontend/src/modules/plex/PlexInfo.dom.test.tsx @@ -426,3 +426,17 @@ describe("a collection strip's card", () => { await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2")); }); }); + +describe("PlexInfo size", () => { + const show = (over: Partial) => + wrap(); + it("names the file's size beside the runtime", () => { + show({ size_bytes: 15_000_000_000 }); + expect(screen.getByText(/^1[45](\.\d)? GB$/)).toBeTruthy(); + }); + it("names none when it is not known", () => { + show({ size_bytes: null }); + expect(screen.getByText("Test Movie")).toBeTruthy(); + expect(screen.queryByText(/ GB$/)).toBeNull(); + }); +}); diff --git a/frontend/src/modules/plex/PlexInfo.tsx b/frontend/src/modules/plex/PlexInfo.tsx index ad2f46e1..c1f99b96 100644 --- a/frontend/src/modules/plex/PlexInfo.tsx +++ b/frontend/src/modules/plex/PlexInfo.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api"; -import { formatCompact, formatDecimal, formatRuntime } from "../../lib/format"; +import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format"; import { notifyActionError } from "../../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; @@ -452,6 +452,9 @@ export default function PlexInfo({ {detail.duration_seconds ? ( {formatRuntime(detail.duration_seconds, lang)} ) : null} + {detail.size_bytes ? ( + {formatBytes(detail.size_bytes, lang)} + ) : null} {detail.studio && ( { expect(screen.getByTestId("clip-panel")).toBeTruthy(); }); + describe("the wait before an auto-skip's countdown", () => { + const withCredits = { + ...item("a", 0, "b"), + markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }], + } as unknown as PlexItemDetail; + const advance = (ms: number) => + act(() => { + vi.advanceTimersByTime(ms); + }); + + // Into the marker with the clock already faked, so the countdown's interval is the fake one's. + const enterMarker = (paused: boolean) => { + vi.useFakeTimers(); + const v = video(); + v._paused = paused; + v._t = 2910; + act(() => void v.dispatchEvent(new Event("timeupdate"))); + }; + + it("holds the countdown for 3 s after the marker starts, then runs it", async () => { + prefs({ autoSkipCredits: true, autoSkipDelay: 1 }); + // Leaving a final marker writes "watched" as it skips, in the same tick: the next item's + // session comes only after promises this clock does not run. + const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] }); + await playingAt("/media/a", 2800); + enterMarker(false); + advance(3500); // past what the delay alone would have waited + expect(left).not.toHaveBeenCalled(); + advance(1000); + expect(left).toHaveBeenCalledWith("a", "watched"); + }); + + it("a delay of 0 is still immediate", async () => { + prefs({ autoSkipCredits: true, autoSkipDelay: 0 }); + const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] }); + await playingAt("/media/a", 2910); + await waitFor(() => expect(lastStart(session)?.[0]).toBe("b")); + }); + + it("waits out the pause: a paused video does not use up the 3 s", async () => { + prefs({ autoSkipCredits: true, autoSkipDelay: 1 }); + const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] }); + await playingAt("/media/a", 2800); + enterMarker(true); + advance(10_000); + expect(left).not.toHaveBeenCalled(); + video()._paused = false; + advance(3500); // the whole wait is still ahead, then the 1 s countdown + expect(left).not.toHaveBeenCalled(); + advance(1000); + expect(left).toHaveBeenCalledWith("a", "watched"); + }); + + // An 8 s intro with the default 5 s countdown: 3 s of wait on top would run past its end, and the + // countdown stops there with nothing skipped. + it("shortens the wait on a marker too short for it, so the skip still fires", async () => { + prefs({ autoSkipCredits: true, autoSkipDelay: 5 }); + const short = { + ...withCredits, + markers: [{ type: "credits", start_s: 2909, end_s: 2917, final: true }], + } as unknown as PlexItemDetail; + const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + mount({ details: { a: short, b: item("b") }, queue: ["a", "b"] }); + await playingAt("/media/a", 2800); + enterMarker(false); // at 2910: 6 s before the marker stops counting + advance(5000); + expect(left).not.toHaveBeenCalled(); // a wait is still kept: 0.5 s of it here + advance(700); + expect(left).toHaveBeenCalledWith("a", "watched"); + }); + }); + it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => { prefs({ autoSkipCredits: true, autoSkipDelay: 0 }); const withCredits = { diff --git a/frontend/src/watch/WatchPage.dom.test.tsx b/frontend/src/watch/WatchPage.dom.test.tsx index ff6883bc..32d444f9 100644 --- a/frontend/src/watch/WatchPage.dom.test.tsx +++ b/frontend/src/watch/WatchPage.dom.test.tsx @@ -24,6 +24,20 @@ afterEach(() => { localStorage.clear(); }); +describe("WatchPage share paths", () => { + it.each(["/watch/tok-p", "/file/tok-p", "/folder/tok-p", "/file/tok-p/extra"])( + "reads the token off %s", + async (path) => { + window.history.replaceState({}, "", path); + mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" }); + render(); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/public/watch/tok-p", expect.anything()), + ); + }, + ); +}); + describe("WatchPage background play", () => { it("hands its
diff --git a/frontend/src/watch/watchStrings.dom.test.ts b/frontend/src/watch/watchStrings.dom.test.ts index 9d470878..4b391b77 100644 --- a/frontend/src/watch/watchStrings.dom.test.ts +++ b/frontend/src/watch/watchStrings.dom.test.ts @@ -28,3 +28,12 @@ describe("the watch page's document language", () => { expect(document.documentElement.lang).toBe(want); }); }); + +// One button, two kinds of link: it says the same kind of word for both, as the app's own buttons +// do in Hungarian — a noun (Letöltés, Megnyitás), not a first-person verb on one of them. +describe("the password form's button", () => { + it("reads as a noun for a video and for anything else in Hungarian", async () => { + const { T } = await pageIn("hu-HU"); + expect([T.watch, T.open]).toEqual(["Megtekintés", "Megnyitás"]); + }); +}); diff --git a/frontend/src/watch/watchStrings.ts b/frontend/src/watch/watchStrings.ts index d2730928..1dc90de3 100644 --- a/frontend/src/watch/watchStrings.ts +++ b/frontend/src/watch/watchStrings.ts @@ -27,6 +27,8 @@ const STR = { crashHint: "Reload the page to try again.", reload: "Reload", passwordTitleFolder: "This folder is password-protected", + passwordTitleFile: "This file is password-protected", + open: "Open", folderEmpty: "This folder is empty.", play: "Play", files: (n: number) => (n === 1 ? "1 file" : `${n} files`), @@ -37,7 +39,7 @@ const STR = { goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.", passwordTitle: "Ez a videó jelszóval védett", passwordPlaceholder: "Add meg a jelszót", - watch: "Megnézem", + watch: "Megtekintés", wrong: "Hibás jelszó.", download: "Letöltés", file: "Fájl", @@ -55,6 +57,8 @@ const STR = { crashHint: "Töltsd újra az oldalt, és próbáld újra.", reload: "Újratöltés", passwordTitleFolder: "Ez a mappa jelszóval védett", + passwordTitleFile: "Ez a fájl jelszóval védett", + open: "Megnyitás", folderEmpty: "Ez a mappa üres.", play: "Lejátszás", files: (n: number) => `${n} fájl`, @@ -83,6 +87,8 @@ const STR = { crashHint: "Lade die Seite neu und versuche es erneut.", reload: "Neu laden", passwordTitleFolder: "Dieser Ordner ist passwortgeschützt", + passwordTitleFile: "Diese Datei ist passwortgeschützt", + open: "Öffnen", folderEmpty: "Dieser Ordner ist leer.", play: "Abspielen", files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`), diff --git a/frontend/src/watchEntry.contract.test.ts b/frontend/src/watchEntry.contract.test.ts index add314b3..e497a7a3 100644 --- a/frontend/src/watchEntry.contract.test.ts +++ b/frontend/src/watchEntry.contract.test.ts @@ -99,6 +99,29 @@ function chain(seen: Map, mod: string): string { return path.join(" → "); } +describe("the paths a share link opens on", () => { + // The server's list: each of the places below that has to know them follows it. + const main = readFileSync(join(SRC, "..", "..", "backend", "app", "main.py"), "utf8"); + const prefixes = [ + ...(/SHARE_PREFIXES = \(([^)]*)\)/.exec(main)?.[1] ?? "").matchAll(/"(\w+)\/"/g), + ].map((m) => m[1]); + + it("are read off the server", () => { + expect(prefixes).toEqual(["watch", "file", "folder"]); + }); + + it("are all kept out of search indexes", () => { + const robots = readFileSync(join(SRC, "..", "public", "robots.txt"), "utf8"); + for (const p of prefixes) expect(robots).toContain(`Disallow: /${p}/`); + }); + + it("all boot the player alone in `vite dev` and all give up their token", () => { + const vite = readFileSync(join(SRC, "..", "vite.config.ts"), "utf8"); + const page = readFileSync(join(SRC, "watch", "WatchPage.tsx"), "utf8"); + for (const src of [vite, page]) expect(src).toContain(`(?:${prefixes.join("|")})`); + }); +}); + describe("the /watch entry", () => { it("loads nothing the app alone may load", () => { const seen = reach(join(SRC, "watchMain.tsx")); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 31f03e0b..ce2a7439 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -32,18 +32,18 @@ const proxy = { "/healthz": proxyOpts, }; -// Two HTML entries: the app (index.html) and a public /watch/ link (watch.html), which loads +// Two HTML entries: the app (index.html) and a public share link (watch.html), which loads // only the player page. In a build the backend picks the file by path (`main.py`); this does the // same for `vite dev`, whose history fallback would otherwise hand /watch/… the app. const watchEntry = (): Plugin => ({ name: "siftlode-watch-entry", configureServer(server) { server.middlewares.use((req, _res, next) => { - if (req.url?.startsWith("/watch/")) req.url = "/watch.html"; + if (req.url && /^\/(?:watch|file|folder)\//.test(req.url)) req.url = "/watch.html"; next(); }); }, - // The server answers every /watch/ link with the built watch.html, so a build without it would + // The server answers every share link with the built watch.html, so a build without it would // ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here — // after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet. enforce: "post",