Compare commits

...

No commits in common. "59b4d89f63ae181c7ebc3d50ba7ab1adb6798dbe" and "cd95e8b57e6f3dbda0c387880ac3ac65015bb9ea" have entirely different histories.

21 changed files with 1578 additions and 7 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
token.txt
.venv
.idea
__pycache__
sigma
nohup.out
cookies.txt

16
LICENSE
View File

@ -1,9 +1,15 @@
MIT License
(c) 2025 ReviveMii Project. All rights reserved. If you want to use this Code, give Credits to ReviveMii Project. https://revivemii.errexe.xyz/
Copyright (c) 2025 TheErrorExe
ReviveMii Project and TheErrorExe is the Developer of this Code. Modification, Network Use and Distribution is allowed if you leave this Comment in the beginning of the Code, and if a website exist, Credits on the Website.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
This Code uses the Invidious API, Google API and yt-dlp. This Code is designed to run on Ubuntu 24.04.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Dont claim that this code is your code. Don't use it without Credits to the ReviveMii Project. Don't use it without this Comment. Don't modify this Comment. You need to make your modified Code Open Source with this exact License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ReviveMii's Server Code is provided "as-is" and "as available." We do not guarantee uninterrupted access, error-free performance, or compatibility with all Wii systems. ReviveMii project is not liable for any damage, loss of data, or other issues arising from the use of this service and code.
If you use this Code, you agree to https://revivemii.errexe.xyz/revivetube/t-and-p.html, also available as http only Version: http://revivemii.errexe.xyz/revivetube/t-and-p.html
ReviveMii Project: https://revivemii.errexe.xyz/
If you are running your own public ReviveTube instance, you must open-source the modified source code.

View File

@ -1,3 +1,61 @@
# modified-revivetube-for-ytolderrexexyz
# ReviveTube
Modified Source Code for ReviveTube
Watch YouTube on your Wii!
ReviveTube: http://yt.old.errexe.xyz/
ReviveMii Homepage: https://revivemii.errexe.xyz/
# Self Hosting
## Docker (Recommended):
ReviveTube now supports Docker. Docker Installation:
Install docker with `sudo apt install docker.io && sudo curl -L "https://github.com/docker/compose/releases/download/v2.20.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && sudo chmod +x /usr/local/bin/docker-compose`
Then run
```bash
wget https://cloud.theerrorexe.dev/revivetube-docker.tar
docker load -i revivetube-docker.tar
```
Create docker-compose.yml:
```diff
version: "3.9"
services:
revivetube:
image: theerrorexe/revivetube:latest
ports:
- "5000:5000"
environment:
- GOOGLE_API_TOKEN=YOUR_GOOGLE_TOKEN
volumes:
- .:/app
restart: unless-stopped
```
Replace YOUR_GOOGLE_TOKEN with your google api token
Now run `sudo docker-compose up -d` or `sudo docker compose up -d`
ReviveTube should now run on Port 5000
## Using Python directly
WARNING: before starting the server, remove the --proxy command and the --cookie command in revivetube.py
Go to https://console.cloud.google.com/ and create a new application with the YouTube Data v3 API.
Click on Credentials and click on new, and create a new API Key. Paste the API Key in token.txt
Install the Requirements:
```bash
pip install -r requirements.txt
```
Search for "--proxy" in revivetube.py and remove the command
Search for "--cookies" in revivetube.py and remove the command
Start the Server:
```bash
python3 revivetube.py
```

View File

@ -0,0 +1,43 @@
import os
import subprocess
import time
def get_folder_size(folder_path):
total_size = 0
for dirpath, dirnames, filenames in os.walk(folder_path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
return total_size
def delete_files(folder_path, extensions):
os.system('sudo pkill -f revivetube.py')
process = subprocess.Popen(['sudo', 'nohup', 'python3', 'revivetube.py'])
for dirpath, dirnames, filenames in os.walk(folder_path):
for filename in filenames:
if any(filename.lower().endswith(ext) for ext in extensions):
filepath = os.path.join(dirpath, filename)
try:
os.remove(filepath)
except:
print("ERROR")
def monitor_folder(folder_path, size_limit_gb, check_interval):
size_limit_bytes = size_limit_gb * 1024 * 1024 * 1024
while True:
folder_size = get_folder_size(folder_path)
if folder_size > size_limit_bytes:
delete_files(folder_path, [".flv", ".mp4"])
time.sleep(check_interval)
if __name__ == "__main__":
folder_to_monitor = "./sigma/videos/"
size_limit = 7
interval = 5
monitor_folder(folder_to_monitor, size_limit, interval)

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

1
generateRequirements.sh Normal file
View File

@ -0,0 +1 @@
pipreqs . --force --ignore .venv

57
helper.py Normal file
View File

@ -0,0 +1,57 @@
import json
import os
import subprocess
import aiofiles
import aiohttp
import asyncio
async def read_file(path):
try:
async with aiofiles.open(path, 'r', encoding='utf-8') as file:
return await file.read()
except Exception as e:
print(f"Error reading file: {str(e)}")
return None
async def get_video_duration_from_file(video_path):
try:
proc = await asyncio.create_subprocess_exec(
'ffprobe', '-v', 'error', '-show_format',
'-show_streams', '-of', 'json', video_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise Exception(stderr.decode())
return float(json.loads(stdout.decode())['format']['duration'])
except Exception as e:
print(f"Error getting video duration: {str(e)}")
return 0.0
async def format_duration(seconds):
return f"{int(seconds//60)}:{int(seconds%60):02d}"
async def get_file_size(file_path):
try:
return os.path.getsize(file_path)
except Exception as e:
print(f"Error getting file size: {str(e)}")
return 0
async def get_range(file_path, byte_range):
try:
async with aiofiles.open(file_path, 'rb') as f:
await f.seek(byte_range[0])
return await f.read(byte_range[1] - byte_range[0] + 1)
except Exception as e:
print(f"Error reading file range: {str(e)}")
return b''
async def get_api_key():
try:
async with aiofiles.open("token.txt", "r") as f:
return (await f.read()).strip()
except Exception as e:
print(f"Error reading API key: {str(e)}")
raise

BIN
like.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
likeicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

BIN
loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
player.swf Normal file

Binary file not shown.

1
pull.sh Normal file
View File

@ -0,0 +1 @@
git pull

3
push.sh Executable file
View File

@ -0,0 +1,3 @@
git add .
git commit -m "something"
git push -u origin main

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
Flask
Requests
yt_dlp
bs4
quart
aiohttp
aiofiles

490
revivetube.py Normal file
View File

@ -0,0 +1,490 @@
"""
(c) 2025 ReviveMii Project. All rights reserved. If you want to use this Code, give Credits to ReviveMii Project. https://revivemii.errexe.xyz/
ReviveMii Project and TheErrorExe is the Developer of this Code. Modification, Network Use and Distribution is allowed if you leave this Comment in the beginning of the Code, and if a website exist, Credits on the Website.
This Code uses the Invidious API, Google API and yt-dlp. This Code is designed to run on Ubuntu 24.04.
Don't claim that this code is your code. Don't use it without Credits to the ReviveMii Project. Don't use it without this Comment. Don't modify this Comment. You need to make your modified Code Open Source with this exact License.
ReviveMii's Server Code is provided "as-is" and "as available." We do not guarantee uninterrupted access, error-free performance, or compatibility with all Wii systems. ReviveMii project is not liable for any damage, loss of data, or other issues arising from the use of this service and code.
If you use this Code, you agree to https://revivemii.errexe.xyz/revivetube/t-and-p.html, also available as http only Version: http://revivemii.errexe.xyz/revivetube/t-and-p.html
ReviveMii Project: https://revivemii.errexe.xyz/
"""
import os
import shutil
import subprocess
import tempfile
import aiofiles
import aiohttp
import asyncio
import yt_dlp
from bs4 import BeautifulSoup
from quart import Quart, request, render_template_string, send_file, Response, abort, jsonify
from helper import *
app = Quart(__name__)
VIDEO_FOLDER = "sigma/videos"
YOUTUBE_API_URL = "https://www.googleapis.com/youtube/v3/videos"
video_status = {}
FILE_SEPARATOR = os.sep
async def read_file(file_path):
async with aiofiles.open(file_path, mode='r') as f:
return await f.read()
async def check_and_create_folder():
while True:
folder_path = './sigma/videos'
if not os.path.exists(folder_path):
os.makedirs(folder_path)
print(f"Folder {folder_path} got created.")
await asyncio.sleep(10)
@app.before_serving
async def startup():
asyncio.create_task(check_and_create_folder())
LOADING_TEMPLATE = None
CHANNEL_TEMPLATE = None
SEARCH_TEMPLATE = None
INDEX_TEMPLATE = None
WATCH_WII_TEMPLATE = None
async def load_templates():
global LOADING_TEMPLATE, CHANNEL_TEMPLATE, SEARCH_TEMPLATE, INDEX_TEMPLATE, WATCH_WII_TEMPLATE
LOADING_TEMPLATE = await read_file(f"site_storage{FILE_SEPARATOR}loading_template.html")
CHANNEL_TEMPLATE = await read_file(f"site_storage{FILE_SEPARATOR}channel_template.html")
SEARCH_TEMPLATE = await read_file(f"site_storage{FILE_SEPARATOR}search_template.html")
INDEX_TEMPLATE = await read_file(f"site_storage{FILE_SEPARATOR}index_template.html")
WATCH_WII_TEMPLATE = await read_file(f"site_storage{FILE_SEPARATOR}watch_wii_template.html")
app.before_serving(load_templates)
os.makedirs(VIDEO_FOLDER, exist_ok=True)
MAX_VIDEO_SIZE = 1 * 1024 * 1024 * 1024
MAX_FOLDER_SIZE = 5 * 1024 * 1024 * 1024
def get_folder_size(path):
total_size = 0
for dirpath, dirnames, filenames in os.walk(path):
for f in filenames:
file_path = os.path.join(dirpath, f)
total_size += os.path.getsize(file_path)
return total_size
@app.route("/thumbnail/<video_id>")
async def get_thumbnail(video_id):
thumbnail_url = f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg"
try:
async with aiohttp.ClientSession() as session:
async with session.get(thumbnail_url) as response:
if response.status == 200:
image_data = await response.read()
return Response(
image_data,
mimetype=response.headers.get("Content-Type", "image/jpeg")
)
return "Thumbnail not found", 404
except Exception as e:
print(f"Error fetching thumbnail: {str(e)}")
return "Internal server error", 500
async def get_video_comments(video_id, max_results=20):
api_key = await get_api_key()
params = {
"part": "snippet",
"videoId": video_id,
"key": api_key,
"maxResults": max_results,
"order": "relevance"
}
try:
async with aiohttp.ClientSession() as session:
async with session.get("https://www.googleapis.com/youtube/v3/commentThreads", params=params, timeout=3) as response:
response.raise_for_status()
data = await response.json()
comments = []
if "items" in data:
for item in data["items"]:
snippet = item["snippet"]["topLevelComment"]["snippet"]
comments.append({
"author": snippet["authorDisplayName"],
"text": snippet["textDisplay"],
"likeCount": snippet.get("likeCount", 0),
"publishedAt": snippet["publishedAt"]
})
return comments
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
print(f"Can't fetch Comments: {str(e)}")
return []
@app.route("/cookies.txt", methods=["GET"])
async def cookies():
return "403 Forbidden", 403
@app.route("/token.txt", methods=["GET"])
async def token():
return "403 Forbidden", 403
@app.route("/nohup.out", methods=["GET"])
async def nohup():
return "403 Forbidden", 403
@app.route("/", methods=["GET"])
async def index():
query = request.args.get("query")
results = None
try:
async with aiohttp.ClientSession() as session:
url = f"https://invidious.errexe.xyz/api/v1/search?q={query}" if query else "https://invidious.errexe.xyz/api/v1/trending"
async with session.get(url, timeout=6) as response:
data = await response.json()
if response.status == 200 and isinstance(data, list):
if query:
results = []
for entry in data:
if entry.get("type") == "video":
results.append({
"type": "video",
"id": entry.get("videoId"),
"title": entry.get("title"),
"uploader": entry.get("author", "Unknown"),
"thumbnail": f"/thumbnail/{entry['videoId']}",
"viewCount": entry.get("viewCountText", "Unknown"),
"published": entry.get("publishedText", "Unknown"),
"duration": await format_duration(entry.get("lengthSeconds", 0))
})
elif entry.get("type") == "channel":
results.append({
"type": "channel",
"id": entry.get("authorId"),
"title": entry.get("author"),
"thumbnail": entry.get("authorThumbnails")[-1]["url"] if entry.get("authorThumbnails") else "/static/default_channel_thumbnail.jpg",
"subCount": entry.get("subCount", "Unknown"),
"videoCount": entry.get("videoCount", "Unknown")
})
return await render_template_string(SEARCH_TEMPLATE, results=results)
else:
results = [
{
"id": entry.get("videoId"),
"title": entry.get("title"),
"uploader": entry.get("author", "Unknown"),
"thumbnail": f"/thumbnail/{entry['videoId']}",
"viewCount": entry.get("viewCountText", "Unknown"),
"published": entry.get("publishedText", "Unknown"),
"duration": await format_duration(entry.get("lengthSeconds", 0))
}
for entry in data
if entry.get("videoId")
]
return await render_template_string(INDEX_TEMPLATE, results=results)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as e:
return "Can't parse Data. If this Issue persists, report it in the Discord Server.", 500
return "No Results or Error in the API.", 404
@app.route("/watch", methods=["GET"])
async def watch():
video_id = request.args.get("video_id")
if not video_id:
return "Missing Video-ID.", 400
video_mp4_path = os.path.join(VIDEO_FOLDER, f"{video_id}.mp4")
video_flv_path = os.path.join(VIDEO_FOLDER, f"{video_id}.flv")
if video_id not in video_status:
video_status[video_id] = {"status": "processing"}
user_agent = request.headers.get("User-Agent", "").lower()
is_wii = "wii" in user_agent and "wiiu" not in user_agent
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"http://localhost:5000/video_metadata/{video_id}", timeout=20) as response:
if response.status == 200:
metadata = await response.json()
else:
return f"Metadata API Error for Video-ID {video_id}.", 500
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
return f"Can't connect to Metadata-API: {str(e)}", 500
comments = await get_video_comments(video_id)
channel_logo_url = ""
subscriber_count = "Unbekannt"
try:
channel_id = metadata['channelId']
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api-superplaycounts.onrender.com/api/youtube-channel-counter/user/{channel_id}", timeout=5) as channel_response:
if channel_response.status == 200:
channel_data = await channel_response.json()
for stat in channel_data.get("statistics", []):
for count in stat.get("counts", []):
if count.get("value") == "subscribers":
subscriber_count = count.get("count", "Unbekannt")
break
for user_info in stat.get("user", []):
if user_info.get("value") == "pfp":
channel_logo_url = user_info.get("count", "").replace("https://", "http://")
break
except Exception as e:
print(f"SuperPlayCounts API Error: {str(e)}")
comment_count = len(comments)
if os.path.exists(video_mp4_path):
video_duration = await get_video_duration_from_file(video_flv_path)
alert_script = ""
if video_duration > 420:
alert_script = """<script type="text/javascript">alert("This Video is long. There is a chance that the Wii will not play the Video. Try a Video under 5 minutes.");</script>"""
if is_wii and os.path.exists(video_flv_path):
return await render_template_string(WATCH_WII_TEMPLATE + alert_script,
title=metadata['title'],
uploader=metadata['uploader'],
channelId=metadata['channelId'],
description=metadata['description'].replace("\n", "<br>"),
viewCount=metadata['viewCount'],
likeCount=metadata['likeCount'],
publishedAt=metadata['publishedAt'],
comments=comments,
commentCount=comment_count,
channel_logo_url=channel_logo_url,
subscriberCount=subscriber_count,
video_id=video_id,
video_flv=f"/sigma/videos/{video_id}.flv",
alert_message="")
return await render_template_string(WATCH_WII_TEMPLATE,
title=metadata['title'],
uploader=metadata['uploader'],
channelId=metadata['channelId'],
description=metadata['description'].replace("\n", "<br>"),
viewCount=metadata['viewCount'],
likeCount=metadata['likeCount'],
publishedAt=metadata['publishedAt'],
comments=comments,
commentCount=comment_count,
channel_logo_url=channel_logo_url,
subscriberCount=subscriber_count,
video_id=video_id,
video_flv=f"/sigma/videos/{video_id}.flv",
alert_message="")
if not os.path.exists(video_mp4_path):
if video_status[video_id]["status"] == "processing":
asyncio.create_task(process_video(video_id))
return await render_template_string(LOADING_TEMPLATE, video_id=video_id)
async def process_video(video_id):
video_mp4_path = os.path.join(VIDEO_FOLDER, f"{video_id}.mp4")
video_flv_path = os.path.join(VIDEO_FOLDER, f"{video_id}.flv")
try:
video_status[video_id] = {"status": "downloading"}
temp_dir = tempfile.mkdtemp()
try:
subprocess.run([
"yt-dlp",
"-o", os.path.join(temp_dir, f"{video_id}.%(ext)s"),
"--cookies", "cookies.txt",
"--proxy", "http://localhost:4000",
"-f", "worstvideo+worstaudio",
f"https://youtube.com/watch?v={video_id}"
], check=True)
downloaded_files = [f for f in os.listdir(temp_dir) if video_id in f]
if not downloaded_files:
raise Exception("No video file downloaded")
downloaded_file = os.path.join(temp_dir, downloaded_files[0])
if not downloaded_file.endswith(".mp4"):
video_status[video_id] = {"status": "converting"}
try:
subprocess.run([
"ffmpeg",
"-y",
"-i", downloaded_file,
"-c:v", "libx264",
"-crf", "51",
"-c:a", "aac",
"-strict", "experimental",
"-preset", "ultrafast",
"-b:a", "64k",
"-movflags", "+faststart",
"-vf", "scale=854:480",
video_mp4_path
], check=True, timeout=300, stderr=subprocess.PIPE)
except subprocess.TimeoutExpired:
raise Exception("MP4 conversion timed out")
except subprocess.CalledProcessError as e:
error_output = e.stderr.decode('utf-8') if e.stderr else str(e)
raise Exception(f"MP4 conversion failed: {error_output}")
else:
shutil.copy(downloaded_file, video_mp4_path)
if not os.path.exists(video_flv_path):
video_status[video_id] = {"status": "converting for Wii"}
try:
subprocess.run([
"ffmpeg",
"-y",
"-i", video_mp4_path,
"-ar", "22050",
"-f", "flv",
"-s", "320x240",
"-ab", "32k",
"-preset", "ultrafast",
"-crf", "51",
"-filter:v", "fps=fps=15",
video_flv_path
], check=True, timeout=300, stderr=subprocess.PIPE)
except subprocess.TimeoutExpired:
raise Exception("FLV conversion timed out")
except subprocess.CalledProcessError as e:
error_output = e.stderr.decode('utf-8') if e.stderr else str(e)
raise Exception(f"FLV conversion failed: {error_output}")
video_status[video_id] = {"status": "complete", "url": f"/sigma/videos/{video_id}.mp4"}
finally:
try:
shutil.rmtree(temp_dir)
except:
pass
except Exception as e:
error_msg = str(e)
video_status[video_id] = {"status": "error", "message": error_msg}
for path in [video_mp4_path, video_flv_path]:
try:
if os.path.exists(path):
os.remove(path)
except:
pass
@app.route("/status/<video_id>")
async def check_status(video_id):
return jsonify(video_status.get(video_id, {"status": "pending"}))
@app.route("/video_metadata/<video_id>")
async def video_metadata(video_id):
api_key = await get_api_key()
params = {
"part": "snippet,statistics",
"id": video_id,
"key": api_key
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(YOUTUBE_API_URL, params=params, timeout=1) as response:
response.raise_for_status()
data = await response.json()
if "items" not in data or len(data["items"]) == 0:
return f"The Video with ID {video_id} was not found.", 404
video_data = data["items"][0]
return {
"title": video_data["snippet"]["title"],
"uploader": video_data["snippet"]["channelTitle"],
"channelId": video_data["snippet"]["channelId"],
"description": video_data["snippet"]["description"],
"viewCount": video_data["statistics"].get("viewCount", "Unknown"),
"likeCount": video_data["statistics"].get("likeCount", "Unknown"),
"dislikeCount": video_data["statistics"].get("dislikeCount", "Unknown"),
"publishedAt": video_data["snippet"].get("publishedAt", "Unknown")
}
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
return f"API Error: {str(e)}", 500
@app.route("/<path:filename>")
async def serve_video(filename):
file_path = os.path.join(filename)
if not os.path.exists(file_path):
return "File not found.", 404
file_size = await get_file_size(file_path)
range_header = request.headers.get('Range', None)
if range_header:
byte_range = range_header.strip().split('=')[1]
start_byte, end_byte = byte_range.split('-')
start_byte = int(start_byte)
end_byte = int(end_byte) if end_byte else file_size - 1
if start_byte >= file_size or end_byte >= file_size:
abort(416)
data = await get_range(file_path, (start_byte, end_byte))
content_range = f"bytes {start_byte}-{end_byte}/{file_size}"
response = Response(
data,
status=206,
mimetype="video/mp4",
content_type="video/mp4",
)
response.headers["Content-Range"] = content_range
response.headers["Content-Length"] = str(len(data))
return response
return await send_file(file_path)
@app.route('/channel', methods=['GET'])
async def channel_m():
channel_id = request.args.get('channel_id', None)
if not channel_id:
return "Channel ID is required.", 400
ydl_opts = {
'quiet': True,
'extract_flat': True,
'playlistend': 20,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(f"https://www.youtube.com/channel/{channel_id}/videos", download=False)
if 'entries' not in info:
return "No videos found.", 404
channel_name = info.get('uploader', 'Unknown')
async with aiohttp.ClientSession() as session:
async with session.get(f"https://invidious.errexe.xyz/channel/{channel_id}", timeout=10) as response:
if response.status != 200:
return "Failed to fetch channel page.", 500
soup = BeautifulSoup(await response.text(), "html.parser")
profile_div = soup.find(class_="channel-profile")
channel_picture = ""
if profile_div:
img_tag = profile_div.find("img")
if img_tag and "src" in img_tag.attrs:
channel_picture = f"http://api.allorigins.win/raw?url=http://invidious.materialio.us{img_tag['src']}"
results = [
{
'id': video['id'],
'duration': 'Duration not available on Channel View',
'title': video['title'],
'uploader': channel_name,
'thumbnail': f"http://yt.old.errexe.xyz/thumbnail/{video['id']}"
}
for video in info['entries']
]
return await render_template_string(
CHANNEL_TEMPLATE,
results=results,
channel_name=channel_name,
channel_picture=channel_picture
)
except Exception as e:
return f"An error occurred: {str(e)}", 500
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)

View File

@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c709974b13b14d9a8a5c19c9cb3e5184"}'></script><!-- End Cloudflare Web Analytics -->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>ReviveTube - A YouTube App for the Wii</title>
<style>
a {
color: #3ea6ff;
text-decoration: none;
}
a:hover {
color: #46bbff;
text-decoration: underline;
}
body {
font-family: Arial, sans-serif;
color: #fff;
background-color: #181818;
margin: 0;
padding: 0;
}
.header {
background-color: #202020;
border-bottom:2px solid #2c2c2c;
padding: 10px 20px;
display: flex;
align-items: center;
}
.logo {
font-size: 24px;
color: #fe0000;
font-weight: bold;
}
.search-container {
flex: 1;
text-align: center;
}
.search-bar {
width: 400px;
padding: 8px;
font-size: 16px;
border: 1px solid #313131;
background-color: #121212;
color: #fff;
}
.search-bar:hover {
border: 1px solid #268ee9;
}
.search-button {
padding: 8px 15px;
font-size: 16px;
background-color: #222222;
border:1px solid #3d3d3d;
color: white;
cursor: pointer;
}
.search-button:hover {
border: 1px solid #268ee9;
}
.content {
padding: 20px;
}
.video-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.video-item {
width: 320px;
margin: 10px;
background-color: #222;
padding: 10px;
}
.video-item img {
width: 100%;
}
.video-item-title {
font-weight: bold;
font-size: 14px;
margin-top: 5px;
}
.video-item-uploader, .video-item-duration {
color: #aaa;
font-size: 12px;
}
</style>
</head>
<body>
<div class="header">
<a href="/" style="text-decoration: none;"><div class="logo"><img src="../favicon.ico" style="width:32px; height:32px; display:inline; position:relative; top:3px; right:3px; padding-right:2px;"><span style="position:relative; top:-4px; padding-left:5px; border-left:1px solid #323232;">
ReviveTube</span></div></a>
<div class="search-container">
<form action="/" method="get">
<input class="search-bar" placeholder="Search YouTube" name="query" type="text">
<input type="submit" class="search-button" value="Search">
</form>
</div>
</div>
<div class="content">
{% if results %}
<div>
<img src="{{ channel_picture if channel_picture else 'default_profile.png' }}"
alt="Channel Profile Picture"
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; display: inline-block; vertical-align: middle; margin-right: 10px;">
<h2 style="display: inline-block; vertical-align: middle; margin: 0; color: white;">{{ channel_name }}</h2>
</div>
<div class="video-grid">
{% for video in results %}
<div class="video-item">
<a href="/watch?video_id={{ video['id'] }}">
<img alt="{{ video['title'] }}" src="{{ video['thumbnail'] }}">
</a>
<div class="video-item-title">{{ video['title'] }}</div>
<div class="video-item-uploader">By: {{ video['uploader'] }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<p style="color: red; text-align: center;">ReviveTube - A YouTube App for the Wii</p>
<p style="text-align: center;"><a href="http://revivemii.xyz" target="_blank">Visit the ReviveMii Project</a></p>
<p style="font-size: 12px; text-align: center;">We are NOT affiliated with Nintendo or YouTube.</p>
<p style="text-align: center;">
<a href="https://github.com/ReviveMii/revivetube/" target="_blank">Source Code</a> |
<a href="https://revivemii.errexe.xyz/discord-redirect.html">Discord Server</a> |
<a href="mailto:theerrorexe@gmail.com">Contact</a> |
<a href="/site_storage/credits.html">Credits</a> |
<a href="mailto:dmca@errexe.xyz">DMCA: dmca@errexe.xyz</a>
</p>
</body>
</html>

118
site_storage/credits.html Normal file
View File

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c709974b13b14d9a8a5c19c9cb3e5184"}'></script><!-- End Cloudflare Web Analytics -->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>ReviveTube - A YouTube App for the Wii</title>
<style>
a {
color: #3ea6ff;
text-decoration: none;
}
a:hover {
color: #46bbff;
text-decoration: underline;
}
body {
font-family: Arial, sans-serif;
color: #fff;
background-color: #181818;
margin: 0;
padding: 0;
}
.header {
background-color: #202020;
border-bottom:2px solid #2c2c2c;
padding: 10px 20px;
display: flex;
align-items: center;
}
.logo {
font-size: 24px;
color: #fe0000;
font-weight: bold;
}
.search-container {
flex: 1;
text-align: center;
}
.search-bar {
width: 400px;
padding: 8px;
font-size: 16px;
border: 1px solid #313131;
background-color: #121212;
color: #fff;
}
.search-bar:hover {
border: 1px solid #268ee9;
}
.search-button {
padding: 8px 15px;
font-size: 16px;
background-color: #222222;
border:1px solid #3d3d3d;
color: white;
cursor: pointer;
}
.search-button:hover {
border: 1px solid #268ee9;
}
.content {
padding: 20px;
}
.video-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.video-item {
width: 320px;
margin: 10px;
background-color: #222;
padding: 10px;
}
.video-item img {
width: 100%;
}
.video-item-title {
font-weight: bold;
font-size: 14px;
margin-top: 5px;
}
.video-item-uploader, .video-item-duration {
color: #aaa;
font-size: 12px;
}
</style>
</head>
<body>
<div class="header">
<div class="logo"><img src="../favicon.ico" style="width:32px; height:32px; display:inline; position:relative; top:3px; right:3px; padding-right:2px;"><span style="position:relative; top:-4px; padding-left:5px; border-left:1px solid #323232;">
ReviveTube</span></div>
<div class="search-container">
<form action="/" method="get">
<input class="search-bar" placeholder="Search YouTube" name="query" type="text">
<input type="submit" class="search-button" value="Search">
</form>
</div>
</div>
<div class="content">
<h1>Credits:</h1>
<p>TheErrorExe (Developer)</p>
<p>Chrisplayz (Credits for the Redesign)</p>
<p>rmilooo (Credits for the Code Cleanup)</p>
</div>
</div>
<p style="color: red; text-align: center;">ReviveTube - A YouTube App for the Wii</p>
<p style="text-align: center;"><a href="http://revivemii.xyz" target="_blank">Visit the ReviveMii Project</a></p>
<p style="font-size: 12px; text-align: center;">We are NOT affiliated with Nintendo or YouTube.</p>
<p style="text-align: center;">
<a href="https://github.com/ReviveMii/revivetube/" target="_blank">Source Code</a> |
<a href="https://revivemii.errexe.xyz/discord-redirect.html">Discord Server</a> |
<a href="mailto:theerrorexe@gmail.com">Contact</a> |
<a href="/site_storage/credits.html">Credits</a> |
<a href="mailto:dmca@errexe.xyz">DMCA: dmca@errexe.xyz</a>
</p>
</body>
</html>

View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c709974b13b14d9a8a5c19c9cb3e5184"}'></script><!-- End Cloudflare Web Analytics -->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>ReviveTube - A YouTube App for the Wii</title>
<style>
a {
color: #3ea6ff;
text-decoration: none;
}
a:hover {
color: #46bbff;
text-decoration: underline;
}
body {
font-family: Arial, sans-serif;
color: #fff;
background-color: #181818;
margin: 0;
padding: 0;
}
.header {
background-color: #202020;
border-bottom:2px solid #2c2c2c;
padding: 10px 20px;
display: flex;
align-items: center;
}
.logo {
font-size: 24px;
color: #fe0000;
font-weight: bold;
}
.search-container {
flex: 1;
text-align: center;
}
.search-bar {
width: 400px;
padding: 8px;
font-size: 16px;
border: 1px solid #313131;
background-color: #121212;
color: #fff;
}
.search-bar:hover {
border: 1px solid #268ee9;
}
.search-button {
padding: 8px 15px;
font-size: 16px;
background-color: #222222;
border:1px solid #3d3d3d;
color: white;
cursor: pointer;
}
.search-button:hover {
border: 1px solid #268ee9;
}
.content {
padding: 20px;
}
.video-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.video-item {
width: 320px;
margin: 10px;
background-color: #222;
padding: 10px;
}
.video-item img {
width: 100%;
}
.video-item-title {
font-weight: bold;
font-size: 14px;
margin-top: 5px;
}
.video-item-uploader, .video-item-duration {
color: #aaa;
font-size: 12px;
}
</style>
</head>
<body>
<div class="header">
<a href="/" style="text-decoration: none;"><div class="logo"><img src="../favicon.ico" style="width:32px; height:32px; display:inline; position:relative; top:3px; right:3px; padding-right:2px;"><span style="position:relative; top:-4px; padding-left:5px; border-left:1px solid #323232;">
ReviveTube</span></div></a>
<div class="search-container">
<form action="/" method="get">
<input class="search-bar" placeholder="Search YouTube" name="query" type="text">
<input type="submit" class="search-button" value="Search">
</form>
</div>
</div>
<div class="content">
<!-- <div style="background-color: lightblue; border-radius: 25px; padding: 20px;">
<h1>We now have a Wii Forwarder for ReviveTube!</h1>
<a href="https://go.revivemii.xyz/revivetube-forwarder.html">Click here (Open this on a Modern Device)</a>
</div> -->
{% if results %}
<div class="video-grid">
{% for video in results %}
<div class="video-item">
<a href="/watch?video_id={{ video['id'] }}">
<img alt="{{ video['title'] }}" src="{{ video['thumbnail'] }}">
</a>
<div class="video-item-title">{{ video['title'] }}</div>
<div class="video-item-uploader">By: {{ video['uploader'] }}</div>
<div class="video-item-duration">Duration: {{ video['duration'] }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<p style="color: red; text-align: center;">ReviveTube - A YouTube App for the Wii</p>
<p style="text-align: center;"><a href="http://revivemii.xyz" target="_blank">Visit the ReviveMii Project</a></p>
<p style="font-size: 12px; text-align: center;">We are NOT affiliated with Nintendo or YouTube.</p>
<p style="text-align: center;">
<a href="https://github.com/ReviveMii/revivetube/" target="_blank">Source Code</a> |
<a href="https://revivemii.errexe.xyz/discord-redirect.html">Discord Server</a> |
<a href="mailto:theerrorexe@gmail.com">Contact</a> |
<a href="/site_storage/credits.html">Credits</a> |
<a href="mailto:dmca@errexe.xyz">DMCA: dmca@errexe.xyz</a>
</p>
</body>
</html>

View File

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c709974b13b14d9a8a5c19c9cb3e5184"}'></script><!-- End Cloudflare Web Analytics -->
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Loading...</title>
<style>
a {
color: #3ea6ff;
text-decoration: none;
}
a:hover {
color: #46bbff;
}
body {
font-family: Arial, sans-serif;
color: #fff;
background-color: #0f0f0f;
margin: 0;
padding: 0;
}
.header {
background-color: #202020;
border-bottom:2px solid #2c2c2c;
padding: 10px 20px;
display: flex;
align-items: center;
}
.logo {
font-size: 24px;
color: #fe0000;
font-weight: bold;
}
.search-container {
flex: 1;
text-align: center;
}
.search-bar {
width: 400px;
padding: 8px;
font-size: 16px;
border: 1px solid #313131;
background-color: #121212;
color: #fff;
}
.search-bar:hover {
border: 1px solid #268ee9;
}
.search-button {
padding: 8px 15px;
font-size: 16px;
background-color: #222222;
border:1px solid #3d3d3d;
color: white;
cursor: pointer;
}
.search-button:hover {
border: 1px solid #268ee9;
}
.loading-section {
text-align: center;
margin-top: 20px;
}
#loadingGif {
width: 50px;
height: 50px;
margin: 20px auto;
}
#goButton {
display: none;
margin-top: 20px;
padding: 10px 20px;
font-size: 16px;
background-color: #4caf50;
color: white;
border: none;
cursor: pointer;
}
#goButton:disabled {
background-color: gray;
cursor: not-allowed;
}
small {
color: grey;
display: block;
margin-top: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<a href="/" style="text-decoration: none;"><div class="logo"><img src="../favicon.ico" style="width:32px; height:32px; display:inline; position:relative; top:3px; right:3px; padding-right:2px;"><span style="position:relative; top:-4px; padding-left:5px; border-left:1px solid #323232;">
ReviveTube</span></div></a>
<div class="search-container">
<form action="/" method="get">
<input class="search-bar" name="query" placeholder="Search YouTube" type="text">
<input type="submit" class="search-button" value="Search">
</form>
</div>
</div>
<div class="loading-section">
<h1>Loading...</h1>
<img alt="Loading..." id="loadingGif" src="loading.gif"/>
<p id="progressText">Fetching Info...</p>
<button id="goButton" onclick="startVideo()">Go</button>
<br>
<small>Loading Screen will <b>NOT</b> work in Dolphin Emulator.<br><br>Long Video = Longer Download and Converting.<br><br>Videos over 5 minutes will not play.</small>
</div>
<script type="text/javascript">
var goButton = document.getElementById('goButton');
var loadingGif = document.getElementById('loadingGif');
var progressText = document.getElementById('progressText');
var videoId = "{{ video_id }}";
function simulateLoading() {
setInterval(checkStatus, 1000);
}
function checkStatus() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/status/' + videoId, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
var response;
try {
response = eval('(' + xhr.responseText + ')');
} catch (e) {
response = { status: 'error' };
}
updateProgress(response);
}
};
xhr.send();
}
function updateProgress(status) {
if (status.status === 'complete') {
window.location.href = '/watch?video_id=' + videoId;
} else if (status.status === 'downloading') {
progressText.innerHTML = 'Downloading...';
} else if (status.status === 'converting') {
progressText.innerHTML = 'Converting video from mp4 to webm...';
} else if (status.status === 'converting for Wii') {
progressText.innerHTML = 'Converting to Flash Video...';
} else {
progressText.innerHTML = 'The Server was unable to process the video! Report the Bug in the Discord Server. <br> Error on Video with ID: {{ video_id }}<br>Discord Server: https://revivemii.xyz/discord-redirect';
}
}
function startVideo() {
window.location.href = '/watch?video_id=' + videoId;
}
window.onload = function () {
simulateLoading();
};
</script>
</body>
</html>

View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c709974b13b14d9a8a5c19c9cb3e5184"}'></script><!-- End Cloudflare Web Analytics -->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>ReviveTube - A YouTube App for the Wii</title>
<style>
a {
color: #3ea6ff;
text-decoration: none;
}
a:hover {
color: #46bbff;
text-decoration: underline;
}
body {
font-family: Arial, sans-serif;
color: #fff;
background-color: #181818;
margin: 0;
padding: 0;
}
.header {
background-color: #202020;
border-bottom:2px solid #2c2c2c;
padding: 10px 20px;
display: flex;
align-items: center;
}
.logo {
font-size: 24px;
color: #fe0000;
font-weight: bold;
}
.search-container {
flex: 1;
text-align: center;
}
.search-bar {
width: 400px;
padding: 8px;
font-size: 16px;
border: 1px solid #313131;
background-color: #121212;
color: #fff;
}
.search-bar:hover {
border: 1px solid #268ee9;
}
.search-button {
padding: 8px 15px;
font-size: 16px;
background-color: #222222;
border:1px solid #3d3d3d;
color: white;
cursor: pointer;
}
.search-button:hover {
border: 1px solid #268ee9;
}
.content {
padding: 20px;
}
.video-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.video-item {
width: 320px;
margin: 10px;
background-color: #222;
padding: 10px;
}
.video-item img {
width: 100%;
}
.video-item-title {
font-weight: bold;
font-size: 14px;
margin-top: 5px;
}
.video-item-uploader, .video-item-duration {
color: #aaa;
font-size: 12px;
}
</style>
</head>
<body>
<div class="header">
<a href="/" style="text-decoration: none;"><div class="logo"><img src="../favicon.ico" style="width:32px; height:32px; display:inline; position:relative; top:3px; right:3px; padding-right:2px;"><span style="position:relative; top:-4px; padding-left:5px; border-left:1px solid #323232;">
ReviveTube</span></div></a>
<div class="search-container">
<form action="/" method="get">
<input class="search-bar" placeholder="Search YouTube" name="query" type="text">
<input type="submit" class="search-button" value="Search">
</form>
</div>
</div>
<div class="content">
{% if results %}
<div class="video-grid">
{% for result in results %}
<div class="video-item">
<a href="{% if result.type == 'video' %}/watch?video_id={{ result.id }}{% else %}/channel?channel_id={{ result.id }}{% endif %}">
<img alt="{{ result.title }}" src="{{ result.thumbnail }}">
</a>
<div class="video-item-title">{{ result.title }}</div>
{% if result.type == 'video' %}
<div class="video-item-uploader">By: {{ result.uploader }}</div>
<div class="video-item-duration">Duration: {{ result.duration }}</div>
{% else %}
<div class="video-item-uploader">Subscribers: {{ result.subCount }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<p style="color: red; text-align: center;">ReviveTube - A YouTube App for the Wii</p>
<p style="text-align: center;"><a href="http://revivemii.xyz" target="_blank">Visit the ReviveMii Project</a></p>
<p style="font-size: 12px; text-align: center;">We are NOT affiliated with Nintendo or YouTube.</p>
<p style="text-align: center;">
<a href="https://github.com/ReviveMii/revivetube/" target="_blank">Source Code</a> |
<a href="https://revivemii.errexe.xyz/discord-redirect.html">Discord Server</a> |
<a href="mailto:theerrorexe@gmail.com">Contact</a> |
<a href="/site_storage/credits.html">Credits</a> |
<a href="mailto:dmca@errexe.xyz">DMCA: dmca@errexe.xyz</a>
</p>
</body>
</html>

View File

@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c709974b13b14d9a8a5c19c9cb3e5184"}'></script><!-- End Cloudflare Web Analytics -->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>{{ title }}</title>
<style>
.header {
background-color: #202020;
border-bottom:2px solid #2c2c2c;
padding: 10px 20px;
display: flex;
align-items: center;
}
.logo {
font-size: 24px;
color: #fe0000;
font-weight: bold;
}
.search-container {
flex: 1;
text-align: center;
}
.search-bar {
width: 400px;
padding: 8px;
font-size: 16px;
border: 1px solid #313131;
background-color: #121212;
color: #fff;
}
.search-bar:hover {
border: 1px solid #268ee9;
}
.search-button {
padding: 8px 15px;
font-size: 16px;
background-color: #222222;
border:1px solid #3d3d3d;
color: white;
cursor: pointer;
}
.search-button:hover {
border: 1px solid #268ee9;
}
a {
color: #3ea6ff;
text-decoration: none;
}
a:hover {
color: #46bbff;
}
body {
font-family: Arial, sans-serif;
color: #fff;
background-color: #0f0f0f;
margin: 0;
padding: 0;
}
.header {
background-color: #202020;
border-bottom: 2px solid #2c2c2c;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: center;
}
.logo {
font-size: 24px;
color: #fe0000;
font-weight: bold;
display: flex;
align-items: center;
}
.logo img {
width: 24px;
height: 24px;
margin-right: 8px;
}
.search-container {
flex: 1;
text-align: center;
}
.search-bar {
width: 400px;
padding: 8px;
font-size: 16px;
border: 1px solid #313131;
background-color: #121212;
color: #fff;
}
.search-bar:hover {
border: 1px solid #268ee9;
}
.search-button {
padding: 8px 15px;
font-size: 16px;
background-color: #222222;
border: 1px solid #3d3d3d;
color: white;
cursor: pointer;
}
.search-button:hover {
border: 1px solid #268ee9;
}
.content {
padding: 20px;
text-align: center;
}
.channel-info {
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
}
.channel-logo {
width: 36px;
height: 36px;
border-radius: 50%;
}
.like-image {
width: 20px;
height: auto;
}
#videooutline {
width: fit-content;
margin: auto;
border-bottom: 6px solid #181818;
border-right: 6px solid #181818;
}
#videooutline:hover {
border-bottom: 6px solid #111111;
border-right: 6px solid #111111;
}
.video-container {
background-color: #131313;
border: 1px solid #525252;
text-align: center;
padding: 10px;
width: fit-content;
}
.video-container:hover {
border: 1px solid #0f0f0f;
}
.comments-section {
margin-top: 20px;
}
.comment {
background-color: #222;
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.comment-author {
font-weight: bold;
color: #3ea6ff;
}
.comment-text {
margin-top: 5px;
}
.comment-likes {
font-size: 12px;
color: #aaa;
}
</style>
<script src="https://unpkg.com/@ruffle-rs/ruffle"></script>
</head>
<body>
<div class="header">
<a href="/" style="text-decoration: none;"><div class="logo"><img src="../favicon.ico" style="width:32px; height:32px; display:inline; position:relative; top:3px; right:3px; padding-right:2px;"><span style="position:relative; top:-4px; padding-left:5px; border-left:1px solid #323232;">
ReviveTube</span></div></a>
<div class="search-container">
<form action="/" method="get">
<input class="search-bar" name="query" placeholder="Search YouTube" type="text">
<input type="submit" class="search-button" value="Search">
</form>
</div>
</div>
<div class="content">
<div id="videooutline">
<div class="video-container">
<object data="/player.swf" height="256" type="application/x-shockwave-flash" width="384">
<param name="wmode" value="transparent">
<param name="allowFullScreen" value="false">
<param name="flashvars" value="filename={{ video_flv }}">
</object>
</div>
</div>
<div class="container">
<h1 class="video-title">{{ title }}</h1>
<div class="video-meta">{{ viewCount }} Views • {{ publishedAt }}</div>
<br>
<div class="channel-info">
<img src="{{ channel_logo_url }}" alt="Channel Logo" class="channel-logo">
<div>
<a href="/channel?channel_id={{ channelId }}">{{ uploader }}</a>
<div class="subscriber-count">{{ subscriberCount }} Subscribers</div>
</div>
</div>
<br>
<div class="like-container">
<img src="/likeicon.png" alt="Like" class="like-image">
<span>{{ likeCount }}</span>
</div><br>
<div class="comments-section">
<h2>Comments ({{ commentCount }})</h2>
{% if comments %}
{% for comment in comments %}
<div class="comment">
<div class="comment-author">{{ comment.author }}</div>
<div class="comment-text">{{ comment.text | safe}}</div>
<div class="comment-likes">👍 {{ comment.likeCount }} | {{ comment.publishedAt }}</div>
</div>
{% endfor %}
{% else %}
<p>No Comments ):</p>
{% endif %}
</div>
</div>
</div>
</body>
</html>