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