forked from midou/invidious
Compare commits
66 Commits
Author | SHA1 | Date | |
---|---|---|---|
f8eb5ab416 | |||
ae2850215f | |||
d418f50576 | |||
8c04768ef8 | |||
a718d5543d | |||
20130db556 | |||
66f3ab0663 | |||
1de7c0caf9 | |||
7d35b6e44f | |||
71a99542fe | |||
8530c1f4ec | |||
29a6291957 | |||
25ba5bda62 | |||
477c84deb1 | |||
c2f7d3d41c | |||
b0b5e3e982 | |||
4fb275ec6e | |||
f99b2cdf01 | |||
5d7bd9af0f | |||
aa819a189e | |||
2e65997447 | |||
3e3de1890a | |||
5b5d69a33b | |||
1289065151 | |||
21a8df42dd | |||
74b285d0f7 | |||
c2e72439f5 | |||
87498ae777 | |||
e122d68acc | |||
b3ca3922a9 | |||
9a7852341d | |||
1922f6c69a | |||
a5e8f87a26 | |||
1d9c6f63e1 | |||
5bdd8327d4 | |||
35ac887133 | |||
d886f8d1e3 | |||
d8b7c0750a | |||
1ab04638e3 | |||
f80f4f2521 | |||
60038b29f1 | |||
099b711400 | |||
b56d07556e | |||
a68924f0ce | |||
51d00b16c3 | |||
bead12d6d0 | |||
1703788f4b | |||
6e092026d2 | |||
95e2d8f1b7 | |||
abb15b82e6 | |||
687013c1b9 | |||
cefb5d679f | |||
62380933b2 | |||
1627cfc2fa | |||
82cc407629 | |||
bac66c798f | |||
2c6f8022e6 | |||
fe5286a210 | |||
bb604c8925 | |||
c166f46b7c | |||
4c8bb3b293 | |||
0f5b93e394 | |||
54f9941c7b | |||
8500a62462 | |||
53c8b5ff2e | |||
64cc0362fb |
137
CHANGELOG.md
Normal file
137
CHANGELOG.md
Normal file
@ -0,0 +1,137 @@
|
||||
# 0.7.0 (2018-09-25)
|
||||
|
||||
## Week 7: 1080p and Search Types
|
||||
|
||||
Hello again everyone! I've got quite a couple announcements this week:
|
||||
|
||||
Experimental 1080p support has been added with [`b3ca392`](https://github.com/omarroth/invidious/b3ca392)2a9073b4abb0d7fde58a3e6098668f53e, and can be enabled by going to preferences and changing `preferred video quality` to `dash`. You can find more details [here](https://github.com/omarroth/invidious/issues/34#issuecomment-424171888). Currently quality and speed controls have not yet been integrated into the player, but I'd still appreciate feedback, mainly on any issues with buffering or DASH playback. I hope to integrate 1080p support into the player and push support site-wide in the coming weeks.
|
||||
|
||||
You can now filter content types in search with the `type:TYPE` filter. Supported content types are `playlist`, `channel`, and `video`. More info is available [here](https://github.com/omarroth/invidious/issues/126#issuecomment-423823148). I think this is quite an improvement in usability and I hope others find the same.
|
||||
|
||||
A [CHANGELOG](https://github.com/omarroth/invidious/blob/master/CHANGELOG.md) has been added to the repository, so folks will now receive a copy of all these updates when cloning. I think this is an improvement in hosting the project, as it is no longer tied to the `/releases` tab on Github or the posts on Patreon.
|
||||
|
||||
Recently, users have been reporting 504s when attempting to access their subscriptions, which is tracked in [#173](https://github.com/omarroth/invidious/issues/173). This is most likely caused by an uptick in usage, which I am absolutely grateful for, but unfortunately has resulted in an increase in costs for hosting the site, which is why I will be bumping my goal on Patreon from $60 to $80. I would appreciate any feedback on how subscriptions could be improved.
|
||||
|
||||
Other minor improvements include:
|
||||
- Additional regions added to bypass geo-block with [`9a78523`](https://github.com/omarroth/invidious/9a78523)41d9d67b6bddd8a9836c1b71c124c3614
|
||||
- Fix for playlists containing less than 100 videos (previously shown as empty) with [`35ac887`](https://github.com/omarroth/invidious/35ac887)13320a970e3a87a26249c2a18a709f020
|
||||
- Fix for `published` date for Reddit comments (previously showing negative seconds) with [`6e09202`](https://github.com/omarroth/invidious/6e09202)6d29eccc3e3adf02be138fddec2354027
|
||||
|
||||
Thank you everyone for your support!
|
||||
|
||||
# 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!
|
40
README.md
40
README.md
@ -28,6 +28,29 @@ BCH: qq4ptclkzej5eza6a50et5ggc58hxsq5aylqut2npk
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker:
|
||||
|
||||
#### Build and start cluster:
|
||||
|
||||
```bash
|
||||
$ docker-compose up
|
||||
```
|
||||
|
||||
And visit `localhost:3000` in your browser.
|
||||
|
||||
#### Rebuild cluster:
|
||||
|
||||
```bash
|
||||
$ docker-compose build
|
||||
```
|
||||
|
||||
#### Delete data and rebuild:
|
||||
|
||||
```bash
|
||||
$ docker volume rm invidious_postgresdata
|
||||
$ docker-compose build
|
||||
```
|
||||
|
||||
### Installing [Crystal](https://github.com/crystal-lang/crystal):
|
||||
|
||||
#### On Arch:
|
||||
@ -74,8 +97,21 @@ $ sudo pacman -S imagemagick librsvg
|
||||
## Usage:
|
||||
|
||||
```bash
|
||||
$ crystal build src/invidious.cr
|
||||
$ ./invidious
|
||||
$ crystal build src/invidious.cr --release
|
||||
$ ./invidious -h
|
||||
Usage: invidious [arguments]
|
||||
-b HOST, --bind HOST Host to bind (defaults to 0.0.0.0)
|
||||
-p PORT, --port PORT Port to listen for connections (defaults to 3000)
|
||||
-s, --ssl Enables SSL
|
||||
--ssl-key-file FILE SSL key file
|
||||
--ssl-cert-file FILE SSL certificate file
|
||||
-h, --help Shows this help
|
||||
-t THREADS, --crawl-threads=THREADS
|
||||
Number of threads for crawling (default: 1)
|
||||
-c THREADS, --channel-threads=THREADS
|
||||
Number of threads for refreshing channels (default: 1)
|
||||
-v THREADS, --video-threads=THREADS
|
||||
Number of threads for refreshing videos (default: 1)
|
||||
```
|
||||
|
||||
Or for development:
|
||||
|
29
assets/js/dash.mediaplayer.min.js
vendored
Normal file
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
3
assets/js/videojs-dash.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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;
|
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@ -0,0 +1,21 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.postgres
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgresdata:/var/lib/postgresql/data
|
||||
invidious:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
postgresdata:
|
15
docker/Dockerfile
Normal file
15
docker/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM archlinux/base
|
||||
|
||||
RUN pacman -Sy --noconfirm shards crystal imagemagick librsvg \
|
||||
which pkgconf gcc ttf-liberation
|
||||
# base-devel contains many other basic packages, that are normally assumed to already exist on a clean arch system
|
||||
|
||||
ADD . /invidious
|
||||
|
||||
WORKDIR /invidious
|
||||
|
||||
RUN sed -i 's/host: localhost/host: postgres/' config/config.yml && \
|
||||
shards && \
|
||||
crystal build src/invidious.cr
|
||||
|
||||
CMD [ "/invidious/invidious" ]
|
10
docker/Dockerfile.postgres
Normal file
10
docker/Dockerfile.postgres
Normal file
@ -0,0 +1,10 @@
|
||||
FROM postgres:10
|
||||
|
||||
ENV POSTGRES_USER postgres
|
||||
|
||||
ADD ./setup.sh /setup.sh
|
||||
ADD ./config/sql /config/sql
|
||||
ADD ./docker/entrypoint.postgres.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
CMD [ "postgres" ]
|
19
docker/entrypoint.postgres.sh
Executable file
19
docker/entrypoint.postgres.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
CMD="$@"
|
||||
if [ ! -f /var/lib/postgresql/data/setupFinished ]; then
|
||||
echo "### first run - setting up invidious database"
|
||||
/usr/local/bin/docker-entrypoint.sh postgres &
|
||||
sleep 10
|
||||
until runuser -l postgres -c 'pg_isready' 2>/dev/null; do
|
||||
>&2 echo "### Postgres is unavailable - waiting"
|
||||
sleep 5
|
||||
done
|
||||
>&2 echo "### importing table schemas"
|
||||
su postgres -c "/setup.sh" && touch /var/lib/postgresql/data/setupFinished
|
||||
echo "### invidious database setup finished"
|
||||
exit
|
||||
fi
|
||||
|
||||
echo "running postgres /usr/local/bin/docker-entrypoint.sh $CMD"
|
||||
exec /usr/local/bin/docker-entrypoint.sh $CMD
|
3
setup.sh
3
setup.sh
@ -1,7 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
createdb invidious
|
||||
createuser kemal
|
||||
#createuser kemal
|
||||
psql -c "CREATE USER kemal WITH PASSWORD 'kemal';"
|
||||
psql invidious < config/sql/channels.sql
|
||||
psql invidious < config/sql/videos.sql
|
||||
psql invidious < config/sql/channel_videos.sql
|
||||
|
@ -1,5 +1,5 @@
|
||||
name: invidious
|
||||
version: 0.4.0
|
||||
version: 0.7.0
|
||||
|
||||
authors:
|
||||
- Omar Roth <omarroth@hotmail.com>
|
||||
|
894
src/invidious.cr
894
src/invidious.cr
File diff suppressed because it is too large
Load Diff
@ -104,13 +104,17 @@ def fetch_channel(ucid, client, db, pull_all_videos = true)
|
||||
|
||||
videos.each do |video|
|
||||
ids << video.id
|
||||
db.exec("UPDATE users SET notifications = notifications || $1 \
|
||||
|
||||
# FIXME: Red videos don't provide published date, so the best we can do is ignore them
|
||||
if Time.now - video.published > 1.minute
|
||||
db.exec("UPDATE users SET notifications = notifications || $1 \
|
||||
WHERE updated < $2 AND $3 = ANY(subscriptions) AND $1 <> ALL(notifications)", video.id, video.published, video.ucid)
|
||||
|
||||
video_array = video.to_a
|
||||
args = arg_array(video_array)
|
||||
db.exec("INSERT INTO channel_videos VALUES (#{args}) ON CONFLICT (id) DO UPDATE SET title = $2, \
|
||||
video_array = video.to_a
|
||||
args = arg_array(video_array)
|
||||
db.exec("INSERT INTO channel_videos VALUES (#{args}) ON CONFLICT (id) DO UPDATE SET title = $2, \
|
||||
published = $3, updated = $4, ucid = $5, author = $6", video_array)
|
||||
end
|
||||
end
|
||||
|
||||
if count < 30
|
||||
@ -176,3 +180,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
|
||||
|
@ -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
|
||||
|
@ -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,161 @@ 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
|
||||
|
||||
video_count = node.xpath_node(%q(.//span[@class="formatted-video-count-label"]/b))
|
||||
if video_count
|
||||
video_count = video_count.content
|
||||
|
||||
if video_count == "50+"
|
||||
author = "YouTube"
|
||||
author_id = "UC-9-kyTW8ZkZNDHQJ6FgpwQ"
|
||||
video_count = video_count.rchop("+")
|
||||
end
|
||||
|
||||
video_count = video_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
|
||||
|
@ -89,6 +89,49 @@ class HTTPClient < HTTP::Client
|
||||
end
|
||||
|
||||
def get_proxies(country_code = "US")
|
||||
# return get_spys_proxies(country_code)
|
||||
return get_nova_proxies(country_code)
|
||||
end
|
||||
|
||||
def get_nova_proxies(country_code = "US")
|
||||
country_code = country_code.downcase
|
||||
client = HTTP::Client.new(URI.parse("https://www.proxynova.com"))
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
|
||||
headers = HTTP::Headers.new
|
||||
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
|
||||
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
|
||||
headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9"
|
||||
headers["Host"] = "www.proxynova.com"
|
||||
headers["Origin"] = "https://www.proxynova.com"
|
||||
headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/"
|
||||
|
||||
response = client.get("/proxy-server-list/country-#{country_code}/", headers)
|
||||
document = XML.parse_html(response.body)
|
||||
|
||||
proxies = [] of {ip: String, port: Int32, score: Float64}
|
||||
document.xpath_nodes(%q(//tr[@data-proxy-id])).each do |node|
|
||||
ip = node.xpath_node(%q(.//td/abbr/script)).not_nil!.content
|
||||
ip = ip.match(/document\.write\('(?<sub1>[^']+)'.substr\(8\) \+ '(?<sub2>[^']+)'/).not_nil!
|
||||
ip = "#{ip["sub1"][8..-1]}#{ip["sub2"]}"
|
||||
port = node.xpath_node(%q(.//td[2])).not_nil!.content.strip.to_i
|
||||
|
||||
anchor = node.xpath_node(%q(.//td[4]/div)).not_nil!
|
||||
speed = anchor["data-value"].to_f
|
||||
latency = anchor["title"].to_f
|
||||
uptime = node.xpath_node(%q(.//td[5]/span)).not_nil!.content.rchop("%").to_f
|
||||
|
||||
# TODO: Tweak me
|
||||
score = (uptime*4 + speed*2 + latency)/7
|
||||
proxies << {ip: ip, port: port, score: score}
|
||||
end
|
||||
|
||||
proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse
|
||||
return proxies
|
||||
end
|
||||
|
||||
def get_spys_proxies(country_code = "US")
|
||||
client = HTTP::Client.new(URI.parse("http://spys.one"))
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
@ -108,7 +151,15 @@ def get_proxies(country_code = "US")
|
||||
"xf4" => "0",
|
||||
"xf5" => "1",
|
||||
}
|
||||
|
||||
response = client.post("/free-proxy-list/#{country_code}/", headers, form: body)
|
||||
20.times do
|
||||
if response.status_code == 200
|
||||
break
|
||||
end
|
||||
response = client.post("/free-proxy-list/#{country_code}/", headers, form: body)
|
||||
end
|
||||
|
||||
response = XML.parse_html(response.body)
|
||||
|
||||
mapping = response.xpath_node(%q(.//body/script)).not_nil!.content
|
||||
@ -157,6 +208,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
|
||||
|
||||
|
@ -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
|
||||
|
||||
@ -150,3 +154,41 @@ def update_decrypt_function
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
def find_working_proxies(regions)
|
||||
proxy_channel = Channel({String, Array({ip: String, port: Int32})}).new
|
||||
|
||||
regions.each do |region|
|
||||
spawn do
|
||||
loop do
|
||||
begin
|
||||
proxies = get_proxies(region).first(20)
|
||||
rescue ex
|
||||
next proxy_channel.send({region, Array({ip: String, port: Int32}).new})
|
||||
end
|
||||
|
||||
proxies.select! do |proxy|
|
||||
begin
|
||||
client = HTTPClient.new(YT_URL)
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
|
||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||
client.set_proxy(proxy)
|
||||
|
||||
client.get("/").status_code == 200
|
||||
rescue ex
|
||||
false
|
||||
end
|
||||
end
|
||||
proxies = proxies.map { |proxy| {ip: proxy[:ip], port: proxy[:port]} }
|
||||
|
||||
proxy_channel.send({region, proxies})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
loop do
|
||||
yield proxy_channel.receive
|
||||
end
|
||||
end
|
||||
|
74
src/invidious/mixes.cr
Normal file
74
src/invidious/mixes.cr
Normal file
@ -0,0 +1,74 @@
|
||||
class MixVideo
|
||||
add_mapping({
|
||||
title: String,
|
||||
id: String,
|
||||
author: String,
|
||||
ucid: String,
|
||||
length_seconds: Int32,
|
||||
index: Int32,
|
||||
})
|
||||
end
|
||||
|
||||
class Mix
|
||||
add_mapping({
|
||||
title: String,
|
||||
id: String,
|
||||
videos: Array(MixVideo),
|
||||
})
|
||||
end
|
||||
|
||||
def fetch_mix(rdid, video_id, cookies = nil)
|
||||
client = make_client(YT_URL)
|
||||
headers = HTTP::Headers.new
|
||||
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36"
|
||||
|
||||
if cookies
|
||||
headers = cookies.add_request_headers(headers)
|
||||
end
|
||||
response = client.get("/watch?v=#{video_id}&list=#{rdid}&bpctr=#{Time.new.epoch + 2000}&gl=US&hl=en", headers)
|
||||
|
||||
yt_data = response.body.match(/window\["ytInitialData"\] = (?<data>.*);/)
|
||||
if yt_data
|
||||
yt_data = JSON.parse(yt_data["data"].rchop(";"))
|
||||
else
|
||||
raise "Could not create mix."
|
||||
end
|
||||
|
||||
playlist = yt_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]
|
||||
mix_title = playlist["title"].as_s
|
||||
|
||||
contents = playlist["contents"].as_a
|
||||
until contents[0]["playlistPanelVideoRenderer"]["videoId"].as_s == video_id
|
||||
contents.shift
|
||||
end
|
||||
|
||||
videos = [] of MixVideo
|
||||
contents.each do |item|
|
||||
item = item["playlistPanelVideoRenderer"]
|
||||
|
||||
id = item["videoId"].as_s
|
||||
title = item["title"]["simpleText"].as_s
|
||||
author = item["longBylineText"]["runs"][0]["text"].as_s
|
||||
ucid = item["longBylineText"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s
|
||||
length_seconds = decode_length_seconds(item["lengthText"]["simpleText"].as_s)
|
||||
index = item["navigationEndpoint"]["watchEndpoint"]["index"].as_i
|
||||
|
||||
videos << MixVideo.new(
|
||||
title,
|
||||
id,
|
||||
author,
|
||||
ucid,
|
||||
length_seconds,
|
||||
index
|
||||
)
|
||||
end
|
||||
|
||||
if !cookies
|
||||
next_page = fetch_mix(rdid, videos[-1].id, response.cookies)
|
||||
videos += next_page.videos
|
||||
end
|
||||
|
||||
videos.uniq! { |video| video.id }
|
||||
videos = videos.first(50)
|
||||
return Mix.new(mix_title, rdid, videos)
|
||||
end
|
@ -1,17 +1,3 @@
|
||||
class Playlist
|
||||
add_mapping({
|
||||
title: String,
|
||||
id: String,
|
||||
author: String,
|
||||
ucid: String,
|
||||
description: String,
|
||||
description_html: String,
|
||||
video_count: Int32,
|
||||
views: Int64,
|
||||
updated: Time,
|
||||
})
|
||||
end
|
||||
|
||||
class PlaylistVideo
|
||||
add_mapping({
|
||||
title: String,
|
||||
@ -25,58 +11,92 @@ class PlaylistVideo
|
||||
})
|
||||
end
|
||||
|
||||
def extract_playlist(plid, page)
|
||||
index = (page - 1) * 100
|
||||
url = produce_playlist_url(plid, index)
|
||||
class Playlist
|
||||
add_mapping({
|
||||
title: String,
|
||||
id: String,
|
||||
author: String,
|
||||
author_thumbnail: String,
|
||||
ucid: String,
|
||||
description: String,
|
||||
description_html: String,
|
||||
video_count: Int32,
|
||||
views: Int64,
|
||||
updated: Time,
|
||||
})
|
||||
end
|
||||
|
||||
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
|
||||
# Playlist has less than one page of videos, so subsequent pages will be empty
|
||||
if page > 1
|
||||
videos = [] of PlaylistVideo
|
||||
else
|
||||
# Extract first page of videos
|
||||
response = client.get("/playlist?list=#{plid}&gl=US&hl=en&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
|
||||
@ -88,7 +108,8 @@ def produce_playlist_url(id, index)
|
||||
end
|
||||
ucid = "VL" + id
|
||||
|
||||
meta = "\x08#{write_var_int(index).join}"
|
||||
meta = [0x08_u8] + write_var_int(index)
|
||||
meta = Slice.new(meta.to_unsafe, meta.size)
|
||||
meta = Base64.urlsafe_encode(meta, false)
|
||||
meta = "PT:#{meta}"
|
||||
|
||||
@ -112,14 +133,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)
|
||||
response = client.get("/playlist?list=#{plid}&disable_polymer=1")
|
||||
|
||||
if plid.starts_with? "UC"
|
||||
plid = "UU#{plid.lchop("UC")}"
|
||||
end
|
||||
|
||||
response = client.get("/playlist?list=#{plid}&hl=en&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,18 +157,29 @@ 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 ||= document.xpath_node(%q(//span[@class="pl-header-description-text"]))
|
||||
description_html, description = html_to_content(description_html)
|
||||
|
||||
anchor = document.xpath_node(%q(//ul[@class="pl-header-details"])).not_nil!
|
||||
author = anchor.xpath_node(%q(.//li[1]/a)).not_nil!.content
|
||||
author_thumbnail = document.xpath_node(%q(//img[@class="channel-header-profile-image"])).try &.["src"]
|
||||
author_thumbnail ||= ""
|
||||
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)
|
||||
@ -147,6 +188,7 @@ def fetch_playlist(plid)
|
||||
title,
|
||||
plid,
|
||||
author,
|
||||
author_thumbnail,
|
||||
ucid,
|
||||
description,
|
||||
description_html,
|
||||
|
@ -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
|
||||
html = client.get("/results?q=#{URI.escape(query)}&page=#{page}&sp=#{search_params}&hl=en&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"
|
||||
|
@ -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,
|
||||
|
@ -109,17 +109,43 @@ 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 = {
|
||||
"GB",
|
||||
"DE",
|
||||
"FR",
|
||||
"IN",
|
||||
"CN",
|
||||
"RU",
|
||||
"CA",
|
||||
"JP",
|
||||
"IT",
|
||||
"TH",
|
||||
"ES",
|
||||
"AE",
|
||||
"KR",
|
||||
"IR",
|
||||
"BR",
|
||||
"PK",
|
||||
"ID",
|
||||
"BD",
|
||||
"MX",
|
||||
"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
|
||||
@ -247,6 +273,12 @@ class Video
|
||||
streams.each { |s| s.add("label", "#{s["quality"]} - #{s["type"].split(";")[0].split("/")[1]}") }
|
||||
streams = streams.uniq { |s| s["label"] }
|
||||
|
||||
if self.info["region"]?
|
||||
streams.each do |fmt|
|
||||
fmt["url"] += "®ion=" + self.info["region"]
|
||||
end
|
||||
end
|
||||
|
||||
if streams[0]? && streams[0]["s"]?
|
||||
streams.each do |fmt|
|
||||
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
|
||||
@ -336,6 +368,12 @@ class Video
|
||||
end
|
||||
end
|
||||
|
||||
if self.info["region"]?
|
||||
adaptive_fmts.each do |fmt|
|
||||
fmt["url"] += "®ion=" + self.info["region"]
|
||||
end
|
||||
end
|
||||
|
||||
if adaptive_fmts[0]? && adaptive_fmts[0]["s"]?
|
||||
adaptive_fmts.each do |fmt|
|
||||
fmt["url"] += "&signature=" + decrypt_signature(fmt["s"], decrypt_function)
|
||||
@ -439,14 +477,14 @@ class CaptionName
|
||||
)
|
||||
end
|
||||
|
||||
def get_video(id, db, refresh = true)
|
||||
def get_video(id, db, proxies = {} of String => Array({ip: String, port: Int32}), refresh = true)
|
||||
if db.query_one?("SELECT EXISTS (SELECT true FROM videos WHERE id = $1)", id, as: Bool)
|
||||
video = db.query_one("SELECT * FROM videos WHERE id = $1", id, as: Video)
|
||||
|
||||
# If record was last updated over 10 minutes ago, refresh (expire param in response lasts for 6 hours)
|
||||
if refresh && Time.now - video.updated > 10.minutes
|
||||
begin
|
||||
video = fetch_video(id)
|
||||
video = fetch_video(id, proxies)
|
||||
video_array = video.to_a
|
||||
|
||||
args = arg_array(video_array[1..-1], 2)
|
||||
@ -461,7 +499,7 @@ def get_video(id, db, refresh = true)
|
||||
end
|
||||
end
|
||||
else
|
||||
video = fetch_video(id)
|
||||
video = fetch_video(id, proxies)
|
||||
video_array = video.to_a
|
||||
|
||||
args = arg_array(video_array)
|
||||
@ -472,13 +510,13 @@ def get_video(id, db, refresh = true)
|
||||
return video
|
||||
end
|
||||
|
||||
def fetch_video(id)
|
||||
def fetch_video(id, proxies)
|
||||
html_channel = Channel(XML::Node).new
|
||||
info_channel = Channel(HTTP::Params).new
|
||||
|
||||
spawn do
|
||||
client = make_client(YT_URL)
|
||||
html = client.get("/watch?v=#{id}&bpctr=#{Time.new.epoch + 2000}&disable_polymer=1")
|
||||
html = client.get("/watch?v=#{id}&bpctr=#{Time.new.epoch + 2000}&gl=US&hl=en&disable_polymer=1")
|
||||
html = XML.parse_html(html.body)
|
||||
|
||||
html_channel.send(html)
|
||||
@ -501,44 +539,58 @@ def fetch_video(id)
|
||||
info = info_channel.receive
|
||||
|
||||
if info["reason"]? && info["reason"].includes? "your country"
|
||||
bypass_channel = Channel({HTTP::Params | Nil, XML::Node | Nil}).new
|
||||
bypass_channel = Channel(HTTPProxy | Nil).new
|
||||
|
||||
BYPASS_REGIONS.each do |country_code|
|
||||
proxies.each do |region, list|
|
||||
spawn do
|
||||
begin
|
||||
proxies = get_proxies(country_code)
|
||||
list.each do |proxy|
|
||||
begin
|
||||
client = HTTPClient.new(YT_URL)
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
|
||||
# Try not to overload single proxy
|
||||
proxy = proxies[0, 5].sample(1)[0]
|
||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||
client.set_proxy(proxy)
|
||||
|
||||
client = HTTPClient.new(URI.parse("https://www.youtube.com"))
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
client.set_proxy(proxy)
|
||||
info = HTTP::Params.parse(client.get("/get_video_info?video_id=#{id}&ps=default&eurl=&gl=US&hl=en&disable_polymer=1").body)
|
||||
if !info["reason"]?
|
||||
bypass_channel.send(proxy)
|
||||
else
|
||||
bypass_channel.send(nil)
|
||||
end
|
||||
|
||||
proxy_info = client.get("/get_video_info?video_id=#{id}&el=detailpage&ps=default&eurl=&gl=US&hl=en&disable_polymer=1")
|
||||
proxy_info = HTTP::Params.parse(proxy_info.body)
|
||||
|
||||
if !proxy_info["reason"]?
|
||||
proxy_html = client.get("/watch?v=#{id}&bpctr=#{Time.new.epoch + 2000}&gl=US&hl=en&disable_polymer=1")
|
||||
proxy_html = XML.parse_html(proxy_html.body)
|
||||
|
||||
bypass_channel.send({proxy_info, proxy_html})
|
||||
else
|
||||
bypass_channel.send({nil, nil})
|
||||
break
|
||||
rescue ex
|
||||
end
|
||||
rescue ex
|
||||
bypass_channel.send({nil, nil})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
BYPASS_REGIONS.size.times do
|
||||
response = bypass_channel.receive
|
||||
if response[0] || response[1]
|
||||
info = response[0].not_nil!
|
||||
html = response[1].not_nil!
|
||||
proxies.size.times do
|
||||
proxy = bypass_channel.receive
|
||||
if proxy
|
||||
begin
|
||||
client = HTTPClient.new(YT_URL)
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
client.set_proxy(proxy)
|
||||
|
||||
html = XML.parse_html(client.get("/watch?v=#{id}&bpctr=#{Time.new.epoch + 2000}&gl=US&hl=en&disable_polymer=1").body)
|
||||
info = HTTP::Params.parse(client.get("/get_video_info?video_id=#{id}&el=detailpage&ps=default&eurl=&gl=US&hl=en&disable_polymer=1").body)
|
||||
|
||||
if info["reason"]?
|
||||
info = HTTP::Params.parse(client.get("/get_video_info?video_id=#{id}&ps=default&eurl=&gl=US&hl=en&disable_polymer=1").body)
|
||||
end
|
||||
|
||||
proxy = {ip: proxy.proxy_host, port: proxy.proxy_port}
|
||||
region = proxies.select { |region, list| list.includes? proxy }
|
||||
if !region.empty?
|
||||
info["region"] = region.keys[0]
|
||||
end
|
||||
|
||||
break
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -568,11 +620,22 @@ 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"]
|
||||
case genre
|
||||
when "Movies"
|
||||
genre_url = "/channel/UClgRkhTL3_hImCAmdLfDE4g"
|
||||
when "Education"
|
||||
# Education channel is linked but does not exist
|
||||
# genre_url = "/channel/UC3yA8nDwraeOfnYfBWun83g"
|
||||
genre_url = ""
|
||||
end
|
||||
genre_url ||= ""
|
||||
|
||||
license = html.xpath_node(%q(//h4[contains(text(),"License")]/parent::*/ul/li))
|
||||
if license
|
||||
@ -670,7 +733,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
|
||||
|
@ -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 %>
|
||||
|
70
src/invidious/views/components/item.ecr
Normal file
70
src/invidious/views/components/item.ecr
Normal file
@ -0,0 +1,70 @@
|
||||
<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 %>
|
||||
<% if item.id.starts_with? "RD" %>
|
||||
<% url = "/mix?list=#{item.id}&continuation=#{item.videos[0]?.try &.id}" %>
|
||||
<% else %>
|
||||
<% url = "/playlist?list=#{item.id}" %>
|
||||
<% end %>
|
||||
<a style="width:100%;" href="<%= url %>">
|
||||
<% 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>
|
||||
<% when MixVideo %>
|
||||
<a style="width:100%;" href="/watch?v=<%= item.id %>">
|
||||
<% 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>
|
||||
<p>
|
||||
<b><a style="width:100%;" href="/channel/<%= item.ucid %>"><%= item.author %></a></b>
|
||||
</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>
|
@ -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] %>">
|
||||
|
@ -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 %>
|
@ -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>
|
@ -13,7 +13,7 @@
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="import_youtube">Import <a target="_blank"
|
||||
<label for="import_youtube">Import <a rel="noopener" target="_blank"
|
||||
href="https://support.google.com/youtube/answer/6224202?hl=en-GB">YouTube subscriptions</a></label>
|
||||
<input type="file" id="import_youtube" name="import_youtube">
|
||||
</div>
|
||||
|
@ -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 %>
|
||||
|
22
src/invidious/views/mix.ecr
Normal file
22
src/invidious/views/mix.ecr
Normal file
@ -0,0 +1,22 @@
|
||||
<% content_for "header" do %>
|
||||
<title><%= mix.title %> - Invidious</title>
|
||||
<% end %>
|
||||
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-2-3">
|
||||
<h3><%= mix.title %></h3>
|
||||
</div>
|
||||
<div class="pure-u-1-3" style="text-align:right;">
|
||||
<h3>
|
||||
<a href="/feed/playlist/<%= mix.id %>"><i class="icon ion-logo-rss"></i></a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% mix.videos.each_slice(4) do |slice| %>
|
||||
<div class="pure-g">
|
||||
<% slice.each do |item| %>
|
||||
<%= rendered "components/item" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
@ -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 %>
|
||||
@ -35,7 +35,7 @@
|
||||
<div class="pure-g h-box">
|
||||
<div class="pure-u-1 pure-u-md-1-5">
|
||||
<% if page >= 2 %>
|
||||
<a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>">Next page</a>
|
||||
<a href="/playlist?list=<%= playlist.id %>&page=<%= page - 1 %>">Previous page</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-md-3-5"></div>
|
||||
|
@ -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>
|
||||
|
@ -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 %>
|
||||
|
@ -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 %>
|
||||
|
@ -28,7 +28,7 @@
|
||||
<div class="pure-u-1 pure-u-md-12-24 searchbar">
|
||||
<form class="pure-form" action="/search" method="get">
|
||||
<fieldset>
|
||||
<input type="search" style="width:100%;" name="q" placeholder="search" value="<%= env.params.query["q"]? || env.get? "search" %>">
|
||||
<input type="search" style="width:100%;" name="q" placeholder="search" value="<%= env.params.query["q"]?.try {|x| HTML.escape(x)} || env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -55,7 +55,13 @@
|
||||
<p><i class="icon ion-ios-eye"></i> <%= number_with_separator(video.views) %></p>
|
||||
<p><i class="icon ion-ios-thumbs-up"></i> <%= number_with_separator(video.likes) %></p>
|
||||
<p><i class="icon ion-ios-thumbs-down"></i> <%= number_with_separator(video.dislikes) %></p>
|
||||
<p id="Genre">Genre: <a href="<%= video.genre_url %>"><%= video.genre %></a></p>
|
||||
<p id="Genre">Genre:
|
||||
<% if video.genre_url.empty? %>
|
||||
<%= video.genre %>
|
||||
<% else %>
|
||||
<a href="<%= video.genre_url %>"><%= video.genre %></a>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if !video.license.empty? %>
|
||||
<p id="License">License: <%= video.license %></p>
|
||||
<% end %>
|
||||
@ -212,7 +218,7 @@ function get_reddit_comments() {
|
||||
{title} \
|
||||
</h3> \
|
||||
<b> \
|
||||
<a target="_blank" href="https://reddit.com{permalink}">View more comments on Reddit</a> \
|
||||
<a rel="noopener" target="_blank" href="https://reddit.com{permalink}">View more comments on Reddit</a> \
|
||||
</b> \
|
||||
</div> \
|
||||
<div>{contentHtml}</div> \
|
||||
|
Reference in New Issue
Block a user