Compare commits
14 Commits
0763e2a0bf
...
2c89497314
Author | SHA1 | Date |
---|---|---|
metalune | 2c89497314 | |
metalune | 2d23b4251d | |
metalune | 308901306b | |
metalune | 0100f9bff6 | |
metalune | f4bdebb9f5 | |
metalune | a624de1ec3 | |
metalune | c56dbd0f38 | |
metalune | 726faaa28d | |
metalune | 7c3d3531e8 | |
southerntofu | 287b2cdbc4 | |
southerntofu | 57dad72d9b | |
southerntofu | 40c1613582 | |
southerntofu | 2b837c887d | |
southerntofu | 2eabe5c1eb |
11
README.md
11
README.md
|
@ -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
151
main.py
|
@ -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"]
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue