From 8383459f70b5583a0751a7f7bcecc4580e098a08 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 6 Nov 2026 00:09:39 +0100 Subject: [PATCH] fix: share, upload, crop, auto-skip, tag chips and See all hold what they claim - Share: a link that expired is never copied (the dialog and one-click Share pick a live one, else make one), says it expired, and the copy-on-open claim clears on the next copy. - A password-protected file link asks to open a file, not to watch a video. - Upload: Cancel, the close button or the backdrop while naming a folder makes no folder; a failed refetch keeps the target; a folder being made says so. - Crop: an infinite number is dropped, not a 500; an unprobed clip's size follows a shown-size crop; the edit_spec docs name frame_w/frame_h; the join and rotated paths are tested. - Auto-skip: the wait gives way on a marker too short for it, so the skip still fires. - Tag chips count the followed channels (subscribed_count) and a toggled chip keeps the focus. - Continue watching's See all sorts by last watched when in progress was already on. --- backend/app/downloads/edit.py | 24 ++- backend/app/downloads/links.py | 19 ++- backend/app/downloads/models.py | 4 +- backend/app/downloads/og.py | 6 +- backend/app/downloads/pipeline.py | 8 +- backend/app/reader/queries.py | 20 ++- backend/app/routes/public.py | 11 +- backend/app/routes/tags.py | 3 +- backend/tests/test_edit_crop_frame.py | 84 +++++++++- backend/tests/test_folder_links.py | 3 +- backend/tests/test_reader_service.py | 22 ++- backend/tests/test_share_paths.py | 11 ++ docs/DESIGN.md | 2 + frontend/src/components/ui/Modal.tsx | 1 + .../channel/ChannelTags.suggest.dom.test.tsx | 1 + frontend/src/i18n/locales/en/downloads.json | 2 + frontend/src/i18n/locales/hu/downloads.json | 2 + frontend/src/lib/api/tags.ts | 4 + frontend/src/lib/downloads.test.ts | 16 ++ frontend/src/lib/downloads.ts | 15 +- .../src/lib/plexPlayerHooks.skip.dom.test.ts | 16 ++ frontend/src/lib/plexPlayerHooks.ts | 21 ++- frontend/src/lib/useTags.dom.test.tsx | 1 + .../channels/TagFilterChips.dom.test.tsx | 75 ++++++++- .../src/modules/channels/TagFilterChips.tsx | 53 ++++++- .../channels/channelColumns.dom.test.tsx | 7 +- .../src/modules/channels/channelColumns.tsx | 2 +- .../channels/useTagSuggestions.dom.test.tsx | 1 + .../modules/files/ShareDialog.dom.test.tsx | 43 ++++- frontend/src/modules/files/ShareDialog.tsx | 36 +++-- .../modules/files/UploadDialog.dom.test.tsx | 68 ++++++++ frontend/src/modules/files/UploadDialog.tsx | 43 +++-- .../files/VideoEditor.crop.dom.test.tsx | 6 +- .../plex/PlexBrowse.seeAll.dom.test.tsx | 148 ++++++++++++++++++ frontend/src/modules/plex/PlexBrowse.tsx | 5 +- .../plex/PlexPlayer.controls.dom.test.tsx | 23 +++ frontend/src/watch/WatchPage.dom.test.tsx | 13 ++ frontend/src/watch/WatchPage.tsx | 17 +- frontend/src/watch/watchStrings.ts | 6 + 39 files changed, 760 insertions(+), 82 deletions(-) create mode 100644 frontend/src/lib/plexPlayerHooks.skip.dom.test.ts create mode 100644 frontend/src/modules/plex/PlexBrowse.seeAll.dom.test.tsx diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index c0e51e90..0686769e 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. @@ -65,8 +67,9 @@ def _crop_frame(crop: dict) -> dict | None: 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. - except (KeyError, TypeError, ValueError): + # 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 @@ -85,6 +88,19 @@ def crop_filter(crop: dict) -> str: ) +def crop_stored_size(crop: dict, src_w: int | None, src_h: int | None) -> tuple[int, int] | None: + """The stored size `crop_filter` cuts out of a `src_w` x `src_h` file, evened as `EVEN_FRAME` + evens it — the clip's size when ffprobe cannot tell. A crop counted in the shown picture is a + fraction of the file's frame, not its own pixel count; None when the source 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. @@ -97,7 +113,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( diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py index 68e5b6c5..f7c48724 100644 --- a/backend/app/downloads/links.py +++ b/backend/app/downloads/links.py @@ -127,18 +127,25 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool: # --- serialization ------------------------------------------------------------------------- -def share_path(db: Session, link: DownloadLink, token: str) -> str: - """The address a link is given out on: its path says what it 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. A file still being made reads as the video it mostly is.""" +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 f"/folder/{token}" + 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 f"/file/{token}" if kind and kind != mediakind.VIDEO else f"/watch/{token}" + 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: 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 626e4e1c..ba287231 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -77,9 +77,9 @@ def _watch_tags(token: str, db: Session) -> str | None: if link is None or linksmod.is_expired(link): return None url = f"{settings.app_base}{linksmod.share_path(db, link, token)}" - folder = link.folder_id is not None - # What the card calls it, as its address does (`links.share_path`). - video = "/watch/" in url + # 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( diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index 1ccbf85a..2b78c204 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -914,13 +914,13 @@ def _process_edit(job_id: int, asset_id: int) -> None: asset.size_bytes = size asset.container = out_ext asset.duration_s = total_s or asset.duration_s + cut = editmod.crop_stored_size(edit_spec["crop"], src_w, src_h) if crop else None 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/reader/queries.py b/backend/app/reader/queries.py index ad3c5353..4c938414 100644 --- a/backend/app/reader/queries.py +++ b/backend/app/reader/queries.py @@ -41,11 +41,23 @@ 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 — the rows the Channels page's tag filter shows. A link outlives an unsubscribe, + so the two differ.""" + subscribed = select(Subscription.channel_id).where(owned_by(Subscription.user_id, user)) + 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/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 index 98b151b4..70d3bbcb 100644 --- a/backend/tests/test_edit_crop_frame.py +++ b/backend/tests/test_edit_crop_frame.py @@ -20,8 +20,10 @@ 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) -> None: - """Red, with the picture's middle half green: a crop of that middle holds no red.""" +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", @@ -32,6 +34,13 @@ def _source(path: Path, size: str, box: str, sar: int) -> None: ], 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]: @@ -50,13 +59,14 @@ def _red_and_green(path: Path) -> tuple[int, int]: return red, green -def _cut(db, make_user, tmp_path, src: Path, crop: dict) -> Path: +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" - job = service.enqueue_edit(db, user.id, source, {"crop": crop}) + 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() @@ -66,6 +76,11 @@ def _cut(db, make_user, tmp_path, src: Path, crop: dict) -> Path: 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) @@ -110,3 +125,64 @@ def test_a_crop_stored_before_it_carried_its_frame_is_cut_as_it_always_was(): 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) + + +@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_reader_service.py b/backend/tests/test_reader_service.py index fa8b540e..81d551d4 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,30 @@ 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 test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user): + """An unsubscribe keeps the channel's tag links; the Channels page's filter lists only followed + channels, so its count must not hold the others.""" + user, other = make_user(), make_user() + tag = service.create_tag(db, user, "Music", "other", None) + for cid in ("UCa", "UCb", "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=other.id, channel_id="UCgone")) + for cid in ("UCa", "UCb", "UCgone"): + db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user")) + db.commit() + [(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id] + assert (count, subscribed) == (3, 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 index 3469a1df..0d347fc9 100644 --- a/backend/tests/test_share_paths.py +++ b/backend/tests/test_share_paths.py @@ -135,3 +135,14 @@ def test_a_card_of_a_video_stays_a_video(shared, 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 f4011479..c8ddabfd 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -863,4 +863,6 @@ of a full-width panel and nothing on the right. | 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found | | 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go | | 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in | +| 2026-11-04 | Tag chip counts (owner D5a) | A Channels tag chip shows how many followed channels it holds: a muted number after the name, no badge | The number in brackets after the name | The owner's pick; the number is a detail of the name, so it takes the name's own text, muted, rather than a `CountBadge` | | 2026-11-05 | Picked tag chips lead the row (owner c) | On the Channels tag row the picked chips come first, in the tags' order, then the rest | Keep the order and fold the unpicked first; mark the "+N" when it holds a pick | The row folds from its end, so a picked tag late in the list sat only inside "+N" with nothing on the row saying a filter was on | +| 2026-11-05 | A toggled tag chip keeps the focus (owner a) | A chip that moves when toggled takes the keyboard focus with it: to its new place, or to "+N" when it went into the closed fold | Keep a picked chip in the "+N" popover until it closes | The row always shows the filters as they are; the focus, not the order, was what got lost | diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 86964f22..762dc53e 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -231,6 +231,7 @@ export default function Modal({ size="sm" variant="ghost" onClick={onClose} + data-dismiss="" > 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/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 8c0ceb85..14fd3482 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -33,6 +33,7 @@ "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" }, @@ -113,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/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 21f02014..6457f66b 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -33,6 +33,7 @@ "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" }, @@ -113,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/tags.ts b/frontend/src/lib/api/tags.ts index fc1db31b..b1e0fb96 100644 --- a/frontend/src/lib/api/tags.ts +++ b/frontend/src/lib/api/tags.ts @@ -7,7 +7,11 @@ 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: the rows the Channels page's filter shows. */ + 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/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 5f7b10cb..63e7e303 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -434,8 +434,18 @@ export function usePlayerVolume(opts: { return { setVol, nudgeVolume, toggleMute, onWheel }; } -// How long a marker plays before an auto-skip's countdown starts (seconds). +// 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 @@ -484,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; @@ -543,14 +554,17 @@ export function useAutoSkip(opts: { if (autoSkipDelay <= 0) return doSkip(m); // immediate // 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. - let elapsed = -AUTO_SKIP_WAIT_S; + // 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.max(0, Math.min(1, elapsed / autoSkipDelay)); + const p = skipCountdown(elapsed, autoSkipDelay); setSkipProgress(p); if (p >= 1) { window.clearInterval(iv); @@ -573,6 +587,7 @@ export function useAutoSkip(opts: { doSkip, videoRef, activeMarkerRef, + absRef, activeAuto, ]); 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 b8e5b9ea..43538a97 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(); @@ -114,4 +115,76 @@ describe("TagFilterChips counts", () => { expect(name("alpha")?.textContent).toBe("alpha42"); expect(name("beta")?.textContent).toBe("beta0"); }); + + 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: along the row, out +// of the "+N" fold, or into it. 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)!; + + it("along the row: the chip keeps the focus where it now stands", () => { + fit.visible = 3; + render(); + press(named("gamma")); + expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([ + "gamma", + "alpha", + "beta", + ]); + expect(document.activeElement).toBe(named("gamma")); + }); + + it("out of the fold: picked in the popover, it is focused on the row", () => { + fit.visible = 1; + render(); + fireEvent.click(moreBtn()!); + const inFold = named("beta"); + press(inFold); + const onRow = named("beta"); + expect(onRow).not.toBe(inFold); + expect(document.activeElement).toBe(onRow); + }); + + 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="menu"]')).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 243883a0..64151afe 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"; @@ -39,9 +39,18 @@ function ChipText({ chip }: { chip: FilterChip }) { ); } -function TagChip({ chip }: { chip: FilterChip }) { +function TagChip({ chip, onToggled }: { chip: FilterChip; onToggled: (key: string) => 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); + chip.onToggle(); + }} + pressed={chip.active} + > ); @@ -104,6 +113,32 @@ export default function TagFilterChips({ 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), or along the row (a keyed move that drops the focus to ). The focus follows + // it: onto the chip where it now stands, or onto "+N" when it went into a closed fold. Held for + // the frame, since the fit re-decides in layout effects after the toggle's render. + const follow = useRef(null); + const foldRef = useRef(null); + const moreRef = useRef(null); + const onToggled = (chipKey: string) => { + follow.current = chipKey; + requestAnimationFrame(() => (follow.current = null)); + }; + useLayoutEffect(() => { + const chipKey = follow.current; + if (!chipKey) return; + const find = (box: HTMLElement | null) => + [...(box?.querySelectorAll("[data-chip-key]") ?? [])].find( + (b) => b.dataset.chipKey === chipKey, + ); + const el = find(rowRef.current) ?? find(foldRef.current) ?? moreRef.current; + if (el && document.activeElement !== el) 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 @@ -132,10 +167,10 @@ export default function TagFilterChips({
{chips.slice(0, visible).map((c) => ( - + ))} {hidden > 0 && ( - + +{hidden} )} @@ -146,9 +181,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 300033d7..80104292 100644 --- a/frontend/src/modules/channels/channelColumns.dom.test.tsx +++ b/frontend/src/modules/channels/channelColumns.dom.test.tsx @@ -188,7 +188,9 @@ describe("buildChannelColumns — Added", () => { }); describe("tagFilterChips", () => { - it("counts a tag's channels, and only a tag's", () => { + // 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", @@ -196,6 +198,7 @@ describe("tagFilterChips", () => { category: "topic", system: false, channel_count: 17, + subscribed_count: 15, }; const chips = tagFilterChips({ userTags: [tag], @@ -206,7 +209,7 @@ describe("tagFilterChips", () => { toggleNoTag: () => {}, t: ((k: string) => k) as never, }); - expect(chips.find((c) => c.label === "Tech")?.count).toBe(17); + 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(); }); diff --git a/frontend/src/modules/channels/channelColumns.tsx b/frontend/src/modules/channels/channelColumns.tsx index fca9084a..ab2b9abc 100644 --- a/frontend/src/modules/channels/channelColumns.tsx +++ b/frontend/src/modules/channels/channelColumns.tsx @@ -72,7 +72,7 @@ export function tagFilterChips(o: { ...o.userTags.map((tg) => ({ key: `t${tg.id}`, label: tg.name, - count: tg.channel_count, + count: tg.subscribed_count, active: o.picked.includes(String(tg.id)), onToggle: () => o.toggle(tg.id), })), 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/files/ShareDialog.dom.test.tsx b/frontend/src/modules/files/ShareDialog.dom.test.tsx index 42e6907d..5e8feb4c 100644 --- a/frontend/src/modules/files/ShareDialog.dom.test.tsx +++ b/frontend/src/modules/files/ShareDialog.dom.test.tsx @@ -110,24 +110,58 @@ describe("ShareDialog · opened to share", () => { , ); }; - const row = (id: number, url: string | null) => ({ + const row = (id: number, url: string | null, expires_at: string | null = null) => ({ id, token: "t", url, allow_download: true, has_password: false, - expires_at: null, + 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); - expect((await screen.findByRole("status")).textContent).toBe("downloads.share.linkCopied"); + 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); + }); + + // 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 () => { @@ -143,7 +177,8 @@ describe("ShareDialog · opened to share", () => { open(); await waitFor(() => expect(h.copyText).toHaveBeenCalled()); await screen.findByRole("button", { name: "downloads.share.copy" }); - expect(screen.queryByRole("status")).toBeNull(); + expect(status()).toBe(""); + expect(screen.queryByText("downloads.share.linkCopied")).toBeNull(); }); it("offers the link in a new tab, beside Copy", async () => { diff --git a/frontend/src/modules/files/ShareDialog.tsx b/frontend/src/modules/files/ShareDialog.tsx index f260b6f1..cf65128b 100644 --- a/frontend/src/modules/files/ShareDialog.tsx +++ b/frontend/src/modules/files/ShareDialog.tsx @@ -12,7 +12,9 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../. import { fetchLinks, invalidateLinks, + linkExpired, linksKey, + liveLink, makeLink, type LinkTarget, } from "../../lib/downloads"; @@ -80,11 +82,14 @@ function UserShare({ job }: { job: DownloadJob }) { function LinkRow({ link, copiedOnOpen, + onCopied, onChanged, }: { link: ShareLink; - /** The dialog put this link on the clipboard when it opened. */ + /** 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(); @@ -95,7 +100,9 @@ function LinkRow({ 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 }), @@ -124,7 +131,9 @@ function LinkRow({ if (link.allow_download) badges.push(t("downloads.share.downloadable")); if (link.expires_at) badges.push( - t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }), + t(linkExpired(link) ? "downloads.share.expiredOn" : "downloads.share.expiresOn", { + date: formatDate(link.expires_at, i18n.language), + }), ); return ( @@ -198,11 +207,9 @@ function LinkRow({
- {copiedOnOpen && ( -

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

- )} + {/* 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 })} @@ -314,7 +321,7 @@ function LinkShare({ target }: { target: LinkTarget }) { }); // 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); @@ -327,13 +334,19 @@ function LinkShare({ target }: { target: LinkTarget }) { useEffect(() => { if (!links.data || autoTried.current) return; autoTried.current = true; - if (links.data.length === 0) + // 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(links.data.find((l) => l.url)).then(setCopiedOnOpen); + else void copiedLink(live).then(setCopiedOnOpen); }, [links.data, createLink]); return (
+

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

{t("downloads.share.linkSection")}
@@ -360,6 +373,7 @@ function LinkShare({ target }: { target: LinkTarget }) { key={l.id} link={l} copiedOnOpen={copiedOnOpen === l.id} + onCopied={() => setCopiedOnOpen(null)} onChanged={invalidate} /> ))} diff --git a/frontend/src/modules/files/UploadDialog.dom.test.tsx b/frontend/src/modules/files/UploadDialog.dom.test.tsx index bb3f3b41..2ecb2dfd 100644 --- a/frontend/src/modules/files/UploadDialog.dom.test.tsx +++ b/frontend/src/modules/files/UploadDialog.dom.test.tsx @@ -186,6 +186,74 @@ describe("UploadDialog · where the file goes", () => { expect(await sentTo()).toBe(7); }); + // The name field saves on blur, and a press on Cancel or ✕ blurs it before the dialog closes. + it.each([ + ["Cancel", () => screen.getByText("downloads.actions.cancel")], + ["the close button", () => screen.getByRole("button", { name: "common.close" })], + ])("dismissing with %s while naming makes no folder", async (_n, dismiss) => { + const onClose = vi.fn(); + wrap(onClose); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + const field = await screen.findByLabelText("files.folder.newName"); + fireEvent.change(field, { target: { value: "Trips" } }); + const control = dismiss(); + fireEvent.pointerDown(control); + fireEvent.blur(field); + fireEvent.click(control); + expect(onClose).toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 20)); // a mutation calls its function a tick later + expect(h.createFolder).not.toHaveBeenCalled(); + }); + + it("leaving the name for anything else in the dialog makes the folder", async () => { + h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" }); + wrap(); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + const field = await screen.findByLabelText("files.folder.newName"); + fireEvent.change(field, { target: { value: "Trips" } }); + fireEvent.pointerDown(screen.getByText("downloads.upload.targetLabel")); + fireEvent.blur(field); + await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips")); + }); + + it("says a new folder is being made while Upload waits for it", async () => { + h.createFolder.mockReturnValue(new Promise(() => {})); + wrap(); + fireEvent.click(await target()); + fireEvent.click(await screen.findByTestId("upload-target-new")); + const field = await screen.findByLabelText("files.folder.newName"); + fireEvent.change(field, { target: { value: "Trips" } }); + fireEvent.keyDown(field, { key: "Enter" }); + expect(await screen.findByText("downloads.upload.makingFolder")).toBeTruthy(); + }); + + // A failed refetch keeps the list it had: the target the file goes to stays shown. + it("keeps the target when a later read of the folders fails", async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + expect((await target()).textContent).toContain("peat"); + h.fileFolders.mockRejectedValue(new Error("down")); + await qc.refetchQueries({ queryKey: ["file-folders"] }).catch(() => {}); + await waitFor(() => expect(qc.getQueryState(["file-folders"])?.status).toBe("error")); + expect((await target()).textContent).toContain("peat"); + expect(screen.queryByText("downloads.upload.targetFailed")).toBeNull(); + }); + it("an empty name makes no folder and leaves the target", async () => { wrap(vi.fn(), 5); fireEvent.click(await target()); diff --git a/frontend/src/modules/files/UploadDialog.tsx b/frontend/src/modules/files/UploadDialog.tsx index 764e713e..0a3e54f9 100644 --- a/frontend/src/modules/files/UploadDialog.tsx +++ b/frontend/src/modules/files/UploadDialog.tsx @@ -88,6 +88,20 @@ export default function UploadDialog({ refocusTarget.current = false; targetBox.current?.querySelector("button")?.focus(); }, [naming]); + // A press on Cancel, the ✕ or the backdrop blurs the name field before the dialog closes, and the + // field saves on blur: that press is "never mind", not "make it". The press is seen before the + // blur it causes, in every browser (which control takes the focus, if any, differs: Safari + // focuses no button). + const dismissing = useRef(false); + useEffect(() => { + const onPress = (e: PointerEvent) => { + const el = e.target instanceof Element ? e.target : null; + dismissing.current = + !!el && (!!el.closest("[data-dismiss]") || el.matches('[role="dialog"]')); + }; + document.addEventListener("pointerdown", onPress, true); + return () => document.removeEventListener("pointerdown", onPress, true); + }, []); const makeFolder = useMutation({ mutationFn: (folder: string) => api.createFolder(folder), onSuccess: (made) => { @@ -113,8 +127,10 @@ export default function UploadDialog({ const pickTarget = ([value]: string[]) => { // After the menu has closed: it hands the focus back as it goes, which took it off a field already // there, and a name field that loses the focus is done, with no name. - if (value === NEW_FOLDER) window.setTimeout(() => setNaming(true)); - else if (value === UPLOADS) setTarget(null); + if (value === NEW_FOLDER) { + dismissing.current = false; // a press before the naming began (Cancel aborting an upload) is not this one + window.setTimeout(() => setNaming(true)); + } else if (value === UPLOADS) setTarget(null); else setTarget(Number(value)); }; // A done file is in Files, opened on it; one the worker still converts is in the tray until it is. @@ -234,11 +250,11 @@ export default function UploadDialog({ maxLength={DISPLAY_NAME_MAX} /> -

- {t("downloads.upload.targetLabel")} -

- {foldersQ.isError ? ( - // The file still goes where the dialog was opened on: only the list could not be read. +

{t("downloads.upload.targetLabel")}

+ {foldersQ.isError && !foldersQ.data ? ( + // The file still goes where the dialog was opened on: only the list could not be read. A + // failed REFETCH keeps the list it had (react-query sets isError beside the data): the + // target the file goes to stays shown, as the rail does it. void foldersQ.refetch()}> {t("downloads.upload.targetFailed")} @@ -249,12 +265,20 @@ export default function UploadDialog({ onDone={(folder, byKey) => { refocusTarget.current = byKey; setNaming(false); + if (!byKey && dismissing.current) return; if (folder?.trim()) makeFolder.mutate(folder.trim()); }} /> ) : ( -
- {foldersQ.data && !busy && ( + // The select's own height held while the list loads, so it does not push the form down. +
+ {makeFolder.isPending && ( + // Upload waits for it (`targetSettling`): this says why. + + {t("downloads.upload.makingFolder", { name: makeFolder.variables })} + + )} + {foldersQ.data && !busy && !makeFolder.isPending && ( (busy ? abortRef.current?.abort() : onClose())} + data-dismiss="" > {t("downloads.actions.cancel")} diff --git a/frontend/src/modules/files/VideoEditor.crop.dom.test.tsx b/frontend/src/modules/files/VideoEditor.crop.dom.test.tsx index 5c8d86cf..e7e54917 100644 --- a/frontend/src/modules/files/VideoEditor.crop.dom.test.tsx +++ b/frontend/src/modules/files/VideoEditor.crop.dom.test.tsx @@ -31,14 +31,18 @@ afterEach(() => { vi.restoreAllMocks(); }); +// Wide pixels: stored 960 across, shown 1920 — the browser reports the shown size, the asset the +// stored one, and the two must not be mistaken for each other. const job = { id: 7, display_name: "Talk", - asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" }, + asset: { duration_s: 100, width: 960, height: 1080, title: "Talk" }, } as unknown as DownloadJob; describe("VideoEditor crop", () => { it("sends the size the crop was measured on with it", async () => { + vi.spyOn(HTMLVideoElement.prototype, "videoWidth", "get").mockReturnValue(1920); + vi.spyOn(HTMLVideoElement.prototype, "videoHeight", "get").mockReturnValue(1080); const enqueue = vi .spyOn(api, "enqueueEdit") .mockResolvedValue({ id: 8 } as unknown as DownloadJob); diff --git a/frontend/src/modules/plex/PlexBrowse.seeAll.dom.test.tsx b/frontend/src/modules/plex/PlexBrowse.seeAll.dom.test.tsx new file mode 100644 index 00000000..ee6eb4ed --- /dev/null +++ b/frontend/src/modules/plex/PlexBrowse.seeAll.dom.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +// +// Discover's See all opens the list its rail is the start of, sorted the way the rail is: Recently +// added newest first, Continue watching in progress by last watched. The rails only call back; what +// each opens is PlexBrowse's, against the Plex store's show and sort, which outlive the Discover / +// Browse toggle — so a sort picked earlier must not survive the press. +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./plexSavedViews", async (importOriginal) => ({ + ...(await importOriginal()), + usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }), +})); +// The rails themselves are PlexDiscover's own test: here only their two See all presses. +vi.mock("./PlexDiscover", () => ({ + default: (p: { onSeeAllRecent: () => void; onSeeAllContinue: () => void }) => ( +
+ + +
+ ), +})); + +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 cfc1e27b..9ca2e8a2 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -592,7 +592,10 @@ function PlexBrowseView({ sub }: { sub: ReturnType sub.open({ kind: "grid" }); }} onSeeAllContinue={() => { - setShow("in_progress"); + // 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/PlexPlayer.controls.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx index c1405d2b..71628250 100644 --- a/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx @@ -1169,6 +1169,29 @@ describe("PlexPlayer — clip mode", () => { 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"); }); }); diff --git a/frontend/src/watch/WatchPage.dom.test.tsx b/frontend/src/watch/WatchPage.dom.test.tsx index 3a47d563..8f2b4e95 100644 --- a/frontend/src/watch/WatchPage.dom.test.tsx +++ b/frontend/src/watch/WatchPage.dom.test.tsx @@ -281,6 +281,19 @@ describe("WatchPage honest failures", () => { return page; } + it.each([ + [{ kind: "file", video: true }, "This video is password-protected", "Watch"], + [{ kind: "file", video: false }, "This file is password-protected", "Open"], + [{ kind: "folder", video: false }, "This folder is password-protected", "Open"], + [{}, "This video is password-protected", "Watch"], + ])("a password link that opens %o asks in its words", async (kind, title, button) => { + window.history.replaceState({}, "", "/file/tok-lock"); + mockMeta({ needs_password: true, ...kind }); + const page = render(); + await page.findByText(title); + expect(page.getByRole("button", { name: button })).toBeTruthy(); + }); + it("too many password tries says so, and keeps the form", async () => { window.history.replaceState({}, "", "/watch/tok-429"); const page = await tryPassword(async () => res(429)); diff --git a/frontend/src/watch/WatchPage.tsx b/frontend/src/watch/WatchPage.tsx index b430bad2..6f00b478 100644 --- a/frontend/src/watch/WatchPage.tsx +++ b/frontend/src/watch/WatchPage.tsx @@ -64,7 +64,7 @@ type Meta = FileMeta | FolderMeta; // What the link answers before it is opened: a password link says only which kind it is. type Answer = | (Meta & { needs_password?: false }) - | { needs_password: true; kind?: "file" | "folder" }; + | { needs_password: true; kind?: "file" | "folder"; video?: boolean }; // Compact display of a URL: drop the protocol + trailing slash so it fits on one line. function displayUrl(url: string): string { @@ -97,8 +97,9 @@ export default function WatchPage() { "loading" | "ready" | "password" | "gone" | "preparing" | "error" >(token ? "loading" : "gone"); const [meta, setMeta] = useState(null); - // A password link's kind, for its title, until it is unlocked. - const [locked, setLocked] = useState<"file" | "folder">("file"); + // What a password link opens, for its prompt's words, until it is unlocked. A server from before + // it said `video` only had video links' words, so a file without it reads as one. + const [locked, setLocked] = useState<"video" | "file" | "folder">("video"); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const errorId = useId(); @@ -124,7 +125,7 @@ export default function WatchPage() { .then((m) => { if (typeof m === "string") setStatus(m); else if (m.needs_password) { - setLocked(m.kind ?? "file"); + setLocked(m.kind === "folder" ? "folder" : m.video === false ? "file" : "video"); setStatus("password"); } else { setMeta(m); @@ -246,7 +247,11 @@ export default function WatchPage() {
{" "} - {locked === "folder" ? T.passwordTitleFolder : T.passwordTitle} + {locked === "folder" + ? T.passwordTitleFolder + : locked === "file" + ? T.passwordTitleFile + : T.passwordTitle}
- {T.watch} + {locked === "video" ? T.watch : T.open}
diff --git a/frontend/src/watch/watchStrings.ts b/frontend/src/watch/watchStrings.ts index d2730928..8bf225d8 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`), @@ -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`),