fix: tag counts, chip focus, upload naming, share links and unsized clips hold after round 2

- A tag counts the followed channels the Subscriptions tab shows: hidden ones are left out, and a
  subscribe, unsubscribe, hide or tag toggle refreshes the count. The Tag manager's row and its
  hover list count the same channels as the chip; /api/tags sends both counts under their names.
- A chip's count keeps the chip's colour after a dot (a dimmed count fell under 4.5:1). A pick in
  the open "+N" fold closes it; a chip that leaves the list hands the focus to the one in its place.
  The fold is a dialog of toggle buttons, not an empty menu.
- Upload: a folder name left by a press is made once the press is over, if the dialog is still
  open, instead of guessing a dismissal from Modal's markup. While the folder is made, its place
  holds the focus and a live region says so.
- Share: an expired link has no Copy and a warn badge; the link made on open goes into the cached
  list, so a reopen makes no second one; the New link form survives the link made on open.
- Watch: a repeated Enter sends one unlock while one is on its way. See all buttons are named with
  their rail.
- An unsized clip of a rotated file gets the upright size its crop cut.
This commit is contained in:
2026-11-06 02:20:52 +01:00
parent 2baad2ebae
commit 4d1cf66d96
30 changed files with 526 additions and 128 deletions
+10
View File
@@ -201,6 +201,16 @@ def _ratio(v: object) -> float | None:
return n / d if n > 0 and d > 0 else None
def quarter_turned(probe: dict) -> bool | None:
"""Whether the first video stream of an ffprobe `-show_streams` JSON is stored turned a quarter
(ffmpeg stands it upright before any filter, so its filters see width and height swapped). None
when there is no video stream."""
for s in probe.get("streams") or []:
if s.get("codec_type") == "video":
return _quarter_turned(s)
return None
def _quarter_turned(stream: dict) -> bool:
rotation: object = (stream.get("tags") or {}).get("rotate")
for sd in stream.get("side_data_list") or []:
+4 -3
View File
@@ -89,9 +89,10 @@ 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."""
"""The size `crop_filter` cuts out of a `src_w` x `src_h` frame, evened as `EVEN_FRAME` evens it —
the clip's size when ffprobe cannot tell. The frame is the one ffmpeg's filters see: a rotated
file's stood upright, its stored size turned (the caller's to turn). A crop counted in the shown
picture is a fraction of that frame, not its own pixel count; None when the size is unknown."""
if crop.get("frame_w") and crop.get("frame_h"):
if not src_w or not src_h:
return None
+18 -1
View File
@@ -837,6 +837,18 @@ def _fail_edit(
)
def _filter_frame(
path: Path, width: int | None, height: int | None
) -> tuple[int | None, int | None]:
"""A file's frame as ffmpeg's filters see it: the stored size, turned when the stream is stored
turned a quarter (ffmpeg stands it upright first). Unknown when the file cannot be read."""
data = probe.probe_json(path)
turned = clipmod.quarter_turned(data) if data else None
if turned is None:
return None, None
return (height, width) if turned else (width, height)
def _process_edit(job_id: int, asset_id: int) -> None:
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
with SessionLocal() as db:
@@ -905,6 +917,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
size = final.stat().st_size if final.exists() else None
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
_, _, out_w, out_h, _ = probe.probe_media(final)
# Unsized, a crop's own size stands in, cut from the frame the filters saw.
cut = (
editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h))
if crop and not (out_w and out_h)
else None
)
with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id)
@@ -914,7 +932,6 @@ 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 cut:
+5 -3
View File
@@ -44,9 +44,11 @@ def video(db: Session, video_id: str) -> Video | None:
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))
subscribe to and have not hidden — the rows the Channels page's tag filter shows (a hidden
channel has its own tab). A link outlives an unsubscribe and a hide, so the two differ."""
subscribed = select(Subscription.channel_id).where(
owned_by(Subscription.user_id, user), Subscription.hidden.is_(False)
)
on_subscribed = dict(
db.execute(
select(ChannelTag.tag_id, func.count(ChannelTag.id))