Compare commits

...

14 Commits

7 changed files with 205 additions and 14 deletions

View File

@ -1,14 +1,11 @@
# SimpleerTube # SimpleerTube
Active Known Instances: To see active instances, refer to [Our Project Page](https://simple-web.metalune.xyz/projects/simpleertube.html)
- https://simpleertube.metalune.xyz
If you want to add your instance to this list, message us on IRC (#simple-web on irc.libera.chat). For the rest of the documentation, https://tube.metalune.xyz will be used as an example instance.
For the rest of the documentation, https://simpleertube.metalune.xyz will be used as an example instance. If you want to visit any page from your PeerTube instance of choice in SimpleerTube, just prepend https://tube.metalune.xyz to the URL.
So, `https://videos.lukesmith.xyz/accounts/luke` becomes `https://tube.metalune.xyz/videos.lukesmith.xyz/accounts/luke`.
If you want to visit any page from your PeerTube instance of choice in SimpleerTube, just prepend https://simpleertube.metalune.xyz to the URL.
So, `https://videos.lukesmith.xyz/accounts/luke` becomes `https://simpleertube.metalune.xyz/videos.lukesmith.xyz/accounts/luke`.
If you visit the main page, you can search globally (it uses [Sepia Search](https://sepiasearch.org) in the backend). If you visit the main page, you can search globally (it uses [Sepia Search](https://sepiasearch.org) in the backend).

151
main.py
View File

@ -1,5 +1,6 @@
from quart import Quart, request, render_template, redirect from quart import Quart, request, render_template, redirect
from datetime import datetime from datetime import datetime
from dateutil import parser as dateutil
from math import ceil from math import ceil
import peertube import peertube
import html2text import html2text
@ -66,15 +67,16 @@ class VideoWrapper:
# Helper Class for using caches # Helper Class for using caches
class Cache: class Cache:
def __init__(self): def __init__(self, criteria = lambda diff: diff.days > 0):
self.dict = {} self.dict = {}
self.criteria = criteria
def get(self, arg, func): def get(self, arg, func):
if arg in self.dict: if arg in self.dict:
last_time_updated = (self.dict[arg])[1] last_time_updated = (self.dict[arg])[1]
time_diff = datetime.now() - last_time_updated time_diff = datetime.now() - last_time_updated
if time_diff.days > 0: if self.criteria(time_diff):
self.dict[arg] = [ self.dict[arg] = [
func(arg), func(arg),
datetime.now() datetime.now()
@ -92,6 +94,10 @@ cached_instance_names = Cache()
cached_account_infos = Cache() cached_account_infos = Cache()
cached_video_channel_infos = Cache() cached_video_channel_infos = Cache()
cached_subscriptions = Cache(criteria = lambda diff: diff.total_seconds() > 60)
cached_account_videos = Cache(criteria = lambda diff: diff.total_seconds() > 1800)
cached_channel_videos = Cache(criteria = lambda diff: diff.total_seconds() > 1800)
# cache the instance names so we don't have to send a request to the domain every time someone # cache the instance names so we don't have to send a request to the domain every time someone
# loads any site # loads any site
def get_instance_name(domain): def get_instance_name(domain):
@ -115,17 +121,157 @@ def get_video_channel(info):
def get_video_channel_info(name): def get_video_channel_info(name):
return cached_video_channel_infos.get(name, get_video_channel) return cached_video_channel_infos.get(name, get_video_channel)
# Get latest remote videos from channel name
def get_latest_channel_videos(name):
return cached_channel_videos.get(name, latest_channel_videos)
# Refresh latest remote videos from channel name
def latest_channel_videos(name):
print("[CACHE] Refreshing channel videos for %s" % name)
(name, domain) = name.split('@')
return peertube.video_channel_videos(domain, name, 0)
# Get latest remote videos from account name
def get_latest_account_videos(name):
return cached_account_videos.get(name, latest_account_videos)
# Refresh latest remote videos from account name
def latest_account_videos(name):
print("[CACHE] Refreshing account videos for %s" % name)
(name, domain) = name.split('@')
return peertube.account_videos(domain, name, 0)
# Get local accounts subscriptions, as specified in accounts.list
def get_subscriptions_accounts():
return cached_subscriptions.get("accounts", load_subscriptions_accounts)
# Refresh local accounts subscriptions
def load_subscriptions_accounts(_):
return load_subscriptions("accounts")
# Get the latest videos from local accounts subscriptions, ordered by most recent; only return `limit` number of videos
def get_subscriptions_accounts_videos(limit=12):
latest = []
for sub in get_subscriptions_accounts():
result = get_latest_account_videos(sub)
if "error" not in result:
account_latest = get_latest_account_videos(sub)["data"]
latest.extend(account_latest)
else:
print("[WARN] Unable to get content from account " + sub)
latest.sort(key = lambda vid: dateutil.isoparse(vid["createdAt"]), reverse=True)
return latest[0:limit]
# Get local channels subscriptions, as specified in channel.list
def get_subscriptions_channels():
return cached_subscriptions.get("channels", load_subscriptions_channels)
# Refresh local channels subscriptions
def load_subscriptions_channels(_):
return load_subscriptions("channels")
# Load subscriptions from a file called `kind`.list (60s cache)
def load_subscriptions(kind):
print("[CACHE] Refreshing subscriptions %s from %s.list" % (kind, kind))
try:
with open(kind + '.list', 'r') as f:
subscriptions = map(find_subscription, f.read().splitlines())
except Exception as e:
print("No `channels.list` file to load for local subscriptions")
subscriptions = []
# Remove comment entries and empty lines
return list(filter(lambda entry: entry != '', subscriptions))
# Builds a unified id@server from one of those syntaxes, additionally stripping extra whitespace and ignoring `#` as comments:
# - id@server
# - @id@server
# - http(s)://server/c/id
# - http(s)://server/a/id
def find_subscription(request):
identifier = request
identifier = identifier.split('#')[0].strip()
# Comment line is returned as empty string
if identifier == '': return ''
if identifier.startswith('@'):
# Strip @ from identifier
return identifier[1:]
if identifier.startswith('http'):
identifier = identifier[4:]
# HTTPS?
if identifier.startswith('s'): identifier = identifier[1:]
# Remove ://
identifier = identifier[3:]
parts = identifier.split('/')
domain = parts[0]
if parts[1] == 'a' or parts[1] == 'c':
# Account or channel found, take the next part
return parts[2] + '@' + domain
else:
# Just check there's an @ in there and it should be fine
if '@' in identifier:
return identifier
# No match was found, we don't understand this URL
print("[WARN] Identifier not understood from local subscriptions:\n%s" % request)
return ''
# Get the latest videos from local channels subscriptions, ordered by most recent; only return `limit` number of videos
def get_subscriptions_channels_videos(limit=12):
latest = []
for sub in get_subscriptions_channels():
result = get_latest_channel_videos(sub)
if "error" not in result:
channel_latest = get_latest_channel_videos(sub)["data"]
latest.extend(channel_latest)
else:
print("[WARN] Unable to get content from channel " + sub)
latest.sort(key = lambda vid: dateutil.isoparse(vid["createdAt"]), reverse=True)
return latest[0:limit]
# Get the latest videos from local channels and accounts subscriptions combined, ordered by most recent; only return `limit` number of videos; NOTE: duplicates are not handled, why would you add both an account and the corresponding channel?
def get_subscriptions_videos(limit=12):
latest = get_subscriptions_channels_videos(limit=limit)
latest.extend(get_subscriptions_accounts_videos(limit=limit))
# TODO: maybe refactor so we don't have to reorder twice? Or maybe the get_ functions can take a ordered=True argument? In this case here, it would be false, because we sort after
latest.sort(key = lambda vid: dateutil.isoparse(vid["createdAt"]), reverse=True)
return latest[0:limit]
# Get the info about local accounts subscriptions
def get_subscriptions_accounts_info():
return map(lambda sub: get_account_info(sub), get_subscriptions_accounts())
# Get the info about local channels subscriptions
def get_subscriptions_channels_info():
return map(lambda sub: get_video_channel_info(sub), get_subscriptions_channels())
# Get the info about local subscriptions for accounts and channels, as a tuple of lists
def get_subscriptions_info():
list = []
list.extend(get_subscriptions_accounts_info())
list.extend(get_subscriptions_channels_info())
return list
app = Quart(__name__) app = Quart(__name__)
@app.route("/") @app.route("/")
async def main(): async def main():
videos = get_subscriptions_videos(limit=12)
# Inside subscriptions variable, you may find either an account info structure, or a channel info structure. Channels may be recognized due to `ownerAccount` property.
subscriptions = get_subscriptions_info()
return await render_template( return await render_template(
"index.html", "index.html",
videos=videos,
subscriptions=subscriptions,
) )
@app.route("/instance", methods=["POST"])
async def jump_to_instance():
domain = (await request.form)["domain"]
return redirect("/" + domain)
@app.route("/search", methods = ["POST"]) @app.route("/search", methods = ["POST"])
async def simpleer_search_redirect(): async def simpleer_search_redirect():
query = (await request.form)["query"] query = (await request.form)["query"]
@ -233,6 +379,7 @@ async def instance_videos_recently_added(domain, page):
@app.route("/<string:domain>/search", methods=["POST"]) @app.route("/<string:domain>/search", methods=["POST"])
async def search_redirect(domain): async def search_redirect(domain):
query = (await request.form)["query"] query = (await request.form)["query"]

View File

@ -3,7 +3,7 @@
<head> <head>
<title>SimpleerTube - Search</title> <title>SimpleerTube - Search</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="SimpleerTube"/> <link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="SimpleerTube"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head> </head>
<body> <body>
@ -15,6 +15,45 @@
<input size="45" style="max-width: 100%" type="text" name="query" id="query" placeholder="SepiaSearch"/> <input size="45" style="max-width: 100%" type="text" name="query" id="query" placeholder="SepiaSearch"/>
<button type="submit">Search</button> <button type="submit">Search</button>
</form> </form>
<br>
<form action="/instance" method="POST">
<label for="domain">Go to a specific instance:</label>
<br>
<input size="45" style="max-width: 100%" type="text" name="domain" id="domain" placeholder="Domain name"/>
<button type="submit">Go!</button>
</form>
{% if videos|length > 0 %}
<hr>
<h2>{{ videos|length }} latest videos from local subscriptions</h2>
<p>{% for sub in subscriptions %}{% if not loop.first %}, {% endif %}<a href="/{{ sub.host }}{% if sub.ownerAccount %}/video-channels{% else %}/accounts{% endif %}/{{ sub.name }}">{{ sub.displayName }} (@{{ sub.name }}@{{ sub.host }})</a>{% endfor %}</p>
<hr>
<div id="wrap">
{% for vid in videos %}
<div class="result-wrapper">
<a href="/{{ vid.account.host }}/videos/watch/{{ vid.uuid }}">
<img src="https://{{ vid.account.host }}{{ vid.thumbnailPath }}" height="150"/>
</a>
<div class="result-info">
<a href="/{{ vid.account.host }}/videos/watch/{{ vid.uuid }}">{{ vid.name }}</a>
<br>
{{ vid.views }} Views
<br>
<a href="/{{ vid.account.host }}/video-channels/{{ vid.channel.name }}@{{ vid.channel.host }}">
<b>{{ vid.channel.displayName }}</b>
</a>
<br>
<a href="/{{ vid.account.host }}/accounts/{{ vid.account.name }}@{{ vid.account.host }}">
{{ vid.account.name }}@{{ vid.account.host }}
</a>
</div>
</div>
{% endfor %}
</div>{% endif %}
</center> </center>
</body> </body>
</html> </html>

View File

@ -8,7 +8,9 @@
<div id="wrap"> <div id="wrap">
{% for video in videos.data %} {% for video in videos.data %}
<div class="result-wrapper"> <div class="result-wrapper">
<img src="https://{{ domain }}{{ video.thumbnailPath }}" height="150"/> <a href="/{{ domain }}/videos/watch/{{ video.uuid }}">
<img src="https://{{ domain }}{{ video.thumbnailPath }}" height="150"/>
</a>
<div class="result-info"> <div class="result-info">
<a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a> <a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a>

View File

@ -8,7 +8,9 @@
<div id="wrap"> <div id="wrap">
{% for video in videos.data %} {% for video in videos.data %}
<div class="result-wrapper"> <div class="result-wrapper">
<img src="https://{{ domain }}{{ video.thumbnailPath }}" height="150"/> <a href="/{{ domain }}/videos/watch/{{ video.uuid }}">
<img src="https://{{ domain }}{{ video.thumbnailPath }}" height="150"/>
</a>
<div class="result-info"> <div class="result-info">
<a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a> <a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a>

View File

@ -8,7 +8,9 @@
<div id="wrap"> <div id="wrap">
{% for video in videos.data %} {% for video in videos.data %}
<div class="result-wrapper"> <div class="result-wrapper">
<img src="https://{{ domain }}{{ video.thumbnailPath }}" height="150"/> <a href="/{{ domain }}/videos/watch/{{ video.uuid }}">
<img src="https://{{ domain }}{{ video.thumbnailPath }}" height="150"/>
</a>
<div class="result-info"> <div class="result-info">
<a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a> <a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a>

View File

@ -8,7 +8,9 @@
<div id="wrap"> <div id="wrap">
{% for video in videos.data %} {% for video in videos.data %}
<div class="result-wrapper"> <div class="result-wrapper">
<img src="https://{{ domain }}{{ video.thumbnailPath }}" height="150"/> <a href="/{{ domain }}/videos/watch/{{ video.uuid }}">
<img src="https://{{ domain }}{{ video.thumbnailPath }}" height="150"/>
</a>
<div class="result-info"> <div class="result-info">
<a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a> <a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a>