2019-03-29 21:30:02 +00:00
struct PlaylistVideo
2020-07-26 14:58:50 +00:00
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
2020-06-15 22:10:30 +00:00
def to_xml ( auto_generated , xml : XML :: Builder )
2019-08-05 23:49:13 +00:00
xml . element ( " entry " ) do
xml . element ( " id " ) { xml . text " yt:video: #{ self . id } " }
xml . element ( " yt:videoId " ) { xml . text self . id }
xml . element ( " yt:channelId " ) { xml . text self . ucid }
xml . element ( " title " ) { xml . text self . title }
2020-06-15 22:10:30 +00:00
xml . element ( " link " , rel : " alternate " , href : " #{ HOST_URL } /watch?v= #{ self . id } " )
2019-08-05 23:49:13 +00:00
xml . element ( " author " ) do
if auto_generated
xml . element ( " name " ) { xml . text self . author }
2020-06-15 22:10:30 +00:00
xml . element ( " uri " ) { xml . text " #{ HOST_URL } /channel/ #{ self . ucid } " }
2019-08-05 23:49:13 +00:00
else
xml . element ( " name " ) { xml . text author }
2020-06-15 22:10:30 +00:00
xml . element ( " uri " ) { xml . text " #{ HOST_URL } /channel/ #{ ucid } " }
2019-08-05 23:49:13 +00:00
end
end
xml . element ( " content " , type : " xhtml " ) do
xml . element ( " div " , xmlns : " http://www.w3.org/1999/xhtml " ) do
2020-06-15 22:10:30 +00:00
xml . element ( " a " , href : " #{ HOST_URL } /watch?v= #{ self . id } " ) do
xml . element ( " img " , src : " #{ HOST_URL } /vi/ #{ self . id } /mqdefault.jpg " )
2019-08-05 23:49:13 +00:00
end
end
end
xml . element ( " published " ) { xml . text self . published . to_s ( " %Y-%m-%dT%H:%M:%S%:z " ) }
xml . element ( " media:group " ) do
xml . element ( " media:title " ) { xml . text self . title }
2020-06-15 22:10:30 +00:00
xml . element ( " media:thumbnail " , url : " #{ HOST_URL } /vi/ #{ self . id } /mqdefault.jpg " ,
2019-08-05 23:49:13 +00:00
width : " 320 " , height : " 180 " )
end
end
end
2020-06-15 22:10:30 +00:00
def to_xml ( auto_generated , xml : XML :: Builder? = nil )
2019-08-05 23:49:13 +00:00
if xml
2020-06-15 22:10:30 +00:00
to_xml ( auto_generated , xml )
2019-08-05 23:49:13 +00:00
else
XML . build do | json |
2020-06-15 22:10:30 +00:00
to_xml ( auto_generated , xml )
2019-08-05 23:49:13 +00:00
end
end
end
2020-06-15 22:10:30 +00:00
def to_json ( locale , json : JSON :: Builder , index : Int32 ?)
2019-06-08 18:31:41 +00:00
json . object do
json . field " title " , self . title
json . field " videoId " , self . id
json . field " author " , self . author
json . field " authorId " , self . ucid
json . field " authorUrl " , " /channel/ #{ self . ucid } "
json . field " videoThumbnails " do
2020-06-15 22:10:30 +00:00
generate_thumbnails ( json , self . id )
2019-06-08 18:31:41 +00:00
end
2019-08-05 23:49:13 +00:00
if index
json . field " index " , index
json . field " indexId " , self . index . to_u64 . to_s ( 16 ) . upcase
else
json . field " index " , self . index
end
2019-06-08 18:31:41 +00:00
json . field " lengthSeconds " , self . length_seconds
end
end
2020-06-15 22:10:30 +00:00
def to_json ( locale , json : JSON :: Builder? = nil , index : Int32 ? = nil )
2019-06-08 18:31:41 +00:00
if json
2020-06-15 22:10:30 +00:00
to_json ( locale , json , index : index )
2019-06-08 18:31:41 +00:00
else
JSON . build do | json |
2020-06-15 22:10:30 +00:00
to_json ( locale , json , index : index )
2019-06-08 18:31:41 +00:00
end
end
end
2018-09-29 04:12:35 +00:00
end
2019-03-29 21:30:02 +00:00
struct Playlist
2020-07-26 14:58:50 +00:00
include DB :: Serializable
property title : String
property id : String
property author : String
property author_thumbnail : String
property ucid : String
property description : String
2021-03-11 00:44:35 +00:00
property description_html : String
2020-07-26 14:58:50 +00:00
property video_count : Int32
property views : Int64
property updated : Time
property thumbnail : String ?
2020-06-15 22:10:30 +00:00
def to_json ( offset , locale , json : JSON :: Builder , continuation : String ? = nil )
2019-08-05 23:49:13 +00:00
json . object do
json . field " type " , " playlist "
json . field " title " , self . title
json . field " playlistId " , self . id
json . field " playlistThumbnail " , self . thumbnail
json . field " author " , self . author
json . field " authorId " , self . ucid
json . field " authorUrl " , " /channel/ #{ self . ucid } "
json . field " authorThumbnails " do
json . array do
qualities = { 32 , 48 , 76 , 100 , 176 , 512 }
qualities . each do | quality |
json . object do
json . field " url " , self . author_thumbnail . not_nil! . gsub ( / = \ d+ / , " =s #{ quality } " )
json . field " width " , quality
json . field " height " , quality
end
end
end
end
2020-06-25 02:18:09 +00:00
json . field " description " , self . description
2019-08-05 23:49:13 +00:00
json . field " descriptionHtml " , self . description_html
json . field " videoCount " , self . video_count
json . field " viewCount " , self . views
json . field " updated " , self . updated . to_unix
json . field " isListed " , self . privacy . public?
json . field " videos " do
json . array do
videos = get_playlist_videos ( PG_DB , self , offset : offset , locale : locale , continuation : continuation )
videos . each_with_index do | video , index |
2020-06-15 22:10:30 +00:00
video . to_json ( locale , json )
2019-08-05 23:49:13 +00:00
end
end
end
end
end
2020-06-15 22:10:30 +00:00
def to_json ( offset , locale , json : JSON :: Builder? = nil , continuation : String ? = nil )
2019-08-05 23:49:13 +00:00
if json
2020-06-15 22:10:30 +00:00
to_json ( offset , locale , json , continuation : continuation )
2019-08-05 23:49:13 +00:00
else
JSON . build do | json |
2020-06-15 22:10:30 +00:00
to_json ( offset , locale , json , continuation : continuation )
2019-08-05 23:49:13 +00:00
end
end
end
def privacy
PlaylistPrivacy :: Public
end
2018-08-15 15:22:36 +00:00
end
2019-08-05 23:49:13 +00:00
enum PlaylistPrivacy
Public = 0
Unlisted = 1
Private = 2
end
2018-09-22 19:13:10 +00:00
2019-08-05 23:49:13 +00:00
struct InvidiousPlaylist
2020-07-26 14:58:50 +00:00
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
2020-06-15 22:10:30 +00:00
def to_json ( offset , locale , json : JSON :: Builder , continuation : String ? = nil )
2019-08-05 23:49:13 +00:00
json . object do
json . field " type " , " invidiousPlaylist "
json . field " title " , self . title
json . field " playlistId " , self . id
2018-10-08 02:11:33 +00:00
2019-08-05 23:49:13 +00:00
json . field " author " , self . author
json . field " authorId " , self . ucid
json . field " authorUrl " , nil
json . field " authorThumbnails " , [ ] of String
json . field " description " , html_to_content ( self . description_html )
json . field " descriptionHtml " , self . description_html
json . field " videoCount " , self . video_count
json . field " viewCount " , self . views
json . field " updated " , self . updated . to_unix
json . field " isListed " , self . privacy . public?
json . field " videos " do
json . array do
2020-12-07 21:28:27 +00:00
if ! offset || offset == 0
index = PG_DB . query_one? ( " SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1 " , self . id , continuation , as : Int64 )
offset = self . index . index ( index ) || 0
end
2019-08-05 23:49:13 +00:00
videos = get_playlist_videos ( PG_DB , self , offset : offset , locale : locale , continuation : continuation )
videos . each_with_index do | video , index |
2020-06-15 22:10:30 +00:00
video . to_json ( locale , json , offset + index )
2019-08-05 23:49:13 +00:00
end
end
end
2018-10-08 02:11:33 +00:00
end
end
2020-06-15 22:10:30 +00:00
def to_json ( offset , locale , json : JSON :: Builder? = nil , continuation : String ? = nil )
2019-08-05 23:49:13 +00:00
if json
2020-06-15 22:10:30 +00:00
to_json ( offset , locale , json , continuation : continuation )
2019-08-05 23:49:13 +00:00
else
JSON . build do | json |
2020-06-15 22:10:30 +00:00
to_json ( offset , locale , json , continuation : continuation )
2019-08-05 23:49:13 +00:00
end
end
end
2018-09-22 19:13:10 +00:00
2019-08-05 23:49:13 +00:00
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 "
end
2018-12-24 23:47:23 +00:00
2019-08-05 23:49:13 +00:00
def author_thumbnail
nil
2018-08-15 15:22:36 +00:00
end
2019-08-05 23:49:13 +00:00
def ucid
nil
end
def views
0 _i64
end
def description_html
HTML . escape ( self . description ) . gsub ( " \n " , " <br> " )
end
end
def create_playlist ( db , title , privacy , user )
plid = " IVPL #{ Random :: Secure . urlsafe_base64 ( 24 ) [ 0 , 31 ] } "
2020-07-26 14:58:50 +00:00
playlist = InvidiousPlaylist . new ( {
title : title . byte_slice ( 0 , 150 ) ,
id : plid ,
author : user . email ,
2019-08-05 23:49:13 +00:00
description : " " , # Max 5000 characters
video_count : 0 ,
2020-07-26 14:58:50 +00:00
created : Time . utc ,
updated : Time . utc ,
privacy : privacy ,
index : [ ] of Int64 ,
} )
2019-08-05 23:49:13 +00:00
playlist_array = playlist . to_a
args = arg_array ( playlist_array )
db . exec ( " INSERT INTO playlists VALUES ( #{ args } ) " , args : playlist_array )
return playlist
2018-09-22 19:13:10 +00:00
end
2020-05-17 11:28:00 +00:00
def subscribe_playlist ( db , user , playlist )
2020-07-26 14:58:50 +00:00
playlist = InvidiousPlaylist . new ( {
title : playlist . title . byte_slice ( 0 , 150 ) ,
id : playlist . id ,
author : user . email ,
2020-05-17 11:28:00 +00:00
description : " " , # Max 5000 characters
video_count : playlist . video_count ,
2020-07-26 14:58:50 +00:00
created : Time . utc ,
updated : playlist . updated ,
privacy : PlaylistPrivacy :: Private ,
index : [ ] of Int64 ,
} )
2020-05-17 11:28:00 +00:00
playlist_array = playlist . to_a
args = arg_array ( playlist_array )
db . exec ( " INSERT INTO playlists VALUES ( #{ args } ) " , args : playlist_array )
return playlist
end
2021-03-20 18:25:02 +00:00
def produce_playlist_continuation ( id , index )
2018-08-15 15:22:36 +00:00
if id . starts_with? " UC "
id = " UU " + id . lchop ( " UC " )
end
2019-10-27 17:50:42 +00:00
plid = " VL " + id
2021-03-20 18:25:02 +00:00
# Emulate a "request counter" increment, to make perfectly valid
# ctokens, even if at the time of writing, it's ignored by youtube.
2021-03-21 15:05:50 +00:00
request_count = ( index / 100 ) . to_i64 || 1_i64
2021-03-20 18:25:02 +00:00
2019-10-27 17:50:42 +00:00
data = { " 1:varint " = > index . to_i64 }
. try { | i | Protodec :: Any . cast_json ( i ) }
. try { | i | Protodec :: Any . from_json ( i ) }
. try { | i | Base64 . urlsafe_encode ( i , padding : false ) }
2021-03-21 15:05:50 +00:00
data_wrapper = { " 1:varint " = > request_count , " 15:string " = > " PT: #{ data } " }
2021-03-20 18:25:02 +00:00
. try { | i | Protodec :: Any . cast_json ( i ) }
. try { | i | Protodec :: Any . from_json ( i ) }
. try { | i | Base64 . urlsafe_encode ( i ) }
. try { | i | URI . encode_www_form ( i ) }
2019-10-27 17:50:42 +00:00
object = {
" 80226972:embedded " = > {
2021-03-21 15:05:50 +00:00
" 2:string " = > plid ,
" 3:string " = > data_wrapper ,
2021-03-20 18:25:02 +00:00
" 35:string " = > id ,
2019-10-27 17:50:42 +00:00
} ,
}
continuation = object . try { | i | Protodec :: Any . cast_json ( object ) }
. try { | i | Protodec :: Any . from_json ( i ) }
. try { | i | Base64 . urlsafe_encode ( i ) }
. try { | i | URI . encode_www_form ( i ) }
2021-03-20 18:25:02 +00:00
return continuation
2018-08-15 15:22:36 +00:00
end
2019-08-05 23:49:13 +00:00
def get_playlist ( db , plid , locale , refresh = true , force_refresh = false )
if plid . starts_with? " IV "
if playlist = db . query_one? ( " SELECT * FROM playlists WHERE id = $1 " , plid , as : InvidiousPlaylist )
return playlist
else
2020-11-30 09:59:21 +00:00
raise InfoException . new ( " Playlist does not exist. " )
2019-08-05 23:49:13 +00:00
end
else
return fetch_playlist ( plid , locale )
end
end
2018-12-20 21:32:09 +00:00
def fetch_playlist ( plid , locale )
2018-09-22 19:13:10 +00:00
if plid . starts_with? " UC "
plid = " UU #{ plid . lchop ( " UC " ) } "
end
2021-08-03 00:58:27 +00:00
initial_data = YoutubeAPI . browse ( " VL " + plid , params : " " )
2018-08-15 15:22:36 +00:00
2021-01-04 04:35:59 +00:00
playlist_sidebar_renderer = initial_data [ " sidebar " ]? . try & . [ " playlistSidebarRenderer " ]? . try & . [ " items " ]?
raise InfoException . new ( " Could not extract playlistSidebarRenderer. " ) if ! playlist_sidebar_renderer
playlist_info = playlist_sidebar_renderer [ 0 ] [ " playlistSidebarPrimaryInfoRenderer " ]?
2020-11-30 09:59:21 +00:00
raise InfoException . new ( " Could not extract playlist info " ) if ! playlist_info
2021-01-04 04:35:59 +00:00
2020-06-25 02:18:09 +00:00
title = playlist_info [ " title " ]? . try & . [ " runs " ] [ 0 ]? . try & . [ " text " ]? . try & . as_s || " "
desc_item = playlist_info [ " description " ]?
2021-03-11 00:44:35 +00:00
description_txt = desc_item . try & . [ " runs " ]? . try & . as_a
. map ( & . [ " text " ] . as_s ) . join ( " " ) || desc_item . try & . [ " simpleText " ]? . try & . as_s || " "
description_html = desc_item . try & . [ " runs " ]? . try & . as_a
. try { | run | content_to_comment_html ( run ) . try & . to_s } || " <p></p> "
2020-06-25 02:18:09 +00:00
thumbnail = playlist_info [ " thumbnailRenderer " ]? . try & . [ " playlistVideoThumbnailRenderer " ]?
. try & . [ " thumbnail " ] [ " thumbnails " ] [ 0 ] [ " url " ]? . try & . as_s
2018-08-15 15:22:36 +00:00
2020-06-25 02:18:09 +00:00
views = 0 _i64
updated = Time . utc
video_count = 0
playlist_info [ " stats " ]? . try & . as_a . each do | stat |
text = stat [ " runs " ]? . try & . as_a . map ( & . [ " text " ] . as_s ) . join ( " " ) || stat [ " simpleText " ]? . try & . as_s
next if ! text
2019-08-22 00:08:11 +00:00
2020-07-25 16:57:15 +00:00
if text . includes? " video "
2020-06-25 02:18:09 +00:00
video_count = text . gsub ( / \ D / , " " ) . to_i? || 0
2020-07-25 16:57:15 +00:00
elsif text . includes? " view "
2020-06-25 02:18:09 +00:00
views = text . gsub ( / \ D / , " " ) . to_i64? || 0 _i64
else
updated = decode_date ( text . lchop ( " Last updated on " ) . lchop ( " Updated " ) )
end
end
2018-08-15 15:22:36 +00:00
2021-01-04 04:35:59 +00:00
if playlist_sidebar_renderer . size < 2
author = " "
author_thumbnail = " "
ucid = " "
else
author_info = playlist_sidebar_renderer [ 1 ] [ " playlistSidebarSecondaryInfoRenderer " ]? . try & . [ " videoOwner " ] [ " videoOwnerRenderer " ]?
raise InfoException . new ( " Could not extract author info " ) if ! author_info
2019-05-01 13:03:58 +00:00
2021-01-04 04:35:59 +00:00
author = author_info [ " title " ] [ " runs " ] [ 0 ] [ " text " ]? . try & . as_s || " "
author_thumbnail = author_info [ " thumbnail " ] [ " thumbnails " ] [ 0 ] [ " url " ]? . try & . as_s || " "
ucid = author_info [ " title " ] [ " runs " ] [ 0 ] [ " navigationEndpoint " ] [ " browseEndpoint " ] [ " browseId " ]? . try & . as_s || " "
end
2018-08-15 15:22:36 +00:00
2020-07-26 14:58:50 +00:00
return Playlist . new ( {
title : title ,
id : plid ,
author : author ,
2018-12-15 19:02:53 +00:00
author_thumbnail : author_thumbnail ,
2020-07-26 14:58:50 +00:00
ucid : ucid ,
2021-03-11 00:44:35 +00:00
description : description_txt ,
description_html : description_html ,
2020-07-26 14:58:50 +00:00
video_count : video_count ,
views : views ,
updated : updated ,
thumbnail : thumbnail ,
} )
2018-08-15 15:22:36 +00:00
end
2018-10-08 02:11:33 +00:00
2019-08-05 23:49:13 +00:00
def get_playlist_videos ( db , playlist , offset , locale = nil , continuation = nil )
2021-03-20 22:12:06 +00:00
# Show empy playlist if requested page is out of range
2021-04-07 13:13:41 +00:00
# (e.g, when a new playlist has been created, offset will be negative)
if offset >= playlist . video_count || offset < 0
2021-03-20 22:12:06 +00:00
return [ ] of PlaylistVideo
2019-08-05 23:49:13 +00:00
end
2021-03-20 22:12:06 +00:00
if playlist . is_a? InvidiousPlaylist
db . query_all ( " SELECT * FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 100 OFFSET $3 " ,
playlist . id , playlist . index , offset , as : PlaylistVideo )
else
2021-07-19 09:09:17 +00:00
if continuation
initial_data = request_youtube_api_next ( continuation , playlist . id )
offset = initial_data [ " contents " ] [ " twoColumnWatchNextResults " ] [ " playlist " ] [ " playlist " ] [ " currentIndex " ] . as_i
end
2021-07-16 21:32:48 +00:00
videos = [ ] of PlaylistVideo
2021-07-17 17:43:03 +00:00
until videos . size >= 100 || videos . size == playlist . video_count || offset >= playlist . video_count
2021-07-18 14:43:37 +00:00
# 100 videos per request
ctoken = produce_playlist_continuation ( playlist . id , offset )
initial_data = request_youtube_api_browse ( ctoken )
2021-07-16 21:32:48 +00:00
videos += extract_playlist_videos ( initial_data )
2021-07-16 23:48:33 +00:00
offset += 100
2021-03-20 22:12:06 +00:00
end
2020-06-25 02:18:09 +00:00
2021-07-16 21:32:48 +00:00
return videos
2019-08-05 23:49:13 +00:00
end
end
2020-06-25 02:18:09 +00:00
def extract_playlist_videos ( initial_data : Hash ( String , JSON :: Any ) )
videos = [ ] of PlaylistVideo
2021-03-20 22:12:06 +00:00
if initial_data [ " contents " ]?
tabs = initial_data [ " contents " ] [ " twoColumnBrowseResultsRenderer " ] [ " tabs " ]
tabs_renderer = tabs . as_a . select ( & . [ " tabRenderer " ] [ " selected " ]? . try & . as_bool ) [ 0 ] [ " tabRenderer " ]
2021-03-20 22:45:27 +00:00
# Watch out the two versions, with and without "s"
if tabs_renderer [ " contents " ]? || tabs_renderer [ " content " ]?
2021-03-20 22:12:06 +00:00
# Initial playlist data
2021-03-20 22:45:27 +00:00
tabs_contents = tabs_renderer . [ " contents " ]? || tabs_renderer . [ " content " ]
list_renderer = tabs_contents . [ " sectionListRenderer " ] [ " contents " ] [ 0 ]
2021-03-20 22:12:06 +00:00
item_renderer = list_renderer . [ " itemSectionRenderer " ] [ " contents " ] [ 0 ]
contents = item_renderer . [ " playlistVideoListRenderer " ] [ " contents " ] . as_a
else
# Continuation data
contents = initial_data [ " onResponseReceivedActions " ] [ 0 ]?
. try & . [ " appendContinuationItemsAction " ] [ " continuationItems " ] . as_a
end
else
contents = initial_data [ " response " ]? . try & . [ " continuationContents " ] [ " playlistVideoListContinuation " ] [ " contents " ] . as_a
end
contents . try & . each do | item |
2020-06-25 02:18:09 +00:00
if i = item [ " playlistVideoRenderer " ]?
video_id = i [ " navigationEndpoint " ] [ " watchEndpoint " ] [ " videoId " ] . as_s
plid = i [ " navigationEndpoint " ] [ " watchEndpoint " ] [ " playlistId " ] . as_s
index = i [ " navigationEndpoint " ] [ " watchEndpoint " ] [ " index " ] . as_i64
thumbnail = i [ " thumbnail " ] [ " thumbnails " ] [ 0 ] [ " url " ] . as_s
title = i [ " title " ] . try { | t | t [ " simpleText " ]? || t [ " runs " ]? . try & . [ 0 ] [ " text " ]? } . try & . as_s || " "
author = i [ " shortBylineText " ]? . try & . [ " runs " ] [ 0 ] [ " text " ] . as_s || " "
ucid = i [ " shortBylineText " ]? . try & . [ " runs " ] [ 0 ] [ " navigationEndpoint " ] [ " browseEndpoint " ] [ " browseId " ] . as_s || " "
length_seconds = i [ " lengthSeconds " ]? . try & . as_s . to_i
live = false
if ! length_seconds
live = true
length_seconds = 0
end
2020-07-26 14:58:50 +00:00
videos << PlaylistVideo . new ( {
title : title ,
id : video_id ,
author : author ,
ucid : ucid ,
2020-06-25 02:18:09 +00:00
length_seconds : length_seconds ,
2020-07-26 14:58:50 +00:00
published : Time . utc ,
plid : plid ,
live_now : live ,
2020-09-25 12:26:07 +00:00
index : index ,
2020-07-26 14:58:50 +00:00
} )
2020-06-25 02:18:09 +00:00
end
end
return videos
end
2018-10-08 02:11:33 +00:00
def template_playlist ( playlist )
html = <<-END_HTML
< h3 >
< a href = " /playlist?list= #{ playlist [ " playlistId " ] } " >
#{playlist["title"]}
< / a>
< / h3>
< div class = " pure-menu pure-menu-scrollable playlist-restricted " >
< ol class = " pure-menu-list " >
END_HTML
playlist [ " videos " ] . as_a . each do | video |
html += <<-END_HTML
2021-07-17 17:43:03 +00:00
< li class = " pure-menu-item " id = " #{ video [ " videoId " ] } " >
2021-07-16 23:38:24 +00:00
< a href = " /watch?v= #{ video [ " videoId " ] } &list= #{ playlist [ " playlistId " ] } &index= #{ video [ " index " ] } " >
2019-03-03 16:03:24 +00:00
< div class = " thumbnail " >
< img class = " thumbnail " src = " /vi/ #{ video [ " videoId " ] } /mqdefault.jpg " >
< p class = " length " > #{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
< / div>
2018-10-08 02:11:33 +00:00
< p style = " width:100% " > #{video["title"]}</p>
< p >
2019-05-02 01:03:39 +00:00
< b style = " width:100% " > #{video["author"]}</b>
2018-10-08 02:11:33 +00:00
< / p>
< / a>
< / li>
END_HTML
end
html += <<-END_HTML
< / ol>
< / div>
< hr >
END_HTML
html
end