2019-03-29 21:30:02 +00:00
struct PlaylistVideo
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
2019-04-03 16:35:58 +00:00
db_mapping ( {
2018-09-29 04:12:35 +00:00
title : String ,
id : String ,
author : String ,
ucid : String ,
length_seconds : Int32 ,
published : Time ,
2019-06-08 01:23:37 +00:00
plid : String ,
2019-08-05 23:49:13 +00:00
index : Int64 ,
2019-03-24 14:10:14 +00:00
live_now : Bool ,
2018-09-29 04:12:35 +00:00
} )
end
2019-03-29 21:30:02 +00:00
struct Playlist
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
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
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
2019-04-03 16:35:58 +00:00
db_mapping ( {
2018-09-05 00:27:10 +00:00
title : String ,
id : String ,
author : String ,
2018-09-25 15:28:40 +00:00
author_thumbnail : String ,
2018-09-05 00:27:10 +00:00
ucid : String ,
description_html : String ,
video_count : Int32 ,
views : Int64 ,
updated : Time ,
2019-08-22 00:08:11 +00:00
thumbnail : String ?,
2018-08-15 15:22:36 +00:00
} )
2019-08-05 23:49:13 +00:00
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-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
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
property thumbnail_id
module PlaylistPrivacyConverter
def self . from_rs ( rs )
return PlaylistPrivacy . parse ( String . new ( rs . read ( Slice ( UInt8 ) ) ) )
2018-09-22 19:13:10 +00:00
end
2019-08-05 23:49:13 +00:00
end
2018-09-22 19:13:10 +00:00
2019-08-05 23:49:13 +00:00
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 ) ,
} )
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 ] } "
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 ,
)
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 )
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 ,
)
playlist_array = playlist . to_a
args = arg_array ( playlist_array )
db . exec ( " INSERT INTO playlists VALUES ( #{ args } ) " , args : playlist_array )
return playlist
end
2018-09-22 19:13:10 +00:00
def extract_playlist ( plid , nodeset , index )
2018-08-15 15:22:36 +00:00
videos = [ ] of PlaylistVideo
2018-09-22 19:13:10 +00:00
nodeset . each_with_index do | video , offset |
anchor = video . xpath_node ( % q ( . / / td [ @class = " pl-video-title " ] ) )
if ! anchor
next
2018-08-15 15:22:36 +00:00
end
2018-09-22 19:13:10 +00:00
title = anchor . xpath_node ( % q ( . / / a ) ) . not_nil! . content . strip ( " \n " )
id = anchor . xpath_node ( % q ( . / / a ) ) . not_nil! [ " href " ] . lchop ( " /watch?v= " ) [ 0 , 11 ]
anchor = anchor . xpath_node ( % q ( . / / div [ @class = " pl-video-owner " ] / a ) )
if anchor
author = anchor . content
ucid = anchor [ " href " ] . split ( " / " ) [ 2 ]
else
author = " "
ucid = " "
end
anchor = video . xpath_node ( % q ( . / / td [ @class = " pl-video-time " ] / div / div [ 1 ] ) )
if anchor && ! anchor . content . empty?
length_seconds = decode_length_seconds ( anchor . content )
2019-03-24 14:10:14 +00:00
live_now = false
2018-09-22 19:13:10 +00:00
else
length_seconds = 0
2019-03-24 14:10:14 +00:00
live_now = true
2018-09-22 19:13:10 +00:00
end
videos << PlaylistVideo . new (
2018-12-20 21:32:09 +00:00
title : title ,
id : id ,
author : author ,
ucid : ucid ,
length_seconds : length_seconds ,
2019-06-08 01:23:37 +00:00
published : Time . utc ,
plid : plid ,
2019-08-05 23:49:13 +00:00
index : ( index + offset ) . to_i64 ,
2019-03-24 14:10:14 +00:00
live_now : live_now
2018-09-22 19:13:10 +00:00
)
2018-08-15 15:22:36 +00:00
end
return videos
end
def produce_playlist_url ( id , index )
if id . starts_with? " UC "
id = " UU " + id . lchop ( " UC " )
end
2019-10-27 17:50:42 +00:00
plid = " VL " + id
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 ) }
object = {
" 80226972:embedded " = > {
" 2:string " = > plid ,
" 3:base64 " = > {
" 15:string " = > " PT: #{ data } " ,
} ,
} ,
}
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 ) }
return " /browse_ajax?continuation= #{ continuation } &gl=US&hl=en "
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
raise " Playlist does not exist. "
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
2019-10-25 16:58:16 +00:00
response = YT_POOL . client & . get ( " /playlist?list= #{ plid } &hl=en&disable_polymer=1 " )
2018-09-23 17:26:12 +00:00
if response . status_code != 200
2019-04-19 16:14:11 +00:00
raise translate ( locale , " Not a playlist. " )
2018-09-23 17:26:12 +00:00
end
2019-01-05 04:48:00 +00:00
body = response . body . gsub ( / <button[^>]+><span[^>]+> \ s*less \ s*<img[^>]+> \ n< \/ span>< \/ button> / , " " )
2018-09-14 02:00:39 +00:00
document = XML . parse_html ( body )
2018-08-15 15:22:36 +00:00
2018-09-23 17:32:32 +00:00
title = document . xpath_node ( % q ( / / h1 [ @class = " pl-header-title " ] ) )
if ! title
2018-12-20 21:32:09 +00:00
raise translate ( locale , " Playlist does not exist. " )
2018-09-23 17:32:32 +00:00
end
title = title . content . strip ( " \n " )
2018-08-15 15:22:36 +00:00
2019-06-08 20:08:27 +00:00
description_html = document . xpath_node ( % q ( / /s pan [ @class = " pl-header-description-text " ] / div / div [ 1 ] ) ) . try & . to_s ||
document . xpath_node ( % q ( / /s pan [ @class = " pl-header-description-text " ] ) ) . try & . to_s || " "
2018-08-15 15:22:36 +00:00
2019-08-22 00:08:11 +00:00
playlist_thumbnail = document . xpath_node ( % q ( / / div [ @class = " pl-header-thumb " ] / img ) ) . try & . [ " data-thumb " ]? ||
document . xpath_node ( % q ( / / div [ @class = " pl-header-thumb " ] / img ) ) . try & . [ " src " ]
2019-05-01 13:03:58 +00:00
# YouTube allows anonymous playlists, so most of this can be empty or optional
anchor = document . xpath_node ( % q ( / / ul [ @class = " pl-header-details " ] ) )
author = anchor . try & . xpath_node ( % q ( . / / li [ 1 ] / a ) ) . try & . content
author || = " "
2018-09-25 15:28:40 +00:00
author_thumbnail = document . xpath_node ( % q ( / /im g [ @class = " channel-header-profile-image " ] ) ) . try & . [ " src " ]
author_thumbnail || = " "
2019-05-01 13:03:58 +00:00
ucid = anchor . try & . xpath_node ( % q ( . / / li [ 1 ] / a ) ) . try & . [ " href " ] . split ( " / " ) [ - 1 ]
ucid || = " "
2018-08-15 15:22:36 +00:00
2019-05-01 13:03:58 +00:00
video_count = anchor . try & . xpath_node ( % q ( . / / li [ 2 ] ) ) . try & . content . gsub ( / \ D / , " " ) . to_i?
video_count || = 0
2019-08-22 00:08:11 +00:00
views = anchor . try & . xpath_node ( % q ( . / / li [ 3 ] ) ) . try & . content . gsub ( / \ D / , " " ) . to_i64?
2019-05-01 13:03:58 +00:00
views || = 0 _i64
2019-08-22 00:08:11 +00:00
updated = anchor . try & . xpath_node ( % q ( . / / li [ 4 ] ) ) . try & . content . lchop ( " Last updated on " ) . lchop ( " Updated " ) . try { | date | decode_date ( date ) }
updated || = Time . utc
2018-08-15 15:22:36 +00:00
playlist = Playlist . new (
2018-12-15 19:02:53 +00:00
title : title ,
id : plid ,
author : author ,
author_thumbnail : author_thumbnail ,
ucid : ucid ,
description_html : description_html ,
video_count : video_count ,
views : views ,
2019-08-22 00:08:11 +00:00
updated : updated ,
thumbnail : playlist_thumbnail ,
2018-08-15 15:22:36 +00:00
)
return playlist
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 )
if playlist . is_a? InvidiousPlaylist
if ! offset
index = PG_DB . query_one? ( " SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1 " , playlist . id , continuation , as : Int64 )
offset = playlist . index . index ( index ) || 0
end
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
fetch_playlist_videos ( playlist . id , playlist . video_count , offset , locale , continuation )
end
end
def fetch_playlist_videos ( plid , video_count , offset = 0 , locale = nil , continuation = nil )
if continuation
2019-10-25 16:58:16 +00:00
html = YT_POOL . client & . get ( " /watch?v= #{ continuation } &list= #{ plid } &gl=US&hl=en&disable_polymer=1&has_verified=1&bpctr=9999999999 " )
2019-08-05 23:49:13 +00:00
html = XML . parse_html ( html . body )
index = html . xpath_node ( % q ( / /s pan [ @id = " playlist-current-index " ] ) ) . try & . content . to_i? . try & . - 1
offset = index || offset
end
if video_count > 100
url = produce_playlist_url ( plid , offset )
2019-10-25 16:58:16 +00:00
response = YT_POOL . client & . get ( url )
2019-08-05 23:49:13 +00:00
response = JSON . parse ( response . body )
if ! response [ " content_html " ]? || response [ " content_html " ] . as_s . empty?
raise translate ( locale , " Empty playlist " )
end
document = XML . parse_html ( response [ " content_html " ] . as_s )
nodeset = document . xpath_nodes ( % q ( . / / tr [ contains ( @class , " pl-video " ) ] ) )
videos = extract_playlist ( plid , nodeset , offset )
elsif offset > 100
return [ ] of PlaylistVideo
else # Extract first page of videos
2019-10-25 16:58:16 +00:00
response = YT_POOL . client & . get ( " /playlist?list= #{ plid } &gl=US&hl=en&disable_polymer=1 " )
2019-08-05 23:49:13 +00:00
document = XML . parse_html ( response . body )
nodeset = document . xpath_nodes ( % q ( . / / tr [ contains ( @class , " pl-video " ) ] ) )
videos = extract_playlist ( plid , nodeset , 0 )
end
until videos . empty? || videos [ 0 ] . index == offset
videos . shift
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
< li class = " pure-menu-item " >
< a href = " /watch?v= #{ video [ " videoId " ] } &list= #{ playlist [ " playlistId " ] } " >
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