feat(api): expose isShort on list items

Three parsers know for certain that an item is a Short and then discard the fact:

- `ShortsLockupViewModelParser` and `ReelItemRendererParser` only ever run on a
  shorts renderer, so every item they produce is a Short by construction.
- `VideoRendererParser` reads a thumbnail overlay whose text is the literal
  "SHORTS" in place of a duration. Its own TODO asks for this: "Add some sort of
  metadata for the type of video (normal, live, premiere, shorts)".

All three emit `VideoBadges::None`, so nothing downstream can tell a Short from a
normal video. What makes that costly is the duration: YouTube no longer reports a
real one for Shorts and these parsers substitute an approximate 60s (as their own
NOTE says), so the only signals left to a client are a length indistinguishable
from a genuine 60-second upload, and a "#shorts" title tag that is a convention
rather than metadata. Measured: all 48 items on a channel's Shorts tab report
exactly `lengthSeconds: 60`, and on a live search 4 of 20 results carried
"#shorts" while running 8-27 minutes.

Add a `Shorts` badge, set it in all three parsers, and serialise it as `isShort`
alongside the existing `isUpcoming` / `isNew` / `is4k` flags. `Shorts` is appended
to the flags enum rather than inserted, since these are bit values and reordering
would change the meaning of an already-persisted badge set.

AI disclosure, per AI_POLICY.md: this patch was written with AI assistance.
Exact model: Claude Opus 5, model ID `claude-opus-5[1m]`. Tool used to interact
with it: Claude Code, Anthropic's agentic CLI. The change was verified against a
live patched instance (see the PR for the measurements) and is running in
production on the submitter's instance. Submitted by and the responsibility of
@mdbraber.
This commit is contained in:
Maarten den Braber 2026-07-30 15:10:03 +02:00
parent 9d1291a0b8
commit a03ef74cab
2 changed files with 28 additions and 2 deletions

View File

@ -9,6 +9,9 @@ enum VideoBadges
VR180 VR180
VR360 VR360
ClosedCaptions ClosedCaptions
# Appended rather than inserted: these are @[Flags] bit values, and reordering
# would silently change the meaning of any already-stored badge set.
Shorts
end end
struct SearchVideo struct SearchVideo
@ -133,6 +136,11 @@ struct SearchVideo
json.field "isVr360", self.badges.vr360? json.field "isVr360", self.badges.vr360?
json.field "is3d", self.badges.three_d? json.field "is3d", self.badges.three_d?
json.field "hasCaptions", self.badges.closed_captions? json.field "hasCaptions", self.badges.closed_captions?
# Whether YouTube served this as a Short. Worth stating outright: the
# duration cannot be used to infer it, because YouTube no longer reports a
# real one for Shorts and the parsers substitute an approximate 60s — so a
# genuine 60-second upload is otherwise indistinguishable from a Short.
json.field "isShort", self.badges.shorts?
end end
end end

View File

@ -156,6 +156,18 @@ private module Parsers
end end
end end
# A Short is marked by the thumbnail's time-status overlay, which carries
# the literal text "SHORTS" where a duration would be. The length branch
# above already special-cases it — approximating 60s, because YouTube no
# longer reports a real duration for Shorts — but then discards the *fact*,
# leaving clients to re-derive it from that approximated length or from a
# "#shorts" title tag. Neither works: a genuine 60-second upload looks
# identical, and the tag is only a convention.
is_shorts_overlay = item_contents["thumbnailOverlays"]?.try &.as_a.any? do |overlay|
overlay.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText").try &.as_s == "SHORTS"
end
badges |= VideoBadges::Shorts if is_shorts_overlay
SearchVideo.new({ SearchVideo.new({
title: title, title: title,
id: video_id, id: video_id,
@ -609,6 +621,8 @@ private module Parsers
duration = (minutes*60 + seconds) duration = (minutes*60 + seconds)
# Shorts is certain here rather than inferred: this parser only ever runs
# on a reel renderer, which YouTube uses exclusively for Shorts.
SearchVideo.new({ SearchVideo.new({
title: title, title: title,
id: video_id, id: video_id,
@ -621,7 +635,7 @@ private module Parsers
premiere_timestamp: Time.unix(0), premiere_timestamp: Time.unix(0),
author_verified: false, author_verified: false,
author_thumbnail: nil, author_thumbnail: nil,
badges: VideoBadges::None, badges: VideoBadges::Shorts,
}) })
end end
@ -872,6 +886,10 @@ private module Parsers
# TODO: Maybe use -1 as an error value and handle that on the frontend? # TODO: Maybe use -1 as an error value and handle that on the frontend?
duration = 60_i32 duration = 60_i32
# Shorts is certain here rather than inferred: this parser only ever runs
# on a shorts renderer. Without the badge, the approximated 60s above is
# the only hint a client has left, and it cannot be told apart from a real
# 60-second upload.
SearchVideo.new({ SearchVideo.new({
title: title, title: title,
id: video_id, id: video_id,
@ -884,7 +902,7 @@ private module Parsers
premiere_timestamp: Time.unix(0), premiere_timestamp: Time.unix(0),
author_verified: false, author_verified: false,
author_thumbnail: nil, author_thumbnail: nil,
badges: VideoBadges::None, badges: VideoBadges::Shorts,
}) })
end end