diff --git a/CHANGELOG.md b/CHANGELOG.md
index 85cd6411..76a1e8f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ 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.115.0 — 2026-11-01
+
+### New
+
+- Clear watch progress is on every YouTube video's menu, on Home, in playlists and in the player's related videos. It forgets where you left a started video and changes nothing else.
+- Channels have an Added column, and an About card opens only when the pointer rests on it.
+- In Files, a press anywhere on a row plays the file.
+- The feed's toolbar pills are named by a glyph, with Sort and View on the filters' row.
+
+### Fixed
+
+- In a play queue, Previous and Next pass over a video that can't play, in the direction you were going, and say why when only such videos are left.
+- A Facebook download titled only “Facebook” now takes the post's text as its name.
+- The player's big view keeps its way out in sight on a tablet, and Tab no longer lands on controls hidden under it.
+
## 0.114.3 — 2026-11-01
### Fixed
diff --git a/VERSION b/VERSION
index c672d9c2..bdc80994 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.114.3
+0.115.0
diff --git a/backend/app/downloads/adapters.py b/backend/app/downloads/adapters.py
index 7878bffe..ee693f6c 100644
--- a/backend/app/downloads/adapters.py
+++ b/backend/app/downloads/adapters.py
@@ -66,6 +66,11 @@ class SourceAdapter:
"""The download's raw title, before `titles.normalize_title` cleans it. Generic: yt-dlp's."""
return info.get("title")
+ def is_placeholder_title(self, title: str | None) -> bool:
+ """Whether `title` is a stand-in the source gives in place of the real one (worth one more,
+ signed-out read of the page). Generic: never."""
+ return False
+
def uploader_url(self, info: dict) -> str | None:
"""The channel/uploader page URL. Generic: whatever yt-dlp exposes directly (YouTube gives
`channel_url`), else None (reddit / a raw media URL have no derivable channel page)."""
@@ -201,11 +206,17 @@ class FacebookAdapter(SourceAdapter):
# Measured (2026-09-14): a SIGNED-IN read gives the placeholder "Video" (plain and as Chrome), and an
# 18+ reel only readable as Chrome gives "Facebook"; the post's text is still in `description`. A
# signed-out read titles the page with that same text behind the engagement counts, cut short.
+ # Measured (2026-11-01, yt-dlp 2026.08.19): since 2026-10-28 every signed-in reel read gives
+ # "Facebook" and NO description, while a signed-out read of the same reel still carries the text —
+ # so a placeholder left with nothing behind it gets one signed-out read (`backfill.public_title_info`).
_PLACEHOLDER_TITLES = frozenset({"video", "facebook"})
+ def is_placeholder_title(self, title: str | None) -> bool:
+ return (title or "").strip().lower() in self._PLACEHOLDER_TITLES
+
def title(self, info: dict) -> str | None:
raw = info.get("title")
- if (raw or "").strip().lower() not in self._PLACEHOLDER_TITLES:
+ if not self.is_placeholder_title(raw):
return raw
line = next(
(ln.strip() for ln in (info.get("description") or "").splitlines() if ln.strip()), ""
diff --git a/backend/app/downloads/backfill.py b/backend/app/downloads/backfill.py
index ae400902..a79ed4e2 100644
--- a/backend/app/downloads/backfill.py
+++ b/backend/app/downloads/backfill.py
@@ -5,7 +5,10 @@ auto-detected channel becomes a clickable link) and `poster_path` (an ffmpeg fra
thumbnail-less sources, so the card / watch page / link preview still shows an image). Best-effort
and safe to re-run: it only touches assets still missing the field, and only re-fetches YouTube /
Facebook sources (a direct media URL like a reddit HLS manifest carries no channel and its signed
-URL may have expired — those just get a locally-generated poster instead).
+URL may have expired — those just get a locally-generated poster instead). A Facebook download
+titled with the source's placeholder ("Facebook", "Video") is retitled from a signed-out read of its
+page, and so is each download name that only repeated the placeholder. That page read
+(`public_title_info`) also titles every new download, from the worker.
Run once per environment:
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
@@ -14,7 +17,8 @@ Run once per environment:
from urllib.parse import urlsplit
import yt_dlp
-from sqlalchemy import select
+from sqlalchemy import select, update
+from sqlalchemy.orm import Session
from app import logs
from app.config import settings
@@ -22,12 +26,14 @@ from app.db import SessionLocal
from app.downloads import adapters
from app.downloads import edit as editmod
from app.downloads import formats, service
-from app.downloads.models import MediaAsset
+from app.downloads.models import DownloadJob, MediaAsset
+from app.models import DISPLAY_TEXT_MAX
+from app.titles import normalize_title
log = logs.get_logger(__name__)
-def _extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
+def extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
"""Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube
needs the POT/player-client strategy even just to extract)."""
with formats.hiding(url):
@@ -62,8 +68,43 @@ def _extract_meta_scoped(url: str, adapter: adapters.SourceAdapter, asset_id: in
return None
+def public_title_info(info: dict, url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict:
+ """`info` to title a download from: itself, unless its title is a source's placeholder with
+ nothing behind it (a signed-in Facebook read: "Facebook", no description). Then one signed-out,
+ metadata-only read of the page lends its text — the description, else its title — as the
+ description the adapter takes a placeholder's title from. Nothing better there: `info` as it was."""
+ if not adapter.is_placeholder_title(adapter.title(info)):
+ return info
+ public = extract_meta(url, adapter, asset_id)
+ text = public and (public.get("description") or public.get("title"))
+ return {**info, "description": text} if text else info
+
+
+def _retitle(db: Session, a: MediaAsset, adapter: adapters.SourceAdapter, url: str) -> bool:
+ """Swap a placeholder title kept from an earlier download for the page's text, and the download
+ names that only repeated it. A name its owner gave (anything else) stays."""
+ if not adapter.is_placeholder_title(a.title):
+ return False
+ info = public_title_info({"title": a.title, "description": None}, url, adapter, a.id)
+ named = normalize_title(adapter.title(info))
+ if not named or adapter.is_placeholder_title(named):
+ return False
+ # One conditional write, not a read and a compare: a name its owner gives while the page is read
+ # is not overwritten by what was read before it. The asset first, as every other writer of the
+ # two locks them (`service.lock_own_job`): the session does not flush by itself.
+ placeholder = a.title
+ a.title = named
+ db.flush()
+ db.execute(
+ update(DownloadJob)
+ .where(DownloadJob.asset_id == a.id, DownloadJob.display_name == placeholder)
+ .values(display_name=named[:DISPLAY_TEXT_MAX])
+ )
+ return True
+
+
def run() -> dict:
- filled_url = filled_poster = 0
+ filled_url = filled_poster = retitled = 0
with SessionLocal() as db:
assets = (
db.execute(
@@ -78,7 +119,7 @@ def run() -> dict:
adapter = adapters.resolve_adapter(a.source_kind, a.source_ref)
if a.uploader_url is None and adapter.wants_metadata_backfill():
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
- info = _extract_meta(url, adapter, a.id)
+ info = extract_meta(url, adapter, a.id)
uu = adapter.uploader_url(info) if info else None
if uu:
a.uploader_url = uu
@@ -86,6 +127,14 @@ def run() -> dict:
log.info(
"asset %s uploader_url filled", a.id
) # the URL names what they downloaded
+ if adapter.wants_metadata_backfill():
+ url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
+ if _retitle(db, a, adapter, url):
+ # Before the poster step: it can cut a frame for 30 s, and these locks held
+ # across it block a rename and deadlock a delete.
+ db.commit()
+ retitled += 1
+ log.info("asset %s retitled", a.id) # the title names what they downloaded
if a.poster_path is None:
# Record a self-hosted poster for every download (the reliable og:image source):
# ensure_poster returns the existing .jpg thumbnail sidecar, or cuts a frame.
@@ -99,6 +148,7 @@ def run() -> dict:
"assets": len(assets),
"filled_uploader_url": filled_url,
"filled_poster": filled_poster,
+ "retitled": retitled,
}
log.info("backfill done: %s", result)
return result
diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py
index 6dd29a92..1ccbf85a 100644
--- a/backend/app/downloads/pipeline.py
+++ b/backend/app/downloads/pipeline.py
@@ -7,6 +7,7 @@ from datetime import date, datetime, timezone
from pathlib import Path
import yt_dlp
+from sqlalchemy import update
from yt_dlp.utils import download_range_func
from app import logs, sysconfig
@@ -16,6 +17,7 @@ from app.downloads import clip as clipmod
from app.downloads import edit as editmod
from app.downloads import errors as dlerrors
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
+from app.downloads import backfill
from app.downloads import service, storage
from app.downloads import urlguard
from app.downloads.jobdb import (
@@ -585,9 +587,14 @@ def _download(
# missing duration hid the trim action. Probed before the sidecars so the .nfo runtime has it
# too. Same probe the upload ingest uses.
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
+ # A placeholder title with nothing behind it ("Facebook" from a signed-in read) borrows the
+ # page's text from one signed-out read; the download itself stays the signed-in one. The page
+ # yt-dlp read, not the link queued: a group post's link is rewritten to its video, and
+ # signed out the post itself is the login wall.
+ titled = backfill.public_title_info(info, info.get("webpage_url") or url, adapter, asset_id)
meta = storage.MediaMeta(
video_id=info.get("id") or source_ref,
- title=normalize_title(adapter.title(info)) or source_ref,
+ title=normalize_title(adapter.title(titled)) or source_ref,
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
upload_date=_parse_upload_date(info.get("upload_date")),
uniqueid_type=adapter.nfo_uniqueid_type(),
@@ -1116,14 +1123,20 @@ def _process_clip(job_id: int, asset_id: int) -> None:
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
placeholder = service.clip_title(None, None)
+ named = None
if asset and fetched_title and asset.title == placeholder:
named = service.clip_title(fetched_title, None)
asset.title = named
- if job_row := db.get(DownloadJob, job_id):
- if job_row.display_name == placeholder:
- fields["display_name"] = named[:DISPLAY_TEXT_MAX]
title = asset.title if asset else None
finalized = service.finalize_if_running(db, job_id, **fields)
+ if finalized and named:
+ # Only a name that still repeats the placeholder, in one conditional write: one its
+ # owner gave meanwhile stays.
+ db.execute(
+ update(DownloadJob)
+ .where(DownloadJob.id == job_id, DownloadJob.display_name == placeholder)
+ .values(display_name=named[:DISPLAY_TEXT_MAX])
+ )
db.commit()
if finalized:
_notify_download_done(db, user_id, title, job_id, kind="edit")
diff --git a/backend/app/reader/service.py b/backend/app/reader/service.py
index fa651005..1041b822 100644
--- a/backend/app/reader/service.py
+++ b/backend/app/reader/service.py
@@ -255,17 +255,6 @@ def mark(db: Session, user: User, video_id: str, status: str) -> None:
)
-def clear_state(db: Session, user: User, video_id: str) -> None:
- """The video back to pristine for this user: status, resume point and `watched_at` all go.
- Playlist membership lives elsewhere and stays."""
- lock_takeout(db, user) # as `unmark`
- row = queries.video_state(db, user, video_id)
- if row is not None:
- db.delete(row)
- _remember_unmark(db, user, video_id)
- db.flush()
-
-
def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
"""The player's resume point. 0 clears it: a row holding nothing else goes, one with a status
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here.
diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py
index 284ea366..beedfa9e 100644
--- a/backend/app/routes/channels.py
+++ b/backend/app/routes/channels.py
@@ -76,6 +76,10 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_
"details_synced": ch.details_synced_at is not None,
"recent_synced": ch.recent_synced_at is not None,
"backfill_done": ch.backfill_done,
+ # When the follow reached this list: written by the subscription import the run that
+ # first saw it, or by a subscribe made here — not the channel row's own date, which an
+ # explored or discovered channel had before it was followed.
+ "added_at": iso(sub.created_at),
}
for sub, ch in rows
]
diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py
index f36294dd..43581896 100644
--- a/backend/app/routes/feed.py
+++ b/backend/app/routes/feed.py
@@ -204,22 +204,21 @@ def set_video_state(
return {"ok": True, "video_id": video_id, "status": status}
-@router.delete("/videos/{video_id}/state")
-def clear_video_state(
+@router.delete("/videos/{video_id}/progress")
+def clear_video_progress(
video_id: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
- """Reset a video to pristine for this user — drop the whole VideoState row (status,
- watch position and watched_at), as if it had never been opened. Saved/playlist membership
- lives elsewhere and is untouched."""
+ """Forget where the user left a video: its resume point goes, its status (watched, hidden) stays.
+ Saved/playlist membership lives elsewhere and is untouched."""
try:
- service.clear_state(db, user, video_id)
+ service.set_progress(db, user, video_id, 0)
db.commit()
- # silent-ok: the row went under the delete, which is the outcome the delete wanted.
+ # silent-ok: the row went under the write, which is the outcome the write wanted.
except StaleDataError:
db.rollback()
- return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
+ return {"ok": True, "video_id": video_id, "position_seconds": 0}
@router.post("/videos/{video_id}/progress")
diff --git a/backend/tests/test_catalog_routes.py b/backend/tests/test_catalog_routes.py
index e144433a..01be059a 100644
--- a/backend/tests/test_catalog_routes.py
+++ b/backend/tests/test_catalog_routes.py
@@ -72,3 +72,23 @@ def test_the_sync_status_counts_the_callers_subscriptions(db, make_user, as_user
assert (body["subscriptions"], body["channels_total"]) == (1, 1)
mine = as_user(user).get("/api/sync/my-status").json()
assert (mine["channels_total"], mine["channels_recent_pending"]) == (1, 1)
+
+
+def test_a_channel_lists_when_it_entered_this_users_list_not_when_the_channel_did(
+ db, make_user, as_user
+):
+ from datetime import datetime, timezone
+
+ user = make_user()
+ db.add(Channel(id="UCa", title="A", created_at=datetime(2026, 6, 10, tzinfo=timezone.utc)))
+ db.flush()
+ db.add(
+ Subscription(
+ user_id=user.id,
+ channel_id="UCa",
+ created_at=datetime(2026, 10, 12, 8, 30, tzinfo=timezone.utc),
+ )
+ )
+ db.commit()
+ listed = as_user(user).get("/api/channels").json()
+ assert [(c["id"], c["added_at"]) for c in listed] == [("UCa", "2026-10-12T08:30:00+00:00")]
diff --git a/backend/tests/test_facebook_titles.py b/backend/tests/test_facebook_titles.py
new file mode 100644
index 00000000..25372183
--- /dev/null
+++ b/backend/tests/test_facebook_titles.py
@@ -0,0 +1,299 @@
+"""A Facebook download's title when the signed-in read gives only a placeholder.
+
+Measured on prod (2026-11-01): every Facebook download since 2026-10-28 is titled "Facebook" — the
+signed-in read (the owner's cookie) now gives that placeholder and NO description, so the adapter had
+no post text to fall back on, and the download name copied the placeholder too. A signed-out read of
+the same reel (yt-dlp 2026.08.19, as on prod) still carries the post's text in its description.
+"""
+
+from sqlalchemy import select, update
+
+from app.config import settings
+from app.db import SessionLocal
+from app.downloads import adapters, backfill, pipeline
+from app.downloads.models import DownloadJob, MediaAsset
+
+_URL = "https://www.facebook.com/reel/1860682468422402/"
+# The page yt-dlp says it read for it (`webpage_url`); a group post's link resolves the same way.
+_PAGE = "https://www.facebook.com/watch/?v=1860682468422402"
+# The measured signed-out read of that reel.
+_PUBLIC = {
+ "title": "163K reactions · 14K shares | sorry not helping #workhumor #officelife | Work dot exe",
+ "description": "sorry not helping\n\n#workhumor #officelife",
+ "uploader": "Work dot exe",
+}
+
+
+def _fake_ydl(monkeypatch, outcome):
+ """yt-dlp faked at its `YoutubeDL` boundary: records each read (its options and its `url`),
+ answers `outcome`."""
+ seen: list[dict] = []
+
+ class FakeYDL:
+ def __init__(self, opts):
+ self.opts = opts
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+ def extract_info(self, url, download):
+ seen.append({**self.opts, "url": url})
+ return outcome
+
+ monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", FakeYDL)
+ return seen
+
+
+def test_a_placeholder_with_nothing_behind_it_takes_the_signed_out_pages_text(monkeypatch):
+ seen = _fake_ydl(monkeypatch, _PUBLIC)
+ fb = adapters.FacebookAdapter()
+
+ info = backfill.public_title_info({"title": "Facebook", "description": None}, _URL, fb, 7)
+
+ assert fb.title(info) == "sorry not helping"
+ # One metadata-only read, and signed out: no cookie goes with it.
+ assert len(seen) == 1
+ assert seen[0]["skip_download"] is True
+ assert not seen[0].get("cookiefile")
+
+
+def test_a_real_title_reads_nothing_more(monkeypatch):
+ seen = _fake_ydl(monkeypatch, _PUBLIC)
+ info = {"title": "Funny Dark Humor Dad Joke!", "description": None}
+
+ assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
+ assert seen == []
+
+
+def test_a_placeholder_with_the_posts_text_behind_it_reads_nothing_more(monkeypatch):
+ seen = _fake_ydl(monkeypatch, _PUBLIC)
+ info = {"title": "Video", "description": "Office jokes, part two"}
+
+ assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
+ assert seen == []
+
+
+def test_a_placeholder_the_signed_out_page_cannot_better_stays(monkeypatch):
+ _fake_ydl(monkeypatch, {"title": "Facebook", "description": None})
+ fb = adapters.FacebookAdapter()
+
+ info = backfill.public_title_info({"title": "Facebook", "description": None}, _URL, fb, 7)
+
+ assert fb.title(info) == "Facebook"
+
+
+def test_another_source_is_never_read_again(monkeypatch):
+ seen = _fake_ydl(monkeypatch, _PUBLIC)
+ info = {"title": "Video", "description": None}
+
+ assert backfill.public_title_info(info, _URL, adapters.SourceAdapter(), 7) is info
+ assert seen == []
+
+
+def test_the_backfill_retitles_a_placeholder_download_and_the_names_that_repeated_it(
+ db, make_user, monkeypatch
+):
+ seen = _fake_ydl(monkeypatch, _PUBLIC)
+ owner, other = make_user(), make_user()
+ asset = MediaAsset(
+ source_kind="url",
+ source_ref=_URL,
+ format_sig="fb",
+ status="ready",
+ rel_path="facebook/x.mp4",
+ poster_path="facebook/x.jpg",
+ uploader_url="https://www.facebook.com/1",
+ title="Facebook",
+ ref_count=2,
+ )
+ db.add(asset)
+ db.flush()
+ copied = DownloadJob(
+ user_id=owner.id,
+ asset_id=asset.id,
+ source_kind="url",
+ source_ref=_URL,
+ status="done",
+ queue_pos=0,
+ profile_snapshot={},
+ display_name="Facebook",
+ )
+ renamed = DownloadJob(
+ user_id=other.id,
+ asset_id=asset.id,
+ source_kind="url",
+ source_ref=_URL,
+ status="done",
+ queue_pos=0,
+ profile_snapshot={},
+ display_name="Office jokes",
+ )
+ db.add_all([copied, renamed])
+ db.commit()
+
+ assert backfill.run()["retitled"] == 1
+ assert len(seen) == 1
+
+ db.expire_all()
+ assert db.get(MediaAsset, asset.id).title == "sorry not helping"
+ assert db.get(DownloadJob, copied.id).display_name == "sorry not helping"
+ assert db.get(DownloadJob, renamed.id).display_name == "Office jokes"
+ # Run again: nothing left to retitle, and no page read for it.
+ assert backfill.run()["retitled"] == 0
+ assert len(seen) == 1
+
+
+def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
+ # The owner renames the download while the backfill reads the page: the owner's name wins, it is
+ # not overwritten by what was read before it.
+ _fake_ydl(monkeypatch, _PUBLIC)
+ owner = make_user()
+ asset = MediaAsset(
+ source_kind="url",
+ source_ref=_URL,
+ format_sig="fb",
+ status="ready",
+ rel_path="facebook/x.mp4",
+ uploader_url="https://www.facebook.com/1",
+ title="Facebook",
+ ref_count=1,
+ )
+ db.add(asset)
+ db.flush()
+ job = DownloadJob(
+ user_id=owner.id,
+ asset_id=asset.id,
+ source_kind="url",
+ source_ref=_URL,
+ status="done",
+ queue_pos=0,
+ profile_snapshot={},
+ display_name="Facebook",
+ )
+ db.add(job)
+ db.commit()
+
+ read_page = backfill.public_title_info
+
+ def renamed_while_reading(*args):
+ with SessionLocal() as other:
+ other.execute(
+ update(DownloadJob)
+ .where(DownloadJob.id == job.id)
+ .values(display_name="Office jokes")
+ )
+ other.commit()
+ return read_page(*args)
+
+ monkeypatch.setattr(backfill, "public_title_info", renamed_while_reading)
+ monkeypatch.setattr(backfill.editmod, "ensure_poster", lambda a, root: None)
+
+ assert backfill.run()["retitled"] == 1
+
+ db.expire_all()
+ assert db.get(DownloadJob, job.id).display_name == "Office jokes"
+
+
+def test_the_poster_step_holds_no_row_lock(db, make_user, monkeypatch):
+ # It can cut a frame for 30 s. A delete locks the asset and then the job: with the job held here
+ # and the asset still to write, that is a deadlock, and a rename waits out the whole cut.
+ _fake_ydl(monkeypatch, _PUBLIC)
+ owner = make_user()
+ asset = MediaAsset(
+ source_kind="url",
+ source_ref=_URL,
+ format_sig="fb",
+ status="ready",
+ rel_path="facebook/x.mp4",
+ uploader_url="https://www.facebook.com/1",
+ title="Facebook",
+ ref_count=1,
+ )
+ db.add(asset)
+ db.flush()
+ job = DownloadJob(
+ user_id=owner.id,
+ asset_id=asset.id,
+ source_kind="url",
+ source_ref=_URL,
+ status="done",
+ queue_pos=0,
+ profile_snapshot={},
+ display_name="Facebook",
+ )
+ db.add(job)
+ db.commit()
+ asset_id, job_id = asset.id, job.id
+
+ def poster_finds_rows_free(a, root):
+ with SessionLocal() as other:
+ other.execute(
+ select(MediaAsset).where(MediaAsset.id == asset_id).with_for_update(nowait=True)
+ )
+ other.execute(
+ select(DownloadJob).where(DownloadJob.id == job_id).with_for_update(nowait=True)
+ )
+ other.rollback()
+ return None
+
+ monkeypatch.setattr(backfill.editmod, "ensure_poster", poster_finds_rows_free)
+
+ assert backfill.run()["retitled"] == 1
+
+
+def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
+ db, make_user, monkeypatch, tmp_path
+):
+ # The whole download path with yt-dlp and the media tools faked: the signed-in read gives only
+ # the placeholder, and the file and the download's name get the post's text.
+ seen = _fake_ydl(monkeypatch, _PUBLIC)
+ monkeypatch.setattr(settings, "files_root", str(tmp_path))
+ monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
+ monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
+ monkeypatch.setattr(
+ pipeline.probe, "resolve_media_facts", lambda produced, info: ("h264", "aac", 720, 1280, 9)
+ )
+ monkeypatch.setattr(pipeline.editmod, "thumbnail_to_jpg", lambda *a: None)
+ monkeypatch.setattr(pipeline.editmod, "ensure_poster", lambda *a: None)
+ owner = make_user()
+ asset = MediaAsset(
+ source_kind="url", source_ref=_URL, format_sig="fb", status="downloading", ref_count=1
+ )
+ db.add(asset)
+ db.flush()
+ job = DownloadJob(
+ user_id=owner.id,
+ asset_id=asset.id,
+ source_kind="url",
+ source_ref=_URL,
+ status="running",
+ queue_pos=0,
+ profile_snapshot={},
+ )
+ db.add(job)
+ db.commit()
+
+ def signed_in_read(job_id, asset_id, url, spec, staging, adapter, **kw):
+ f = staging / "1860682468422402.mp4"
+ f.write_bytes(b"v" * 100)
+ return {
+ "id": "1860682468422402",
+ "title": "Facebook",
+ "uploader": "Work dot exe",
+ "webpage_url": _PAGE,
+ "requested_downloads": [{"filepath": str(f)}],
+ }
+
+ monkeypatch.setattr(pipeline, "_extract_with_fallback", signed_in_read)
+ pipeline._download(job.id, asset.id, "url", _URL, {}, owner.id)
+
+ # The signed-out read goes to the page yt-dlp read, not the link queued: a group post's link
+ # resolves to its video, and signed out the post itself is the login wall.
+ assert [r["url"] for r in seen] == [_PAGE]
+ db.expire_all()
+ assert db.get(DownloadJob, job.id).status == "done"
+ assert db.get(MediaAsset, asset.id).title == "sorry not helping"
+ assert db.get(DownloadJob, job.id).display_name == "sorry not helping"
diff --git a/backend/tests/test_failure_lines.py b/backend/tests/test_failure_lines.py
index 4e43e501..e5122140 100644
--- a/backend/tests/test_failure_lines.py
+++ b/backend/tests/test_failure_lines.py
@@ -368,7 +368,7 @@ def test_the_metadata_backfill_names_the_asset_and_not_the_download(sink, monkey
)
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing)
- assert backfill._extract_meta(url, adapters.YouTubeAdapter(), 11) is None
+ assert backfill.extract_meta(url, adapters.YouTubeAdapter(), 11) is None
lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]]
assert lines and all("dQw4w9WgXcQ" not in json.dumps(o) for o in lines)
@@ -393,7 +393,7 @@ def test_the_metadata_backfill_takes_out_a_generic_name_whatever_it_holds(sink,
raise yt_dlp.utils.DownloadError("ERROR: [generic] Our Trip: Day 1: Unable to download")
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing)
- assert backfill._extract_meta(url, adapters.SourceAdapter(), 11) is None
+ assert backfill.extract_meta(url, adapters.SourceAdapter(), 11) is None
lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]]
assert lines and all("Day 1" not in json.dumps(o) for o in lines)
diff --git a/backend/tests/test_owner_scoping.py b/backend/tests/test_owner_scoping.py
index c85a69de..51ccb83f 100644
--- a/backend/tests/test_owner_scoping.py
+++ b/backend/tests/test_owner_scoping.py
@@ -181,7 +181,7 @@ def test_clearing_a_video_leaves_another_users_state(db, make_user, as_user):
_videos(db, "v1")
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
db.commit()
- assert as_user(me).delete("/api/videos/v1/state").status_code == 200
+ assert as_user(me).delete("/api/videos/v1/progress").status_code == 200
assert _video_state(db, other) == ("hidden", 90)
diff --git a/backend/tests/test_reader_service.py b/backend/tests/test_reader_service.py
index 97522c3d..63e4c0b8 100644
--- a/backend/tests/test_reader_service.py
+++ b/backend/tests/test_reader_service.py
@@ -190,9 +190,6 @@ def test_a_cleared_resume_point_drops_a_bare_row_and_zeroes_one_with_a_status(db
db.commit()
assert _state(db, user, "v1") is None
assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0)
- service.clear_state(db, user, "v2")
- db.commit()
- assert _state(db, user, "v2") is None
# --- playlists -------------------------------------------------------------------------------
diff --git a/backend/tests/test_route_commits.py b/backend/tests/test_route_commits.py
index 5dea6860..6491fec5 100644
--- a/backend/tests/test_route_commits.py
+++ b/backend/tests/test_route_commits.py
@@ -101,8 +101,10 @@ def test_a_video_state_cleared_through_the_route_is_gone(db, make_user, as_user)
_video(db)
db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90))
db.commit()
- assert as_user(user).delete("/api/videos/v1/state").status_code == 200
- assert _state(db, user) is None
+ assert as_user(user).delete("/api/videos/v1/progress").status_code == 200
+ db.expire_all()
+ row = _state(db, user)
+ assert (row.status, row.position_seconds) == ("hidden", 0)
# --- saved views ------------------------------------------------------------------------------
diff --git a/backend/tests/test_takeout.py b/backend/tests/test_takeout.py
index d63f3475..896b19ab 100644
--- a/backend/tests/test_takeout.py
+++ b/backend/tests/test_takeout.py
@@ -292,17 +292,16 @@ def test_a_known_video_is_marked_watched_at_its_takeout_time_and_the_rest_wait_o
assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40)
-@pytest.mark.parametrize("undo", ["unmark", "clear_state"])
-def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user, undo):
- """Un-marking (or resetting) a video leaves no state row, and the import inserted a watched one
- over the user's own choice. A view on YouTube after the un-mark is a new view, and counts."""
- user = make_user(f"{undo}@example.com")
+def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user):
+ """Un-marking a video leaves no state row, and the import inserted a watched one over the user's
+ own choice. A view on YouTube after the un-mark is a new view, and counts."""
+ user = make_user("unmark@example.com")
for vid in ("undone00000", "again000000"):
_video(db, vid)
db.commit()
for vid in ("undone00000", "again000000"):
service.mark(db, user, vid, "watched")
- getattr(service, undo)(db, user, vid)
+ service.unmark(db, user, vid)
db.commit()
assert _states(db, user) == {}
later = datetime.now(timezone.utc) + timedelta(minutes=5)
@@ -603,7 +602,6 @@ def test_staging_and_settling_take_the_users_one_lock(db, make_user):
),
# Between the import's read of the un-marks and its marking, one would be overwritten.
"unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"),
- "reset": lambda s, u: service.clear_state(s, u, "aaaaaaaaaaa"),
}
with SessionLocal() as holder:
service.lock_takeout(holder, holder.get(User, user.id))
diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py
index 0b385594..e0fcd46d 100644
--- a/backend/tests/test_upload.py
+++ b/backend/tests/test_upload.py
@@ -579,7 +579,7 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
# enqueue_clip slices the source's title to the same width.
# files.ingest_spooled passes through `spool_upload`'s value, bounded there.
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
-# pipeline._download / _process_clip / worker._finish_from_cache
+# pipeline._download / _process_clip / worker._finish_from_cache / backfill._retitle
# slice a fetched or stored title to the column's width.
#
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
@@ -596,6 +596,7 @@ DISPLAY_WRITE_SITES = {
("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"},
("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"},
+ ("downloads/backfill.py", "_retitle"): {"named[:DISPLAY_TEXT_MAX]"},
("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"},
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 01c946fa..c173a62e 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -256,7 +256,7 @@ Also in force:
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its
- menu alike; clearing a video's progress and status is the eraser (`Eraser`). A failed download's Retry
+ menu alike; clearing a video's watch progress is the eraser (`Eraser`). A failed download's Retry
had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
@@ -829,3 +829,8 @@ of a full-width panel and nothing on the right.
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
+| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
+| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
+| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
+| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
+| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own |
diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts
index 09c78984..b0bd837d 100644
--- a/frontend/e2e/channel.spec.ts
+++ b/frontend/e2e/channel.spec.ts
@@ -114,7 +114,9 @@ test.describe("channel view", () => {
}) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
- const count = page.getByTestId("item-count");
+ // A pill of the feed's toolbar, which rides in the fixed chrome (the page has no count pill: its
+ // header counts the videos).
+ const count = page.getByTestId("feed-show");
await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible();
await expect(page.getByTestId("channel-folded")).toHaveCount(0);
diff --git a/frontend/src/components/ui/DataTable.cards.dom.test.tsx b/frontend/src/components/ui/DataTable.cards.dom.test.tsx
index abf57aef..f543a227 100644
--- a/frontend/src/components/ui/DataTable.cards.dom.test.tsx
+++ b/frontend/src/components/ui/DataTable.cards.dom.test.tsx
@@ -2,9 +2,9 @@
//
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
-// card's line and on the next line of another.
+// card's line and on the next line of another. A card is its row's press surface too, as the row is.
import { describe, it, expect, afterEach, vi } from "vitest";
-import { render, cleanup, screen } from "@testing-library/react";
+import { render, cleanup, screen, fireEvent } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -43,4 +43,21 @@ describe("DataTable cards", () => {
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
});
+
+ it("opens its object from a tap on the card, and leaves its control's tap to the control", () => {
+ narrow(false);
+ const open = vi.fn();
+ render(
+ r.id}
+ rowOpen={() => open}
+ />,
+ );
+ fireEvent.click(screen.getByText("1 MB"), { detail: 1 });
+ expect(open).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByText("more"), { detail: 1 });
+ expect(open).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx
index bf179689..463243b4 100644
--- a/frontend/src/components/ui/DataTable.tsx
+++ b/frontend/src/components/ui/DataTable.tsx
@@ -9,6 +9,7 @@ import { alignClass } from "./tableAlign";
import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery";
+import { withRowOpen } from "./rowOpen";
import { SURFACE } from "./surfaces";
import { HiddenFilterCue } from "./HiddenFilterCue";
import { useProgressiveRows } from "./progressiveRows";
@@ -57,6 +58,7 @@ interface TableRowProps {
index: number;
columns: Column[];
rowProps?: (row: T) => Record;
+ rowOpen?: (row: T) => (() => void) | null;
rowClassName?: (row: T) => string;
keyboardNav: boolean;
}
@@ -69,14 +71,16 @@ function TableRow({
index,
columns,
rowProps,
+ rowOpen,
rowClassName,
keyboardNav,
}: TableRowProps) {
+ const open = rowOpen?.(row) ?? null;
return (
{columns.map((col) => (
({
emptyText,
rowClassName,
rowProps,
+ rowOpen,
controlsPosition = "bottom",
controlsLeading,
controlsInBand = false,
@@ -137,6 +142,10 @@ export default function DataTable({
// answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free
// and never learns what a menu is.
rowProps?: (row: T) => Record;
+ // The row's default action (play, open), or null where a row has none: a press anywhere on the row
+ // or its card twin runs it, except on a control inside it or at the end of a text selection
+ // (`rowOpen.ts`). The keyboard's Enter keeps reaching it through the row's `data-nav-open` element.
+ rowOpen?: (row: T) => (() => void) | null;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
@@ -414,6 +423,7 @@ export default function DataTable({
index={i}
columns={columns}
rowProps={rowProps}
+ rowOpen={rowOpen}
rowClassName={rowClassName}
keyboardNav={keyboardNav}
/>
@@ -430,9 +440,9 @@ export default function DataTable({
{shown.map((row, i) => (
{columns
.filter((c) => c.cardPrimary)
diff --git a/frontend/src/components/ui/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx
index d94b4997..822d4e52 100644
--- a/frontend/src/components/ui/FilterSelect.tsx
+++ b/frontend/src/components/ui/FilterSelect.tsx
@@ -1,6 +1,6 @@
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { MenuCheck } from "./MenuCheck";
-import { SelectTrigger } from "./SelectTrigger";
+import { GroupGlyph, SelectTrigger } from "./SelectTrigger";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { SURFACE } from "./surfaces";
@@ -27,6 +27,10 @@ export interface FilterSelectProps {
/** Show only the value on the trigger — for a form row whose own caption already names the field.
* The label still names the control and its menu for assistive tech. */
hideLabel?: boolean;
+ /** A glyph that names the group in place of its word, for a toolbar where the value says enough
+ * once the group is recognisable (the feed's watch state: an eye). The word moves to the tooltip
+ * and stays in the accessible name. */
+ icon?: ReactNode;
options: readonly FilterSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
@@ -46,6 +50,7 @@ export default function FilterSelect({
multiple = false,
requireOne = true,
hideLabel = false,
+ icon,
testId,
}: FilterSelectProps) {
const [open, setOpen] = useState(false);
@@ -95,7 +100,9 @@ export default function FilterSelect({
{icon}}
+ title={icon ? label : undefined}
ref={setReference}
{...pop.getReferenceProps()}
data-testid={testId}
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md
index 8dd13ac4..7418c0ad 100644
--- a/frontend/src/components/ui/README.md
+++ b/frontend/src/components/ui/README.md
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
-| area | components |
-| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
-| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `