1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# frozen_string_literal: true
require_relative "../inner_tube"
require_relative "../sync"
require_relative "../views/sources/youtube"
module RankKing
module Sources
# A list that follows one of the owner's YouTube playlists, read
# through InnerTube, so private ones and Watch Later work too. Nothing
# signs rank-king in for good: each fetch takes a youtube.com cookie
# pasted in for it, and the whole playlist is fetched again.
class YouTube
# Which playlist each list follows, keyed by list_id.
TABLE = :youtube_sources
# Which video each item is, keyed by item_id.
ITEMS = :youtube_items
# Where the new-list form sends a pasted cookie for its choice of
# playlists.
PLAYLISTS_PATH = "/youtube/playlists"
class Provider
#: (InnerTube inner_tube) -> void
def initialize(inner_tube)
@inner_tube = inner_tube
end
#: () -> String
def title = "YouTube playlist"
#: (List list) -> YouTube
def source(list) = YouTube.new(list, @inner_tube)
#: (csrf: Method) -> _Component
def fields(csrf:) = Views::Sources::YouTubeFields.new(csrf:)
# The new-list form's playlist picker, drawn from a pasted cookie. It
# is a POST so the cookie stays out of URLs and logs.
#: (Roda::RodaRequest r) -> void
def routes(r)
r.post PLAYLISTS_PATH.delete_prefix("/") do
playlists(r.params["cookie"]).call
end
end
# The choice of playlists a pasted cookie opens. Whatever stops it
# is said in the choice's place.
#: (untyped text) -> _Component
def playlists(text)
Views::Sources::YouTubePlaylists.new(playlists: @inner_tube.playlists(YouTube.cookie(text)))
rescue InnerTube::Error => e
Views::Sources::YouTubePlaylists.new(playlists: nil, error: e.message)
end
# Follows the playlist chosen on the new-list form and fetches it
# with the cookie pasted there.
#: (List list, Hash[String, untyped] params) -> void
def create(list, params)
playlist_id = params["playlist"].to_s[/\A[\w-]+\z/] or raise Invalid, "Choose a playlist to follow."
cookie = InnerTube.cookie(params["cookie"].to_s) or raise Invalid, "That cookie has no SAPISID to sign requests with."
DB[TABLE].insert(list_id: list.id, playlist_id:)
source(list).fetch(cookie)
end
end
#: (untyped text) -> String
def self.cookie(text)
InnerTube.cookie(text.to_s) or raise InnerTube::Error, "That cookie has no SAPISID to sign requests with."
end
#: (List list, InnerTube inner_tube) -> void
def initialize(list, inner_tube)
@list = list
@inner_tube = inner_tube
end
#: () -> String
def label = "Sync from YouTube"
#: (csrf: Method) -> _Component
def form(csrf:) = Views::Sources::YouTube.new(list: @list, csrf:)
# Each item as its video looked at the last sync. A page renders every
# item, so the first call reads the whole list's videos at once.
#: (Item item) -> _Component
def item(item)
@cached ||= DB[ITEMS].where(item_id: Item.where(list_id: @list.id).select(:id)).to_hash(:item_id)
video = @cached[item.id] || {}
Views::Sources::YouTubeItem.new(title: item.name, channel: video[:channel_title], thumbnail: video[:thumbnail_url])
end
# The dialog posts the cookie to fetch with.
#: (Hash[String, untyped] params) -> void
def sync(params)
fetch(self.class.cookie(params["cookie"]))
end
#: (String cookie) -> Sync::Summary
def fetch(cookie)
# Kept for saved, which caches each video's details by its item.
@videos = @inner_tube.playlist_videos(cookie, playlist_id).uniq(&:id).to_h { |video|
[video.id, video] #: [String, InnerTube::Video]
}
entries = @videos.each_value.map { |video| Entry.new(key: video.id, name: video.title) }
Sync.call(@list, entries, source: self)
end
#: () -> String
def playlist_id = DB[TABLE].where(list_id: @list.id).get(:playlist_id).to_s
#: () -> Hash[String, Item]
def items_by_key
video_ids = DB[ITEMS].where(item_id: Item.where(list_id: @list.id).select(:id)).to_hash(:item_id, :video_id)
Item.where(id: video_ids.keys).all.to_h { |item|
[video_ids.fetch(item.id).to_s, item] #: [String, Item]
}
end
#: (Item item, Entry entry) -> void
def saved(item, entry)
video = @videos&.fetch(entry.key)
DB[ITEMS].replace(
item_id: item.id, video_id: entry.key, title: entry.name,
channel_title: video&.channel_title, thumbnail_url: video&.thumbnail_url,
)
end
end
end
end