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
|
||||
|
||||
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
151
main.py
|
@ -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"]
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue