Add playlist-backed Trending/Popular feeds

New config lists trending_playlists / popular_playlists. When set,
the feed serves the merged content of those local PUBLIC playlists
instead of the stock feed:

- Swap at the data layer (fetch_trending, popular_videos), so the
  HTML views and /api/v1/trending|popular emit the same items and
  API clients (Yattee, Materialious) need no changes.
- Playlists merge in config order, each in its own order,
  duplicates dropped. Missing or non-public entries are skipped
  and logged, never fatal.
- One local Postgres query per playlist — no YouTube call.
- PlaylistVideo converts to SearchVideo with neutral defaults for
  metadata a playlist row lacks (views, description, thumbnails).
- Trending category tabs and region are ignored while set. With a
  single trending playlist, 'View as playlist' links to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
NeskireDK 2026-08-12 18:47:52 +02:00
parent 31fe847126
commit c644d73def
5 changed files with 94 additions and 0 deletions

View File

@ -391,6 +391,24 @@ https_only: false
# - 192.168.1.101
# logout_url: "https://auth.example.com/logout"
##
## Playlist-backed feeds (ArikTube extension).
##
## When set, the Trending and/or Popular feed serves the content of these
## local PUBLIC playlists instead of the stock feed. Playlists merge in
## the listed order, each in its own order, duplicates dropped. The swap
## is at the data layer, so /api/v1/trending and /api/v1/popular serve
## the same items — API clients need no changes. Missing or non-public
## playlists are skipped and logged. Trending category tabs and region
## are ignored while a list is set.
##
## Default: [] (stock feeds)
##
#trending_playlists:
# - IVPLxxxxxxxxxxxxxxxxxxxx
#popular_playlists:
# - IVPLxxxxxxxxxxxxxxxxxxxx
##
## Enable/Disable the captcha challenge on the login page.
##

View File

@ -193,6 +193,11 @@ Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new
Invidious::Jobs.start_all
def popular_videos
# ArikTube: playlist-backed Popular
unless CONFIG.popular_playlists.empty?
return Invidious::PlaylistFeeds.feed_videos(CONFIG.popular_playlists)
end
Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get
end

View File

@ -147,6 +147,11 @@ class Config
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
property use_pubsub_feeds : Bool | Int32 = false
property popular_enabled : Bool = true
# Playlist-backed feeds (ArikTube extension): when set, the Trending or
# Popular feed serves these local PUBLIC playlists (merged in order,
# duplicates dropped) instead of the stock feed content.
property trending_playlists : Array(String) = [] of String
property popular_playlists : Array(String) = [] of String
property captcha_enabled : Bool = true
property login_enabled : Bool = true
property registration_enabled : Bool = true

View File

@ -0,0 +1,58 @@
# Playlist-backed Trending/Popular feeds (ArikTube extension).
#
# On a personal instance "Trending" and "Popular" mean "what this server's
# owner pushes". When `trending_playlists` / `popular_playlists` name local
# public playlists, those feeds serve the merged playlist content instead.
# The swap happens at the data layer (fetch_trending / popular_videos), so
# the HTML views and /api/v1/trending|popular emit the same items and every
# API client shows them without client-side support.
module Invidious::PlaylistFeeds
extend self
# Merged videos of the configured playlists: config order, each playlist
# in its own order, duplicates dropped. Missing or non-public playlists
# are skipped with a log line so one bad entry cannot break the feed.
def feed_videos(plids : Array(String)) : Array(SearchVideo)
videos = [] of SearchVideo
seen = Set(String).new
plids.each do |plid|
playlist = Invidious::Database::Playlists.select(id: plid)
if playlist.nil?
LOGGER.warn("PlaylistFeeds: playlist #{plid} does not exist, skipping")
next
end
if playlist.privacy != PlaylistPrivacy::Public
LOGGER.warn("PlaylistFeeds: playlist #{plid} is not public, skipping")
next
end
Invidious::Database::PlaylistVideos.select(plid, playlist.index, 0, limit: 200).each do |video|
next if seen.includes?(video.id)
seen << video.id
videos << as_search_video(video)
end
end
videos
end
# PlaylistVideo lacks views/description/thumbnails metadata; neutral
# defaults keep the SearchVideo JSON shape the feed endpoints promise.
private def as_search_video(video : PlaylistVideo) : SearchVideo
SearchVideo.new({
title: video.title,
id: video.id,
author: video.author,
ucid: video.ucid,
published: video.published,
views: 0_i64,
description_html: "",
length_seconds: video.length_seconds,
premiere_timestamp: nil,
author_verified: false,
author_thumbnail: nil,
badges: video.live_now ? VideoBadges::LiveNow : VideoBadges::None,
})
end
end

View File

@ -1,4 +1,12 @@
def fetch_trending(trending_type, region, locale)
# ArikTube: playlist-backed Trending. Category tabs and region are
# ignored on purpose — the owner's list is the feed.
unless CONFIG.trending_playlists.empty?
plids = CONFIG.trending_playlists
plid = plids.size == 1 ? plids[0] : nil
return {Invidious::PlaylistFeeds.feed_videos(plids), plid}
end
region ||= "US"
region = region.upcase