Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92543510a6 | ||
|
|
9bb865bf21 | ||
|
|
aaf729306f | ||
|
|
5df22d0f4c | ||
|
|
16e0848ddf | ||
|
|
9b4343a3bb | ||
|
|
cc505447e0 | ||
|
|
a24d8c4fff | ||
|
|
c8a008a526 | ||
|
|
a9fcb28b1b | ||
|
|
e050e53653 | ||
|
|
b1d6389e3d |
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,4 +3,3 @@ yarn.lock
|
||||
package-lock.json
|
||||
.env
|
||||
json.sqlite
|
||||
config.json
|
||||
@ -1,9 +1,169 @@
|
||||
### Code of Conduct
|
||||
# Contributor Covenant Code of Conduct / PokeTube code of conduct
|
||||
|
||||
By accessing, using, or contributing to Poke, you agree that all actions, behaviors, and interactions are subject to and governed by the **Poke Project Code of Conduct**, available at:
|
||||
## Our Pledge
|
||||
|
||||
https://poketube.fun/code-of-conduct
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
The provisions, standards, and expectations set forth therein apply in full to your use of this service and its related platforms.
|
||||
Any breach of these terms may result in limitation or termination of access as outlined in that document. More sections or updates may be introduced in the future without prior notice. Continued use constitutes acceptance of any such changes.
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
iamashley@duck.com (E-mail) https://discord.gg/pfKSQ3pMfW (Discord server) https://matrix.to/#/#poke:vern.cc (matrix space) and https://rvlt.gg/poke (revolt server).
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Additional Terms for Poketube
|
||||
**TL;DR**: You are encouraged not to edit or remove these terms from Poketube. While you have the freedom to make changes in your Poketube fork, if you choose to modify this document, please refrain from using the title "Poketube Code of Conduct." Everyone can copy and share this document as is, but making changes is allowed with the aforementioned condition. If your chosen alternative code of conduct doesn't include provisions against hate speech, inappropriate behavior, anti-immigrant sentiments, far-right, or authoritarian content, it's not recommended.
|
||||
|
||||
1. Definitions
|
||||
|
||||
- **"Alternative Code of Conduct"**: This refers to a code of conduct other than the Contributor Covenant Code of Conduct.
|
||||
|
||||
- **"Free Software"**: The definition of "free software" is in accordance with the GNU GPL version 3. You can find a complete copy of it in the LICENSE file.
|
||||
|
||||
- **Hate Speech**: Hate speech includes any communication, whether written, spoken, or expressed in any form, that promotes discrimination, hostility, or violence against individuals or groups based on attributes such as race, ethnicity, gender, religion, or other protected characteristics.
|
||||
|
||||
- **Inappropriate Behavior**: Inappropriate behavior encompasses actions or expressions that create an unwelcome, hostile, or offensive environment for others, such as harassment, intimidation, or bullying.
|
||||
|
||||
- **Authoritarianism**: Authoritarianism is characterized by an emphasis on strong central authority, limited individual freedoms, and restrictions on democratic processes. Content or behavior that promotes authoritarian principles, suppresses freedom of speech, individual rights, or democratic values is strongly discouraged.
|
||||
|
||||
- **Protected characteristics** include attributes such as race, ethnicity, gender, religion, sexual orientation, disability, and other traits or qualities safeguarded from discrimination by relevant laws and regulations. This defines what is meant by "protected characteristics" in the context of this document.
|
||||
|
||||
2. Terms
|
||||
|
||||
NOTE: The Contributor Covenant Code of Conduct already includes provisions on some of these issues. Our intention is to provide a more defined and explicit statement regarding these prohibitions to ensure a clear and inclusive community environment.
|
||||
|
||||
YOU ARE NOT ENCOURAGED TO EDIT, REMOVE, OR ALTER THE TERMS OF THIS FILE. However, should you choose to make changes, please avoid using the title "Poketube Code of Conduct." Removing this file from your Poketube fork is allowed. Everyone, without exception, is permitted to create unmodified copies of this document and distribute it as is; however, modifications to this document are allowed with the aforementioned condition.
|
||||
|
||||
It is of paramount importance to emphasize that the promotion or glorification of anti-immigrant sentiments, the alignment with far-right ideologies, Islamophobia, or any form of religious discrimination is strongly discouraged within the scope of Poketube. We maintain a stance against such content, which includes material that discriminates against immigrants, promotes hatred or hostility towards religious groups, or actively supports extremist beliefs associated with far-right ideologies. This stance is encouraged and non-binding.
|
||||
|
||||
We believe in fostering an environment that is inclusive, respectful, and free from discrimination or the promotion of extremist ideologies. As such, any content found in violation of this encouragement will be addressed promptly and appropriately.
|
||||
|
||||
THE CLARITY AND FORCE OF THIS STATEMENT ARE INTENDED TO ENCOURAGE CLEAR GUIDELINES: ANTI-IMMIGRANT SENTIMENTS, FAR-RIGHT IDEOLOGIES, ISLAMOPHOBIA, RELIGIOUS DISCRIMINATION, MISOGYNY, AND SEXISM ARE STRONGLY DISCOURAGED AND NOT PREFERRED WITHIN OUR COMMUNITY. OUR HOPE IS TO MAINTAIN A RESPECTFUL AND INCLUSIVE ATMOSPHERE FOR ALL, REGARDLESS OF THEIR BACKGROUND, BELIEFS, OR IDENTITY.
|
||||
|
||||
These terms may be subject to change, and any updates will be communicated to the Poketube community. Changes to these terms will be communicated to users.
|
||||
|
||||
**3. Application of GNU Kind Communication Policy**
|
||||
|
||||
These terms also align with the principles outlined in the [GNU Kind Communication Policy](https://www.gnu.org/philosophy/kind-communication.html), which encourage respectful and inclusive communication within the Poketube community.
|
||||
|
||||
It is crucial to note that we respect the diverse opinions and beliefs of our users.
|
||||
|
||||
***Additional terms end lol***
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
The developers are aware that the terms of service that apply to apps distributed via Apple's App Store services and similar app stores may conflict
|
||||
with rights granted under the Poke license, the GNU General
|
||||
Public License, version 3.
|
||||
|
||||
The copyright holders of the Poke project do not wish this conflict to prevent the otherwise-compliant distribution of derived apps via the App Store and similar app stores.
|
||||
|
||||
Therefore, we have committed not to pursue any license
|
||||
violation that results solely from the conflict between the GNU GPLv3
|
||||
and the Apple App Store terms of service or similar app stores. In
|
||||
other words, as long as you comply with the GPL in all other respects,
|
||||
including its requirements to provide users with source code and the
|
||||
text of the license, we will not object to your distribution of the
|
||||
Poke project through the App Store.
|
||||
109
README.md
109
README.md
@ -1,78 +1,59 @@
|
||||
<h1 align="center">
|
||||
<a href="https://poketube.fun/watch?v=QZfH7cFp3Ys">
|
||||
<a href="https://poketube.fun/watch?v=9sJUDx7iEJw&quality=medium&=sjohgteojgytrueugtye4jhtytjrjnyıı">
|
||||
<img src="https://poketube.fun/css/logo-poke.svg" width="400">
|
||||
</a>
|
||||
<a href="http://www.defectivebydesign.org/drm-free">
|
||||
<img src="https://static.fsf.org/dbd/label/DRM-free%20label%20120.en.png"
|
||||
alt="DRM Free" width="65" height="65" border="0" align="middle" />
|
||||
</a>
|
||||
<p>THE PRIVACY APP OF YOUR DREAMS! :3</p>
|
||||
<p>PRIVACY APP OF YOUR DREAMS! :3</p>
|
||||
</h1>
|
||||
|
||||
<div align="center">
|
||||
<p>Be anonymous while watching videos, gaming, and listening to music on Poke - the free privacy front-end!</p>
|
||||
<p>Be anonymous while watching (cat falling) videos, searching the web, and listening to music on Poke - the free privacy front-end!</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="#welcome">Welcome!</a> | <a href="#features">Features</a> | <a href="#no-non-free-codec-needed">No Non-Free Codec</a> | <a href="#hosting-poke">Hosting</a> | <a href="#poke-community">Community</a> | <a href="#legal">Legal</a>
|
||||
<br><br>
|
||||
<img src="https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg" alt="Stand with Ukraine">
|
||||
<img src="https://codeberg.org/ashley/pages/raw/branch/main/images/trans-badge.svg">
|
||||
<img src="https://codeberg.org/ashley/pages/raw/branch/main/images/free-Palestine.svg"><br>
|
||||
<a href="https://codeberg.org/ashley/poke/src/branch/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-GPL--3.0--or--later-FF6666" alt="GPL-3.0-or-later SPDX License">
|
||||
<a href="https://status.poketube.fun" target="_blank">
|
||||
<img width="170" src="https://api.netweak.com/status-badges/K2LY9" alt="Status Badge"/>
|
||||
</a>
|
||||
<img src="https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg" alt="Stand with Ukraine">
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-GPL--3-FF6666" alt="GPL-3 License">
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/Powered%20by-GNU/Linux-333333?logo=gnu" alt="GNU/Linux">
|
||||
<img src="https://img.shields.io/badge/Web%20Server-Nginx-009639?logo=nginx&logoColor=white" alt="Nginx">
|
||||
<br> <img src="https://img.shields.io/badge/Backend-Express.js-000000?logo=express" alt="Express.js">
|
||||
<img src="https://img.shields.io/badge/Frontend-EJS-F4D03F" alt="EJS">
|
||||
<img src="https://img.shields.io/badge/Language-JavaScript-F7DF1E?logo=javascript&logoColor=black" alt="JavaScript">
|
||||
</div>
|
||||
<br>
|
||||
|
||||
|
||||
|
||||
<img src="https://codeberg.org/ashley/pages/raw/branch/main/Untitled.webp">
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
## Welcome!
|
||||
|
||||
Welcome to Poke (formerly PokeTube), the privacy-friendly YouTube front-end built with the invidious API!
|
||||
Welcome to Poke (formerly PokeTube), the privacy-friendly YouTube front-end built with the InnerTube API! Imagine paying for YouTube Premium just to download videos - couldn't be us (literally).
|
||||
|
||||
## Features
|
||||
|
||||
| <img width="100%" style="border-radius: 24px" src="./css/README_RYD.png"> | <div style="text-align: left"><h3>🔙 Built-In Return YouTube Dislikes</h3>See the dislikes from *returnyoutubedislike* - because sometimes you need to know how bad that video really is :3</div> |
|
||||
| - | - |
|
||||
| <div style="text-align: right"><h3>📱 PWA Support</h3>With PWA Support, you can install Poke on your mobile device. Now you can pretend to be productive while watching cat videos on the go, mreoww! :3</div> | <img width="100%" style="border-radius: 24px" src="./css/README_PWA.jpg"> |
|
||||
| <h3>🎨 Customize</h3>Customize Poke however you want. Make it as unique as your taste in memes. | <h3>📥 Accounts</h3>Suscribe (yes Suscribe hehe sussy baka) to whaever channel you want! </div> |
|
||||
| <h3>☁️ PokeWeather</h3>Check The weather privately on PokeWeather!!! | <h3>🎶 And...</h3>Ambient mode, HQ audio, and even more! :3 |
|
||||
| <h3>🔍 Web Search</h3>Search the web privately on PokeTube. Incognito mode who? | <h3>🎶 And...</h3>Ambient mode, HQ audio, and even more! :3 |
|
||||
|
||||
## No Non-Free Codec Needed
|
||||
|
||||
Poke uses Free Software codecs! No non-free components included :3
|
||||
Poke uses OpenH264, which is free software! No non-free components included :3 View the source code of OpenH264 [here](https://github.com/cisco/openh264.git). Because who wants to deal with non-free stuff? Not us!~
|
||||
|
||||
## Hosting Poke
|
||||
|
||||
### NodeJS
|
||||
|
||||
1. **Install Packages**
|
||||
- Fedora/RHEL GNU/linux: `$ sudo dnf install git make gcc libcurl nodejs python libcurl g++ curl-config`
|
||||
- Debian/Ubuntu GNU/linux: `$ sudo apt install git make gcc libcurl4-openssl-dev nodejs npm python g++`
|
||||
- Alpine Linux (non-gnu): `$ apk add git nodejs npm python make gcc g++ libcurl curl-dev`
|
||||
|
||||
|
||||
- Fedora/RHEL: `$ sudo dnf install git make gcc libcurl nodejs python libcurl4 g++`
|
||||
- Debian/Ubuntu: `$ sudo apt install git make gcc libcurl4-openssl-dev nodejs npm python g++`
|
||||
|
||||
2. **Clone Repo**
|
||||
- Codeberg: `$ git clone https://codeberg.org/ashley/poke.git`
|
||||
- GitHub: `$ git clone https://github.com/ashley0143/poke.git`
|
||||
|
||||
reccomended unoffical mirrors:
|
||||
- git.lgbt: `$ git clone https://git.lgbt/mirror/poke.git` [sync every 10mins]
|
||||
- nin0git :`$ git clone https://git.nin0.dev/mirrors/poke.git` [sync every 10mins]
|
||||
|
||||
not reccomended, unstable
|
||||
- none!!! yippee
|
||||
|
||||
|
||||
3. **Install Dependencies**
|
||||
- `$ cd poke`
|
||||
@ -81,39 +62,49 @@ Poke uses Free Software codecs! No non-free components included :3
|
||||
4. **Start Server**
|
||||
- `$ node server.js`
|
||||
|
||||
Congrats! Poke should now be running on `localhost:6003`! 🎉
|
||||
|
||||
Congrats! PokeTube should now be running on `localhost:6003`! 🎉
|
||||
|
||||
### Docker
|
||||
|
||||
1. **Create Directory**
|
||||
- `$ mkdir poketube && cd poketube`
|
||||
|
||||
2. **Download Config**
|
||||
- `$ curl -O https://codeberg.org/Ashley/poke/raw/branch/main/docker-compose.yml`
|
||||
|
||||
3. **Run PokeTube**
|
||||
- `$ docker compose up -d`
|
||||
|
||||
Congrats! PokeTube should now be running on `localhost:6003`! 🎉
|
||||
|
||||
## Poke Community
|
||||
|
||||
Join us on [Discord](https://discord.poketube.fun/) ! I promise we're cool! <3
|
||||
Join us on [Revolt](https://rvlt.gg/poketube) or [Matrix](https://matrix.to/#/#poke:vern.cc)! I promise we're cool! <3
|
||||
|
||||
or if u like fedi, we host [PokeSocial](https://social.poketube.fun) as well :3
|
||||
|
||||
|
||||
## The Legal Stuff (boring tbh)
|
||||
the main parts of the project is Under GPL-3.0-OR-LATER :3
|
||||
see https://poketube.fun/license
|
||||
|
||||
see the each sections LICENSE tho!!
|
||||
|
||||
Copyleft 2021-202x Poke Project of poke initative, mostly ashley0143
|
||||
Trans rights!
|
||||
|
||||
https://initiative.poketube.fun/
|
||||
|
||||
poke proudly does not support the ["source first"](https://sourcefirst.com/) or ["open source"](https://opensource.org) movement :3
|
||||
|
||||
we support the free software movement (fsf.org)
|
||||
|
||||
please dont use the term "open source", see gnu.org/not-open-source for more information on why its a wrong term to use!
|
||||
|
||||
see the each sections LICENSE tho!!
|
||||
Copyleft 2021-202x Poke Project
|
||||
|
||||
[Code Of conduct](https://codeberg.org/Ashley/poke/src/branch/main/CODE_OF_CONDUCT.md)
|
||||
|
||||
<hr>
|
||||
|
||||
[Privacy Policy](https://poketube.fun/privacy)
|
||||
|
||||
TL;DR: we dont collect or share your personal info that's it lol
|
||||
<hr>
|
||||
TL;DR: we dont collect or share your personal info, that's it lol.
|
||||
|
||||
<p align="center"> <a href="https://www.amd.com/en/products/processors/server/epyc/7003-series/amd-epyc-7543.html"> <img width="65" height="65" src="https://codeberg.org/ashley/pages/raw/branch/main/amd.jpeg" alt="AMD EPYC"> </a> <a href="https://ubuntu.com/server"> <img width="65" height="65" src="https://res.cloudinary.com/canonical/image/fetch/f_auto,q_auto,fl_sanitize,w_317/https%3A%2F%2Fassets.ubuntu.com%2Fv1%2Ff76dd871-ubuntu-certified.png" alt="Ubuntu Certified"> </a> </p> <p align="center"> <small>Poke is proudly powered by AMD + Ubuntu servers!!!!!!!!!! (via Skrime Hosting). Parts of Poke ran on Glitch.com from <i>2021–2023</i>.</small> </p> <p align="center"> <a href="https://glitch.com/"> <img src="https://cdn.glitch.global/d68d17bb-f2c0-4bc3-993f-50902734f652/glitch-fastly-lock-up.svg" alt="Glitch logo"> </a> </p>
|
||||
We use the GNU Coding Standard Thingy, see [this link.](https://www.gnu.org/prep/standards)
|
||||
|
||||
|
||||
<div align="center">
|
||||
<p>Parts of Poke are hosted on Glitch.com since <i>2020</i>.</p>
|
||||
<a href="https://glitch.com/">
|
||||
<img src="https://cdn.glitch.global/d68d17bb-f2c0-4bc3-993f-50902734f652/glitch-fastly-lock-up.svg">
|
||||
</a>
|
||||
<br><hr>
|
||||
<a href="https://gnu.org/not-open-source">
|
||||
<img width="200" src="https://autumn.revolt.chat/attachments/eNpfwV2C1_wudONe43YCvWr-4vbvLpG78HbuXgOYfO">
|
||||
</a>
|
||||
</div>
|
||||
@ -1 +0,0 @@
|
||||
haii!! these files are made for the poke's server - if u wanna use them on ur server u might have to change the directories :p
|
||||
@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
#
|
||||
# Copyright (C) 2024-20xx Poke! (https://codeberg.org/ashley/poke)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
scriptDir=$(dirname "$(readlink -f "$0")")
|
||||
|
||||
output=$(docker run quay.io/invidious/youtube-trusted-session-generator)
|
||||
|
||||
visitor_data=$(echo "$output" | grep -oP '(?<=visitor_data: )[^ ]+')
|
||||
po_token=$(echo "$output" | grep -oP '(?<=po_token: )[^ ]+')
|
||||
|
||||
if [ -z "$visitor_data" ] || [ -z "$po_token" ]; then
|
||||
echo "Error: Could not generate visitor_data or po_token."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed -i "s/visitor_data: .*/visitor_data: $visitor_data/g" $scriptDir/../services/invidious/docker-compose.yml
|
||||
sed -i "s/po_token: .*/po_token: $po_token/g" $scriptDir/../services/invidious/docker-compose.yml
|
||||
|
||||
cd $scriptDir/../services/invidious
|
||||
|
||||
docker compose up -d
|
||||
|
||||
echo "Successfully updated visitor_data and po_token on Invidious."
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
#
|
||||
# Copyright (C) 2024-20xx Poke! (https://codeberg.org/ashley/poke)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
# Function to generate a random Chrome version
|
||||
generate_random_chrome_version() {
|
||||
major=$((RANDOM % 100 + 1)) # Major version 1-99
|
||||
minor=$((RANDOM % 100)) # Minor version 0-99
|
||||
build=$((RANDOM % 10000)) # Build version 0-9999
|
||||
patch=$((RANDOM % 100)) # Patch version 0-99
|
||||
echo "$major.$minor.$build.$patch"
|
||||
}
|
||||
|
||||
restart_services() {
|
||||
echo "Restarting services..."
|
||||
|
||||
# Navigate to the script directory
|
||||
scriptDir=$(dirname "$(readlink -f "$0")")
|
||||
|
||||
cd "$scriptDir/../services/invidious" || { echo "Error: Failed to navigate to $scriptDir/../services/invidious"; exit 1; }
|
||||
|
||||
docker compose down
|
||||
echo "Services stopped. Restarting..."
|
||||
|
||||
docker compose up -d
|
||||
echo "Services restarted successfully."
|
||||
|
||||
/home/qt/globe/scripts/inv-update-token.sh
|
||||
}
|
||||
|
||||
fetch_playlist() {
|
||||
local playlist_id="$1"
|
||||
response=$(curl -s -w "%{http_code}" -o /tmp/playlist_data.json "https://invid-api.poketube.fun/api/v1/playlists/${playlist_id}")
|
||||
|
||||
if [ "$response" -eq 502 ] || [ "$response" -eq 500 ] || [ "$response" -eq 403 ]; then
|
||||
echo "Error: Failed to fetch playlist data. HTTP Status: $response"
|
||||
restart_services
|
||||
return 1
|
||||
elif [ "$response" -ne 200 ]; then
|
||||
echo "Error: Failed to fetch playlist data. HTTP Status: $response"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
extract_video_ids() {
|
||||
local json_data="$1"
|
||||
video_ids=$(jq -r '.videos[].videoId' "$json_data")
|
||||
if [ -z "$video_ids" ]; then
|
||||
echo "Error: Failed to extract video IDs from the playlist data."
|
||||
return 1
|
||||
fi
|
||||
echo "$video_ids"
|
||||
}
|
||||
|
||||
# Playlist IDs to fetch
|
||||
playlist_ids=("PLMws9SCqJ1JCeVMVPsdamuUM0HK0MbA6g")
|
||||
|
||||
# Base URL for the API
|
||||
base_url="http://localhost:54301/latest_version?id="
|
||||
|
||||
# Pick a random playlist (without using invalid options in shuf)
|
||||
random_playlist_id="PLMC9KNkIncKvYin_USF1qoJQnIyMAfRxl"
|
||||
echo "Randomly selected playlist: $random_playlist_id"
|
||||
|
||||
# Fetch playlist JSON data
|
||||
fetch_playlist "$random_playlist_id"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Playlist fetch failed. Restarting services..."
|
||||
restart_services # Restart services before exiting
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract video IDs from the playlist
|
||||
video_ids=($(extract_video_ids "/tmp/playlist_data.json"))
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to extract video IDs. Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Shuffle video IDs and pick 4 random videos
|
||||
shuffled_video_ids=($(shuf -e "${video_ids[@]}" | head -n 4))
|
||||
|
||||
error_count=0
|
||||
all_errors=(500 502 403)
|
||||
|
||||
for video_id in "${shuffled_video_ids[@]}"; do
|
||||
# Add a cache buster query (unique random number)
|
||||
unique_param=$RANDOM
|
||||
url="${base_url}${video_id}&itag=18&local=true&_=${unique_param}"
|
||||
|
||||
# Generate a random Chrome version
|
||||
chrome_version=$(generate_random_chrome_version)
|
||||
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$chrome_version Safari/537.36"
|
||||
|
||||
response_headers=$(curl -s -D - -H "Cache-Control: no-cache, no-store, must-revalidate" \
|
||||
-H "Pragma: no-cache" -H "Expires: 0" -A "$user_agent" "$url" -o /dev/null)
|
||||
|
||||
# Extract ETag and last modified info (if available)
|
||||
etag=$(echo "$response_headers" | grep -i ETag | awk '{print $2}' | tr -d '"')
|
||||
last_modified=$(echo "$response_headers" | grep -i Last-Modified | cut -d' ' -f2-)
|
||||
|
||||
# Use conditional request if ETag is present
|
||||
if [ -n "$etag" ]; then
|
||||
status_code=$(curl -s -o /dev/null -w "%{http_code}" -H "If-None-Match: $etag" \
|
||||
-H "Cache-Control: no-cache, no-store, must-revalidate" -A "$user_agent" "$url")
|
||||
else
|
||||
status_code=$(curl -s -o /dev/null -w "%{http_code}" -A "$user_agent" "$url")
|
||||
fi
|
||||
|
||||
# Echo the status code for visibility
|
||||
echo "Checking URL: $url"
|
||||
echo "User Agent: $user_agent"
|
||||
echo "HTTP Status Code for ID $video_id: $status_code"
|
||||
|
||||
if [[ " ${all_errors[@]} " =~ " ${status_code} " ]]; then
|
||||
echo "Error: Received $status_code for ID $video_id."
|
||||
error_count=$((error_count + 1))
|
||||
|
||||
# Run the token refresh script
|
||||
echo "Running inv-update-token.sh for ID $video_id..."
|
||||
/home/qt/globe/scripts/inv-update-token.sh
|
||||
/home/qt/globe/scripts/inv-update-token.sh
|
||||
echo "inv-update-token.sh script executed successfully."
|
||||
|
||||
# Fetch the video again after token refresh
|
||||
status_code=$(curl -s -o /dev/null -w "%{http_code}" -A "$user_agent" "$url")
|
||||
echo "Post-token-refresh Status Code for ID $video_id: $status_code"
|
||||
|
||||
# Check if it still results in 500/502/403 after refresh
|
||||
if [[ " ${all_errors[@]} " =~ " ${status_code} " ]]; then
|
||||
echo "Error: Received $status_code for ID $video_id after token refresh."
|
||||
else
|
||||
echo "Token refresh succeeded for ID $video_id."
|
||||
fi
|
||||
elif [ "$status_code" -eq 304 ]; then
|
||||
echo "Content is still fresh for ID $video_id. No action required."
|
||||
else
|
||||
echo "we are so barack (Status code for ID $video_id is neither 502, 500, nor 403.)"
|
||||
fi
|
||||
|
||||
echo "----------------****************----------------" # Separator for readability
|
||||
done
|
||||
|
||||
# If all videos still resulted in 500/502/403 errors even after running inv-update-token.sh, try restaring
|
||||
if [ "$error_count" -eq "${#shuffled_video_ids[@]}" ]; then
|
||||
echo "All videos failed to load after running inv-update-token.sh. Restarting services..."
|
||||
restart_services
|
||||
fi
|
||||
@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
#CHANGE BOTH OF THESE TO THE CORRECT VALUES
|
||||
domain="server_ddns_ip_here"
|
||||
wireguard_file="/path/to/wg0.conf"
|
||||
|
||||
new_ip=$(dig +short "$domain" | tail -n1)
|
||||
current_ip=$(grep -Eo 'Endpoint = [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "$wireguard_file" | awk '{print $3}')
|
||||
|
||||
while true; do
|
||||
read -p "Are you sure you want to run this? Have you checked the gluetun logs first? [Yy/Nn]" yn
|
||||
case $yn in Y|y|Yes|yes* )
|
||||
if [[ "$new_ip" == "$current_ip" ]]; then
|
||||
echo "IP is already up to date: $current_ip"
|
||||
exit 0
|
||||
else
|
||||
sed -i -r "s/^(Endpoint = +)([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)(:[0-9]+)/\1$new_ip\3/" "$wireguard_file"
|
||||
|
||||
echo "IP updated in $wireguard_file, new IP is: $(grep '^Endpoint' "$wireguard_file")"
|
||||
|
||||
echo "Restarting gluetun..."
|
||||
|
||||
docker restart gluetun >/dev/null 2>&1
|
||||
|
||||
echo "Restarting companion..."
|
||||
|
||||
docker restart invidious-companion-1 >/dev/null 2>&1
|
||||
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
|
||||
N|n|No|no* ) exit;;
|
||||
* ) echo "Please answer yes or no.";;
|
||||
|
||||
esac
|
||||
done
|
||||
@ -1,700 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# poke-nginx-analytics — IPv4/IPv6 traffic analyzer for Nginx access logs.
|
||||
# Author: Ashley Iris — https://ashley0143.xyz
|
||||
# Copyright (C) 2025 Poke Initiative
|
||||
#
|
||||
# This is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --------------------------- Metadata ---------------------------
|
||||
|
||||
VERSION="1.3.0"
|
||||
SCRIPT_NAME="poke-nginx-analytics.sh"
|
||||
|
||||
# DISCLAIMER:
|
||||
# This tool reads your existing local Nginx access logs and prints
|
||||
# aggregate counts to your terminal. It makes no network requests,
|
||||
# stores nothing, and sends nothing anywhere. Optional flags let you
|
||||
# ignore bot traffic and anonymize IPs in summaries. In other words:
|
||||
# this is not “data collection” — it’s a local, read-only report.
|
||||
|
||||
# --------------------------- Defaults ---------------------------
|
||||
|
||||
LOG_GLOB="/var/log/nginx/access.log*"
|
||||
DATEPAT="$(date +"%d/%b/%Y")" # e.g. "02/Oct/2025"
|
||||
SINCE="" # "HH:MM" within DATEPAT (inclusive)
|
||||
UNTIL="" # "HH:MM" within DATEPAT (inclusive)
|
||||
WATCH_INTERVAL="" # seconds; if set, loop output
|
||||
|
||||
SUCCESS_CODES_DEFAULT="200,301,302,304"
|
||||
SUCCESS_REGEX_DEFAULT=""
|
||||
|
||||
IGNORE_BOTS_DEFAULT="0"
|
||||
BOT_REGEX_DEFAULT='(?i)(bot|spider|crawler|bingpreview|httpclient|curl|wget|headless|phantom|scrapy|uptimerobot|validator|pingdom|ahrefs|semrush|mj12|yandex|baiduspider|facebookexternalhit|discordbot)'
|
||||
|
||||
TOP_LIMIT_DEFAULT=10
|
||||
ANONIP_DEFAULT="0" # mask IPs in uniques/top lists (v4 /24; v6 /64)
|
||||
|
||||
SHOW_LOADING_DEFAULT="1" # show “Loading …” spinner for longer actions
|
||||
|
||||
# --------------------------- License / Privacy ------------------
|
||||
|
||||
print_license() {
|
||||
cat <<'LIC'
|
||||
poke-nginx-analytics — IPv4/IPv6 traffic analyzer for Nginx access logs.
|
||||
Author: Ashley Iris — https://ashley0143.xyz
|
||||
Copyright (C) 2025 Poke Initiative
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
this program. If not, see: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
LIC
|
||||
}
|
||||
|
||||
print_privacy() {
|
||||
cat <<'PV'
|
||||
The Software operates locally on the controller’s host and processes only the HTTP access log files specified by the controller (by default, /var/log/nginx/access.log*). Processing is limited to parsing log lines to compute ephemeral aggregate statistics such as counts by IP version, status code, and hour; no identification, profiling, cross-service correlation, or tracking is performed. The Software initiates no outbound network connections and transmits no telemetry or data to third parties. Output is directed solely to the invoking terminal session and is not persisted unless the controller elects to do so (for example, by redirecting standard output). The controller remains solely responsible for the lawfulness of logging and retention; the Software does not modify logging configuration or retention policies and does not create additional stores. Optional controls permit the controller to pseudonymize displayed addresses (masking IPv4 to /24 and IPv6 to /64) and to exclude typical automated User-Agents; these controls affect only presentation and do not alter source logs. Use of the Software constitutes processing under the controller’s legitimate administrative interests in operating, securing, and troubleshooting services. The Software performs deterministic, documented parsing of provided files and is publicly licensed under GPL-3.0-or-later, enabling independent audit. No special privileges are required beyond read access to the designated log files.
|
||||
PV
|
||||
}
|
||||
|
||||
# --------------------------- Usage ------------------------------
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
$SCRIPT_NAME v$VERSION — IPv4/IPv6 traffic analyzer for Nginx access logs
|
||||
Author: Ashley Iris — https://ashley0143.xyz
|
||||
|
||||
Usage:
|
||||
$SCRIPT_NAME <subcommand> [options]
|
||||
|
||||
Subcommands:
|
||||
today Show today's IPv4 and IPv6 counts and percentages (optionally time-windowed).
|
||||
today-success Same as above but success-only.
|
||||
today-fail Same as above but fail-only (non-success).
|
||||
success-rate Show success rate today for v4/v6, plus totals and top 5 fail codes.
|
||||
v4-today IPv4 count today.
|
||||
v6-today IPv6 count today.
|
||||
v4-today-success IPv4 success-only today.
|
||||
v6-today-success IPv6 success-only today.
|
||||
breakdown-today v4|v6 Status-code breakdown today for v4 or v6.
|
||||
hourly Hourly breakdown today for v4/v6 (+totals).
|
||||
uniques Unique IP counts today for v4 and v6 (respects --anonip).
|
||||
top-ips v4|v6 Top client IPs today for the chosen family (respects --anonip).
|
||||
top-fails Top 5 failure reasons today (codes + short explanations and common fixes).
|
||||
all IPv4 vs IPv6 totals from all logs (no date/time filter).
|
||||
|
||||
Options:
|
||||
--file PATH|GLOB Single log path or glob (default: /var/log/nginx/access.log*)
|
||||
--date DD/Mon/YYYY Override date filter (default: today)
|
||||
--since HH:MM Start time within --date (inclusive)
|
||||
--until HH:MM End time within --date (inclusive)
|
||||
--success-codes "list" Comma-separated list (default: 200,301,302,304)
|
||||
--success-regex REGEX Regex for success, e.g. '^(2..|3..)$' (overrides codes list)
|
||||
--ignore-bots Exclude bots by UA regex (see --bot-regex)
|
||||
--bot-regex REGEX Override bot UA regex (default is broad and case-insensitive)
|
||||
--limit N Limit for top-ips (default: 10)
|
||||
--anonip Mask IPs in uniques/top-ips (v4 /24; v6 /64)
|
||||
--watch SECONDS Refresh output every N seconds
|
||||
--no-loading Disable the spinner/“Loading …” message
|
||||
--license | -license Print GPL-3.0-or-later license notice
|
||||
--privacy Print a privacy statement
|
||||
--version Print version and exit
|
||||
-h, --help Show this help
|
||||
|
||||
Notes:
|
||||
- Handles rotated + gz logs automatically (access.log, access.log.1, access.log.2.gz, ...).
|
||||
- Time filters apply inside the chosen --date only.
|
||||
- If both --success-regex and --success-codes are set, regex wins.
|
||||
USAGE
|
||||
}
|
||||
|
||||
# --------------------------- Helpers ---------------------------
|
||||
|
||||
LOG_FILES=()
|
||||
SUCCESS_CODES="$SUCCESS_CODES_DEFAULT"
|
||||
SUCCESS_REGEX="$SUCCESS_REGEX_DEFAULT"
|
||||
TOP_LIMIT="$TOP_LIMIT_DEFAULT"
|
||||
IGNORE_BOTS="$IGNORE_BOTS_DEFAULT"
|
||||
BOT_REGEX="$BOT_REGEX_DEFAULT"
|
||||
ANONIP="$ANONIP_DEFAULT"
|
||||
SHOW_LOADING="$SHOW_LOADING_DEFAULT"
|
||||
|
||||
SUB=""
|
||||
FAMILY="" # v4 or v6
|
||||
|
||||
expand_logs() {
|
||||
local glob="${1:-$LOG_GLOB}"
|
||||
# shellcheck disable=SC2206
|
||||
local arr=($glob)
|
||||
LOG_FILES=()
|
||||
for f in "${arr[@]}"; do
|
||||
if ([[ -f "$f" ]] || [[ "$f" =~ \* ]]); then LOG_FILES+=("$f"); fi
|
||||
done
|
||||
if [[ ${#LOG_FILES[@]} -eq 1 && "${LOG_FILES[0]}" == *"*"* ]]; then
|
||||
# shellcheck disable=SC2206
|
||||
local rexpanded=(${LOG_FILES[0]})
|
||||
LOG_FILES=()
|
||||
for f in "${rexpanded[@]}"; do
|
||||
if [[ -f "$f" ]]; then LOG_FILES+=("$f"); fi
|
||||
done
|
||||
fi
|
||||
if [[ ${#LOG_FILES[@]} -eq 0 ]]; then
|
||||
echo "No log files found for: $glob" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
read_logs() {
|
||||
for f in "${LOG_FILES[@]}"; do
|
||||
case "$f" in
|
||||
*.gz) zcat -f -- "$f" ;;
|
||||
*) cat -- "$f" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
percent() {
|
||||
local part="$1" total="$2"
|
||||
if [[ "$total" -eq 0 ]]; then printf "0.0"; return; fi
|
||||
awk -v p="$part" -v t="$total" 'BEGIN{ printf("%.1f", (p*100.0)/t) }'
|
||||
}
|
||||
|
||||
awk_date_guard() {
|
||||
if [[ -z "$SINCE$UNTIL" ]]; then
|
||||
echo 'substr($4,2,11)==datepat'
|
||||
else
|
||||
local cond='substr($4,2,11)==datepat'
|
||||
if [[ -n "$SINCE" ]]; then
|
||||
cond="$cond && substr(\$4,14,5)>=since"
|
||||
fi
|
||||
if [[ -n "$UNTIL" ]]; then
|
||||
cond="$cond && substr(\$4,14,5)<=until"
|
||||
fi
|
||||
echo "$cond"
|
||||
fi
|
||||
}
|
||||
|
||||
awk_common_header() {
|
||||
cat <<'AWKHEAD'
|
||||
function is_ipv4(ip) { return ip ~ /^([0-9]{1,3}\.){3}[0-9]{1,3}$/ }
|
||||
function is_ipv6(ip) { return ip ~ /:/ && !(ip ~ /^([0-9]{1,3}\.){3}[0-9]{1,3}$/) }
|
||||
|
||||
# Get the last quoted field as UA (combined log format)
|
||||
function extract_ua(line, ua) {
|
||||
if (match(line, /"[^"]*"$/)) {
|
||||
ua = substr(line, RSTART+1, RLENGTH-2);
|
||||
return ua;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
# Mask IPv4 to /24, IPv6 to /64 (display only)
|
||||
function anon_ip(ip, a, i, out, n) {
|
||||
if (anonip==0) return ip;
|
||||
if (is_ipv4(ip)) {
|
||||
n = split(ip, a, ".");
|
||||
if (n==4) { a[4]=0; return a[1]"."a[2]"."a[3]".0" }
|
||||
return ip;
|
||||
} else if (is_ipv6(ip)) {
|
||||
n = split(ip, a, ":");
|
||||
for (i=5; i<=n; i++) a[i]="0000";
|
||||
out=a[1];
|
||||
for (i=2;i<=n;i++) out=out ":" a[i];
|
||||
return out;
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
function ok_status(code) { # success by regex or by list
|
||||
if (has_regex) return (code ~ success_regex);
|
||||
return (code in okcodes);
|
||||
}
|
||||
AWKHEAD
|
||||
}
|
||||
|
||||
awk_begin_block() {
|
||||
local bot_awk_regex
|
||||
bot_awk_regex=$(printf '%s' "$BOT_REGEX" | sed 's/[&/\]/\\&/g')
|
||||
cat <<EOF
|
||||
BEGIN {
|
||||
has_regex = ("$SUCCESS_REGEX" != "");
|
||||
success_regex = "$SUCCESS_REGEX";
|
||||
if (!has_regex) {
|
||||
split("$SUCCESS_CODES", arr, ",");
|
||||
for (i in arr) okcodes[arr[i]]=1;
|
||||
}
|
||||
ignore_bots = $IGNORE_BOTS;
|
||||
bot_regex = "$bot_awk_regex";
|
||||
anonip = $ANONIP;
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
count_family_today() {
|
||||
local fam="$1" mode="$2"
|
||||
local date_guard; date_guard="$(awk_date_guard)"
|
||||
local pred_ip
|
||||
if [[ "$fam" == "v4" ]]; then pred_ip='is_ipv4($1)'
|
||||
else pred_ip='is_ipv6($1)'
|
||||
fi
|
||||
|
||||
awk -v datepat="$DATEPAT" -v since="$SINCE" -v until="$UNTIL" '
|
||||
'"$(awk_common_header)"'
|
||||
'"$(awk_begin_block)"'
|
||||
{
|
||||
if (!('"$date_guard"')) next;
|
||||
|
||||
if (ignore_bots) {
|
||||
ua = extract_ua($0);
|
||||
if (ua ~ bot_regex) next;
|
||||
}
|
||||
|
||||
if (!('"$pred_ip"')) next;
|
||||
|
||||
status = $9;
|
||||
success = ok_status(status);
|
||||
|
||||
if ("'"$mode"'"=="any") c++;
|
||||
else if ("'"$mode"'"=="success" && success) c++;
|
||||
else if ("'"$mode"'"=="fail" && !success) c++;
|
||||
}
|
||||
END { print c+0 }
|
||||
' < <(read_logs)
|
||||
}
|
||||
|
||||
status_breakdown_today() {
|
||||
local fam="$1"
|
||||
local date_guard; date_guard="$(awk_date_guard)"
|
||||
local pred_ip
|
||||
if [[ "$fam" == "v4" ]]; then pred_ip='is_ipv4($1)'
|
||||
else pred_ip='is_ipv6($1)'
|
||||
fi
|
||||
|
||||
awk -v datepat="$DATEPAT" -v since="$SINCE" -v until="$UNTIL" '
|
||||
'"$(awk_common_header)"'
|
||||
'"$(awk_begin_block)"'
|
||||
{
|
||||
if (!('"$date_guard"')) next;
|
||||
|
||||
if (ignore_bots) {
|
||||
ua = extract_ua($0);
|
||||
if (ua ~ bot_regex) next;
|
||||
}
|
||||
|
||||
if (!('"$pred_ip"')) next;
|
||||
|
||||
code=$9; count[code]++;
|
||||
}
|
||||
END {
|
||||
for (c in count) printf "%10d %s\n", count[c], c | "sort -nr";
|
||||
close("sort -nr");
|
||||
}
|
||||
' < <(read_logs)
|
||||
}
|
||||
|
||||
hourly_breakdown_today() {
|
||||
local date_guard; date_guard="$(awk_date_guard)"
|
||||
|
||||
awk -v datepat="$DATEPAT" -v since="$SINCE" -v until="$UNTIL" '
|
||||
'"$(awk_common_header)"'
|
||||
'"$(awk_begin_block)"'
|
||||
{
|
||||
if (!('"$date_guard"')) next;
|
||||
|
||||
if (ignore_bots) {
|
||||
ua = extract_ua($0);
|
||||
if (ua ~ bot_regex) next;
|
||||
}
|
||||
|
||||
hh=substr($4,14,2);
|
||||
if (is_ipv4($1)) v4[hh]++; else if (is_ipv6($1)) v6[hh]++;
|
||||
}
|
||||
END {
|
||||
printf "Hour | v4 v6 total\n";
|
||||
printf "------+------------------------\n";
|
||||
for (i=0; i<24; i++) {
|
||||
h = sprintf("%02d", i);
|
||||
a=v4[h]+0; b=v6[h]+0; t=a+b;
|
||||
printf "%s | %6d %6d %8d\n", h, a, b, t;
|
||||
}
|
||||
}
|
||||
' < <(read_logs)
|
||||
}
|
||||
|
||||
unique_ips_today() {
|
||||
local date_guard; date_guard="$(awk_date_guard)"
|
||||
|
||||
awk -v datepat="$DATEPAT" -v since="$SINCE" -v until="$UNTIL" '
|
||||
'"$(awk_common_header)"'
|
||||
'"$(awk_begin_block)"'
|
||||
{
|
||||
if (!('"$date_guard"')) next;
|
||||
|
||||
if (ignore_bots) {
|
||||
ua = extract_ua($0);
|
||||
if (ua ~ bot_regex) next;
|
||||
}
|
||||
|
||||
ip=$1;
|
||||
aip=anon_ip(ip);
|
||||
|
||||
if (is_ipv4(ip)) v4[aip]=1;
|
||||
else if (is_ipv6(ip)) v6[aip]=1;
|
||||
}
|
||||
END {
|
||||
print (length(v4)+0) " " (length(v6)+0);
|
||||
}
|
||||
' < <(read_logs)
|
||||
}
|
||||
|
||||
top_ips_today() {
|
||||
local fam="$1" limit="$2"
|
||||
local date_guard; date_guard="$(awk_date_guard)"
|
||||
local pred_ip
|
||||
if [[ "$fam" == "v4" ]]; then pred_ip='is_ipv4($1)'
|
||||
else pred_ip='is_ipv6($1)'
|
||||
fi
|
||||
|
||||
awk -v datepat="$DATEPAT" -v since="$SINCE" -v until="$UNTIL" -v lim="$limit" '
|
||||
'"$(awk_common_header)"'
|
||||
'"$(awk_begin_block)"'
|
||||
{
|
||||
if (!('"$date_guard"')) next;
|
||||
|
||||
if (ignore_bots) {
|
||||
ua = extract_ua($0);
|
||||
if (ua ~ bot_regex) next;
|
||||
}
|
||||
|
||||
if (!('"$pred_ip"')) next;
|
||||
|
||||
ip=$1;
|
||||
aip=anon_ip(ip);
|
||||
c[aip]++;
|
||||
}
|
||||
END {
|
||||
cmd = "sort -nr | head -n " lim;
|
||||
for (k in c) printf "%10d %s\n", c[k], k | cmd;
|
||||
close(cmd);
|
||||
}
|
||||
' < <(read_logs)
|
||||
}
|
||||
|
||||
# collect top failure codes (v4+v6 combined)
|
||||
_top_fail_codes_today_raw() {
|
||||
local limit="${1:-5}"
|
||||
local date_guard; date_guard="$(awk_date_guard)"
|
||||
awk -v datepat="$DATEPAT" -v since="$SINCE" -v until="$UNTIL" -v lim="$limit" '
|
||||
'"$(awk_common_header)"'
|
||||
'"$(awk_begin_block)"'
|
||||
{
|
||||
if (!('"$date_guard"')) next;
|
||||
|
||||
if (ignore_bots) {
|
||||
ua = extract_ua($0);
|
||||
if (ua ~ bot_regex) next;
|
||||
}
|
||||
|
||||
code=$9;
|
||||
if (!ok_status(code)) fails[code]++;
|
||||
}
|
||||
END {
|
||||
cmd = "sort -nr | head -n " lim;
|
||||
for (c in fails) printf "%10d %s\n", fails[c], c | cmd;
|
||||
close(cmd);
|
||||
}
|
||||
' < <(read_logs)
|
||||
}
|
||||
|
||||
# map codes to short reason + common fixes
|
||||
_code_reason_and_fix() {
|
||||
local code="$1"
|
||||
case "$code" in
|
||||
404) echo "Not Found | Check routes/file paths; verify upstream/location blocks; add/refresh indexes." ;;
|
||||
403) echo "Forbidden | Fix permissions/SELinux; review 'deny' rules; ensure correct root/user; auth config." ;;
|
||||
400) echo "Bad Request | Validate request size/headers; client encoding; large header buffers." ;;
|
||||
401) echo "Unauthorized | Confirm auth headers/keys; verify Basic/Bearer configs; clock skew for signed URLs." ;;
|
||||
408) echo "Client Timeout | Client slow; increase client_header/body_timeout; check network; throttle long uploads." ;;
|
||||
413) echo "Payload Too Large | Raise client_max_body_size; review upload size from client/app." ;;
|
||||
414) echo "URI Too Long | Reduce query size; switch to POST; adjust large_client_header_buffers." ;;
|
||||
429) echo "Too Many Requests | Tuning rate limiting/burst; whitelist health checks/bots if intended." ;;
|
||||
499) echo "Client Closed Request | Client aborted/timeout; investigate latency; optimize upstream; keepalive." ;;
|
||||
500) echo "Internal Server Error | Check app errors; upstream logs; fastcgi/proxy params; temp file perms." ;;
|
||||
502) echo "Bad Gateway | Upstream down/misconfigured; check upstream server, sockets, DNS, healthchecks." ;;
|
||||
503) echo "Service Unavailable | Upstream overloaded/maintenance; tune workers; queue/backoff; autoscale." ;;
|
||||
504) echo "Gateway Timeout | Upstream slow; raise proxy_read_timeout; profile app/DB; connection pool." ;;
|
||||
530|531|532) echo "Upstream/Custom Error | Vendor or custom code; check error_page mapping and upstream." ;;
|
||||
301|302|304) echo "Redirect/Not Modified | Typically not a failure; ensure correct cache/redirect rules." ;;
|
||||
*) # generic 4xx/5xx buckets
|
||||
case "$code" in
|
||||
4??) echo "Client Error | Validate client requests, auth, size limits, and security rules." ;;
|
||||
5??) echo "Server Error | Inspect upstream/app, timeouts, resource limits, and Nginx proxy config." ;;
|
||||
*) echo "Other | Review Nginx error logs and upstream/application logs." ;;
|
||||
esac ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# pretty top fails with reasons and fixes
|
||||
top_fail_reasons_today() {
|
||||
local limit="${1:-5}"
|
||||
_top_fail_codes_today_raw "$limit" | while read -r cnt code; do
|
||||
local info; info=$(_code_reason_and_fix "$code")
|
||||
local reason=${info%|*}
|
||||
local fix=${info#*|}
|
||||
printf "%10d %s — %s\n fix: %s\n" "$cnt" "$code" "$(echo "$reason" | sed 's/^ *//')" "$(echo "$fix" | sed 's/^ *//')"
|
||||
done
|
||||
}
|
||||
|
||||
count_all_family() {
|
||||
local fam="$1"
|
||||
local pred_ip
|
||||
if [[ "$fam" == "v4" ]]; then pred_ip='is_ipv4($1)'
|
||||
else pred_ip='is_ipv6($1)'
|
||||
fi
|
||||
|
||||
awk '
|
||||
'"$(awk_common_header)"'
|
||||
BEGIN { ignore_bots=0; has_regex=0; anonip=0; }
|
||||
{
|
||||
if (!('"$pred_ip"')) next;
|
||||
c++;
|
||||
}
|
||||
END { print c+0 }
|
||||
' < <(read_logs)
|
||||
}
|
||||
|
||||
# ----------------------- Spinner / Loading ---------------------
|
||||
|
||||
_spinner_start() {
|
||||
[[ "$SHOW_LOADING" != "1" || -n "${WATCH_INTERVAL}" ]] && return 0
|
||||
local msg="${1:-Loading…}"
|
||||
printf "%s " "$msg"
|
||||
(
|
||||
i=0
|
||||
while :; do
|
||||
case $((i%4)) in
|
||||
0) c='|';;
|
||||
1) c='/';;
|
||||
2) c='-';;
|
||||
3) c='\\';;
|
||||
esac
|
||||
printf "\r%s %s" "$msg" "$c"
|
||||
i=$((i+1))
|
||||
sleep 0.15
|
||||
done
|
||||
) &
|
||||
SPINNER_PID=$!
|
||||
disown "$SPINNER_PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
_spinner_stop() {
|
||||
[[ "${SPINNER_PID:-}" =~ ^[0-9]+$ ]] || return 0
|
||||
kill "$SPINNER_PID" 2>/dev/null || true
|
||||
wait "$SPINNER_PID" 2>/dev/null || true
|
||||
unset SPINNER_PID
|
||||
printf "\r\033[K"
|
||||
}
|
||||
|
||||
run_with_spinner() {
|
||||
local msg="$1"; shift
|
||||
local tmp; tmp="$(mktemp)"
|
||||
_spinner_start "$msg"
|
||||
(
|
||||
"$@"
|
||||
) >"$tmp" 2>&1 || true
|
||||
_spinner_stop
|
||||
cat "$tmp"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# -------------------------- CLI Parse --------------------------
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--license|-license) print_license; exit 0 ;;
|
||||
--privacy) print_privacy; exit 0 ;;
|
||||
--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
today|today-success|today-fail|success-rate|v4-today|v6-today|v4-today-success|v6-today-success|breakdown-today|hourly|uniques|top-ips|top-fails|all)
|
||||
SUB="$1"; shift ;;
|
||||
v4|v6) FAMILY="$1"; shift ;;
|
||||
--file) LOG_GLOB="$2"; shift 2 ;;
|
||||
--date) DATEPAT="$2"; shift 2 ;;
|
||||
--since) SINCE="$2"; shift 2 ;;
|
||||
--until) UNTIL="$2"; shift 2 ;;
|
||||
--success-codes) SUCCESS_CODES="$2"; shift 2 ;;
|
||||
--success-regex) SUCCESS_REGEX="$2"; shift 2 ;;
|
||||
--ignore-bots) IGNORE_BOTS="1"; shift ;;
|
||||
--bot-regex) BOT_REGEX="$2"; shift 2 ;;
|
||||
--limit) TOP_LIMIT="$2"; shift 2 ;;
|
||||
--anonip) ANONIP="1"; shift ;;
|
||||
--watch) WATCH_INTERVAL="$2"; shift 2 ;;
|
||||
--no-loading) SHOW_LOADING="0"; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown argument: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
expand_logs "$LOG_GLOB"
|
||||
|
||||
# ---------------------------- Runner ---------------------------
|
||||
|
||||
_do_command() {
|
||||
case "$SUB" in
|
||||
today)
|
||||
v4=$(count_family_today v4 any)
|
||||
v6=$(count_family_today v6 any)
|
||||
total=$((v4+v6))
|
||||
pv4=$(percent "$v4" "$total"); pv6=$(percent "$v6" "$total")
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "IPv4: $v4 (${pv4}%)"
|
||||
echo "IPv6: $v6 (${pv6}%)"
|
||||
echo "Total requests: $total"
|
||||
;;
|
||||
today-success)
|
||||
v4=$(count_family_today v4 success)
|
||||
v6=$(count_family_today v6 success)
|
||||
total=$((v4+v6))
|
||||
pv4=$(percent "$v4" "$total"); pv6=$(percent "$v6" "$total")
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ -n "$SUCCESS_REGEX" ]] && echo "Success regex: $SUCCESS_REGEX" || echo "Success codes: $SUCCESS_CODES"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "IPv4 (success-only): $v4 (${pv4}%)"
|
||||
echo "IPv6 (success-only): $v6 (${pv6}%)"
|
||||
echo "Total successful requests: $total"
|
||||
;;
|
||||
today-fail)
|
||||
v4=$(count_family_today v4 fail)
|
||||
v6=$(count_family_today v6 fail)
|
||||
total=$((v4+v6))
|
||||
pv4=$(percent "$v4" "$total"); pv6=$(percent "$v6" "$total")
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ -n "$SUCCESS_REGEX" ]] && echo "Success complement of regex: $SUCCESS_REGEX" || echo "Fail = not in: $SUCCESS_CODES"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "IPv4 (fail-only): $v4 (${pv4}%)"
|
||||
echo "IPv6 (fail-only): $v6 (${pv6}%)"
|
||||
echo "Total failed requests: $total"
|
||||
echo "Top 5 failure reasons:"
|
||||
top_fail_reasons_today 5
|
||||
;;
|
||||
success-rate)
|
||||
s4=$(count_family_today v4 success)
|
||||
a4=$(count_family_today v4 any)
|
||||
s6=$(count_family_today v6 success)
|
||||
a6=$(count_family_today v6 any)
|
||||
r4=$(percent "$s4" "$a4")
|
||||
r6=$(percent "$s6" "$a6")
|
||||
stotal=$((s4+s6))
|
||||
atotal=$((a4+a6))
|
||||
ftotal=$((atotal-stotal))
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ -n "$SUCCESS_REGEX" ]] && echo "Success regex: $SUCCESS_REGEX" || echo "Success codes: $SUCCESS_CODES"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "IPv4 success-rate: $s4 / $a4 (${r4}%)"
|
||||
echo "IPv6 success-rate: $s6 / $a6 (${r6}%)"
|
||||
echo "—"
|
||||
echo "TOTAL requests: $atotal"
|
||||
echo "TOTAL successful: $stotal"
|
||||
echo "TOTAL failed: $ftotal"
|
||||
echo "Top 5 failure reasons (combined v4+v6):"
|
||||
top_fail_reasons_today 5
|
||||
;;
|
||||
v4-today)
|
||||
v4=$(count_family_today v4 any)
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "IPv4: $v4"
|
||||
;;
|
||||
v6-today)
|
||||
v6=$(count_family_today v6 any)
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "IPv6: $v6"
|
||||
;;
|
||||
v4-today-success)
|
||||
v4=$(count_family_today v4 success)
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ -n "$SUCCESS_REGEX" ]] && echo "Success regex: $SUCCESS_REGEX" || echo "Success codes: $SUCCESS_CODES"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "IPv4 (success-only): $v4"
|
||||
;;
|
||||
v6-today-success)
|
||||
v6=$(count_family_today v6 success)
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ -n "$SUCCESS_REGEX" ]] && echo "Success regex: $SUCCESS_REGEX" || echo "Success codes: $SUCCESS_CODES"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "IPv6 (success-only): $v6"
|
||||
;;
|
||||
breakdown-today)
|
||||
if [[ -z "$FAMILY" ]]; then echo "Specify family: v4 or v6" >&2; exit 1; fi
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "Status breakdown ($FAMILY):"
|
||||
status_breakdown_today "$FAMILY"
|
||||
;;
|
||||
hourly)
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
hourly_breakdown_today
|
||||
;;
|
||||
uniques)
|
||||
read -r u4 u6 < <(unique_ips_today)
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
[[ "$ANONIP" == "1" ]] && echo "IP anonymization: enabled (v4 /24, v6 /64)"
|
||||
echo "Unique IPv4 IPs: $u4"
|
||||
echo "Unique IPv6 IPs: $u6"
|
||||
;;
|
||||
top-ips)
|
||||
if [[ -z "$FAMILY" ]] ; then echo "Specify family: v4 or v6" >&2; exit 1; fi
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
[[ "$ANONIP" == "1" ]] && echo "IP anonymization: enabled (v4 /24, v6 /64)"
|
||||
echo "Top IPs ($FAMILY), limit $TOP_LIMIT:"
|
||||
top_ips_today "$FAMILY" "$TOP_LIMIT"
|
||||
;;
|
||||
top-fails)
|
||||
echo "Date: $DATEPAT${SINCE:+ from $SINCE}${UNTIL:+ to $UNTIL}"
|
||||
[[ "$IGNORE_BOTS" == "1" ]] && echo "Ignoring bots by UA regex"
|
||||
echo "Top 5 failure reasons (combined v4+v6):"
|
||||
top_fail_reasons_today 5
|
||||
;;
|
||||
all)
|
||||
v4=$(count_all_family v4)
|
||||
v6=$(count_all_family v6)
|
||||
total=$((v4+v6))
|
||||
pv4=$(percent "$v4" "$total"); pv6=$(percent "$v6" "$total")
|
||||
echo "All logs (no date/time filter)"
|
||||
echo "IPv4: $v4 (${pv4}%)"
|
||||
echo "IPv6: $v6 (${pv6}%)"
|
||||
echo "Total requests: $total"
|
||||
;;
|
||||
*)
|
||||
usage; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_once() { _do_command; }
|
||||
|
||||
if [[ -n "$WATCH_INTERVAL" ]]; then
|
||||
while :; do
|
||||
clear || true
|
||||
run_with_spinner "Loading…" run_once
|
||||
sleep "$WATCH_INTERVAL"
|
||||
done
|
||||
else
|
||||
run_with_spinner "Loading…" run_once
|
||||
fi
|
||||
@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
#CHANGE ME
|
||||
compose_file="/path/to/compose/file"
|
||||
|
||||
# Threshold in MB (8 GB = 8192 MB)
|
||||
threshold=8980
|
||||
|
||||
# Get total memory usage in MB
|
||||
used=$(free -m | awk '/^Mem:/ {print $3}')
|
||||
|
||||
if [ "$USED" -ge "$THRESHOLD" ]; then
|
||||
echo "Memory usage is ${used}MB (≥ ${threshold}MB). Restarting Invidious..."
|
||||
docker compose -f "$compose_file" down >/dev/null 2>&1
|
||||
docker compose -f "$compose_file" up -d >/dev/null 2>&1
|
||||
else
|
||||
echo "Memory usage is ${used}MB (< ${threshold}MB). No restart performed."
|
||||
fi
|
||||
@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
path=/your/path/here/
|
||||
opts=("Switch to router VPN" "Switch to Cloudflare VPN" "Exit")
|
||||
PS3="Choose an option:"
|
||||
select o in "${opts[@]}"
|
||||
do
|
||||
case "$REPLY" in
|
||||
1) echo "Switching to router VPN..."
|
||||
rm $path/wg0.conf
|
||||
ln -s $path/wg0.conf.router $path/wg0.conf
|
||||
docker compose -f $path/docker-compose.yml down >/dev/null 2>&1
|
||||
docker compose -f $path/docker-compose.yml up -d >/dev/null 2>&1
|
||||
exit 0
|
||||
;;
|
||||
|
||||
2) echo "Switching to Cloudflare VPN..."
|
||||
rm $path/wg0.conf
|
||||
ln -s $path/wg0.conf.cf $path/wg0.conf
|
||||
docker compose -f $path/docker-compose.yml down >/dev/null 2>&1
|
||||
docker compose -f $path/docker-compose.yml up -d >/dev/null 2>&1
|
||||
exit 0
|
||||
;;
|
||||
3) break;
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=YouTube anti-block
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/home/qt/globe/scripts/inv-restart-docker.sh
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Make yt anti-block Run every Minute
|
||||
|
||||
[Timer]
|
||||
OnUnitActiveSec=1min
|
||||
Unit=yt-block-protect.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
||||
@ -4,17 +4,14 @@
|
||||
"dislikes": "https://returnyoutubedislikeapi.com/votes?videoId=",
|
||||
"invchannel": "https://invid-api.poketube.fun/api/v1",
|
||||
"p_url":"https://p.poketube.fun",
|
||||
"useragent":"PokeTube/2.0.0 (GNU/Linux; Android 14; Trisquel 11; poketube-vidious; like FreeTube)",
|
||||
"media_proxy": "https://image-proxy.poketube.fun",
|
||||
"videourl":"https://eu-proxy.poketube.fun",
|
||||
"email_main_url":"https://email-server.poketube.fun",
|
||||
"mastodon_client_url":"https://social.poketube.fun",
|
||||
"mastodon_client_server_name":"PokeSocial",
|
||||
"mastodon_client_url":"https://fediverse.poketube.fun",
|
||||
"libreoffice_online_url":"https://office.poketube.fun",
|
||||
"cacher_max_age": "86400",
|
||||
"cacher_max_age": "864000",
|
||||
"enablealwayshttps": false,
|
||||
"proxylocation":"USA",
|
||||
"banner":"welcome to poke!",
|
||||
"t_url": "https://t.poketube.fun/",
|
||||
"server_port": "6003"
|
||||
}
|
||||
16
core/InnerTube/CacheItem.cs
Normal file
16
core/InnerTube/CacheItem.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace InnerTube
|
||||
{
|
||||
public class CacheItem<T>
|
||||
{
|
||||
public T Item;
|
||||
public DateTimeOffset ExpireTime;
|
||||
|
||||
public CacheItem(T item, TimeSpan expiresIn)
|
||||
{
|
||||
Item = item;
|
||||
ExpireTime = DateTimeOffset.Now.Add(expiresIn);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
core/InnerTube/Enums.cs
Normal file
12
core/InnerTube/Enums.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace InnerTube
|
||||
{
|
||||
public enum ChannelTabs
|
||||
{
|
||||
Home,
|
||||
Videos,
|
||||
Playlists,
|
||||
Community,
|
||||
Channels,
|
||||
About
|
||||
}
|
||||
}
|
||||
11
core/InnerTube/InnerTube.csproj
Normal file
11
core/InnerTube/InnerTube.csproj
Normal file
@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
380
core/InnerTube/Models/DynamicItem.cs
Normal file
380
core/InnerTube/Models/DynamicItem.cs
Normal file
@ -0,0 +1,380 @@
|
||||
using System;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class DynamicItem
|
||||
{
|
||||
public string Id;
|
||||
public string Title;
|
||||
public Thumbnail[] Thumbnails;
|
||||
|
||||
public virtual XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("DynamicItem");
|
||||
item.SetAttribute("id", Id);
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class VideoItem : DynamicItem
|
||||
{
|
||||
public string UploadedAt;
|
||||
public long Views;
|
||||
public Channel Channel;
|
||||
public string Duration;
|
||||
public string Description;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Video");
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("duration", Duration);
|
||||
item.SetAttribute("views", Views.ToString());
|
||||
item.SetAttribute("uploadedAt", UploadedAt);
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
if (Channel is not null)
|
||||
item.AppendChild(Channel.GetXmlElement(doc));
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Description))
|
||||
{
|
||||
XmlElement description = doc.CreateElement("Description");
|
||||
description.InnerText = Description;
|
||||
item.AppendChild(description);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class PlaylistItem : DynamicItem
|
||||
{
|
||||
public int VideoCount;
|
||||
public string FirstVideoId;
|
||||
public Channel Channel;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Playlist");
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("videoCount", VideoCount.ToString());
|
||||
item.SetAttribute("firstVideoId", FirstVideoId);
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
item.AppendChild(Channel.GetXmlElement(doc));
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class RadioItem : DynamicItem
|
||||
{
|
||||
public string FirstVideoId;
|
||||
public Channel Channel;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Radio");
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("firstVideoId", FirstVideoId);
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
item.AppendChild(Channel.GetXmlElement(doc));
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class ChannelItem : DynamicItem
|
||||
{
|
||||
public string Url;
|
||||
public string Description;
|
||||
public long VideoCount;
|
||||
public string Subscribers;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Channel");
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("videoCount", VideoCount.ToString());
|
||||
item.SetAttribute("subscribers", Subscribers);
|
||||
if (!string.IsNullOrWhiteSpace(Url))
|
||||
item.SetAttribute("customUrl", Url);
|
||||
|
||||
XmlElement title = doc.CreateElement("Name");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
|
||||
XmlElement description = doc.CreateElement("Description");
|
||||
description.InnerText = Description;
|
||||
item.AppendChild(description);
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Avatar");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class ContinuationItem : DynamicItem
|
||||
{
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Continuation");
|
||||
item.SetAttribute("key", Id);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class ShelfItem : DynamicItem
|
||||
{
|
||||
public DynamicItem[] Items;
|
||||
public int CollapsedItemCount;
|
||||
public BadgeItem[] Badges;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Shelf");
|
||||
item.SetAttribute("title", Title);
|
||||
item.SetAttribute("collapsedItemCount", CollapsedItemCount.ToString());
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
if (Badges.Length > 0)
|
||||
{
|
||||
XmlElement badges = doc.CreateElement("Badges");
|
||||
foreach (BadgeItem badge in Badges) badges.AppendChild(badge.GetXmlElement(doc));
|
||||
item.AppendChild(badges);
|
||||
}
|
||||
|
||||
XmlElement items = doc.CreateElement("Items");
|
||||
foreach (DynamicItem dynamicItem in Items) items.AppendChild(dynamicItem.GetXmlElement(doc));
|
||||
item.AppendChild(items);
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class HorizontalCardListItem : DynamicItem
|
||||
{
|
||||
public DynamicItem[] Items;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("CardList");
|
||||
item.SetAttribute("title", Title);
|
||||
|
||||
foreach (DynamicItem dynamicItem in Items) item.AppendChild(dynamicItem.GetXmlElement(doc));
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class CardItem : DynamicItem
|
||||
{
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Card");
|
||||
item.SetAttribute("title", Title);
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class PlaylistVideoItem : DynamicItem
|
||||
{
|
||||
public long Index;
|
||||
public Channel Channel;
|
||||
public string Duration;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Video");
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("index", Index.ToString());
|
||||
item.SetAttribute("duration", Duration);
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
item.AppendChild(Channel.GetXmlElement(doc));
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
public class ItemSectionItem : DynamicItem
|
||||
{
|
||||
public DynamicItem[] Contents;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement section = doc.CreateElement("ItemSection");
|
||||
foreach (DynamicItem item in Contents) section.AppendChild(item.GetXmlElement(doc));
|
||||
return section;
|
||||
}
|
||||
}
|
||||
|
||||
public class MessageItem : DynamicItem
|
||||
{
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement message = doc.CreateElement("Message");
|
||||
message.InnerText = Title;
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
public class ChannelAboutItem : DynamicItem
|
||||
{
|
||||
public string Description;
|
||||
public string Country;
|
||||
public string Joined;
|
||||
public string ViewCount;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement about = doc.CreateElement("About");
|
||||
XmlElement description = doc.CreateElement("Description");
|
||||
description.InnerText = Description;
|
||||
about.AppendChild(description);
|
||||
XmlElement country = doc.CreateElement("Location");
|
||||
country.InnerText = Country;
|
||||
about.AppendChild(country);
|
||||
XmlElement joined = doc.CreateElement("Joined");
|
||||
joined.InnerText = Joined;
|
||||
about.AppendChild(joined);
|
||||
XmlElement viewCount = doc.CreateElement("ViewCount");
|
||||
viewCount.InnerText = ViewCount;
|
||||
about.AppendChild(viewCount);
|
||||
return about;
|
||||
}
|
||||
}
|
||||
|
||||
public class BadgeItem : DynamicItem
|
||||
{
|
||||
public string Style;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement badge = doc.CreateElement("Badge");
|
||||
badge.SetAttribute("style", Style);
|
||||
badge.InnerText = Title;
|
||||
return badge;
|
||||
}
|
||||
}
|
||||
|
||||
public class StationItem : DynamicItem
|
||||
{
|
||||
public int VideoCount;
|
||||
public string FirstVideoId;
|
||||
public string Description;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Station");
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("videoCount", VideoCount.ToString());
|
||||
item.SetAttribute("firstVideoId", FirstVideoId);
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
|
||||
XmlElement description = doc.CreateElement("Description");
|
||||
description.InnerText = Description;
|
||||
item.AppendChild(description);
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
core/InnerTube/Models/RequestContext.cs
Normal file
67
core/InnerTube/Models/RequestContext.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class RequestContext
|
||||
{
|
||||
[JsonProperty("context")] public Context Context;
|
||||
|
||||
public static string BuildRequestContextJson(Dictionary<string, object> additionalFields, string language = "en",
|
||||
string region = "US", string clientName = "WEB", string clientVersion = "2.20220224.07.00")
|
||||
{
|
||||
RequestContext ctx = new()
|
||||
{
|
||||
Context = new Context(
|
||||
new RequestClient(language, region, clientName, clientVersion),
|
||||
new RequestUser(false))
|
||||
};
|
||||
|
||||
string json1 = JsonConvert.SerializeObject(ctx);
|
||||
Dictionary<string, object> json2 = JsonConvert.DeserializeObject<Dictionary<string, object>>(json1);
|
||||
foreach (KeyValuePair<string,object> pair in additionalFields) json2.Add(pair.Key, pair.Value);
|
||||
|
||||
return JsonConvert.SerializeObject(json2);
|
||||
}
|
||||
}
|
||||
|
||||
public class Context
|
||||
{
|
||||
[JsonProperty("client")] public RequestClient RequestClient { get; set; }
|
||||
[JsonProperty("user")] public RequestUser RequestUser { get; set; }
|
||||
|
||||
public Context(RequestClient requestClient, RequestUser requestUser)
|
||||
{
|
||||
RequestClient = requestClient;
|
||||
RequestUser = requestUser;
|
||||
}
|
||||
}
|
||||
|
||||
public class RequestClient
|
||||
{
|
||||
[JsonProperty("hl")] public string Language { get; set; }
|
||||
[JsonProperty("gl")] public string Region { get; set; }
|
||||
[JsonProperty("clientName")] public string ClientName { get; set; }
|
||||
[JsonProperty("clientVersion")] public string ClientVersion { get; set; }
|
||||
[JsonProperty("deviceModel")] public string DeviceModel { get; set; }
|
||||
|
||||
public RequestClient(string language, string region, string clientName, string clientVersion)
|
||||
{
|
||||
Language = language;
|
||||
Region = region;
|
||||
ClientName = clientName;
|
||||
ClientVersion = clientVersion;
|
||||
if (clientName == "IOS") DeviceModel = "iPhone14,3";
|
||||
}
|
||||
}
|
||||
|
||||
public class RequestUser
|
||||
{
|
||||
[JsonProperty("lockedSafetyMode")] public bool LockedSafetyMode { get; set; }
|
||||
|
||||
public RequestUser(bool lockedSafetyMode)
|
||||
{
|
||||
LockedSafetyMode = lockedSafetyMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
core/InnerTube/Models/YoutubeChannel.cs
Normal file
71
core/InnerTube/Models/YoutubeChannel.cs
Normal file
@ -0,0 +1,71 @@
|
||||
using System.Xml;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class YoutubeChannel
|
||||
{
|
||||
public string Id;
|
||||
public string Name;
|
||||
public string Url;
|
||||
public Thumbnail[] Avatars;
|
||||
public Thumbnail[] Banners;
|
||||
public string Description;
|
||||
public DynamicItem[] Videos;
|
||||
public string Subscribers;
|
||||
|
||||
public string GetHtmlDescription()
|
||||
{
|
||||
return Utils.GetHtmlDescription(Description);
|
||||
}
|
||||
|
||||
public XmlDocument GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement channel = doc.CreateElement("Channel");
|
||||
channel.SetAttribute("id", Id);
|
||||
if (Id != Url)
|
||||
channel.SetAttribute("customUrl", Url);
|
||||
|
||||
XmlElement metadata = doc.CreateElement("Metadata");
|
||||
|
||||
XmlElement name = doc.CreateElement("Name");
|
||||
name.InnerText = Name;
|
||||
metadata.AppendChild(name);
|
||||
|
||||
XmlElement avatars = doc.CreateElement("Avatars");
|
||||
foreach (Thumbnail t in Avatars)
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
avatars.AppendChild(thumbnail);
|
||||
}
|
||||
metadata.AppendChild(avatars);
|
||||
|
||||
XmlElement banners = doc.CreateElement("Banners");
|
||||
foreach (Thumbnail t in Banners)
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
banners.AppendChild(thumbnail);
|
||||
}
|
||||
metadata.AppendChild(banners);
|
||||
|
||||
XmlElement subscriberCount = doc.CreateElement("Subscribers");
|
||||
subscriberCount.InnerText = Subscribers;
|
||||
metadata.AppendChild(subscriberCount);
|
||||
|
||||
channel.AppendChild(metadata);
|
||||
|
||||
XmlElement contents = doc.CreateElement("Contents");
|
||||
foreach (DynamicItem item in Videos) contents.AppendChild(item.GetXmlElement(doc));
|
||||
channel.AppendChild(contents);
|
||||
|
||||
doc.AppendChild(channel);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
core/InnerTube/Models/YoutubeLocals.cs
Normal file
40
core/InnerTube/Models/YoutubeLocals.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Xml;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class YoutubeLocals
|
||||
{
|
||||
public Dictionary<string, string> Languages { get; set; }
|
||||
public Dictionary<string, string> Regions { get; set; }
|
||||
|
||||
public XmlDocument GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement locals = doc.CreateElement("Locals");
|
||||
|
||||
XmlElement languages = doc.CreateElement("Languages");
|
||||
foreach (KeyValuePair<string, string> l in Languages)
|
||||
{
|
||||
XmlElement language = doc.CreateElement("Language");
|
||||
language.SetAttribute("hl", l.Key);
|
||||
language.InnerText = l.Value;
|
||||
languages.AppendChild(language);
|
||||
}
|
||||
locals.AppendChild(languages);
|
||||
|
||||
XmlElement regions = doc.CreateElement("Regions");
|
||||
foreach (KeyValuePair<string, string> r in Regions)
|
||||
{
|
||||
XmlElement region = doc.CreateElement("Region");
|
||||
region.SetAttribute("gl", r.Key);
|
||||
region.InnerText = r.Value;
|
||||
regions.AppendChild(region);
|
||||
}
|
||||
locals.AppendChild(regions);
|
||||
|
||||
doc.AppendChild(locals);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
230
core/InnerTube/Models/YoutubePlayer.cs
Normal file
230
core/InnerTube/Models/YoutubePlayer.cs
Normal file
@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Xml;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class YoutubePlayer
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string[] Tags { get; set; }
|
||||
public Channel Channel { get; set; }
|
||||
public long? Duration { get; set; }
|
||||
public bool IsLive { get; set; }
|
||||
public Chapter[] Chapters { get; set; }
|
||||
public Thumbnail[] Thumbnails { get; set; }
|
||||
public Format[] Formats { get; set; }
|
||||
public Format[] AdaptiveFormats { get; set; }
|
||||
public string HlsManifestUrl { get; set; }
|
||||
public Subtitle[] Subtitles { get; set; }
|
||||
public string[] Storyboards { get; set; }
|
||||
public string ExpiresInSeconds { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
public string GetHtmlDescription()
|
||||
{
|
||||
return Utils.GetHtmlDescription(Description);
|
||||
}
|
||||
|
||||
public XmlDocument GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
XmlElement error = doc.CreateElement("Error");
|
||||
error.InnerText = ErrorMessage;
|
||||
doc.AppendChild(error);
|
||||
}
|
||||
else
|
||||
{
|
||||
XmlElement player = doc.CreateElement("Player");
|
||||
player.SetAttribute("id", Id);
|
||||
player.SetAttribute("duration", Duration.ToString());
|
||||
player.SetAttribute("isLive", IsLive.ToString());
|
||||
player.SetAttribute("expiresInSeconds", ExpiresInSeconds);
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
player.AppendChild(title);
|
||||
|
||||
XmlElement description = doc.CreateElement("Description");
|
||||
description.InnerText = Description;
|
||||
player.AppendChild(description);
|
||||
|
||||
XmlElement tags = doc.CreateElement("Tags");
|
||||
foreach (string tag in Tags ?? Array.Empty<string>())
|
||||
{
|
||||
XmlElement tagElement = doc.CreateElement("Tag");
|
||||
tagElement.InnerText = tag;
|
||||
tags.AppendChild(tagElement);
|
||||
}
|
||||
player.AppendChild(tags);
|
||||
|
||||
player.AppendChild(Channel.GetXmlElement(doc));
|
||||
|
||||
XmlElement thumbnails = doc.CreateElement("Thumbnails");
|
||||
foreach (Thumbnail t in Thumbnails)
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
thumbnails.AppendChild(thumbnail);
|
||||
}
|
||||
player.AppendChild(thumbnails);
|
||||
|
||||
XmlElement formats = doc.CreateElement("Formats");
|
||||
foreach (Format f in Formats ?? Array.Empty<Format>()) formats.AppendChild(f.GetXmlElement(doc));
|
||||
player.AppendChild(formats);
|
||||
|
||||
XmlElement adaptiveFormats = doc.CreateElement("AdaptiveFormats");
|
||||
foreach (Format f in AdaptiveFormats ?? Array.Empty<Format>()) adaptiveFormats.AppendChild(f.GetXmlElement(doc));
|
||||
player.AppendChild(adaptiveFormats);
|
||||
|
||||
XmlElement storyboards = doc.CreateElement("Storyboards");
|
||||
foreach (string s in Storyboards)
|
||||
{
|
||||
XmlElement storyboard = doc.CreateElement("Storyboard");
|
||||
storyboard.InnerText = s;
|
||||
storyboards.AppendChild(storyboard);
|
||||
}
|
||||
player.AppendChild(storyboards);
|
||||
|
||||
XmlElement subtitles = doc.CreateElement("Subtitles");
|
||||
foreach (Subtitle s in Subtitles ?? Array.Empty<Subtitle>()) subtitles.AppendChild(s.GetXmlElement(doc));
|
||||
player.AppendChild(subtitles);
|
||||
|
||||
doc.AppendChild(player);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
public class Chapter
|
||||
{
|
||||
[JsonProperty("title")] public string Title { get; set; }
|
||||
[JsonProperty("start_time")] public long StartTime { get; set; }
|
||||
[JsonProperty("end_time")] public long EndTime { get; set; }
|
||||
}
|
||||
|
||||
public class Format
|
||||
{
|
||||
[JsonProperty("format")] public string FormatName { get; set; }
|
||||
[JsonProperty("format_id")] public string FormatId { get; set; }
|
||||
[JsonProperty("format_note")] public string FormatNote { get; set; }
|
||||
[JsonProperty("filesize")] public long? Filesize { get; set; }
|
||||
[JsonProperty("quality")] public long Quality { get; set; }
|
||||
[JsonProperty("bitrate")] public double Bitrate { get; set; }
|
||||
[JsonProperty("audio_codec")] public string AudioCodec { get; set; }
|
||||
[JsonProperty("video_codec")] public string VideoCodec { get; set; }
|
||||
[JsonProperty("audio_sample_rate")] public long? AudioSampleRate { get; set; }
|
||||
[JsonProperty("resolution")] public string Resolution { get; set; }
|
||||
[JsonProperty("url")] public string Url { get; set; }
|
||||
[JsonProperty("init_range")] public Range InitRange { get; set; }
|
||||
[JsonProperty("index_range")] public Range IndexRange { get; set; }
|
||||
|
||||
public XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement format = doc.CreateElement("Format");
|
||||
|
||||
format.SetAttribute("id", FormatId);
|
||||
format.SetAttribute("label", FormatName);
|
||||
format.SetAttribute("filesize", Filesize.ToString());
|
||||
format.SetAttribute("quality", Bitrate.ToString());
|
||||
format.SetAttribute("audioCodec", AudioCodec);
|
||||
format.SetAttribute("videoCodec", VideoCodec);
|
||||
if (AudioSampleRate != null)
|
||||
format.SetAttribute("audioSampleRate", AudioSampleRate.ToString());
|
||||
else
|
||||
format.SetAttribute("resolution", Resolution);
|
||||
|
||||
XmlElement url = doc.CreateElement("URL");
|
||||
url.InnerText = Url;
|
||||
format.AppendChild(url);
|
||||
|
||||
if (InitRange != null && IndexRange != null)
|
||||
{
|
||||
XmlElement initRange = doc.CreateElement("InitRange");
|
||||
initRange.SetAttribute("start", InitRange.Start);
|
||||
initRange.SetAttribute("end", InitRange.End);
|
||||
format.AppendChild(initRange);
|
||||
|
||||
XmlElement indexRange = doc.CreateElement("IndexRange");
|
||||
indexRange.SetAttribute("start", IndexRange.Start);
|
||||
indexRange.SetAttribute("end", IndexRange.End);
|
||||
format.AppendChild(indexRange);
|
||||
}
|
||||
|
||||
return format;
|
||||
}
|
||||
}
|
||||
|
||||
public class Range
|
||||
{
|
||||
[JsonProperty("start")] public string Start { get; set; }
|
||||
[JsonProperty("end")] public string End { get; set; }
|
||||
|
||||
public Range(string start, string end)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
}
|
||||
}
|
||||
|
||||
public class Channel
|
||||
{
|
||||
[JsonProperty("name")] public string Name { get; set; }
|
||||
[JsonProperty("id")] public string Id { get; set; }
|
||||
[JsonProperty("subscriberCount")] public string SubscriberCount { get; set; }
|
||||
[JsonProperty("avatars")] public Thumbnail[] Avatars { get; set; }
|
||||
|
||||
public XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement channel = doc.CreateElement("Channel");
|
||||
channel.SetAttribute("id", Id);
|
||||
if (!string.IsNullOrWhiteSpace(SubscriberCount))
|
||||
channel.SetAttribute("subscriberCount", SubscriberCount);
|
||||
|
||||
XmlElement name = doc.CreateElement("Name");
|
||||
name.InnerText = Name;
|
||||
channel.AppendChild(name);
|
||||
|
||||
foreach (Thumbnail avatarThumb in Avatars ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement avatar = doc.CreateElement("Avatar");
|
||||
avatar.SetAttribute("width", avatarThumb.Width.ToString());
|
||||
avatar.SetAttribute("height", avatarThumb.Height.ToString());
|
||||
avatar.InnerText = avatarThumb.Url;
|
||||
channel.AppendChild(avatar);
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
|
||||
public class Subtitle
|
||||
{
|
||||
[JsonProperty("ext")] public string Ext { get; set; }
|
||||
[JsonProperty("name")] public string Language { get; set; }
|
||||
[JsonProperty("url")] public string Url { get; set; }
|
||||
|
||||
public XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement subtitle = doc.CreateElement("Subtitle");
|
||||
subtitle.SetAttribute("ext", Ext);
|
||||
subtitle.SetAttribute("language", Language);
|
||||
subtitle.InnerText = Url;
|
||||
return subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
public class Thumbnail
|
||||
{
|
||||
[JsonProperty("height")] public long Height { get; set; }
|
||||
[JsonProperty("url")] public string Url { get; set; }
|
||||
[JsonProperty("width")] public long Width { get; set; }
|
||||
}
|
||||
}
|
||||
68
core/InnerTube/Models/YoutubePlaylist.cs
Normal file
68
core/InnerTube/Models/YoutubePlaylist.cs
Normal file
@ -0,0 +1,68 @@
|
||||
using System.Xml;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class YoutubePlaylist
|
||||
{
|
||||
public string Id;
|
||||
public string Title;
|
||||
public string Description;
|
||||
public string VideoCount;
|
||||
public string ViewCount;
|
||||
public string LastUpdated;
|
||||
public Thumbnail[] Thumbnail;
|
||||
public Channel Channel;
|
||||
public DynamicItem[] Videos;
|
||||
public string ContinuationKey;
|
||||
|
||||
public string GetHtmlDescription() => Utils.GetHtmlDescription(Description);
|
||||
|
||||
public XmlDocument GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement playlist = doc.CreateElement("Playlist");
|
||||
playlist.SetAttribute("id", Id);
|
||||
playlist.SetAttribute("continuation", ContinuationKey);
|
||||
|
||||
XmlElement metadata = doc.CreateElement("Metadata");
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
metadata.AppendChild(title);
|
||||
|
||||
metadata.AppendChild(Channel.GetXmlElement(doc));
|
||||
|
||||
XmlElement thumbnails = doc.CreateElement("Thumbnails");
|
||||
foreach (Thumbnail t in Thumbnail)
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
thumbnails.AppendChild(thumbnail);
|
||||
}
|
||||
metadata.AppendChild(thumbnails);
|
||||
|
||||
XmlElement videoCount = doc.CreateElement("VideoCount");
|
||||
XmlElement viewCount = doc.CreateElement("ViewCount");
|
||||
XmlElement lastUpdated = doc.CreateElement("LastUpdated");
|
||||
|
||||
videoCount.InnerText = VideoCount;
|
||||
viewCount.InnerText = ViewCount;
|
||||
lastUpdated.InnerText = LastUpdated;
|
||||
|
||||
metadata.AppendChild(videoCount);
|
||||
metadata.AppendChild(viewCount);
|
||||
metadata.AppendChild(lastUpdated);
|
||||
|
||||
playlist.AppendChild(metadata);
|
||||
|
||||
XmlElement results = doc.CreateElement("Videos");
|
||||
foreach (DynamicItem result in Videos) results.AppendChild(result.GetXmlElement(doc));
|
||||
playlist.AppendChild(results);
|
||||
|
||||
doc.AppendChild(playlist);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
core/InnerTube/Models/YoutubeSearchResults.cs
Normal file
39
core/InnerTube/Models/YoutubeSearchResults.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using System.Xml;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class YoutubeSearchResults
|
||||
{
|
||||
public string[] Refinements;
|
||||
public long EstimatedResults;
|
||||
public DynamicItem[] Results;
|
||||
public string ContinuationKey;
|
||||
|
||||
public XmlDocument GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement search = doc.CreateElement("Search");
|
||||
search.SetAttribute("estimatedResults", EstimatedResults.ToString());
|
||||
search.SetAttribute("continuation", ContinuationKey);
|
||||
|
||||
if (Refinements.Length > 0)
|
||||
{
|
||||
XmlElement refinements = doc.CreateElement("Refinements");
|
||||
foreach (string refinementText in Refinements)
|
||||
{
|
||||
XmlElement refinement = doc.CreateElement("Refinement");
|
||||
refinement.InnerText = refinementText;
|
||||
refinements.AppendChild(refinement);
|
||||
}
|
||||
search.AppendChild(refinements);
|
||||
}
|
||||
|
||||
XmlElement results = doc.CreateElement("Results");
|
||||
foreach (DynamicItem result in Results) results.AppendChild(result.GetXmlElement(doc));
|
||||
search.AppendChild(results);
|
||||
|
||||
doc.AppendChild(search);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
core/InnerTube/Models/YoutubeStoryboardSpec.cs
Normal file
38
core/InnerTube/Models/YoutubeStoryboardSpec.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class YoutubeStoryboardSpec
|
||||
{
|
||||
public Dictionary<string, string> Urls = new();
|
||||
public YoutubeStoryboardSpec(string specStr, long duration)
|
||||
{
|
||||
if (specStr is null) return;
|
||||
List<string> spec = new(specStr.Split("|"));
|
||||
string baseUrl = spec[0];
|
||||
spec.RemoveAt(0);
|
||||
spec.Reverse();
|
||||
int L = spec.Count - 1;
|
||||
for (int i = 0; i < spec.Count; i++)
|
||||
{
|
||||
string[] args = spec[i].Split("#");
|
||||
int width = int.Parse(args[0]);
|
||||
int height = int.Parse(args[1]);
|
||||
int frameCount = int.Parse(args[2]);
|
||||
int cols = int.Parse(args[3]);
|
||||
int rows = int.Parse(args[4]);
|
||||
string N = args[6];
|
||||
string sigh = args[7];
|
||||
string url = baseUrl
|
||||
.Replace("$L", (spec.Count - 1 - i).ToString())
|
||||
.Replace("$N", N) + "&sigh=" + sigh;
|
||||
float fragmentCount = frameCount / (cols * rows);
|
||||
float fragmentDuration = duration / fragmentCount;
|
||||
|
||||
for (int j = 0; j < Math.Ceiling(fragmentCount); j++)
|
||||
Urls.TryAdd($"L{spec.Count - 1 - i}", url.Replace("$M", j.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
core/InnerTube/Models/YoutubeTrends.cs
Normal file
70
core/InnerTube/Models/YoutubeTrends.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Xml;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class YoutubeTrends
|
||||
{
|
||||
public TrendCategory[] Categories;
|
||||
public DynamicItem[] Videos;
|
||||
|
||||
public XmlDocument GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement explore = doc.CreateElement("Explore");
|
||||
|
||||
XmlElement categories = doc.CreateElement("Categories");
|
||||
foreach (TrendCategory category in Categories ?? Array.Empty<TrendCategory>()) categories.AppendChild(category.GetXmlElement(doc));
|
||||
explore.AppendChild(categories);
|
||||
|
||||
XmlElement contents = doc.CreateElement("Videos");
|
||||
foreach (DynamicItem item in Videos ?? Array.Empty<DynamicItem>()) contents.AppendChild(item.GetXmlElement(doc));
|
||||
explore.AppendChild(contents);
|
||||
|
||||
doc.AppendChild(explore);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
public class TrendCategory
|
||||
{
|
||||
public string Label;
|
||||
public Thumbnail[] BackgroundImage;
|
||||
public Thumbnail[] Icon;
|
||||
public string Id;
|
||||
|
||||
public XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement category = doc.CreateElement("Category");
|
||||
category.SetAttribute("id", Id);
|
||||
|
||||
XmlElement title = doc.CreateElement("Name");
|
||||
title.InnerText = Label;
|
||||
category.AppendChild(title);
|
||||
|
||||
XmlElement backgroundImages = doc.CreateElement("BackgroundImage");
|
||||
foreach (Thumbnail t in BackgroundImage ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
backgroundImages.AppendChild(thumbnail);
|
||||
}
|
||||
category.AppendChild(backgroundImages);
|
||||
|
||||
XmlElement icons = doc.CreateElement("Icon");
|
||||
foreach (Thumbnail t in Icon ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
icons.AppendChild(thumbnail);
|
||||
}
|
||||
category.AppendChild(icons);
|
||||
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
core/InnerTube/Models/YoutubeVideo.cs
Normal file
45
core/InnerTube/Models/YoutubeVideo.cs
Normal file
@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Xml;
|
||||
|
||||
namespace InnerTube.Models
|
||||
{
|
||||
public class YoutubeVideo
|
||||
{
|
||||
public string Id;
|
||||
public string Title;
|
||||
public string Description;
|
||||
public Channel Channel;
|
||||
public string UploadDate;
|
||||
public DynamicItem[] Recommended;
|
||||
public string Views;
|
||||
|
||||
public string GetHtmlDescription() => InnerTube.Utils.GetHtmlDescription(Description);
|
||||
|
||||
public XmlDocument GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement item = doc.CreateElement("Video");
|
||||
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("views", Views);
|
||||
item.SetAttribute("uploadDate", UploadDate);
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
|
||||
XmlElement description = doc.CreateElement("Description");
|
||||
description.InnerText = Description;
|
||||
item.AppendChild(description);
|
||||
|
||||
item.AppendChild(Channel.GetXmlElement(doc));
|
||||
|
||||
XmlElement recommendations = doc.CreateElement("Recommendations");
|
||||
foreach (DynamicItem f in Recommended ?? Array.Empty<DynamicItem>()) recommendations.AppendChild(f.GetXmlElement(doc));
|
||||
item.AppendChild(recommendations);
|
||||
|
||||
doc.AppendChild(item);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
core/InnerTube/ReturnYouTubeDislike.cs
Normal file
44
core/InnerTube/ReturnYouTubeDislike.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace InnerTube
|
||||
{
|
||||
public static class ReturnYouTubeDislike
|
||||
{
|
||||
private static HttpClient _client = new();
|
||||
private static Dictionary<string, YoutubeDislikes> DislikesCache = new();
|
||||
|
||||
// TODO: better cache
|
||||
public static async Task<YoutubeDislikes> GetDislikes(string videoId)
|
||||
{
|
||||
if (DislikesCache.ContainsKey(videoId))
|
||||
return DislikesCache[videoId];
|
||||
|
||||
HttpResponseMessage response = await _client.GetAsync("https://returnyoutubedislikeapi.com/votes?videoId=" + videoId);
|
||||
string json = await response.Content.ReadAsStringAsync();
|
||||
YoutubeDislikes dislikes = JsonConvert.DeserializeObject<YoutubeDislikes>(json);
|
||||
if (dislikes is not null)
|
||||
DislikesCache.Add(videoId, dislikes);
|
||||
return dislikes ?? new YoutubeDislikes();
|
||||
}
|
||||
}
|
||||
|
||||
public class YoutubeDislikes
|
||||
{
|
||||
[JsonProperty("id")] public string Id { get; set; }
|
||||
[JsonProperty("dateCreated")] public string DateCreated { get; set; }
|
||||
[JsonProperty("likes")] public long Likes { get; set; }
|
||||
[JsonProperty("dislikes")] public long Dislikes { get; set; }
|
||||
[JsonProperty("rating")] public double Rating { get; set; }
|
||||
[JsonProperty("viewCount")] public long Views { get; set; }
|
||||
[JsonProperty("deleted")] public bool Deleted { get; set; }
|
||||
|
||||
public float GetLikePercentage()
|
||||
{
|
||||
return Likes / (float)(Likes + Dislikes) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
457
core/InnerTube/Utils.cs
Normal file
457
core/InnerTube/Utils.cs
Normal file
@ -0,0 +1,457 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using System.Xml;
|
||||
using InnerTube.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace InnerTube
|
||||
{
|
||||
public static class Utils
|
||||
{
|
||||
private static string Sapisid;
|
||||
private static string Psid;
|
||||
private static bool UseAuthorization;
|
||||
|
||||
public static string GetHtmlDescription(string description) => description?.Replace("\n", "<br>") ?? "";
|
||||
|
||||
public static string GetMpdManifest(this YoutubePlayer player, string proxyUrl, string videoCodec = null, string audioCodec = null)
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
|
||||
XmlDeclaration xmlDeclaration = doc.CreateXmlDeclaration("1.0", "UTF-8", null);
|
||||
XmlElement root = doc.DocumentElement;
|
||||
doc.InsertBefore(xmlDeclaration, root);
|
||||
|
||||
XmlElement mpdRoot = doc.CreateElement(string.Empty, "MPD", string.Empty);
|
||||
mpdRoot.SetAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
|
||||
mpdRoot.SetAttribute("xmlns", "urn:mpeg:dash:schema:mpd:2011");
|
||||
mpdRoot.SetAttribute("xsi:schemaLocation", "urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd");
|
||||
//mpdRoot.SetAttribute("profiles", "urn:mpeg:dash:profile:isoff-on-demand:2011");
|
||||
mpdRoot.SetAttribute("profiles", "urn:mpeg:dash:profile:isoff-main:2011");
|
||||
mpdRoot.SetAttribute("type", "static");
|
||||
mpdRoot.SetAttribute("minBufferTime", "PT1.500S");
|
||||
TimeSpan durationTs = TimeSpan.FromMilliseconds(double.Parse(HttpUtility
|
||||
.ParseQueryString(player.Formats.First().Url.Split("?")[1])
|
||||
.Get("dur")?.Replace(".", "") ?? "0"));
|
||||
StringBuilder duration = new("PT");
|
||||
if (durationTs.TotalHours > 0)
|
||||
duration.Append($"{durationTs.Hours}H");
|
||||
if (durationTs.Minutes > 0)
|
||||
duration.Append($"{durationTs.Minutes}M");
|
||||
if (durationTs.Seconds > 0)
|
||||
duration.Append(durationTs.Seconds);
|
||||
mpdRoot.SetAttribute("mediaPresentationDuration", $"{duration}.{durationTs.Milliseconds}S");
|
||||
doc.AppendChild(mpdRoot);
|
||||
|
||||
XmlElement period = doc.CreateElement("Period");
|
||||
|
||||
period.AppendChild(doc.CreateComment("Audio Adaptation Set"));
|
||||
XmlElement audioAdaptationSet = doc.CreateElement("AdaptationSet");
|
||||
List<Format> audios;
|
||||
if (audioCodec != "all")
|
||||
audios = player.AdaptiveFormats
|
||||
.Where(x => x.AudioSampleRate.HasValue && x.FormatId != "17" &&
|
||||
(audioCodec == null || x.AudioCodec.ToLower().Contains(audioCodec.ToLower())))
|
||||
.GroupBy(x => x.FormatNote)
|
||||
.Select(x => x.Last())
|
||||
.ToList();
|
||||
else
|
||||
audios = player.AdaptiveFormats
|
||||
.Where(x => x.AudioSampleRate.HasValue && x.FormatId != "17")
|
||||
.ToList();
|
||||
|
||||
audioAdaptationSet.SetAttribute("mimeType",
|
||||
HttpUtility.ParseQueryString(audios.First().Url.Split("?")[1]).Get("mime"));
|
||||
audioAdaptationSet.SetAttribute("subsegmentAlignment", "true");
|
||||
audioAdaptationSet.SetAttribute("contentType", "audio");
|
||||
foreach (Format format in audios)
|
||||
{
|
||||
XmlElement representation = doc.CreateElement("Representation");
|
||||
representation.SetAttribute("id", format.FormatId);
|
||||
representation.SetAttribute("codecs", format.AudioCodec);
|
||||
representation.SetAttribute("startWithSAP", "1");
|
||||
representation.SetAttribute("bandwidth",
|
||||
Math.Floor((format.Filesize ?? 1) / (double)player.Duration).ToString());
|
||||
|
||||
XmlElement audioChannelConfiguration = doc.CreateElement("AudioChannelConfiguration");
|
||||
audioChannelConfiguration.SetAttribute("schemeIdUri",
|
||||
"urn:mpeg:dash:23003:3:audio_channel_configuration:2011");
|
||||
audioChannelConfiguration.SetAttribute("value", "2");
|
||||
representation.AppendChild(audioChannelConfiguration);
|
||||
|
||||
XmlElement baseUrl = doc.CreateElement("BaseURL");
|
||||
baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) ? format.Url : $"{proxyUrl}media/{player.Id}/{format.FormatId}";
|
||||
representation.AppendChild(baseUrl);
|
||||
|
||||
if (format.IndexRange != null && format.InitRange != null)
|
||||
{
|
||||
XmlElement segmentBase = doc.CreateElement("SegmentBase");
|
||||
segmentBase.SetAttribute("indexRange", $"{format.IndexRange.Start}-{format.IndexRange.End}");
|
||||
segmentBase.SetAttribute("indexRangeExact", "true");
|
||||
|
||||
XmlElement initialization = doc.CreateElement("Initialization");
|
||||
initialization.SetAttribute("range", $"{format.InitRange.Start}-{format.InitRange.End}");
|
||||
|
||||
segmentBase.AppendChild(initialization);
|
||||
representation.AppendChild(segmentBase);
|
||||
}
|
||||
|
||||
audioAdaptationSet.AppendChild(representation);
|
||||
}
|
||||
|
||||
period.AppendChild(audioAdaptationSet);
|
||||
|
||||
period.AppendChild(doc.CreateComment("Video Adaptation Set"));
|
||||
|
||||
List<Format> videos;
|
||||
if (videoCodec != "all")
|
||||
videos = player.AdaptiveFormats.Where(x => !x.AudioSampleRate.HasValue && x.FormatId != "17" &&
|
||||
(videoCodec == null || x.VideoCodec.ToLower()
|
||||
.Contains(videoCodec.ToLower())))
|
||||
.GroupBy(x => x.FormatNote)
|
||||
.Select(x => x.Last())
|
||||
.ToList();
|
||||
else
|
||||
videos = player.AdaptiveFormats.Where(x => x.Resolution != "audio only" && x.FormatId != "17").ToList();
|
||||
|
||||
|
||||
XmlElement videoAdaptationSet = doc.CreateElement("AdaptationSet");
|
||||
videoAdaptationSet.SetAttribute("mimeType",
|
||||
HttpUtility.ParseQueryString(videos.FirstOrDefault()?.Url?.Split("?")[1] ?? "mime=video/mp4")
|
||||
.Get("mime"));
|
||||
videoAdaptationSet.SetAttribute("subsegmentAlignment", "true");
|
||||
videoAdaptationSet.SetAttribute("contentType", "video");
|
||||
|
||||
foreach (Format format in videos)
|
||||
{
|
||||
XmlElement representation = doc.CreateElement("Representation");
|
||||
representation.SetAttribute("id", format.FormatId);
|
||||
representation.SetAttribute("codecs", format.VideoCodec);
|
||||
representation.SetAttribute("startWithSAP", "1");
|
||||
string[] widthAndHeight = format.Resolution.Split("x");
|
||||
representation.SetAttribute("width", widthAndHeight[0]);
|
||||
representation.SetAttribute("height", widthAndHeight[1]);
|
||||
representation.SetAttribute("bandwidth",
|
||||
Math.Floor((format.Filesize ?? 1) / (double)player.Duration).ToString());
|
||||
|
||||
XmlElement baseUrl = doc.CreateElement("BaseURL");
|
||||
baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) ? format.Url : $"{proxyUrl}media/{player.Id}/{format.FormatId}";
|
||||
representation.AppendChild(baseUrl);
|
||||
|
||||
if (format.IndexRange != null && format.InitRange != null)
|
||||
{
|
||||
XmlElement segmentBase = doc.CreateElement("SegmentBase");
|
||||
segmentBase.SetAttribute("indexRange", $"{format.IndexRange.Start}-{format.IndexRange.End}");
|
||||
segmentBase.SetAttribute("indexRangeExact", "true");
|
||||
|
||||
XmlElement initialization = doc.CreateElement("Initialization");
|
||||
initialization.SetAttribute("range", $"{format.InitRange.Start}-{format.InitRange.End}");
|
||||
|
||||
segmentBase.AppendChild(initialization);
|
||||
representation.AppendChild(segmentBase);
|
||||
}
|
||||
|
||||
videoAdaptationSet.AppendChild(representation);
|
||||
}
|
||||
|
||||
period.AppendChild(videoAdaptationSet);
|
||||
|
||||
period.AppendChild(doc.CreateComment("Subtitle Adaptation Sets"));
|
||||
foreach (Subtitle subtitle in player.Subtitles ?? Array.Empty<Subtitle>())
|
||||
{
|
||||
period.AppendChild(doc.CreateComment(subtitle.Language));
|
||||
XmlElement adaptationSet = doc.CreateElement("AdaptationSet");
|
||||
adaptationSet.SetAttribute("mimeType", "text/vtt");
|
||||
adaptationSet.SetAttribute("lang", subtitle.Language);
|
||||
|
||||
XmlElement representation = doc.CreateElement("Representation");
|
||||
representation.SetAttribute("id", $"caption_{subtitle.Language.ToLower()}");
|
||||
representation.SetAttribute("bandwidth", "256"); // ...why do we need this for a plaintext file
|
||||
|
||||
XmlElement baseUrl = doc.CreateElement("BaseURL");
|
||||
string url = subtitle.Url;
|
||||
url = url.Replace("fmt=srv3", "fmt=vtt");
|
||||
baseUrl.InnerText = string.IsNullOrWhiteSpace(proxyUrl) ? url : $"{proxyUrl}caption/{player.Id}/{subtitle.Language}";
|
||||
|
||||
representation.AppendChild(baseUrl);
|
||||
adaptationSet.AppendChild(representation);
|
||||
period.AppendChild(adaptationSet);
|
||||
}
|
||||
|
||||
mpdRoot.AppendChild(period);
|
||||
return doc.OuterXml.Replace(" schemaLocation=\"", " xsi:schemaLocation=\"");
|
||||
}
|
||||
|
||||
public static async Task<string> GetHlsManifest(this YoutubePlayer player, string proxyUrl)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.AppendLine("#EXTM3U");
|
||||
sb.AppendLine("##Generated by LightTube");
|
||||
sb.AppendLine("##Video ID: " + player.Id);
|
||||
|
||||
sb.AppendLine("#EXT-X-VERSION:7");
|
||||
sb.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
|
||||
|
||||
string hls = await new HttpClient().GetStringAsync(player.HlsManifestUrl);
|
||||
string[] hlsLines = hls.Split("\n");
|
||||
foreach (string line in hlsLines)
|
||||
{
|
||||
if (line.StartsWith("#EXT-X-STREAM-INF:"))
|
||||
sb.AppendLine(line);
|
||||
if (line.StartsWith("http"))
|
||||
{
|
||||
Uri u = new(line);
|
||||
sb.AppendLine($"{proxyUrl}/ytmanifest?path={HttpUtility.UrlEncode(u.PathAndQuery)}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static string ReadRuns(JArray runs)
|
||||
{
|
||||
string str = "";
|
||||
foreach (JToken runToken in runs ?? new JArray())
|
||||
{
|
||||
JObject run = runToken as JObject;
|
||||
if (run is null) continue;
|
||||
|
||||
if (run.ContainsKey("bold"))
|
||||
{
|
||||
str += "<b>" + run["text"] + "</b>";
|
||||
}
|
||||
else if (run.ContainsKey("navigationEndpoint"))
|
||||
{
|
||||
if (run?["navigationEndpoint"]?["urlEndpoint"] is not null)
|
||||
{
|
||||
string url = run["navigationEndpoint"]?["urlEndpoint"]?["url"]?.ToString() ?? "";
|
||||
if (url.StartsWith("https://www.youtube.com/redirect"))
|
||||
{
|
||||
NameValueCollection qsl = HttpUtility.ParseQueryString(url.Split("?")[1]);
|
||||
url = qsl["url"] ?? qsl["q"];
|
||||
}
|
||||
|
||||
str += $"<a href=\"{url}\">{run["text"]}</a>";
|
||||
}
|
||||
else if (run?["navigationEndpoint"]?["commandMetadata"] is not null)
|
||||
{
|
||||
string url = run["navigationEndpoint"]?["commandMetadata"]?["webCommandMetadata"]?["url"]
|
||||
?.ToString() ?? "";
|
||||
if (url.StartsWith("/"))
|
||||
url = "https://youtube.com" + url;
|
||||
str += $"<a href=\"{url}\">{run["text"]}</a>";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
str += run["text"];
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
public static Thumbnail ParseThumbnails(JToken arg) => new()
|
||||
{
|
||||
Height = arg["height"]?.ToObject<long>() ?? -1,
|
||||
Url = arg["url"]?.ToString() ?? string.Empty,
|
||||
Width = arg["width"]?.ToObject<long>() ?? -1
|
||||
};
|
||||
|
||||
public static async Task<JObject> GetAuthorizedPlayer(string id, HttpClient client)
|
||||
{
|
||||
HttpRequestMessage hrm = new(HttpMethod.Post,
|
||||
"https://www.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8");
|
||||
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(
|
||||
RequestContext.BuildRequestContextJson(new Dictionary<string, object>
|
||||
{
|
||||
["videoId"] = id
|
||||
}));
|
||||
ByteArrayContent byteContent = new(buffer);
|
||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
hrm.Content = byteContent;
|
||||
|
||||
if (UseAuthorization)
|
||||
{
|
||||
hrm.Headers.Add("Cookie", GenerateAuthCookie());
|
||||
hrm.Headers.Add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:96.0) Gecko/20100101 Firefox/96.0");
|
||||
hrm.Headers.Add("Authorization", GenerateAuthHeader());
|
||||
hrm.Headers.Add("X-Origin", "https://www.youtube.com");
|
||||
hrm.Headers.Add("X-Youtube-Client-Name", "1");
|
||||
hrm.Headers.Add("X-Youtube-Client-Version", "2.20210721.00.00");
|
||||
hrm.Headers.Add("Accept-Language", "en-US;q=0.8,en;q=0.7");
|
||||
hrm.Headers.Add("Origin", "https://www.youtube.com");
|
||||
hrm.Headers.Add("Referer", "https://www.youtube.com/watch?v=" + id);
|
||||
}
|
||||
|
||||
HttpResponseMessage ytPlayerRequest = await client.SendAsync(hrm);
|
||||
return JObject.Parse(await ytPlayerRequest.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
internal static string GenerateAuthHeader()
|
||||
{
|
||||
if (!UseAuthorization) return "None none";
|
||||
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
string hashInput = timestamp + " " + Sapisid + " https://www.youtube.com";
|
||||
string hashDigest = GenerateSha1Hash(hashInput);
|
||||
return $"SAPISIDHASH {timestamp}_{hashDigest}";
|
||||
}
|
||||
|
||||
internal static string GenerateAuthCookie() => UseAuthorization ? $"SAPISID={Sapisid}; __Secure-3PAPISID={Sapisid}; __Secure-3PSID={Psid};" : ";";
|
||||
|
||||
private static string GenerateSha1Hash(string input)
|
||||
{
|
||||
using SHA1Managed sha1 = new();
|
||||
byte[] hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(input));
|
||||
StringBuilder sb = new(hash.Length * 2);
|
||||
foreach (byte b in hash) sb.Append(b.ToString("X2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static string GetExtension(this Format format)
|
||||
{
|
||||
if (format.VideoCodec != "none") return "mp4";
|
||||
else
|
||||
switch (format.FormatId)
|
||||
{
|
||||
case "139":
|
||||
case "140":
|
||||
case "141":
|
||||
case "256":
|
||||
case "258":
|
||||
case "327":
|
||||
return "mp3";
|
||||
case "249":
|
||||
case "250":
|
||||
case "251":
|
||||
case "338":
|
||||
return "opus";
|
||||
}
|
||||
|
||||
return "mp4";
|
||||
}
|
||||
|
||||
public static void SetAuthorization(bool canUseAuthorizedEndpoints, string sapisid, string psid)
|
||||
{
|
||||
UseAuthorization = canUseAuthorizedEndpoints;
|
||||
Sapisid = sapisid;
|
||||
Psid = psid;
|
||||
}
|
||||
|
||||
internal static string GetCodec(string mimetypeString, bool audioCodec)
|
||||
{
|
||||
string acodec = "";
|
||||
string vcodec = "";
|
||||
|
||||
Match match = Regex.Match(mimetypeString, "codecs=\"([\\s\\S]+?)\"");
|
||||
|
||||
string[] g = match.Groups[1].ToString().Split(",");
|
||||
foreach (string codec in g)
|
||||
{
|
||||
switch (codec.Split(".")[0].Trim())
|
||||
{
|
||||
case "avc1":
|
||||
case "av01":
|
||||
case "vp9":
|
||||
case "mp4v":
|
||||
vcodec = codec;
|
||||
break;
|
||||
case "mp4a":
|
||||
case "opus":
|
||||
acodec = codec;
|
||||
break;
|
||||
default:
|
||||
Console.WriteLine("Unknown codec type: " + codec.Split(".")[0].Trim());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (audioCodec ? acodec : vcodec).Trim();
|
||||
}
|
||||
|
||||
public static string GetFormatName(JToken formatToken)
|
||||
{
|
||||
string format = formatToken["itag"]?.ToString() switch
|
||||
{
|
||||
"160" => "144p",
|
||||
"278" => "144p",
|
||||
"330" => "144p",
|
||||
"394" => "144p",
|
||||
"694" => "144p",
|
||||
|
||||
"133" => "240p",
|
||||
"242" => "240p",
|
||||
"331" => "240p",
|
||||
"395" => "240p",
|
||||
"695" => "240p",
|
||||
|
||||
"134" => "360p",
|
||||
"243" => "360p",
|
||||
"332" => "360p",
|
||||
"396" => "360p",
|
||||
"696" => "360p",
|
||||
|
||||
"135" => "480p",
|
||||
"244" => "480p",
|
||||
"333" => "480p",
|
||||
"397" => "480p",
|
||||
"697" => "480p",
|
||||
|
||||
"136" => "720p",
|
||||
"247" => "720p",
|
||||
"298" => "720p",
|
||||
"302" => "720p",
|
||||
"334" => "720p",
|
||||
"398" => "720p",
|
||||
"698" => "720p",
|
||||
|
||||
"137" => "1080p",
|
||||
"299" => "1080p",
|
||||
"248" => "1080p",
|
||||
"303" => "1080p",
|
||||
"335" => "1080p",
|
||||
"399" => "1080p",
|
||||
"699" => "1080p",
|
||||
|
||||
"264" => "1440p",
|
||||
"271" => "1440p",
|
||||
"304" => "1440p",
|
||||
"308" => "1440p",
|
||||
"336" => "1440p",
|
||||
"400" => "1440p",
|
||||
"700" => "1440p",
|
||||
|
||||
"266" => "2160p",
|
||||
"305" => "2160p",
|
||||
"313" => "2160p",
|
||||
"315" => "2160p",
|
||||
"337" => "2160p",
|
||||
"401" => "2160p",
|
||||
"701" => "2160p",
|
||||
|
||||
"138" => "4320p",
|
||||
"272" => "4320p",
|
||||
"402" => "4320p",
|
||||
"571" => "4320p",
|
||||
|
||||
var _ => $"{formatToken["height"]}p",
|
||||
};
|
||||
|
||||
return format == "p"
|
||||
? formatToken["audioQuality"]?.ToString().ToLowerInvariant()
|
||||
: (formatToken["fps"]?.ToObject<int>() ?? 0) > 30
|
||||
? $"{format}{formatToken["fps"]}"
|
||||
: format;
|
||||
}
|
||||
}
|
||||
}
|
||||
790
core/InnerTube/Youtube.cs
Normal file
790
core/InnerTube/Youtube.cs
Normal file
@ -0,0 +1,790 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using InnerTube.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace InnerTube
|
||||
{
|
||||
public class Youtube
|
||||
{
|
||||
internal readonly HttpClient Client = new();
|
||||
|
||||
public readonly Dictionary<string, CacheItem<YoutubePlayer>> PlayerCache = new();
|
||||
|
||||
private readonly Dictionary<ChannelTabs, string> ChannelTabParams = new()
|
||||
{
|
||||
[ChannelTabs.Home] = @"EghmZWF0dXJlZA%3D%3D",
|
||||
[ChannelTabs.Videos] = @"EgZ2aWRlb3M%3D",
|
||||
[ChannelTabs.Playlists] = @"EglwbGF5bGlzdHM%3D",
|
||||
[ChannelTabs.Community] = @"Egljb21tdW5pdHk%3D",
|
||||
[ChannelTabs.Channels] = @"EghjaGFubmVscw%3D%3D",
|
||||
[ChannelTabs.About] = @"EgVhYm91dA%3D%3D"
|
||||
};
|
||||
|
||||
private async Task<JObject> MakeRequest(string endpoint, Dictionary<string, object> postData, string language,
|
||||
string region, string clientName = "WEB", string clientId = "1", string clientVersion = "2.20220405", bool authorized = false)
|
||||
{
|
||||
HttpRequestMessage hrm = new(HttpMethod.Post,
|
||||
@$"https://www.youtube.com/youtubei/v1/{endpoint}?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8");
|
||||
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(RequestContext.BuildRequestContextJson(postData, language, region, clientName, clientVersion));
|
||||
ByteArrayContent byteContent = new(buffer);
|
||||
if (authorized)
|
||||
{
|
||||
hrm.Headers.Add("Cookie", Utils.GenerateAuthCookie());
|
||||
hrm.Headers.Add("Authorization", Utils.GenerateAuthHeader());
|
||||
hrm.Headers.Add("X-Youtube-Client-Name", clientId);
|
||||
hrm.Headers.Add("X-Youtube-Client-Version", clientVersion);
|
||||
hrm.Headers.Add("Origin", "https://www.youtube.com");
|
||||
}
|
||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
hrm.Content = byteContent;
|
||||
HttpResponseMessage ytPlayerRequest = await Client.SendAsync(hrm);
|
||||
|
||||
return JObject.Parse(await ytPlayerRequest.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
public async Task<YoutubePlayer> GetPlayerAsync(string videoId, string language = "en", string region = "US", bool iOS = false)
|
||||
{
|
||||
if (PlayerCache.Any(x => x.Key == videoId && x.Value.ExpireTime > DateTimeOffset.Now))
|
||||
{
|
||||
CacheItem<YoutubePlayer> item = PlayerCache[videoId];
|
||||
item.Item.ExpiresInSeconds = ((int)(item.ExpireTime - DateTimeOffset.Now).TotalSeconds).ToString();
|
||||
return item.Item;
|
||||
}
|
||||
|
||||
JObject player = await MakeRequest("player", new Dictionary<string, object>
|
||||
{
|
||||
["videoId"] = videoId,
|
||||
["contentCheckOk"] = true,
|
||||
["racyCheckOk"] = true
|
||||
}, language, region, iOS ? "IOS" : "ANDROID", iOS ? "5" : "3", "17.13.3", true);
|
||||
|
||||
switch (player["playabilityStatus"]?["status"]?.ToString())
|
||||
{
|
||||
case "OK":
|
||||
YoutubeStoryboardSpec storyboardSpec =
|
||||
new(player["storyboards"]?["playerStoryboardSpecRenderer"]?["spec"]?.ToString(), player["videoDetails"]?["lengthSeconds"]?.ToObject<long>() ?? 0);
|
||||
YoutubePlayer video = new()
|
||||
{
|
||||
Id = player["videoDetails"]?["videoId"]?.ToString(),
|
||||
Title = player["videoDetails"]?["title"]?.ToString(),
|
||||
Description = player["videoDetails"]?["shortDescription"]?.ToString(),
|
||||
Tags = player["videoDetails"]?["keywords"]?.ToObject<string[]>(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = player["videoDetails"]?["author"]?.ToString(),
|
||||
Id = player["videoDetails"]?["channelId"]?.ToString(),
|
||||
Avatars = Array.Empty<Thumbnail>()
|
||||
},
|
||||
Duration = player["videoDetails"]?["lengthSeconds"]?.ToObject<long>(),
|
||||
IsLive = player["videoDetails"]?["isLiveContent"]?.ToObject<bool>() ?? false,
|
||||
Chapters = Array.Empty<Chapter>(),
|
||||
Thumbnails = player["videoDetails"]?["thumbnail"]?["thumbnails"]?.Select(x => new Thumbnail
|
||||
{
|
||||
Height = x["height"]?.ToObject<int>() ?? -1,
|
||||
Url = x["url"]?.ToString(),
|
||||
Width = x["width"]?.ToObject<int>() ?? -1
|
||||
}).ToArray(),
|
||||
Formats = player["streamingData"]?["formats"]?.Select(x => new Format
|
||||
{
|
||||
FormatName = Utils.GetFormatName(x),
|
||||
FormatId = x["itag"]?.ToString(),
|
||||
FormatNote = x["quality"]?.ToString(),
|
||||
Filesize = x["contentLength"]?.ToObject<long>(),
|
||||
Bitrate = x["bitrate"]?.ToObject<long>() ?? 0,
|
||||
AudioCodec = Utils.GetCodec(x["mimeType"]?.ToString(), true),
|
||||
VideoCodec = Utils.GetCodec(x["mimeType"]?.ToString(), false),
|
||||
AudioSampleRate = x["audioSampleRate"]?.ToObject<long>(),
|
||||
Resolution = $"{x["width"] ?? "0"}x{x["height"] ?? "0"}",
|
||||
Url = x["url"]?.ToString()
|
||||
}).ToArray() ?? Array.Empty<Format>(),
|
||||
AdaptiveFormats = player["streamingData"]?["adaptiveFormats"]?.Select(x => new Format
|
||||
{
|
||||
FormatName = Utils.GetFormatName(x),
|
||||
FormatId = x["itag"]?.ToString(),
|
||||
FormatNote = x["quality"]?.ToString(),
|
||||
Filesize = x["contentLength"]?.ToObject<long>(),
|
||||
Bitrate = x["bitrate"]?.ToObject<long>() ?? 0,
|
||||
AudioCodec = Utils.GetCodec(x["mimeType"].ToString(), true),
|
||||
VideoCodec = Utils.GetCodec(x["mimeType"].ToString(), false),
|
||||
AudioSampleRate = x["audioSampleRate"]?.ToObject<long>(),
|
||||
Resolution = $"{x["width"] ?? "0"}x{x["height"] ?? "0"}",
|
||||
Url = x["url"]?.ToString(),
|
||||
InitRange = x["initRange"]?.ToObject<Models.Range>(),
|
||||
IndexRange = x["indexRange"]?.ToObject<Models.Range>()
|
||||
}).ToArray() ?? Array.Empty<Format>(),
|
||||
HlsManifestUrl = player["streamingData"]?["hlsManifestUrl"]?.ToString(),
|
||||
Subtitles = player["captions"]?["playerCaptionsTracklistRenderer"]?["captionTracks"]?.Select(
|
||||
x => new Subtitle
|
||||
{
|
||||
Ext = HttpUtility.ParseQueryString(x["baseUrl"].ToString()).Get("fmt"),
|
||||
Language = Utils.ReadRuns(x["name"]?["runs"]?.ToObject<JArray>()),
|
||||
Url = x["baseUrl"].ToString()
|
||||
}).ToArray(),
|
||||
Storyboards = storyboardSpec.Urls.TryGetValue("L0", out string sb) ? new[] { sb } : Array.Empty<string>(),
|
||||
ExpiresInSeconds = player["streamingData"]?["expiresInSeconds"]?.ToString(),
|
||||
ErrorMessage = null
|
||||
};
|
||||
PlayerCache.Remove(videoId);
|
||||
PlayerCache.Add(videoId,
|
||||
new CacheItem<YoutubePlayer>(video,
|
||||
TimeSpan.FromSeconds(int.Parse(video.ExpiresInSeconds ?? "21600"))
|
||||
.Subtract(TimeSpan.FromHours(1))));
|
||||
return video;
|
||||
case "LOGIN_REQUIRED":
|
||||
return new YoutubePlayer
|
||||
{
|
||||
Id = "",
|
||||
Title = "",
|
||||
Description = "",
|
||||
Tags = Array.Empty<string>(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = "",
|
||||
Id = "",
|
||||
SubscriberCount = "",
|
||||
Avatars = Array.Empty<Thumbnail>()
|
||||
},
|
||||
Duration = 0,
|
||||
IsLive = false,
|
||||
Chapters = Array.Empty<Chapter>(),
|
||||
Thumbnails = Array.Empty<Thumbnail>(),
|
||||
Formats = Array.Empty<Format>(),
|
||||
AdaptiveFormats = Array.Empty<Format>(),
|
||||
Subtitles = Array.Empty<Subtitle>(),
|
||||
Storyboards = Array.Empty<string>(),
|
||||
ExpiresInSeconds = "0",
|
||||
ErrorMessage =
|
||||
"This video is age-restricted. Please contact this instances authors to update their configuration"
|
||||
};
|
||||
default:
|
||||
return new YoutubePlayer
|
||||
{
|
||||
Id = "",
|
||||
Title = "",
|
||||
Description = "",
|
||||
Tags = Array.Empty<string>(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = "",
|
||||
Id = "",
|
||||
SubscriberCount = "",
|
||||
Avatars = Array.Empty<Thumbnail>()
|
||||
},
|
||||
Duration = 0,
|
||||
IsLive = false,
|
||||
Chapters = Array.Empty<Chapter>(),
|
||||
Thumbnails = Array.Empty<Thumbnail>(),
|
||||
Formats = Array.Empty<Format>(),
|
||||
AdaptiveFormats = Array.Empty<Format>(),
|
||||
Subtitles = Array.Empty<Subtitle>(),
|
||||
Storyboards = Array.Empty<string>(),
|
||||
ExpiresInSeconds = "0",
|
||||
ErrorMessage = player["playabilityStatus"]?["reason"]?.ToString() ?? "Something has gone *really* wrong"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<YoutubeVideo> GetVideoAsync(string videoId, string language = "en", string region = "US")
|
||||
{
|
||||
JObject player = await MakeRequest("next", new Dictionary<string, object>
|
||||
{
|
||||
["videoId"] = videoId
|
||||
}, language, region);
|
||||
|
||||
JToken[] contents =
|
||||
(player?["contents"]?["twoColumnWatchNextResults"]?["results"]?["results"]?["contents"]
|
||||
?.ToObject<JArray>() ?? new JArray())
|
||||
.SkipWhile(x => !x.First.Path.EndsWith("videoPrimaryInfoRenderer")).ToArray();
|
||||
|
||||
YoutubeVideo video = new();
|
||||
video.Id = player["currentVideoEndpoint"]?["watchEndpoint"]?["videoId"]?.ToString();
|
||||
try
|
||||
{
|
||||
|
||||
video.Title = Utils.ReadRuns(
|
||||
contents[0]
|
||||
["videoPrimaryInfoRenderer"]?["title"]?["runs"]?.ToObject<JArray>());
|
||||
video.Description = Utils.ReadRuns(
|
||||
contents[1]
|
||||
["videoSecondaryInfoRenderer"]?["description"]?["runs"]?.ToObject<JArray>());
|
||||
video.Views = contents[0]
|
||||
["videoPrimaryInfoRenderer"]?["viewCount"]?["videoViewCountRenderer"]?["viewCount"]?["simpleText"]?.ToString();
|
||||
video.Channel = new Channel
|
||||
{
|
||||
Name =
|
||||
contents[1]
|
||||
["videoSecondaryInfoRenderer"]?["owner"]?["videoOwnerRenderer"]?["title"]?["runs"]?[0]?[
|
||||
"text"]?.ToString(),
|
||||
Id = contents[1]
|
||||
["videoSecondaryInfoRenderer"]?["owner"]?["videoOwnerRenderer"]?["title"]?["runs"]?[0]?
|
||||
["navigationEndpoint"]?["browseEndpoint"]?["browseId"]?.ToString(),
|
||||
SubscriberCount =
|
||||
contents[1]
|
||||
["videoSecondaryInfoRenderer"]?["owner"]?["videoOwnerRenderer"]?["subscriberCountText"]?[
|
||||
"simpleText"]?.ToString(),
|
||||
Avatars =
|
||||
(contents[1][
|
||||
"videoSecondaryInfoRenderer"]?["owner"]?["videoOwnerRenderer"]?["thumbnail"]?[
|
||||
"thumbnails"]
|
||||
?.ToObject<JArray>() ?? new JArray()).Select(Utils.ParseThumbnails).ToArray()
|
||||
};
|
||||
video.UploadDate = contents[0][
|
||||
"videoPrimaryInfoRenderer"]?["dateText"]?["simpleText"]?.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
video.Title ??= "";
|
||||
video.Description ??= "";
|
||||
video.Channel ??= new Channel
|
||||
{
|
||||
Name = "",
|
||||
Id = "",
|
||||
SubscriberCount = "",
|
||||
Avatars = Array.Empty<Thumbnail>()
|
||||
};
|
||||
video.UploadDate ??= "";
|
||||
}
|
||||
video.Recommended = ParseRenderers(
|
||||
player?["contents"]?["twoColumnWatchNextResults"]?["secondaryResults"]?["secondaryResults"]?
|
||||
["results"]?.ToObject<JArray>() ?? new JArray());
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
public async Task<YoutubeSearchResults> SearchAsync(string query, string continuation = null,
|
||||
string language = "en", string region = "US")
|
||||
{
|
||||
Dictionary<string, object> data = new();
|
||||
if (string.IsNullOrWhiteSpace(continuation))
|
||||
data.Add("query", query);
|
||||
else
|
||||
data.Add("continuation", continuation);
|
||||
JObject search = await MakeRequest("search", data, language, region);
|
||||
|
||||
return new YoutubeSearchResults
|
||||
{
|
||||
Refinements = search?["refinements"]?.ToObject<string[]>() ?? Array.Empty<string>(),
|
||||
EstimatedResults = search?["estimatedResults"]?.ToObject<long>() ?? 0,
|
||||
Results = ParseRenderers(
|
||||
search?["contents"]?["twoColumnSearchResultsRenderer"]?["primaryContents"]?["sectionListRenderer"]?
|
||||
["contents"]?[0]?["itemSectionRenderer"]?["contents"]?.ToObject<JArray>() ??
|
||||
search?["onResponseReceivedCommands"]?[0]?["appendContinuationItemsAction"]?["continuationItems"]?
|
||||
[0]?["itemSectionRenderer"]?["contents"]?.ToObject<JArray>() ?? new JArray()),
|
||||
ContinuationKey =
|
||||
search?["contents"]?["twoColumnSearchResultsRenderer"]?["primaryContents"]?["sectionListRenderer"]?
|
||||
["contents"]?[1]?["continuationItemRenderer"]?["continuationEndpoint"]?["continuationCommand"]?
|
||||
["token"]?.ToString() ??
|
||||
search?["onResponseReceivedCommands"]?[0]?["appendContinuationItemsAction"]?["continuationItems"]?
|
||||
[1]?["continuationItemRenderer"]?["continuationEndpoint"]?["continuationCommand"]?["token"]
|
||||
?.ToString() ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<YoutubePlaylist> GetPlaylistAsync(string id, string continuation = null,
|
||||
string language = "en", string region = "US")
|
||||
{
|
||||
Dictionary<string, object> data = new();
|
||||
if (string.IsNullOrWhiteSpace(continuation))
|
||||
data.Add("browseId", "VL" + id);
|
||||
else
|
||||
data.Add("continuation", continuation);
|
||||
JObject playlist = await MakeRequest("browse", data, language, region);
|
||||
DynamicItem[] renderers = ParseRenderers(
|
||||
playlist?["contents"]?["twoColumnBrowseResultsRenderer"]?["tabs"]?[0]?["tabRenderer"]?["content"]?
|
||||
["sectionListRenderer"]?["contents"]?[0]?["itemSectionRenderer"]?["contents"]?[0]?
|
||||
["playlistVideoListRenderer"]?["contents"]?.ToObject<JArray>() ??
|
||||
playlist?["onResponseReceivedActions"]?[0]?["appendContinuationItemsAction"]?["continuationItems"]
|
||||
?.ToObject<JArray>() ?? new JArray());
|
||||
|
||||
return new YoutubePlaylist
|
||||
{
|
||||
Id = id,
|
||||
Title = playlist?["metadata"]?["playlistMetadataRenderer"]?["title"]?.ToString(),
|
||||
Description = playlist?["metadata"]?["playlistMetadataRenderer"]?["description"]?.ToString(),
|
||||
VideoCount = playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[0]?[
|
||||
"playlistSidebarPrimaryInfoRenderer"]?["stats"]?[0]?["runs"]?[0]?["text"]?.ToString(),
|
||||
ViewCount = playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[0]?[
|
||||
"playlistSidebarPrimaryInfoRenderer"]?["stats"]?[1]?["simpleText"]?.ToString(),
|
||||
LastUpdated = Utils.ReadRuns(playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[0]?[
|
||||
"playlistSidebarPrimaryInfoRenderer"]?["stats"]?[2]?["runs"]?.ToObject<JArray>() ?? new JArray()),
|
||||
Thumbnail = (playlist?["microformat"]?["microformatDataRenderer"]?["thumbnail"]?["thumbnails"] ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name =
|
||||
playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[1]?
|
||||
["playlistSidebarSecondaryInfoRenderer"]?["videoOwner"]?["videoOwnerRenderer"]?["title"]?
|
||||
["runs"]?[0]?["text"]?.ToString(),
|
||||
Id = playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[1]?
|
||||
["playlistSidebarSecondaryInfoRenderer"]?["videoOwner"]?["videoOwnerRenderer"]?
|
||||
["navigationEndpoint"]?["browseEndpoint"]?["browseId"]?.ToString(),
|
||||
SubscriberCount = "",
|
||||
Avatars =
|
||||
(playlist?["sidebar"]?["playlistSidebarRenderer"]?["items"]?[1]?
|
||||
["playlistSidebarSecondaryInfoRenderer"]?["videoOwner"]?["videoOwnerRenderer"]?["thumbnail"]
|
||||
?["thumbnails"] ?? new JArray()).Select(Utils.ParseThumbnails).ToArray()
|
||||
},
|
||||
Videos = renderers.Where(x => x is not ContinuationItem).ToArray(),
|
||||
ContinuationKey = renderers.FirstOrDefault(x => x is ContinuationItem)?.Id
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<YoutubeChannel> GetChannelAsync(string id, ChannelTabs tab = ChannelTabs.Home,
|
||||
string continuation = null, string language = "en", string region = "US")
|
||||
{
|
||||
Dictionary<string, object> data = new();
|
||||
if (string.IsNullOrWhiteSpace(continuation))
|
||||
{
|
||||
data.Add("browseId", id);
|
||||
if (string.IsNullOrWhiteSpace(continuation))
|
||||
data.Add("params", ChannelTabParams[tab]);
|
||||
}
|
||||
else
|
||||
{
|
||||
data.Add("continuation", continuation);
|
||||
}
|
||||
|
||||
JObject channel = await MakeRequest("browse", data, language, region);
|
||||
JArray mainArray =
|
||||
(channel?["contents"]?["twoColumnBrowseResultsRenderer"]?["tabs"]?.ToObject<JArray>() ?? new JArray())
|
||||
.FirstOrDefault(x => x?["tabRenderer"]?["selected"]?.ToObject<bool>() ?? false)?["tabRenderer"]?[
|
||||
"content"]?
|
||||
["sectionListRenderer"]?["contents"]?.ToObject<JArray>();
|
||||
|
||||
return new YoutubeChannel
|
||||
{
|
||||
Id = channel?["metadata"]?["channelMetadataRenderer"]?["externalId"]?.ToString(),
|
||||
Name = channel?["metadata"]?["channelMetadataRenderer"]?["title"]?.ToString(),
|
||||
Url = channel?["metadata"]?["channelMetadataRenderer"]?["externalId"]?.ToString(),
|
||||
Avatars = (channel?["metadata"]?["channelMetadataRenderer"]?["avatar"]?["thumbnails"] ?? new JArray())
|
||||
.Select(Utils.ParseThumbnails).ToArray(),
|
||||
Banners = (channel?["header"]?["c4TabbedHeaderRenderer"]?["banner"]?["thumbnails"] ?? new JArray())
|
||||
.Select(Utils.ParseThumbnails).ToArray(),
|
||||
Description = channel?["metadata"]?["channelMetadataRenderer"]?["description"]?.ToString(),
|
||||
Videos = ParseRenderers(mainArray ??
|
||||
channel?["onResponseReceivedActions"]?[0]?["appendContinuationItemsAction"]?
|
||||
["continuationItems"]?.ToObject<JArray>() ?? new JArray()),
|
||||
Subscribers = channel?["header"]?["c4TabbedHeaderRenderer"]?["subscriberCountText"]?["simpleText"]
|
||||
?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<YoutubeTrends> GetExploreAsync(string browseId = null, string continuation = null, string language = "en", string region = "US")
|
||||
{
|
||||
Dictionary<string, object> data = new();
|
||||
if (string.IsNullOrWhiteSpace(continuation))
|
||||
{
|
||||
data.Add("browseId", browseId ?? "FEexplore");
|
||||
}
|
||||
else
|
||||
{
|
||||
data.Add("continuation", continuation);
|
||||
}
|
||||
|
||||
JObject explore = await MakeRequest("browse", data, language, region);
|
||||
JToken[] token =
|
||||
(explore?["contents"]?["twoColumnBrowseResultsRenderer"]?["tabs"]?[0]?["tabRenderer"]?["content"]?
|
||||
["sectionListRenderer"]?["contents"]?.ToObject<JArray>() ?? new JArray()).Skip(1).ToArray();
|
||||
|
||||
JArray mainArray = new(token.Select(x => x is JObject obj ? obj : null).Where(x => x is not null));
|
||||
|
||||
return new YoutubeTrends
|
||||
{
|
||||
Categories = explore?["contents"]?["twoColumnBrowseResultsRenderer"]?["tabs"]?[0]?["tabRenderer"]?["content"]?["sectionListRenderer"]?["contents"]?[0]?["itemSectionRenderer"]?["contents"]?[0]?["destinationShelfRenderer"]?["destinationButtons"]?.Select(
|
||||
x =>
|
||||
{
|
||||
JToken rendererObject = x?["destinationButtonRenderer"];
|
||||
TrendCategory category = new()
|
||||
{
|
||||
Label = rendererObject?["label"]?["simpleText"]?.ToString(),
|
||||
BackgroundImage = (rendererObject?["backgroundImage"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
Icon = (rendererObject?["iconImage"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
Id = $"{rendererObject?["onTap"]?["browseEndpoint"]?["browseId"]}"
|
||||
};
|
||||
return category;
|
||||
}).ToArray(),
|
||||
Videos = ParseRenderers(mainArray)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<YoutubeLocals> GetLocalsAsync(string language = "en", string region = "US")
|
||||
{
|
||||
JObject locals = await MakeRequest("account/account_menu", new Dictionary<string, object>(), language,
|
||||
region);
|
||||
|
||||
return new YoutubeLocals
|
||||
{
|
||||
Languages =
|
||||
locals["actions"]?[0]?["openPopupAction"]?["popup"]?["multiPageMenuRenderer"]?["sections"]?[0]?
|
||||
["multiPageMenuSectionRenderer"]?["items"]?[1]?["compactLinkRenderer"]?["serviceEndpoint"]?
|
||||
["signalServiceEndpoint"]?["actions"]?[0]?["getMultiPageMenuAction"]?["menu"]?
|
||||
["multiPageMenuRenderer"]?["sections"]?[0]?["multiPageMenuSectionRenderer"]?["items"]?
|
||||
.ToObject<JArray>()?.ToDictionary(
|
||||
x => x?["compactLinkRenderer"]?["serviceEndpoint"]?["signalServiceEndpoint"]?
|
||||
["actions"]?[0]?["selectLanguageCommand"]?["hl"]?.ToString(),
|
||||
x => x?["compactLinkRenderer"]?["title"]?["simpleText"]?.ToString()),
|
||||
Regions =
|
||||
locals["actions"]?[0]?["openPopupAction"]?["popup"]?["multiPageMenuRenderer"]?["sections"]?[0]?
|
||||
["multiPageMenuSectionRenderer"]?["items"]?[2]?["compactLinkRenderer"]?["serviceEndpoint"]?
|
||||
["signalServiceEndpoint"]?["actions"]?[0]?["getMultiPageMenuAction"]?["menu"]?
|
||||
["multiPageMenuRenderer"]?["sections"]?[0]?["multiPageMenuSectionRenderer"]?["items"]?
|
||||
.ToObject<JArray>()?.ToDictionary(
|
||||
x => x?["compactLinkRenderer"]?["serviceEndpoint"]?["signalServiceEndpoint"]?
|
||||
["actions"]?[0]?["selectCountryCommand"]?["gl"]?.ToString(),
|
||||
x => x?["compactLinkRenderer"]?["title"]?["simpleText"]?.ToString())
|
||||
};
|
||||
}
|
||||
|
||||
private DynamicItem[] ParseRenderers(JArray renderersArray)
|
||||
{
|
||||
List<DynamicItem> items = new();
|
||||
|
||||
foreach (JToken jToken in renderersArray)
|
||||
{
|
||||
JObject recommendationContainer = jToken as JObject;
|
||||
string rendererName = recommendationContainer?.First?.Path.Split(".").Last() ?? "";
|
||||
JObject rendererItem = recommendationContainer?[rendererName]?.ToObject<JObject>();
|
||||
switch (rendererName)
|
||||
{
|
||||
case "videoRenderer":
|
||||
items.Add(new VideoItem
|
||||
{
|
||||
Id = rendererItem?["videoId"]?.ToString(),
|
||||
Title = Utils.ReadRuns(rendererItem?["title"]?["runs"]?.ToObject<JArray>() ??
|
||||
new JArray()),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
UploadedAt = rendererItem?["publishedTimeText"]?["simpleText"]?.ToString(),
|
||||
Views = long.TryParse(
|
||||
rendererItem?["viewCountText"]?["simpleText"]?.ToString().Split(" ")[0]
|
||||
.Replace(",", "").Replace(".", "") ?? "0", out long vV) ? vV : 0,
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = rendererItem?["longBylineText"]?["runs"]?[0]?["text"]?.ToString(),
|
||||
Id = rendererItem?["longBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||
"browseEndpoint"]?["browseId"]?.ToString(),
|
||||
SubscriberCount = null,
|
||||
Avatars =
|
||||
(rendererItem?["channelThumbnailSupportedRenderers"]?[
|
||||
"channelThumbnailWithLinkRenderer"]?["thumbnail"]?["thumbnails"]
|
||||
?.ToObject<JArray>() ?? new JArray()).Select(Utils.ParseThumbnails)
|
||||
.ToArray()
|
||||
},
|
||||
Duration = rendererItem?["thumbnailOverlays"]?[0]?[
|
||||
"thumbnailOverlayTimeStatusRenderer"]?["text"]?["simpleText"]?.ToString(),
|
||||
Description = Utils.ReadRuns(rendererItem?["detailedMetadataSnippets"]?[0]?[
|
||||
"snippetText"]?["runs"]?.ToObject<JArray>() ?? new JArray())
|
||||
});
|
||||
break;
|
||||
case "gridVideoRenderer":
|
||||
items.Add(new VideoItem
|
||||
{
|
||||
Id = rendererItem?["videoId"]?.ToString(),
|
||||
Title = rendererItem?["title"]?["simpleText"]?.ToString() ?? Utils.ReadRuns(
|
||||
rendererItem?["title"]?["runs"]?.ToObject<JArray>() ?? new JArray()),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
UploadedAt = rendererItem?["publishedTimeText"]?["simpleText"]?.ToString(),
|
||||
Views = long.TryParse(
|
||||
rendererItem?["viewCountText"]?["simpleText"]?.ToString().Split(" ")[0]
|
||||
.Replace(",", "").Replace(".", "") ?? "0", out long gVV) ? gVV : 0,
|
||||
Channel = null,
|
||||
Duration = rendererItem?["thumbnailOverlays"]?[0]?[
|
||||
"thumbnailOverlayTimeStatusRenderer"]?["text"]?["simpleText"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "playlistRenderer":
|
||||
items.Add(new PlaylistItem
|
||||
{
|
||||
Id = rendererItem?["playlistId"]
|
||||
?.ToString(),
|
||||
Title = rendererItem?["title"]?["simpleText"]
|
||||
?.ToString(),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnails"]?[0]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
VideoCount = int.TryParse(
|
||||
rendererItem?["videoCountText"]?["runs"]?[0]?["text"]?.ToString().Replace(",", "")
|
||||
.Replace(".", "") ?? "0", out int pVC) ? pVC : 0,
|
||||
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]
|
||||
?.ToString(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = rendererItem?["longBylineText"]?["runs"]?[0]?["text"]
|
||||
?.ToString(),
|
||||
Id = rendererItem?["longBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||
"browseEndpoint"]?["browseId"]
|
||||
?.ToString(),
|
||||
SubscriberCount = null,
|
||||
Avatars = null
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "channelRenderer":
|
||||
items.Add(new ChannelItem
|
||||
{
|
||||
Id = rendererItem?["channelId"]?.ToString(),
|
||||
Title = rendererItem?["title"]?["simpleText"]?.ToString(),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]
|
||||
?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails)
|
||||
.ToArray(), //
|
||||
Url = rendererItem?["navigationEndpoint"]?["commandMetadata"]?["webCommandMetadata"]?["url"]
|
||||
?.ToString(),
|
||||
Description =
|
||||
Utils.ReadRuns(rendererItem?["descriptionSnippet"]?["runs"]?.ToObject<JArray>() ??
|
||||
new JArray()),
|
||||
VideoCount = long.TryParse(
|
||||
rendererItem?["videoCountText"]?["runs"]?[0]?["text"]
|
||||
?.ToString()
|
||||
.Replace(",",
|
||||
"")
|
||||
.Replace(".",
|
||||
"") ??
|
||||
"0", out long cVC) ? cVC : 0,
|
||||
Subscribers = rendererItem?["subscriberCountText"]?["simpleText"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "radioRenderer":
|
||||
items.Add(new RadioItem
|
||||
{
|
||||
Id = rendererItem?["playlistId"]
|
||||
?.ToString(),
|
||||
Title = rendererItem?["title"]?["simpleText"]
|
||||
?.ToString(),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]
|
||||
?.ToString(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = rendererItem?["longBylineText"]?["simpleText"]?.ToString(),
|
||||
Id = "",
|
||||
SubscriberCount = null,
|
||||
Avatars = null
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "shelfRenderer":
|
||||
items.Add(new ShelfItem
|
||||
{
|
||||
Title = rendererItem?["title"]?["simpleText"]
|
||||
?.ToString() ??
|
||||
rendererItem?["title"]?["runs"]?[0]?["text"]
|
||||
?.ToString(),
|
||||
Thumbnails = (rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
Items = ParseRenderers(
|
||||
rendererItem?["content"]?["verticalListRenderer"]?["items"]
|
||||
?.ToObject<JArray>() ??
|
||||
rendererItem?["content"]?["horizontalListRenderer"]?["items"]
|
||||
?.ToObject<JArray>() ??
|
||||
rendererItem?["content"]?["expandedShelfContentsRenderer"]?["items"]
|
||||
?.ToObject<JArray>() ??
|
||||
new JArray()),
|
||||
CollapsedItemCount =
|
||||
rendererItem?["content"]?["verticalListRenderer"]?["collapsedItemCount"]
|
||||
?.ToObject<int>() ?? 0,
|
||||
Badges = ParseRenderers(rendererItem?["badges"]?.ToObject<JArray>() ?? new JArray())
|
||||
.Where(x => x is BadgeItem).Cast<BadgeItem>().ToArray(),
|
||||
});
|
||||
break;
|
||||
case "horizontalCardListRenderer":
|
||||
items.Add(new HorizontalCardListItem
|
||||
{
|
||||
Title = rendererItem?["header"]?["richListHeaderRenderer"]?["title"]?["simpleText"]
|
||||
?.ToString(),
|
||||
Items = ParseRenderers(rendererItem?["cards"]?.ToObject<JArray>() ?? new JArray())
|
||||
});
|
||||
break;
|
||||
case "searchRefinementCardRenderer":
|
||||
items.Add(new CardItem
|
||||
{
|
||||
Title = Utils.ReadRuns(rendererItem?["query"]?["runs"]?.ToObject<JArray>() ??
|
||||
new JArray()),
|
||||
Thumbnails = (rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray()
|
||||
});
|
||||
break;
|
||||
case "compactVideoRenderer":
|
||||
items.Add(new VideoItem
|
||||
{
|
||||
Id = rendererItem?["videoId"]?.ToString(),
|
||||
Title = rendererItem?["title"]?["simpleText"]?.ToString(),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
UploadedAt = rendererItem?["publishedTimeText"]?["simpleText"]?.ToString(),
|
||||
Views = long.TryParse(
|
||||
rendererItem?["viewCountText"]?["simpleText"]?.ToString().Split(" ")[0]
|
||||
.Replace(",", "").Replace(".", "") ?? "0", out long cVV) ? cVV : 0,
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = rendererItem?["longBylineText"]?["runs"]?[0]?["text"]?.ToString(),
|
||||
Id = rendererItem?["longBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||
"browseEndpoint"]?["browseId"]?.ToString(),
|
||||
SubscriberCount = null,
|
||||
Avatars = null
|
||||
},
|
||||
Duration = rendererItem?["thumbnailOverlays"]?[0]?[
|
||||
"thumbnailOverlayTimeStatusRenderer"]?["text"]?["simpleText"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "compactPlaylistRenderer":
|
||||
items.Add(new PlaylistItem
|
||||
{
|
||||
Id = rendererItem?["playlistId"]
|
||||
?.ToString(),
|
||||
Title = rendererItem?["title"]?["simpleText"]
|
||||
?.ToString(),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]
|
||||
?.ToObject<JArray>() ?? new JArray()).Select(Utils.ParseThumbnails)
|
||||
.ToArray(),
|
||||
VideoCount = int.TryParse(
|
||||
rendererItem?["videoCountText"]?["runs"]?[0]?["text"]?.ToString().Replace(",", "")
|
||||
.Replace(".", "") ?? "0", out int cPVC) ? cPVC : 0,
|
||||
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]
|
||||
?.ToString(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = rendererItem?["longBylineText"]?["runs"]?[0]?["text"]
|
||||
?.ToString(),
|
||||
Id = rendererItem?["longBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||
"browseEndpoint"]?["browseId"]
|
||||
?.ToString(),
|
||||
SubscriberCount = null,
|
||||
Avatars = null
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "compactRadioRenderer":
|
||||
items.Add(new RadioItem
|
||||
{
|
||||
Id = rendererItem?["playlistId"]
|
||||
?.ToString(),
|
||||
Title = rendererItem?["title"]?["simpleText"]
|
||||
?.ToString(),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]
|
||||
?.ToObject<JArray>() ?? new JArray()).Select(Utils.ParseThumbnails)
|
||||
.ToArray(),
|
||||
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]
|
||||
?.ToString(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = rendererItem?["longBylineText"]?["simpleText"]?.ToString(),
|
||||
Id = "",
|
||||
SubscriberCount = null,
|
||||
Avatars = null
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "continuationItemRenderer":
|
||||
items.Add(new ContinuationItem
|
||||
{
|
||||
Id = rendererItem?["continuationEndpoint"]?["continuationCommand"]?["token"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "playlistVideoRenderer":
|
||||
items.Add(new PlaylistVideoItem
|
||||
{
|
||||
Id = rendererItem?["videoId"]?.ToString(),
|
||||
Index = rendererItem?["index"]?["simpleText"]?.ToObject<long>() ?? 0,
|
||||
Title = Utils.ReadRuns(rendererItem?["title"]?["runs"]?.ToObject<JArray>() ??
|
||||
new JArray()),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = rendererItem?["shortBylineText"]?["runs"]?[0]?["text"]?.ToString(),
|
||||
Id = rendererItem?["shortBylineText"]?["runs"]?[0]?["navigationEndpoint"]?[
|
||||
"browseEndpoint"]?["browseId"]?.ToString(),
|
||||
SubscriberCount = null,
|
||||
Avatars = null
|
||||
},
|
||||
Duration = rendererItem?["lengthText"]?["simpleText"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "itemSectionRenderer":
|
||||
items.Add(new ItemSectionItem
|
||||
{
|
||||
Contents = ParseRenderers(rendererItem?["contents"]?.ToObject<JArray>() ?? new JArray())
|
||||
});
|
||||
break;
|
||||
case "gridRenderer":
|
||||
items.Add(new ItemSectionItem
|
||||
{
|
||||
Contents = ParseRenderers(rendererItem?["items"]?.ToObject<JArray>() ?? new JArray())
|
||||
});
|
||||
break;
|
||||
case "messageRenderer":
|
||||
items.Add(new MessageItem
|
||||
{
|
||||
Title = rendererItem?["text"]?["simpleText"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "channelAboutFullMetadataRenderer":
|
||||
items.Add(new ChannelAboutItem
|
||||
{
|
||||
Description = rendererItem?["description"]?["simpleText"]?.ToString(),
|
||||
Country = rendererItem?["country"]?["simpleText"]?.ToString(),
|
||||
Joined = Utils.ReadRuns(rendererItem?["joinedDateText"]?["runs"]?.ToObject<JArray>() ??
|
||||
new JArray()),
|
||||
ViewCount = rendererItem?["viewCountText"]?["simpleText"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "compactStationRenderer":
|
||||
items.Add(new StationItem
|
||||
{
|
||||
Id = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["playlistId"]?.ToString(),
|
||||
Title = rendererItem?["title"]?["simpleText"]?.ToString(),
|
||||
Thumbnails =
|
||||
(rendererItem?["thumbnail"]?["thumbnails"]?.ToObject<JArray>() ??
|
||||
new JArray()).Select(Utils.ParseThumbnails).ToArray(),
|
||||
VideoCount = rendererItem?["videoCountText"]?["runs"]?[0]?["text"].ToObject<int>() ?? 0,
|
||||
FirstVideoId = rendererItem?["navigationEndpoint"]?["watchEndpoint"]?["videoId"]?.ToString(),
|
||||
Description = rendererItem?["description"]?["simpleText"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "metadataBadgeRenderer":
|
||||
items.Add(new BadgeItem
|
||||
{
|
||||
Title = rendererItem?["label"]?.ToString(),
|
||||
Style = rendererItem?["style"]?.ToString()
|
||||
});
|
||||
break;
|
||||
case "promotedSparklesWebRenderer":
|
||||
// this is an ad
|
||||
// no one likes ads
|
||||
break;
|
||||
default:
|
||||
items.Add(new DynamicItem
|
||||
{
|
||||
Id = rendererName,
|
||||
Title = rendererItem?.ToString()
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
core/LightTube/Contexts/BaseContext.cs
Normal file
7
core/LightTube/Contexts/BaseContext.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace LightTube.Contexts
|
||||
{
|
||||
public class BaseContext
|
||||
{
|
||||
public bool MobileLayout;
|
||||
}
|
||||
}
|
||||
7
core/LightTube/Contexts/ErrorContext.cs
Normal file
7
core/LightTube/Contexts/ErrorContext.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace LightTube.Contexts
|
||||
{
|
||||
public class ErrorContext : BaseContext
|
||||
{
|
||||
public string Path;
|
||||
}
|
||||
}
|
||||
11
core/LightTube/Contexts/FeedContext.cs
Normal file
11
core/LightTube/Contexts/FeedContext.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using LightTube.Database;
|
||||
|
||||
namespace LightTube.Contexts
|
||||
{
|
||||
public class FeedContext : BaseContext
|
||||
{
|
||||
public LTChannel[] Channels;
|
||||
public FeedVideo[] Videos;
|
||||
public string RssToken;
|
||||
}
|
||||
}
|
||||
13
core/LightTube/Contexts/LocalsContext.cs
Normal file
13
core/LightTube/Contexts/LocalsContext.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using InnerTube.Models;
|
||||
|
||||
namespace LightTube.Contexts
|
||||
{
|
||||
public class LocalsContext : BaseContext
|
||||
{
|
||||
public Dictionary<string, string> Languages;
|
||||
public Dictionary<string, string> Regions;
|
||||
public string CurrentLanguage;
|
||||
public string CurrentRegion;
|
||||
}
|
||||
}
|
||||
11
core/LightTube/Contexts/PlaylistsContext.cs
Normal file
11
core/LightTube/Contexts/PlaylistsContext.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using InnerTube.Models;
|
||||
using LightTube.Database;
|
||||
|
||||
namespace LightTube.Contexts
|
||||
{
|
||||
public class PlaylistsContext : BaseContext
|
||||
{
|
||||
public IEnumerable<LTPlaylist> Playlists;
|
||||
}
|
||||
}
|
||||
351
core/LightTube/Controllers/AccountController.cs
Normal file
351
core/LightTube/Controllers/AccountController.cs
Normal file
@ -0,0 +1,351 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using InnerTube;
|
||||
using InnerTube.Models;
|
||||
using LightTube.Contexts;
|
||||
using LightTube.Database;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
public class AccountController : Controller
|
||||
{
|
||||
private readonly Youtube _youtube;
|
||||
|
||||
public AccountController(Youtube youtube)
|
||||
{
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
[Route("/Account")]
|
||||
public IActionResult Account()
|
||||
{
|
||||
return View(new BaseContext
|
||||
{
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Login(string err = null)
|
||||
{
|
||||
if (HttpContext.TryGetUser(out LTUser _, "web"))
|
||||
return Redirect("/");
|
||||
|
||||
return View(new MessageContext
|
||||
{
|
||||
Message = err,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Login(string userid, string password)
|
||||
{
|
||||
if (HttpContext.TryGetUser(out LTUser _, "web"))
|
||||
return Redirect("/");
|
||||
|
||||
try
|
||||
{
|
||||
LTLogin login = await DatabaseManager.Logins.CreateToken(userid, password, Request.Headers["user-agent"], new []{"web"});
|
||||
Response.Cookies.Append("token", login.Token, new CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.MaxValue
|
||||
});
|
||||
|
||||
return Redirect("/");
|
||||
}
|
||||
catch (KeyNotFoundException e)
|
||||
{
|
||||
return Redirect("/Account/Login?err=" + HttpUtility.UrlEncode(e.Message));
|
||||
}
|
||||
catch (UnauthorizedAccessException e)
|
||||
{
|
||||
return Redirect("/Account/Login?err=" + HttpUtility.UrlEncode(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
if (HttpContext.Request.Cookies.TryGetValue("token", out string token))
|
||||
{
|
||||
await DatabaseManager.Logins.RemoveToken(token);
|
||||
}
|
||||
|
||||
HttpContext.Response.Cookies.Delete("token");
|
||||
HttpContext.Response.Cookies.Delete("account_data");
|
||||
return Redirect("/");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Register(string err = null)
|
||||
{
|
||||
if (HttpContext.TryGetUser(out LTUser _, "web"))
|
||||
return Redirect("/");
|
||||
|
||||
return View(new MessageContext
|
||||
{
|
||||
Message = err,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Register(string userid, string password)
|
||||
{
|
||||
if (HttpContext.TryGetUser(out LTUser _, "web"))
|
||||
return Redirect("/");
|
||||
|
||||
try
|
||||
{
|
||||
await DatabaseManager.Logins.CreateUser(userid, password);
|
||||
LTLogin login = await DatabaseManager.Logins.CreateToken(userid, password, Request.Headers["user-agent"], new []{"web"});
|
||||
Response.Cookies.Append("token", login.Token, new CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.MaxValue
|
||||
});
|
||||
|
||||
return Redirect("/");
|
||||
}
|
||||
catch (DuplicateNameException e)
|
||||
{
|
||||
return Redirect("/Account/Register?err=" + HttpUtility.UrlEncode(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public IActionResult RegisterLocal()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser _, "web"))
|
||||
HttpContext.CreateLocalAccount();
|
||||
|
||||
return Redirect("/");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Delete(string err = null)
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser _, "web"))
|
||||
return Redirect("/");
|
||||
|
||||
return View(new MessageContext
|
||||
{
|
||||
Message = err,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Delete(string userid, string password)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (userid == "Local Account" && password == "local_account")
|
||||
Response.Cookies.Delete("account_data");
|
||||
else
|
||||
await DatabaseManager.Logins.DeleteUser(userid, password);
|
||||
return Redirect("/Account/Register?err=Account+deleted");
|
||||
}
|
||||
catch (KeyNotFoundException e)
|
||||
{
|
||||
return Redirect("/Account/Delete?err=" + HttpUtility.UrlEncode(e.Message));
|
||||
}
|
||||
catch (UnauthorizedAccessException e)
|
||||
{
|
||||
return Redirect("/Account/Delete?err=" + HttpUtility.UrlEncode(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Logins()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser _, "web") || !HttpContext.Request.Cookies.TryGetValue("token", out string token))
|
||||
return Redirect("/Account/Login");
|
||||
|
||||
return View(new LoginsContext
|
||||
{
|
||||
CurrentLogin = await DatabaseManager.Logins.GetCurrentLoginId(token),
|
||||
Logins = await DatabaseManager.Logins.GetAllUserTokens(token),
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IActionResult> DisableLogin(string id)
|
||||
{
|
||||
if (!HttpContext.Request.Cookies.TryGetValue("token", out string token))
|
||||
return Redirect("/Account/Login");
|
||||
|
||||
try
|
||||
{
|
||||
await DatabaseManager.Logins.RemoveTokenFromId(token, id);
|
||||
} catch { }
|
||||
return Redirect("/Account/Logins");
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Subscribe(string channel)
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
YoutubeChannel youtubeChannel = await _youtube.GetChannelAsync(channel, ChannelTabs.About);
|
||||
|
||||
(LTChannel channel, bool subscribed) result;
|
||||
result.channel = await DatabaseManager.Channels.UpdateChannel(youtubeChannel.Id, youtubeChannel.Name, youtubeChannel.Subscribers,
|
||||
youtubeChannel.Avatars.First().Url);
|
||||
|
||||
if (user.PasswordHash == "local_account")
|
||||
{
|
||||
LTChannel ltChannel = await DatabaseManager.Channels.UpdateChannel(youtubeChannel.Id, youtubeChannel.Name, youtubeChannel.Subscribers,
|
||||
youtubeChannel.Avatars.First().Url);
|
||||
if (user.SubscribedChannels.Contains(ltChannel.ChannelId))
|
||||
user.SubscribedChannels.Remove(ltChannel.ChannelId);
|
||||
else
|
||||
user.SubscribedChannels.Add(ltChannel.ChannelId);
|
||||
|
||||
HttpContext.Response.Cookies.Append("account_data", JsonConvert.SerializeObject(user),
|
||||
new CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.MaxValue
|
||||
});
|
||||
|
||||
result.subscribed = user.SubscribedChannels.Contains(ltChannel.ChannelId);
|
||||
}
|
||||
else
|
||||
{
|
||||
result =
|
||||
await DatabaseManager.Logins.SubscribeToChannel(user, youtubeChannel);
|
||||
}
|
||||
|
||||
return Ok(result.subscribed ? "true" : "false");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
public IActionResult SubscriptionsJson()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
return Json(Array.Empty<string>());
|
||||
try
|
||||
{
|
||||
return Json(user.SubscribedChannels);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Json(Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Settings()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
Redirect("/Account/Login");
|
||||
|
||||
if (Request.Method == "POST")
|
||||
{
|
||||
CookieOptions opts = new()
|
||||
{
|
||||
Expires = DateTimeOffset.MaxValue
|
||||
};
|
||||
foreach ((string key, StringValues value) in Request.Form)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "theme":
|
||||
Response.Cookies.Append("theme", value, opts);
|
||||
break;
|
||||
case "hl":
|
||||
Response.Cookies.Append("hl", value, opts);
|
||||
break;
|
||||
case "gl":
|
||||
Response.Cookies.Append("gl", value, opts);
|
||||
break;
|
||||
case "compatibility":
|
||||
Response.Cookies.Append("compatibility", value, opts);
|
||||
break;
|
||||
case "api-access":
|
||||
await DatabaseManager.Logins.SetApiAccess(user, bool.Parse(value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Redirect("/Account");
|
||||
}
|
||||
|
||||
YoutubeLocals locals = await _youtube.GetLocalsAsync();
|
||||
|
||||
Request.Cookies.TryGetValue("theme", out string theme);
|
||||
|
||||
bool compatibility = false;
|
||||
if (Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||
bool.TryParse(compatibilityString, out compatibility);
|
||||
|
||||
return View(new SettingsContext
|
||||
{
|
||||
Languages = locals.Languages,
|
||||
Regions = locals.Regions,
|
||||
CurrentLanguage = HttpContext.GetLanguage(),
|
||||
CurrentRegion = HttpContext.GetRegion(),
|
||||
MobileLayout = Utils.IsClientMobile(Request),
|
||||
Theme = theme ?? "light",
|
||||
CompatibilityMode = compatibility,
|
||||
ApiAccess = user.ApiAccess
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IActionResult> AddVideoToPlaylist(string v)
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
Redirect("/Account/Login");
|
||||
|
||||
JObject ytPlayer = await InnerTube.Utils.GetAuthorizedPlayer(v, new HttpClient());
|
||||
return View(new AddToPlaylistContext
|
||||
{
|
||||
Id = v,
|
||||
Video = await _youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||
Playlists = await DatabaseManager.Playlists.GetUserPlaylists(user.UserID),
|
||||
Thumbnail = ytPlayer?["videoDetails"]?["thumbnail"]?["thumbnails"]?[0]?["url"]?.ToString() ?? $"https://i.ytimg.com/vi_webp/{v}/maxresdefault.webp",
|
||||
MobileLayout = Utils.IsClientMobile(Request),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult CreatePlaylist(string returnUrl = null)
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
Redirect("/Account/Login");
|
||||
|
||||
return View(new BaseContext
|
||||
{
|
||||
MobileLayout = Utils.IsClientMobile(Request),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreatePlaylist()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
Redirect("/Account/Login");
|
||||
|
||||
if (!Request.Form.ContainsKey("name") || string.IsNullOrWhiteSpace(Request.Form["name"])) return BadRequest();
|
||||
|
||||
LTPlaylist pl = await DatabaseManager.Playlists.CreatePlaylist(
|
||||
user,
|
||||
Request.Form["name"],
|
||||
string.IsNullOrWhiteSpace(Request.Form["description"]) ? "" : Request.Form["description"],
|
||||
Enum.Parse<PlaylistVisibility>(string.IsNullOrWhiteSpace(Request.Form["visibility"]) ? "UNLISTED" : Request.Form["visibility"]));
|
||||
|
||||
return Redirect($"/playlist?list={pl.Id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
187
core/LightTube/Controllers/ApiController.cs
Normal file
187
core/LightTube/Controllers/ApiController.cs
Normal file
@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using InnerTube;
|
||||
using InnerTube.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
[Route("/api")]
|
||||
public class ApiController : Controller
|
||||
{
|
||||
private const string VideoIdRegex = @"[a-zA-Z0-9_-]{11}";
|
||||
private const string ChannelIdRegex = @"[a-zA-Z0-9_-]{24}";
|
||||
private const string PlaylistIdRegex = @"[a-zA-Z0-9_-]{34}";
|
||||
private readonly Youtube _youtube;
|
||||
|
||||
public ApiController(Youtube youtube)
|
||||
{
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
private IActionResult Xml(XmlNode xmlDocument)
|
||||
{
|
||||
MemoryStream ms = new();
|
||||
ms.Write(Encoding.UTF8.GetBytes(xmlDocument.OuterXml));
|
||||
ms.Position = 0;
|
||||
HttpContext.Response.Headers.Add("Access-Control-Allow-Origin", "*");
|
||||
return File(ms, "application/xml");
|
||||
}
|
||||
|
||||
[Route("player")]
|
||||
public async Task<IActionResult> GetPlayerInfo(string v)
|
||||
{
|
||||
if (v is null)
|
||||
return GetErrorVideoPlayer("", "Missing YouTube ID (query parameter `v`)");
|
||||
|
||||
Regex regex = new(VideoIdRegex);
|
||||
if (!regex.IsMatch(v) || v.Length != 11)
|
||||
return GetErrorVideoPlayer(v, "Invalid YouTube ID " + v);
|
||||
|
||||
try
|
||||
{
|
||||
YoutubePlayer player =
|
||||
await _youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||
XmlDocument xml = player.GetXmlDocument();
|
||||
return Xml(xml);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return GetErrorVideoPlayer(v, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult GetErrorVideoPlayer(string videoId, string message)
|
||||
{
|
||||
YoutubePlayer player = new()
|
||||
{
|
||||
Id = videoId,
|
||||
Title = "",
|
||||
Description = "",
|
||||
Tags = Array.Empty<string>(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = "",
|
||||
Id = "",
|
||||
Avatars = Array.Empty<Thumbnail>()
|
||||
},
|
||||
Duration = 0,
|
||||
Chapters = Array.Empty<Chapter>(),
|
||||
Thumbnails = Array.Empty<Thumbnail>(),
|
||||
Formats = Array.Empty<Format>(),
|
||||
AdaptiveFormats = Array.Empty<Format>(),
|
||||
Subtitles = Array.Empty<Subtitle>(),
|
||||
Storyboards = Array.Empty<string>(),
|
||||
ExpiresInSeconds = "0",
|
||||
ErrorMessage = message
|
||||
};
|
||||
return Xml(player.GetXmlDocument());
|
||||
}
|
||||
|
||||
[Route("video")]
|
||||
public async Task<IActionResult> GetVideoInfo(string v)
|
||||
{
|
||||
if (v is null)
|
||||
return GetErrorVideoPlayer("", "Missing YouTube ID (query parameter `v`)");
|
||||
|
||||
Regex regex = new(VideoIdRegex);
|
||||
if (!regex.IsMatch(v) || v.Length != 11)
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement item = doc.CreateElement("Error");
|
||||
|
||||
item.InnerText = "Invalid YouTube ID " + v;
|
||||
|
||||
doc.AppendChild(item);
|
||||
return Xml(doc);
|
||||
}
|
||||
|
||||
YoutubeVideo player = await _youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||
XmlDocument xml = player.GetXmlDocument();
|
||||
return Xml(xml);
|
||||
}
|
||||
|
||||
[Route("search")]
|
||||
public async Task<IActionResult> Search(string query, string continuation = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(continuation))
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement item = doc.CreateElement("Error");
|
||||
|
||||
item.InnerText = "Invalid query " + query;
|
||||
|
||||
doc.AppendChild(item);
|
||||
return Xml(doc);
|
||||
}
|
||||
|
||||
YoutubeSearchResults player = await _youtube.SearchAsync(query, continuation, HttpContext.GetLanguage(),
|
||||
HttpContext.GetRegion());
|
||||
XmlDocument xml = player.GetXmlDocument();
|
||||
return Xml(xml);
|
||||
}
|
||||
|
||||
[Route("playlist")]
|
||||
public async Task<IActionResult> Playlist(string id, string continuation = null)
|
||||
{
|
||||
Regex regex = new(PlaylistIdRegex);
|
||||
if (!regex.IsMatch(id) || id.Length != 34) return GetErrorVideoPlayer(id, "Invalid playlist ID " + id);
|
||||
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(continuation))
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement item = doc.CreateElement("Error");
|
||||
|
||||
item.InnerText = "Invalid ID " + id;
|
||||
|
||||
doc.AppendChild(item);
|
||||
return Xml(doc);
|
||||
}
|
||||
|
||||
YoutubePlaylist player = await _youtube.GetPlaylistAsync(id, continuation, HttpContext.GetLanguage(),
|
||||
HttpContext.GetRegion());
|
||||
XmlDocument xml = player.GetXmlDocument();
|
||||
return Xml(xml);
|
||||
}
|
||||
|
||||
[Route("channel")]
|
||||
public async Task<IActionResult> Channel(string id, ChannelTabs tab = ChannelTabs.Home,
|
||||
string continuation = null)
|
||||
{
|
||||
Regex regex = new(ChannelIdRegex);
|
||||
if (!regex.IsMatch(id) || id.Length != 24) return GetErrorVideoPlayer(id, "Invalid channel ID " + id);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(continuation))
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement item = doc.CreateElement("Error");
|
||||
|
||||
item.InnerText = "Invalid ID " + id;
|
||||
|
||||
doc.AppendChild(item);
|
||||
return Xml(doc);
|
||||
}
|
||||
|
||||
YoutubeChannel player = await _youtube.GetChannelAsync(id, tab, continuation, HttpContext.GetLanguage(),
|
||||
HttpContext.GetRegion());
|
||||
XmlDocument xml = player.GetXmlDocument();
|
||||
return Xml(xml);
|
||||
}
|
||||
|
||||
[Route("trending")]
|
||||
public async Task<IActionResult> Trending(string id, string continuation = null)
|
||||
{
|
||||
YoutubeTrends player = await _youtube.GetExploreAsync(id, continuation,
|
||||
HttpContext.GetLanguage(),
|
||||
HttpContext.GetRegion());
|
||||
XmlDocument xml = player.GetXmlDocument();
|
||||
return Xml(xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
189
core/LightTube/Controllers/AuthorizedApiController.cs
Normal file
189
core/LightTube/Controllers/AuthorizedApiController.cs
Normal file
@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using InnerTube;
|
||||
using InnerTube.Models;
|
||||
using LightTube.Database;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
[Route("/api/auth")]
|
||||
public class AuthorizedApiController : Controller
|
||||
{
|
||||
private readonly Youtube _youtube;
|
||||
|
||||
private IReadOnlyList<string> _scopes = new[]
|
||||
{
|
||||
"api.subscriptions.read",
|
||||
"api.subscriptions.write"
|
||||
};
|
||||
|
||||
public AuthorizedApiController(Youtube youtube)
|
||||
{
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
private IActionResult Xml(XmlNode xmlDocument, HttpStatusCode statusCode)
|
||||
{
|
||||
MemoryStream ms = new();
|
||||
ms.Write(Encoding.UTF8.GetBytes(xmlDocument.OuterXml));
|
||||
ms.Position = 0;
|
||||
HttpContext.Response.Headers.Add("Access-Control-Allow-Origin", "*");
|
||||
Response.StatusCode = (int)statusCode;
|
||||
return File(ms, "application/xml");
|
||||
}
|
||||
|
||||
private XmlNode BuildErrorXml(string message)
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement error = doc.CreateElement("Error");
|
||||
error.InnerText = message;
|
||||
doc.AppendChild(error);
|
||||
return doc;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("getToken")]
|
||||
public async Task<IActionResult> GetToken()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("User-Agent", out StringValues userAgent))
|
||||
return Xml(BuildErrorXml("Missing User-Agent header"), HttpStatusCode.BadRequest);
|
||||
|
||||
Match match = Regex.Match(userAgent.ToString(), DatabaseManager.ApiUaRegex);
|
||||
if (!match.Success)
|
||||
return Xml(BuildErrorXml("Bad User-Agent header. Please see 'Documentation/API requests'"), HttpStatusCode.BadRequest);
|
||||
if (match.Groups[1].ToString() != "1.0")
|
||||
return Xml(BuildErrorXml($"Unknown API version {match.Groups[1]}"), HttpStatusCode.BadRequest);
|
||||
|
||||
if (!Request.Form.TryGetValue("user", out StringValues user))
|
||||
return Xml(BuildErrorXml("Missing request value: 'user'"), HttpStatusCode.BadRequest);
|
||||
if (!Request.Form.TryGetValue("password", out StringValues password))
|
||||
return Xml(BuildErrorXml("Missing request value: 'password'"), HttpStatusCode.BadRequest);
|
||||
if (!Request.Form.TryGetValue("scopes", out StringValues scopes))
|
||||
return Xml(BuildErrorXml("Missing request value: 'scopes'"), HttpStatusCode.BadRequest);
|
||||
|
||||
string[] newScopes = scopes.First().Split(",");
|
||||
foreach (string s in newScopes)
|
||||
if (!_scopes.Contains(s))
|
||||
return Xml(BuildErrorXml($"Unknown scope '{s}'"), HttpStatusCode.BadRequest);
|
||||
|
||||
try
|
||||
{
|
||||
LTLogin ltLogin =
|
||||
await DatabaseManager.Logins.CreateToken(user, password, userAgent.ToString(),
|
||||
scopes.First().Split(","));
|
||||
return Xml(ltLogin.GetXmlElement(), HttpStatusCode.Created);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Xml(BuildErrorXml("Invalid credentials"), HttpStatusCode.Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return Xml(BuildErrorXml("User has API access disabled"), HttpStatusCode.Forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
[Route("subscriptions/feed")]
|
||||
public async Task<IActionResult> SubscriptionsFeed()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "api.subscriptions.read"))
|
||||
return Xml(BuildErrorXml("Unauthorized"), HttpStatusCode.Unauthorized);
|
||||
|
||||
SubscriptionFeed feed = new()
|
||||
{
|
||||
videos = await YoutubeRSS.GetMultipleFeeds(user.SubscribedChannels)
|
||||
};
|
||||
|
||||
return Xml(feed.GetXmlDocument(), HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("subscriptions/channels")]
|
||||
public IActionResult SubscriptionsChannels()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "api.subscriptions.read"))
|
||||
return Xml(BuildErrorXml("Unauthorized"), HttpStatusCode.Unauthorized);
|
||||
|
||||
SubscriptionChannels feed = new()
|
||||
{
|
||||
Channels = user.SubscribedChannels.Select(DatabaseManager.Channels.GetChannel).ToArray()
|
||||
};
|
||||
Array.Sort(feed.Channels, (p, q) => string.Compare(p.Name, q.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return Xml(feed.GetXmlDocument(), HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[Route("subscriptions/channels")]
|
||||
public async Task<IActionResult> Subscribe()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "api.subscriptions.write"))
|
||||
return Xml(BuildErrorXml("Unauthorized"), HttpStatusCode.Unauthorized);
|
||||
|
||||
Request.Form.TryGetValue("id", out StringValues ids);
|
||||
string id = ids.ToString();
|
||||
|
||||
if (user.SubscribedChannels.Contains(id))
|
||||
return StatusCode((int)HttpStatusCode.NotModified);
|
||||
|
||||
try
|
||||
{
|
||||
YoutubeChannel channel = await _youtube.GetChannelAsync(id);
|
||||
|
||||
if (channel.Id is null)
|
||||
return StatusCode((int)HttpStatusCode.NotFound);
|
||||
|
||||
(LTChannel ltChannel, bool _) = await DatabaseManager.Logins.SubscribeToChannel(user, channel);
|
||||
|
||||
XmlDocument doc = new();
|
||||
doc.AppendChild(ltChannel.GetXmlElement(doc));
|
||||
return Xml(doc, HttpStatusCode.OK);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Xml(BuildErrorXml(e.Message), HttpStatusCode.InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
[Route("subscriptions/channels")]
|
||||
public async Task<IActionResult> Unsubscribe()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "api.subscriptions.write"))
|
||||
return Xml(BuildErrorXml("Unauthorized"), HttpStatusCode.Unauthorized);
|
||||
|
||||
Request.Form.TryGetValue("id", out StringValues ids);
|
||||
string id = ids.ToString();
|
||||
|
||||
if (!user.SubscribedChannels.Contains(id))
|
||||
return StatusCode((int)HttpStatusCode.NotModified);
|
||||
|
||||
try
|
||||
{
|
||||
YoutubeChannel channel = await _youtube.GetChannelAsync(id);
|
||||
|
||||
if (channel.Id is null)
|
||||
return StatusCode((int)HttpStatusCode.NotFound);
|
||||
|
||||
(LTChannel ltChannel, bool _) = await DatabaseManager.Logins.SubscribeToChannel(user, channel);
|
||||
|
||||
XmlDocument doc = new();
|
||||
doc.AppendChild(ltChannel.GetXmlElement(doc));
|
||||
return Xml(doc, HttpStatusCode.OK);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Xml(BuildErrorXml(e.Message), HttpStatusCode.InternalServerError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
core/LightTube/Controllers/FeedController.cs
Normal file
104
core/LightTube/Controllers/FeedController.cs
Normal file
@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using LightTube.Contexts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using InnerTube;
|
||||
using LightTube.Database;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
[Route("/feed")]
|
||||
public class FeedController : Controller
|
||||
{
|
||||
private readonly ILogger<FeedController> _logger;
|
||||
private readonly Youtube _youtube;
|
||||
|
||||
public FeedController(ILogger<FeedController> logger, Youtube youtube)
|
||||
{
|
||||
_logger = logger;
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
[Route("subscriptions")]
|
||||
public async Task<IActionResult> Subscriptions()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
return Redirect("/Account/Login");
|
||||
|
||||
try
|
||||
{
|
||||
FeedContext context = new()
|
||||
{
|
||||
Channels = user.SubscribedChannels.Select(DatabaseManager.Channels.GetChannel).ToArray(),
|
||||
Videos = await YoutubeRSS.GetMultipleFeeds(user.SubscribedChannels),
|
||||
RssToken = user.RssToken,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
};
|
||||
Array.Sort(context.Channels, (p, q) => string.Compare(p.Name, q.Name, StringComparison.OrdinalIgnoreCase));
|
||||
return View(context);
|
||||
}
|
||||
catch
|
||||
{
|
||||
HttpContext.Response.Cookies.Delete("token");
|
||||
return Redirect("/Account/Login");
|
||||
}
|
||||
}
|
||||
|
||||
[Route("channels")]
|
||||
public IActionResult Channels()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
return Redirect("/Account/Login");
|
||||
|
||||
try
|
||||
{
|
||||
FeedContext context = new()
|
||||
{
|
||||
Channels = user.SubscribedChannels.Select(DatabaseManager.Channels.GetChannel).ToArray(),
|
||||
Videos = null,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
};
|
||||
Array.Sort(context.Channels, (p, q) => string.Compare(p.Name, q.Name, StringComparison.OrdinalIgnoreCase));
|
||||
return View(context);
|
||||
}
|
||||
catch
|
||||
{
|
||||
HttpContext.Response.Cookies.Delete("token");
|
||||
return Redirect("/Account/Login");
|
||||
}
|
||||
}
|
||||
|
||||
[Route("explore")]
|
||||
public IActionResult Explore()
|
||||
{
|
||||
return View(new BaseContext
|
||||
{
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
});
|
||||
}
|
||||
|
||||
[Route("/feed/library")]
|
||||
public async Task<IActionResult> Playlists()
|
||||
{
|
||||
if (!HttpContext.TryGetUser(out LTUser user, "web"))
|
||||
Redirect("/Account/Login");
|
||||
|
||||
return View(new PlaylistsContext
|
||||
{
|
||||
MobileLayout = Utils.IsClientMobile(Request),
|
||||
Playlists = await DatabaseManager.Playlists.GetUserPlaylists(user.UserID)
|
||||
});
|
||||
}
|
||||
|
||||
[Route("/rss")]
|
||||
public async Task<IActionResult> Playlists(string token, int limit = 15)
|
||||
{
|
||||
if (!DatabaseManager.TryGetRssUser(token, out LTUser user))
|
||||
return Unauthorized();
|
||||
return File(Encoding.UTF8.GetBytes(await user.GenerateRssFeed(Request.Host.ToString(), Math.Clamp(limit, 0, 50))), "application/xml");
|
||||
}
|
||||
}
|
||||
}
|
||||
50
core/LightTube/Controllers/HomeController.cs
Normal file
50
core/LightTube/Controllers/HomeController.cs
Normal file
@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using LightTube.Contexts;
|
||||
using LightTube.Models;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using InnerTube;
|
||||
using InnerTube.Models;
|
||||
using ErrorContext = LightTube.Contexts.ErrorContext;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
public class HomeController : Controller
|
||||
{
|
||||
private readonly ILogger<HomeController> _logger;
|
||||
private readonly Youtube _youtube;
|
||||
|
||||
public HomeController(ILogger<HomeController> logger, Youtube youtube)
|
||||
{
|
||||
_logger = logger;
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View(new BaseContext
|
||||
{
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
});
|
||||
}
|
||||
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
public IActionResult Error()
|
||||
{
|
||||
return View(new ErrorContext
|
||||
{
|
||||
Path = HttpContext.Features.Get<IExceptionHandlerPathFeature>().Path,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
62
core/LightTube/Controllers/ManifestController.cs
Normal file
62
core/LightTube/Controllers/ManifestController.cs
Normal file
@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using InnerTube;
|
||||
using InnerTube.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
[Route("/manifest")]
|
||||
public class ManifestController : Controller
|
||||
{
|
||||
private readonly Youtube _youtube;
|
||||
private readonly HttpClient _client = new();
|
||||
|
||||
public ManifestController(Youtube youtube)
|
||||
{
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
[Route("{v}")]
|
||||
public async Task<IActionResult> DefaultManifest(string v)
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||
return StatusCode(500, player.ErrorMessage);
|
||||
return Redirect(player.IsLive ? $"/manifest/{v}.m3u8" : $"/manifest/{v}.mpd" + Request.QueryString);
|
||||
}
|
||||
|
||||
[Route("{v}.mpd")]
|
||||
public async Task<IActionResult> DashManifest(string v, string videoCodec = null, string audioCodec = null, bool useProxy = true)
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||
string manifest = player.GetMpdManifest(useProxy ? $"https://{Request.Host}/proxy/" : null, videoCodec, audioCodec);
|
||||
return File(Encoding.UTF8.GetBytes(manifest), "application/dash+xml");
|
||||
}
|
||||
|
||||
[Route("{v}.m3u8")]
|
||||
public async Task<IActionResult> HlsManifest(string v, bool useProxy = true)
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion(), true);
|
||||
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||
return StatusCode(403, player.ErrorMessage);
|
||||
|
||||
if (player.IsLive)
|
||||
{
|
||||
string manifest = await player.GetHlsManifest(useProxy ? $"https://{Request.Host}/proxy" : null);
|
||||
return File(Encoding.UTF8.GetBytes(manifest), "application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
if (useProxy)
|
||||
return StatusCode(400, "HLS proxy for non-live videos are not supported at the moment.");
|
||||
return Redirect(player.HlsManifestUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
517
core/LightTube/Controllers/ProxyController.cs
Normal file
517
core/LightTube/Controllers/ProxyController.cs
Normal file
@ -0,0 +1,517 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using InnerTube;
|
||||
using InnerTube.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
[Route("/proxy")]
|
||||
public class ProxyController : Controller
|
||||
{
|
||||
private readonly ILogger<YoutubeController> _logger;
|
||||
private readonly Youtube _youtube;
|
||||
private string[] BlockedHeaders =
|
||||
{
|
||||
"host",
|
||||
"cookies"
|
||||
};
|
||||
|
||||
public ProxyController(ILogger<YoutubeController> logger, Youtube youtube)
|
||||
{
|
||||
_logger = logger;
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
[Route("media/{videoId}/{formatId}")]
|
||||
public async Task Media(string videoId, string formatId)
|
||||
{
|
||||
try
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(player.ErrorMessage));
|
||||
await Response.StartAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
List<Format> formats = new();
|
||||
|
||||
formats.AddRange(player.Formats);
|
||||
formats.AddRange(player.AdaptiveFormats);
|
||||
|
||||
if (!formats.Any(x => x.FormatId == formatId))
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(
|
||||
$"Format with ID {formatId} not found.\nAvailable IDs are: {string.Join(", ", formats.Select(x => x.FormatId.ToString()))}"));
|
||||
await Response.StartAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
string url = formats.First(x => x.FormatId == formatId).Url;
|
||||
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest) WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
request.Method = Request.Method;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
HttpWebResponse response;
|
||||
|
||||
try
|
||||
{
|
||||
response = (HttpWebResponse) request.GetResponse();
|
||||
}
|
||||
catch (WebException e)
|
||||
{
|
||||
response = e.Response as HttpWebResponse;
|
||||
}
|
||||
|
||||
if (response == null)
|
||||
await Response.StartAsync();
|
||||
|
||||
foreach (string header in response.Headers.AllKeys)
|
||||
if (Response.Headers.ContainsKey(header))
|
||||
Response.Headers[header] = response.Headers.Get(header);
|
||||
else
|
||||
Response.Headers.Add(header, response.Headers.Get(header));
|
||||
Response.StatusCode = (int) response.StatusCode;
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
try
|
||||
{
|
||||
await stream.CopyToAsync(Response.Body, HttpContext.RequestAborted);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// an exception is thrown if the client suddenly stops streaming
|
||||
}
|
||||
|
||||
await Response.StartAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(e.ToString()));
|
||||
await Response.StartAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Route("download/{videoId}/{formatId}/{filename}")]
|
||||
public async Task Download(string videoId, string formatId, string filename)
|
||||
{
|
||||
try
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(player.ErrorMessage));
|
||||
await Response.StartAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
List<Format> formats = new();
|
||||
|
||||
formats.AddRange(player.Formats);
|
||||
formats.AddRange(player.AdaptiveFormats);
|
||||
|
||||
if (!formats.Any(x => x.FormatId == formatId))
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(
|
||||
$"Format with ID {formatId} not found.\nAvailable IDs are: {string.Join(", ", formats.Select(x => x.FormatId.ToString()))}"));
|
||||
await Response.StartAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
string url = formats.First(x => x.FormatId == formatId).Url;
|
||||
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest) WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
request.Method = Request.Method;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
HttpWebResponse response;
|
||||
|
||||
try
|
||||
{
|
||||
response = (HttpWebResponse) request.GetResponse();
|
||||
}
|
||||
catch (WebException e)
|
||||
{
|
||||
response = e.Response as HttpWebResponse;
|
||||
}
|
||||
|
||||
if (response == null)
|
||||
await Response.StartAsync();
|
||||
|
||||
foreach (string header in response.Headers.AllKeys)
|
||||
if (Response.Headers.ContainsKey(header))
|
||||
Response.Headers[header] = response.Headers.Get(header);
|
||||
else
|
||||
Response.Headers.Add(header, response.Headers.Get(header));
|
||||
Response.Headers.Add("Content-Disposition", $"attachment; filename=\"{Regex.Replace(filename, @"[^\u0000-\u007F]+", string.Empty)}\"");
|
||||
Response.StatusCode = (int) response.StatusCode;
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
try
|
||||
{
|
||||
await stream.CopyToAsync(Response.Body, HttpContext.RequestAborted);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// an exception is thrown if the client suddenly stops streaming
|
||||
}
|
||||
|
||||
await Response.StartAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(e.ToString()));
|
||||
await Response.StartAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Route("caption/{videoId}/{language}")]
|
||||
public async Task<FileStreamResult> SubtitleProxy(string videoId, string language)
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||
return File(new MemoryStream(Encoding.UTF8.GetBytes(player.ErrorMessage)),
|
||||
"text/plain");
|
||||
}
|
||||
|
||||
string url = null;
|
||||
Subtitle? subtitle = player.Subtitles.FirstOrDefault(x => string.Equals(x.Language, language, StringComparison.InvariantCultureIgnoreCase));
|
||||
if (subtitle is null)
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||
return File(
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(
|
||||
$"There are no available subtitles for {language}. Available language codes are: {string.Join(", ", player.Subtitles.Select(x => $"\"{x.Language}\""))}")),
|
||||
"text/plain");
|
||||
}
|
||||
url = subtitle.Url.Replace("fmt=srv3", "fmt=vtt");
|
||||
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
using StreamReader reader = new(stream);
|
||||
|
||||
return File(new MemoryStream(Encoding.UTF8.GetBytes(await reader.ReadToEndAsync())),
|
||||
"text/vtt");
|
||||
}
|
||||
|
||||
[Route("image")]
|
||||
[Obsolete("Use /proxy/thumbnail instead")]
|
||||
public async Task ImageProxy(string url)
|
||||
{
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||
|
||||
foreach (string header in response.Headers.AllKeys)
|
||||
if (Response.Headers.ContainsKey(header))
|
||||
Response.Headers[header] = response.Headers.Get(header);
|
||||
else
|
||||
Response.Headers.Add(header, response.Headers.Get(header));
|
||||
Response.StatusCode = (int)response.StatusCode;
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
await stream.CopyToAsync(Response.Body);
|
||||
await Response.StartAsync();
|
||||
}
|
||||
|
||||
[Route("thumbnail/{videoId}/{index:int}")]
|
||||
public async Task ThumbnailProxy(string videoId, int index = 0)
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||
if (index == -1) index = player.Thumbnails.Length - 1;
|
||||
if (index >= player.Thumbnails.Length)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(
|
||||
$"Cannot find thumbnail #{index} for {videoId}. The maximum quality is {player.Thumbnails.Length - 1}"));
|
||||
await Response.StartAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
string url = player.Thumbnails.FirstOrDefault()?.Url;
|
||||
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||
|
||||
foreach (string header in response.Headers.AllKeys)
|
||||
if (Response.Headers.ContainsKey(header))
|
||||
Response.Headers[header] = response.Headers.Get(header);
|
||||
else
|
||||
Response.Headers.Add(header, response.Headers.Get(header));
|
||||
Response.StatusCode = (int)response.StatusCode;
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
await stream.CopyToAsync(Response.Body);
|
||||
await Response.StartAsync();
|
||||
}
|
||||
|
||||
[Route("storyboard/{videoId}")]
|
||||
public async Task StoryboardProxy(string videoId)
|
||||
{
|
||||
try
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId);
|
||||
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(player.ErrorMessage));
|
||||
await Response.StartAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!player.Storyboards.Any())
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes("No usable storyboard found."));
|
||||
await Response.StartAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
string url = player.Storyboards.First();
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||
|
||||
foreach (string header in response.Headers.AllKeys)
|
||||
if (Response.Headers.ContainsKey(header))
|
||||
Response.Headers[header] = response.Headers.Get(header);
|
||||
else
|
||||
Response.Headers.Add(header, response.Headers.Get(header));
|
||||
Response.StatusCode = (int)response.StatusCode;
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
await stream.CopyToAsync(Response.Body);
|
||||
await Response.StartAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||
await Response.Body.WriteAsync(Encoding.UTF8.GetBytes(e.ToString()));
|
||||
await Response.StartAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Route("hls")]
|
||||
public async Task<IActionResult> HlsProxy(string url)
|
||||
{
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
using StreamReader reader = new(stream);
|
||||
string manifest = await reader.ReadToEndAsync();
|
||||
StringBuilder proxyManifest = new ();
|
||||
|
||||
foreach (string s in manifest.Split("\n"))
|
||||
{
|
||||
// also check if proxy enabled
|
||||
proxyManifest.AppendLine(!s.StartsWith("http")
|
||||
? s
|
||||
: $"https://{Request.Host}/proxy/video?url={HttpUtility.UrlEncode(s)}");
|
||||
}
|
||||
|
||||
return File(new MemoryStream(Encoding.UTF8.GetBytes(proxyManifest.ToString())),
|
||||
"application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
[Route("manifest/{videoId}")]
|
||||
public async Task<IActionResult> ManifestProxy(string videoId, string formatId, bool useProxy = true)
|
||||
{
|
||||
YoutubePlayer player = await _youtube.GetPlayerAsync(videoId, iOS: true);
|
||||
if (!string.IsNullOrWhiteSpace(player.ErrorMessage))
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.InternalServerError;
|
||||
return File(new MemoryStream(Encoding.UTF8.GetBytes(player.ErrorMessage)),
|
||||
"text/plain");
|
||||
}
|
||||
|
||||
if (player.HlsManifestUrl == null)
|
||||
{
|
||||
Response.StatusCode = (int) HttpStatusCode.NotFound;
|
||||
return File(new MemoryStream(Encoding.UTF8.GetBytes("This video does not have an HLS manifest URL")),
|
||||
"text/plain");
|
||||
}
|
||||
|
||||
string url = player.HlsManifestUrl;
|
||||
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
using StreamReader reader = new(stream);
|
||||
string manifest = await reader.ReadToEndAsync();
|
||||
StringBuilder proxyManifest = new ();
|
||||
|
||||
if (useProxy)
|
||||
foreach (string s in manifest.Split("\n"))
|
||||
{
|
||||
// also check if proxy enabled
|
||||
proxyManifest.AppendLine(!s.StartsWith("http")
|
||||
? s
|
||||
: $"https://{Request.Host}/proxy/ytmanifest?path=" + HttpUtility.UrlEncode(s[46..]));
|
||||
}
|
||||
else
|
||||
proxyManifest.Append(manifest);
|
||||
|
||||
return File(new MemoryStream(Encoding.UTF8.GetBytes(proxyManifest.ToString())),
|
||||
"application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
[Route("ytmanifest")]
|
||||
public async Task<IActionResult> YoutubeManifestProxy(string path)
|
||||
{
|
||||
string url = "https://manifest.googlevideo.com" + path;
|
||||
StringBuilder sb = new();
|
||||
|
||||
if (!url.StartsWith("http://") && !url.StartsWith("https://"))
|
||||
url = "https://" + url;
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
using StreamReader reader = new(stream);
|
||||
string manifest = await reader.ReadToEndAsync();
|
||||
|
||||
foreach (string line in manifest.Split("\n"))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
sb.AppendLine();
|
||||
else if (line.StartsWith("#"))
|
||||
sb.AppendLine(line);
|
||||
else
|
||||
{
|
||||
Uri u = new(line);
|
||||
sb.AppendLine($"https://{Request.Host}/proxy/videoplayback?host={u.Host}&path={HttpUtility.UrlEncode(u.PathAndQuery)}");
|
||||
}
|
||||
}
|
||||
|
||||
return File(new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())),
|
||||
"application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
[Route("videoplayback")]
|
||||
public async Task VideoPlaybackProxy(string path, string host)
|
||||
{
|
||||
// make sure this is only used in livestreams
|
||||
|
||||
string url = $"https://{host}{path}";
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
|
||||
request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
foreach ((string header, StringValues values) in HttpContext.Request.Headers.Where(header =>
|
||||
!header.Key.StartsWith(":") && !BlockedHeaders.Contains(header.Key.ToLower())))
|
||||
foreach (string value in values)
|
||||
request.Headers.Add(header, value);
|
||||
|
||||
using HttpWebResponse response = (HttpWebResponse)request.GetResponse();
|
||||
|
||||
await using Stream stream = response.GetResponseStream();
|
||||
|
||||
Response.ContentType = "application/octet-stream";
|
||||
await Response.StartAsync();
|
||||
await stream.CopyToAsync(Response.Body, HttpContext.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
core/LightTube/Controllers/TogglesController.cs
Normal file
67
core/LightTube/Controllers/TogglesController.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
[Route("/toggles")]
|
||||
public class TogglesController : Controller
|
||||
{
|
||||
[Route("theme")]
|
||||
public IActionResult ToggleTheme(string redirectUrl)
|
||||
{
|
||||
if (Request.Cookies.TryGetValue("theme", out string theme))
|
||||
Response.Cookies.Append("theme", theme switch
|
||||
{
|
||||
"light" => "dark",
|
||||
"dark" => "light",
|
||||
var _ => "dark"
|
||||
}, new CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.MaxValue
|
||||
});
|
||||
else
|
||||
Response.Cookies.Append("theme", "light");
|
||||
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
[Route("compatibility")]
|
||||
public IActionResult ToggleCompatibility(string redirectUrl)
|
||||
{
|
||||
if (Request.Cookies.TryGetValue("compatibility", out string compatibility))
|
||||
Response.Cookies.Append("compatibility", compatibility switch
|
||||
{
|
||||
"true" => "false",
|
||||
"false" => "true",
|
||||
var _ => "true"
|
||||
}, new CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.MaxValue
|
||||
});
|
||||
else
|
||||
Response.Cookies.Append("compatibility", "true");
|
||||
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
[Route("collapse_guide")]
|
||||
public IActionResult ToggleCollapseGuide(string redirectUrl)
|
||||
{
|
||||
if (Request.Cookies.TryGetValue("minmode", out string minmode))
|
||||
Response.Cookies.Append("minmode", minmode switch
|
||||
{
|
||||
"true" => "false",
|
||||
"false" => "true",
|
||||
var _ => "true"
|
||||
}, new CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.MaxValue
|
||||
});
|
||||
else
|
||||
Response.Cookies.Append("minmode", "true");
|
||||
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
226
core/LightTube/Controllers/YoutubeController.cs
Normal file
226
core/LightTube/Controllers/YoutubeController.cs
Normal file
@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using LightTube.Contexts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using InnerTube;
|
||||
using InnerTube.Models;
|
||||
using LightTube.Database;
|
||||
|
||||
namespace LightTube.Controllers
|
||||
{
|
||||
public class YoutubeController : Controller
|
||||
{
|
||||
private readonly ILogger<YoutubeController> _logger;
|
||||
private readonly Youtube _youtube;
|
||||
|
||||
public YoutubeController(ILogger<YoutubeController> logger, Youtube youtube)
|
||||
{
|
||||
_logger = logger;
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
[Route("/watch")]
|
||||
public async Task<IActionResult> Watch(string v, string quality = null)
|
||||
{
|
||||
Task[] tasks = {
|
||||
_youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||
_youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||
ReturnYouTubeDislike.GetDislikes(v)
|
||||
};
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
bool cookieCompatibility = false;
|
||||
if (Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||
bool.TryParse(compatibilityString, out cookieCompatibility);
|
||||
|
||||
PlayerContext context = new()
|
||||
{
|
||||
Player = (tasks[0] as Task<YoutubePlayer>)?.Result,
|
||||
Video = (tasks[1] as Task<YoutubeVideo>)?.Result,
|
||||
Engagement = (tasks[2] as Task<YoutubeDislikes>)?.Result,
|
||||
Resolution = quality ?? (tasks[0] as Task<YoutubePlayer>)?.Result.Formats.FirstOrDefault(x => x.FormatId != "17")?.FormatNote,
|
||||
MobileLayout = Utils.IsClientMobile(Request),
|
||||
CompatibilityMode = cookieCompatibility
|
||||
};
|
||||
return View(context);
|
||||
}
|
||||
|
||||
[Route("/download")]
|
||||
public async Task<IActionResult> Download(string v)
|
||||
{
|
||||
Task[] tasks = {
|
||||
_youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||
_youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||
ReturnYouTubeDislike.GetDislikes(v)
|
||||
};
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
bool cookieCompatibility = false;
|
||||
if (Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||
bool.TryParse(compatibilityString, out cookieCompatibility);
|
||||
|
||||
PlayerContext context = new()
|
||||
{
|
||||
Player = (tasks[0] as Task<YoutubePlayer>)?.Result,
|
||||
Video = (tasks[1] as Task<YoutubeVideo>)?.Result,
|
||||
Engagement = null,
|
||||
MobileLayout = Utils.IsClientMobile(Request),
|
||||
CompatibilityMode = cookieCompatibility
|
||||
};
|
||||
return View(context);
|
||||
}
|
||||
|
||||
[Route("/embed/{v}")]
|
||||
public async Task<IActionResult> Embed(string v, string quality = null, bool compatibility = false)
|
||||
{
|
||||
Task[] tasks = {
|
||||
_youtube.GetPlayerAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||
_youtube.GetVideoAsync(v, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||
ReturnYouTubeDislike.GetDislikes(v)
|
||||
};
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
catch { }
|
||||
|
||||
|
||||
bool cookieCompatibility = false;
|
||||
if (Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||
bool.TryParse(compatibilityString, out cookieCompatibility);
|
||||
|
||||
PlayerContext context = new()
|
||||
{
|
||||
Player = (tasks[0] as Task<YoutubePlayer>)?.Result,
|
||||
Video = (tasks[1] as Task<YoutubeVideo>)?.Result,
|
||||
Engagement = (tasks[2] as Task<YoutubeDislikes>)?.Result,
|
||||
Resolution = quality ?? (tasks[0] as Task<YoutubePlayer>)?.Result.Formats.FirstOrDefault(x => x.FormatId != "17")?.FormatNote,
|
||||
CompatibilityMode = compatibility || cookieCompatibility,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
};
|
||||
return View(context);
|
||||
}
|
||||
|
||||
[Route("/results")]
|
||||
public async Task<IActionResult> Search(string search_query, string continuation = null)
|
||||
{
|
||||
SearchContext context = new()
|
||||
{
|
||||
Query = search_query,
|
||||
ContinuationKey = continuation,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(search_query))
|
||||
{
|
||||
context.Results = await _youtube.SearchAsync(search_query, continuation, HttpContext.GetLanguage(),
|
||||
HttpContext.GetRegion());
|
||||
Response.Cookies.Append("search_query", search_query);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Results =
|
||||
new YoutubeSearchResults
|
||||
{
|
||||
Refinements = Array.Empty<string>(),
|
||||
EstimatedResults = 0,
|
||||
Results = Array.Empty<DynamicItem>(),
|
||||
ContinuationKey = null
|
||||
};
|
||||
}
|
||||
return View(context);
|
||||
}
|
||||
|
||||
[Route("/playlist")]
|
||||
public async Task<IActionResult> Playlist(string list, string continuation = null, int? delete = null, string add = null, string remove = null)
|
||||
{
|
||||
HttpContext.TryGetUser(out LTUser user, "web");
|
||||
|
||||
YoutubePlaylist pl = list.StartsWith("LT-PL")
|
||||
? await (await DatabaseManager.Playlists.GetPlaylist(list)).ToYoutubePlaylist()
|
||||
: await _youtube.GetPlaylistAsync(list, continuation, HttpContext.GetLanguage(), HttpContext.GetRegion());
|
||||
|
||||
string message = "";
|
||||
|
||||
if (list.StartsWith("LT-PL") && (await DatabaseManager.Playlists.GetPlaylist(list)).Visibility == PlaylistVisibility.PRIVATE && pl.Channel.Name != user?.UserID)
|
||||
pl = new YoutubePlaylist
|
||||
{
|
||||
Id = null,
|
||||
Title = "",
|
||||
Description = "",
|
||||
VideoCount = "",
|
||||
ViewCount = "",
|
||||
LastUpdated = "",
|
||||
Thumbnail = Array.Empty<Thumbnail>(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = "",
|
||||
Id = "",
|
||||
SubscriberCount = "",
|
||||
Avatars = Array.Empty<Thumbnail>()
|
||||
},
|
||||
Videos = Array.Empty<DynamicItem>(),
|
||||
ContinuationKey = null
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pl.Title)) message = "Playlist unavailable";
|
||||
|
||||
if (list.StartsWith("LT-PL") && pl.Channel.Name == user?.UserID)
|
||||
{
|
||||
if (delete != null)
|
||||
{
|
||||
LTVideo removed = await DatabaseManager.Playlists.RemoveVideoFromPlaylist(list, delete.Value);
|
||||
message += $"Removed video '{removed.Title}'";
|
||||
}
|
||||
|
||||
if (add != null)
|
||||
{
|
||||
LTVideo added = await DatabaseManager.Playlists.AddVideoToPlaylist(list, add);
|
||||
message += $"Added video '{added.Title}'";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(remove))
|
||||
{
|
||||
await DatabaseManager.Playlists.DeletePlaylist(list);
|
||||
message = "Playlist deleted";
|
||||
}
|
||||
|
||||
pl = await (await DatabaseManager.Playlists.GetPlaylist(list)).ToYoutubePlaylist();
|
||||
}
|
||||
|
||||
PlaylistContext context = new()
|
||||
{
|
||||
Playlist = pl,
|
||||
Id = list,
|
||||
ContinuationToken = continuation,
|
||||
MobileLayout = Utils.IsClientMobile(Request),
|
||||
Message = message,
|
||||
Editable = list.StartsWith("LT-PL") && pl.Channel.Name == user?.UserID
|
||||
};
|
||||
return View(context);
|
||||
}
|
||||
|
||||
[Route("/channel/{id}")]
|
||||
public async Task<IActionResult> Channel(string id, string continuation = null)
|
||||
{
|
||||
ChannelContext context = new()
|
||||
{
|
||||
Channel = await _youtube.GetChannelAsync(id, ChannelTabs.Videos, continuation, HttpContext.GetLanguage(), HttpContext.GetRegion()),
|
||||
Id = id,
|
||||
ContinuationToken = continuation,
|
||||
MobileLayout = Utils.IsClientMobile(Request)
|
||||
};
|
||||
await DatabaseManager.Channels.UpdateChannel(context.Channel.Id, context.Channel.Name, context.Channel.Subscribers,
|
||||
context.Channel.Avatars.First().Url.ToString());
|
||||
return View(context);
|
||||
}
|
||||
|
||||
[Route("/shorts/{id}")]
|
||||
public IActionResult Shorts(string id)
|
||||
{
|
||||
// yea no fuck shorts
|
||||
return Redirect("/watch?v=" + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
core/LightTube/Database/ChannelManager.cs
Normal file
46
core/LightTube/Database/ChannelManager.cs
Normal file
@ -0,0 +1,46 @@
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
public class ChannelManager
|
||||
{
|
||||
private static IMongoCollection<LTChannel> _channelCacheCollection;
|
||||
|
||||
public ChannelManager(IMongoCollection<LTChannel> channelCacheCollection)
|
||||
{
|
||||
_channelCacheCollection = channelCacheCollection;
|
||||
}
|
||||
|
||||
public LTChannel GetChannel(string id)
|
||||
{
|
||||
LTChannel res = _channelCacheCollection.FindSync(x => x.ChannelId == id).FirstOrDefault();
|
||||
return res ?? new LTChannel
|
||||
{
|
||||
Name = "Unknown Channel",
|
||||
ChannelId = id,
|
||||
IconUrl = "",
|
||||
Subscribers = ""
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LTChannel> UpdateChannel(string id, string name, string subscribers, string iconUrl)
|
||||
{
|
||||
LTChannel channel = new()
|
||||
{
|
||||
ChannelId = id,
|
||||
Name = name,
|
||||
Subscribers = subscribers,
|
||||
IconUrl = iconUrl
|
||||
};
|
||||
if (channel.IconUrl is null && !string.IsNullOrWhiteSpace(GetChannel(id).IconUrl))
|
||||
channel.IconUrl = GetChannel(id).IconUrl;
|
||||
if (await _channelCacheCollection.CountDocumentsAsync(x => x.ChannelId == id) > 0)
|
||||
await _channelCacheCollection.ReplaceOneAsync(x => x.ChannelId == id, channel);
|
||||
else
|
||||
await _channelCacheCollection.InsertOneAsync(channel);
|
||||
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
}
|
||||
154
core/LightTube/Database/DatabaseManager.cs
Normal file
154
core/LightTube/Database/DatabaseManager.cs
Normal file
@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using InnerTube;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using MongoDB.Driver;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
public static class DatabaseManager
|
||||
{
|
||||
public static readonly string ApiUaRegex = "LightTubeApiClient\\/([0-9.]*) ([\\S]+?)\\/([0-9.]*) \\(([\\s\\S]+?)\\)";
|
||||
|
||||
private static IMongoCollection<LTUser> _userCollection;
|
||||
private static IMongoCollection<LTLogin> _tokenCollection;
|
||||
private static IMongoCollection<LTChannel> _channelCacheCollection;
|
||||
private static IMongoCollection<LTPlaylist> _playlistCollection;
|
||||
private static IMongoCollection<LTVideo> _videoCacheCollection;
|
||||
public static LoginManager Logins { get; private set; }
|
||||
public static ChannelManager Channels { get; private set; }
|
||||
public static PlaylistManager Playlists { get; private set; }
|
||||
|
||||
public static void Init(string connstr, Youtube youtube)
|
||||
{
|
||||
MongoClient client = new(connstr);
|
||||
IMongoDatabase database = client.GetDatabase("lighttube");
|
||||
_userCollection = database.GetCollection<LTUser>("users");
|
||||
_tokenCollection = database.GetCollection<LTLogin>("tokens");
|
||||
_playlistCollection = database.GetCollection<LTPlaylist>("playlists");
|
||||
_channelCacheCollection = database.GetCollection<LTChannel>("channelCache");
|
||||
_videoCacheCollection = database.GetCollection<LTVideo>("videoCache");
|
||||
Logins = new LoginManager(_userCollection, _tokenCollection);
|
||||
Channels = new ChannelManager(_channelCacheCollection);
|
||||
Playlists = new PlaylistManager(_userCollection, _playlistCollection, _videoCacheCollection, youtube);
|
||||
}
|
||||
|
||||
public static void CreateLocalAccount(this HttpContext context)
|
||||
{
|
||||
bool accountExists = false;
|
||||
|
||||
// Check local account
|
||||
if (context.Request.Cookies.TryGetValue("account_data", out string accountJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (accountJson != null)
|
||||
{
|
||||
LTUser tempUser = JsonConvert.DeserializeObject<LTUser>(accountJson) ?? new LTUser();
|
||||
if (tempUser.UserID == "Local Account" && tempUser.PasswordHash == "local_account")
|
||||
accountExists = true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Account already exists, just leave it there
|
||||
if (accountExists) return;
|
||||
|
||||
LTUser user = new()
|
||||
{
|
||||
UserID = "Local Account",
|
||||
PasswordHash = "local_account",
|
||||
SubscribedChannels = new List<string>()
|
||||
};
|
||||
|
||||
context.Response.Cookies.Append("account_data", JsonConvert.SerializeObject(user), new CookieOptions
|
||||
{
|
||||
Expires = DateTimeOffset.MaxValue
|
||||
});
|
||||
}
|
||||
|
||||
public static bool TryGetUser(this HttpContext context, out LTUser user, string requiredScope)
|
||||
{
|
||||
// Check local account
|
||||
if (context.Request.Cookies.TryGetValue("account_data", out string accountJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (accountJson != null)
|
||||
{
|
||||
LTUser tempUser = JsonConvert.DeserializeObject<LTUser>(accountJson) ?? new LTUser();
|
||||
if (tempUser.UserID == "Local Account" && tempUser.PasswordHash == "local_account")
|
||||
{
|
||||
user = tempUser;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cloud account
|
||||
if (!context.Request.Cookies.TryGetValue("token", out string token))
|
||||
if (context.Request.Headers.TryGetValue("Authorization", out StringValues tokens))
|
||||
token = tokens.ToString();
|
||||
else
|
||||
{
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (token != null)
|
||||
{
|
||||
user = Logins.GetUserFromToken(token).Result;
|
||||
LTLogin login = Logins.GetLoginFromToken(token).Result;
|
||||
if (login.Scopes.Contains(requiredScope))
|
||||
{
|
||||
#pragma warning disable 4014
|
||||
login.UpdateLastAccess(DateTimeOffset.Now);
|
||||
#pragma warning restore 4014
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryGetRssUser(string token, out LTUser user)
|
||||
{
|
||||
if (token is null)
|
||||
{
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
user = Logins.GetUserFromRssToken(token).Result;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
core/LightTube/Database/LTChannel.cs
Normal file
31
core/LightTube/Database/LTChannel.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System.Xml;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
[BsonIgnoreExtraElements]
|
||||
public class LTChannel
|
||||
{
|
||||
public string ChannelId;
|
||||
public string Name;
|
||||
public string Subscribers;
|
||||
public string IconUrl;
|
||||
|
||||
public XmlNode GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Channel");
|
||||
item.SetAttribute("id", ChannelId);
|
||||
item.SetAttribute("subscribers", Subscribers);
|
||||
|
||||
XmlElement title = doc.CreateElement("Name");
|
||||
title.InnerText = Name;
|
||||
item.AppendChild(title);
|
||||
|
||||
XmlElement thumbnail = doc.CreateElement("Avatar");
|
||||
thumbnail.InnerText = IconUrl;
|
||||
item.AppendChild(thumbnail);
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
core/LightTube/Database/LTLogin.cs
Normal file
84
core/LightTube/Database/LTLogin.cs
Normal file
@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using System.Xml;
|
||||
using Humanizer;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using MyCSharp.HttpUserAgentParser;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
[BsonIgnoreExtraElements]
|
||||
public class LTLogin
|
||||
{
|
||||
public string Identifier;
|
||||
public string Email;
|
||||
public string Token;
|
||||
public string UserAgent;
|
||||
public string[] Scopes;
|
||||
public DateTimeOffset Created = DateTimeOffset.MinValue;
|
||||
public DateTimeOffset LastSeen = DateTimeOffset.MinValue;
|
||||
|
||||
public XmlDocument GetXmlElement()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement login = doc.CreateElement("Login");
|
||||
login.SetAttribute("id", Identifier);
|
||||
login.SetAttribute("user", Email);
|
||||
|
||||
XmlElement token = doc.CreateElement("Token");
|
||||
token.InnerText = Token;
|
||||
login.AppendChild(token);
|
||||
|
||||
XmlElement scopes = doc.CreateElement("Scopes");
|
||||
foreach (string scope in Scopes)
|
||||
{
|
||||
XmlElement scopeElement = doc.CreateElement("Scope");
|
||||
scopeElement.InnerText = scope;
|
||||
login.AppendChild(scopeElement);
|
||||
}
|
||||
login.AppendChild(scopes);
|
||||
|
||||
doc.AppendChild(login);
|
||||
return doc;
|
||||
}
|
||||
|
||||
public string GetTitle()
|
||||
{
|
||||
Match match = Regex.Match(UserAgent, DatabaseManager.ApiUaRegex);
|
||||
if (match.Success)
|
||||
return $"API App: {match.Groups[2]} {match.Groups[3]}";
|
||||
|
||||
HttpUserAgentInformation client = HttpUserAgentParser.Parse(UserAgent);
|
||||
StringBuilder sb = new($"{client.Name} {client.Version}");
|
||||
if (client.Platform.HasValue)
|
||||
sb.Append($" on {client.Platform.Value.PlatformType.ToString()}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public string GetDescription()
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine($"Created: {Created.Humanize(DateTimeOffset.Now)}");
|
||||
sb.AppendLine($"Last seen: {LastSeen.Humanize(DateTimeOffset.Now)}");
|
||||
|
||||
Match match = Regex.Match(UserAgent, DatabaseManager.ApiUaRegex);
|
||||
if (match.Success)
|
||||
{
|
||||
sb.AppendLine($"API version: {HttpUtility.HtmlEncode(match.Groups[1])}");
|
||||
sb.AppendLine($"App info: {HttpUtility.HtmlEncode(match.Groups[4])}");
|
||||
sb.AppendLine("Allowed scopes:");
|
||||
foreach (string scope in Scopes) sb.AppendLine($"- {scope}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task UpdateLastAccess(DateTimeOffset newTime)
|
||||
{
|
||||
await DatabaseManager.Logins.UpdateLastAccess(Identifier, newTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
core/LightTube/Database/LTPlaylist.cs
Normal file
68
core/LightTube/Database/LTPlaylist.cs
Normal file
@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using InnerTube.Models;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
public class LTPlaylist
|
||||
{
|
||||
public string Id;
|
||||
public string Name;
|
||||
public string Description;
|
||||
public PlaylistVisibility Visibility;
|
||||
public List<string> VideoIds;
|
||||
public string Author;
|
||||
public DateTimeOffset LastUpdated;
|
||||
|
||||
public async Task<YoutubePlaylist> ToYoutubePlaylist()
|
||||
{
|
||||
List<Thumbnail> t = new();
|
||||
if (VideoIds.Count > 0)
|
||||
t.Add(new Thumbnail { Url = $"https://i.ytimg.com/vi_webp/{VideoIds.First()}/maxresdefault.webp" });
|
||||
YoutubePlaylist playlist = new()
|
||||
{
|
||||
Id = Id,
|
||||
Title = Name,
|
||||
Description = Description,
|
||||
VideoCount = VideoIds.Count.ToString(),
|
||||
ViewCount = "0",
|
||||
LastUpdated = "Last updated " + LastUpdated.ToString("MMMM dd, yyyy"),
|
||||
Thumbnail = t.ToArray(),
|
||||
Channel = new Channel
|
||||
{
|
||||
Name = Author,
|
||||
Id = GenerateChannelId(),
|
||||
SubscriberCount = "0 subscribers",
|
||||
Avatars = Array.Empty<Thumbnail>()
|
||||
},
|
||||
Videos = (await DatabaseManager.Playlists.GetPlaylistVideos(Id)).Select(x =>
|
||||
{
|
||||
x.Index = VideoIds.IndexOf(x.Id) + 1;
|
||||
return x;
|
||||
}).Cast<DynamicItem>().ToArray(),
|
||||
ContinuationKey = null
|
||||
};
|
||||
return playlist;
|
||||
}
|
||||
|
||||
private string GenerateChannelId()
|
||||
{
|
||||
StringBuilder sb = new("LTU-" + Author.Trim() + "_");
|
||||
|
||||
string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
Random rng = new(Author.GetHashCode());
|
||||
while (sb.Length < 32) sb.Append(alphabet[rng.Next(0, alphabet.Length)]);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public enum PlaylistVisibility
|
||||
{
|
||||
PRIVATE,
|
||||
UNLISTED,
|
||||
VISIBLE
|
||||
}
|
||||
}
|
||||
102
core/LightTube/Database/LTUser.cs
Normal file
102
core/LightTube/Database/LTUser.cs
Normal file
@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
[BsonIgnoreExtraElements]
|
||||
public class LTUser
|
||||
{
|
||||
public string UserID;
|
||||
public string PasswordHash;
|
||||
public List<string> SubscribedChannels;
|
||||
public bool ApiAccess;
|
||||
public string RssToken;
|
||||
|
||||
public async Task<string> GenerateRssFeed(string hostUrl, int limit)
|
||||
{
|
||||
XmlDocument document = new();
|
||||
XmlElement rss = document.CreateElement("rss");
|
||||
rss.SetAttribute("version", "2.0");
|
||||
|
||||
XmlElement channel = document.CreateElement("channel");
|
||||
|
||||
XmlElement title = document.CreateElement("title");
|
||||
title.InnerText = "LightTube subscriptions RSS feed for " + UserID;
|
||||
channel.AppendChild(title);
|
||||
|
||||
XmlElement description = document.CreateElement("description");
|
||||
description.InnerText = $"LightTube subscriptions RSS feed for {UserID} with {SubscribedChannels.Count} channels";
|
||||
channel.AppendChild(description);
|
||||
|
||||
FeedVideo[] feeds = await YoutubeRSS.GetMultipleFeeds(SubscribedChannels);
|
||||
IEnumerable<FeedVideo> feedVideos = feeds.Take(limit);
|
||||
|
||||
foreach (FeedVideo video in feedVideos)
|
||||
{
|
||||
XmlElement item = document.CreateElement("item");
|
||||
|
||||
XmlElement id = document.CreateElement("id");
|
||||
id.InnerText = $"id:video:{video.Id}";
|
||||
item.AppendChild(id);
|
||||
|
||||
XmlElement vtitle = document.CreateElement("title");
|
||||
vtitle.InnerText = video.Title;
|
||||
item.AppendChild(vtitle);
|
||||
|
||||
XmlElement vdescription = document.CreateElement("description");
|
||||
vdescription.InnerText = video.Description;
|
||||
item.AppendChild(vdescription);
|
||||
|
||||
XmlElement link = document.CreateElement("link");
|
||||
link.InnerText = $"https://{hostUrl}/watch?v={video.Id}";
|
||||
item.AppendChild(link);
|
||||
|
||||
XmlElement published = document.CreateElement("pubDate");
|
||||
published.InnerText = video.PublishedDate.ToString("R");
|
||||
item.AppendChild(published);
|
||||
|
||||
XmlElement author = document.CreateElement("author");
|
||||
|
||||
XmlElement name = document.CreateElement("name");
|
||||
name.InnerText = video.ChannelName;
|
||||
author.AppendChild(name);
|
||||
|
||||
XmlElement uri = document.CreateElement("uri");
|
||||
uri.InnerText = $"https://{hostUrl}/channel/{video.ChannelId}";
|
||||
author.AppendChild(uri);
|
||||
|
||||
item.AppendChild(author);
|
||||
/*
|
||||
XmlElement mediaGroup = document.CreateElement("media_group");
|
||||
|
||||
XmlElement mediaTitle = document.CreateElement("media_title");
|
||||
mediaTitle.InnerText = video.Title;
|
||||
mediaGroup.AppendChild(mediaTitle);
|
||||
|
||||
XmlElement mediaThumbnail = document.CreateElement("media_thumbnail");
|
||||
mediaThumbnail.SetAttribute("url", video.Thumbnail);
|
||||
mediaGroup.AppendChild(mediaThumbnail);
|
||||
|
||||
XmlElement mediaContent = document.CreateElement("media_content");
|
||||
mediaContent.SetAttribute("url", $"https://{hostUrl}/embed/{video.Id}");
|
||||
mediaContent.SetAttribute("type", "text/html");
|
||||
mediaGroup.AppendChild(mediaContent);
|
||||
|
||||
item.AppendChild(mediaGroup);
|
||||
*/
|
||||
channel.AppendChild(item);
|
||||
}
|
||||
|
||||
rss.AppendChild(channel);
|
||||
|
||||
document.AppendChild(rss);
|
||||
return document.OuterXml;//.Replace("<media_", "<media:").Replace("</media_", "</media:");
|
||||
}
|
||||
}
|
||||
}
|
||||
39
core/LightTube/Database/LTVideo.cs
Normal file
39
core/LightTube/Database/LTVideo.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Xml;
|
||||
using InnerTube.Models;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
public class LTVideo : PlaylistVideoItem
|
||||
{
|
||||
public string UploadedAt;
|
||||
public long Views;
|
||||
|
||||
public override XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Video");
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("duration", Duration);
|
||||
item.SetAttribute("views", Views.ToString());
|
||||
item.SetAttribute("uploadedAt", UploadedAt);
|
||||
item.SetAttribute("index", Index.ToString());
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
if (Channel is not null)
|
||||
item.AppendChild(Channel.GetXmlElement(doc));
|
||||
|
||||
foreach (Thumbnail t in Thumbnails ?? Array.Empty<Thumbnail>())
|
||||
{
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.SetAttribute("width", t.Width.ToString());
|
||||
thumbnail.SetAttribute("height", t.Height.ToString());
|
||||
thumbnail.InnerText = t.Url;
|
||||
item.AppendChild(thumbnail);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
172
core/LightTube/Database/LoginManager.cs
Normal file
172
core/LightTube/Database/LoginManager.cs
Normal file
@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using InnerTube.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
public class LoginManager
|
||||
{
|
||||
private IMongoCollection<LTUser> _userCollection;
|
||||
private IMongoCollection<LTLogin> _tokenCollection;
|
||||
|
||||
public LoginManager(IMongoCollection<LTUser> userCollection, IMongoCollection<LTLogin> tokenCollection)
|
||||
{
|
||||
_userCollection = userCollection;
|
||||
_tokenCollection = tokenCollection;
|
||||
}
|
||||
|
||||
public async Task<LTLogin> CreateToken(string email, string password, string userAgent, string[] scopes)
|
||||
{
|
||||
IAsyncCursor<LTUser> users = await _userCollection.FindAsync(x => x.UserID == email);
|
||||
if (!await users.AnyAsync())
|
||||
throw new UnauthorizedAccessException("Invalid credentials");
|
||||
LTUser user = (await _userCollection.FindAsync(x => x.UserID == email)).First();
|
||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||
throw new UnauthorizedAccessException("Invalid credentials");
|
||||
if (!scopes.Contains("web") && !user.ApiAccess)
|
||||
throw new InvalidOperationException("This user has API access disabled");
|
||||
|
||||
LTLogin login = new()
|
||||
{
|
||||
Identifier = Guid.NewGuid().ToString(),
|
||||
Email = email,
|
||||
Token = GenerateToken(256),
|
||||
UserAgent = userAgent,
|
||||
Scopes = scopes.ToArray(),
|
||||
Created = DateTimeOffset.Now,
|
||||
LastSeen = DateTimeOffset.Now
|
||||
};
|
||||
await _tokenCollection.InsertOneAsync(login);
|
||||
return login;
|
||||
}
|
||||
|
||||
public async Task UpdateLastAccess(string id, DateTimeOffset offset)
|
||||
{
|
||||
LTLogin login = (await _tokenCollection.FindAsync(x => x.Identifier == id)).First();
|
||||
login.LastSeen = offset;
|
||||
await _tokenCollection.ReplaceOneAsync(x => x.Identifier == id, login);
|
||||
}
|
||||
|
||||
public async Task RemoveToken(string token)
|
||||
{
|
||||
await _tokenCollection.FindOneAndDeleteAsync(t => t.Token == token);
|
||||
}
|
||||
|
||||
public async Task RemoveToken(string email, string password, string identifier)
|
||||
{
|
||||
IAsyncCursor<LTUser> users = await _userCollection.FindAsync(x => x.UserID == email);
|
||||
if (!await users.AnyAsync())
|
||||
throw new KeyNotFoundException("Invalid credentials");
|
||||
LTUser user = (await _userCollection.FindAsync(x => x.UserID == email)).First();
|
||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||
throw new UnauthorizedAccessException("Invalid credentials");
|
||||
|
||||
await _tokenCollection.FindOneAndDeleteAsync(t => t.Identifier == identifier && t.Email == user.UserID);
|
||||
}
|
||||
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public async Task RemoveTokenFromId(string sourceToken, string identifier)
|
||||
{
|
||||
LTLogin login = (await _tokenCollection.FindAsync(x => x.Token == sourceToken)).First();
|
||||
LTLogin deletedLogin = (await _tokenCollection.FindAsync(x => x.Identifier == identifier)).First();
|
||||
|
||||
if (login.Email == deletedLogin.Email)
|
||||
await _tokenCollection.FindOneAndDeleteAsync(t => t.Identifier == identifier);
|
||||
else
|
||||
throw new UnauthorizedAccessException(
|
||||
"Logged in user does not match the token that is supposed to be deleted");
|
||||
}
|
||||
|
||||
public async Task<LTUser> GetUserFromToken(string token)
|
||||
{
|
||||
string email = (await _tokenCollection.FindAsync(x => x.Token == token)).First().Email;
|
||||
return (await _userCollection.FindAsync(u => u.UserID == email)).First();
|
||||
}
|
||||
|
||||
public async Task<LTUser> GetUserFromRssToken(string token) => (await _userCollection.FindAsync(u => u.RssToken == token)).First();
|
||||
|
||||
public async Task<LTLogin> GetLoginFromToken(string token)
|
||||
{
|
||||
var res = await _tokenCollection.FindAsync(x => x.Token == token);
|
||||
return res.First();
|
||||
}
|
||||
|
||||
public async Task<List<LTLogin>> GetAllUserTokens(string token)
|
||||
{
|
||||
string email = (await _tokenCollection.FindAsync(x => x.Token == token)).First().Email;
|
||||
return await (await _tokenCollection.FindAsync(u => u.Email == email)).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<string> GetCurrentLoginId(string token)
|
||||
{
|
||||
return (await _tokenCollection.FindAsync(t => t.Token == token)).First().Identifier;
|
||||
}
|
||||
|
||||
public async Task<(LTChannel channel, bool subscribed)> SubscribeToChannel(LTUser user, YoutubeChannel channel)
|
||||
{
|
||||
LTChannel ltChannel = await DatabaseManager.Channels.UpdateChannel(channel.Id, channel.Name, channel.Subscribers,
|
||||
channel.Avatars.FirstOrDefault()?.Url);
|
||||
|
||||
if (user.SubscribedChannels.Contains(ltChannel.ChannelId))
|
||||
user.SubscribedChannels.Remove(ltChannel.ChannelId);
|
||||
else
|
||||
user.SubscribedChannels.Add(ltChannel.ChannelId);
|
||||
|
||||
await _userCollection.ReplaceOneAsync(x => x.UserID == user.UserID, user);
|
||||
return (ltChannel, user.SubscribedChannels.Contains(ltChannel.ChannelId));
|
||||
}
|
||||
|
||||
public async Task SetApiAccess(LTUser user, bool access)
|
||||
{
|
||||
user.ApiAccess = access;
|
||||
await _userCollection.ReplaceOneAsync(x => x.UserID == user.UserID, user);
|
||||
}
|
||||
|
||||
public async Task DeleteUser(string email, string password)
|
||||
{
|
||||
IAsyncCursor<LTUser> users = await _userCollection.FindAsync(x => x.UserID == email);
|
||||
if (!await users.AnyAsync())
|
||||
throw new KeyNotFoundException("Invalid credentials");
|
||||
LTUser user = (await _userCollection.FindAsync(x => x.UserID == email)).First();
|
||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||
throw new UnauthorizedAccessException("Invalid credentials");
|
||||
|
||||
await _userCollection.DeleteOneAsync(x => x.UserID == email);
|
||||
await _tokenCollection.DeleteManyAsync(x => x.Email == email);
|
||||
foreach (LTPlaylist pl in await DatabaseManager.Playlists.GetUserPlaylists(email))
|
||||
await DatabaseManager.Playlists.DeletePlaylist(pl.Id);
|
||||
}
|
||||
|
||||
public async Task CreateUser(string email, string password)
|
||||
{
|
||||
IAsyncCursor<LTUser> users = await _userCollection.FindAsync(x => x.UserID == email);
|
||||
if (await users.AnyAsync())
|
||||
throw new DuplicateNameException("A user with that email already exists");
|
||||
|
||||
LTUser user = new()
|
||||
{
|
||||
UserID = email,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||
SubscribedChannels = new List<string>(),
|
||||
RssToken = GenerateToken(32)
|
||||
};
|
||||
await _userCollection.InsertOneAsync(user);
|
||||
}
|
||||
|
||||
private string GenerateToken(int length)
|
||||
{
|
||||
string tokenAlphabet = @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-+*/()[]{}";
|
||||
Random rng = new();
|
||||
StringBuilder sb = new();
|
||||
for (int i = 0; i < length; i++)
|
||||
sb.Append(tokenAlphabet[rng.Next(0, tokenAlphabet.Length)]);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
161
core/LightTube/Database/PlaylistManager.cs
Normal file
161
core/LightTube/Database/PlaylistManager.cs
Normal file
@ -0,0 +1,161 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using InnerTube;
|
||||
using InnerTube.Models;
|
||||
using MongoDB.Driver;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
public class PlaylistManager
|
||||
{
|
||||
private IMongoCollection<LTUser> _userCollection;
|
||||
private IMongoCollection<LTPlaylist> _playlistCollection;
|
||||
private IMongoCollection<LTVideo> _videoCacheCollection;
|
||||
private Youtube _youtube;
|
||||
|
||||
public PlaylistManager(IMongoCollection<LTUser> userCollection, IMongoCollection<LTPlaylist> playlistCollection,
|
||||
IMongoCollection<LTVideo> videoCacheCollection, Youtube youtube)
|
||||
{
|
||||
_userCollection = userCollection;
|
||||
_playlistCollection = playlistCollection;
|
||||
_videoCacheCollection = videoCacheCollection;
|
||||
_youtube = youtube;
|
||||
}
|
||||
|
||||
public async Task<LTPlaylist> CreatePlaylist(LTUser user, string name, string description,
|
||||
PlaylistVisibility visibility, string idPrefix = null)
|
||||
{
|
||||
if (await _userCollection.CountDocumentsAsync(x => x.UserID == user.UserID) == 0)
|
||||
throw new UnauthorizedAccessException("Local accounts cannot create playlists");
|
||||
|
||||
LTPlaylist pl = new()
|
||||
{
|
||||
Id = GenerateAuthorId(idPrefix),
|
||||
Name = name,
|
||||
Description = description,
|
||||
Visibility = visibility,
|
||||
VideoIds = new List<string>(),
|
||||
Author = user.UserID,
|
||||
LastUpdated = DateTimeOffset.Now
|
||||
};
|
||||
|
||||
await _playlistCollection.InsertOneAsync(pl).ConfigureAwait(false);
|
||||
|
||||
return pl;
|
||||
}
|
||||
|
||||
public async Task<LTPlaylist> GetPlaylist(string id)
|
||||
{
|
||||
IAsyncCursor<LTPlaylist> cursor = await _playlistCollection.FindAsync(x => x.Id == id);
|
||||
return await cursor.FirstOrDefaultAsync() ?? new LTPlaylist
|
||||
{
|
||||
Id = null,
|
||||
Name = "",
|
||||
Description = "",
|
||||
Visibility = PlaylistVisibility.VISIBLE,
|
||||
VideoIds = new List<string>(),
|
||||
Author = "",
|
||||
LastUpdated = DateTimeOffset.MinValue
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<LTVideo>> GetPlaylistVideos(string id)
|
||||
{
|
||||
LTPlaylist pl = await GetPlaylist(id);
|
||||
List<LTVideo> videos = new();
|
||||
|
||||
foreach (string videoId in pl.VideoIds)
|
||||
{
|
||||
IAsyncCursor<LTVideo> cursor = await _videoCacheCollection.FindAsync(x => x.Id == videoId);
|
||||
videos.Add(await cursor.FirstAsync());
|
||||
}
|
||||
|
||||
return videos;
|
||||
}
|
||||
|
||||
public async Task<LTVideo> AddVideoToPlaylist(string playlistId, string videoId)
|
||||
{
|
||||
LTPlaylist pl = await GetPlaylist(playlistId);
|
||||
YoutubeVideo vid = await _youtube.GetVideoAsync(videoId);
|
||||
JObject ytPlayer = await InnerTube.Utils.GetAuthorizedPlayer(videoId, new HttpClient());
|
||||
|
||||
if (string.IsNullOrEmpty(vid.Id))
|
||||
throw new KeyNotFoundException($"Couldn't find a video with ID '{videoId}'");
|
||||
|
||||
LTVideo v = new()
|
||||
{
|
||||
Id = vid.Id,
|
||||
Title = vid.Title,
|
||||
Thumbnails = ytPlayer?["videoDetails"]?["thumbnail"]?["thumbnails"]?.ToObject<Thumbnail[]>() ?? new []
|
||||
{
|
||||
new Thumbnail { Url = $"https://i.ytimg.com/vi_webp/{vid.Id}/maxresdefault.webp" }
|
||||
},
|
||||
UploadedAt = vid.UploadDate,
|
||||
Views = long.Parse(vid.Views.Split(" ")[0].Replace(",", "").Replace(".", "")),
|
||||
Channel = vid.Channel,
|
||||
Duration = GetDurationString(ytPlayer?["videoDetails"]?["lengthSeconds"]?.ToObject<long>() ?? 0),
|
||||
Index = pl.VideoIds.Count
|
||||
};
|
||||
pl.VideoIds.Add(vid.Id);
|
||||
|
||||
if (await _videoCacheCollection.CountDocumentsAsync(x => x.Id == vid.Id) == 0)
|
||||
await _videoCacheCollection.InsertOneAsync(v);
|
||||
else
|
||||
await _videoCacheCollection.FindOneAndReplaceAsync(x => x.Id == vid.Id, v);
|
||||
|
||||
UpdateDefinition<LTPlaylist> update = Builders<LTPlaylist>.Update
|
||||
.Push(x => x.VideoIds, vid.Id);
|
||||
_playlistCollection.FindOneAndUpdate(x => x.Id == playlistId, update);
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
public async Task<LTVideo> RemoveVideoFromPlaylist(string playlistId, int videoIndex)
|
||||
{
|
||||
LTPlaylist pl = await GetPlaylist(playlistId);
|
||||
|
||||
IAsyncCursor<LTVideo> cursor = await _videoCacheCollection.FindAsync(x => x.Id == pl.VideoIds[videoIndex]);
|
||||
LTVideo v = await cursor.FirstAsync();
|
||||
pl.VideoIds.RemoveAt(videoIndex);
|
||||
|
||||
await _playlistCollection.FindOneAndReplaceAsync(x => x.Id == playlistId, pl);
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LTPlaylist>> GetUserPlaylists(string userId)
|
||||
{
|
||||
IAsyncCursor<LTPlaylist> cursor = await _playlistCollection.FindAsync(x => x.Author == userId);
|
||||
|
||||
return cursor.ToEnumerable();
|
||||
}
|
||||
|
||||
private string GetDurationString(long length)
|
||||
{
|
||||
string s = TimeSpan.FromSeconds(length).ToString();
|
||||
while (s.StartsWith("00:") && s.Length > 5) s = s[3..];
|
||||
return s;
|
||||
}
|
||||
|
||||
public static string GenerateAuthorId(string prefix)
|
||||
{
|
||||
StringBuilder sb = new(string.IsNullOrWhiteSpace(prefix) || prefix.Trim().Length > 20
|
||||
? "LT-PL"
|
||||
: "LT-PL-" + prefix.Trim() + "_");
|
||||
|
||||
string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
Random rng = new();
|
||||
while (sb.Length < 32) sb.Append(alphabet[rng.Next(0, alphabet.Length)]);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task DeletePlaylist(string playlistId)
|
||||
{
|
||||
await _playlistCollection.DeleteOneAsync(x => x.Id == playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
core/LightTube/Database/SubscriptionChannels.cs
Normal file
18
core/LightTube/Database/SubscriptionChannels.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using System.Xml;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
public class SubscriptionChannels
|
||||
{
|
||||
public LTChannel[] Channels { get; set; }
|
||||
|
||||
public XmlNode GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement feed = doc.CreateElement("Subscriptions");
|
||||
foreach (LTChannel channel in Channels) feed.AppendChild(channel.GetXmlElement(doc));
|
||||
doc.AppendChild(feed);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
core/LightTube/Database/SubscriptionFeed.cs
Normal file
18
core/LightTube/Database/SubscriptionFeed.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using System.Xml;
|
||||
|
||||
namespace LightTube.Database
|
||||
{
|
||||
public class SubscriptionFeed
|
||||
{
|
||||
public FeedVideo[] videos;
|
||||
|
||||
public XmlDocument GetXmlDocument()
|
||||
{
|
||||
XmlDocument doc = new();
|
||||
XmlElement feed = doc.CreateElement("Feed");
|
||||
foreach (FeedVideo feedVideo in videos) feed.AppendChild(feedVideo.GetXmlElement(doc));
|
||||
doc.AppendChild(feed);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
core/LightTube/LightTube.csproj
Normal file
19
core/LightTube/LightTube.csproj
Normal file
@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\InnerTube\InnerTube.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.2" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.14.1" />
|
||||
<PackageReference Include="MyCSharp.HttpUserAgentParser" Version="1.1.5" />
|
||||
<PackageReference Include="YamlDotNet" Version="11.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
11
core/LightTube/Models/ErrorViewModel.cs
Normal file
11
core/LightTube/Models/ErrorViewModel.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace LightTube.Models
|
||||
{
|
||||
public class ErrorViewModel
|
||||
{
|
||||
public string RequestId { get; set; }
|
||||
|
||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
}
|
||||
}
|
||||
26
core/LightTube/Program.cs
Normal file
26
core/LightTube/Program.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightTube
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Configuration.LoadConfiguration();
|
||||
InnerTube.Utils.SetAuthorization(Configuration.Instance.Credentials.CanUseAuthorizedEndpoints(),
|
||||
Configuration.Instance.Credentials.Sapisid, Configuration.Instance.Credentials.Psid);
|
||||
CreateHostBuilder(args).Build().Run();
|
||||
}
|
||||
|
||||
public static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||
Host.CreateDefaultBuilder(args)
|
||||
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
|
||||
}
|
||||
}
|
||||
60
core/LightTube/Views/Account/Account.cshtml
Normal file
60
core/LightTube/Views/Account/Account.cshtml
Normal file
@ -0,0 +1,60 @@
|
||||
@using LightTube.Database
|
||||
@using System.Web
|
||||
@model LightTube.Contexts.BaseContext
|
||||
|
||||
@{
|
||||
ViewBag.Title = "Account";
|
||||
Layout = "_Layout";
|
||||
|
||||
Context.Request.Cookies.TryGetValue("theme", out string theme);
|
||||
if (!new[] { "light", "dark" }.Contains(theme)) theme = "light";
|
||||
|
||||
string newTheme = theme switch {
|
||||
"light" => "dark",
|
||||
"dark" => "light",
|
||||
var _ => "dark"
|
||||
};
|
||||
|
||||
bool compatibility = false;
|
||||
if (Context.Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||
bool.TryParse(compatibilityString, out compatibility);
|
||||
}
|
||||
|
||||
<div class="login-container">
|
||||
<div>
|
||||
<div class="fullscreen-account-menu">
|
||||
<h1>Settings</h1>
|
||||
<br>
|
||||
<div class="guide-item">
|
||||
<a href="/toggles/theme?redirectUrl=@(HttpUtility.UrlEncode($"{Context.Request.Path}{Context.Request.QueryString}"))">Switch to @(newTheme) theme</a>
|
||||
</div>
|
||||
<br>
|
||||
@if (Context.TryGetUser(out LTUser user, "web"))
|
||||
{
|
||||
<div class="guide-item">
|
||||
<a href="/Account/Settings">Settings</a>
|
||||
</div>
|
||||
@if (user.PasswordHash != "local_account")
|
||||
{
|
||||
<div class="guide-item">
|
||||
<a href="/Account/Logins">Active logins</a>
|
||||
</div>
|
||||
}
|
||||
<div class="guide-item">
|
||||
<a href="/Account/Logout">Log out</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="guide-item">
|
||||
<a href="/Account/Login">Log in</a>
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<a href="/Account/Register">Register</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
52
core/LightTube/Views/Account/AddVideoToPlaylist.cshtml
Normal file
52
core/LightTube/Views/Account/AddVideoToPlaylist.cshtml
Normal file
@ -0,0 +1,52 @@
|
||||
@using System.Web
|
||||
@using LightTube.Database
|
||||
@model LightTube.Contexts.AddToPlaylistContext
|
||||
|
||||
@{
|
||||
ViewBag.Metadata = new Dictionary<string, string>();
|
||||
ViewBag.Metadata["author"] = Model.Video.Channel.Name;
|
||||
ViewBag.Metadata["og:title"] = Model.Video.Title;
|
||||
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Thumbnail)}";
|
||||
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Thumbnail)}";
|
||||
ViewBag.Title = Model.Video.Title;
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="playlist-page">
|
||||
<div class="playlist-info">
|
||||
<div class="thumbnail" style="background-image: url('@Model.Thumbnail')">
|
||||
<a href="/watch?v=@Model.Video.Id">Watch</a>
|
||||
</div>
|
||||
<p class="title">@Model.Video.Title</p>
|
||||
<span class="info">@Model.Video.Views • @Model.Video.UploadDate</span>
|
||||
<div class="channel-info">
|
||||
<a href="/channel/@Model.Video.Channel.Id" class="avatar">
|
||||
<img src="@Model.Video.Channel.Avatars.LastOrDefault()?.Url">
|
||||
</a>
|
||||
<div class="name">
|
||||
<a class="name" href="/channel/@Model.Video.Channel.Id">@Model.Video.Channel.Name</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-list playlist-list playlist-video-list">
|
||||
<h3>Add to one of these playlists:</h3>
|
||||
<a class="login-button" href="/Account/CreatePlaylist" style="margin:unset;">Create playlist</a>
|
||||
@foreach (LTPlaylist playlist in Model.Playlists)
|
||||
{
|
||||
<div class="playlist-video">
|
||||
<a href="/playlist?list=@playlist.Id&add=@Model.Id" class="thumbnail"
|
||||
style="background-image: url('https://i.ytimg.com/vi_webp/@playlist.VideoIds.FirstOrDefault()/maxresdefault.webp')">
|
||||
</a>
|
||||
<div class="info">
|
||||
<a href="/playlist?list=@playlist.Id&add=@Model.Id" class="title max-lines-2">
|
||||
@playlist.Name
|
||||
</a>
|
||||
<div>
|
||||
<span>@playlist.VideoIds.Count videos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
25
core/LightTube/Views/Account/CreatePlaylist.cshtml
Normal file
25
core/LightTube/Views/Account/CreatePlaylist.cshtml
Normal file
@ -0,0 +1,25 @@
|
||||
@model LightTube.Contexts.BaseContext
|
||||
|
||||
@{
|
||||
ViewBag.Title = "Create Playlist";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="login-container">
|
||||
<div>
|
||||
<div>
|
||||
<form asp-action="CreatePlaylist" method="POST" class="playlist-form">
|
||||
<h1>Create Playlist</h1>
|
||||
<input name="name" type="text" placeholder="Playlist Name">
|
||||
<input name="description" type="text" placeholder="Description">
|
||||
<select name="visibility">
|
||||
<option value="UNLISTED">Anyone with the link can view</option>
|
||||
<option value="PRIVATE">Only you can view</option>
|
||||
</select>
|
||||
<input type="submit" value="Create">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
46
core/LightTube/Views/Account/Delete.cshtml
Normal file
46
core/LightTube/Views/Account/Delete.cshtml
Normal file
@ -0,0 +1,46 @@
|
||||
@using LightTube.Database
|
||||
@model LightTube.Contexts.MessageContext
|
||||
@{
|
||||
ViewData["Title"] = "Delete Account";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||
{
|
||||
<div class="login-message">
|
||||
@Model.Message
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="login-container">
|
||||
<div>
|
||||
<div>
|
||||
@if (Context.Request.Cookies.TryGetValue("account_data", out string _))
|
||||
{
|
||||
Context.TryGetUser(out LTUser user, "web");
|
||||
<form asp-action="Delete" method="POST" class="login-form">
|
||||
<h1>Delete Account</h1>
|
||||
<p>Deleting a local account</p>
|
||||
<input name="email" type="hidden" value="@user.UserID">
|
||||
<input name="password" type="hidden" value="@user.PasswordHash">
|
||||
<input type="submit" value="Delete Account" class="login-button danger">
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form asp-action="Delete" method="POST" class="login-form">
|
||||
<h1>Delete Account</h1>
|
||||
<input name="userid" type="text" placeholder="UserID">
|
||||
<input name="password" type="password" placeholder="Password">
|
||||
<input type="submit" value="Delete Account" class="login-button danger">
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<h1>Warning!</h1>
|
||||
<p>You cannot undo this operation! After you enter your username and password, your account will get deleted forever.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
29
core/LightTube/Views/Account/Login.cshtml
Normal file
29
core/LightTube/Views/Account/Login.cshtml
Normal file
@ -0,0 +1,29 @@
|
||||
@model LightTube.Contexts.MessageContext
|
||||
@{
|
||||
ViewData["Title"] = "Login";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||
{
|
||||
<div class="login-message">
|
||||
@Model.Message
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="login-container">
|
||||
<div>
|
||||
<div>
|
||||
<form asp-action="Login" method="POST" class="login-form">
|
||||
<h1>Log in</h1>
|
||||
<input name="userid" type="text" placeholder="UserID">
|
||||
<input name="password" type="password" placeholder="Password">
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Don't have an account?</h2>
|
||||
<a href="/Account/Register" class="login-button">Create an account</a>
|
||||
</div>
|
||||
</div>
|
||||
18
core/LightTube/Views/Account/Logins.cshtml
Normal file
18
core/LightTube/Views/Account/Logins.cshtml
Normal file
@ -0,0 +1,18 @@
|
||||
@using LightTube.Database
|
||||
@model LightTube.Contexts.LoginsContext
|
||||
@{
|
||||
ViewData["Title"] = "Active Logins";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<h1 style="text-align:center;">Active Logins</h1>
|
||||
<div class="logins-container">
|
||||
@foreach (LTLogin login in Model.Logins)
|
||||
{
|
||||
<div class="login">
|
||||
<h2 class="max-lines-1">@(login.Identifier == Model.CurrentLogin ? "(This window) " : "")@login.GetTitle()</h2>
|
||||
<p>@Html.Raw(login.GetDescription().Replace("\n", "<br>"))</p>
|
||||
<a href="/Account/DisableLogin?id=@login.Identifier" class="login-button" style="color:red;">Disable</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
42
core/LightTube/Views/Account/Register.cshtml
Normal file
42
core/LightTube/Views/Account/Register.cshtml
Normal file
@ -0,0 +1,42 @@
|
||||
@model LightTube.Contexts.MessageContext
|
||||
@{
|
||||
ViewData["Title"] = "Register";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||
{
|
||||
<div class="login-message">
|
||||
@Model.Message
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="login-container">
|
||||
<div>
|
||||
<div>
|
||||
<form asp-action="Register" method="POST" class="login-form">
|
||||
<h1>Register</h1>
|
||||
<input name="userid" type="text" placeholder="UserID">
|
||||
<input name="password" type="password" placeholder="Password">
|
||||
<input type="submit" value="Register">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<h1>...or register with a local account</h1>
|
||||
<h2>What is the difference?</h2>
|
||||
<ul>
|
||||
<li>Remote account data is saved in this lighttube instance, while local account data is stored in
|
||||
your browser's cookies
|
||||
<ul>
|
||||
<li>This means that the author of this lighttube instance cannot see your account data</li>
|
||||
<li>It also means that, if you clear your cookies a lot, your account data will also get
|
||||
lost with the cookies</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/Account/RegisterLocal" class="login-button">Create local account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
63
core/LightTube/Views/Account/Settings.cshtml
Normal file
63
core/LightTube/Views/Account/Settings.cshtml
Normal file
@ -0,0 +1,63 @@
|
||||
@model LightTube.Contexts.SettingsContext
|
||||
|
||||
@{
|
||||
ViewBag.Title = "Settings";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
|
||||
<form method="post">
|
||||
<div class="settings-content">
|
||||
<h1 style="text-align:center">Settings</h1>
|
||||
<div>
|
||||
<label for="settings-theme">Theme</label>
|
||||
<select id="settings-theme" name="theme">
|
||||
@Html.Raw($"<option value='light' {(Model.Theme == "light" ? "selected" : "")}>Light</option>")
|
||||
@Html.Raw($"<option value='dark' {(Model.Theme == "dark" ? "selected" : "")}>Dark</option>")
|
||||
</select>
|
||||
<p>This is the visual theme the website will use.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="settings-yhl">Content Language</label>
|
||||
<select id="settings-yhl" name="hl">
|
||||
@foreach (KeyValuePair<string, string> o in Model.Languages)
|
||||
{
|
||||
@Html.Raw($"<option value='{o.Key}' {(o.Key == Model.CurrentLanguage ? "selected" : "")}>{o.Value}</option>")
|
||||
}
|
||||
</select>
|
||||
<p>The language YouTube will deliver the content in. This will not affect LightTube's UI language.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="settings-ygl">Content Region</label>
|
||||
<select id="settings-ygl" name="gl">
|
||||
@foreach (KeyValuePair<string, string> o in Model.Regions)
|
||||
{
|
||||
@Html.Raw($"<option value='{o.Key}' {(o.Key == Model.CurrentRegion ? "selected" : "")}>{o.Value}</option>")
|
||||
}
|
||||
</select>
|
||||
<p>The language YouTube will deliver the content for. It is used for the explore page and the recommendations.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="settings-player">Player</label>
|
||||
<select id="settings-player" name="compatibility">
|
||||
@Html.Raw($"<option value=\"false\" {(Model.CompatibilityMode ? "" : "selected")}>DASH playback with muxed fallback (recommended)</option>")
|
||||
@Html.Raw($"<option value=\"true\" {(Model.CompatibilityMode ? "selected" : "")}>Muxed formats only (only supports 360p & 720p)</option>")
|
||||
</select>
|
||||
<p>Player behaviour. DASH playback allows for resolutions over 720p, but it is not compatible in all browsers. (e.g: Firefox Mobile)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="settings-api">API Access</label>
|
||||
<select id="settings-api" name="api-access">
|
||||
@Html.Raw($"<option value=\"true\" {(Model.ApiAccess ? "selected" : "")}>Enabled</option>")
|
||||
@Html.Raw($"<option value=\"false\" {(Model.ApiAccess ? "" : "selected")}>Disabled</option>")
|
||||
</select>
|
||||
<p>This will allow apps to log in using your username and password</p>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:row">
|
||||
<a href="/Account/Logins" class="login-button">Active Logins</a>
|
||||
<a href="/Account/Delete" class="login-button" style="color:red">Delete Account</a>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<input type="submit" class="login-button" value="Save"/>
|
||||
</form>
|
||||
26
core/LightTube/Views/Feed/Channels.cshtml
Normal file
26
core/LightTube/Views/Feed/Channels.cshtml
Normal file
@ -0,0 +1,26 @@
|
||||
@using LightTube.Database
|
||||
@model LightTube.Contexts.FeedContext
|
||||
|
||||
@{
|
||||
ViewBag.Title = "Channel list";
|
||||
}
|
||||
|
||||
<div class="video-list">
|
||||
@foreach (LTChannel channel in Model.Channels)
|
||||
{
|
||||
<div class="channel">
|
||||
<a href="/channel/@channel.ChannelId" class="avatar">
|
||||
<img src="@channel.IconUrl" alt="Channel Avatar">
|
||||
</a>
|
||||
<a href="/channel/@channel.ChannelId" class="info">
|
||||
<span class="name max-lines-2">@channel.Name</span>
|
||||
<div>
|
||||
<div>
|
||||
<span>@channel.Subscribers</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<button class="subscribe-button" data-cid="@channel.ChannelId">Subscribe</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
8
core/LightTube/Views/Feed/Explore.cshtml
Normal file
8
core/LightTube/Views/Feed/Explore.cshtml
Normal file
@ -0,0 +1,8 @@
|
||||
@{
|
||||
ViewData["Title"] = "Explore";
|
||||
ViewData["SelectedGuideItem"] = "explore";
|
||||
}
|
||||
|
||||
<div style="text-align: center">
|
||||
<h1>Coming soon!</h1>
|
||||
</div>
|
||||
35
core/LightTube/Views/Feed/Playlists.cshtml
Normal file
35
core/LightTube/Views/Feed/Playlists.cshtml
Normal file
@ -0,0 +1,35 @@
|
||||
@using LightTube.Database
|
||||
@model LightTube.Contexts.PlaylistsContext
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Playlists";
|
||||
ViewData["SelectedGuideItem"] = "library";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="video-list">
|
||||
<h2>Playlists</h2>
|
||||
@foreach (LTPlaylist playlist in Model.Playlists)
|
||||
{
|
||||
<div class="playlist">
|
||||
<a href="/watch?v=@playlist.VideoIds.FirstOrDefault()&list=@playlist.Id" class="thumbnail" style="background-image: url('https://i.ytimg.com/vi_webp/@playlist.VideoIds.FirstOrDefault()/maxresdefault.webp')">
|
||||
<div>
|
||||
<span>@playlist.VideoIds.Count</span><span>VIDEOS</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="info">
|
||||
<a href="/watch?v=@playlist.VideoIds.FirstOrDefault()&list=@playlist.Id" class="title max-lines-2">@playlist.Name</a>
|
||||
<div>
|
||||
<a href="/channel/@PlaylistManager.GenerateAuthorId(playlist.Author)">@playlist.Author</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/playlist?list=@playlist.Id">
|
||||
<b>View Full Playlist</b>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
55
core/LightTube/Views/Feed/Subscriptions.cshtml
Normal file
55
core/LightTube/Views/Feed/Subscriptions.cshtml
Normal file
@ -0,0 +1,55 @@
|
||||
@using Humanizer
|
||||
@using LightTube.Database
|
||||
@using System.Web
|
||||
@model LightTube.Contexts.FeedContext
|
||||
@{
|
||||
ViewData["Title"] = "Subscriptions";
|
||||
ViewData["SelectedGuideItem"] = "subs";
|
||||
|
||||
bool minMode = false;
|
||||
if (Context.Request.Cookies.TryGetValue("minMode", out string minModeString))
|
||||
bool.TryParse(minModeString, out minMode);
|
||||
}
|
||||
|
||||
<div class="horizontal-channel-list" style="max-width: @(!Model.MobileLayout ? $"calc(100vw - {(minMode ? 80 : 312)}px);" : "")">
|
||||
<a href="/feed/channels" class="channel">
|
||||
<i class="bi bi-gear"></i>
|
||||
<div class="name max-lines-2">Manage Channels</div>
|
||||
</a>
|
||||
<a href="/rss?token=@HttpUtility.UrlEncode(Model.RssToken)" class="channel">
|
||||
<i class="bi bi-rss"></i>
|
||||
<div class="name max-lines-2">RSS Feed</div>
|
||||
</a>
|
||||
@foreach (LTChannel channel in Model.Channels)
|
||||
{
|
||||
<a href="/channel/@channel.ChannelId" class="channel">
|
||||
<img src="@channel.IconUrl" loading="lazy">
|
||||
<div class="name max-lines-2">@channel.Name</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rich-video-grid">
|
||||
@foreach (FeedVideo video in Model.Videos)
|
||||
{
|
||||
<div class="video">
|
||||
<a href="/watch?v=@video.Id" class="thumbnail img-thumbnail">
|
||||
<img src="@video.Thumbnail" loading="lazy">
|
||||
</a>
|
||||
<a href="/channel/@video.ChannelId" class="avatar">
|
||||
<img src="@Model.Channels.First(x => x.ChannelId == video.ChannelId).IconUrl">
|
||||
</a>
|
||||
<div class="info">
|
||||
<a href="/watch?v=@video.Id" class="title max-lines-2">@video.Title</a>
|
||||
<div>
|
||||
<a href="/channel/@video.ChannelId">@video.ChannelName</a>
|
||||
<div>
|
||||
<span>@video.ViewCount views</span>
|
||||
<span>•</span>
|
||||
<span>@video.PublishedDate.Humanize(DateTimeOffset.Now)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
15
core/LightTube/Views/Home/Index.cshtml
Normal file
15
core/LightTube/Views/Home/Index.cshtml
Normal file
@ -0,0 +1,15 @@
|
||||
@model LightTube.Contexts.BaseContext
|
||||
@{
|
||||
ViewBag.Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["og:title"] = "LightTube",
|
||||
["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}",
|
||||
["og:description"] = "An alternative, privacy respecting front end for YouTube",
|
||||
};
|
||||
ViewData["Title"] = "Home Page";
|
||||
ViewData["SelectedGuideItem"] = "home";
|
||||
}
|
||||
|
||||
<div style="text-align: center">
|
||||
<h1>@Configuration.Instance.Interface.MessageOfTheDay</h1>
|
||||
</div>
|
||||
17
core/LightTube/Views/Shared/Error.cshtml
Normal file
17
core/LightTube/Views/Shared/Error.cshtml
Normal file
@ -0,0 +1,17 @@
|
||||
@model LightTube.Contexts.ErrorContext
|
||||
@{
|
||||
ViewData["Title"] = "Error";
|
||||
}
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
<p>
|
||||
You can try other alternatives to access this resource such as:
|
||||
<b>
|
||||
<a href="https://invidio.us@($"{Model.Path}{Context.Request.QueryString}")">Invidious</a>
|
||||
</b>
|
||||
or
|
||||
<b>
|
||||
<a href="https://youtube.com@($"{Model.Path}{Context.Request.QueryString}")">YouTube</a>
|
||||
</b>
|
||||
</p>
|
||||
139
core/LightTube/Views/Shared/_Layout.cshtml
Normal file
139
core/LightTube/Views/Shared/_Layout.cshtml
Normal file
@ -0,0 +1,139 @@
|
||||
@using System.Web
|
||||
@using LightTube.Contexts
|
||||
@model LightTube.Contexts.BaseContext
|
||||
@{
|
||||
bool compatibility = false;
|
||||
if (Context.Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||
bool.TryParse(compatibilityString, out compatibility);
|
||||
|
||||
bool minMode = false;
|
||||
if (Context.Request.Cookies.TryGetValue("minMode", out string minModeString))
|
||||
bool.TryParse(minModeString, out minMode);
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<meta property="og:site_name" content="lighttube" />
|
||||
<meta property="og:type" content="website" />
|
||||
@if (ViewBag.Metadata is not null)
|
||||
{
|
||||
@foreach (KeyValuePair<string, string> metaTag in ViewBag.Metadata)
|
||||
{
|
||||
if (metaTag.Key.StartsWith("og:"))
|
||||
{
|
||||
<meta property="@metaTag.Key" content="@metaTag.Value"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<meta name="@metaTag.Key" content="@metaTag.Value"/>
|
||||
}
|
||||
}
|
||||
}
|
||||
<meta property="theme-color" content="#AA0000" />
|
||||
<title>@ViewData["Title"] - lighttube</title>
|
||||
@if ((ViewData["HideGuide"] ?? false).Equals(true))
|
||||
{
|
||||
<style> .guide { display: none !important; } </style>
|
||||
}
|
||||
@{
|
||||
Context.Request.Cookies.TryGetValue("theme", out string theme);
|
||||
if (!new[] { "light", "dark" }.Contains(theme)) theme = "light";
|
||||
}
|
||||
<link rel="stylesheet" href="@($"~/css/colors-{theme}.css")" asp-append-version="true"/>
|
||||
@if (Model.MobileLayout)
|
||||
{
|
||||
<link rel="stylesheet" href="~/css/mobile.css" asp-append-version="true"/>
|
||||
<link rel="stylesheet" href="~/css/lt-video/player-mobile.css" asp-append-version="true"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<link rel="stylesheet" href="~/css/desktop.css" asp-append-version="true"/>
|
||||
<link rel="stylesheet" href="~/css/lt-video/player-desktop.css" asp-append-version="true"/>
|
||||
}
|
||||
<link rel="stylesheet" href="~/css/bootstrap-icons/bootstrap-icons.css" asp-append-version="true"/>
|
||||
<link rel="icon" href="~/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="top-bar @(ViewData["UseFullSizeSearchBar"]?.Equals(true) ?? false ? "full-size-search" : "")">
|
||||
<a class="logo" href="/">light<b>tube</b></a>
|
||||
<div class="divider"></div>
|
||||
<form action="/results">
|
||||
<input type="text" placeholder="Search" name="search_query" value="@(Model is SearchContext ctx ? ctx.Query : Context.Request.Cookies.TryGetValue("search_query", out string s) ? s : "")">
|
||||
<input type="submit" value="Search">
|
||||
</form>
|
||||
<div class="divider"></div>
|
||||
<div class="search-button">
|
||||
<a class="icon-link" href="/results">
|
||||
<i class="bi bi-search"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="account" tabindex="-1">
|
||||
<a class="icon-link" href="/Account">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</a>
|
||||
<div class="account-menu">
|
||||
@Html.Partial("_LoginLogoutPartial")
|
||||
<div class="guide-item"><a href="/toggles/theme?redirectUrl=@(HttpUtility.UrlEncode($"{Context.Request.Path}{Context.Request.QueryString}"))">Toggle Theme</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guide @(minMode ? "minmode" : "")">
|
||||
<div class="guide-item @(ViewData["SelectedGuideItem"] as string == "home" ? "active" : "")">
|
||||
<a href="/">
|
||||
<i class="icon bi bi-house-door"></i>
|
||||
Home
|
||||
</a>
|
||||
</div>
|
||||
<div class="guide-item @(ViewData["SelectedGuideItem"] as string == "explore" ? "active" : "")">
|
||||
<a href="/feed/explore">
|
||||
<i class="icon bi bi-compass"></i>
|
||||
Explore
|
||||
</a>
|
||||
</div>
|
||||
<div class="guide-item @(ViewData["SelectedGuideItem"] as string == "subs" ? "active" : "")">
|
||||
<a href="/feed/subscriptions">
|
||||
<i class="icon bi bi-inboxes"></i>
|
||||
Subscriptions
|
||||
</a>
|
||||
</div>
|
||||
<div class="guide-item @(ViewData["SelectedGuideItem"] as string == "library" ? "active" : "")">
|
||||
<a href="/feed/library">
|
||||
<i class="icon bi bi-list-ul"></i>
|
||||
Library
|
||||
</a>
|
||||
</div>
|
||||
<div class="hide-on-minmode guide-item">
|
||||
<a href="/toggles/collapse_guide?redirectUrl=@(HttpUtility.UrlEncode($"{Context.Request.Path}{Context.Request.QueryString}"))">
|
||||
<i class="icon"><i class="bi bi-arrow-left-square"></i></i>
|
||||
Collapse Guide
|
||||
</a>
|
||||
</div>
|
||||
<div class="show-on-minmode guide-item">
|
||||
<a href="/toggles/collapse_guide?redirectUrl=@(HttpUtility.UrlEncode($"{Context.Request.Path}{Context.Request.QueryString}"))">
|
||||
<i class="icon"><i class="bi bi-arrow-right-square"></i></i>
|
||||
Expand
|
||||
</a>
|
||||
</div>
|
||||
<hr class="hide-on-minmode">
|
||||
<p class="hide-on-minmode">
|
||||
<a href="https://gitlab.com/kuylar/lighttube/-/blob/master/README.md">About</a><br>
|
||||
<a href="https://gitlab.com/kuylar/lighttube/-/blob/master/OTHERLIBS.md">How LightTube works</a><br>
|
||||
<a href="https://gitlab.com/kuylar/lighttube">Source code</a>
|
||||
<a href="https://gitlab.com/kuylar/lighttube/-/wikis/XML-API">API</a>
|
||||
<a href="https://gitlab.com/kuylar/lighttube/-/blob/master/LICENSE">License</a><br>
|
||||
<span style="font-weight: normal">Running on LightTube v@(Utils.GetVersion())</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app">
|
||||
@RenderBody()
|
||||
</div>
|
||||
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
16
core/LightTube/Views/Shared/_LoginLogoutPartial.cshtml
Normal file
16
core/LightTube/Views/Shared/_LoginLogoutPartial.cshtml
Normal file
@ -0,0 +1,16 @@
|
||||
@using LightTube.Database
|
||||
@if (Context.TryGetUser(out LTUser user, "web"))
|
||||
{
|
||||
<div class="guide-item"><a>@user.UserID.Split("@")[0]</a></div>
|
||||
@if (user.PasswordHash != "local_account")
|
||||
{
|
||||
<div class="guide-item"><a href="/Account/Logins">Active logins</a></div>
|
||||
}
|
||||
<div class="guide-item"><a href="/Account/Logout">Log out</a></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="guide-item"><a href="/Account/Login">Log in</a></div>
|
||||
<div class="guide-item"><a href="/Account/Register">Register</a></div>
|
||||
}
|
||||
<div class="guide-item"><a href="/Account/Settings">Settings</a></div>
|
||||
80
core/LightTube/Views/Youtube/Channel.cshtml
Normal file
80
core/LightTube/Views/Youtube/Channel.cshtml
Normal file
@ -0,0 +1,80 @@
|
||||
@using InnerTube.Models
|
||||
@using System.Web
|
||||
@model LightTube.Contexts.ChannelContext
|
||||
|
||||
@{
|
||||
ViewBag.Metadata = new Dictionary<string, string>();
|
||||
ViewBag.Metadata["og:title"] = Model.Channel.Name;
|
||||
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Channel.Avatars.FirstOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Channel.Avatars.LastOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["og:description"] = Model.Channel.Description;
|
||||
ViewBag.Title = Model.Channel.Name;
|
||||
Layout = "_Layout";
|
||||
|
||||
DynamicItem[] contents;
|
||||
try
|
||||
{
|
||||
contents = ((ItemSectionItem)((ItemSectionItem)Model.Channel.Videos[0]).Contents[0]).Contents;
|
||||
}
|
||||
catch
|
||||
{
|
||||
contents = Model.Channel.Videos;
|
||||
}
|
||||
}
|
||||
|
||||
<div class="channel-page">
|
||||
@if (Model.Channel.Banners.Length > 0)
|
||||
{
|
||||
<img class="channel-banner" alt="Channel Banner" src="@Model.Channel.Banners.Last().Url">
|
||||
}
|
||||
|
||||
<div class="channel-info-container">
|
||||
<div class="channel-info">
|
||||
<a href="/channel/@Model.Channel.Id" class="avatar">
|
||||
<img src="@Model.Channel.Avatars.LastOrDefault()?.Url" alt="Channel Avatar">
|
||||
</a>
|
||||
<div class="name">
|
||||
<a>@Model.Channel.Name</a>
|
||||
<span>@Model.Channel.Subscribers</span>
|
||||
</div>
|
||||
<button class="subscribe-button" data-cid="@Model.Channel.Id">Subscribe</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>About</h3>
|
||||
<p>@Html.Raw(Model.Channel.GetHtmlDescription())</p>
|
||||
<br><br>
|
||||
<h3>Uploads</h3>
|
||||
<div class="video-grid">
|
||||
@foreach (VideoItem video in contents.Where(x => x is VideoItem).Cast<VideoItem>())
|
||||
{
|
||||
<a href="/watch?v=@video.Id" class="video">
|
||||
<div class="thumbnail" style="background-image: url('@video.Thumbnails.LastOrDefault()?.Url')"><span class="video-length">@video.Duration</span></div>
|
||||
<div class="info">
|
||||
<span class="title max-lines-2">@video.Title</span>
|
||||
<div>
|
||||
<div>
|
||||
<span>@video.Views views</span>
|
||||
<span>@video.UploadedAt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="pagination-buttons">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ContinuationToken))
|
||||
{
|
||||
<a href="/channel?id=@Model.Id">First Page</a>
|
||||
}
|
||||
<div class="divider"></div>
|
||||
<span>•</span>
|
||||
<div class="divider"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(contents.FirstOrDefault(x => x is ContinuationItem)?.Id))
|
||||
{
|
||||
<a href="/channel/@Model.Id?continuation=@(contents.FirstOrDefault(x => x is ContinuationItem)?.Id)">Next Page</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
95
core/LightTube/Views/Youtube/Download.cshtml
Normal file
95
core/LightTube/Views/Youtube/Download.cshtml
Normal file
@ -0,0 +1,95 @@
|
||||
@using System.Web
|
||||
@using InnerTube
|
||||
@using InnerTube.Models
|
||||
@model LightTube.Contexts.PlayerContext
|
||||
|
||||
@{
|
||||
ViewBag.Metadata = new Dictionary<string, string>();
|
||||
ViewBag.Metadata["author"] = Model.Video.Channel.Name;
|
||||
ViewBag.Metadata["og:title"] = Model.Player.Title;
|
||||
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.FirstOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.LastOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Title = Model.Player.Title;
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="playlist-page">
|
||||
<div class="playlist-info">
|
||||
<div class="thumbnail" style="background-image: url('@Model.Player.Thumbnails.Last().Url')">
|
||||
<a href="/watch?v=@Model.Player.Id">Watch</a>
|
||||
</div>
|
||||
<p class="title">@Model.Player.Title</p>
|
||||
<span class="info">@Model.Video.Views • @Model.Video.UploadDate</span>
|
||||
<div class="channel-info">
|
||||
<a href="/channel/@Model.Player.Channel.Id" class="avatar">
|
||||
<img src="@Model.Player.Channel.Avatars.LastOrDefault()?.Url">
|
||||
</a>
|
||||
<div class="name">
|
||||
<a class="name" href="/channel/@Model.Player.Channel.Id">@Model.Player.Channel.Name</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-list download-list playlist-video-list">
|
||||
<div class="format-list">
|
||||
<h2>Muxed formats</h2>
|
||||
<p>These downloads have both video and audio in them</p>
|
||||
@foreach (Format format in Model.Player.Formats)
|
||||
{
|
||||
<div class="download-format">
|
||||
<div>
|
||||
@format.FormatNote
|
||||
</div>
|
||||
<a href="/proxy/download/@Model.Video.Id/@format.FormatId/@(HttpUtility.UrlEncode(Model.Video.Title)).@format.GetExtension()">
|
||||
<i class="bi bi-download"></i>
|
||||
Download through LightTube
|
||||
</a>
|
||||
<a href="@format.Url">
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
Download through YouTube
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="format-list">
|
||||
<h2>Audio only formats</h2>
|
||||
<p>These downloads have only have audio in them</p>
|
||||
@foreach (Format format in Model.Player.AdaptiveFormats.Where(x => x.VideoCodec == "none"))
|
||||
{
|
||||
<div class="download-format">
|
||||
<div>
|
||||
@format.FormatNote (Codec: @format.AudioCodec, Sample Rate: @format.AudioSampleRate)
|
||||
</div>
|
||||
<a href="/proxy/download/@Model.Video.Id/@format.FormatId/@(HttpUtility.UrlEncode(Model.Video.Title)).@format.GetExtension()">
|
||||
<i class="bi bi-download"></i>
|
||||
Download through LightTube
|
||||
</a>
|
||||
<a href="@format.Url">
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
Download through YouTube
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="format-list">
|
||||
<h2>Video only formats</h2>
|
||||
<p>These downloads have only have video in them</p>
|
||||
@foreach (Format format in Model.Player.AdaptiveFormats.Where(x => x.AudioCodec == "none"))
|
||||
{
|
||||
<div class="download-format">
|
||||
<div>
|
||||
@format.FormatNote (Codec: @format.VideoCodec)
|
||||
</div>
|
||||
<a href="/proxy/download/@Model.Video.Id/@format.FormatId/@(HttpUtility.UrlEncode(Model.Video.Title)).@format.GetExtension()">
|
||||
<i class="bi bi-download"></i>
|
||||
Download through LightTube
|
||||
</a>
|
||||
<a href="@format.Url">
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
Download through YouTube
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
146
core/LightTube/Views/Youtube/Embed.cshtml
Normal file
146
core/LightTube/Views/Youtube/Embed.cshtml
Normal file
@ -0,0 +1,146 @@
|
||||
@using System.Collections.Specialized
|
||||
@using System.Web
|
||||
@using InnerTube.Models
|
||||
@model LightTube.Contexts.PlayerContext
|
||||
|
||||
@{
|
||||
ViewBag.Metadata = new Dictionary<string, string>();
|
||||
ViewBag.Metadata["author"] = Model.Video.Channel.Name;
|
||||
ViewBag.Metadata["og:title"] = Model.Player.Title;
|
||||
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.FirstOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.LastOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["og:description"] = Model.Player.Description;
|
||||
ViewBag.Title = Model.Player.Title;
|
||||
|
||||
Layout = null;
|
||||
try
|
||||
{
|
||||
ViewBag.Metadata["og:video"] = $"/proxy/video?url={HttpUtility.UrlEncode(Model.Player.Formats.First().Url.ToString())}";
|
||||
Model.Resolution ??= Model.Player.Formats.First().FormatNote;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
bool live = Model.Player.Formats.Length == 0 && Model.Player.AdaptiveFormats.Length > 0;
|
||||
bool canPlay = true;
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<meta property="og:site_name" content="lighttube"/>
|
||||
<meta property="og:type" content="website"/>
|
||||
@if (ViewBag.Metadata is not null)
|
||||
{
|
||||
@foreach (KeyValuePair<string, string> metaTag in ViewBag.Metadata)
|
||||
{
|
||||
if (metaTag.Key.StartsWith("og:"))
|
||||
{
|
||||
<meta property="@metaTag.Key" content="@metaTag.Value"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<meta name="@metaTag.Key" content="@metaTag.Value"/>
|
||||
}
|
||||
}
|
||||
}
|
||||
<meta property="theme-color" content="#AA0000"/>
|
||||
<title>@ViewData["Title"] - lighttube</title>
|
||||
<link rel="stylesheet" href="~/css/bootstrap-icons/bootstrap-icons.css"/>
|
||||
<link rel="stylesheet" href="~/css/desktop.css" asp-append-version="true"/>
|
||||
<link rel="stylesheet" href="~/css/lt-video/player-desktop.css" asp-append-version="true"/>
|
||||
<link rel="icon" href="~/favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@if (live)
|
||||
{
|
||||
<video class="player" poster="@Model.Player.Thumbnails.LastOrDefault()?.Url">
|
||||
</video>
|
||||
}
|
||||
else if (Model.Player.Formats.Length > 0)
|
||||
{
|
||||
<video class="player" controls src="/proxy/media/@Model.Player.Id/@HttpUtility.UrlEncode(Model.Player.Formats.First(x => x.FormatNote == Model.Resolution && x.FormatId != "17").FormatId)" poster="@Model.Player.Thumbnails.LastOrDefault()?.Url">
|
||||
@foreach (Subtitle subtitle in Model.Player.Subtitles ?? Array.Empty<Subtitle>())
|
||||
{
|
||||
@:<track src="/proxy/caption/@Model.Player.Id/@HttpUtility.UrlEncode(subtitle.Language).Replace("+", "%20")" label="@subtitle.Language" kind="subtitles">
|
||||
}
|
||||
</video>
|
||||
}
|
||||
else
|
||||
{
|
||||
canPlay = false;
|
||||
<div id="player" class="player error" style="background-image: url('@Model.Player.Thumbnails.LastOrDefault()?.Url')">
|
||||
@if (string.IsNullOrWhiteSpace(Model.Player.ErrorMessage))
|
||||
{
|
||||
<span>
|
||||
No playable streams returned from the API (@Model.Player.Formats.Length/@Model.Player.AdaptiveFormats.Length)
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>
|
||||
@Model.Player.ErrorMessage
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (canPlay)
|
||||
{
|
||||
<script src="/js/lt-video/player-desktop.js"></script>
|
||||
@if (!Model.CompatibilityMode && !live)
|
||||
{
|
||||
<script src="/js/shaka-player/shaka-player.compiled.min.js"></script>
|
||||
<script>
|
||||
let player = undefined;
|
||||
loadPlayerWithShaka("video", {
|
||||
"id": "@Model.Video.Id",
|
||||
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||
"embed": true,
|
||||
"live": false,
|
||||
"storyboard": "/proxy/image?url=@HttpUtility.UrlEncode(Model.Player.Storyboards.FirstOrDefault())"
|
||||
}, [
|
||||
@foreach(Format f in Model.Player.Formats.Reverse())
|
||||
{
|
||||
@:{"height": @f.Resolution.Split("x")[1],"label":"@f.FormatName","src": "/proxy/video?url=@HttpUtility.UrlEncode(f.Url)"},
|
||||
}
|
||||
], "https://@(Context.Request.Host)/manifest/@(Model.Video.Id).mpd").then(x => player = x).catch(alert);;
|
||||
</script>
|
||||
}
|
||||
else if (live)
|
||||
{
|
||||
<script src="/js/hls.js/hls.min.js"></script>
|
||||
<script>
|
||||
let player = undefined;
|
||||
loadPlayerWithHls("video", {
|
||||
"id": "@(Model.Video.Id)",
|
||||
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||
"embed": true,
|
||||
"live": true
|
||||
}, "https://@(Context.Request.Host)/manifest/@(Model.Video.Id).m3u8").then(x => player = x).catch(alert);
|
||||
</script>
|
||||
}
|
||||
else
|
||||
{
|
||||
<script>
|
||||
const player = new Player("video", {
|
||||
"id": "@Model.Video.Id",
|
||||
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||
"embed": true,
|
||||
"live": false,
|
||||
"storyboard": "/proxy/image?url=@HttpUtility.UrlEncode(Model.Player.Storyboards.FirstOrDefault())"
|
||||
}, [
|
||||
@foreach(Format f in Model.Player.Formats.Reverse())
|
||||
{
|
||||
@:{"height": @f.Resolution.Split("x")[1],"label":"@f.FormatName","src": "/proxy/video?url=@HttpUtility.UrlEncode(f.Url)"},
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
}
|
||||
}
|
||||
</body>
|
||||
</html>
|
||||
85
core/LightTube/Views/Youtube/Playlist.cshtml
Normal file
85
core/LightTube/Views/Youtube/Playlist.cshtml
Normal file
@ -0,0 +1,85 @@
|
||||
@using InnerTube.Models
|
||||
@using System.Web
|
||||
@model LightTube.Contexts.PlaylistContext
|
||||
|
||||
@{
|
||||
ViewBag.Title = Model.Playlist.Title;
|
||||
ViewBag.Metadata = new Dictionary<string, string>();
|
||||
ViewBag.Metadata["og:title"] = Model.Playlist.Title;
|
||||
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Playlist.Thumbnail.FirstOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Playlist.Thumbnail.LastOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["og:description"] = Model.Playlist.Description;
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||
{
|
||||
<div class="playlist-message" style="padding: 16px;background-color: var(--border-color); color: var(--text-primary);">
|
||||
@Model.Message
|
||||
</div>
|
||||
}
|
||||
<div class="playlist-page">
|
||||
<div class="playlist-info">
|
||||
<div class="thumbnail" style="background-image: url('@Model.Playlist.Thumbnail.LastOrDefault()?.Url')">
|
||||
<a href="/watch?v=@Model.Playlist.Videos.FirstOrDefault()?.Id&list=@Model.Id">Play all</a>
|
||||
</div>
|
||||
<p class="title">@Model.Playlist.Title</p>
|
||||
<span class="info">@Model.Playlist.VideoCount videos • @Model.Playlist.ViewCount views • @Model.Playlist.LastUpdated</span>
|
||||
<span class="description">@Html.Raw(Model.Playlist.GetHtmlDescription())</span>
|
||||
<a href="/playlist?list=@Model.Id&remove=true" class="login-button" style="margin:unset;">
|
||||
<i class="bi bi-trash"></i>
|
||||
Delete playlist
|
||||
</a>
|
||||
<div class="channel-info">
|
||||
<a href="/channel/@Model.Playlist.Channel.Id" class="avatar">
|
||||
<img src="@Model.Playlist.Channel.Avatars.LastOrDefault()?.Url">
|
||||
</a>
|
||||
<div class="name">
|
||||
<a class="name" href="/channel/@Model.Playlist.Channel.Id">@Model.Playlist.Channel.Name</a>
|
||||
</div>
|
||||
<button class="subscribe-button" data-cid="@Model.Playlist.Channel.Id">Subscribe</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-list playlist-video-list">
|
||||
@foreach (PlaylistVideoItem video in Model.Playlist.Videos.Cast<PlaylistVideoItem>())
|
||||
{
|
||||
<div class="playlist-video">
|
||||
<a href="/watch?v=@video.Id&list=@Model.Id" class="index">
|
||||
@video.Index
|
||||
</a>
|
||||
<a href="/watch?v=@video.Id&list=@Model.Id" class="thumbnail"
|
||||
style="background-image: url('@video.Thumbnails.LastOrDefault()?.Url')">
|
||||
<span class="video-length">@video.Duration</span>
|
||||
</a>
|
||||
<div class="info">
|
||||
<a href="/watch?v=@video.Id&list=@Model.Id" class="title max-lines-2">
|
||||
@video.Title
|
||||
</a>
|
||||
<div>
|
||||
<a href="/channel/@video.Channel.Name">@video.Channel.Name</a>
|
||||
</div>
|
||||
</div>
|
||||
@if (Model.Editable)
|
||||
{
|
||||
<a href="/playlist?list=@Model.Id&delete=@(video.Index - 1)" class="edit">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagination-buttons">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ContinuationToken))
|
||||
{
|
||||
<a href="/playlist?list=@Model.Id">First Page</a>
|
||||
}
|
||||
<div class="divider"></div>
|
||||
<span>•</span>
|
||||
<div class="divider"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Playlist.ContinuationKey))
|
||||
{
|
||||
<a href="/playlist?list=@Model.Id&continuation=@Model.Playlist.ContinuationKey">Next Page</a>
|
||||
}
|
||||
</div>
|
||||
28
core/LightTube/Views/Youtube/Search.cshtml
Normal file
28
core/LightTube/Views/Youtube/Search.cshtml
Normal file
@ -0,0 +1,28 @@
|
||||
@using InnerTube.Models
|
||||
@model LightTube.Contexts.SearchContext
|
||||
|
||||
@{
|
||||
ViewBag.Title = Model.Query;
|
||||
Layout = "_Layout";
|
||||
ViewData["UseFullSizeSearchBar"] = Model.MobileLayout;
|
||||
}
|
||||
|
||||
<div class="video-list">
|
||||
@foreach (DynamicItem preview in Model.Results.Results)
|
||||
{
|
||||
@preview.GetHtml()
|
||||
}
|
||||
</div>
|
||||
<div class="pagination-buttons">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ContinuationKey))
|
||||
{
|
||||
<a href="/results?search_query=@Model.Query">First Page</a>
|
||||
}
|
||||
<div class="divider"></div>
|
||||
<span>•</span>
|
||||
<div class="divider"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Results.ContinuationKey))
|
||||
{
|
||||
<a href="/results?search_query=@Model.Query&continuation=@Model.Results.ContinuationKey">Next Page</a>
|
||||
}
|
||||
</div>
|
||||
325
core/LightTube/Views/Youtube/Watch.cshtml
Normal file
325
core/LightTube/Views/Youtube/Watch.cshtml
Normal file
@ -0,0 +1,325 @@
|
||||
@using System.Text.RegularExpressions
|
||||
@using System.Web
|
||||
@using InnerTube.Models
|
||||
@model LightTube.Contexts.PlayerContext
|
||||
|
||||
@{
|
||||
bool compatibility = false;
|
||||
if (Context.Request.Cookies.TryGetValue("compatibility", out string compatibilityString))
|
||||
bool.TryParse(compatibilityString, out compatibility);
|
||||
|
||||
ViewBag.Metadata = new Dictionary<string, string>();
|
||||
ViewBag.Metadata["author"] = Model.Video.Channel.Name;
|
||||
ViewBag.Metadata["og:title"] = Model.Player.Title;
|
||||
ViewBag.Metadata["og:url"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}{Url.ActionContext.HttpContext.Request.Path}{Url.ActionContext.HttpContext.Request.QueryString}";
|
||||
ViewBag.Metadata["og:image"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.FirstOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["twitter:card"] = $"{Url.ActionContext.HttpContext.Request.Scheme}://{Url.ActionContext.HttpContext.Request.Host}/proxy/image?url={HttpUtility.UrlEncode(Model.Player.Thumbnails.LastOrDefault()?.Url?.ToString())}";
|
||||
ViewBag.Metadata["og:description"] = Model.Player.Description;
|
||||
ViewBag.Title = Model.Player.Title;
|
||||
|
||||
Layout = "_Layout";
|
||||
try
|
||||
{
|
||||
ViewBag.Metadata["og:video"] = $"/proxy/video?url={HttpUtility.UrlEncode(Model.Player.Formats.First().Url.ToString())}";
|
||||
Model.Resolution ??= Model.Player.Formats.First().FormatNote;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
ViewData["HideGuide"] = true;
|
||||
|
||||
bool live = Model.Player.Formats.Length == 0 && Model.Player.AdaptiveFormats.Length > 0;
|
||||
string description = Model.Video.GetHtmlDescription();
|
||||
const string youtubePattern = @"[w.]*youtube[-nockie]*\.com";
|
||||
|
||||
// turn URLs into hyperlinks
|
||||
Regex urlRegex = new(youtubePattern, RegexOptions.IgnoreCase);
|
||||
Match m;
|
||||
for (m = urlRegex.Match(description); m.Success; m = m.NextMatch())
|
||||
description = description.Replace(m.Groups[0].ToString(),
|
||||
$"{Url.ActionContext.HttpContext.Request.Host}");
|
||||
|
||||
bool canPlay = true;
|
||||
}
|
||||
|
||||
<!-- TODO: chapters -->
|
||||
<div class="watch-page">
|
||||
<div class="primary">
|
||||
<div class="video-player-container">
|
||||
|
||||
@if (live)
|
||||
{
|
||||
<video class="player" poster="@Model.Player.Thumbnails.LastOrDefault()?.Url">
|
||||
</video>
|
||||
}
|
||||
else if (Model.Player.Formats.Length > 0)
|
||||
{
|
||||
<video class="player" controls src="/proxy/media/@Model.Player.Id/@HttpUtility.UrlEncode(Model.Player.Formats.First(x => x.FormatNote == Model.Resolution && x.FormatId != "17").FormatId)" poster="@Model.Player.Thumbnails.LastOrDefault()?.Url">
|
||||
@foreach (Subtitle subtitle in Model.Player.Subtitles ?? Array.Empty<Subtitle>())
|
||||
{
|
||||
@:<track src="/proxy/caption/@Model.Player.Id/@HttpUtility.UrlEncode(subtitle.Language).Replace("+", "%20")" label="@subtitle.Language" kind="subtitles">
|
||||
}
|
||||
</video>
|
||||
}
|
||||
else
|
||||
{
|
||||
canPlay = false;
|
||||
<div id="player" class="player error" style="background-image: url('@Model.Player.Thumbnails.LastOrDefault()?.Url')">
|
||||
@if (string.IsNullOrWhiteSpace(Model.Player.ErrorMessage))
|
||||
{
|
||||
<span>
|
||||
No playable streams returned from the API (@Model.Player.Formats.Length/@Model.Player.AdaptiveFormats.Length)
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>
|
||||
@Model.Player.ErrorMessage
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.MobileLayout)
|
||||
{
|
||||
<div class="video-info">
|
||||
<div class="video-title">@Model.Video.Title</div>
|
||||
<div class="video-info-bar">
|
||||
<span>@Model.Video.Views</span>
|
||||
<span>Published @Model.Video.UploadDate</span>
|
||||
<div class="divider"></div>
|
||||
<div class="video-info-buttons">
|
||||
<div>
|
||||
<i class="bi bi-hand-thumbs-up"></i><span>@Model.Engagement.Likes</span>
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-hand-thumbs-down"></i><span>@Model.Engagement.Dislikes</span>
|
||||
</div>
|
||||
<a href="/download?v=@Model.Video.Id">
|
||||
<i class="bi bi-download"></i>
|
||||
Download
|
||||
</a>
|
||||
<a href="/Account/AddVideoToPlaylist?v=@Model.Video.Id">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
Save
|
||||
</a>
|
||||
<a href="https://www.youtube.com/watch?v=@Model.Video.Id">
|
||||
<i class="bi bi-share"></i>
|
||||
YouTube link
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-info">
|
||||
<a href="/channel/@Model.Video.Channel.Id" class="avatar">
|
||||
<img src="@Model.Video.Channel.Avatars.LastOrDefault()?.Url">
|
||||
</a>
|
||||
<div class="name">
|
||||
<a href="/channel/@Model.Video.Channel.Id">@Model.Video.Channel.Name</a>
|
||||
</div>
|
||||
<button class="subscribe-button" data-cid="@Model.Video.Channel.Id">Subscribe</button>
|
||||
</div>
|
||||
<p class="description">@Html.Raw(description)</p>
|
||||
</div>
|
||||
<hr>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="video-info">
|
||||
<div class="video-title">@Model.Video.Title</div>
|
||||
<p class="video-sub-info description">
|
||||
<span>@Model.Video.Views @Model.Video.UploadDate</span> @Html.Raw(description)
|
||||
</p>
|
||||
<div class="video-info-buttons">
|
||||
<div>
|
||||
<i class="bi bi-hand-thumbs-up"></i>
|
||||
@Model.Engagement.Likes
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-hand-thumbs-down"></i>
|
||||
@Model.Engagement.Dislikes
|
||||
</div>
|
||||
<a href="/download?v=@Model.Player.Id">
|
||||
<i class="bi bi-download"></i>
|
||||
Download
|
||||
</a>
|
||||
<a href="/Account/AddVideoToPlaylist?v=@Model.Video.Id">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
Save
|
||||
</a>
|
||||
<a href="https://www.youtube.com/watch?v=@Model.Video.Id">
|
||||
<i class="bi bi-share"></i>
|
||||
YouTube link
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-info__bordered">
|
||||
<a href="/channel/@Model.Video.Channel.Id" class="avatar">
|
||||
<img src="@Model.Video.Channel.Avatars.FirstOrDefault()?.Url">
|
||||
</a>
|
||||
<div class="name">
|
||||
<a href="/channel/@Model.Video.Channel.Id">@Model.Video.Channel.Name</a>
|
||||
</div>
|
||||
<div class="subscriber-count">
|
||||
@Model.Video.Channel.SubscriberCount
|
||||
</div>
|
||||
<button class="subscribe-button" data-cid="@Model.Video.Channel.Id">Subscribe</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="secondary">
|
||||
<noscript>
|
||||
<div class="resolutions-list">
|
||||
<h3>Change Resolution</h3>
|
||||
<div>
|
||||
@foreach (Format format in Model.Player.Formats.Where(x => x.FormatId != "17"))
|
||||
{
|
||||
@if (format.FormatNote == Model.Resolution)
|
||||
{
|
||||
<b>@format.FormatNote (current)</b>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/watch?v=@Model.Player.Id&quality=@format.FormatNote">@format.FormatNote</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
<div class="recommended-list">
|
||||
|
||||
@if (Model.Video.Recommended.Length == 0)
|
||||
{
|
||||
<p style="text-align: center">None :(<br>This is most likely an age-restricted video</p>
|
||||
}
|
||||
@foreach (DynamicItem recommendation in Model.Video.Recommended)
|
||||
{
|
||||
switch (recommendation)
|
||||
{
|
||||
case VideoItem video:
|
||||
<div class="video">
|
||||
<a href="/watch?v=@video.Id" class="thumbnail" style="background-image: url('@video.Thumbnails.LastOrDefault()?.Url')">
|
||||
<span class="video-length">@video.Duration</span>
|
||||
</a>
|
||||
<div class="info">
|
||||
<a href="/watch?v=@video.Id" class="title max-lines-2">@video.Title</a>
|
||||
<div>
|
||||
<a href="/channel/@video.Channel.Id" class="max-lines-1">@video.Channel.Name</a>
|
||||
<div>
|
||||
<span>@video.Views views</span>
|
||||
<span>•</span>
|
||||
<span>@video.UploadedAt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
case PlaylistItem playlist:
|
||||
<div class="playlist">
|
||||
<a href="/watch?v=@playlist.FirstVideoId&list=@playlist.Id" class="thumbnail" style="background-image: url('@playlist.Thumbnails.LastOrDefault()?.Url')">
|
||||
<div>
|
||||
<span>@playlist.VideoCount</span>
|
||||
<span>VIDEOS</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="info">
|
||||
<a href="/watch?v=@playlist.FirstVideoId&list=@playlist.Id" class="title max-lines-2">@playlist.Title</a>
|
||||
<div>
|
||||
<a href="/channel/@playlist.Channel.Id">@playlist.Channel.Name</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
case RadioItem radio:
|
||||
<div class="playlist">
|
||||
<a href="/watch?v=@radio.FirstVideoId&list=@radio.Id" class="thumbnail" style="background-image: url('@radio.Thumbnails.LastOrDefault()?.Url')">
|
||||
<div>
|
||||
<span>MIX</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="info">
|
||||
<a href="/watch?v=@radio.FirstVideoId&list=@radio.Id" class="title max-lines-2">@radio.Title</a>
|
||||
<div>
|
||||
<span>@radio.Channel.Name</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
case ContinuationItem continuationItem:
|
||||
break;
|
||||
default:
|
||||
<div class="video">
|
||||
<div class="thumbnail" style="background-image: url('@recommendation.Thumbnails?.LastOrDefault()?.Url')"></div>
|
||||
<div class="info">
|
||||
<span class="title max-lines-2">@recommendation.GetType().Name</span>
|
||||
<div>
|
||||
<b>WARNING:</b> Unknown recommendation type: @recommendation.Id
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (canPlay)
|
||||
{
|
||||
@if (Model.MobileLayout)
|
||||
{
|
||||
<script src="/js/lt-video/player-mobile.js"></script>
|
||||
}
|
||||
else
|
||||
{
|
||||
<script src="/js/lt-video/player-desktop.js"></script>
|
||||
}
|
||||
@if (!Model.CompatibilityMode && !live)
|
||||
{
|
||||
<script src="/js/shaka-player/shaka-player.compiled.min.js"></script>
|
||||
<script>
|
||||
let player = undefined;
|
||||
loadPlayerWithShaka("video", {
|
||||
"id": "@Model.Video.Id",
|
||||
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||
"embed": false,
|
||||
"live": false,
|
||||
"storyboard": "/proxy/image?url=@HttpUtility.UrlEncode(Model.Player.Storyboards.FirstOrDefault())"
|
||||
}, [
|
||||
@foreach (Format f in Model.Player.Formats.Reverse())
|
||||
{
|
||||
@:{"height": @f.Resolution.Split("x")[1],"label":"@f.FormatName","src": "/proxy/video?url=@HttpUtility.UrlEncode(f.Url)"},
|
||||
}
|
||||
], "https://@(Context.Request.Host)/manifest/@(Model.Video.Id).mpd").then(x => player = x).catch(alert);
|
||||
</script>
|
||||
}
|
||||
else if (live)
|
||||
{
|
||||
<script src="/js/hls.js/hls.min.js"></script>
|
||||
<script>
|
||||
let player = undefined;
|
||||
loadPlayerWithHls("video", {
|
||||
"id": "@(Model.Video.Id)",
|
||||
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||
"embed": false,
|
||||
"live": true
|
||||
}, "https://@(Context.Request.Host)/manifest/@(Model.Video.Id).m3u8").then(x => player = x).catch(alert);
|
||||
</script>
|
||||
}
|
||||
else
|
||||
{
|
||||
<script>
|
||||
const player = new Player("video", {
|
||||
"id": "@Model.Video.Id",
|
||||
"title": "@Html.Raw(Model.Video.Title.Replace("\"", "\\\""))",
|
||||
"embed": false,
|
||||
"live": false,
|
||||
"storyboard": "/proxy/image?url=@HttpUtility.UrlEncode(Model.Player.Storyboards.FirstOrDefault())"
|
||||
}, [
|
||||
@foreach (Format f in Model.Player.Formats.Reverse())
|
||||
{
|
||||
@:{"height": @f.Resolution.Split("x")[1],"label":"@f.FormatName","src": "/proxy/media/@(Model.Player.Id)/@(f.FormatId)"},
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
}
|
||||
}
|
||||
3
core/LightTube/Views/_ViewImports.cshtml
Normal file
3
core/LightTube/Views/_ViewImports.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@using LightTube
|
||||
@using LightTube.Models
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
core/LightTube/Views/_ViewStart.cshtml
Normal file
3
core/LightTube/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
113
core/LightTube/YoutubeRSS.cs
Normal file
113
core/LightTube/YoutubeRSS.cs
Normal file
@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace LightTube
|
||||
{
|
||||
public static class YoutubeRSS
|
||||
{
|
||||
private static HttpClient _httpClient = new();
|
||||
|
||||
public static async Task<ChannelFeed> GetChannelFeed(string channelId)
|
||||
{
|
||||
HttpResponseMessage response =
|
||||
await _httpClient.GetAsync("https://www.youtube.com/feeds/videos.xml?channel_id=" + channelId);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.NotFound => new KeyNotFoundException($"Channel '{channelId}' does not exist"),
|
||||
var _ => new Exception("Failed to fetch RSS feed for channel " + channelId)
|
||||
};
|
||||
|
||||
ChannelFeed feed = new();
|
||||
|
||||
string xml = await response.Content.ReadAsStringAsync();
|
||||
XDocument doc = XDocument.Parse(xml);
|
||||
|
||||
feed.Name = doc.Descendants().First(p => p.Name.LocalName == "title").Value;
|
||||
feed.Id = doc.Descendants().First(p => p.Name.LocalName == "channelId").Value;
|
||||
feed.Videos = doc.Descendants().Where(p => p.Name.LocalName == "entry").Select(x => new FeedVideo
|
||||
{
|
||||
Id = x.Descendants().First(p => p.Name.LocalName == "videoId").Value,
|
||||
Title = x.Descendants().First(p => p.Name.LocalName == "title").Value,
|
||||
Description = x.Descendants().First(p => p.Name.LocalName == "description").Value,
|
||||
ViewCount = long.Parse(x.Descendants().First(p => p.Name.LocalName == "statistics").Attribute("views")?.Value ?? "-1"),
|
||||
Thumbnail = x.Descendants().First(p => p.Name.LocalName == "thumbnail").Attribute("url")?.Value,
|
||||
ChannelName = x.Descendants().First(p => p.Name.LocalName == "name").Value,
|
||||
ChannelId = x.Descendants().First(p => p.Name.LocalName == "channelId").Value,
|
||||
PublishedDate = DateTimeOffset.Parse(x.Descendants().First(p => p.Name.LocalName == "published").Value)
|
||||
}).ToArray();
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
public static async Task<FeedVideo[]> GetMultipleFeeds(IEnumerable<string> channelIds)
|
||||
{
|
||||
Task<ChannelFeed>[] feeds = channelIds.Select(YoutubeRSS.GetChannelFeed).ToArray();
|
||||
await Task.WhenAll(feeds);
|
||||
|
||||
List<FeedVideo> videos = new();
|
||||
foreach (ChannelFeed feed in feeds.Select(x => x.Result)) videos.AddRange(feed.Videos);
|
||||
|
||||
videos.Sort((a, b) => DateTimeOffset.Compare(b.PublishedDate, a.PublishedDate));
|
||||
return videos.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChannelFeed
|
||||
{
|
||||
public string Name;
|
||||
public string Id;
|
||||
public FeedVideo[] Videos;
|
||||
}
|
||||
|
||||
public class FeedVideo
|
||||
{
|
||||
public string Id;
|
||||
public string Title;
|
||||
public string Description;
|
||||
public long ViewCount;
|
||||
public string Thumbnail;
|
||||
public string ChannelName;
|
||||
public string ChannelId;
|
||||
public DateTimeOffset PublishedDate;
|
||||
|
||||
public XmlElement GetXmlElement(XmlDocument doc)
|
||||
{
|
||||
XmlElement item = doc.CreateElement("Video");
|
||||
item.SetAttribute("id", Id);
|
||||
item.SetAttribute("views", ViewCount.ToString());
|
||||
item.SetAttribute("uploadedAt", PublishedDate.ToUnixTimeSeconds().ToString());
|
||||
|
||||
XmlElement title = doc.CreateElement("Title");
|
||||
title.InnerText = Title;
|
||||
item.AppendChild(title);
|
||||
XmlElement channel = doc.CreateElement("Channel");
|
||||
channel.SetAttribute("id", ChannelId);
|
||||
|
||||
XmlElement channelTitle = doc.CreateElement("Name");
|
||||
channelTitle.InnerText = ChannelName;
|
||||
channel.AppendChild(channelTitle);
|
||||
|
||||
item.AppendChild(channel);
|
||||
|
||||
XmlElement thumbnail = doc.CreateElement("Thumbnail");
|
||||
thumbnail.InnerText = Thumbnail;
|
||||
item.AppendChild(thumbnail);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Description))
|
||||
{
|
||||
XmlElement description = doc.CreateElement("Description");
|
||||
description.InnerText = Description;
|
||||
item.AppendChild(description);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
core/LightTube/appsettings.Development.json
Normal file
9
core/LightTube/appsettings.Development.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
core/LightTube/appsettings.json
Normal file
10
core/LightTube/appsettings.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
1704
core/LightTube/wwwroot/css/bootstrap-icons/bootstrap-icons.css
vendored
Normal file
1704
core/LightTube/wwwroot/css/bootstrap-icons/bootstrap-icons.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
19
core/LightTube/wwwroot/css/colors-dark.css
Normal file
19
core/LightTube/wwwroot/css/colors-dark.css
Normal file
@ -0,0 +1,19 @@
|
||||
:root {
|
||||
--text-primary: #fff;
|
||||
--text-secondary: #808080;
|
||||
--text-link: #3ea6ff;
|
||||
|
||||
--app-background: #181818;
|
||||
--context-menu-background: #333;
|
||||
--border-color: #444;
|
||||
--item-hover-background: #373737;
|
||||
--item-active-background: #383838;
|
||||
|
||||
--top-bar-background: #202020;
|
||||
--guide-background: #212121;
|
||||
|
||||
--thumbnail-background: #252525;
|
||||
|
||||
--channel-info-background: #181818;
|
||||
--channel-contents-background: #0f0f0f;
|
||||
}
|
||||
19
core/LightTube/wwwroot/css/colors-light.css
Normal file
19
core/LightTube/wwwroot/css/colors-light.css
Normal file
@ -0,0 +1,19 @@
|
||||
:root {
|
||||
--text-primary: #000;
|
||||
--text-secondary: #606060;
|
||||
--text-link: #3ea6ff;
|
||||
|
||||
--app-background: #f9f9f9;
|
||||
--context-menu-background: #f2f2f2;
|
||||
--border-color: #c5c5c5;
|
||||
--item-hover-background: #f2f2f2;
|
||||
--item-active-background: #E5E5E5;;
|
||||
|
||||
--top-bar-background: #FFF;
|
||||
--guide-background: #FFF;
|
||||
|
||||
--thumbnail-background: #CCC;
|
||||
|
||||
--channel-info-background: #fff;
|
||||
--channel-contents-background: #f9f9f9;
|
||||
}
|
||||
1232
core/LightTube/wwwroot/css/desktop.css
Normal file
1232
core/LightTube/wwwroot/css/desktop.css
Normal file
File diff suppressed because it is too large
Load Diff
267
core/LightTube/wwwroot/css/lt-video/player-desktop.css
Normal file
267
core/LightTube/wwwroot/css/lt-video/player-desktop.css
Normal file
@ -0,0 +1,267 @@
|
||||
* {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.player {
|
||||
background-color: #000 !important;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min-content;
|
||||
grid-template-rows: max-content 1fr max-content max-content max-content;
|
||||
gap: 0 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.player * {
|
||||
color: #fff;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.player.embed, video.embed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.player * {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.player > video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
grid-area: 1 / 1 / 6 / 3;
|
||||
}
|
||||
|
||||
.player.hide-controls > .player-title,
|
||||
.player.hide-controls > .player-controls,
|
||||
.player.hide-controls > .player-playback-bar-container,
|
||||
.player.hide-controls > .player-menu {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
grid-area: 1 / 1 / 2 / 3;
|
||||
color: white;
|
||||
z-index: 2;
|
||||
font-size: 27px;
|
||||
background-image: linear-gradient(180deg, #0007 0%, #0000 100%);
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
padding-top: 4px;
|
||||
color: #ddd !important;
|
||||
width: 100%;
|
||||
height: min-content;
|
||||
position: relative;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background-image: linear-gradient(0deg, #0007 0%, #0007 80%, #0000 100%);
|
||||
grid-area: 5 / 1 / 6 / 3;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.player-controls > span {
|
||||
line-height: 48px;
|
||||
height: 48px;
|
||||
font-size: 109%;
|
||||
}
|
||||
|
||||
.player-controls-padding {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.player-button {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
transition: width ease-in 250ms;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 36px;
|
||||
text-align: center;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.player-button, .player-button * {
|
||||
color: #dddddd !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.player-button > i {
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.player-button:hover, .player-button:hover * {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.player-volume {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.player-volume:hover {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.player-button-divider {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.player-button-menu {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.player-menu {
|
||||
grid-area: 3 / 2 / 4 / 3;
|
||||
z-index: 3;
|
||||
position: relative;
|
||||
background-color: #000a !important;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.player-menu > div {
|
||||
overflow-y: scroll;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.player-menu-item {
|
||||
padding: 4px 8px;
|
||||
height: 2rem;
|
||||
line-height: 2rem;
|
||||
color: white;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.player-menu-item > .bi {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.player-menu-item > .bi-::before {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: ""
|
||||
}
|
||||
|
||||
.player-menu-item:hover {
|
||||
background-color: #fff3 !important;
|
||||
}
|
||||
|
||||
.player-playback-bar {
|
||||
transition: width linear 100ms;
|
||||
}
|
||||
|
||||
.player-playback-bar-container {
|
||||
grid-area: 4 / 1 / 5 / 3;
|
||||
height: 4px;
|
||||
transition: height linear 100ms;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.player-playback-bar-bg {
|
||||
background-color: #fff3 !important;
|
||||
width: calc(100% - 24px);
|
||||
margin: auto;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.player-playback-bar-bg > * {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
|
||||
.player-playback-bar-container:hover {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.player-playback-bar-buffer {
|
||||
background-color: #fffa !important;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.player-playback-bar-fg {
|
||||
background-color: #f00 !important;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.player-playback-bar-hover {
|
||||
width: min-content !important;
|
||||
padding: 4px;
|
||||
position: fixed;
|
||||
color: white;
|
||||
display: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.player-playback-bar-hover > span {
|
||||
background-color: #000 !important;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.player-storyboard-image-container {
|
||||
background-repeat: no-repeat;
|
||||
display: inline-block;
|
||||
width: 144px;
|
||||
height: 81px;
|
||||
}
|
||||
|
||||
.player-storyboard-image {
|
||||
background-repeat: no-repeat;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 27px;
|
||||
background-position-x: 0;
|
||||
background-position-y: 0;
|
||||
transform: scale(3);
|
||||
position: relative;
|
||||
box-sizing: content-box;
|
||||
border: 1px solid white;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
.player-buffering {
|
||||
grid-area: 1 / 1 / 6 / 3;
|
||||
background-color: #000A;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.player-buffering-spinner {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
153
core/LightTube/wwwroot/css/lt-video/player-mobile.css
Normal file
153
core/LightTube/wwwroot/css/lt-video/player-mobile.css
Normal file
@ -0,0 +1,153 @@
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.player {
|
||||
background-color: #000 !important;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min-content;
|
||||
grid-template-rows: max-content 1fr max-content max-content max-content;
|
||||
gap: 0 0;
|
||||
width: 100%;
|
||||
/*
|
||||
height: 100%;
|
||||
*/
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.player * {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player.embed, video.embed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.player * {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.player > video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
grid-area: 1 / 1 / 6 / 3;
|
||||
}
|
||||
|
||||
.player.hide-controls > .player-title,
|
||||
.player.hide-controls > .player-controls,
|
||||
.player.hide-controls > .player-playback-bar-container,
|
||||
.player.hide-controls > .player-menu {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
background-color: #0007;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
grid-area: 1 / 1 / 6 / 3;
|
||||
}
|
||||
|
||||
.player-button {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
font-size: 90px;
|
||||
text-align: center;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.player-tiny-button {
|
||||
width: 40px;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.player-tiny-button > i {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.player-button, .player-button * {
|
||||
color: #dddddd !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.player-button > i {
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.player-button:hover, .player-button:hover * {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.player-playback-bar {
|
||||
transition: width linear 100ms;
|
||||
}
|
||||
|
||||
.player-playback-bar-container {
|
||||
grid-area: 4 / 1 / 5 / 3;
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 8px;
|
||||
transition: height linear 100ms;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.player-playback-bar-bg {
|
||||
background-color: #fff3 !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.player-playback-bar-bg > * {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
|
||||
.player-playback-bar-buffer {
|
||||
background-color: #fffa !important;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.player-playback-bar-fg {
|
||||
background-color: #f00 !important;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.player-buffering {
|
||||
grid-area: 1 / 1 / 6 / 3;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.player-buffering-spinner {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
1201
core/LightTube/wwwroot/css/mobile.css
Normal file
1201
core/LightTube/wwwroot/css/mobile.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
core/LightTube/wwwroot/favicon.ico
Normal file
BIN
core/LightTube/wwwroot/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
1
core/LightTube/wwwroot/icons/collapse_guide.svg
Normal file
1
core/LightTube/wwwroot/icons/collapse_guide.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 3)"><path d="m.5 12.5v-10c0-1.1045695.8954305-2 2-2h10c1.1045695 0 2 .8954305 2 2v10c0 1.1045695-.8954305 2-2 2h-10c-1.1045695 0-2-.8954305-2-2z"/><path d="m2.5 12.5v-10c0-1.1045695.8954305-2 2-2h-2c-1 0-2 .8954305-2 2v10c0 1.1045695 1 2 2 2h2c-1.1045695 0-2-.8954305-2-2z" fill="currentColor"/><path d="m7.5 10.5-3-3 3-3"/><path d="m12.5 7.5h-8"/></g></svg>
|
||||
|
After Width: | Height: | Size: 568 B |
1
core/LightTube/wwwroot/icons/compass.svg
Normal file
1
core/LightTube/wwwroot/icons/compass.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(2 2)"><circle cx="8.5" cy="8.5" r="8"/><path d="m10.5 9.5-4 3v-5l4-3z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 290 B |
1
core/LightTube/wwwroot/icons/dislike.svg
Normal file
1
core/LightTube/wwwroot/icons/dislike.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="matrix(1 0 0 -1 2 18)"><path d="m11.6427217 13.7567397-3.14377399-1.2567396h-4v-7.00000002h2l2.80105246-5.5c.57989907 0 1.07487363.2050252 1.48492373.61507546.4100508.41005058.6150761.90502516.6150755 1.48492425l-.8999994 2.40000029 4.0310597 1.34368655c.9979872.33266243 1.5591794 1.37584131 1.3086286 2.37964122l-.0684258.21997226-1.5536355 4.14302809c-.3878403 1.0342407-1.5406646 1.5582517-2.5749053 1.1704115z"/><path d="m1.5 4.5h2c.55228475 0 1 .44771525 1 1v8c0 .5522847-.44771525 1-1 1h-2c-.55228475 0-1-.4477153-1-1v-8c0-.55228475.44771525-1 1-1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 766 B |
1
core/LightTube/wwwroot/icons/home.svg
Normal file
1
core/LightTube/wwwroot/icons/home.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height="21" viewBox="0 0 21 21" width="21" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(1 1)"><path d="m.5 9.5 9-9 9 9"/><path d="m2.5 7.5v8c0 .5522847.44771525 1 1 1h3c.55228475 0 1-.4477153 1-1v-4c0-.5522847.44771525-1 1-1h2c.5522847 0 1 .4477153 1 1v4c0 .5522847.4477153 1 1 1h3c.5522847 0 1-.4477153 1-1v-8"/></g></svg>
|
||||
|
After Width: | Height: | Size: 443 B |
16
core/LightTube/wwwroot/icons/icons.txt
Normal file
16
core/LightTube/wwwroot/icons/icons.txt
Normal file
@ -0,0 +1,16 @@
|
||||
https://systemuicons.com/
|
||||
=========================
|
||||
home:
|
||||
https://systemuicons.com/images/icons/home_door.svg
|
||||
browse:
|
||||
https://systemuicons.com/images/icons/compass.svg
|
||||
subscriptions:
|
||||
-
|
||||
profile:
|
||||
https://systemuicons.com/images/icons/user_male_circle.svg
|
||||
search:
|
||||
https://systemuicons.com/images/icons/search.svg
|
||||
like:
|
||||
https://systemuicons.com/images/icons/thumbs_up.svg
|
||||
dislike:
|
||||
https://systemuicons.com/images/icons/thumbs_down.svg
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user