mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-10-31 04:32:02 +00:00 
			
		
		
		
	Fix warnings with latest version of Crystal
This commit is contained in:
		
							
								
								
									
										118
									
								
								src/invidious.cr
									
									
									
									
									
								
							
							
						
						
									
										118
									
								
								src/invidious.cr
									
									
									
									
									
								
							| @@ -1203,17 +1203,17 @@ post "/playlist_ajax" do |env| | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     playlist_video = PlaylistVideo.new( | ||||
|       title: video.title, | ||||
|       id: video.id, | ||||
|       author: video.author, | ||||
|       ucid: video.ucid, | ||||
|     playlist_video = PlaylistVideo.new({ | ||||
|       title:          video.title, | ||||
|       id:             video.id, | ||||
|       author:         video.author, | ||||
|       ucid:           video.ucid, | ||||
|       length_seconds: video.length_seconds, | ||||
|       published: video.published, | ||||
|       plid: playlist_id, | ||||
|       live_now: video.live_now, | ||||
|       index: Random::Secure.rand(0_i64..Int64::MAX) | ||||
|     ) | ||||
|       published:      video.published, | ||||
|       plid:           playlist_id, | ||||
|       live_now:       video.live_now, | ||||
|       index:          Random::Secure.rand(0_i64..Int64::MAX), | ||||
|     }) | ||||
|  | ||||
|     video_array = playlist_video.to_a | ||||
|     args = arg_array(video_array) | ||||
| @@ -1839,8 +1839,8 @@ post "/login" do |env| | ||||
|       sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) | ||||
|       user, sid = create_user(sid, email, password) | ||||
|       user_array = user.to_a | ||||
|       user_array[4] = user_array[4].to_json # User preferences | ||||
|  | ||||
|       user_array[4] = user_array[4].to_json | ||||
|       args = arg_array(user_array) | ||||
|  | ||||
|       PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array) | ||||
| @@ -2519,7 +2519,7 @@ post "/data_control" do |env| | ||||
|   if user | ||||
|     user = user.as(User) | ||||
|  | ||||
|     # TODO: Find better way to prevent timeout | ||||
|     # TODO: Find a way to prevent browser timeout | ||||
|  | ||||
|     HTTP::FormData.parse(env.request) do |part| | ||||
|       body = part.body.gets_to_end | ||||
| @@ -2546,7 +2546,7 @@ post "/data_control" do |env| | ||||
|         end | ||||
|  | ||||
|         if body["preferences"]? | ||||
|           user.preferences = Preferences.from_json(body["preferences"].to_json, user.preferences) | ||||
|           user.preferences = Preferences.from_json(body["preferences"].to_json) | ||||
|           PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", user.preferences.to_json, user.email) | ||||
|         end | ||||
|  | ||||
| @@ -2573,17 +2573,17 @@ post "/data_control" do |env| | ||||
|                 next | ||||
|               end | ||||
|  | ||||
|               playlist_video = PlaylistVideo.new( | ||||
|                 title: video.title, | ||||
|                 id: video.id, | ||||
|                 author: video.author, | ||||
|                 ucid: video.ucid, | ||||
|               playlist_video = PlaylistVideo.new({ | ||||
|                 title:          video.title, | ||||
|                 id:             video.id, | ||||
|                 author:         video.author, | ||||
|                 ucid:           video.ucid, | ||||
|                 length_seconds: video.length_seconds, | ||||
|                 published: video.published, | ||||
|                 plid: playlist.id, | ||||
|                 live_now: video.live_now, | ||||
|                 index: Random::Secure.rand(0_i64..Int64::MAX) | ||||
|               ) | ||||
|                 published:      video.published, | ||||
|                 plid:           playlist.id, | ||||
|                 live_now:       video.live_now, | ||||
|                 index:          Random::Secure.rand(0_i64..Int64::MAX), | ||||
|               }) | ||||
|  | ||||
|               video_array = playlist_video.to_a | ||||
|               args = arg_array(video_array) | ||||
| @@ -3154,20 +3154,20 @@ get "/feed/channel/:ucid" do |env| | ||||
|     description_html = entry.xpath_node("group/description").not_nil!.to_s | ||||
|     views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 | ||||
|  | ||||
|     SearchVideo.new( | ||||
|       title: title, | ||||
|       id: video_id, | ||||
|       author: author, | ||||
|       ucid: ucid, | ||||
|       published: published, | ||||
|       views: views, | ||||
|       description_html: description_html, | ||||
|       length_seconds: 0, | ||||
|       live_now: false, | ||||
|       paid: false, | ||||
|       premium: false, | ||||
|       premiere_timestamp: nil | ||||
|     ) | ||||
|     SearchVideo.new({ | ||||
|       title:              title, | ||||
|       id:                 video_id, | ||||
|       author:             author, | ||||
|       ucid:               ucid, | ||||
|       published:          published, | ||||
|       views:              views, | ||||
|       description_html:   description_html, | ||||
|       length_seconds:     0, | ||||
|       live_now:           false, | ||||
|       paid:               false, | ||||
|       premium:            false, | ||||
|       premiere_timestamp: nil, | ||||
|     }) | ||||
|   end | ||||
|  | ||||
|   XML.build(indent: "  ", encoding: "UTF-8") do |xml| | ||||
| @@ -3397,18 +3397,18 @@ post "/feed/webhook/:token" do |env| | ||||
|       }.to_json | ||||
|       PG_DB.exec("NOTIFY notifications, E'#{payload}'") | ||||
|  | ||||
|       video = ChannelVideo.new( | ||||
|         id: id, | ||||
|         title: video.title, | ||||
|         published: published, | ||||
|         updated: updated, | ||||
|         ucid: video.ucid, | ||||
|         author: author, | ||||
|         length_seconds: video.length_seconds, | ||||
|         live_now: video.live_now, | ||||
|       video = ChannelVideo.new({ | ||||
|         id:                 id, | ||||
|         title:              video.title, | ||||
|         published:          published, | ||||
|         updated:            updated, | ||||
|         ucid:               video.ucid, | ||||
|         author:             author, | ||||
|         length_seconds:     video.length_seconds, | ||||
|         live_now:           video.live_now, | ||||
|         premiere_timestamp: video.premiere_timestamp, | ||||
|         views: video.views, | ||||
|       ) | ||||
|         views:              video.views, | ||||
|       }) | ||||
|  | ||||
|       PG_DB.query_all("UPDATE users SET feed_needs_update = true, notifications = array_append(notifications, $1) \ | ||||
|         WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", | ||||
| @@ -4666,7 +4666,7 @@ post "/api/v1/auth/preferences" do |env| | ||||
|   user = env.get("user").as(User) | ||||
|  | ||||
|   begin | ||||
|     preferences = Preferences.from_json(env.request.body || "{}", user.preferences) | ||||
|     preferences = Preferences.from_json(env.request.body || "{}") | ||||
|   rescue | ||||
|     preferences = user.preferences | ||||
|   end | ||||
| @@ -4920,17 +4920,17 @@ post "/api/v1/auth/playlists/:plid/videos" do |env| | ||||
|     next error_message | ||||
|   end | ||||
|  | ||||
|   playlist_video = PlaylistVideo.new( | ||||
|     title: video.title, | ||||
|     id: video.id, | ||||
|     author: video.author, | ||||
|     ucid: video.ucid, | ||||
|   playlist_video = PlaylistVideo.new({ | ||||
|     title:          video.title, | ||||
|     id:             video.id, | ||||
|     author:         video.author, | ||||
|     ucid:           video.ucid, | ||||
|     length_seconds: video.length_seconds, | ||||
|     published: video.published, | ||||
|     plid: plid, | ||||
|     live_now: video.live_now, | ||||
|     index: Random::Secure.rand(0_i64..Int64::MAX) | ||||
|   ) | ||||
|     published:      video.published, | ||||
|     plid:           plid, | ||||
|     live_now:       video.live_now, | ||||
|     index:          Random::Secure.rand(0_i64..Int64::MAX), | ||||
|   }) | ||||
|  | ||||
|   video_array = playlist_video.to_a | ||||
|   args = arg_array(video_array) | ||||
|   | ||||
| @@ -1,14 +1,27 @@ | ||||
| struct InvidiousChannel | ||||
|   db_mapping({ | ||||
|     id:         String, | ||||
|     author:     String, | ||||
|     updated:    Time, | ||||
|     deleted:    Bool, | ||||
|     subscribed: Time?, | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property id : String | ||||
|   property author : String | ||||
|   property updated : Time | ||||
|   property deleted : Bool | ||||
|   property subscribed : Time? | ||||
| end | ||||
|  | ||||
| struct ChannelVideo | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property id : String | ||||
|   property title : String | ||||
|   property published : Time | ||||
|   property updated : Time | ||||
|   property ucid : String | ||||
|   property author : String | ||||
|   property length_seconds : Int32 = 0 | ||||
|   property live_now : Bool = false | ||||
|   property premiere_timestamp : Time? = nil | ||||
|   property views : Int64? = nil | ||||
|  | ||||
|   def to_json(locale, json : JSON::Builder) | ||||
|     json.object do | ||||
|       json.field "type", "shortVideo" | ||||
| @@ -84,49 +97,36 @@ struct ChannelVideo | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     id:                 String, | ||||
|     title:              String, | ||||
|     published:          Time, | ||||
|     updated:            Time, | ||||
|     ucid:               String, | ||||
|     author:             String, | ||||
|     length_seconds:     {type: Int32, default: 0}, | ||||
|     live_now:           {type: Bool, default: false}, | ||||
|     premiere_timestamp: {type: Time?, default: nil}, | ||||
|     views:              {type: Int64?, default: nil}, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct AboutRelatedChannel | ||||
|   db_mapping({ | ||||
|     ucid:             String, | ||||
|     author:           String, | ||||
|     author_url:       String, | ||||
|     author_thumbnail: String, | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property ucid : String | ||||
|   property author : String | ||||
|   property author_url : String | ||||
|   property author_thumbnail : String | ||||
| end | ||||
|  | ||||
| # TODO: Refactor into either SearchChannel or InvidiousChannel | ||||
| struct AboutChannel | ||||
|   db_mapping({ | ||||
|     ucid:               String, | ||||
|     author:             String, | ||||
|     auto_generated:     Bool, | ||||
|     author_url:         String, | ||||
|     author_thumbnail:   String, | ||||
|     banner:             String?, | ||||
|     description_html:   String, | ||||
|     paid:               Bool, | ||||
|     total_views:        Int64, | ||||
|     sub_count:          Int32, | ||||
|     joined:             Time, | ||||
|     is_family_friendly: Bool, | ||||
|     allowed_regions:    Array(String), | ||||
|     related_channels:   Array(AboutRelatedChannel), | ||||
|     tabs:               Array(String), | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property ucid : String | ||||
|   property author : String | ||||
|   property auto_generated : Bool | ||||
|   property author_url : String | ||||
|   property author_thumbnail : String | ||||
|   property banner : String? | ||||
|   property description_html : String | ||||
|   property paid : Bool | ||||
|   property total_views : Int64 | ||||
|   property sub_count : Int32 | ||||
|   property joined : Time | ||||
|   property is_family_friendly : Bool | ||||
|   property allowed_regions : Array(String) | ||||
|   property related_channels : Array(AboutRelatedChannel) | ||||
|   property tabs : Array(String) | ||||
| end | ||||
|  | ||||
| class ChannelRedirect < Exception | ||||
| @@ -248,18 +248,18 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) | ||||
|  | ||||
|     premiere_timestamp = channel_video.try &.premiere_timestamp | ||||
|  | ||||
|     video = ChannelVideo.new( | ||||
|       id: video_id, | ||||
|       title: title, | ||||
|       published: published, | ||||
|       updated: Time.utc, | ||||
|       ucid: ucid, | ||||
|       author: author, | ||||
|       length_seconds: length_seconds, | ||||
|       live_now: live_now, | ||||
|     video = ChannelVideo.new({ | ||||
|       id:                 video_id, | ||||
|       title:              title, | ||||
|       published:          published, | ||||
|       updated:            Time.utc, | ||||
|       ucid:               ucid, | ||||
|       author:             author, | ||||
|       length_seconds:     length_seconds, | ||||
|       live_now:           live_now, | ||||
|       premiere_timestamp: premiere_timestamp, | ||||
|       views: views, | ||||
|     ) | ||||
|       views:              views, | ||||
|     }) | ||||
|  | ||||
|     emails = db.query_all("UPDATE users SET notifications = array_append(notifications, $1) \ | ||||
|       WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications) RETURNING email", | ||||
| @@ -298,18 +298,18 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) | ||||
|       videos = extract_videos(initial_data.as_h, author, ucid) | ||||
|  | ||||
|       count = videos.size | ||||
|       videos = videos.map { |video| ChannelVideo.new( | ||||
|         id: video.id, | ||||
|         title: video.title, | ||||
|         published: video.published, | ||||
|         updated: Time.utc, | ||||
|         ucid: video.ucid, | ||||
|         author: video.author, | ||||
|         length_seconds: video.length_seconds, | ||||
|         live_now: video.live_now, | ||||
|       videos = videos.map { |video| ChannelVideo.new({ | ||||
|         id:                 video.id, | ||||
|         title:              video.title, | ||||
|         published:          video.published, | ||||
|         updated:            Time.utc, | ||||
|         ucid:               video.ucid, | ||||
|         author:             video.author, | ||||
|         length_seconds:     video.length_seconds, | ||||
|         live_now:           video.live_now, | ||||
|         premiere_timestamp: video.premiere_timestamp, | ||||
|         views: video.views | ||||
|       ) } | ||||
|         views:              video.views, | ||||
|       }) } | ||||
|  | ||||
|       videos.each do |video| | ||||
|         ids << video.id | ||||
| @@ -352,7 +352,13 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) | ||||
|     db.exec("DELETE FROM channel_videos * WHERE NOT id = ANY ('{#{ids.map { |id| %("#{id}") }.join(",")}}') AND ucid = $1", ucid) | ||||
|   end | ||||
|  | ||||
|   channel = InvidiousChannel.new(ucid, author, Time.utc, false, nil) | ||||
|   channel = InvidiousChannel.new({ | ||||
|     id:         ucid, | ||||
|     author:     author, | ||||
|     updated:    Time.utc, | ||||
|     deleted:    false, | ||||
|     subscribed: nil, | ||||
|   }) | ||||
|  | ||||
|   return channel | ||||
| end | ||||
| @@ -395,12 +401,12 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = " | ||||
|     "80226972:embedded" => { | ||||
|       "2:string" => ucid, | ||||
|       "3:base64" => { | ||||
|         "2:string" => "videos", | ||||
|         "6:varint":   2_i64, | ||||
|         "7:varint":   1_i64, | ||||
|         "12:varint":  1_i64, | ||||
|         "13:string":  "", | ||||
|         "23:varint":  0_i64, | ||||
|         "2:string"  => "videos", | ||||
|         "6:varint"  => 2_i64, | ||||
|         "7:varint"  => 1_i64, | ||||
|         "12:varint" => 1_i64, | ||||
|         "13:string" => "", | ||||
|         "23:varint" => 0_i64, | ||||
|       }, | ||||
|     }, | ||||
|   } | ||||
| @@ -444,12 +450,12 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated | ||||
|     "80226972:embedded" => { | ||||
|       "2:string" => ucid, | ||||
|       "3:base64" => { | ||||
|         "2:string" => "playlists", | ||||
|         "6:varint":   2_i64, | ||||
|         "7:varint":   1_i64, | ||||
|         "12:varint":  1_i64, | ||||
|         "13:string":  "", | ||||
|         "23:varint":  0_i64, | ||||
|         "2:string"  => "playlists", | ||||
|         "6:varint"  => 2_i64, | ||||
|         "7:varint"  => 1_i64, | ||||
|         "12:varint" => 1_i64, | ||||
|         "13:string" => "", | ||||
|         "23:varint" => 0_i64, | ||||
|       }, | ||||
|     }, | ||||
|   } | ||||
| @@ -849,12 +855,12 @@ def get_about_info(ucid, locale) | ||||
|     related_author_thumbnail = node.xpath_node(%q(.//img)).try &.["data-thumb"] | ||||
|     related_author_thumbnail ||= "" | ||||
|  | ||||
|     AboutRelatedChannel.new( | ||||
|       ucid: related_id, | ||||
|       author: related_title, | ||||
|       author_url: related_author_url, | ||||
|     AboutRelatedChannel.new({ | ||||
|       ucid:             related_id, | ||||
|       author:           related_title, | ||||
|       author_url:       related_author_url, | ||||
|       author_thumbnail: related_author_thumbnail, | ||||
|     ) | ||||
|     }) | ||||
|   end | ||||
|  | ||||
|   joined = about.xpath_node(%q(//span[contains(., "Joined")])) | ||||
| @@ -876,23 +882,23 @@ def get_about_info(ucid, locale) | ||||
|  | ||||
|   tabs = about.xpath_nodes(%q(//ul[@id="channel-navigation-menu"]/li/a/span)).map { |node| node.content.downcase } | ||||
|  | ||||
|   AboutChannel.new( | ||||
|     ucid: ucid, | ||||
|     author: author, | ||||
|     auto_generated: auto_generated, | ||||
|     author_url: author_url, | ||||
|     author_thumbnail: author_thumbnail, | ||||
|     banner: banner, | ||||
|     description_html: description_html, | ||||
|     paid: paid, | ||||
|     total_views: total_views, | ||||
|     sub_count: sub_count, | ||||
|     joined: joined, | ||||
|   AboutChannel.new({ | ||||
|     ucid:               ucid, | ||||
|     author:             author, | ||||
|     auto_generated:     auto_generated, | ||||
|     author_url:         author_url, | ||||
|     author_thumbnail:   author_thumbnail, | ||||
|     banner:             banner, | ||||
|     description_html:   description_html, | ||||
|     paid:               paid, | ||||
|     total_views:        total_views, | ||||
|     sub_count:          sub_count, | ||||
|     joined:             joined, | ||||
|     is_family_friendly: is_family_friendly, | ||||
|     allowed_regions: allowed_regions, | ||||
|     related_channels: related_channels, | ||||
|     tabs: tabs | ||||
|   ) | ||||
|     allowed_regions:    allowed_regions, | ||||
|     related_channels:   related_channels, | ||||
|     tabs:               tabs, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") | ||||
|   | ||||
| @@ -1,11 +1,23 @@ | ||||
| class RedditThing | ||||
|   JSON.mapping({ | ||||
|     kind: String, | ||||
|     data: RedditComment | RedditLink | RedditMore | RedditListing, | ||||
|   }) | ||||
|   include JSON::Serializable | ||||
|  | ||||
|   property kind : String | ||||
|   property data : RedditComment | RedditLink | RedditMore | RedditListing | ||||
| end | ||||
|  | ||||
| class RedditComment | ||||
|   include JSON::Serializable | ||||
|  | ||||
|   property author : String | ||||
|   property body_html : String | ||||
|   property replies : RedditThing | String | ||||
|   property score : Int32 | ||||
|   property depth : Int32 | ||||
|   property permalink : String | ||||
|  | ||||
|   @[JSON::Field(converter: RedditComment::TimeConverter)] | ||||
|   property created_utc : Time | ||||
|  | ||||
|   module TimeConverter | ||||
|     def self.from_json(value : JSON::PullParser) : Time | ||||
|       Time.unix(value.read_float.to_i) | ||||
| @@ -15,46 +27,33 @@ class RedditComment | ||||
|       json.number(value.to_unix) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   JSON.mapping({ | ||||
|     author:      String, | ||||
|     body_html:   String, | ||||
|     replies:     RedditThing | String, | ||||
|     score:       Int32, | ||||
|     depth:       Int32, | ||||
|     permalink:   String, | ||||
|     created_utc: { | ||||
|       type:      Time, | ||||
|       converter: RedditComment::TimeConverter, | ||||
|     }, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct RedditLink | ||||
|   JSON.mapping({ | ||||
|     author:       String, | ||||
|     score:        Int32, | ||||
|     subreddit:    String, | ||||
|     num_comments: Int32, | ||||
|     id:           String, | ||||
|     permalink:    String, | ||||
|     title:        String, | ||||
|   }) | ||||
|   include JSON::Serializable | ||||
|  | ||||
|   property author : String | ||||
|   property score : Int32 | ||||
|   property subreddit : String | ||||
|   property num_comments : Int32 | ||||
|   property id : String | ||||
|   property permalink : String | ||||
|   property title : String | ||||
| end | ||||
|  | ||||
| struct RedditMore | ||||
|   JSON.mapping({ | ||||
|     children: Array(String), | ||||
|     count:    Int32, | ||||
|     depth:    Int32, | ||||
|   }) | ||||
|   include JSON::Serializable | ||||
|  | ||||
|   property children : Array(String) | ||||
|   property count : Int32 | ||||
|   property depth : Int32 | ||||
| end | ||||
|  | ||||
| class RedditListing | ||||
|   JSON.mapping({ | ||||
|     children: Array(RedditThing), | ||||
|     modhash:  String, | ||||
|   }) | ||||
|   include JSON::Serializable | ||||
|  | ||||
|   property children : Array(RedditThing) | ||||
|   property modhash : String | ||||
| end | ||||
|  | ||||
| def fetch_youtube_comments(id, db, cursor, format, locale, thin_mode, region, sort_by = "top") | ||||
|   | ||||
| @@ -1,219 +1,100 @@ | ||||
| require "./macros" | ||||
|  | ||||
| struct Nonce | ||||
|   db_mapping({ | ||||
|     nonce:  String, | ||||
|     expire: Time, | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property nonce : String | ||||
|   property expire : Time | ||||
| end | ||||
|  | ||||
| struct SessionId | ||||
|   db_mapping({ | ||||
|     id:     String, | ||||
|     email:  String, | ||||
|     issued: String, | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property id : String | ||||
|   property email : String | ||||
|   property issued : String | ||||
| end | ||||
|  | ||||
| struct Annotation | ||||
|   db_mapping({ | ||||
|     id:          String, | ||||
|     annotations: String, | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property id : String | ||||
|   property annotations : String | ||||
| end | ||||
|  | ||||
| struct ConfigPreferences | ||||
|   module StringToArray | ||||
|     def self.to_json(value : Array(String), json : JSON::Builder) | ||||
|       json.array do | ||||
|         value.each do |element| | ||||
|           json.string element | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   include YAML::Serializable | ||||
|  | ||||
|     def self.from_json(value : JSON::PullParser) : Array(String) | ||||
|       begin | ||||
|         result = [] of String | ||||
|         value.read_array do | ||||
|           result << HTML.escape(value.read_string[0, 100]) | ||||
|         end | ||||
|       rescue ex | ||||
|         result = [HTML.escape(value.read_string[0, 100]), ""] | ||||
|       end | ||||
|   property annotations : Bool = false | ||||
|   property annotations_subscribed : Bool = false | ||||
|   property autoplay : Bool = false | ||||
|   property captions : Array(String) = ["", "", ""] | ||||
|   property comments : Array(String) = ["youtube", ""] | ||||
|   property continue : Bool = false | ||||
|   property continue_autoplay : Bool = true | ||||
|   property dark_mode : String = "" | ||||
|   property latest_only : Bool = false | ||||
|   property listen : Bool = false | ||||
|   property local : Bool = false | ||||
|   property locale : String = "en-US" | ||||
|   property max_results : Int32 = 40 | ||||
|   property notifications_only : Bool = false | ||||
|   property player_style : String = "invidious" | ||||
|   property quality : String = "hd720" | ||||
|   property default_home : String = "Popular" | ||||
|   property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] | ||||
|   property related_videos : Bool = true | ||||
|   property sort : String = "published" | ||||
|   property speed : Float32 = 1.0_f32 | ||||
|   property thin_mode : Bool = false | ||||
|   property unseen_only : Bool = false | ||||
|   property video_loop : Bool = false | ||||
|   property volume : Int32 = 100 | ||||
|  | ||||
|       result | ||||
|     end | ||||
|  | ||||
|     def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) | ||||
|       yaml.sequence do | ||||
|         value.each do |element| | ||||
|           yaml.scalar element | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) | ||||
|       begin | ||||
|         unless node.is_a?(YAML::Nodes::Sequence) | ||||
|           node.raise "Expected sequence, not #{node.class}" | ||||
|         end | ||||
|  | ||||
|         result = [] of String | ||||
|         node.nodes.each do |item| | ||||
|           unless item.is_a?(YAML::Nodes::Scalar) | ||||
|             node.raise "Expected scalar, not #{item.class}" | ||||
|           end | ||||
|  | ||||
|           result << HTML.escape(item.value[0, 100]) | ||||
|         end | ||||
|       rescue ex | ||||
|         if node.is_a?(YAML::Nodes::Scalar) | ||||
|           result = [HTML.escape(node.value[0, 100]), ""] | ||||
|         else | ||||
|           result = ["", ""] | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       result | ||||
|     end | ||||
|   def to_tuple | ||||
|     {% begin %} | ||||
|       { | ||||
|         {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}} | ||||
|       } | ||||
|     {% end %} | ||||
|   end | ||||
|  | ||||
|   module BoolToString | ||||
|     def self.to_json(value : String, json : JSON::Builder) | ||||
|       json.string value | ||||
|     end | ||||
|  | ||||
|     def self.from_json(value : JSON::PullParser) : String | ||||
|       begin | ||||
|         result = value.read_string | ||||
|  | ||||
|         if result.empty? | ||||
|           CONFIG.default_user_preferences.dark_mode | ||||
|         else | ||||
|           result | ||||
|         end | ||||
|       rescue ex | ||||
|         if value.read_bool | ||||
|           "dark" | ||||
|         else | ||||
|           "light" | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) | ||||
|       yaml.scalar value | ||||
|     end | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String | ||||
|       unless node.is_a?(YAML::Nodes::Scalar) | ||||
|         node.raise "Expected scalar, not #{node.class}" | ||||
|       end | ||||
|  | ||||
|       case node.value | ||||
|       when "true" | ||||
|         "dark" | ||||
|       when "false" | ||||
|         "light" | ||||
|       when "" | ||||
|         CONFIG.default_user_preferences.dark_mode | ||||
|       else | ||||
|         node.value | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   yaml_mapping({ | ||||
|     annotations:            {type: Bool, default: false}, | ||||
|     annotations_subscribed: {type: Bool, default: false}, | ||||
|     autoplay:               {type: Bool, default: false}, | ||||
|     captions:               {type: Array(String), default: ["", "", ""], converter: StringToArray}, | ||||
|     comments:               {type: Array(String), default: ["youtube", ""], converter: StringToArray}, | ||||
|     continue:               {type: Bool, default: false}, | ||||
|     continue_autoplay:      {type: Bool, default: true}, | ||||
|     dark_mode:              {type: String, default: "", converter: BoolToString}, | ||||
|     latest_only:            {type: Bool, default: false}, | ||||
|     listen:                 {type: Bool, default: false}, | ||||
|     local:                  {type: Bool, default: false}, | ||||
|     locale:                 {type: String, default: "en-US"}, | ||||
|     max_results:            {type: Int32, default: 40}, | ||||
|     notifications_only:     {type: Bool, default: false}, | ||||
|     player_style:           {type: String, default: "invidious"}, | ||||
|     quality:                {type: String, default: "hd720"}, | ||||
|     default_home:           {type: String, default: "Popular"}, | ||||
|     feed_menu:              {type: Array(String), default: ["Popular", "Trending", "Subscriptions", "Playlists"]}, | ||||
|     related_videos:         {type: Bool, default: true}, | ||||
|     sort:                   {type: String, default: "published"}, | ||||
|     speed:                  {type: Float32, default: 1.0_f32}, | ||||
|     thin_mode:              {type: Bool, default: false}, | ||||
|     unseen_only:            {type: Bool, default: false}, | ||||
|     video_loop:             {type: Bool, default: false}, | ||||
|     volume:                 {type: Int32, default: 100}, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct Config | ||||
|   module ConfigPreferencesConverter | ||||
|     def self.to_yaml(value : Preferences, yaml : YAML::Nodes::Builder) | ||||
|       value.to_yaml(yaml) | ||||
|     end | ||||
|   include YAML::Serializable | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Preferences | ||||
|       Preferences.new(*ConfigPreferences.new(ctx, node).to_tuple) | ||||
|     end | ||||
|   end | ||||
|   property channel_threads : Int32                 # Number of threads to use for crawling videos from channels (for updating subscriptions) | ||||
|   property feed_threads : Int32                    # Number of threads to use for updating feeds | ||||
|   property db : DBConfig                           # Database configuration | ||||
|   property full_refresh : Bool                     # Used for crawling channels: threads should check all videos uploaded by a channel | ||||
|   property https_only : Bool?                      # Used to tell Invidious it is behind a proxy, so links to resources should be https:// | ||||
|   property hmac_key : String?                      # HMAC signing key for CSRF tokens and verifying pubsub subscriptions | ||||
|   property domain : String?                        # Domain to be used for links to resources on the site where an absolute URL is required | ||||
|   property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) | ||||
|   property captcha_enabled : Bool = true | ||||
|   property login_enabled : Bool = true | ||||
|   property registration_enabled : Bool = true | ||||
|   property statistics_enabled : Bool = false | ||||
|   property admins : Array(String) = [] of String | ||||
|   property external_port : Int32? = nil | ||||
|   property default_user_preferences : ConfigPreferences | ||||
|   property dmca_content : Array(String) = [] of String    # For compliance with DMCA, disables download widget using list of video IDs | ||||
|   property check_tables : Bool = false                    # Check table integrity, automatically try to add any missing columns, create tables, etc. | ||||
|   property cache_annotations : Bool = false               # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards | ||||
|   property banner : String? = nil                         # Optional banner to be displayed along top of page for announcements, etc. | ||||
|   property hsts : Bool? = true                            # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely | ||||
|   property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' | ||||
|  | ||||
|   module FamilyConverter | ||||
|     def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder) | ||||
|       case value | ||||
|       when Socket::Family::UNSPEC | ||||
|         yaml.scalar nil | ||||
|       when Socket::Family::INET | ||||
|         yaml.scalar "ipv4" | ||||
|       when Socket::Family::INET6 | ||||
|         yaml.scalar "ipv6" | ||||
|       when Socket::Family::UNIX | ||||
|         raise "Invalid socket family #{value}" | ||||
|       end | ||||
|     end | ||||
|   @[YAML::Field(converter: Preferences::FamilyConverter)] | ||||
|   property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) | ||||
|   property port : Int32 = 3000                                     # Port to listen for connections (overrided by command line argument) | ||||
|   property host_binding : String = "0.0.0.0"                       # Host to bind (overrided by command line argument) | ||||
|   property pool_size : Int32 = 100                                 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) | ||||
|   property admin_email : String = "omarroth@protonmail.com"        # Email for bug reports | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family | ||||
|       if node.is_a?(YAML::Nodes::Scalar) | ||||
|         case node.value.downcase | ||||
|         when "ipv4" | ||||
|           Socket::Family::INET | ||||
|         when "ipv6" | ||||
|           Socket::Family::INET6 | ||||
|         else | ||||
|           Socket::Family::UNSPEC | ||||
|         end | ||||
|       else | ||||
|         node.raise "Expected scalar, not #{node.class}" | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   module StringToCookies | ||||
|     def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder) | ||||
|       (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml) | ||||
|     end | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies | ||||
|       unless node.is_a?(YAML::Nodes::Scalar) | ||||
|         node.raise "Expected scalar, not #{node.class}" | ||||
|       end | ||||
|  | ||||
|       cookies = HTTP::Cookies.new | ||||
|       node.value.split(";").each do |cookie| | ||||
|         next if cookie.strip.empty? | ||||
|         name, value = cookie.split("=", 2) | ||||
|         cookies << HTTP::Cookie.new(name.strip, value.strip) | ||||
|       end | ||||
|  | ||||
|       cookies | ||||
|     end | ||||
|   end | ||||
|   @[YAML::Field(converter: Preferences::StringToCookies)] | ||||
|   property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format | ||||
|   property captcha_key : String? = nil                 # Key for Anti-Captcha | ||||
|  | ||||
|   def disabled?(option) | ||||
|     case disabled = CONFIG.disable_proxy | ||||
| @@ -229,50 +110,16 @@ struct Config | ||||
|       return false | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   YAML.mapping({ | ||||
|     channel_threads:          Int32,                                # Number of threads to use for crawling videos from channels (for updating subscriptions) | ||||
|     feed_threads:             Int32,                                # Number of threads to use for updating feeds | ||||
|     db:                       DBConfig,                             # Database configuration | ||||
|     full_refresh:             Bool,                                 # Used for crawling channels: threads should check all videos uploaded by a channel | ||||
|     https_only:               Bool?,                                # Used to tell Invidious it is behind a proxy, so links to resources should be https:// | ||||
|     hmac_key:                 String?,                              # HMAC signing key for CSRF tokens and verifying pubsub subscriptions | ||||
|     domain:                   String?,                              # Domain to be used for links to resources on the site where an absolute URL is required | ||||
|     use_pubsub_feeds:         {type: Bool | Int32, default: false}, # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) | ||||
|     captcha_enabled:          {type: Bool, default: true}, | ||||
|     login_enabled:            {type: Bool, default: true}, | ||||
|     registration_enabled:     {type: Bool, default: true}, | ||||
|     statistics_enabled:       {type: Bool, default: false}, | ||||
|     admins:                   {type: Array(String), default: [] of String}, | ||||
|     external_port:            {type: Int32?, default: nil}, | ||||
|     default_user_preferences: {type: Preferences, | ||||
|                                default: Preferences.new(*ConfigPreferences.from_yaml("").to_tuple), | ||||
|                                converter: ConfigPreferencesConverter, | ||||
|     }, | ||||
|     dmca_content:      {type: Array(String), default: [] of String},                                        # For compliance with DMCA, disables download widget using list of video IDs | ||||
|     check_tables:      {type: Bool, default: false},                                                        # Check table integrity, automatically try to add any missing columns, create tables, etc. | ||||
|     cache_annotations: {type: Bool, default: false},                                                        # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards | ||||
|     banner:            {type: String?, default: nil},                                                       # Optional banner to be displayed along top of page for announcements, etc. | ||||
|     hsts:              {type: Bool?, default: true},                                                        # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely | ||||
|     disable_proxy:     {type: Bool? | Array(String)?, default: false},                                      # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' | ||||
|     force_resolve:     {type: Socket::Family, default: Socket::Family::UNSPEC, converter: FamilyConverter}, # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) | ||||
|     port:              {type: Int32, default: 3000},                                                        # Port to listen for connections (overrided by command line argument) | ||||
|     host_binding:      {type: String, default: "0.0.0.0"},                                                  # Host to bind (overrided by command line argument) | ||||
|     pool_size:         {type: Int32, default: 100},                                                         # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) | ||||
|     admin_email:       {type: String, default: "omarroth@protonmail.com"},                                  # Email for bug reports | ||||
|     cookies:           {type: HTTP::Cookies, default: HTTP::Cookies.new, converter: StringToCookies},       # Saved cookies in "name1=value1; name2=value2..." format | ||||
|     captcha_key:       {type: String?, default: nil},                                                       # Key for Anti-Captcha | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct DBConfig | ||||
|   yaml_mapping({ | ||||
|     user:     String, | ||||
|     password: String, | ||||
|     host:     String, | ||||
|     port:     Int32, | ||||
|     dbname:   String, | ||||
|   }) | ||||
|   include YAML::Serializable | ||||
|  | ||||
|   property user : String | ||||
|   property password : String | ||||
|   property host : String | ||||
|   property port : Int32 | ||||
|   property dbname : String | ||||
| end | ||||
|  | ||||
| def login_req(f_req) | ||||
| @@ -365,20 +212,20 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri | ||||
|               end | ||||
|             end | ||||
|  | ||||
|             items << SearchVideo.new( | ||||
|               title: title, | ||||
|               id: video_id, | ||||
|               author: author, | ||||
|               ucid: author_id, | ||||
|               published: published, | ||||
|               views: view_count, | ||||
|               description_html: description_html, | ||||
|               length_seconds: length_seconds, | ||||
|               live_now: live_now, | ||||
|               paid: paid, | ||||
|               premium: premium, | ||||
|               premiere_timestamp: premiere_timestamp | ||||
|             ) | ||||
|             items << SearchVideo.new({ | ||||
|               title:              title, | ||||
|               id:                 video_id, | ||||
|               author:             author, | ||||
|               ucid:               author_id, | ||||
|               published:          published, | ||||
|               views:              view_count, | ||||
|               description_html:   description_html, | ||||
|               length_seconds:     length_seconds, | ||||
|               live_now:           live_now, | ||||
|               paid:               paid, | ||||
|               premium:            premium, | ||||
|               premiere_timestamp: premiere_timestamp, | ||||
|             }) | ||||
|           elsif i = item["channelRenderer"]? | ||||
|             author = i["title"]["simpleText"]?.try &.as_s || author_fallback || "" | ||||
|             author_id = i["channelId"]?.try &.as_s || author_id_fallback || "" | ||||
| @@ -391,15 +238,15 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri | ||||
|             video_count = i["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 | ||||
|             description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || "" | ||||
|  | ||||
|             items << SearchChannel.new( | ||||
|               author: author, | ||||
|               ucid: author_id, | ||||
|             items << SearchChannel.new({ | ||||
|               author:           author, | ||||
|               ucid:             author_id, | ||||
|               author_thumbnail: author_thumbnail, | ||||
|               subscriber_count: subscriber_count, | ||||
|               video_count: video_count, | ||||
|               video_count:      video_count, | ||||
|               description_html: description_html, | ||||
|               auto_generated: auto_generated, | ||||
|             ) | ||||
|               auto_generated:   auto_generated, | ||||
|             }) | ||||
|           elsif i = item["gridPlaylistRenderer"]? | ||||
|             title = i["title"]["runs"].as_a[0]?.try &.["text"].as_s || "" | ||||
|             plid = i["playlistId"]?.try &.as_s || "" | ||||
| @@ -407,15 +254,15 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri | ||||
|             video_count = i["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 | ||||
|             playlist_thumbnail = i["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || "" | ||||
|  | ||||
|             items << SearchPlaylist.new( | ||||
|               title: title, | ||||
|               id: plid, | ||||
|               author: author_fallback || "", | ||||
|               ucid: author_id_fallback || "", | ||||
|             items << SearchPlaylist.new({ | ||||
|               title:       title, | ||||
|               id:          plid, | ||||
|               author:      author_fallback || "", | ||||
|               ucid:        author_id_fallback || "", | ||||
|               video_count: video_count, | ||||
|               videos: [] of SearchPlaylistVideo, | ||||
|               thumbnail: playlist_thumbnail | ||||
|             ) | ||||
|               videos:      [] of SearchPlaylistVideo, | ||||
|               thumbnail:   playlist_thumbnail, | ||||
|             }) | ||||
|           elsif i = item["playlistRenderer"]? | ||||
|             title = i["title"]["simpleText"]?.try &.as_s || "" | ||||
|             plid = i["playlistId"]?.try &.as_s || "" | ||||
| @@ -432,24 +279,24 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri | ||||
|               v_title = v["title"]["simpleText"]?.try &.as_s || "" | ||||
|               v_id = v["videoId"]?.try &.as_s || "" | ||||
|               v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0 | ||||
|               SearchPlaylistVideo.new( | ||||
|                 title: v_title, | ||||
|                 id: v_id, | ||||
|                 length_seconds: v_length_seconds | ||||
|               ) | ||||
|               SearchPlaylistVideo.new({ | ||||
|                 title:          v_title, | ||||
|                 id:             v_id, | ||||
|                 length_seconds: v_length_seconds, | ||||
|               }) | ||||
|             end || [] of SearchPlaylistVideo | ||||
|  | ||||
|             # TODO: i["publishedTimeText"]? | ||||
|  | ||||
|             items << SearchPlaylist.new( | ||||
|               title: title, | ||||
|               id: plid, | ||||
|               author: author, | ||||
|               ucid: author_id, | ||||
|             items << SearchPlaylist.new({ | ||||
|               title:       title, | ||||
|               id:          plid, | ||||
|               author:      author, | ||||
|               ucid:        author_id, | ||||
|               video_count: video_count, | ||||
|               videos: videos, | ||||
|               thumbnail: playlist_thumbnail | ||||
|             ) | ||||
|               videos:      videos, | ||||
|               thumbnail:   playlist_thumbnail, | ||||
|             }) | ||||
|           elsif i = item["radioRenderer"]? # Mix | ||||
|             # TODO | ||||
|           elsif i = item["showRenderer"]? # Show | ||||
| @@ -465,6 +312,7 @@ end | ||||
|  | ||||
| def check_enum(db, logger, enum_name, struct_type = nil) | ||||
|   return # TODO | ||||
|  | ||||
|   if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) | ||||
|     logger.puts("CREATE TYPE #{enum_name}") | ||||
|  | ||||
| @@ -488,7 +336,7 @@ def check_table(db, logger, table_name, struct_type = nil) | ||||
|  | ||||
|   return if !struct_type | ||||
|  | ||||
|   struct_array = struct_type.to_type_tuple | ||||
|   struct_array = struct_type.type_array | ||||
|   column_array = get_column_array(db, table_name) | ||||
|   column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?<types>[\d\D]*?)\);/) | ||||
|     .try &.["types"].split(",").map { |line| line.strip }.reject &.starts_with?("CONSTRAINT") | ||||
|   | ||||
| @@ -67,7 +67,7 @@ def refresh_feeds(db, logger, config) | ||||
|             begin | ||||
|               # Drop outdated views | ||||
|               column_array = get_column_array(db, view_name) | ||||
|               ChannelVideo.to_type_tuple.each_with_index do |name, i| | ||||
|               ChannelVideo.type_array.each_with_index do |name, i| | ||||
|                 if name != column_array[i]? | ||||
|                   logger.puts("DROP MATERIALIZED VIEW #{view_name}") | ||||
|                   db.exec("DROP MATERIALIZED VIEW #{view_name}") | ||||
|   | ||||
| @@ -1,43 +1,51 @@ | ||||
| macro db_mapping(mapping) | ||||
|   def initialize({{*mapping.keys.map { |id| "@#{id}".id }}}) | ||||
|   end | ||||
| module DB::Serializable | ||||
|   macro included | ||||
|     {% verbatim do %} | ||||
|       macro finished | ||||
|         def self.type_array | ||||
|           \{{ @type.instance_vars | ||||
|             .reject { |var| var.annotation(::DB::Field) && var.annotation(::DB::Field)[:ignore] } | ||||
|             .map { |name| name.stringify } | ||||
|           }} | ||||
|         end | ||||
|  | ||||
|   def to_a | ||||
|       return [ {{*mapping.keys.map { |id| "@#{id}".id }}} ] | ||||
|   end | ||||
|         def initialize(tuple) | ||||
|           \{% for var in @type.instance_vars %} | ||||
|             \{% ann = var.annotation(::DB::Field) %} | ||||
|             \{% if ann && ann[:ignore] %} | ||||
|             \{% else %} | ||||
|               @\{{var.name}} = tuple[:\{{var.name.id}}] | ||||
|             \{% end %} | ||||
|           \{% end %} | ||||
|         end | ||||
|  | ||||
|   def self.to_type_tuple | ||||
|       return { {{*mapping.keys.map { |id| "#{id}" }}} } | ||||
|         def to_a | ||||
|           \{{ @type.instance_vars | ||||
|             .reject { |var| var.annotation(::DB::Field) && var.annotation(::DB::Field)[:ignore] } | ||||
|             .map { |name| name } | ||||
|           }} | ||||
|         end | ||||
|       end | ||||
|     {% end %} | ||||
|   end | ||||
|  | ||||
|   DB.mapping( {{mapping}} ) | ||||
| end | ||||
|  | ||||
| macro json_mapping(mapping) | ||||
|   def initialize({{*mapping.keys.map { |id| "@#{id}".id }}}) | ||||
| module JSON::Serializable | ||||
|   macro included | ||||
|     {% verbatim do %} | ||||
|       macro finished | ||||
|         def initialize(tuple) | ||||
|           \{% for var in @type.instance_vars %} | ||||
|             \{% ann = var.annotation(::JSON::Field) %} | ||||
|             \{% if ann && ann[:ignore] %} | ||||
|             \{% else %} | ||||
|               @\{{var.name}} = tuple[:\{{var.name.id}}] | ||||
|             \{% end %} | ||||
|           \{% end %} | ||||
|         end | ||||
|       end | ||||
|     {% end %} | ||||
|   end | ||||
|  | ||||
|   def to_a | ||||
|       return [ {{*mapping.keys.map { |id| "@#{id}".id }}} ] | ||||
|   end | ||||
|  | ||||
|   patched_json_mapping( {{mapping}} ) | ||||
|   YAML.mapping( {{mapping}} ) | ||||
| end | ||||
|  | ||||
| macro yaml_mapping(mapping) | ||||
|   def initialize({{*mapping.keys.map { |id| "@#{id}".id }}}) | ||||
|   end | ||||
|  | ||||
|   def to_a | ||||
|       return [ {{*mapping.keys.map { |id| "@#{id}".id }}} ] | ||||
|   end | ||||
|  | ||||
|   def to_tuple | ||||
|       return { {{*mapping.keys.map { |id| "@#{id}".id }}} } | ||||
|   end | ||||
|  | ||||
|   YAML.mapping({{mapping}}) | ||||
| end | ||||
|  | ||||
| macro templated(filename, template = "template") | ||||
|   | ||||
| @@ -1,166 +0,0 @@ | ||||
| # Overloads https://github.com/crystal-lang/crystal/blob/0.28.0/src/json/from_json.cr#L24 | ||||
| def Object.from_json(string_or_io, default) : self | ||||
|   parser = JSON::PullParser.new(string_or_io) | ||||
|   new parser, default | ||||
| end | ||||
|  | ||||
| # Adds configurable 'default' | ||||
| macro patched_json_mapping(_properties_, strict = false) | ||||
|   {% for key, value in _properties_ %} | ||||
|     {% _properties_[key] = {type: value} unless value.is_a?(HashLiteral) || value.is_a?(NamedTupleLiteral) %} | ||||
|   {% end %} | ||||
|  | ||||
|   {% for key, value in _properties_ %} | ||||
|     {% _properties_[key][:key_id] = key.id.gsub(/\?$/, "") %} | ||||
|   {% end %} | ||||
|  | ||||
|   {% for key, value in _properties_ %} | ||||
|     @{{value[:key_id]}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }} | ||||
|  | ||||
|     {% if value[:setter] == nil ? true : value[:setter] %} | ||||
|       def {{value[:key_id]}}=(_{{value[:key_id]}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }}) | ||||
|         @{{value[:key_id]}} = _{{value[:key_id]}} | ||||
|       end | ||||
|     {% end %} | ||||
|  | ||||
|     {% if value[:getter] == nil ? true : value[:getter] %} | ||||
|       def {{key.id}} : {{value[:type]}}{{ (value[:nilable] ? "?" : "").id }} | ||||
|         @{{value[:key_id]}} | ||||
|       end | ||||
|     {% end %} | ||||
|  | ||||
|     {% if value[:presence] %} | ||||
|       @{{value[:key_id]}}_present : Bool = false | ||||
|  | ||||
|       def {{value[:key_id]}}_present? | ||||
|         @{{value[:key_id]}}_present | ||||
|       end | ||||
|     {% end %} | ||||
|   {% end %} | ||||
|  | ||||
|   def initialize(%pull : ::JSON::PullParser, default = nil) | ||||
|     {% for key, value in _properties_ %} | ||||
|       %var{key.id} = nil | ||||
|       %found{key.id} = false | ||||
|     {% end %} | ||||
|  | ||||
|     %location = %pull.location | ||||
|     begin | ||||
|       %pull.read_begin_object | ||||
|     rescue exc : ::JSON::ParseException | ||||
|       raise ::JSON::MappingError.new(exc.message, self.class.to_s, nil, *%location, exc) | ||||
|     end | ||||
|     until %pull.kind.end_object? | ||||
|       %key_location = %pull.location | ||||
|       key = %pull.read_object_key | ||||
|       case key | ||||
|       {% for key, value in _properties_ %} | ||||
|         when {{value[:key] || value[:key_id].stringify}} | ||||
|           %found{key.id} = true | ||||
|           begin | ||||
|             %var{key.id} = | ||||
|               {% if value[:nilable] || value[:default] != nil %} %pull.read_null_or { {% end %} | ||||
|  | ||||
|               {% if value[:root] %} | ||||
|                 %pull.on_key!({{value[:root]}}) do | ||||
|               {% end %} | ||||
|  | ||||
|               {% if value[:converter] %} | ||||
|                 {{value[:converter]}}.from_json(%pull) | ||||
|               {% elsif value[:type].is_a?(Path) || value[:type].is_a?(Generic) %} | ||||
|                 {{value[:type]}}.new(%pull) | ||||
|               {% else %} | ||||
|                 ::Union({{value[:type]}}).new(%pull) | ||||
|               {% end %} | ||||
|  | ||||
|               {% if value[:root] %} | ||||
|                 end | ||||
|               {% end %} | ||||
|  | ||||
|             {% if value[:nilable] || value[:default] != nil %} } {% end %} | ||||
|           rescue exc : ::JSON::ParseException | ||||
|             raise ::JSON::MappingError.new(exc.message, self.class.to_s, {{value[:key] || value[:key_id].stringify}}, *%key_location, exc) | ||||
|           end | ||||
|       {% end %} | ||||
|       else | ||||
|         {% if strict %} | ||||
|           raise ::JSON::MappingError.new("Unknown JSON attribute: #{key}", self.class.to_s, nil, *%key_location, nil) | ||||
|         {% else %} | ||||
|           %pull.skip | ||||
|         {% end %} | ||||
|       end | ||||
|     end | ||||
|     %pull.read_next | ||||
|  | ||||
|     {% for key, value in _properties_ %} | ||||
|       {% unless value[:nilable] || value[:default] != nil %} | ||||
|         if %var{key.id}.nil? && !%found{key.id} && !::Union({{value[:type]}}).nilable? | ||||
|           raise ::JSON::MappingError.new("Missing JSON attribute: {{(value[:key] || value[:key_id]).id}}", self.class.to_s, nil, *%location, nil) | ||||
|         end | ||||
|       {% end %} | ||||
|  | ||||
|       {% if value[:nilable] %} | ||||
|         {% if value[:default] != nil %} | ||||
|           @{{value[:key_id]}} = %found{key.id} ? %var{key.id} : (default.responds_to?(:{{value[:key_id]}}) ? default.{{value[:key_id]}} : {{value[:default]}}) | ||||
|         {% else %} | ||||
|           @{{value[:key_id]}} = %var{key.id} | ||||
|         {% end %} | ||||
|       {% elsif value[:default] != nil %} | ||||
|         @{{value[:key_id]}} = %var{key.id}.nil? ? (default.responds_to?(:{{value[:key_id]}}) ? default.{{value[:key_id]}} : {{value[:default]}}) : %var{key.id} | ||||
|       {% else %} | ||||
|         @{{value[:key_id]}} = (%var{key.id}).as({{value[:type]}}) | ||||
|       {% end %} | ||||
|  | ||||
|       {% if value[:presence] %} | ||||
|         @{{value[:key_id]}}_present = %found{key.id} | ||||
|       {% end %} | ||||
|     {% end %} | ||||
|   end | ||||
|  | ||||
|   def to_json(json : ::JSON::Builder) | ||||
|     json.object do | ||||
|       {% for key, value in _properties_ %} | ||||
|         _{{value[:key_id]}} = @{{value[:key_id]}} | ||||
|  | ||||
|         {% unless value[:emit_null] %} | ||||
|           unless _{{value[:key_id]}}.nil? | ||||
|         {% end %} | ||||
|  | ||||
|           json.field({{value[:key] || value[:key_id].stringify}}) do | ||||
|             {% if value[:root] %} | ||||
|               {% if value[:emit_null] %} | ||||
|                 if _{{value[:key_id]}}.nil? | ||||
|                   nil.to_json(json) | ||||
|                 else | ||||
|               {% end %} | ||||
|  | ||||
|               json.object do | ||||
|                 json.field({{value[:root]}}) do | ||||
|             {% end %} | ||||
|  | ||||
|             {% if value[:converter] %} | ||||
|               if _{{value[:key_id]}} | ||||
|                 {{ value[:converter] }}.to_json(_{{value[:key_id]}}, json) | ||||
|               else | ||||
|                 nil.to_json(json) | ||||
|               end | ||||
|             {% else %} | ||||
|               _{{value[:key_id]}}.to_json(json) | ||||
|             {% end %} | ||||
|  | ||||
|             {% if value[:root] %} | ||||
|               {% if value[:emit_null] %} | ||||
|                 end | ||||
|               {% end %} | ||||
|                 end | ||||
|               end | ||||
|             {% end %} | ||||
|           end | ||||
|  | ||||
|         {% unless value[:emit_null] %} | ||||
|           end | ||||
|         {% end %} | ||||
|       {% end %} | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -1,21 +1,21 @@ | ||||
| struct MixVideo | ||||
|   db_mapping({ | ||||
|     title:          String, | ||||
|     id:             String, | ||||
|     author:         String, | ||||
|     ucid:           String, | ||||
|     length_seconds: Int32, | ||||
|     index:          Int32, | ||||
|     rdid:           String, | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property title : String | ||||
|   property id : String | ||||
|   property author : String | ||||
|   property ucid : String | ||||
|   property length_seconds : Int32 | ||||
|   property index : Int32 | ||||
|   property rdid : String | ||||
| end | ||||
|  | ||||
| struct Mix | ||||
|   db_mapping({ | ||||
|     title:  String, | ||||
|     id:     String, | ||||
|     videos: Array(MixVideo), | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property title : String | ||||
|   property id : String | ||||
|   property videos : Array(MixVideo) | ||||
| end | ||||
|  | ||||
| def fetch_mix(rdid, video_id, cookies = nil, locale = nil) | ||||
| @@ -48,23 +48,22 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil) | ||||
|  | ||||
|     id = item["videoId"].as_s | ||||
|     title = item["title"]?.try &.["simpleText"].as_s | ||||
|     if !title | ||||
|       next | ||||
|     end | ||||
|     next if !title | ||||
|  | ||||
|     author = item["longBylineText"]["runs"][0]["text"].as_s | ||||
|     ucid = item["longBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s | ||||
|     length_seconds = decode_length_seconds(item["lengthText"]["simpleText"].as_s) | ||||
|     index = item["navigationEndpoint"]["watchEndpoint"]["index"].as_i | ||||
|  | ||||
|     videos << MixVideo.new( | ||||
|       title, | ||||
|       id, | ||||
|       author, | ||||
|       ucid, | ||||
|       length_seconds, | ||||
|       index, | ||||
|       rdid | ||||
|     ) | ||||
|     videos << MixVideo.new({ | ||||
|       title:          title, | ||||
|       id:             id, | ||||
|       author:         author, | ||||
|       ucid:           ucid, | ||||
|       length_seconds: length_seconds, | ||||
|       index:          index, | ||||
|       rdid:           rdid, | ||||
|     }) | ||||
|   end | ||||
|  | ||||
|   if !cookies | ||||
| @@ -74,7 +73,11 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil) | ||||
|  | ||||
|   videos.uniq! { |video| video.id } | ||||
|   videos = videos.first(50) | ||||
|   return Mix.new(mix_title, rdid, videos) | ||||
|   return Mix.new({ | ||||
|     title:  mix_title, | ||||
|     id:     rdid, | ||||
|     videos: videos, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| def template_mix(mix) | ||||
|   | ||||
| @@ -1,4 +1,16 @@ | ||||
| struct PlaylistVideo | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property title : String | ||||
|   property id : String | ||||
|   property author : String | ||||
|   property ucid : String | ||||
|   property length_seconds : Int32 | ||||
|   property published : Time | ||||
|   property plid : String | ||||
|   property index : Int64 | ||||
|   property live_now : Bool | ||||
|  | ||||
|   def to_xml(auto_generated, xml : XML::Builder) | ||||
|     xml.element("entry") do | ||||
|       xml.element("id") { xml.text "yt:video:#{self.id}" } | ||||
| @@ -78,21 +90,22 @@ struct PlaylistVideo | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     title:          String, | ||||
|     id:             String, | ||||
|     author:         String, | ||||
|     ucid:           String, | ||||
|     length_seconds: Int32, | ||||
|     published:      Time, | ||||
|     plid:           String, | ||||
|     index:          Int64, | ||||
|     live_now:       Bool, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct Playlist | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property title : String | ||||
|   property id : String | ||||
|   property author : String | ||||
|   property author_thumbnail : String | ||||
|   property ucid : String | ||||
|   property description : String | ||||
|   property video_count : Int32 | ||||
|   property views : Int64 | ||||
|   property updated : Time | ||||
|   property thumbnail : String? | ||||
|  | ||||
|   def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) | ||||
|     json.object do | ||||
|       json.field "type", "playlist" | ||||
| @@ -147,19 +160,6 @@ struct Playlist | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     title:            String, | ||||
|     id:               String, | ||||
|     author:           String, | ||||
|     author_thumbnail: String, | ||||
|     ucid:             String, | ||||
|     description:      String, | ||||
|     video_count:      Int32, | ||||
|     views:            Int64, | ||||
|     updated:          Time, | ||||
|     thumbnail:        String?, | ||||
|   }) | ||||
|  | ||||
|   def privacy | ||||
|     PlaylistPrivacy::Public | ||||
|   end | ||||
| @@ -176,6 +176,29 @@ enum PlaylistPrivacy | ||||
| end | ||||
|  | ||||
| struct InvidiousPlaylist | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property title : String | ||||
|   property id : String | ||||
|   property author : String | ||||
|   property description : String = "" | ||||
|   property video_count : Int32 | ||||
|   property created : Time | ||||
|   property updated : Time | ||||
|  | ||||
|   @[DB::Field(converter: InvidiousPlaylist::PlaylistPrivacyConverter)] | ||||
|   property privacy : PlaylistPrivacy = PlaylistPrivacy::Private | ||||
|   property index : Array(Int64) | ||||
|  | ||||
|   @[DB::Field(ignore: true)] | ||||
|   property thumbnail_id : String? | ||||
|  | ||||
|   module PlaylistPrivacyConverter | ||||
|     def self.from_rs(rs) | ||||
|       return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8)))) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) | ||||
|     json.object do | ||||
|       json.field "type", "invidiousPlaylist" | ||||
| @@ -216,26 +239,6 @@ struct InvidiousPlaylist | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   property thumbnail_id | ||||
|  | ||||
|   module PlaylistPrivacyConverter | ||||
|     def self.from_rs(rs) | ||||
|       return PlaylistPrivacy.parse(String.new(rs.read(Slice(UInt8)))) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     title:       String, | ||||
|     id:          String, | ||||
|     author:      String, | ||||
|     description: {type: String, default: ""}, | ||||
|     video_count: Int32, | ||||
|     created:     Time, | ||||
|     updated:     Time, | ||||
|     privacy:     {type: PlaylistPrivacy, default: PlaylistPrivacy::Private, converter: PlaylistPrivacyConverter}, | ||||
|     index:       Array(Int64), | ||||
|   }) | ||||
|  | ||||
|   def thumbnail | ||||
|     @thumbnail_id ||= PG_DB.query_one?("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 1", self.id, self.index, as: String) || "-----------" | ||||
|     "/vi/#{@thumbnail_id}/mqdefault.jpg" | ||||
| @@ -261,17 +264,17 @@ end | ||||
| def create_playlist(db, title, privacy, user) | ||||
|   plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}" | ||||
|  | ||||
|   playlist = InvidiousPlaylist.new( | ||||
|     title: title.byte_slice(0, 150), | ||||
|     id: plid, | ||||
|     author: user.email, | ||||
|   playlist = InvidiousPlaylist.new({ | ||||
|     title:       title.byte_slice(0, 150), | ||||
|     id:          plid, | ||||
|     author:      user.email, | ||||
|     description: "", # Max 5000 characters | ||||
|     video_count: 0, | ||||
|     created: Time.utc, | ||||
|     updated: Time.utc, | ||||
|     privacy: privacy, | ||||
|     index: [] of Int64, | ||||
|   ) | ||||
|     created:     Time.utc, | ||||
|     updated:     Time.utc, | ||||
|     privacy:     privacy, | ||||
|     index:       [] of Int64, | ||||
|   }) | ||||
|  | ||||
|   playlist_array = playlist.to_a | ||||
|   args = arg_array(playlist_array) | ||||
| @@ -282,17 +285,17 @@ def create_playlist(db, title, privacy, user) | ||||
| end | ||||
|  | ||||
| def subscribe_playlist(db, user, playlist) | ||||
|   playlist = InvidiousPlaylist.new( | ||||
|     title: playlist.title.byte_slice(0, 150), | ||||
|     id: playlist.id, | ||||
|     author: user.email, | ||||
|   playlist = InvidiousPlaylist.new({ | ||||
|     title:       playlist.title.byte_slice(0, 150), | ||||
|     id:          playlist.id, | ||||
|     author:      user.email, | ||||
|     description: "", # Max 5000 characters | ||||
|     video_count: playlist.video_count, | ||||
|     created: Time.utc, | ||||
|     updated: playlist.updated, | ||||
|     privacy: PlaylistPrivacy::Private, | ||||
|     index: [] of Int64, | ||||
|   ) | ||||
|     created:     Time.utc, | ||||
|     updated:     playlist.updated, | ||||
|     privacy:     PlaylistPrivacy::Private, | ||||
|     index:       [] of Int64, | ||||
|   }) | ||||
|  | ||||
|   playlist_array = playlist.to_a | ||||
|   args = arg_array(playlist_array) | ||||
| @@ -393,18 +396,18 @@ def fetch_playlist(plid, locale) | ||||
|   author = author_info["title"]["runs"][0]["text"]?.try &.as_s || "" | ||||
|   ucid = author_info["title"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"]?.try &.as_s || "" | ||||
|  | ||||
|   return Playlist.new( | ||||
|     title: title, | ||||
|     id: plid, | ||||
|     author: author, | ||||
|   return Playlist.new({ | ||||
|     title:            title, | ||||
|     id:               plid, | ||||
|     author:           author, | ||||
|     author_thumbnail: author_thumbnail, | ||||
|     ucid: ucid, | ||||
|     description: description, | ||||
|     video_count: video_count, | ||||
|     views: views, | ||||
|     updated: updated, | ||||
|     thumbnail: thumbnail | ||||
|   ) | ||||
|     ucid:             ucid, | ||||
|     description:      description, | ||||
|     video_count:      video_count, | ||||
|     views:            views, | ||||
|     updated:          updated, | ||||
|     thumbnail:        thumbnail, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) | ||||
| @@ -471,17 +474,17 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any)) | ||||
|         length_seconds = 0 | ||||
|       end | ||||
|  | ||||
|       videos << PlaylistVideo.new( | ||||
|         title: title, | ||||
|         id: video_id, | ||||
|         author: author, | ||||
|         ucid: ucid, | ||||
|       videos << PlaylistVideo.new({ | ||||
|         title:          title, | ||||
|         id:             video_id, | ||||
|         author:         author, | ||||
|         ucid:           ucid, | ||||
|         length_seconds: length_seconds, | ||||
|         published: Time.utc, | ||||
|         plid: plid, | ||||
|         live_now: live, | ||||
|         index: index - 1 | ||||
|       ) | ||||
|         published:      Time.utc, | ||||
|         plid:           plid, | ||||
|         live_now:       live, | ||||
|         index:          index - 1, | ||||
|       }) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,19 @@ | ||||
| struct SearchVideo | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property title : String | ||||
|   property id : String | ||||
|   property author : String | ||||
|   property ucid : String | ||||
|   property published : Time | ||||
|   property views : Int64 | ||||
|   property description_html : String | ||||
|   property length_seconds : Int32 | ||||
|   property live_now : Bool | ||||
|   property paid : Bool | ||||
|   property premium : Bool | ||||
|   property premiere_timestamp : Time? | ||||
|  | ||||
|   def to_xml(auto_generated, query_params, xml : XML::Builder) | ||||
|     query_params["v"] = self.id | ||||
|  | ||||
| @@ -99,32 +114,27 @@ struct SearchVideo | ||||
|   def is_upcoming | ||||
|     premiere_timestamp ? true : false | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     title:              String, | ||||
|     id:                 String, | ||||
|     author:             String, | ||||
|     ucid:               String, | ||||
|     published:          Time, | ||||
|     views:              Int64, | ||||
|     description_html:   String, | ||||
|     length_seconds:     Int32, | ||||
|     live_now:           Bool, | ||||
|     paid:               Bool, | ||||
|     premium:            Bool, | ||||
|     premiere_timestamp: Time?, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct SearchPlaylistVideo | ||||
|   db_mapping({ | ||||
|     title:          String, | ||||
|     id:             String, | ||||
|     length_seconds: Int32, | ||||
|   }) | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property title : String | ||||
|   property id : String | ||||
|   property length_seconds : Int32 | ||||
| end | ||||
|  | ||||
| struct SearchPlaylist | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property title : String | ||||
|   property id : String | ||||
|   property author : String | ||||
|   property ucid : String | ||||
|   property video_count : Int32 | ||||
|   property videos : Array(SearchPlaylistVideo) | ||||
|   property thumbnail : String? | ||||
|  | ||||
|   def to_json(locale, json : JSON::Builder) | ||||
|     json.object do | ||||
|       json.field "type", "playlist" | ||||
| @@ -164,19 +174,19 @@ struct SearchPlaylist | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     title:       String, | ||||
|     id:          String, | ||||
|     author:      String, | ||||
|     ucid:        String, | ||||
|     video_count: Int32, | ||||
|     videos:      Array(SearchPlaylistVideo), | ||||
|     thumbnail:   String?, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct SearchChannel | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property author : String | ||||
|   property ucid : String | ||||
|   property author_thumbnail : String | ||||
|   property subscriber_count : Int32 | ||||
|   property video_count : Int32 | ||||
|   property description_html : String | ||||
|   property auto_generated : Bool | ||||
|  | ||||
|   def to_json(locale, json : JSON::Builder) | ||||
|     json.object do | ||||
|       json.field "type", "channel" | ||||
| @@ -216,16 +226,6 @@ struct SearchChannel | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     author:           String, | ||||
|     ucid:             String, | ||||
|     author_thumbnail: String, | ||||
|     subscriber_count: Int32, | ||||
|     video_count:      Int32, | ||||
|     description_html: String, | ||||
|     auto_generated:   Bool, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | ||||
|   | ||||
| @@ -4,6 +4,20 @@ require "crypto/bcrypt/password" | ||||
| MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } | ||||
|  | ||||
| struct User | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property updated : Time | ||||
|   property notifications : Array(String) | ||||
|   property subscriptions : Array(String) | ||||
|   property email : String | ||||
|  | ||||
|   @[DB::Field(converter: User::PreferencesConverter)] | ||||
|   property preferences : Preferences | ||||
|   property password : String? | ||||
|   property token : String | ||||
|   property watched : Array(String) | ||||
|   property feed_needs_update : Bool? | ||||
|  | ||||
|   module PreferencesConverter | ||||
|     def self.from_rs(rs) | ||||
|       begin | ||||
| @@ -13,31 +27,78 @@ struct User | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     updated:       Time, | ||||
|     notifications: Array(String), | ||||
|     subscriptions: Array(String), | ||||
|     email:         String, | ||||
|     preferences:   { | ||||
|       type:      Preferences, | ||||
|       converter: PreferencesConverter, | ||||
|     }, | ||||
|     password:          String?, | ||||
|     token:             String, | ||||
|     watched:           Array(String), | ||||
|     feed_needs_update: Bool?, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct Preferences | ||||
|   module ProcessString | ||||
|   include JSON::Serializable | ||||
|   include YAML::Serializable | ||||
|  | ||||
|   property annotations : Bool = CONFIG.default_user_preferences.annotations | ||||
|   property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed | ||||
|   property autoplay : Bool = CONFIG.default_user_preferences.autoplay | ||||
|  | ||||
|   @[JSON::Field(converter: Preferences::StringToArray)] | ||||
|   @[YAML::Field(converter: Preferences::StringToArray)] | ||||
|   property captions : Array(String) = CONFIG.default_user_preferences.captions | ||||
|  | ||||
|   @[JSON::Field(converter: Preferences::StringToArray)] | ||||
|   @[YAML::Field(converter: Preferences::StringToArray)] | ||||
|   property comments : Array(String) = CONFIG.default_user_preferences.comments | ||||
|   property continue : Bool = CONFIG.default_user_preferences.continue | ||||
|   property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay | ||||
|  | ||||
|   @[JSON::Field(converter: Preferences::BoolToString)] | ||||
|   @[YAML::Field(converter: Preferences::BoolToString)] | ||||
|   property dark_mode : String = CONFIG.default_user_preferences.dark_mode | ||||
|   property latest_only : Bool = CONFIG.default_user_preferences.latest_only | ||||
|   property listen : Bool = CONFIG.default_user_preferences.listen | ||||
|   property local : Bool = CONFIG.default_user_preferences.local | ||||
|  | ||||
|   @[JSON::Field(converter: Preferences::ProcessString)] | ||||
|   property locale : String = CONFIG.default_user_preferences.locale | ||||
|  | ||||
|   @[JSON::Field(converter: Preferences::ClampInt)] | ||||
|   property max_results : Int32 = CONFIG.default_user_preferences.max_results | ||||
|   property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only | ||||
|  | ||||
|   @[JSON::Field(converter: Preferences::ProcessString)] | ||||
|   property player_style : String = CONFIG.default_user_preferences.player_style | ||||
|  | ||||
|   @[JSON::Field(converter: Preferences::ProcessString)] | ||||
|   property quality : String = CONFIG.default_user_preferences.quality | ||||
|   property default_home : String = CONFIG.default_user_preferences.default_home | ||||
|   property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu | ||||
|   property related_videos : Bool = CONFIG.default_user_preferences.related_videos | ||||
|  | ||||
|   @[JSON::Field(converter: Preferences::ProcessString)] | ||||
|   property sort : String = CONFIG.default_user_preferences.sort | ||||
|   property speed : Float32 = CONFIG.default_user_preferences.speed | ||||
|   property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode | ||||
|   property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only | ||||
|   property video_loop : Bool = CONFIG.default_user_preferences.video_loop | ||||
|   property volume : Int32 = CONFIG.default_user_preferences.volume | ||||
|  | ||||
|   module BoolToString | ||||
|     def self.to_json(value : String, json : JSON::Builder) | ||||
|       json.string value | ||||
|     end | ||||
|  | ||||
|     def self.from_json(value : JSON::PullParser) : String | ||||
|       HTML.escape(value.read_string[0, 100]) | ||||
|       begin | ||||
|         result = value.read_string | ||||
|  | ||||
|         if result.empty? | ||||
|           CONFIG.default_user_preferences.dark_mode | ||||
|         else | ||||
|           result | ||||
|         end | ||||
|       rescue ex | ||||
|         if value.read_bool | ||||
|           "dark" | ||||
|         else | ||||
|           "light" | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) | ||||
| @@ -45,7 +106,20 @@ struct Preferences | ||||
|     end | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String | ||||
|       HTML.escape(node.value[0, 100]) | ||||
|       unless node.is_a?(YAML::Nodes::Scalar) | ||||
|         node.raise "Expected scalar, not #{node.class}" | ||||
|       end | ||||
|  | ||||
|       case node.value | ||||
|       when "true" | ||||
|         "dark" | ||||
|       when "false" | ||||
|         "light" | ||||
|       when "" | ||||
|         CONFIG.default_user_preferences.dark_mode | ||||
|       else | ||||
|         node.value | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -67,33 +141,130 @@ struct Preferences | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   json_mapping({ | ||||
|     annotations:            {type: Bool, default: CONFIG.default_user_preferences.annotations}, | ||||
|     annotations_subscribed: {type: Bool, default: CONFIG.default_user_preferences.annotations_subscribed}, | ||||
|     autoplay:               {type: Bool, default: CONFIG.default_user_preferences.autoplay}, | ||||
|     captions:               {type: Array(String), default: CONFIG.default_user_preferences.captions, converter: ConfigPreferences::StringToArray}, | ||||
|     comments:               {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: ConfigPreferences::StringToArray}, | ||||
|     continue:               {type: Bool, default: CONFIG.default_user_preferences.continue}, | ||||
|     continue_autoplay:      {type: Bool, default: CONFIG.default_user_preferences.continue_autoplay}, | ||||
|     dark_mode:              {type: String, default: CONFIG.default_user_preferences.dark_mode, converter: ConfigPreferences::BoolToString}, | ||||
|     latest_only:            {type: Bool, default: CONFIG.default_user_preferences.latest_only}, | ||||
|     listen:                 {type: Bool, default: CONFIG.default_user_preferences.listen}, | ||||
|     local:                  {type: Bool, default: CONFIG.default_user_preferences.local}, | ||||
|     locale:                 {type: String, default: CONFIG.default_user_preferences.locale, converter: ProcessString}, | ||||
|     max_results:            {type: Int32, default: CONFIG.default_user_preferences.max_results, converter: ClampInt}, | ||||
|     notifications_only:     {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, | ||||
|     player_style:           {type: String, default: CONFIG.default_user_preferences.player_style, converter: ProcessString}, | ||||
|     quality:                {type: String, default: CONFIG.default_user_preferences.quality, converter: ProcessString}, | ||||
|     default_home:           {type: String, default: CONFIG.default_user_preferences.default_home}, | ||||
|     feed_menu:              {type: Array(String), default: CONFIG.default_user_preferences.feed_menu}, | ||||
|     related_videos:         {type: Bool, default: CONFIG.default_user_preferences.related_videos}, | ||||
|     sort:                   {type: String, default: CONFIG.default_user_preferences.sort, converter: ProcessString}, | ||||
|     speed:                  {type: Float32, default: CONFIG.default_user_preferences.speed}, | ||||
|     thin_mode:              {type: Bool, default: CONFIG.default_user_preferences.thin_mode}, | ||||
|     unseen_only:            {type: Bool, default: CONFIG.default_user_preferences.unseen_only}, | ||||
|     video_loop:             {type: Bool, default: CONFIG.default_user_preferences.video_loop}, | ||||
|     volume:                 {type: Int32, default: CONFIG.default_user_preferences.volume}, | ||||
|   }) | ||||
|   module FamilyConverter | ||||
|     def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder) | ||||
|       case value | ||||
|       when Socket::Family::UNSPEC | ||||
|         yaml.scalar nil | ||||
|       when Socket::Family::INET | ||||
|         yaml.scalar "ipv4" | ||||
|       when Socket::Family::INET6 | ||||
|         yaml.scalar "ipv6" | ||||
|       when Socket::Family::UNIX | ||||
|         raise "Invalid socket family #{value}" | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family | ||||
|       if node.is_a?(YAML::Nodes::Scalar) | ||||
|         case node.value.downcase | ||||
|         when "ipv4" | ||||
|           Socket::Family::INET | ||||
|         when "ipv6" | ||||
|           Socket::Family::INET6 | ||||
|         else | ||||
|           Socket::Family::UNSPEC | ||||
|         end | ||||
|       else | ||||
|         node.raise "Expected scalar, not #{node.class}" | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   module ProcessString | ||||
|     def self.to_json(value : String, json : JSON::Builder) | ||||
|       json.string value | ||||
|     end | ||||
|  | ||||
|     def self.from_json(value : JSON::PullParser) : String | ||||
|       HTML.escape(value.read_string[0, 100]) | ||||
|     end | ||||
|  | ||||
|     def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) | ||||
|       yaml.scalar value | ||||
|     end | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String | ||||
|       HTML.escape(node.value[0, 100]) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   module StringToArray | ||||
|     def self.to_json(value : Array(String), json : JSON::Builder) | ||||
|       json.array do | ||||
|         value.each do |element| | ||||
|           json.string element | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def self.from_json(value : JSON::PullParser) : Array(String) | ||||
|       begin | ||||
|         result = [] of String | ||||
|         value.read_array do | ||||
|           result << HTML.escape(value.read_string[0, 100]) | ||||
|         end | ||||
|       rescue ex | ||||
|         result = [HTML.escape(value.read_string[0, 100]), ""] | ||||
|       end | ||||
|  | ||||
|       result | ||||
|     end | ||||
|  | ||||
|     def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) | ||||
|       yaml.sequence do | ||||
|         value.each do |element| | ||||
|           yaml.scalar element | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) | ||||
|       begin | ||||
|         unless node.is_a?(YAML::Nodes::Sequence) | ||||
|           node.raise "Expected sequence, not #{node.class}" | ||||
|         end | ||||
|  | ||||
|         result = [] of String | ||||
|         node.nodes.each do |item| | ||||
|           unless item.is_a?(YAML::Nodes::Scalar) | ||||
|             node.raise "Expected scalar, not #{item.class}" | ||||
|           end | ||||
|  | ||||
|           result << HTML.escape(item.value[0, 100]) | ||||
|         end | ||||
|       rescue ex | ||||
|         if node.is_a?(YAML::Nodes::Scalar) | ||||
|           result = [HTML.escape(node.value[0, 100]), ""] | ||||
|         else | ||||
|           result = ["", ""] | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       result | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   module StringToCookies | ||||
|     def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder) | ||||
|       (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml) | ||||
|     end | ||||
|  | ||||
|     def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies | ||||
|       unless node.is_a?(YAML::Nodes::Scalar) | ||||
|         node.raise "Expected scalar, not #{node.class}" | ||||
|       end | ||||
|  | ||||
|       cookies = HTTP::Cookies.new | ||||
|       node.value.split(";").each do |cookie| | ||||
|         next if cookie.strip.empty? | ||||
|         name, value = cookie.split("=", 2) | ||||
|         cookies << HTTP::Cookie.new(name.strip, value.strip) | ||||
|       end | ||||
|  | ||||
|       cookies | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | ||||
| def get_user(sid, headers, db, refresh = true) | ||||
| @@ -103,8 +274,7 @@ def get_user(sid, headers, db, refresh = true) | ||||
|     if refresh && Time.utc - user.updated > 1.minute | ||||
|       user, sid = fetch_user(sid, headers, db) | ||||
|       user_array = user.to_a | ||||
|  | ||||
|       user_array[4] = user_array[4].to_json | ||||
|       user_array[4] = user_array[4].to_json # User preferences | ||||
|       args = arg_array(user_array) | ||||
|  | ||||
|       db.exec("INSERT INTO users VALUES (#{args}) \ | ||||
| @@ -122,8 +292,7 @@ def get_user(sid, headers, db, refresh = true) | ||||
|   else | ||||
|     user, sid = fetch_user(sid, headers, db) | ||||
|     user_array = user.to_a | ||||
|  | ||||
|     user_array[4] = user_array[4].to_json | ||||
|     user_array[4] = user_array[4].to_json # User preferences | ||||
|     args = arg_array(user.to_a) | ||||
|  | ||||
|     db.exec("INSERT INTO users VALUES (#{args}) \ | ||||
| @@ -166,7 +335,17 @@ def fetch_user(sid, headers, db) | ||||
|  | ||||
|   token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) | ||||
|  | ||||
|   user = User.new(Time.utc, [] of String, channels, email, CONFIG.default_user_preferences, nil, token, [] of String, true) | ||||
|   user = User.new({ | ||||
|     updated:           Time.utc, | ||||
|     notifications:     [] of String, | ||||
|     subscriptions:     channels, | ||||
|     email:             email, | ||||
|     preferences:       Preferences.new(CONFIG.default_user_preferences.to_tuple), | ||||
|     password:          nil, | ||||
|     token:             token, | ||||
|     watched:           [] of String, | ||||
|     feed_needs_update: true, | ||||
|   }) | ||||
|   return user, sid | ||||
| end | ||||
|  | ||||
| @@ -174,7 +353,17 @@ def create_user(sid, email, password) | ||||
|   password = Crypto::Bcrypt::Password.create(password, cost: 10) | ||||
|   token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) | ||||
|  | ||||
|   user = User.new(Time.utc, [] of String, [] of String, email, CONFIG.default_user_preferences, password.to_s, token, [] of String, true) | ||||
|   user = User.new({ | ||||
|     updated:           Time.utc, | ||||
|     notifications:     [] of String, | ||||
|     subscriptions:     [] of String, | ||||
|     email:             email, | ||||
|     preferences:       Preferences.new(CONFIG.default_user_preferences.to_tuple), | ||||
|     password:          password.to_s, | ||||
|     token:             token, | ||||
|     watched:           [] of String, | ||||
|     feed_needs_update: true, | ||||
|   }) | ||||
|  | ||||
|   return user, sid | ||||
| end | ||||
| @@ -281,48 +470,6 @@ def subscribe_ajax(channel_id, action, env_headers) | ||||
|   end | ||||
| end | ||||
|  | ||||
| # TODO: Playlist stub, sync with YouTube for Google accounts | ||||
| # def playlist_ajax(video_ids, source_playlist_id, name, privacy, action, env_headers) | ||||
| #   headers = HTTP::Headers.new | ||||
| #   headers["Cookie"] = env_headers["Cookie"] | ||||
| # | ||||
| #   html = YT_POOL.client &.get("/view_all_playlists", headers) | ||||
| # | ||||
| #   cookies = HTTP::Cookies.from_headers(headers) | ||||
| #   html.cookies.each do |cookie| | ||||
| #     if {"VISITOR_INFO1_LIVE", "YSC", "SIDCC"}.includes? cookie.name | ||||
| #       if cookies[cookie.name]? | ||||
| #         cookies[cookie.name] = cookie | ||||
| #       else | ||||
| #         cookies << cookie | ||||
| #       end | ||||
| #     end | ||||
| #   end | ||||
| #   headers = cookies.add_request_headers(headers) | ||||
| # | ||||
| #   if match = html.body.match(/'XSRF_TOKEN': "(?<session_token>[^"]+)"/) | ||||
| #     session_token = match["session_token"] | ||||
| # | ||||
| #     headers["content-type"] = "application/x-www-form-urlencoded" | ||||
| # | ||||
| #     post_req = { | ||||
| #       video_ids:          [] of String, | ||||
| #       source_playlist_id: "", | ||||
| #       n:                  name, | ||||
| #       p:                  privacy, | ||||
| #       session_token:      session_token, | ||||
| #     } | ||||
| #     post_url = "/playlist_ajax?#{action}=1" | ||||
| # | ||||
| #     response = client.post(post_url, headers, form: post_req) | ||||
| #     if response.status_code == 200 | ||||
| #       return JSON.parse(response.body)["result"]["playlistId"].as_s | ||||
| #     else | ||||
| #       return nil | ||||
| #     end | ||||
| #   end | ||||
| # end | ||||
|  | ||||
| def get_subscription_feed(db, user, max_results = 40, page = 1) | ||||
|   limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) | ||||
|   offset = (page - 1) * limit | ||||
|   | ||||
| @@ -222,30 +222,50 @@ VIDEO_FORMATS = { | ||||
| } | ||||
|  | ||||
| struct VideoPreferences | ||||
|   json_mapping({ | ||||
|     annotations:        Bool, | ||||
|     autoplay:           Bool, | ||||
|     comments:           Array(String), | ||||
|     continue:           Bool, | ||||
|     continue_autoplay:  Bool, | ||||
|     controls:           Bool, | ||||
|     listen:             Bool, | ||||
|     local:              Bool, | ||||
|     preferred_captions: Array(String), | ||||
|     player_style:       String, | ||||
|     quality:            String, | ||||
|     raw:                Bool, | ||||
|     region:             String?, | ||||
|     related_videos:     Bool, | ||||
|     speed:              (Float32 | Float64), | ||||
|     video_end:          (Float64 | Int32), | ||||
|     video_loop:         Bool, | ||||
|     video_start:        (Float64 | Int32), | ||||
|     volume:             Int32, | ||||
|   }) | ||||
|   include JSON::Serializable | ||||
|  | ||||
|   property annotations : Bool | ||||
|   property autoplay : Bool | ||||
|   property comments : Array(String) | ||||
|   property continue : Bool | ||||
|   property continue_autoplay : Bool | ||||
|   property controls : Bool | ||||
|   property listen : Bool | ||||
|   property local : Bool | ||||
|   property preferred_captions : Array(String) | ||||
|   property player_style : String | ||||
|   property quality : String | ||||
|   property raw : Bool | ||||
|   property region : String? | ||||
|   property related_videos : Bool | ||||
|   property speed : Float32 | Float64 | ||||
|   property video_end : Float64 | Int32 | ||||
|   property video_loop : Bool | ||||
|   property video_start : Float64 | Int32 | ||||
|   property volume : Int32 | ||||
| end | ||||
|  | ||||
| struct Video | ||||
|   include DB::Serializable | ||||
|  | ||||
|   property id : String | ||||
|  | ||||
|   @[DB::Field(converter: Video::JSONConverter)] | ||||
|   property info : Hash(String, JSON::Any) | ||||
|   property updated : Time | ||||
|  | ||||
|   @[DB::Field(ignore: true)] | ||||
|   property captions : Array(Caption)? | ||||
|  | ||||
|   @[DB::Field(ignore: true)] | ||||
|   property adaptive_fmts : Array(Hash(String, JSON::Any))? | ||||
|  | ||||
|   @[DB::Field(ignore: true)] | ||||
|   property fmt_stream : Array(Hash(String, JSON::Any))? | ||||
|  | ||||
|   @[DB::Field(ignore: true)] | ||||
|   property description : String? | ||||
|  | ||||
|   module JSONConverter | ||||
|     def self.from_rs(rs) | ||||
|       JSON.parse(rs.read(String)).as_h | ||||
| @@ -552,6 +572,7 @@ struct Video | ||||
|  | ||||
|   def fmt_stream | ||||
|     return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream | ||||
|  | ||||
|     fmt_stream = info["streamingData"]?.try &.["formats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) | ||||
|     fmt_stream.each do |fmt| | ||||
|       if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } | ||||
| @@ -751,30 +772,20 @@ struct Video | ||||
|   def session_token : String? | ||||
|     info["sessionToken"]?.try &.as_s? | ||||
|   end | ||||
|  | ||||
|   db_mapping({ | ||||
|     id:      String, | ||||
|     info:    {type: Hash(String, JSON::Any), converter: Video::JSONConverter}, | ||||
|     updated: Time, | ||||
|   }) | ||||
|  | ||||
|   @captions : Array(Caption)? | ||||
|   @adaptive_fmts : Array(Hash(String, JSON::Any))? | ||||
|   @fmt_stream : Array(Hash(String, JSON::Any))? | ||||
| end | ||||
|  | ||||
| struct Caption | ||||
|   json_mapping({ | ||||
|     name:         CaptionName, | ||||
|     baseUrl:      String, | ||||
|     languageCode: String, | ||||
|   }) | ||||
| end | ||||
|  | ||||
| struct CaptionName | ||||
|   json_mapping({ | ||||
|     simpleText: String, | ||||
|   }) | ||||
|   include JSON::Serializable | ||||
|  | ||||
|   property simpleText : String | ||||
| end | ||||
|  | ||||
| struct Caption | ||||
|   include JSON::Serializable | ||||
|  | ||||
|   property name : CaptionName | ||||
|   property baseUrl : String | ||||
|   property languageCode : String | ||||
| end | ||||
|  | ||||
| class VideoRedirect < Exception | ||||
| @@ -990,7 +1001,12 @@ def fetch_video(id, region) | ||||
|  | ||||
|   raise info["reason"]?.try &.as_s || "" if !info["videoDetails"]? | ||||
|  | ||||
|   video = Video.new(id, info, Time.utc) | ||||
|   video = Video.new({ | ||||
|     id:      id, | ||||
|     info:    info, | ||||
|     updated: Time.utc, | ||||
|   }) | ||||
|  | ||||
|   return video | ||||
| end | ||||
|  | ||||
| @@ -1097,27 +1113,27 @@ def process_video_params(query, preferences) | ||||
|   controls ||= 1 | ||||
|   controls = controls >= 1 | ||||
|  | ||||
|   params = VideoPreferences.new( | ||||
|     annotations: annotations, | ||||
|     autoplay: autoplay, | ||||
|     comments: comments, | ||||
|     continue: continue, | ||||
|     continue_autoplay: continue_autoplay, | ||||
|     controls: controls, | ||||
|     listen: listen, | ||||
|     local: local, | ||||
|     player_style: player_style, | ||||
|   params = VideoPreferences.new({ | ||||
|     annotations:        annotations, | ||||
|     autoplay:           autoplay, | ||||
|     comments:           comments, | ||||
|     continue:           continue, | ||||
|     continue_autoplay:  continue_autoplay, | ||||
|     controls:           controls, | ||||
|     listen:             listen, | ||||
|     local:              local, | ||||
|     player_style:       player_style, | ||||
|     preferred_captions: preferred_captions, | ||||
|     quality: quality, | ||||
|     raw: raw, | ||||
|     region: region, | ||||
|     related_videos: related_videos, | ||||
|     speed: speed, | ||||
|     video_end: video_end, | ||||
|     video_loop: video_loop, | ||||
|     video_start: video_start, | ||||
|     volume: volume, | ||||
|   ) | ||||
|     quality:            quality, | ||||
|     raw:                raw, | ||||
|     region:             region, | ||||
|     related_videos:     related_videos, | ||||
|     speed:              speed, | ||||
|     video_end:          video_end, | ||||
|     video_loop:         video_loop, | ||||
|     video_start:        video_start, | ||||
|     volume:             volume, | ||||
|   }) | ||||
|  | ||||
|   return params | ||||
| end | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Omar Roth
					Omar Roth