Compare commits

...

38 Commits
0.6.0 ... 0.7.0

Author SHA1 Message Date
Omar Roth
e122d68acc 0.7.0 2018-09-24 19:48:06 -05:00
Omar Roth
b3ca3922a9 Add experimental dash support 2018-09-24 19:28:36 -05:00
Omar Roth
9a7852341d Use custom ranking for regions 2018-09-24 14:24:33 -05:00
Omar Roth
1922f6c69a Add more regions to geo-bypass 2018-09-23 19:29:47 -05:00
Omar Roth
a5e8f87a26 Add error for non-existent playlists 2018-09-23 12:32:32 -05:00
Omar Roth
1d9c6f63e1 Add better error for invalid playlists 2018-09-23 12:26:12 -05:00
Omar Roth
5bdd8327d4 Add better support for movies in search and watch page 2018-09-23 12:13:08 -05:00
Omar Roth
35ac887133 Add fix for playlists with less than 100 videos 2018-09-22 14:13:10 -05:00
Omar Roth
d886f8d1e3 Add fix for playlists with no videos 2018-09-22 14:11:01 -05:00
Omar Roth
d8b7c0750a Fix name of playlist resource 2018-09-22 11:34:29 -05:00
Omar Roth
1ab04638e3 Add 'videoCount' to playlists in search 2018-09-22 11:14:57 -05:00
Omar Roth
f80f4f2521 Add '/api/v1/channels/search' 2018-09-22 10:49:42 -05:00
Omar Roth
60038b29f1 Fix typo in user defaults 2018-09-21 11:06:35 -05:00
Omar Roth
099b711400 Add 'host' to API thumbnails 2018-09-21 10:11:04 -05:00
Omar Roth
b56d07556e Remove break point 2018-09-21 09:49:08 -05:00
Omar Roth
a68924f0ce Refactor name to ucid conversion 2018-09-21 09:40:04 -05:00
Omar Roth
51d00b16c3 Use hash instead of btree for channel_videos_ucid_idx 2018-09-21 08:52:27 -05:00
Omar Roth
bead12d6d0 Add styling for commits to CHANGELOG 2018-09-20 17:50:47 -05:00
Omar Roth
1703788f4b Revert "Replace ytimg with local thumbnails"
This reverts commit 95e2d8f1b7.
2018-09-20 11:05:25 -05:00
Omar Roth
6e092026d2 Fix for Reddit timezone 2018-09-20 11:04:36 -05:00
Omar Roth
95e2d8f1b7 Replace ytimg with local thumbnails 2018-09-20 10:35:49 -05:00
Omar Roth
abb15b82e6 Don't discard search results with no metadata 2018-09-20 10:24:27 -05:00
Omar Roth
687013c1b9 Add minor convenience features to search 2018-09-20 10:16:10 -05:00
Omar Roth
cefb5d679f Add label to HLS 2018-09-20 09:45:49 -05:00
Omar Roth
62380933b2 Add support for playlists and channels in search 2018-09-20 09:36:09 -05:00
Omar Roth
1627cfc2fa Update changelog 2018-09-19 19:27:08 -05:00
Omar Roth
82cc407629 Add CHANGELOG.md 2018-09-19 19:25:31 -05:00
Omar Roth
bac66c798f Remove debug info 2018-09-19 15:58:00 -05:00
Omar Roth
2c6f8022e6 Fix comments where link has no host 2018-09-19 15:25:49 -05:00
Omar Roth
fe5286a210 Add 'maxres' to video thumbnails 2018-09-19 15:24:41 -05:00
Omar Roth
bb604c8925 Add movies to search results 2018-09-19 15:24:19 -05:00
Omar Roth
c166f46b7c Add caption URL to video info 2018-09-19 14:08:59 -05:00
Omar Roth
4c8bb3b293 Fix typo 2018-09-19 10:39:07 -05:00
Omar Roth
0f5b93e394 Add 'authorId' to /api/v1/trending and /api/v1/top 2018-09-19 10:37:00 -05:00
Omar Roth
54f9941c7b Add alias '/api/v1/channels/videos/:ucid' 2018-09-18 10:47:22 -05:00
Omar Roth
8500a62462 Add /api/v1/insights 2018-09-17 20:08:26 -05:00
Omar Roth
53c8b5ff2e Minor refactor 2018-09-17 20:07:32 -05:00
Omar Roth
64cc0362fb Add fix for playlists with no views 2018-09-17 19:21:13 -05:00
25 changed files with 994 additions and 459 deletions

116
CHANGELOG.md Normal file
View File

@@ -0,0 +1,116 @@
# 0.6.0 (2018-09-18)
## Week 6: Filters and Thumbnails
Hello again! This week I'm happy to mention a couple new features to search as well as some miscellaneous usability improvements.
You can now constrain your search query to a specific channel with the `channel:CHANNEL` filter (see [#165](https://github.com/omarroth/invidious/issues/165) for more details). Unfortunately, other search filters combined with channel search are not yet supported. I hope to add support for them in the coming weeks.
You can also now search only your subscriptions by adding `subscriptions:true` to your query (see [#30](https://github.com/omarroth/invidious/issues/30) for more details). It's not quite ready for widespread use but I would appreciate feedback as the site updates to fully support it. Other search filters are not yet supported with `subscriptions:true`, but I hope to add more functionality to this as well.
With [#153](https://github.com/omarroth/invidious/issues/153) and [#168](https://github.com/omarroth/invidious/issues/168) all images on the site are now proxied through Invidious. In addition to offering the user more protection from Google's eyes, it also allows the site to automatically pick out the highest resolution thumbnail for videos. I think this is quite a large aesthetic improvement and I hope others will find the same.
As a smaller improvement to the site, you can also now view RSS feeds for playlists with [#113](https://github.com/omarroth/invidious/issues/113).
These updates are also now listed under Github's [releases](https://github.com/omarroth/invidious/releases). I'm also planning on adding them as a `CHANGELOG.md` in the repository itself so people can receive a copy with the project's source.
That's all for this week. Thank you everyone for your support!
# 0.5.0 (2018-09-11)
## Week 5: Privacy and Security
I hope everyone had a good weekend! This past week I've been fixing some issues that have been brought to my attention to help better protect users and help them keep their anonymity.
An issue with open referers has been fixed with [`29a2186`](https://github.com/omarroth/invidious/29a2186), which prevents potential redirects to external sites on actions such as login or modifying preferences.
Additionally, X-XSS-Protection, X-Content-Type-Options, and X-Frame-Options headers have been added with [`96234e5`](https://github.com/omarroth/invidious/96234e5), which should keep users safer while using the site.
A potential XSS vector has also been fixed in YouTube comments with [`8c45694`](https://github.com/omarroth/invidious/8c45694).
All the above vulnerabilities were brought to my attention by someone who wishes to remain anonymous, but I would like to say again here how thankful I am. If anyone else would like to get in touch please feel free to email me at omarroth@hotmail.com or omarroth@protonmail.com.
This week a couple changes have been made to better protect user's privacy as well.
All CSS and JS assets are now served locally with [`3ec684a`](https://github.com/omarroth/invidious/3ec684a), which means users no longer need to whitelist unpkg.com. Although I personally have encountered few issues, I understand that many folks would like to keep their browsing activity contained to as few parties as possible. In the coming week I also hope to proxy YouTube images, so that no user data is sent to Google.
YouTube links in comments now should redirect properly to the Invidious alternate with [`1c8bd67`](https://github.com/omarroth/invidious/1c8bd67) and [`cf63c82`](https://github.com/omarroth/invidious/cf63c82), so users can more easily evade Google tracking.
I'm also happy to mention a couple quality of life features this week:
Invidious now shows a video's "license" if provided, see [#159](https://github.com/omarroth/invidious/issues/159) for more details. You can also search for videos licensed under the creative commons with "QUERY features:creative_commons".
Videos with only one source will always display the cog for changing quality, so that users can see what quality is currently playing. See [#158](https://github.com/omarroth/invidious/issues/158) for more details.
Folks have also probably noticed that the gutters on either side of the screen have been shrunk down quite significantly, so that more of the screen is filled with content. Hopefully this can be improved even more in the coming weeks.
"Music", "Sports", and "Popular on YouTube" channels now properly display their videos. You can subscribe to these channels just as you would normally.
This coming week I'm planning on spending time with my family, so I unfortunately may not be as responsive. I do still hope to add some smaller features for next week however, and I hope to continue development soon.
Thank you everyone again for your support.
# 0.4.0 (2018-09-06)
## Week 4: Genre Channels
Hello! I hope everyone enjoyed their weekend. Without further ado:
Just today genre channels have been added with [#119](https://github.com/omarroth/invidious/issues/119). More information on genre channels is available [here](https://support.google.com/youtube/answer/2579942). You can subscribe to them as normally, and view them as RSS. I think they offer an interesting alternative way to find new content and I hope people find them useful.
This past week folks have started reporting 504s on their subscription page (see [#144](https://github.com/omarroth/invidious/issues/144) for more details). Upgrading the database server appeared to fix the issue, as well as providing a smoother experience across the site. Unfortunately, that means I will be increasing the goal from $50 to $60 in order to meet the increased hosting costs.
With [#134](https://github.com/omarroth/invidious/issues/134), comments are now formatted correctly, providing support for bold, italics, and links in comments. I think this improvement makes them much easier to read, and I hope others find the same. Also to note is that links in both comments and the video description now no longer contain any of Google's tracking with [#115](https://github.com/omarroth/invidious/issues/115).
One of the major use cases for Invidious is as a stripped-down version of YouTube. In line with that, I'm happy to announce that you can now hide related videos if you're logged in, for users that prefer an even more lightweight experience.
Finally, I'm pleased to announce that Invidious has hit 100 stars on GitHub. I am very happy that Invidious has proven to be useful to so many people, and I can't say how grateful I am to everyone for their continued support.
Enjoy the rest of your week everyone!
# 0.3.0 (2018-09-06)
## Week 3: Quality of Life
Hello everyone! This week I've been working on some smaller features that will hopefully make the site more functional.
Search filters have been added with [#126](https://github.com/omarroth/invidious/issues/126). You can now specify 'sort', 'date', 'duration', and 'features' within your query using the 'operator:value' syntax. I'd recommend taking a look [here](https://github.com/omarroth/invidious/blob/master/src/invidious/search.cr#L33-L114) for a list of supported options and at [#126](https://github.com/omarroth/invidious/issues/126) for some examples. This also opens the door for features such as [#30](https://github.com/omarroth/invidious/issues/30) which can be implemented as filters. I think advanced search is a major point in which Invidious can improve on YouTube and hope to add more features soon!
This week a more advanced system for viewing fallback comments has been added (see [#84](https://github.com/omarroth/invidious/issues/84) for more details). You can now specify a comment fallback in your preferences, which Invidious will use. If, for example, no Reddit comments are available for a given video, it can choose to fallback on YouTube comments. This also makes it possible to turn comments off completely for users that prefer a more streamlined experience.
With [#98](https://github.com/omarroth/invidious/issues/98), it is now possible for users to specify preferences without creating an account. You can now change speed, volume, subtitles, autoplay, loop, and quality using query parameters. See the issue above for more details and several examples.
I'd also like to announce that I've set up an account on [Liberapay](https://liberapay.com/omarroth), for patrons that prefer a privacy-friendly alternative to Patreon. Liberapay also does not take any percentage of donations, so I'd recommend donating some to the Liberapay for their hard work. Go check it out!
[Two weeks ago](https://github.com/omarroth/invidious/releases/tag/0.1.0) I mentioned adding 1080p support into the player. Currently, the only thing blocking is [#207](https://github.com/videojs/http-streaming/pull/207) in the excellent [http-streaming](https://github.com/videojs/http-streaming) library. I hope to work with the videojs team to merge it soon and finally implement 1080p support!
That's all for this week, thank you again everyone for your support!
# 0.2.0 (2018-09-06)
## Week 2: Toward Playlists
Sorry for the late update! Not as much to announce this week, but still a couple things of note:
I'm happy to announce that a playlists page and API endpoint has been added so you can now view playlists. Currently, you cannot watch playlists through the player, but I hope to add that in the coming week as well as adding functionality to add and modify playlists. There is a good conversation on [#114](https://github.com/omarroth/invidious/issues/114) about giving playlists even more functionality, which I think is interesting and would appreciate feedback on.
As an update to the Invidious API announcement last week, I've been working with [**@PrestonN**](https://github.com/PrestonN), the developer of [FreeTube](https://github.com/FreeTubeApp/FreeTube), to help migrate his project to the Invidious API. Because of it's increasing popularity, he has had trouble keeping under the quota set by YouTube's API. I hope to improve the API to meet his and others needs and I'd recommend folks to keep an eye on his excellent project! There is a good discussion with his thoughts [here](https://github.com/FreeTubeApp/FreeTube/issues/100).
A couple of miscellaneous features and bugfixes:
- You can now login to Invidious simultaneously from multiple devices - [#109](https://github.com/omarroth/invidious/issues/109)
- Added a note for scheduled livestreams - [#124](https://github.com/omarroth/invidious/issues/124)
- Changed YouTube comment header to "View x comments" - [#120](https://github.com/omarroth/invidious/issues/120)
Enjoy your week everyone!
# 0.1.0 (2018-09-06)
## Week 1: Invidious API and Geo-Bypass
Hello everyone! This past week there have been quite a few things worthy of mention:
I'm happy to announce the [Invidious Developer API](https://github.com/omarroth/invidious/wiki/API). The Invidious API does not use any of the official YouTube APIs, and instead crawls the site to provide a JSON interface for other developers to use. It's still under development but is already powering [CloudTube](https://github.com/cloudrac3r/cadencegq). The API currently does not have a quota (compared to YouTube) which I hope to continue thanks to continued support from my Patrons. Hopefully other developers find it useful, and I hope to continue to improve it so it can better serve the community.
Just today partial support for bypassing geo-restrictions has been added with [fada57a](https://github.com/omarroth/invidious/commit/fada57a307d66d696d9286fc943c579a3fd22de6). If a video is unblocked in one of: United States, Canada, Germany, France, Japan, Russia, or United Kingdom, then Invidious will be able to serve video info. Currently you will not yet be able to access the video files themselves, but in the coming week I hope to proxy videos so that users can enjoy content across borders.
Support for generating DASH manifests has been fixed, in the coming week I hope to integrate this functionality into the watch page, so users can view videos in 1080p and above.
Thank you everyone for your continued interest and support!

29
assets/js/dash.mediaplayer.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
assets/js/videojs-dash.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -33,6 +33,6 @@ CREATE INDEX channel_videos_published_idx
-- DROP INDEX public.channel_videos_ucid_idx;
CREATE INDEX channel_videos_ucid_idx
ON public.channel_videos USING btree
ON public.channel_videos USING hash
(ucid COLLATE pg_catalog."default")
TABLESPACE pg_default;

View File

@@ -1,5 +1,5 @@
name: invidious
version: 0.4.0
version: 0.7.0
authors:
- Omar Roth <omarroth@hotmail.com>

View File

@@ -390,16 +390,12 @@ get "/playlist" do |env|
page = env.params.query["page"]?.try &.to_i?
page ||= 1
if plid
begin
videos = extract_playlist(plid, page)
rescue ex
error_message = ex.message
next templated "error"
end
begin
playlist = fetch_playlist(plid)
else
next env.redirect "/"
videos = fetch_playlist_videos(plid, page, playlist.video_count)
rescue ex
error_message = ex.message
next templated "error"
end
templated "playlist"
@@ -438,6 +434,7 @@ get "/search" do |env|
ucids ||= [] of String
channel = nil
content_type = "all"
date = ""
duration = ""
features = [] of String
@@ -446,11 +443,13 @@ get "/search" do |env|
operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) }
operators.each do |operator|
key, value = operator.split(":")
key, value = operator.downcase.split(":")
case key
when "channel", "user"
channel = value
when "content_type", "type"
content_type = value
when "date"
date = value
when "duration"
@@ -471,15 +470,15 @@ get "/search" do |env|
elsif subscriptions
videos = PG_DB.query_all("SELECT id,title,published,updated,ucid,author FROM (
SELECT *,
to_tsvector(channel_videos.title) ||
to_tsvector(channel_videos.author)
as document
to_tsvector(channel_videos.title) ||
to_tsvector(channel_videos.author)
as document
FROM channel_videos WHERE ucid IN (#{arg_array(ucids, 3)})
) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", [search_query, (page - 1) * 20] + ucids, as: ChannelVideo)
) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", [search_query, (page - 1) * 20] + ucids, as: ChannelVideo)
count = videos.size
else
begin
search_params = produce_search_params(sort: sort, date: date, content_type: "video",
search_params = produce_search_params(sort: sort, date: date, content_type: content_type,
duration: duration, features: features)
rescue ex
error_message = ex.message
@@ -1337,12 +1336,12 @@ get "/feed/subscriptions" do |env|
end
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM channel_videos WHERE \
ucid IN (#{ucids}) AND id NOT IN (#{watched}) ORDER BY ucid, published DESC",
ucid IN (#{ucids}) AND id NOT IN (#{watched}) ORDER BY ucid, published DESC",
user.subscriptions + user.watched, as: ChannelVideo)
else
args = arg_array(user.subscriptions)
videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM channel_videos WHERE \
ucid IN (#{args}) ORDER BY ucid, published DESC", user.subscriptions, as: ChannelVideo)
ucid IN (#{args}) ORDER BY ucid, published DESC", user.subscriptions, as: ChannelVideo)
end
videos.sort_by! { |video| video.published }.reverse!
@@ -1400,44 +1399,18 @@ get "/feed/subscriptions" do |env|
end
get "/feed/channel/:ucid" do |env|
env.response.content_type = "text/xml"
ucid = env.params.url["ucid"]
begin
author, ucid, auto_generated = get_about_info(ucid)
rescue ex
error_message = "User does not exist"
halt env, status_code: 404, response: error_message
end
client = make_client(YT_URL)
if !ucid.match(/UC[a-zA-Z0-9_-]{22}/)
rss = client.get("/feeds/videos.xml?user=#{ucid}")
rss = XML.parse_html(rss.body)
ucid = rss.xpath_node("//feed/channelid")
if !ucid
error_message = "User does not exist."
halt env, status_code: 404, response: error_message
end
ucid = ucid.content
author = rss.xpath_node("//author/name").not_nil!.content
next env.redirect "/feed/channel/#{ucid}"
else
rss = client.get("/feeds/videos.xml?channel_id=#{ucid}")
rss = XML.parse_html(rss.body)
ucid = rss.xpath_node("//feed/channelid")
if !ucid
error_message = "User does not exist."
next templated "error"
end
ucid = ucid.content
author = rss.xpath_node("//author/name").not_nil!.content
end
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
end
page = 1
videos = [] of SearchVideo
@@ -1460,8 +1433,6 @@ get "/feed/channel/:ucid" do |env|
end
end
channel = get_channel(ucid, client, PG_DB, pull_all_videos: false)
host_url = make_host_url(Kemal.config.ssl || CONFIG.https_only, env.request.headers["Host"]?)
path = env.request.path
@@ -1471,11 +1442,11 @@ get "/feed/channel/:ucid" do |env|
xml.element("link", rel: "self", href: "#{host_url}#{path}")
xml.element("id") { xml.text "yt:channel:#{ucid}" }
xml.element("yt:channelId") { xml.text ucid }
xml.element("title") { xml.text channel.author }
xml.element("title") { xml.text author }
xml.element("link", rel: "alternate", href: "#{host_url}/channel/#{ucid}")
xml.element("author") do
xml.element("name") { xml.text channel.author }
xml.element("name") { xml.text author }
xml.element("uri") { xml.text "#{host_url}/channel/#{ucid}" }
end
@@ -1514,7 +1485,6 @@ get "/feed/channel/:ucid" do |env|
end
end
env.response.content_type = "text/xml"
feed
end
@@ -1692,40 +1662,11 @@ get "/channel/:ucid" do |env|
page = env.params.query["page"]?.try &.to_i?
page ||= 1
client = make_client(YT_URL)
if !ucid.match(/UC[a-zA-Z0-9_-]{22}/)
rss = client.get("/feeds/videos.xml?user=#{ucid}")
rss = XML.parse_html(rss.body)
ucid = rss.xpath_node("//feed/channelid")
if !ucid
error_message = "User does not exist."
next templated "error"
end
ucid = ucid.content
author = rss.xpath_node("//author/name").not_nil!.content
next env.redirect "/channel/#{ucid}"
else
rss = client.get("/feeds/videos.xml?channel_id=#{ucid}")
rss = XML.parse_html(rss.body)
ucid = rss.xpath_node("//feed/channelid")
if !ucid
error_message = "User does not exist."
next templated "error"
end
ucid = ucid.content
author = rss.xpath_node("//author/name").not_nil!.content
end
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
begin
author, ucid, auto_generated = get_about_info(ucid)
rescue ex
error_message = "User does not exist"
next templated "error"
end
if !auto_generated
@@ -1736,6 +1677,8 @@ get "/channel/:ucid" do |env|
end
end
client = make_client(YT_URL)
videos = [] of SearchVideo
2.times do |i|
url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated)
@@ -1798,6 +1741,7 @@ get "/api/v1/captions/:id" do |env|
json.object do
json.field "label", caption.name.simpleText
json.field "languageCode", caption.languageCode
json.field "url", "/api/v1/captions/#{id}?label=#{URI.escape(caption.name.simpleText)}"
end
end
end
@@ -1967,43 +1911,7 @@ get "/api/v1/comments/:id" do |env|
content_html = HTML.escape(content_html)
end
content_html ||= node_comment["contentText"]["runs"].as_a.map do |run|
text = HTML.escape(run["text"].as_s)
if run["text"] == "\n"
text = "<br>"
end
if run["bold"]?
text = "<b>#{text}</b>"
end
if run["italics"]?
text = "<i>#{text}</i>"
end
if run["navigationEndpoint"]?
url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s
if url
url = URI.parse(url)
if {"m.youtube.com", "www.youtube.com", "youtu.be"}.includes? url.host
if url.path == "/redirect"
url = HTTP::Params.parse(url.query.not_nil!)["q"]
else
url = url.full_path
end
end
else
url = run["navigationEndpoint"]["commandMetadata"]?.try &.["webCommandMetadata"]["url"].as_s
end
text = %(<a href="#{url}">#{text}</a>)
end
text
end.join.rchop('\ufeff')
content_html ||= content_to_comment_html(node_comment["contentText"]["runs"].as_a)
content_html, content = html_to_content(content_html)
author = node_comment["authorText"]?.try &.["simpleText"]
@@ -2091,7 +1999,7 @@ get "/api/v1/comments/:id" do |env|
end
elsif source == "reddit"
client = make_client(REDDIT_URL)
headers = HTTP::Headers{"User-Agent" => "web:invidio.us:v0.2.0 (by /u/omarroth)"}
headers = HTTP::Headers{"User-Agent" => "web:invidio.us:v0.6.0 (by /u/omarroth)"}
begin
comments, reddit_thread = get_reddit_comments(id, client, headers)
content_html = template_reddit_comments(comments)
@@ -2124,6 +2032,86 @@ get "/api/v1/comments/:id" do |env|
end
end
get "/api/v1/insights/:id" do |env|
id = env.params.url["id"]
env.response.content_type = "application/json"
client = make_client(YT_URL)
headers = HTTP::Headers.new
html = client.get("/watch?v=#{id}&disable_polymer=1")
headers["cookie"] = html.cookies.add_request_headers(headers)["cookie"]
headers["content-type"] = "application/x-www-form-urlencoded"
headers["x-client-data"] = "CIi2yQEIpbbJAQipncoBCNedygEIqKPKAQ=="
headers["x-spf-previous"] = "https://www.youtube.com/watch?v=#{id}"
headers["x-spf-referer"] = "https://www.youtube.com/watch?v=#{id}"
headers["x-youtube-client-name"] = "1"
headers["x-youtube-client-version"] = "2.20180719"
body = html.body
session_token = body.match(/'XSRF_TOKEN': "(?<session_token>[A-Za-z0-9\_\-\=]+)"/).not_nil!["session_token"]
post_req = {
"session_token" => session_token,
}
post_req = HTTP::Params.encode(post_req)
response = client.post("/insight_ajax?action_get_statistics_and_data=1&v=#{id}", headers, post_req).body
response = XML.parse(response)
html_content = XML.parse_html(response.xpath_node(%q(//html_content)).not_nil!.content)
graph_data = response.xpath_node(%q(//graph_data))
if !graph_data
error = html_content.xpath_node(%q(//p)).not_nil!.content
next {"error" => error}.to_json
end
graph_data = JSON.parse(graph_data.content)
view_count = 0_i64
time_watched = 0_i64
subscriptions_driven = 0
shares = 0
stats_nodes = html_content.xpath_nodes(%q(//table/tr/td))
stats_nodes.each do |node|
key = node.xpath_node(%q(.//span))
value = node.xpath_node(%q(.//div))
if !key || !value
next
end
key = key.content
value = value.content
case key
when "Views"
view_count = value.delete(", ").to_i64
when "Time watched"
time_watched = value
when "Subscriptions driven"
subscriptions_driven = value.delete(", ").to_i
when "Shares"
shares = value.delete(", ").to_i
end
end
avg_view_duration_seconds = html_content.xpath_node(%q(//div[@id="stats-chart-tab-watch-time"]/span/span[2])).not_nil!.content
avg_view_duration_seconds = decode_length_seconds(avg_view_duration_seconds)
{
"viewCount" => view_count,
"timeWatchedText" => time_watched,
"subscriptionsDriven" => subscriptions_driven,
"shares" => shares,
"avgViewDurationSeconds" => avg_view_duration_seconds,
"graphData" => graph_data,
}.to_json
end
get "/api/v1/videos/:id" do |env|
id = env.params.url["id"]
@@ -2131,8 +2119,8 @@ get "/api/v1/videos/:id" do |env|
video = get_video(id, PG_DB)
rescue ex
env.response.content_type = "application/json"
response = {"error" => ex.message}.to_json
halt env, status_code: 500, response: response
error_message = {"error" => ex.message}.to_json
halt env, status_code: 500, response: error_message
end
fmt_stream = video.fmt_stream(decrypt_function)
@@ -2278,6 +2266,7 @@ get "/api/v1/videos/:id" do |env|
json.object do
json.field "label", caption.name.simpleText
json.field "languageCode", caption.languageCode
json.field "url", "/api/v1/captions/#{id}?label=#{URI.escape(caption.name.simpleText)}"
end
end
end
@@ -2329,6 +2318,7 @@ get "/api/v1/trending" do |env|
json.field "viewCount", video.views
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "published", video.published.epoch
@@ -2359,6 +2349,7 @@ get "/api/v1/top" do |env|
json.field "viewCount", video.views
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
@@ -2378,42 +2369,18 @@ get "/api/v1/top" do |env|
end
get "/api/v1/channels/:ucid" do |env|
env.response.content_type = "application/json"
ucid = env.params.url["ucid"]
begin
author, ucid, auto_generated = get_about_info(ucid)
rescue ex
error_message = {"error" => "User does not exist"}.to_json
halt env, status_code: 404, response: error_message
end
client = make_client(YT_URL)
if !ucid.match(/UC[a-zA-Z0-9_-]{22}/)
rss = client.get("/feeds/videos.xml?user=#{ucid}")
rss = XML.parse_html(rss.body)
ucid = rss.xpath_node("//feed/channelid")
if !ucid
env.response.content_type = "application/json"
next {"error" => "User does not exist"}.to_json
end
ucid = ucid.content
author = rss.xpath_node("//author/name").not_nil!.content
next env.redirect "/api/v1/channels/#{ucid}"
else
rss = client.get("/feeds/videos.xml?channel_id=#{ucid}")
rss = XML.parse_html(rss.body)
ucid = rss.xpath_node("//feed/channelid")
if !ucid
error_message = "User does not exist."
next templated "error"
end
ucid = ucid.content
author = rss.xpath_node("//author/name").not_nil!.content
end
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
end
page = 1
@@ -2496,7 +2463,7 @@ get "/api/v1/channels/:ucid" do |env|
json.field "authorThumbnails" do
json.array do
qualities = [32, 48, 76, 100, 512]
qualities = [32, 48, 76, 100, 176, 512]
qualities.each do |quality|
json.object do
@@ -2554,109 +2521,182 @@ get "/api/v1/channels/:ucid" do |env|
end
end
env.response.content_type = "application/json"
channel_info
end
get "/api/v1/channels/:ucid/videos" do |env|
["/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"].each do |route|
get route do |env|
env.response.content_type = "application/json"
ucid = env.params.url["ucid"]
page = env.params.query["page"]?.try &.to_i?
page ||= 1
begin
author, ucid, auto_generated = get_about_info(ucid)
rescue ex
error_message = {"error" => "User does not exist"}.to_json
halt env, status_code: 404, response: error_message
end
client = make_client(YT_URL)
videos = [] of SearchVideo
2.times do |i|
url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated)
response = client.get(url)
json = JSON.parse(response.body)
if json["content_html"]? && !json["content_html"].as_s.empty?
document = XML.parse_html(json["content_html"].as_s)
nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
if auto_generated
videos += extract_videos(nodeset)
else
videos += extract_videos(nodeset, ucid)
end
else
break
end
end
result = JSON.build do |json|
json.array do
videos.each do |video|
json.object do
json.field "title", video.title
json.field "videoId", video.id
if auto_generated
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.ucid}"
else
json.field "author", author
json.field "authorId", ucid
json.field "authorUrl", "/channel/#{ucid}"
end
json.field "videoThumbnails" do
generate_thumbnails(json, video.id)
end
json.field "description", video.description
json.field "descriptionHtml", video.description_html
json.field "viewCount", video.views
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
json.field "lengthSeconds", video.length_seconds
end
end
end
end
result
end
end
get "/api/v1/channels/search/:ucid" do |env|
env.response.content_type = "application/json"
ucid = env.params.url["ucid"]
query = env.params.query["q"]?
query ||= ""
page = env.params.query["page"]?.try &.to_i?
page ||= 1
client = make_client(YT_URL)
if !ucid.match(/UC[a-zA-Z0-9_-]{22}/)
rss = client.get("/feeds/videos.xml?user=#{ucid}")
rss = XML.parse_html(rss.body)
ucid = rss.xpath_node("//feed/channelid")
if !ucid
env.response.content_type = "application/json"
next {"error" => "User does not exist"}.to_json
end
ucid = ucid.content
author = rss.xpath_node("//author/name").not_nil!.content
next env.redirect "/feed/channel/#{ucid}"
else
rss = client.get("/feeds/videos.xml?channel_id=#{ucid}")
rss = XML.parse_html(rss.body)
ucid = rss.xpath_node("//feed/channelid")
if !ucid
error_message = "User does not exist."
next templated "error"
end
ucid = ucid.content
author = rss.xpath_node("//author/name").not_nil!.content
end
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
if author.ends_with?(" - Topic") ||
{"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author
auto_generated = true
end
videos = [] of SearchVideo
2.times do |i|
url = produce_channel_videos_url(ucid, page * 2 + (i - 1), auto_generated: auto_generated)
response = client.get(url)
json = JSON.parse(response.body)
if json["content_html"]? && !json["content_html"].as_s.empty?
document = XML.parse_html(json["content_html"].as_s)
nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
if auto_generated
videos += extract_videos(nodeset)
else
videos += extract_videos(nodeset, ucid)
end
else
break
end
end
result = JSON.build do |json|
count, search_results = channel_search(query, page, ucid)
response = JSON.build do |json|
json.array do
videos.each do |video|
search_results.each do |item|
json.object do
json.field "title", video.title
json.field "videoId", video.id
case item
when SearchVideo
json.field "type", "video"
json.field "title", item.title
json.field "videoId", item.id
if auto_generated
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.ucid}"
else
json.field "author", author
json.field "authorId", ucid
json.field "authorUrl", "/channel/#{ucid}"
json.field "author", item.author
json.field "authorId", item.ucid
json.field "authorUrl", "/channel/#{item.ucid}"
json.field "videoThumbnails" do
generate_thumbnails(json, item.id)
end
json.field "description", item.description
json.field "descriptionHtml", item.description_html
json.field "viewCount", item.views
json.field "published", item.published.epoch
json.field "publishedText", "#{recode_date(item.published)} ago"
json.field "lengthSeconds", item.length_seconds
json.field "liveNow", item.live_now
when SearchPlaylist
json.field "type", "playlist"
json.field "title", item.title
json.field "playlistId", item.id
json.field "author", item.author
json.field "authorId", item.ucid
json.field "authorUrl", "/channel/#{item.ucid}"
json.field "videoCount", item.video_count
json.field "videos" do
json.array do
item.videos.each do |video|
json.object do
json.field "title", video.title
json.field "videoId", video.id
json.field "lengthSeconds", video.length_seconds
json.field "videoThumbnails" do
generate_thumbnails(json, video.id)
end
end
end
end
end
when SearchChannel
json.field "type", "channel"
json.field "author", item.author
json.field "authorId", item.ucid
json.field "authorUrl", "/channel/#{item.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", item.author_thumbnail.gsub("=s176-", "=s#{quality}-")
json.field "width", quality
json.field "height", quality
end
end
end
end
json.field "subCount", item.subscriber_count
json.field "videoCount", item.video_count
json.field "description", item.description
json.field "descriptionHtml", item.description_html
end
json.field "videoThumbnails" do
generate_thumbnails(json, video.id)
end
json.field "description", video.description
json.field "descriptionHtml", video.description_html
json.field "viewCount", video.views
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
json.field "lengthSeconds", video.length_seconds
end
end
end
end
env.response.content_type = "application/json"
result
response
end
get "/api/v1/search" do |env|
env.response.content_type = "application/json"
query = env.params.query["q"]?
query ||= ""
@@ -2676,13 +2716,13 @@ get "/api/v1/search" do |env|
features ||= [] of String
# TODO: Support other content types
content_type = "video"
env.response.content_type = "application/json"
content_type = env.params.query["type"]?.try &.downcase
content_type ||= "video"
begin
search_params = produce_search_params(sort_by, date, content_type, duration, features)
rescue ex
env.response.status_code = 400
next JSON.build do |json|
json.object do
json.field "error", ex.message
@@ -2690,29 +2730,83 @@ get "/api/v1/search" do |env|
end
end
count, search_results = search(query, page, search_params).as(Tuple)
response = JSON.build do |json|
json.array do
count, search_results = search(query, page, search_params).as(Tuple)
search_results.each do |video|
search_results.each do |item|
json.object do
json.field "title", video.title
json.field "videoId", video.id
case item
when SearchVideo
json.field "type", "video"
json.field "title", item.title
json.field "videoId", item.id
json.field "author", video.author
json.field "authorId", video.ucid
json.field "authorUrl", "/channel/#{video.ucid}"
json.field "author", item.author
json.field "authorId", item.ucid
json.field "authorUrl", "/channel/#{item.ucid}"
json.field "videoThumbnails" do
generate_thumbnails(json, video.id)
json.field "videoThumbnails" do
generate_thumbnails(json, item.id)
end
json.field "description", item.description
json.field "descriptionHtml", item.description_html
json.field "viewCount", item.views
json.field "published", item.published.epoch
json.field "publishedText", "#{recode_date(item.published)} ago"
json.field "lengthSeconds", item.length_seconds
json.field "liveNow", item.live_now
when SearchPlaylist
json.field "type", "playlist"
json.field "title", item.title
json.field "playlistId", item.id
json.field "author", item.author
json.field "authorId", item.ucid
json.field "authorUrl", "/channel/#{item.ucid}"
json.field "videoCount", item.video_count
json.field "videos" do
json.array do
item.videos.each do |video|
json.object do
json.field "title", video.title
json.field "videoId", video.id
json.field "lengthSeconds", video.length_seconds
json.field "videoThumbnails" do
generate_thumbnails(json, video.id)
end
end
end
end
end
when SearchChannel
json.field "type", "channel"
json.field "author", item.author
json.field "authorId", item.ucid
json.field "authorUrl", "/channel/#{item.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", item.author_thumbnail.gsub("=s176-", "=s#{quality}-")
json.field "width", quality
json.field "height", quality
end
end
end
end
json.field "subCount", item.subscriber_count
json.field "videoCount", item.video_count
json.field "description", item.description
json.field "descriptionHtml", item.description_html
end
json.field "description", video.description
json.field "descriptionHtml", video.description_html
json.field "viewCount", video.views
json.field "published", video.published.epoch
json.field "publishedText", "#{recode_date(video.published)} ago"
json.field "lengthSeconds", video.length_seconds
end
end
end
@@ -2722,25 +2816,24 @@ get "/api/v1/search" do |env|
end
get "/api/v1/playlists/:plid" do |env|
env.response.content_type = "application/json"
plid = env.params.url["plid"]
page = env.params.query["page"]?.try &.to_i?
page ||= 1
begin
videos = extract_playlist(plid, page)
playlist = fetch_playlist(plid)
videos = fetch_playlist_videos(plid, page, playlist.video_count)
rescue ex
env.response.content_type = "application/json"
response = {"error" => "Playlist is empty"}.to_json
halt env, status_code: 404, response: response
error_message = {"error" => "Playlist is empty"}.to_json
halt env, status_code: 404, response: error_message
end
playlist = fetch_playlist(plid)
response = JSON.build do |json|
json.object do
json.field "title", playlist.title
json.field "id", playlist.id
json.field "playlistId", playlist.id
json.field "author", playlist.author
json.field "authorId", playlist.ucid
@@ -2758,7 +2851,7 @@ get "/api/v1/playlists/:plid" do |env|
videos.each do |video|
json.object do
json.field "title", video.title
json.field "id", video.id
json.field "videoId", video.id
json.field "author", video.author
json.field "authorId", video.ucid
@@ -2777,7 +2870,6 @@ get "/api/v1/playlists/:plid" do |env|
end
end
env.response.content_type = "application/json"
response
end

View File

@@ -176,3 +176,32 @@ def produce_channel_videos_url(ucid, page = 1, auto_generated = nil)
return url
end
def get_about_info(ucid)
client = make_client(YT_URL)
about = client.get("/user/#{ucid}/about?disable_polymer=1")
about = XML.parse_html(about.body)
if !about.xpath_node(%q(//span[@class="qualified-channel-title-text"]/a))
about = client.get("/channel/#{ucid}/about?disable_polymer=1")
about = XML.parse_html(about.body)
end
if !about.xpath_node(%q(//span[@class="qualified-channel-title-text"]/a))
raise "User does not exist."
end
author = about.xpath_node(%q(//span[@class="qualified-channel-title-text"]/a)).not_nil!.content
ucid = about.xpath_node(%q(//link[@rel="canonical"])).not_nil!["href"].split("/")[-1]
# Auto-generated channels
# https://support.google.com/youtube/answer/2579942
auto_generated = false
if about.xpath_node(%q(//ul[@class="about-custom-links"]/li/a[@title="Auto-generated by YouTube"])) ||
about.xpath_node(%q(//span[@class="qualified-channel-title-badge"]/span[@title="Auto-generated by YouTube"]))
auto_generated = true
end
return {author, ucid, auto_generated}
end

View File

@@ -17,12 +17,12 @@ class RedditComment
end
JSON.mapping({
author: String,
body_html: String,
replies: RedditThing | String,
score: Int32,
depth: Int32,
created: {
author: String,
body_html: String,
replies: RedditThing | String,
score: Int32,
depth: Int32,
created_utc: {
type: Time,
converter: RedditComment::TimeConverter,
},
@@ -159,7 +159,7 @@ def template_reddit_comments(root)
<a href="javascript:void(0)" onclick="toggle(this)">[ - ]</a>
<i class="icon ion-ios-thumbs-up"></i> #{score}
<b><a href="https://www.reddit.com/user/#{author}">#{author}</a></b>
- #{recode_date(child.created)} ago
- #{recode_date(child.created_utc)} ago
</p>
<div>
#{body_html}
@@ -242,3 +242,44 @@ def fill_links(html, scheme, host)
return html
end
def content_to_comment_html(content)
comment_html = content.map do |run|
text = HTML.escape(run["text"].as_s)
if run["text"] == "\n"
text = "<br>"
end
if run["bold"]?
text = "<b>#{text}</b>"
end
if run["italics"]?
text = "<i>#{text}</i>"
end
if run["navigationEndpoint"]?
url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s
if url
url = URI.parse(url)
if !url.host || {"m.youtube.com", "www.youtube.com", "youtu.be"}.includes? url.host
if url.path == "/redirect"
url = HTTP::Params.parse(url.query.not_nil!)["q"]
else
url = url.full_path
end
end
else
url = run["navigationEndpoint"]["commandMetadata"]?.try &.["webCommandMetadata"]["url"].as_s
end
text = %(<a href="#{url}">#{text}</a>)
end
text
end.join.rchop('\ufeff')
return comment_html
end

View File

@@ -196,8 +196,14 @@ def html_to_content(description_html)
end
def extract_videos(nodeset, ucid = nil)
videos = extract_items(nodeset, ucid)
videos.select! { |item| !item.is_a?(SearchChannel | SearchPlaylist) }
videos.map { |video| video.as(SearchVideo) }
end
def extract_items(nodeset, ucid = nil)
# TODO: Make this a 'common', so it makes more sense to be used here
videos = [] of SearchVideo
items = [] of SearchItem
nodeset.each do |node|
anchor = node.xpath_node(%q(.//h3[contains(@class,"yt-lockup-title")]/a))
@@ -209,78 +215,150 @@ def extract_videos(nodeset, ucid = nil)
next
end
case node.xpath_node(%q(.//div)).not_nil!["class"]
when .includes? "yt-lockup-movie-vertical-poster"
next
when .includes? "yt-lockup-playlist"
next
when .includes? "yt-lockup-channel"
next
end
title = anchor.content.strip
id = anchor["href"].lchop("/watch?v=")
if ucid
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
if !anchor
author = ""
author_id = ""
else
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-byline")]/a))
if !anchor
next
end
author = anchor.content
author = anchor.content.strip
author_id = anchor["href"].split("/")[-1]
end
metadata = node.xpath_nodes(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li))
if metadata.empty?
anchor = node.xpath_node(%q(.//h3[contains(@class, "yt-lockup-title")]/a))
if !anchor
next
end
begin
published = decode_date(metadata[0].content.lchop("Streamed ").lchop("Starts "))
rescue ex
end
begin
published ||= Time.epoch(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
rescue ex
end
published ||= Time.now
begin
view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64?
rescue ex
end
begin
view_count ||= metadata.try &.[1].content.delete("No views,").try &.to_i64?
rescue ex
end
view_count ||= 0_i64
title = anchor.content.strip
id = anchor["href"]
description_html = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-description")]))
description_html, description = html_to_content(description_html)
length_seconds = node.xpath_node(%q(.//span[@class="video-time"]))
if length_seconds
length_seconds = decode_length_seconds(length_seconds.content)
else
length_seconds = -1
tile = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-tile")]))
if !tile
next
end
videos << SearchVideo.new(
title,
id,
author,
author_id,
published,
view_count,
description,
description_html,
length_seconds,
)
case tile["class"]
when .includes? "yt-lockup-playlist"
plid = HTTP::Params.parse(URI.parse(id).query.not_nil!)["list"]
anchor = node.xpath_node(%q(.//div[contains(@class, "yt-lockup-meta")]/a))
if !anchor
anchor = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li/a))
end
if anchor
video_count = anchor.content.match(/View full playlist \((?<count>\d+)/).try &.["count"].to_i?
end
video_count ||= 0
videos = [] of SearchPlaylistVideo
node.xpath_nodes(%q(.//*[contains(@class, "yt-lockup-playlist-items")]/li)).each do |video|
anchor = video.xpath_node(%q(.//a))
if anchor
video_title = anchor.content.strip
id = HTTP::Params.parse(URI.parse(anchor["href"]).query.not_nil!)["v"]
end
video_title ||= ""
id ||= ""
anchor = video.xpath_node(%q(.//span/span))
if anchor
length_seconds = decode_length_seconds(anchor.content)
end
length_seconds ||= 0
videos << SearchPlaylistVideo.new(
video_title,
id,
length_seconds
)
end
items << SearchPlaylist.new(
title,
plid,
author,
author_id,
video_count,
videos
)
when .includes? "yt-lockup-channel"
author = title.strip
ucid = id.split("/")[-1]
author_thumbnail = node.xpath_node(%q(.//div/span/img)).try &.["data-thumb"]?
author_thumbnail ||= node.xpath_node(%q(.//div/span/img)).try &.["src"]
author_thumbnail ||= ""
subscriber_count = node.xpath_node(%q(.//span[contains(@class, "yt-subscriber-count")])).try &.["title"].delete(",").to_i?
subscriber_count ||= 0
video_count = node.xpath_node(%q(.//ul[@class="yt-lockup-meta-info"]/li)).try &.content.split(" ")[0].delete(",").to_i?
video_count ||= 0
items << SearchChannel.new(
author,
ucid,
author_thumbnail,
subscriber_count,
video_count,
description,
description_html
)
else
id = id.lchop("/watch?v=")
metadata = node.xpath_nodes(%q(.//div[contains(@class,"yt-lockup-meta")]/ul/li))
begin
published = decode_date(metadata[0].content.lchop("Streamed ").lchop("Starts "))
rescue ex
end
begin
published ||= Time.epoch(metadata[0].xpath_node(%q(.//span)).not_nil!["data-timestamp"].to_i64)
rescue ex
end
published ||= Time.now
begin
view_count = metadata[0].content.rchop(" watching").delete(",").try &.to_i64?
rescue ex
end
begin
view_count ||= metadata.try &.[1].content.delete("No views,").try &.to_i64?
rescue ex
end
view_count ||= 0_i64
length_seconds = node.xpath_node(%q(.//span[@class="video-time"]))
if length_seconds
length_seconds = decode_length_seconds(length_seconds.content)
else
length_seconds = -1
end
live_now = node.xpath_node(%q(.//span[contains(@class, "yt-badge-live")]))
if live_now
live_now = true
else
live_now = false
end
items << SearchVideo.new(
title,
id,
author,
author_id,
published,
view_count,
description,
description_html,
length_seconds,
live_now
)
end
end
return videos
return items
end

View File

@@ -157,6 +157,7 @@ def get_proxies(country_code = "US")
proxies << {ip: ip, port: port, score: score}
end
proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse
return proxies
end

View File

@@ -3,13 +3,17 @@ def crawl_videos(db)
random = Random.new
search(random.base64(3)).as(Tuple)[1].each do |video|
ids << video.id
if video.is_a?(SearchVideo)
ids << video.id
end
end
loop do
if ids.empty?
search(random.base64(3)).as(Tuple)[1].each do |video|
ids << video.id
if video.is_a?(SearchVideo)
ids << video.id
end
end
end

View File

@@ -25,58 +25,75 @@ class PlaylistVideo
})
end
def extract_playlist(plid, page)
index = (page - 1) * 100
url = produce_playlist_url(plid, index)
def fetch_playlist_videos(plid, page, video_count)
client = make_client(YT_URL)
response = client.get(url)
response = JSON.parse(response.body)
if !response["content_html"]? || response["content_html"].as_s.empty?
raise "Playlist does not exist"
if video_count > 100
index = (page - 1) * 100
url = produce_playlist_url(plid, index)
response = client.get(url)
response = JSON.parse(response.body)
if !response["content_html"]? || response["content_html"].as_s.empty?
raise "Playlist is empty"
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, index)
else
if page > 1
videos = [] of PlaylistVideo
else
response = client.get("/playlist?list=#{plid}&disable_polymer=1")
document = XML.parse_html(response.body)
nodeset = document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")]))
videos = extract_playlist(plid, nodeset, 0)
end
end
return videos
end
def extract_playlist(plid, nodeset, index)
videos = [] of PlaylistVideo
document = XML.parse_html(response["content_html"].as_s)
anchor = document.xpath_node(%q(//div[@class="pl-video-owner"]/a))
if anchor
document.xpath_nodes(%q(.//tr[contains(@class, "pl-video")])).each_with_index do |video, offset|
anchor = video.xpath_node(%q(.//td[@class="pl-video-title"]))
if !anchor
next
end
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)
else
length_seconds = 0
end
videos << PlaylistVideo.new(
title,
id,
author,
ucid,
length_seconds,
Time.now,
[plid],
index + offset,
)
nodeset.each_with_index do |video, offset|
anchor = video.xpath_node(%q(.//td[@class="pl-video-title"]))
if !anchor
next
end
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)
else
length_seconds = 0
end
videos << PlaylistVideo.new(
title,
id,
author,
ucid,
length_seconds,
Time.now,
[plid],
index + offset,
)
end
return videos
@@ -112,14 +129,23 @@ def produce_playlist_url(id, index)
continuation = Base64.urlsafe_encode(continuation)
continuation = URI.escape(continuation)
url = "/browse_ajax?action_continuation=1&continuation=#{continuation}"
url = "/browse_ajax?continuation=#{continuation}"
return url
end
def fetch_playlist(plid)
client = make_client(YT_URL)
if plid.starts_with? "UC"
plid = "UU#{plid.lchop("UC")}"
end
response = client.get("/playlist?list=#{plid}&disable_polymer=1")
if response.status_code != 200
raise "Invalid playlist."
end
body = response.body.gsub(<<-END_BUTTON
<button class="yt-uix-button yt-uix-button-size-default yt-uix-button-link yt-uix-expander-head playlist-description-expander yt-uix-inlineedit-ignore-edit" type="button" onclick=";return false;"><span class="yt-uix-button-content"> less <img alt="" src="/yts/img/pixel-vfl3z5WfW.gif">
</span></button>
@@ -127,8 +153,11 @@ def fetch_playlist(plid)
, "")
document = XML.parse_html(body)
title = document.xpath_node(%q(//h1[@class="pl-header-title"])).not_nil!.content
title = title.strip(" \n")
title = document.xpath_node(%q(//h1[@class="pl-header-title"]))
if !title
raise "Playlist does not exist."
end
title = title.content.strip(" \n")
description_html = document.xpath_node(%q(//span[@class="pl-header-description-text"]/div/div[1]))
description_html, description = html_to_content(description_html)
@@ -138,7 +167,12 @@ def fetch_playlist(plid)
ucid = anchor.xpath_node(%q(.//li[1]/a)).not_nil!["href"].split("/")[2]
video_count = anchor.xpath_node(%q(.//li[2])).not_nil!.content.delete("videos, ").to_i
views = anchor.xpath_node(%q(.//li[3])).not_nil!.content.delete("views, ").to_i64
views = anchor.xpath_node(%q(.//li[3])).not_nil!.content.delete("No views, ")
if views.empty?
views = 0_i64
else
views = views.to_i64
end
updated = anchor.xpath_node(%q(.//li[4])).not_nil!.content.lchop("Last updated on ").lchop("Updated ")
updated = decode_date(updated)

View File

@@ -9,9 +9,43 @@ class SearchVideo
description: String,
description_html: String,
length_seconds: Int32,
live_now: Bool,
})
end
class SearchPlaylistVideo
add_mapping({
title: String,
id: String,
length_seconds: Int32,
})
end
class SearchPlaylist
add_mapping({
title: String,
id: String,
author: String,
ucid: String,
video_count: Int32,
videos: Array(SearchPlaylistVideo),
})
end
class SearchChannel
add_mapping({
author: String,
ucid: String,
author_thumbnail: String,
subscriber_count: Int32,
video_count: Int32,
description: String,
description_html: String,
})
end
alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist
def channel_search(query, page, channel)
client = make_client(YT_URL)
@@ -26,7 +60,7 @@ def channel_search(query, page, channel)
end
if !canonical
return 0, [] of SearchVideo
return 0, [] of SearchItem
end
ucid = canonical["href"].split("/")[-1]
@@ -40,31 +74,31 @@ def channel_search(query, page, channel)
nodeset = document.xpath_nodes(%q(//li[contains(@class, "feed-item-container")]))
count = nodeset.size
videos = extract_videos(nodeset)
items = extract_items(nodeset)
else
count = 0
videos = [] of SearchVideo
items = [] of SearchItem
end
return count, videos
return count, items
end
def search(query, page = 1, search_params = produce_search_params(content_type: "video"))
def search(query, page = 1, search_params = produce_search_params(content_type: "all"))
client = make_client(YT_URL)
if query.empty?
return {0, [] of SearchVideo}
return {0, [] of SearchItem}
end
html = client.get("/results?q=#{URI.escape(query)}&page=#{page}&sp=#{search_params}&disable_polymer=1").body
if html.empty?
return {0, [] of SearchVideo}
return {0, [] of SearchItem}
end
html = XML.parse_html(html)
nodeset = html.xpath_nodes(%q(//ol[@class="item-section"]/li))
videos = extract_videos(nodeset)
items = extract_items(nodeset)
return {nodeset.size, videos}
return {nodeset.size, items}
end
def produce_search_params(sort : String = "relevance", date : String = "", content_type : String = "",
@@ -110,8 +144,10 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
"\x10\x04"
when "show"
"\x10\x05"
else
when "all"
""
else
"\x10\x01"
end
body += case duration
@@ -133,7 +169,7 @@ def produce_search_params(sort : String = "relevance", date : String = "", conte
"\x30\x01"
when "3d"
"\x38\x01"
when "live"
when "live", "livestream"
"\x40\x01"
when "purchased"
"\x48\x01"

View File

@@ -36,7 +36,7 @@ DEFAULT_USER_PREFERENCES = Preferences.from_json({
"captions" => ["", "", ""],
"related_videos" => true,
"dark_mode" => false,
"thin_mode " => false,
"thin_mode" => false,
"max_results" => 40,
"sort" => "published",
"latest_only" => false,

View File

@@ -109,17 +109,44 @@ CAPTION_LANGUAGES = {
}
REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"}
BYPASS_REGIONS = {"CA", "DE", "FR", "JP", "RU", "UK"}
BYPASS_REGIONS = {
"UK",
"DE",
"FR",
"IN",
"CN",
"RU",
"CA",
"JP",
"IT",
"TH",
"ES",
"AE",
"KR",
"IR",
"BR",
"PK",
"ID",
"BD",
"MX",
"ET",
"PH",
"EG",
"VN",
"CD",
"TR",
}
VIDEO_THUMBNAILS = {
{name: "maxresdefault", url: "maxresdefault", height: 720, width: 1280},
{name: "sddefault", url: "sddefault", height: 480, width: 640},
{name: "high", url: "hqdefault", height: 360, width: 480},
{name: "medium", url: "mqdefault", height: 180, width: 320},
{name: "default", url: "default", height: 90, width: 120},
{name: "start", url: "1", height: 90, width: 120},
{name: "middle", url: "2", height: 90, width: 120},
{name: "end", url: "3", height: 90, width: 120},
{name: "maxres", host: "invidio.us", url: "maxres", height: 720, width: 1280},
{name: "maxresdefault", host: "i.ytimg.com", url: "maxresdefault", height: 720, width: 1280},
{name: "sddefault", host: "i.ytimg.com", url: "sddefault", height: 480, width: 640},
{name: "high", host: "i.ytimg.com", url: "hqdefault", height: 360, width: 480},
{name: "medium", host: "i.ytimg.com", url: "mqdefault", height: 180, width: 320},
{name: "default", host: "i.ytimg.com", url: "default", height: 90, width: 120},
{name: "start", host: "i.ytimg.com", url: "1", height: 90, width: 120},
{name: "middle", host: "i.ytimg.com", url: "2", height: 90, width: 120},
{name: "end", host: "i.ytimg.com", url: "3", height: 90, width: 120},
}
# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476
@@ -539,6 +566,7 @@ def fetch_video(id)
if response[0] || response[1]
info = response[0].not_nil!
html = response[1].not_nil!
break
end
end
end
@@ -568,11 +596,17 @@ def fetch_video(id)
published = html.xpath_node(%q(//meta[@itemprop="datePublished"])).not_nil!["content"]
published = Time.parse(published, "%Y-%m-%d", Time::Location.local)
allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",")
is_family_friendly = html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True"
allowed_regions = html.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).try &.["content"].split(",")
allowed_regions ||= [] of String
is_family_friendly = html.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).try &.["content"] == "True"
is_family_friendly ||= true
genre = html.xpath_node(%q(//meta[@itemprop="genre"])).not_nil!["content"]
genre_url = html.xpath_node(%(//a[text()="#{genre}"])).not_nil!["href"]
genre_url = html.xpath_node(%(//a[text()="#{genre}"])).try &.["href"]
if genre == "Movies"
genre_url ||= "/channel/UClgRkhTL3_hImCAmdLfDE4g"
end
genre_url = ""
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li))
if license
@@ -670,7 +704,7 @@ def generate_thumbnails(json, id)
VIDEO_THUMBNAILS.each do |thumbnail|
json.object do
json.field "quality", thumbnail[:name]
json.field "url", "https://i.ytimg.com/vi/#{id}/#{thumbnail["url"]}.jpg"
json.field "url", "https://#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg"
json.field "width", thumbnail[:width]
json.field "height", thumbnail[:height]
end

View File

@@ -37,8 +37,8 @@
<% videos.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
<%= rendered "components/video" %>
<% slice.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,54 @@
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<% case item when %>
<% when SearchChannel %>
<a style="width:100%;" href="/channel/<%= item.ucid %>">
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
<% else %>
<center>
<img style="width:56.25%;" src="/ggpht<%= URI.parse(item.author_thumbnail).full_path %>"/>
</center>
<% end %>
<p><%= item.author %></p>
</a>
<p><%= number_with_separator(item.subscriber_count) %> subscribers</p>
<h5><%= item.description_html %></h5>
<% when SearchPlaylist %>
<a style="width:100%;" href="/playlist?list=<%= item.id %>">
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
<% else %>
<img style="width:100%;" src="/vi/<%= item.videos[0]?.try &.id %>/mqdefault.jpg"/>
<% end %>
<p><%= item.title %></p>
</a>
<p>
<b><a style="width:100%;" href="/channel/<%= item.ucid %>"><%= item.author %></a></b>
</p>
<p><%= number_with_separator(item.video_count) %> videos</p>
<p>PLAYLIST</p>
<% else %>
<% if item.responds_to?(:playlists) && !item.playlists.empty? %>
<% params = "&list=#{item.playlists[0]}" %>
<% else %>
<% params = nil %>
<% end %>
<a style="width:100%;" href="/watch?v=<%= item.id %><%= params %>">
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
<% else %>
<img style="width:100%;" src="/vi/<%= item.id %>/mqdefault.jpg"/>
<% end %>
<p><%= item.title %></p>
</a>
<% if item.responds_to?(:live_now) && item.live_now %>
<p>LIVE</p>
<% end %>
<p>
<b><a style="width:100%;" href="/channel/<%= item.ucid %>"><%= item.author %></a></b>
</p>
<% if Time.now - item.published > 1.minute %>
<h5>Shared <%= recode_date(item.published) %> ago</h5>
<% end %>
<% end %>
</div>
</div>

View File

@@ -4,13 +4,16 @@
<% if params[:video_loop] %>loop<% end %>
<% if params[:controls] %>controls<% end %>>
<% if hlsvp %>
<source src="<%= hlsvp %>" type="application/x-mpegURL">
<source src="<%= hlsvp %>" type="application/x-mpegURL" label="livestream">
<% else %>
<% if params[:listen] %>
<% audio_streams.each_with_index do |fmt, i| %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>">
<% end %>
<% else %>
<% if params[:quality] == "dash" %>
<source src="/api/manifest/dash/id/<%= video.id %>?local=true" type='application/dash+xml' label="dash">
<% end %>
<% fmt_stream.each_with_index do |fmt, i| %>
<% if params[:quality] %>
<source src="<%= fmt["url"] %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= params[:quality] == fmt["label"].split(" - ")[0] %>">

View File

@@ -8,4 +8,8 @@
<script src="/js/videojs-markers.min.js"></script>
<script src="/js/videojs-share.min.js"></script>
<script src="/js/videojs-http-streaming.min.js"></script>
<% if env.get?("user") && env.get("user").as(User).preferences.quality == "dash" %>
<script src="/js/dash.mediaplayer.min.js"></script>
<script src="/js/videojs-dash.min.js"></script>
<script src="/js/videojs-contrib-quality-levels.min.js"></script>
<% end %>

View File

@@ -1,23 +0,0 @@
<div class="pure-u-1 pure-u-md-1-4">
<div class="h-box">
<% if video.responds_to?(:playlists) && !video.playlists.empty? %>
<% params = "&list=#{video.playlists[0]}" %>
<% else %>
<% params = nil %>
<% end %>
<a style="width:100%;" href="/watch?v=<%= video.id %><%= params %>">
<% if env.get?("user") && env.get("user").as(User).preferences.thin_mode %>
<% else %>
<img style="width:100%;" src="/vi/<%= video.id %>/mqdefault.jpg"/>
<% end %>
<p><%= video.title %></p>
</a>
<p>
<b><a style="width:100%;" href="/channel/<%= video.ucid %>"><%= video.author %></a></b>
</p>
<% if Time.now - video.published > 1.minute %>
<h5>Shared <%= recode_date(video.published) %> ago</h5>
<% end %>
</div>
</div>

View File

@@ -4,8 +4,8 @@
<% top_videos.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
<%= rendered "components/video" %>
<% slice.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>

View File

@@ -26,8 +26,8 @@
<% videos.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
<%= rendered "components/video" %>
<% slice.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>

View File

@@ -35,7 +35,7 @@ function update_value(element) {
<div class="pure-control-group">
<label for="quality">Preferred video quality: </label>
<select name="quality" id="quality">
<% {"hd720", "medium", "small"}.each do |option| %>
<% {"dash", "hd720", "medium", "small"}.each do |option| %>
<option <% if user.preferences.quality == option %> selected <% end %>><%= option %></option>
<% end %>
</select>

View File

@@ -4,8 +4,8 @@
<% videos.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
<%= rendered "components/video" %>
<% slice.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>

View File

@@ -25,8 +25,8 @@
<% notifications.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
<%= rendered "components/video" %>
<% slice.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>
@@ -37,8 +37,8 @@
<% videos.each_slice(4) do |slice| %>
<div class="pure-g">
<% slice.each do |video| %>
<%= rendered "components/video" %>
<% slice.each do |item| %>
<%= rendered "components/item" %>
<% end %>
</div>
<% end %>