invidious/spec/invidious/arik_feed_kinds_spec.cr
André Eriksen 6ccb0520cf
Record the content kind of subscription feed entries
Upstream has no way to tell a Short from a long-form upload in a feed:
`ChannelVideo#to_json` reports `"type": "shortVideo"` for every row, and
`channel_videos.length_seconds` is 0 for anything absent from the channel's
Videos tab — Shorts and stream VODs alike. 531 of 1586 rows (34%) were in
that state.

YouTube's per-channel uploads playlists supply the signal, addressed by
replacing the "UC" of the channel ID: UULF long-form, UUSH Shorts, UULV live.
Measured over 75 channels, 15 newest entries each: 1114 long-form, 679 Shorts,
299 live, one Short leaking into a UULF feed.

ClassifyChannelVideosJob labels `kind` from those feeds, falling back to a
capped `HEAD /shorts/<id>` probe (200 = Short, 303 = not) for rows older than
a 15-entry window. `feed_kinds` restricts the subscription feed via a
predicate in each user's materialized view.

A separate job rather than an edit to `fetch_channel`, and `kind` is absent
from the insert's `ON CONFLICT DO UPDATE` set, so a channel refresh cannot
overwrite a label. Unclassified entries are always shown and an empty
`feed_kinds` admits everything, so the feed cannot end up blank.

Also folds in three pre-existing ameba Performance/ChainedCallWithNoBang
fixes in arik_settings.cr and admin_settings.cr, which a newer ameba release
started flagging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:41:12 +02:00

166 lines
5.8 KiB
Crystal

require "../spec_helper"
require "../../src/invidious/arik_feed_kinds"
Spectator.describe Invidious::ArikFeedKinds do
alias Kinds = Invidious::ArikFeedKinds
UCID = "UC7qUL2EsTHpNcgsz7woW9Iw"
describe ".uploads_playlist_id" do
it "swaps the UC prefix for the kind's prefix" do
expect(Kinds.uploads_playlist_id(UCID, "video")).to eq("UULF7qUL2EsTHpNcgsz7woW9Iw")
expect(Kinds.uploads_playlist_id(UCID, "short")).to eq("UUSH7qUL2EsTHpNcgsz7woW9Iw")
expect(Kinds.uploads_playlist_id(UCID, "live")).to eq("UULV7qUL2EsTHpNcgsz7woW9Iw")
end
it "keeps the ID length, so the suffix is never truncated" do
plid = Kinds.uploads_playlist_id(UCID, "short").not_nil!
expect(plid.size).to eq(UCID.size + 2)
expect(plid.ends_with?(UCID[2..])).to be_true
end
it "refuses an ID that is not a channel ID" do
expect(Kinds.uploads_playlist_id("PL7qUL2EsTHpNcgsz7woW9Iw", "short")).to be_nil
expect(Kinds.uploads_playlist_id("UC", "short")).to be_nil
expect(Kinds.uploads_playlist_id("UCtooshort", "short")).to be_nil
expect(Kinds.uploads_playlist_id("UC7qUL2EsTHpNcgsz7woW9I/", "short")).to be_nil
end
it "refuses a kind it does not know" do
expect(Kinds.uploads_playlist_id(UCID, "premiere")).to be_nil
expect(Kinds.uploads_playlist_id(UCID, "")).to be_nil
end
end
describe ".feed_resource" do
it "addresses the playlist feed, not the channel feed" do
expect(Kinds.feed_resource(UCID, "short"))
.to eq("/feeds/videos.xml?playlist_id=UUSH7qUL2EsTHpNcgsz7woW9Iw")
end
it "is nil for an unusable channel or kind" do
expect(Kinds.feed_resource("nonsense", "short")).to be_nil
expect(Kinds.feed_resource(UCID, "nonsense")).to be_nil
end
end
describe ".kind_from_probe_status" do
it "reads YouTube's answer: 200 is a Short, a redirect is not" do
expect(Kinds.kind_from_probe_status(200)).to eq("short")
expect(Kinds.kind_from_probe_status(303)).to eq("video")
expect(Kinds.kind_from_probe_status(301)).to eq("video")
expect(Kinds.kind_from_probe_status(302)).to eq("video")
end
it "gives no answer for a status that carries none" do
expect(Kinds.kind_from_probe_status(429)).to be_nil
expect(Kinds.kind_from_probe_status(500)).to be_nil
expect(Kinds.kind_from_probe_status(404)).to be_nil
end
end
describe ".visible?" do
it "hides a kind the feed does not admit" do
expect(Kinds.visible?("short", ["video"])).to be_false
expect(Kinds.visible?("live", ["video"])).to be_false
expect(Kinds.visible?("video", ["video"])).to be_true
expect(Kinds.visible?("live", ["video", "live"])).to be_true
end
it "shows an unclassified entry" do
expect(Kinds.visible?(nil, ["video"])).to be_true
end
it "shows an entry whose stored kind is not one we know" do
expect(Kinds.visible?("premiere", ["video"])).to be_true
end
it "shows everything when nothing is configured" do
expect(Kinds.visible?("short", [] of String)).to be_true
end
end
describe ".json_type" do
it "reports the kind in the vocabulary clients use for search results" do
expect(Kinds.json_type("video")).to eq("video")
expect(Kinds.json_type("short")).to eq("shortVideo")
expect(Kinds.json_type("live")).to eq("stream")
end
it "calls an unclassified entry a video, not a Short" do
expect(Kinds.json_type(nil)).to eq("video")
expect(Kinds.json_type("premiere")).to eq("video")
end
end
describe ".clean_kinds" do
it "accepts the known kinds and fixes their order" do
cleaned, errors = Kinds.clean_kinds(["live", "video"])
expect(cleaned).to eq(["video", "live"])
expect(errors).to be_empty
end
it "drops blanks and duplicates, and normalizes case" do
cleaned, errors = Kinds.clean_kinds(["VIDEO", " video ", "", " "])
expect(cleaned).to eq(["video"])
expect(errors).to be_empty
end
it "reports anything that is not a kind" do
cleaned, errors = Kinds.clean_kinds(["video", "premiere"])
expect(cleaned).to eq(["video"])
expect(errors.size).to eq(1)
expect(errors[0]).to contain("premiere")
end
end
describe ".decode_kinds" do
it "returns nothing for a missing row, so the config stays authoritative" do
kinds, error = Kinds.decode_kinds(nil)
expect(kinds).to be_nil
expect(error).to be_nil
end
it "decodes a stored list" do
kinds, error = Kinds.decode_kinds(%(["video", "live"]))
expect(kinds).to eq(["video", "live"])
expect(error).to be_nil
end
it "refuses a malformed row instead of raising" do
kinds, error = Kinds.decode_kinds(%({"video": true}))
expect(kinds).to be_nil
expect(error).not_to be_nil
kinds, error = Kinds.decode_kinds(%(["premiere"]))
expect(kinds).to be_nil
expect(error.not_nil!).to contain("premiere")
end
end
describe ".view_predicate" do
it "admits NULL alongside the configured kinds" do
predicate = Kinds.view_predicate(["video"], "cv.kind")
expect(predicate).to contain("cv.kind IS NULL")
expect(predicate).to contain("cv.kind IN ('video')")
expect(predicate).to start_with(" AND (")
end
it "lists every configured kind" do
predicate = Kinds.view_predicate(["video", "live"], "cv.kind")
expect(predicate).to contain("'video', 'live'")
end
it "is empty when the feed admits everything" do
expect(Kinds.view_predicate([] of String, "cv.kind")).to eq("")
expect(Kinds.view_predicate(["video", "short", "live"], "cv.kind")).to eq("")
end
it "only ever emits the kinds it validated" do
cleaned, _ = Kinds.clean_kinds(["video'; DROP TABLE users; --"])
expect(cleaned).to be_empty
expect(Kinds.view_predicate(cleaned, "cv.kind")).to eq("")
end
end
end