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:
@@ -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 []:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -178,6 +178,27 @@ def test_a_clip_ffprobe_cannot_size_gets_the_size_its_crop_cut(
|
||||
assert (asset.width, asset.height) == (160, 120)
|
||||
|
||||
|
||||
def test_a_rotated_clip_ffprobe_cannot_size_gets_the_upright_size_its_crop_cut(
|
||||
db, make_user, tmp_path, monkeypatch
|
||||
):
|
||||
"""The frame was drawn on the upright picture, which is the stored one turned: so is its cut."""
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
|
||||
|
||||
def probe_fails():
|
||||
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
|
||||
|
||||
asset = _cut_asset(
|
||||
db, make_user, src,
|
||||
{"crop": {"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320}},
|
||||
then=probe_fails,
|
||||
) # fmt: skip
|
||||
|
||||
# What ffmpeg cut (the rotated test above): 240 x 160, not the stored 320 x 120.
|
||||
assert (asset.width, asset.height) == (240, 160)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["frame_w", "x"])
|
||||
def test_an_infinite_number_in_a_crop_is_dropped_not_a_crash(key):
|
||||
crop = {"x": 4, "y": 6, "w": 100, "h": 50, "frame_w": 640, "frame_h": 240}
|
||||
|
||||
@@ -66,22 +66,36 @@ def test_a_system_tag_is_nobodys_to_edit(db, make_user):
|
||||
# --- 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."""
|
||||
def _music_on_four(db, make_user):
|
||||
"""A tag on four channels: two followed, one followed but hidden, one no longer followed."""
|
||||
user, other = make_user(), make_user()
|
||||
tag = service.create_tag(db, user, "Music", "other", None)
|
||||
for cid in ("UCa", "UCb", "UCgone"):
|
||||
for cid in ("UCa", "UCb", "UChid", "UCgone"):
|
||||
db.add(Channel(id=cid, title=cid))
|
||||
db.flush()
|
||||
for cid in ("UCa", "UCb"):
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid))
|
||||
db.add(Subscription(user_id=user.id, channel_id="UChid", hidden=True))
|
||||
db.add(Subscription(user_id=other.id, channel_id="UCgone"))
|
||||
for cid in ("UCa", "UCb", "UCgone"):
|
||||
for cid in ("UCa", "UCb", "UChid", "UCgone"):
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user"))
|
||||
db.commit()
|
||||
return user, tag
|
||||
|
||||
|
||||
def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user):
|
||||
"""An unsubscribe and a hide keep the channel's tag links; the Channels page's filter lists only
|
||||
the followed channels not hidden, so its count must not hold the others."""
|
||||
user, tag = _music_on_four(db, make_user)
|
||||
[(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id]
|
||||
assert (count, subscribed) == (3, 2)
|
||||
assert (count, subscribed) == (4, 2)
|
||||
|
||||
|
||||
def test_the_tag_list_sends_each_count_under_its_own_name(db, make_user, as_user):
|
||||
"""Both counts are ints, so a swap would type-check: the answer itself pins which is which."""
|
||||
user, tag = _music_on_four(db, make_user)
|
||||
[row] = [t for t in as_user(user).get("/api/tags").json() if t["id"] == tag.id]
|
||||
assert (row["channel_count"], row["subscribed_count"]) == (4, 2)
|
||||
|
||||
|
||||
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
|
||||
|
||||
Reference in New Issue
Block a user