Compare commits

...

14 Commits

7 changed files with 205 additions and 14 deletions

View File

@ -1,14 +1,11 @@
# SimpleerTube
Active Known Instances:
- https://simpleertube.metalune.xyz
To see active instances, refer to [Our Project Page](https://simple-web.metalune.xyz/projects/simpleertube.html)
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://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 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 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 datetime import datetime
from dateutil import parser as dateutil
from math import ceil
import peertube
import html2text
@ -66,15 +67,16 @@ class VideoWrapper:
# Helper Class for using caches
class Cache:
def __init__(self):
def __init__(self, criteria = lambda diff: diff.days > 0):
self.dict = {}
self.criteria = criteria
def get(self, arg, func):
if arg in self.dict:
last_time_updated = (self.dict[arg])[1]
time_diff = datetime.now() - last_time_updated
if time_diff.days > 0:
if self.criteria(time_diff):
self.dict[arg] = [
func(arg),
datetime.now()
@ -92,6 +94,10 @@ cached_instance_names = Cache()
cached_account_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
# loads any site
def get_instance_name(domain):
@ -115,17 +121,157 @@ def get_video_channel(info):
def get_video_channel_info(name):
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.route("/")
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(
"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"])
async def simpleer_search_redirect():
query = (await request.form)["query"]
@ -233,6 +379,7 @@ async def instance_videos_recently_added(domain, page):
@app.route("/<string:domain>/search", methods=["POST"])
async def search_redirect(domain):
query = (await request.form)["query"]

View File

@ -3,7 +3,7 @@
<head>
<title>SimpleerTube - Search</title>
<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" />
</head>
<body>
@ -15,6 +15,45 @@
<input size="45" style="max-width: 100%" type="text" name="query" id="query" placeholder="SepiaSearch"/>
<button type="submit">Search</button>
</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>
</body>
</html>

View File

@ -8,7 +8,9 @@
<div id="wrap">
{% for video in videos.data %}
<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">
<a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a>

View File

@ -8,7 +8,9 @@
<div id="wrap">
{% for video in videos.data %}
<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">
<a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a>

View File

@ -8,7 +8,9 @@
<div id="wrap">
{% for video in videos.data %}
<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">
<a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a>

View File

@ -8,7 +8,9 @@
<div id="wrap">
{% for video in videos.data %}
<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">
<a href="/{{ domain }}/videos/watch/{{ video.uuid }}">{{ video.name }}</a>