Merge branch 'master' into stable

This commit is contained in:
j 2025-03-09 17:59:57 +01:00
commit 79da1115aa
54 changed files with 728 additions and 248 deletions

3
ctl
View file

@ -30,9 +30,6 @@ if [ "$action" = "init" ]; then
$SUDO git clone -b $branch https://code.0x2620.org/0x2620/oxjs.git static/oxjs $SUDO git clone -b $branch https://code.0x2620.org/0x2620/oxjs.git static/oxjs
fi fi
$SUDO mkdir -p src $SUDO mkdir -p src
if [ ! -d src/oxtimelines ]; then
$SUDO git clone -b $branch https://code.0x2620.org/0x2620/oxtimelines.git src/oxtimelines
fi
for package in oxtimelines python-ox; do for package in oxtimelines python-ox; do
cd ${BASE} cd ${BASE}
if [ ! -d src/${package} ]; then if [ ! -d src/${package} ]; then

36
pandora/app/oidc.py Normal file
View file

@ -0,0 +1,36 @@
import unicodedata
from django.contrib.auth import get_user_model
import mozilla_django_oidc.auth
from user.utils import prepare_user
User = get_user_model()
class OIDCAuthenticationBackend(mozilla_django_oidc.auth.OIDCAuthenticationBackend):
def create_user(self, claims):
user = super(OIDCAuthenticationBackend, self).create_user(claims)
username = None
for key in ('preferred_username', 'name'):
if claims.get(key):
username = claims[key]
break
n = 1
if username and username != user.username:
uname = username
while User.objects.filter(username=uname).exclude(id=user.id).exists():
n += 1
uname = '%s (%s)' % (username, n)
user.username = uname
user.save()
prepare_user(user)
return user
def update_user(self, user, claims):
return user
def generate_username(email):
return unicodedata.normalize('NFKC', email)[:150]

View file

@ -184,6 +184,7 @@ def init(request, data):
except: except:
pass pass
config['site']['oidc'] = bool(getattr(settings, 'OIDC_RP_CLIENT_ID', False))
response['data']['site'] = config response['data']['site'] = config
response['data']['user'] = init_user(request.user, request) response['data']['user'] = init_user(request.user, request)
request.session['last_init'] = str(datetime.now()) request.session['last_init'] = str(datetime.now())

View file

@ -40,8 +40,12 @@ info_key_map = {
'display_id': 'id', 'display_id': 'id',
} }
YT_DLP = ['yt-dlp']
if settings.YT_DLP_EXTRA:
YT_DLP += settings.YT_DLP_EXTRA
def get_info(url, referer=None): def get_info(url, referer=None):
cmd = ['yt-dlp', '-j', '--all-subs', url] cmd = YT_DLP + ['-j', '--all-subs', url]
if referer: if referer:
cmd += ['--referer', referer] cmd += ['--referer', referer]
p = subprocess.Popen(cmd, p = subprocess.Popen(cmd,
@ -93,7 +97,7 @@ def add_subtitles(item, media, tmp):
sub.save() sub.save()
def load_formats(url): def load_formats(url):
cmd = ['yt-dlp', '-q', url, '-j', '-F'] cmd = YT_DLP + ['-q', url, '-j', '-F']
p = subprocess.Popen(cmd, p = subprocess.Popen(cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, close_fds=True) stderr=subprocess.PIPE, close_fds=True)
@ -112,7 +116,7 @@ def download(item_id, url, referer=None):
if isinstance(tmp, bytes): if isinstance(tmp, bytes):
tmp = tmp.decode('utf-8') tmp = tmp.decode('utf-8')
os.chdir(tmp) os.chdir(tmp)
cmd = ['yt-dlp', '-q', media['url']] cmd = YT_DLP + ['-q', media['url']]
if referer: if referer:
cmd += ['--referer', referer] cmd += ['--referer', referer]
elif 'referer' in media: elif 'referer' in media:

View file

@ -19,10 +19,13 @@ import ox.image
from ox.utils import json from ox.utils import json
from django.conf import settings from django.conf import settings
from PIL import Image, ImageOps from PIL import Image, ImageOps
import pillow_avif
from pillow_heif import register_heif_opener
from .chop import Chop, make_keyframe_index from .chop import Chop, make_keyframe_index
register_heif_opener()
logger = logging.getLogger('pandora.' + __name__) logger = logging.getLogger('pandora.' + __name__)
img_extension = 'jpg' img_extension = 'jpg'

View file

@ -0,0 +1,32 @@
# Generated by Django 4.2.7 on 2025-01-23 10:48
from django.db import migrations, models
def update_path(apps, schema_editor):
File = apps.get_model("archive", "File")
for file in File.objects.all():
if file.path:
parts = file.path.split('/')
file.filename = parts.pop()
file.folder = '/'.join(parts)
file.save()
class Migration(migrations.Migration):
dependencies = [
('archive', '0007_stream_archive_str_file_id_69a542_idx'),
]
operations = [
migrations.AddField(
model_name='file',
name='filename',
field=models.CharField(default='', max_length=2048),
),
migrations.AddField(
model_name='file',
name='folder',
field=models.CharField(default='', max_length=2048),
),
migrations.RunPython(update_path),
]

View file

@ -53,6 +53,9 @@ class File(models.Model):
path = models.CharField(max_length=2048, default="") # canoncial path/file path = models.CharField(max_length=2048, default="") # canoncial path/file
sort_path = models.CharField(max_length=2048, default="") # sort name sort_path = models.CharField(max_length=2048, default="") # sort name
folder = models.CharField(max_length=2048, default="")
filename = models.CharField(max_length=2048, default="")
type = models.CharField(default="", max_length=255) type = models.CharField(default="", max_length=255)
# editable # editable
@ -194,6 +197,13 @@ class File(models.Model):
data['part'] = str(data['part']) data['part'] = str(data['part'])
return data return data
def update_path(self):
path = self.normalize_path()
parts = path.split('/')
self.filename = parts.pop()
self.folder = '/'.join(parts)
return path
def normalize_path(self): def normalize_path(self):
# FIXME: always use format_path # FIXME: always use format_path
if settings.CONFIG['site']['folderdepth'] == 4: if settings.CONFIG['site']['folderdepth'] == 4:
@ -257,7 +267,7 @@ class File(models.Model):
update_path = False update_path = False
if self.info: if self.info:
if self.id: if self.id:
self.path = self.normalize_path() self.path = self.update_path()
else: else:
update_path = True update_path = True
if self.item: if self.item:
@ -290,7 +300,7 @@ class File(models.Model):
self.streams.filter(source=None, available=True).count() self.streams.filter(source=None, available=True).count()
super(File, self).save(*args, **kwargs) super(File, self).save(*args, **kwargs)
if update_path: if update_path:
self.path = self.normalize_path() self.path = self.update_path()
super(File, self).save(*args, **kwargs) super(File, self).save(*args, **kwargs)
def get_path(self, name): def get_path(self, name):
@ -474,6 +484,9 @@ class File(models.Model):
'videoCodec': self.video_codec, 'videoCodec': self.video_codec,
'wanted': self.wanted, 'wanted': self.wanted,
} }
for key in ('folder', 'filename'):
if keys and key in keys:
data[key] = getattr(self, key)
if error: if error:
data['error'] = error data['error'] = error
for key in self.PATH_INFO: for key in self.PATH_INFO:
@ -540,7 +553,7 @@ class File(models.Model):
def process_stream(self): def process_stream(self):
''' '''
extract derivatives from webm upload extract derivatives from stream upload
''' '''
from . import tasks from . import tasks
return tasks.process_stream.delay(self.id) return tasks.process_stream.delay(self.id)

View file

@ -56,6 +56,7 @@
"canExportAnnotations": {"friend": true, "staff": true, "admin": true}, "canExportAnnotations": {"friend": true, "staff": true, "admin": true},
"canImportAnnotations": {"staff": true, "admin": true}, "canImportAnnotations": {"staff": true, "admin": true},
"canImportItems": {}, "canImportItems": {},
"canTranscribeAudio": {},
"canManageDocuments": {"staff": true, "admin": true}, "canManageDocuments": {"staff": true, "admin": true},
"canManageEntities": {"staff": true, "admin": true}, "canManageEntities": {"staff": true, "admin": true},
"canManageHome": {}, "canManageHome": {},
@ -1401,7 +1402,7 @@
240, 288, 360, 432, 480, 720 and 1080. 240, 288, 360, 432, 480, 720 and 1080.
*/ */
"video": { "video": {
"formats": ["webm", "mp4"], "formats": ["mp4"],
// fixme: this should be named "ratio" or "defaultRatio", // fixme: this should be named "ratio" or "defaultRatio",
// as it also applies to clip lists (on load) // as it also applies to clip lists (on load)
"previewRatio": 1.7777777778, "previewRatio": 1.7777777778,

View file

@ -58,6 +58,7 @@
"canImportAnnotations": {"researcher": true, "staff": true, "admin": true}, "canImportAnnotations": {"researcher": true, "staff": true, "admin": true},
// import needs to handle itemRequiresVideo=false first // import needs to handle itemRequiresVideo=false first
"canImportItems": {}, "canImportItems": {},
"canTranscribeAudio": {},
"canManageDocuments": {"member": true, "researcher": true, "staff": true, "admin": true}, "canManageDocuments": {"member": true, "researcher": true, "staff": true, "admin": true},
"canManageEntities": {"member": true, "researcher": true, "staff": true, "admin": true}, "canManageEntities": {"member": true, "researcher": true, "staff": true, "admin": true},
"canManageHome": {"staff": true, "admin": true}, "canManageHome": {"staff": true, "admin": true},
@ -1887,7 +1888,7 @@
240, 288, 360, 432, 480, 720 and 1080. 240, 288, 360, 432, 480, 720 and 1080.
*/ */
"video": { "video": {
"formats": ["webm", "mp4"], "formats": ["mp4"],
"previewRatio": 1.375, "previewRatio": 1.375,
"resolutions": [240, 480] "resolutions": [240, 480]
} }

View file

@ -56,6 +56,7 @@
"canExportAnnotations": {"member": true, "staff": true, "admin": true}, "canExportAnnotations": {"member": true, "staff": true, "admin": true},
"canImportAnnotations": {"member": true, "staff": true, "admin": true}, "canImportAnnotations": {"member": true, "staff": true, "admin": true},
"canImportItems": {"member": true, "staff": true, "admin": true}, "canImportItems": {"member": true, "staff": true, "admin": true},
"canTranscribeAudio": {"staff": true, "admin": true},
"canManageDocuments": {"member": true, "staff": true, "admin": true}, "canManageDocuments": {"member": true, "staff": true, "admin": true},
"canManageEntities": {"member": true, "staff": true, "admin": true}, "canManageEntities": {"member": true, "staff": true, "admin": true},
"canManageHome": {"staff": true, "admin": true}, "canManageHome": {"staff": true, "admin": true},
@ -1391,7 +1392,7 @@
240, 288, 360, 432, 480, 720 and 1080. 240, 288, 360, 432, 480, 720 and 1080.
*/ */
"video": { "video": {
"formats": ["webm", "mp4"], "formats": ["mp4"],
"previewRatio": 1.3333333333, "previewRatio": 1.3333333333,
//supported resolutions are //supported resolutions are
//1080, 720, 480, 432, 360, 288, 240, 144, 96 //1080, 720, 480, 432, 360, 288, 240, 144, 96

View file

@ -63,6 +63,7 @@ examples (config.SITENAME.jsonc) that are part of this pan.do/ra distribution.
"canExportAnnotations": {"member": true, "staff": true, "admin": true}, "canExportAnnotations": {"member": true, "staff": true, "admin": true},
"canImportAnnotations": {"member": true, "staff": true, "admin": true}, "canImportAnnotations": {"member": true, "staff": true, "admin": true},
"canImportItems": {"member": true, "staff": true, "admin": true}, "canImportItems": {"member": true, "staff": true, "admin": true},
"canTranscribeAudio": {},
"canManageDocuments": {"member": true, "staff": true, "admin": true}, "canManageDocuments": {"member": true, "staff": true, "admin": true},
"canManageEntities": {"member": true, "staff": true, "admin": true}, "canManageEntities": {"member": true, "staff": true, "admin": true},
"canManageHome": {"staff": true, "admin": true}, "canManageHome": {"staff": true, "admin": true},
@ -1280,8 +1281,8 @@ examples (config.SITENAME.jsonc) that are part of this pan.do/ra distribution.
240, 288, 360, 432, 480, 720 and 1080. 240, 288, 360, 432, 480, 720 and 1080.
*/ */
"video": { "video": {
"downloadFormat": "webm", "downloadFormat": "mp4",
"formats": ["webm", "mp4"], "formats": ["mp4"],
"previewRatio": 1.3333333333, "previewRatio": 1.3333333333,
"resolutions": [240, 480] "resolutions": [240, 480]
} }

View file

@ -8,6 +8,7 @@ from django.conf import settings
logger = logging.getLogger('pandora.' + __name__) logger = logging.getLogger('pandora.' + __name__)
IMAGE_EXTENSIONS = ('png', 'jpg', 'webp', 'heic', 'heif')
def extract_text(pdf, page=None): def extract_text(pdf, page=None):
if page is not None: if page is not None:
@ -53,7 +54,7 @@ class FulltextMixin:
if self.file: if self.file:
if self.extension == 'pdf': if self.extension == 'pdf':
return extract_text(self.file.path) return extract_text(self.file.path)
elif self.extension in ('png', 'jpg'): elif self.extension in IMAGE_EXTENSIONS:
return ocr_image(self.file.path) return ocr_image(self.file.path)
elif self.extension == 'html': elif self.extension == 'html':
return self.data.get('text', '') return self.data.get('text', '')
@ -180,7 +181,7 @@ class FulltextPageMixin(FulltextMixin):
if self.document.file: if self.document.file:
if self.document.extension == 'pdf': if self.document.extension == 'pdf':
return extract_text(self.document.file.path, self.page) return extract_text(self.document.file.path, self.page)
elif self.extension in ('png', 'jpg'): elif self.extension in IMAGE_EXTENSIONS:
return ocr_image(self.document.file.path) return ocr_image(self.document.file.path)
elif self.extension == 'html': elif self.extension == 'html':
# FIXME: is there a nice way to split that into pages # FIXME: is there a nice way to split that into pages

View file

@ -521,14 +521,17 @@ class Document(models.Model, FulltextMixin):
return save_chunk(self, self.file, chunk, offset, name, done_cb) return save_chunk(self, self.file, chunk, offset, name, done_cb)
return False, 0 return False, 0
def thumbnail(self, size=None, page=None): def thumbnail(self, size=None, page=None, accept=None):
if not self.file: if not self.file:
return os.path.join(settings.STATIC_ROOT, 'png/document.png') return os.path.join(settings.STATIC_ROOT, 'png/document.png')
src = self.file.path src = self.file.path
folder = os.path.dirname(src) folder = os.path.dirname(src)
if size: if size:
size = int(size) size = int(size)
path = os.path.join(folder, '%d.jpg' % size) ext = 'jpg'
if accept and 'image/avif' in accept and size > 512:
ext = 'avif'
path = os.path.join(folder, '%d.%s' % (size, ext))
else: else:
path = src path = src
if self.extension == 'pdf': if self.extension == 'pdf':
@ -561,7 +564,7 @@ class Document(models.Model, FulltextMixin):
path = os.path.join(folder, '%dp%d,%s.jpg' % (size, page, ','.join(map(str, crop)))) path = os.path.join(folder, '%dp%d,%s.jpg' % (size, page, ','.join(map(str, crop))))
if not os.path.exists(path): if not os.path.exists(path):
resize_image(src, path, size=size) resize_image(src, path, size=size)
elif self.extension in ('jpg', 'png', 'gif'): elif self.extension in ('jpg', 'png', 'gif', 'webp', 'heic', 'heif'):
if os.path.exists(src): if os.path.exists(src):
if size and page: if size and page:
crop = list(map(int, page.split(','))) crop = list(map(int, page.split(',')))
@ -574,7 +577,7 @@ class Document(models.Model, FulltextMixin):
img = open_image_rgb(path) img = open_image_rgb(path)
src = path src = path
if size < max(img.size): if size < max(img.size):
path = os.path.join(folder, '%sp%s.jpg' % (size, ','.join(map(str, crop)))) path = os.path.join(folder, '%sp%s.%s' % (size, ','.join(map(str, crop)), ext))
if not os.path.exists(path): if not os.path.exists(path):
resize_image(src, path, size=size) resize_image(src, path, size=size)
if os.path.exists(src) and not os.path.exists(path): if os.path.exists(src) and not os.path.exists(path):

View file

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from glob import glob
import mimetypes
import os import os
import re import re
from glob import glob
import unicodedata import unicodedata
import ox import ox
@ -378,15 +379,24 @@ actions.register(sortDocuments, cache=False)
def file(request, id, name=None): def file(request, id, name=None):
document = get_document_or_404_json(request, id) document = get_document_or_404_json(request, id)
accept = request.headers.get("Accept")
mime_type = mimetypes.guess_type(document.file.path)[0]
mime_type = 'image/%s' % document.extension
if accept and 'image/' in accept and document.extension in (
'webp', 'heif', 'heic', 'avif', 'tiff'
) and mime_type not in accept:
image_size = max(document.width, document.height)
return HttpFileResponse(document.thumbnail(image_size, accept=accept))
return HttpFileResponse(document.file.path) return HttpFileResponse(document.file.path)
def thumbnail(request, id, size=256, page=None): def thumbnail(request, id, size=256, page=None):
size = int(size) size = int(size)
document = get_document_or_404_json(request, id) document = get_document_or_404_json(request, id)
accept = request.headers.get("Accept")
if "q" in request.GET and page: if "q" in request.GET and page:
img = document.highlight_page(page, request.GET["q"], size) img = document.highlight_page(page, request.GET["q"], size)
return HttpResponse(img, content_type="image/jpeg") return HttpResponse(img, content_type="image/jpeg")
return HttpFileResponse(document.thumbnail(size, page=page)) return HttpFileResponse(document.thumbnail(size, page=page, accept=accept))
@login_required_json @login_required_json

View file

@ -495,6 +495,9 @@ class Clip(models.Model):
'id': self.get_id(), 'id': self.get_id(),
'index': self.index, 'index': self.index,
'volume': self.volume, 'volume': self.volume,
'hue': self.hue,
'saturation': self.saturation,
'lightness': self.lightness,
} }
if self.annotation: if self.annotation:
data['annotation'] = self.annotation.public_id data['annotation'] = self.annotation.public_id

View file

@ -1375,7 +1375,7 @@ class Item(models.Model):
self.poster_height = self.poster.height self.poster_height = self.poster.height
self.poster_width = self.poster.width self.poster_width = self.poster.width
self.clear_poster_cache(self.poster.path) self.clear_poster_cache(self.poster.path)
if self.cache.get('posterRatio') != self.poster_width / self.poster_height: if self.poster_width and self.cache.get('posterRatio') != self.poster_width / self.poster_height:
self.update_cache(poster_width=self.poster_width, self.update_cache(poster_width=self.poster_width,
poster_height=self.poster_height) poster_height=self.poster_height)

View file

@ -1004,7 +1004,9 @@ def download_source(request, id, part=None):
response['Content-Disposition'] = "attachment; filename*=UTF-8''%s" % quote(filename.encode('utf-8')) response['Content-Disposition'] = "attachment; filename*=UTF-8''%s" % quote(filename.encode('utf-8'))
return response return response
def download(request, id, resolution=None, format='webm', part=None): def download(request, id, resolution=None, format=None, part=None):
if format is None:
format = settings.CONFIG['video']['formats'][0]
item = get_object_or_404(models.Item, public_id=id) item = get_object_or_404(models.Item, public_id=id)
if not resolution or int(resolution) not in settings.CONFIG['video']['resolutions']: if not resolution or int(resolution) not in settings.CONFIG['video']['resolutions']:
resolution = max(settings.CONFIG['video']['resolutions']) resolution = max(settings.CONFIG['video']['resolutions'])

View file

@ -34,6 +34,13 @@ def api(request):
return response return response
if request.META.get('CONTENT_TYPE') == 'application/json': if request.META.get('CONTENT_TYPE') == 'application/json':
r = json.loads(request.body.decode('utf-8')) r = json.loads(request.body.decode('utf-8'))
if 'action' not in r:
logger.error("invalid api request: %s", r)
response = render_to_json_response(json_response(status=400,
text='Invalid request'))
response['Access-Control-Allow-Origin'] = '*'
return response
else:
action = r['action'] action = r['action']
data = r.get('data', {}) data = r.get('data', {})
else: else:

View file

@ -111,6 +111,7 @@ ROOT_URLCONF = 'urls'
INSTALLED_APPS = ( INSTALLED_APPS = (
'django.contrib.auth', 'django.contrib.auth',
'mozilla_django_oidc',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.sites', 'django.contrib.sites',
@ -158,6 +159,27 @@ INSTALLED_APPS = (
) )
AUTH_USER_MODEL = 'system.User' AUTH_USER_MODEL = 'system.User'
AUTH_PROFILE_MODULE = 'user.UserProfile'
AUTH_CHECK_USERNAME = True
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
)
# OpenID Connect login support
LOGIN_REDIRECT_URL = "/grid"
LOGOUT_REDIRECT_URL = "/grid"
OIDC_USERNAME_ALGO = "app.oidc.generate_username"
OIDC_RP_CLIENT_ID = None
# define those in local_settings to enable OCID based login
#OIDC_RP_CLIENT_ID = '<client id>'
#OIDC_RP_CLIENT_SECRET = '<client secret>'
#OIDC_RP_SIGN_ALGO = "RS256"
#OIDC_OP_JWKS_ENDPOINT = "<jwks endpoint>"
#OIDC_OP_AUTHORIZATION_ENDPOINT = "<authorization endpoint>"
#OIDC_OP_TOKEN_ENDPOINT = "<token endpoint>"
#OIDC_OP_USER_ENDPOINT = "<user endpoint>"
# Log errors into db # Log errors into db
LOGGING = { LOGGING = {
@ -193,8 +215,6 @@ CACHES = {
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_PROFILE_MODULE = 'user.UserProfile'
AUTH_CHECK_USERNAME = True
FFMPEG = 'ffmpeg' FFMPEG = 'ffmpeg'
FFPROBE = 'ffprobe' FFPROBE = 'ffprobe'
USE_VP9 = True USE_VP9 = True
@ -291,6 +311,8 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 32 * 1024 * 1024
EMPTY_CLIPS = True EMPTY_CLIPS = True
YT_DLP_EXTRA = []
#you can ignore things below this line #you can ignore things below this line
#========================================================================= #=========================================================================
LOCAL_APPS = [] LOCAL_APPS = []
@ -321,3 +343,7 @@ except NameError:
INSTALLED_APPS = tuple(list(INSTALLED_APPS) + LOCAL_APPS) INSTALLED_APPS = tuple(list(INSTALLED_APPS) + LOCAL_APPS)
if OIDC_RP_CLIENT_ID:
AUTHENTICATION_BACKENDS = list(AUTHENTICATION_BACKENDS) + [
'app.oidc.OIDCAuthenticationBackend'
]

View file

@ -88,6 +88,9 @@ class Task(models.Model):
except Item.DoesNotExist: except Item.DoesNotExist:
return False return False
if self.status == 'transcribing':
return False
if self.item.files.filter(wanted=True, available=False).count(): if self.item.files.filter(wanted=True, available=False).count():
status = 'pending' status = 'pending'
elif self.item.files.filter(uploading=True).count(): elif self.item.files.filter(uploading=True).count():

View file

@ -22,8 +22,8 @@
{% endif %} {% endif %}
<meta property="og:site_name" content="{{ settings.SITENAME }}"/> <meta property="og:site_name" content="{{ settings.SITENAME }}"/>
{% compress css file m %} {% compress css file m %}
<link rel="stylesheet" href="{% static 'mobile/css/reset.css' %}"></link> <link rel="stylesheet" href="{% static 'mobile/css/reset.css' %}">
<link rel="stylesheet" href="{% static 'mobile/css/style.css' %}"></link> <link rel="stylesheet" href="{% static 'mobile/css/style.css' %}">
{% endcompress %} {% endcompress %}
<meta name="google" value="notranslate"/> <meta name="google" value="notranslate"/>
</head> </head>

View file

@ -2,7 +2,7 @@
import os import os
import importlib import importlib
from django.urls import path, re_path from django.urls import path, re_path, include
from oxdjango.http import HttpFileResponse from oxdjango.http import HttpFileResponse
from django.conf import settings from django.conf import settings
@ -33,9 +33,15 @@ import urlalias.views
def serve_static_file(path, location, content_type): def serve_static_file(path, location, content_type):
return HttpFileResponse(location, content_type=content_type) return HttpFileResponse(location, content_type=content_type)
urlpatterns = [ urlpatterns = [
#path('admin/', admin.site.urls), #path('admin/', admin.site.urls),
]
if settings.OIDC_RP_CLIENT_ID:
urlpatterns += [
path('oidc/', include('mozilla_django_oidc.urls')),
]
urlpatterns += [
re_path(r'^api/locale.(?P<lang>.*).json$', translation.views.locale_json), re_path(r'^api/locale.(?P<lang>.*).json$', translation.views.locale_json),
re_path(r'^api/upload/text/?$', text.views.upload), re_path(r'^api/upload/text/?$', text.views.upload),
re_path(r'^api/upload/document/?$', document.views.upload), re_path(r'^api/upload/document/?$', document.views.upload),
@ -47,7 +53,7 @@ urlpatterns = [
re_path(r'^resetUI$', user.views.reset_ui), re_path(r'^resetUI$', user.views.reset_ui),
re_path(r'^collection/(?P<id>.*?)/icon(?P<size>\d*).jpg$', documentcollection.views.icon), re_path(r'^collection/(?P<id>.*?)/icon(?P<size>\d*).jpg$', documentcollection.views.icon),
re_path(r'^documents/(?P<id>[A-Z0-9]+)/(?P<size>\d*)p(?P<page>[\d,]*).jpg$', document.views.thumbnail), re_path(r'^documents/(?P<id>[A-Z0-9]+)/(?P<size>\d*)p(?P<page>[\d,]*).jpg$', document.views.thumbnail),
re_path(r'^documents/(?P<id>[A-Z0-9]+)/(?P<name>.*?\.[^\d]{3})$', document.views.file), re_path(r'^documents/(?P<id>[A-Z0-9]+)/(?P<name>.*?\.[^\d]{3,4})$', document.views.file),
re_path(r'^documents/(?P<fragment>.*?)$', document.views.document), re_path(r'^documents/(?P<fragment>.*?)$', document.views.document),
re_path(r'^edit/(?P<id>.*?)/icon(?P<size>\d*).jpg$', edit.views.icon), re_path(r'^edit/(?P<id>.*?)/icon(?P<size>\d*).jpg$', edit.views.icon),
re_path(r'^list/(?P<id>.*?)/icon(?P<size>\d*).jpg$', itemlist.views.icon), re_path(r'^list/(?P<id>.*?)/icon(?P<size>\d*).jpg$', itemlist.views.icon),

View file

@ -436,8 +436,7 @@ def has_capability(user, capability):
level = 'guest' level = 'guest'
else: else:
level = user.profile.get_level() level = user.profile.get_level()
return level in settings.CONFIG['capabilities'][capability] \ return settings.CONFIG['capabilities'].get(capability, {}).get(level)
and settings.CONFIG['capabilities'][capability][level]
def merge_users(old, new): def merge_users(old, new):
old.annotations.all().update(user=new) old.annotations.all().update(user=new)

View file

@ -3,6 +3,38 @@ from django.contrib.gis.geoip2 import GeoIP2, GeoIP2Exception
import ox import ox
def prepare_user(user):
from django.contrib.auth import get_user_model
from django.conf import settings
from itemlist.models import List, Position
from django.db.models import Max
User = get_user_model()
first_user_qs = User.objects.all()
if user.id:
first_user_qs = first_user_qs.exclude(id=user.id)
if first_user_qs.count() == 0:
user.is_superuser = True
user.is_staff = True
user.save()
for l in settings.CONFIG['personalLists']:
list = List(name=l['title'], user=user)
for key in ('query', 'public', 'featured'):
if key in l:
setattr(list, key, l[key])
if key == 'query':
for c in list.query['conditions']:
if c['key'] == 'user':
c['value'] = c['value'].format(username=user.username)
list.save()
pos = Position(list=list, section='personal', user=user)
qs = Position.objects.filter(user=user, section='personal')
pos.position = (qs.aggregate(Max('position'))['position__max'] or 0) + 1
pos.save()
def get_ip(request): def get_ip(request):
if 'HTTP_X_FORWARDED_FOR' in request.META: if 'HTTP_X_FORWARDED_FOR' in request.META:
ip = request.META['HTTP_X_FORWARDED_FOR'].split(',')[0] ip = request.META['HTTP_X_FORWARDED_FOR'].split(',')[0]

View file

@ -28,7 +28,7 @@ from user.models import Group
from . import models from . import models
from .decorators import capability_required_json from .decorators import capability_required_json
from .utils import rename_user from .utils import rename_user, prepare_user
User = get_user_model() User = get_user_model()
@ -177,28 +177,10 @@ def signup(request, data):
} }
}) })
else: else:
first_user = User.objects.count() == 0
user = User(username=data['username'], email=data['email']) user = User(username=data['username'], email=data['email'])
user.set_password(data['password']) user.set_password(data['password'])
#make first user admin
user.is_superuser = first_user
user.is_staff = first_user
user.save() user.save()
#create default user lists: prepare_user(user)
for l in settings.CONFIG['personalLists']:
list = List(name=l['title'], user=user)
for key in ('query', 'public', 'featured'):
if key in l:
setattr(list, key, l[key])
if key == 'query':
for c in list.query['conditions']:
if c['key'] == 'user':
c['value'] = c['value'].format(username=user.username)
list.save()
pos = Position(list=list, section='personal', user=user)
qs = Position.objects.filter(user=user, section='personal')
pos.position = (qs.aggregate(Max('position'))['position__max'] or 0) + 1
pos.save()
if request.session.session_key: if request.session.session_key:
models.SessionData.objects.filter(session_key=request.session.session_key).update(user=user) models.SessionData.objects.filter(session_key=request.session.session_key).update(user=user)
ui = json.loads(request.session.get('ui', 'null')) ui = json.loads(request.session.get('ui', 'null'))

View file

@ -20,3 +20,6 @@ future
pytz pytz
pypdfium2 pypdfium2
Pillow>=10 Pillow>=10
pillow-heif
pillow-avif-plugin
mozilla-django-oidc==4.0.1

View file

@ -106,6 +106,13 @@ pandora.ui.addFilesDialog = function(options) {
}); });
var selectItems = []; var selectItems = [];
selectItems.push({
id: 'one',
title: Ox._(
options.items.length > 1 ? 'Create new {0} with multiple parts' : 'Create new {0}',
[pandora.site.itemName.singular.toLowerCase()]
)
});
if (pandora.user.ui.item && options.editable) { if (pandora.user.ui.item && options.editable) {
selectItems.push({ selectItems.push({
id: 'add', id: 'add',
@ -124,13 +131,6 @@ pandora.ui.addFilesDialog = function(options) {
) )
}); });
} }
selectItems.push({
id: 'one',
title: Ox._(
options.items.length > 1 ? 'Create new {0} with multiple parts' : 'Create new {0}',
[pandora.site.itemName.singular.toLowerCase()]
)
});
var $select = Ox.Select({ var $select = Ox.Select({
items: selectItems, items: selectItems,
width: 256 width: 256

View file

@ -100,6 +100,10 @@ pandora.ui.appPanel = function() {
pandora.$ui.siteDialog = pandora.ui.siteDialog(page).open(); pandora.$ui.siteDialog = pandora.ui.siteDialog(page).open();
} }
} else if (['signup', 'signin'].indexOf(page) > -1) { } else if (['signup', 'signin'].indexOf(page) > -1) {
if (pandora.site.site.oidc) {
pandora.oidcLogin()
return
}
if (pandora.user.level == 'guest') { if (pandora.user.level == 'guest') {
if (pandora.$ui.accountDialog && pandora.$ui.accountDialog.is(':visible')) { if (pandora.$ui.accountDialog && pandora.$ui.accountDialog.is(':visible')) {
pandora.$ui.accountDialog.options(pandora.ui.accountDialogOptions(page)); pandora.$ui.accountDialog.options(pandora.ui.accountDialogOptions(page));

View file

@ -21,11 +21,13 @@ pandora.ui.editor = function(data) {
censoredIcon: pandora.site.cantPlay.icon, censoredIcon: pandora.site.cantPlay.icon,
censoredTooltip: Ox._(pandora.site.cantPlay.text), censoredTooltip: Ox._(pandora.site.cantPlay.text),
clickLink: pandora.clickLink, clickLink: pandora.clickLink,
confirmDeleteDialog: confirmDeleteDialog,
cuts: data.cuts || [], cuts: data.cuts || [],
duration: data.duration, duration: data.duration,
enableDownload: pandora.hasCapability('canDownloadVideo') >= data.rightslevel || data.editable, enableDownload: pandora.hasCapability('canDownloadVideo') >= data.rightslevel || data.editable,
enableExport: pandora.hasCapability('canExportAnnotations') || data.editable, enableExport: pandora.hasCapability('canExportAnnotations') || data.editable,
enableImport: pandora.hasCapability('canImportAnnotations') || data.editable, enableImport: pandora.hasCapability('canImportAnnotations') || data.editable,
enableTranscribe: pandora.hasCapability('canTranscribeAudio') && data.editable,
enableSetPosterFrame: !pandora.site.media.importFrames && data.editable, enableSetPosterFrame: !pandora.site.media.importFrames && data.editable,
enableSubtitles: ui.videoSubtitles, enableSubtitles: ui.videoSubtitles,
find: ui.itemFind, find: ui.itemFind,
@ -220,7 +222,9 @@ pandora.ui.editor = function(data) {
}); });
} }
that.updateAnnotation(data.id, result.data); that.updateAnnotation(data.id, result.data);
if (result.data.id) {
pandora.UI.set('videoPoints.' + ui.item + '.annotation', result.data.id.split('/')[1] || ''); pandora.UI.set('videoPoints.' + ui.item + '.annotation', result.data.id.split('/')[1] || '');
}
Ox.Request.clearCache(); Ox.Request.clearCache();
}; };
var edit = { var edit = {
@ -391,7 +395,21 @@ pandora.ui.editor = function(data) {
pandora.UI.set({videoResolution: data.resolution}); pandora.UI.set({videoResolution: data.resolution});
}, },
select: function(data) { select: function(data) {
if (Ox.isArray(data.id)) {
var range = data.id.map(id => {
return getAnnotationById(id)
})
data['in'] = Ox.min(range.map(annotation => { return annotation["in"]; }))
data['out'] = Ox.max(range.map(annotation => { return annotation["out"]; }))
pandora.UI.set('videoPoints.' + ui.item, {
annotation: '',
'in': data['in'],
out: data.out,
position: ui.videoPoints[ui.item].position
})
} else {
pandora.UI.set('videoPoints.' + ui.item + '.annotation', data.id.split('/')[1] || ''); pandora.UI.set('videoPoints.' + ui.item + '.annotation', data.id.split('/')[1] || '');
}
}, },
showentityinfo: function(data) { showentityinfo: function(data) {
pandora.URL.push('/entities/' + data.id) pandora.URL.push('/entities/' + data.id)
@ -417,6 +435,54 @@ pandora.ui.editor = function(data) {
togglesize: function(data) { togglesize: function(data) {
pandora.UI.set({videoSize: data.size}); pandora.UI.set({videoSize: data.size});
}, },
transcribeaudio: function() {
const $dialog = pandora.ui.iconDialog({
buttons: [
Ox.Button({
id: 'cancel',
title: Ox._('Cancel')
})
.bindEvent({
click: function() {
$dialog.close();
}
}),
Ox.Button({
id: 'transcribe',
title: Ox._('Transcribe Audio')
})
.bindEvent({
click: function() {
$dialog.close();
pandora.api.transcribeAudio({
item: pandora.user.ui.item
}, function(result) {
if (result.data.taskId) {
pandora.wait(result.data.taskId, function(result) {
Ox.Request.clearCache();
if (ui.item == data.id && ui.itemView == 'editor') {
pandora.$ui.contentPanel.replaceElement(
1, pandora.$ui.item = pandora.ui.item()
);
}
})
}
})
pandora.ui.tasksDialog().open();
}
})
],
content: Ox._(
'Are you sure you want to add automated transcription to "{0}"',
[data.title]
),
keys: {enter: 'transcribe', escape: 'cancel'},
title: Ox._(
'Transcribe {0} {1}', [data.title]
)
});
$dialog.open()
},
pandora_showannotations: function(data) { pandora_showannotations: function(data) {
that.options({showAnnotations: data.value}); that.options({showAnnotations: data.value});
}, },
@ -428,6 +494,60 @@ pandora.ui.editor = function(data) {
pandora._dontSelectResult = false; pandora._dontSelectResult = false;
function confirmDeleteDialog(options, callback) {
const title = Ox.getObjectById(
pandora.site.layers,
getAnnotationById(options.items[0]).layer
).item
const subject = options.items.length == 1 ? title : title + 's'
const $dialog = pandora.ui.iconDialog({
buttons: [
Ox.Button({
id: 'cancel',
title: Ox._('Keep {0}', [Ox._(subject)])
})
.bindEvent({
click: function() {
$dialog.close();
}
}),
Ox.Button({
id: 'delete',
title: Ox._('Delete {0}', [Ox._(subject)])
})
.bindEvent({
click: function() {
$dialog.close();
callback()
}
})
],
content: Ox._(
'Are you sure you want delete {0} {1}?<br><br>All data will be removed.',
[options.items.length, Ox._(subject.toLowerCase())]
),
height: 96,
keys: {enter: 'delete', escape: 'cancel'},
title: Ox._(
'Delete {0}', [Ox._(subject)]
)
});
$dialog.open()
return $dialog
}
function getAnnotationById(id) {
var annotation
data.annotations.forEach(layer => {
layer.items.forEach(a => {
if (a.id == id) {
annotation = a
}
})
})
return annotation;
}
function updateBrowser() { function updateBrowser() {
pandora.$ui.browser.find('img[src*="/' + ui.item + '/"]').each(function() { pandora.$ui.browser.find('img[src*="/' + ui.item + '/"]').each(function() {
$(this).attr({ $(this).attr({

View file

@ -102,6 +102,13 @@ pandora.ui.exportAnnotationsDialog = function(options) {
$button.wrap($('<a>')); $button.wrap($('<a>'));
// On wrap, a reference to the link would *not* be the link in the DOM // On wrap, a reference to the link would *not* be the link in the DOM
$link = $($button.parent()); $link = $($button.parent());
$link.on({
click: function() {
setTimeout(() => {
that.close()
}, 10)
}
})
updateLink(); updateLink();
} }

View file

@ -424,18 +424,26 @@ pandora.ui.folders = function(section) {
}).bindEvent({ }).bindEvent({
click: function() { click: function() {
var $dialog = pandora.ui.iconDialog({ var $dialog = pandora.ui.iconDialog({
buttons: title != Ox._('Featured ' + folderItems) ? [ buttons: title != Ox._('Featured ' + folderItems) ? [].concat(
pandora.site.site.oidc ? []
: [
Ox.Button({title: Ox._('Sign Up...')}).bindEvent({ Ox.Button({title: Ox._('Sign Up...')}).bindEvent({
click: function() { click: function() {
$dialog.close(); $dialog.close();
pandora.$ui.accountDialog = pandora.ui.accountDialog('signup').open(); pandora.$ui.accountDialog = pandora.ui.accountDialog('signup').open();
} }
}), })
],
[
Ox.Button({title: Ox._('Sign In...')}).bindEvent({ Ox.Button({title: Ox._('Sign In...')}).bindEvent({
click: function() { click: function() {
$dialog.close(); $dialog.close();
if (pandora.site.site.oidc) {
pandora.oidcLogin()
} else {
pandora.$ui.accountDialog = pandora.ui.accountDialog('signin').open(); pandora.$ui.accountDialog = pandora.ui.accountDialog('signin').open();
} }
}
}), }),
{}, {},
Ox.Button({title: Ox._('Not Now')}).bindEvent({ Ox.Button({title: Ox._('Not Now')}).bindEvent({
@ -443,7 +451,8 @@ pandora.ui.folders = function(section) {
$dialog.close(); $dialog.close();
} }
}) })
] : [ ]
): [
Ox.Button({title: Ox._('Close')}).bindEvent({ Ox.Button({title: Ox._('Close')}).bindEvent({
click: function() { click: function() {
$dialog.close(); $dialog.close();

View file

@ -1,6 +1,10 @@
'use strict'; 'use strict';
pandora.getItemTitle = function(itemData, includeYear) { pandora.getItemTitle = function(itemData, includeYear) {
var director = itemData.director
if (!Ox.isArray(director)) {
director = [director]
}
return (itemData.title || Ox._('Untitled')) + ( return (itemData.title || Ox._('Untitled')) + (
Ox.len(itemData.director) || (includeYear && itemData.year) Ox.len(itemData.director) || (includeYear && itemData.year)
? ' (' + ( ? ' (' + (

View file

@ -137,6 +137,7 @@ pandora.ui.helpDialog = function() {
that.select = function(id) { that.select = function(id) {
var img, $img; var img, $img;
if ($text) {
$text.html(text[id || 'help']).scrollTop(0); $text.html(text[id || 'help']).scrollTop(0);
img = $text.find('img'); img = $text.find('img');
if (img) { if (img) {
@ -155,6 +156,7 @@ pandora.ui.helpDialog = function() {
textAlign: 'right', textAlign: 'right',
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
}); });
}
return that; return that;
} }

View file

@ -171,13 +171,13 @@ pandora.ui.home = function() {
}), }),
$signinButton = Ox.Button({ $signinButton = Ox.Button({
title: Ox._('Sign In'), title: Ox._('Sign In'),
width: 74 width: pandora.site.site.oidc ? 156 : 74
}) })
.css({ .css({
position: 'absolute', position: 'absolute',
left: 0, left: 0,
top: '112px', top: '112px',
right: '82px', right: pandora.site.site.oidc ? '164px' : '82px',
bottom: 0, bottom: 0,
margin: 'auto', margin: 'auto',
opacity: 0 opacity: 0
@ -248,7 +248,13 @@ pandora.ui.home = function() {
adjustRatio(); adjustRatio();
if (pandora.user.level == 'guest') { if (pandora.user.level == 'guest') {
if (pandora.site.site.oidc) {
$signinButton.options({
width: 156
})
} else {
$signupButton.appendTo(that); $signupButton.appendTo(that);
}
$signinButton.appendTo(that); $signinButton.appendTo(that);
} else { } else {
$preferencesButton.appendTo(that); $preferencesButton.appendTo(that);

View file

@ -201,10 +201,11 @@ pandora.ui.importAnnotationsDialog = function(options) {
pandora.$ui.contentPanel.replaceElement( pandora.$ui.contentPanel.replaceElement(
1, pandora.$ui.item = pandora.ui.item() 1, pandora.$ui.item = pandora.ui.item()
); );
that.close();
} else { } else {
$status.html(Ox._('Import failed.')); $status.html(Ox._('Import failed.'));
}
enableButtons(); enableButtons();
}
}); });
} else { } else {
$status.html(Ox._('Import failed.')); $status.html(Ox._('Import failed.'));

View file

@ -39,7 +39,7 @@ pandora.ui.item = function() {
} }
}) })
if (!Ox.isEmpty(set)) { if (!Ox.isEmpty(set)) {
pandora.UI.set('videoPoints.' + item, Ox.extend(videoPoints[point], set[point])) pandora.UI.set('videoPoints.' + item, Ox.extend(videoPoints, set))
} }
} }
@ -63,9 +63,9 @@ pandora.ui.item = function() {
var tasks = result_.data.items.filter(function(task) { return task.item == item}) var tasks = result_.data.items.filter(function(task) { return task.item == item})
if (tasks.length > 0) { if (tasks.length > 0) {
html = Ox._( html = Ox._(
'<i>{0}</i> is currently processed. ' '<i>{0}</i> is currently being processed. '
+ '{1} view will be available in a moment.', + '{1} view will be available in a moment.',
[result.data.title, Ox._(pandora.user.ui.itemView)] [result.data.title, Ox.toTitleCase(Ox._(pandora.user.ui.itemView))]
) )
} }
note.html(html) note.html(html)
@ -202,6 +202,10 @@ pandora.ui.item = function() {
} }
}); });
} }
// avoid loop while selecting multiple annotations
if (options.selected == '' && Ox.isArray(pandora.$ui[pandora.user.ui.itemView].options('selected'))) {
delete options.selected
}
pandora.$ui[pandora.user.ui.itemView].options(options); pandora.$ui[pandora.user.ui.itemView].options(options);
} }
} }

View file

@ -270,7 +270,7 @@ pandora.ui.list = function() {
item: function(data, sort, size) { item: function(data, sort, size) {
size = 128; size = 128;
var clipsQuery = pandora.getClipsQuery(), var clipsQuery = pandora.getClipsQuery(),
isClipsQuery = !!clipsQuery.conditions.length, isClipsQuery = clipsQuery.conditions.length > 1,
ratio = ui.icons == 'posters' ratio = ui.icons == 'posters'
? (ui.showSitePosters ? pandora.site.posters.ratio : data.posterRatio) : 1, ? (ui.showSitePosters ? pandora.site.posters.ratio : data.posterRatio) : 1,
url = pandora.getMediaURL('/' + data.id + '/' + ( url = pandora.getMediaURL('/' + data.id + '/' + (
@ -352,7 +352,7 @@ pandora.ui.list = function() {
}, },
items: function(data, callback) { items: function(data, callback) {
var clipsQuery = pandora.getClipsQuery(), var clipsQuery = pandora.getClipsQuery(),
isClipsQuery = !!clipsQuery.conditions.length; isClipsQuery = clipsQuery.conditions.length > 1;
pandora.api.find(Ox.extend(data, Ox.extend({ pandora.api.find(Ox.extend(data, Ox.extend({
query: ui.find query: ui.find
}, isClipsQuery ? {clips: { }, isClipsQuery ? {clips: {

View file

@ -46,7 +46,9 @@ pandora.ui.mainMenu = function() {
{ id: 'tasks', title: Ox._('Tasks...'), disabled: isGuest }, { id: 'tasks', title: Ox._('Tasks...'), disabled: isGuest },
{ id: 'archives', title: Ox._('Archives...'), disabled: /*isGuest*/ true }, { id: 'archives', title: Ox._('Archives...'), disabled: /*isGuest*/ true },
{}, {},
{ id: 'signup', title: Ox._('Sign Up...'), disabled: !isGuest }, !pandora.site.site.oidc
? { id: 'signup', title: Ox._('Sign Up...'), disabled: !isGuest }
: [],
isGuest ? { id: 'signin', title: Ox._('Sign In...')} isGuest ? { id: 'signin', title: Ox._('Sign In...')}
: { id: 'signout', title: Ox._('Sign Out...')} : { id: 'signout', title: Ox._('Sign Out...')}
] }, ] },

View file

@ -142,6 +142,22 @@ pandora.ui.mediaView = function(options) {
visible: true, visible: true,
width: 360 width: 360
}, },
{
align: 'left',
id: 'folder',
operator: '+',
title: Ox._('Folder'),
visible: false,
width: 360
},
{
align: 'left',
id: 'filename',
operator: '+',
title: Ox._('Filename'),
visible: false,
width: 360
},
{ {
editable: true, editable: true,
id: 'version', id: 'version',
@ -441,6 +457,8 @@ pandora.ui.mediaView = function(options) {
}); });
} }
} }
}).on({
keyup: updateForm
}); });
}); });
@ -663,7 +681,9 @@ pandora.ui.mediaView = function(options) {
}); });
} }
self.$moveButton.options({ self.$moveButton.options({
disabled: self.selected.length == 0 disabled: self.selected.length == 0 || (
self.$idInput.value().length + self.$titleInput.value().length
) == 0
}); });
self.$menu[ self.$menu[
self.selected.length == 0 ? 'disableItem' : 'enableItem' self.selected.length == 0 ? 'disableItem' : 'enableItem'

View file

@ -67,7 +67,7 @@ pandora.ui.metadataDialog = function(data) {
'To update the metadata for this {0}, please enter its IMDb ID.', 'To update the metadata for this {0}, please enter its IMDb ID.',
[pandora.site.itemName.singular.toLowerCase()] [pandora.site.itemName.singular.toLowerCase()]
), ),
keyboard: {enter: 'update', escape: 'close'}, keys: {enter: 'update', escape: 'close'},
title: Ox._('Update Metadata') title: Ox._('Update Metadata')
}); });
} }

View file

@ -354,6 +354,10 @@ appPanel
if (pandora.user.ui.itemView == 'video') { if (pandora.user.ui.itemView == 'video') {
pandora.user.ui.itemView = 'player'; pandora.user.ui.itemView = 'player';
} }
// item find accross relodas breaks links to in/out
if (pandora.user.ui.itemFind) {
pandora.user.ui.itemFind = "";
}
Ox.extend(pandora.site, { Ox.extend(pandora.site, {
calendar: data.site.layers.some(function(layer) { calendar: data.site.layers.some(function(layer) {

View file

@ -79,7 +79,8 @@ pandora.ui.tasksDialog = function(options) {
'processing': 'Processing', 'processing': 'Processing',
'canceled': 'Canceled', 'canceled': 'Canceled',
'failed': 'Failed', 'failed': 'Failed',
'finished': 'Finished' 'finished': 'Finished',
'transcribing': 'Transcribing'
}[value] || value; }[value] || value;
}, },
id: 'status', id: 'status',

View file

@ -8,8 +8,6 @@ pandora.ui.uploadDocumentDialog = function(options, callback) {
existingFiles = [], existingFiles = [],
uploadFiles = [], uploadFiles = [],
supportedExtensions = ['gif', 'jpg', 'jpeg', 'pdf', 'png'],
filename, filename,
ids = [], ids = [],
@ -72,7 +70,7 @@ pandora.ui.uploadDocumentDialog = function(options, callback) {
}); });
if (!Ox.every(extensions, function(extension) { if (!Ox.every(extensions, function(extension) {
return Ox.contains(supportedExtensions, extension) return Ox.contains(pandora.documentExtensions, extension)
})) { })) {
return errorDialog( return errorDialog(
Ox._('Supported file types are GIF, JPG, PNG and PDF.') Ox._('Supported file types are GIF, JPG, PNG and PDF.')

View file

@ -420,13 +420,28 @@ pandora.createLinks = function($element) {
}); });
}; };
pandora.imageExtensions = [
'avif',
'gif',
'heic',
'heif',
'jpeg',
'jpg',
'png',
'tiff',
'webp'
];
pandora.documentExtensions = [
'pdf', /* 'epub', 'txt', */
].concat(pandora.imageExtensions);
pandora.uploadDroppedFiles = function(files) { pandora.uploadDroppedFiles = function(files) {
var documentExtensions = ['pdf', /* 'epub', 'txt', */ 'png', 'gif', 'jpg', 'jpeg'];
files = Ox.map(files, function(file) { return file }); files = Ox.map(files, function(file) { return file });
if (files.every(function(file) { if (files.every(function(file) {
var extension = file.name.split('.').pop().toLowerCase() var extension = file.name.split('.').pop().toLowerCase()
return Ox.contains(documentExtensions, extension) return Ox.contains(pandora.documentExtensions, extension)
})) { })) {
pandora.ui.uploadDocumentDialog({ pandora.ui.uploadDocumentDialog({
files: files files: files
@ -2132,7 +2147,7 @@ pandora.getSpan = function(state, val, callback) {
} else { } else {
state.span = val; state.span = val;
} }
} else if (Ox.contains(['gif', 'jpg', 'png'], extension)) { } else if (Ox.contains(pandora.imageExtensions, extension)) {
values = val.split(','); values = val.split(',');
if (values.length == 4) { if (values.length == 4) {
state.span = values.map(function(number, index) { state.span = values.map(function(number, index) {
@ -2650,6 +2665,11 @@ pandora.logEvent = function(data, event, element) {
} }
}; };
pandora.oidcLogin = function() {
Ox.LoadingScreen().css({zIndex: 100}).addClass('OxScreen').appendTo(document.body).show().start()
document.location.href = '/oidc/authenticate/';
};
pandora.openLicenseDialog = function() { pandora.openLicenseDialog = function() {
if (!Ox.Focus.focusedElementIsInput() && !pandora.hasDialogOrScreen()) { if (!Ox.Focus.focusedElementIsInput() && !pandora.hasDialogOrScreen()) {
pandora.ui.licenseDialog().open().bindEvent({ pandora.ui.licenseDialog().open().bindEvent({

View file

@ -201,3 +201,75 @@ ol li {
width: 64px; width: 64px;
height: 64px; height: 64px;
} }
.seekbar {
padding: 12px 22px;
position: relative;
width: 100%;
}
.fullscreen .seekbar {
padding: 28px 22px;
}
.seekbar-progress {
height: 10px;
border: solid 1px #B1B1B1;
}
.seekbar-progress [role="progressbar"] {
height: 100%;
position: relative;
background-color: #B1B1B180;
}
.seekbar-progress [role="progressbar"]:after {
content: " ";
display: block;
width: 20px;
height: 20px;
position: absolute;
top: -6px;
right: -10px;
background-color: #B1B1B1;
border-radius: 20px;
}
.seekbar input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 100%;
margin: 0;
position: absolute;
top: 0;
left: 0;
z-index: 2;
background: transparent;
outline: 0;
border: 0;
}
.seekbar input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
display: block;
width: 48px;
height: 48px;
background-color: transparent;
}
.seekbar input[type="range"]::-moz-range-thumb {
display: block;
width: 48px;
height: 48px;
background: transparent;
border: 0;
}
.seekbar input[type="range"]::-moz-range-track {
background: transparent;
border: 0;
}
.seekbar input[type="range"]::-moz-focus-outer {
border: 0;
}

View file

@ -8,6 +8,7 @@ VideoElement <f> VideoElement Object
loop <b|false> loop playback loop <b|false> loop playback
playbackRate <n|1> playback rate playbackRate <n|1> playback rate
position <n|0> start position position <n|0> start position
in <n|0> start offset
self <o> Shared private variable self <o> Shared private variable
([options[, self]]) -> <o:Element> VideoElement Object ([options[, self]]) -> <o:Element> VideoElement Object
loadedmetadata <!> loadedmetadata loadedmetadata <!> loadedmetadata
@ -38,6 +39,7 @@ window.VideoElement = function(options) {
muted: false, muted: false,
playbackRate: 1, playbackRate: 1,
position: 0, position: 0,
"in": 0,
volume: 1 volume: 1
} }
Object.assign(self.options, options); Object.assign(self.options, options);
@ -166,9 +168,10 @@ window.VideoElement = function(options) {
function getCurrentTime() { function getCurrentTime() {
var item = self.items[self.currentItem]; var item = self.items[self.currentItem];
return self.seeking || self.loading var currentTime = self.seeking || self.loading
? self.currentTime ? self.currentTime
: item ? item.position + self.video.currentTime - item['in'] : 0; : item ? item.position + self.video.currentTime - item['in'] - self.options["in"] : 0;
return currentTime
} }
function getset(key, value) { function getset(key, value) {
@ -528,6 +531,10 @@ window.VideoElement = function(options) {
function setCurrentTime(time) { function setCurrentTime(time) {
debug('Video', 'sCT', time); debug('Video', 'sCT', time);
if (self.options["in"]) {
debug('Video', 'sCT shift time by', self.options["in"], 'to', time);
time += self.options["in"]
}
var currentTime, currentItem; var currentTime, currentItem;
self.items.forEach(function(item, i) { self.items.forEach(function(item, i) {
if (time >= item.position if (time >= item.position

View file

@ -10,6 +10,7 @@ window.VideoPlayer = function(options) {
loop: false, loop: false,
muted: false, muted: false,
playbackRate: 1, playbackRate: 1,
"in": 0,
position: 0, position: 0,
volume: 1 volume: 1
} }
@ -153,9 +154,11 @@ window.VideoPlayer = function(options) {
${icon.mute} ${icon.mute}
</div> </div>
<div class="position"> <div class="position">
<div class="bar"> <div class="seekbar">
<div class="progress"></div> <input type="range" value="0" min='0' max='100' step='.25' />
<div class="seekbar-progress">
<div role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="38" style="width: 0%;"></div>
</div>
</div> </div>
</div> </div>
<div class="time"> <div class="time">
@ -223,15 +226,31 @@ window.VideoPlayer = function(options) {
} }
} }
var showControls var showControls
function hideControlsLater() {
if (showControls) {
clearTimeout(showControls)
}
showControls = setTimeout(() => {
if (touching) {
hideControlsLater()
} else {
self.controls.style.opacity = that.paused ? '1' : '0'
showControls = null
}
}, 3000)
}
var toggleControls = event => { var toggleControls = event => {
if (event.target.tagName == "INPUT") {
if (showControls) {
clearTimeout(showControls)
}
return
}
if (self.controls.style.opacity == '0') { if (self.controls.style.opacity == '0') {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
self.controls.style.opacity = '1' self.controls.style.opacity = '1'
showControls = setTimeout(() => { hideControlsLater()
self.controls.style.opacity = that.paused ? '1' : '0'
showControls = null
}, 3000)
} else { } else {
self.controls.style.opacity = '0' self.controls.style.opacity = '0'
} }
@ -241,10 +260,7 @@ window.VideoPlayer = function(options) {
clearTimeout(showControls) clearTimeout(showControls)
} }
self.controls.style.opacity = '1' self.controls.style.opacity = '1'
showControls = setTimeout(() => { hideControlsLater()
self.controls.style.opacity = that.paused ? '1' : '0'
showControls = null
}, 3000)
}) })
self.controls.addEventListener("mouseleave", event => { self.controls.addEventListener("mouseleave", event => {
if (showControls) { if (showControls) {
@ -253,7 +269,13 @@ window.VideoPlayer = function(options) {
self.controls.style.opacity = that.paused ? '1' : '0' self.controls.style.opacity = that.paused ? '1' : '0'
showControls = null showControls = null
}) })
self.controls.addEventListener("touchstart", event => {
touching = true
})
self.controls.addEventListener("touchstart", toggleControls) self.controls.addEventListener("touchstart", toggleControls)
self.controls.addEventListener("touchend", event => {
touching = false
})
self.controls.querySelector('.toggle').addEventListener("click", toggleVideo) self.controls.querySelector('.toggle').addEventListener("click", toggleVideo)
self.controls.querySelector('.volume').addEventListener("click", toggleSound) self.controls.querySelector('.volume').addEventListener("click", toggleSound)
self.controls.querySelector('.fullscreen-btn').addEventListener("click", toggleFullscreen) self.controls.querySelector('.fullscreen-btn').addEventListener("click", toggleFullscreen)
@ -310,6 +332,7 @@ window.VideoPlayer = function(options) {
that.append(unblock) that.append(unblock)
}) })
var loading = true var loading = true
var touching = false
that.brightness(0) that.brightness(0)
that.addEventListener("loadedmetadata", event => { that.addEventListener("loadedmetadata", event => {
// //
@ -331,42 +354,40 @@ window.VideoPlayer = function(options) {
} }
}) })
var time = that.querySelector('.controls .time div'), var time = that.querySelector('.controls .time div');
progress = that.querySelector('.controls .position .progress') const progressbar = that.querySelector('.seekbar div[role="progressbar"]');
that.querySelector('.controls .position').addEventListener("click", event => { function setProgressPosition(value) {
var bar = event.target progressbar.style.width = value + '%';
while (bar && !bar.classList.contains('bar')) { progressbar.setAttribute('aria-valuenow', value);
bar = bar.parentElement
} }
if (bar && bar.classList.contains('bar')) { that.querySelector('.controls .position input').addEventListener('input', event => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
var rect = bar.getBoundingClientRect() setProgressPosition(event.target.value)
var x = event.clientX - rect.x var position = event.target.value/100 * self.options.duration
var percent = x / rect.width displayTime(position)
var position = percent * self.options.duration
if (self.options.position) {
position += self.options.position
}
progress.style.width = (100 * percent) + '%'
that.currentTime(position) that.currentTime(position)
} hideControlsLater()
}) })
that.addEventListener("timeupdate", event => { function displayTime(currentTime) {
var currentTime = that.currentTime(), duration = formatDuration(self.options.duration)
duration = self.options.duration
if (self.options.position) {
currentTime -= self.options.position
}
progress.style.width = (100 * currentTime / duration) + '%'
duration = formatDuration(duration)
currentTime = formatDuration(currentTime) currentTime = formatDuration(currentTime)
while (duration && duration.startsWith('00:')) { while (duration && duration.startsWith('00:')) {
duration = duration.slice(3) duration = duration.slice(3)
} }
currentTime = currentTime.slice(currentTime.length - duration.length) currentTime = currentTime.slice(currentTime.length - duration.length)
time.innerText = `${currentTime} / ${duration}` time.innerText = `${currentTime} / ${duration}`
}
that.addEventListener("timeupdate", event => {
var currentTime = that.currentTime(),
duration = self.options.duration
if (self.options.position) {
currentTime -= self.options.position
}
setProgressPosition(100 * currentTime / duration)
displayTime(currentTime)
}) })
that.addEventListener("play", event => { that.addEventListener("play", event => {

View file

@ -1,35 +1,4 @@
const getSortValue = function(value) {
var sortValue = value;
function trim(value) {
return value.replace(/^\W+(?=\w)/, '');
}
if (
isEmpty(value)
|| isNull(value)
|| isUndefined(value)
) {
sortValue = null;
} else if (isString(value)) {
// make lowercase and remove leading non-word characters
sortValue = trim(value.toLowerCase());
// move leading articles to the end
// and remove leading non-word characters
['a', 'an', 'the'].forEach(function(article) {
if (new RegExp('^' + article + ' ').test(sortValue)) {
sortValue = trim(sortValue.slice(article.length + 1))
+ ', ' + sortValue.slice(0, article.length);
return false; // break
}
});
// remove thousand separators and pad numbers
sortValue = sortValue.replace(/(\d),(?=(\d{3}))/g, '$1')
.replace(/\d+/g, function(match) {
return match.padStart(64, '0')
});
}
return sortValue;
};
const sortByKey = function(array, by) { const sortByKey = function(array, by) {
return array.sort(function(a, b) { return array.sort(function(a, b) {
@ -122,7 +91,7 @@ async function loadEdit(id, args) {
} }
} }
data.edit = response['data'] data.edit = response['data']
if (data.edit.status !== 'public') { if (['public', 'featured'].indexOf(data.edit.status) == -1) {
return { return {
site: data.site, site: data.site,
error: { error: {

View file

@ -129,6 +129,10 @@ async function loadData(id, args) {
<span class="icon">${icon.down}</span> <span class="icon">${icon.down}</span>
${layerData.title} ${layerData.title}
</h3>`) </h3>`)
data.layers[layer] = sortBy(data.layers[layer], [
{key: "in", operator: "+"},
{key: "created", operator: "+"}
])
data.layers[layer].forEach(annotation => { data.layers[layer].forEach(annotation => {
if (pandora.url) { if (pandora.url) {
annotation.value = annotation.value.replace( annotation.value = annotation.value.replace(

View file

@ -67,6 +67,7 @@ function parseURL() {
id = id.replace('/editor/', '/').replace('/player/', '/') id = id.replace('/editor/', '/').replace('/player/', '/')
type = "item" type = "item"
} }
//console.log(type, id, args)
return [type, id, args] return [type, id, args]
} }

View file

@ -75,7 +75,8 @@ function renderItem(data) {
var video = window.video = VideoPlayer({ var video = window.video = VideoPlayer({
items: data.videos, items: data.videos,
poster: data.poster, poster: data.poster,
position: data["in"] || 0, "in": data["in"] || 0,
position: 0,
duration: data.duration, duration: data.duration,
aspectratio: data.aspectratio aspectratio: data.aspectratio
}) })
@ -85,16 +86,10 @@ function renderItem(data) {
video.addEventListener("loadedmetadata", event => { video.addEventListener("loadedmetadata", event => {
// //
}) })
video.addEventListener("timeupdate", event => {
var currentTime = video.currentTime() function updateAnnotations(currentTime) {
if (currentTime >= data['out']) {
if (!video.paused) {
video.pause()
}
video.currentTime(data['in'])
}
div.querySelectorAll('.annotation').forEach(annot => { div.querySelectorAll('.annotation').forEach(annot => {
var now = currentTime var now = currentTime + (data["in"] || 0)
var start = parseFloat(annot.dataset.in) var start = parseFloat(annot.dataset.in)
var end = parseFloat(annot.dataset.out) var end = parseFloat(annot.dataset.out)
if (now >= start && now <= end) { if (now >= start && now <= end) {
@ -107,8 +102,18 @@ function renderItem(data) {
} }
} }
}) })
}
video.addEventListener("timeupdate", event => {
var currentTime = video.currentTime()
if ((currentTime + (data["in"] || 0)) >= data['out']) {
if (!video.paused) {
video.pause()
}
video.currentTime(0)
}
updateAnnotations(currentTime)
}) })
updateAnnotations(data["position"] || 0)
if (item.next || item.previous) { if (item.next || item.previous) {
var nav = document.createElement('nav') var nav = document.createElement('nav')
nav.classList.add('items') nav.classList.add('items')

View file

@ -125,7 +125,10 @@ const clickLink = function(event) {
} }
document.location.hash = '#' + link.slice(1) document.location.hash = '#' + link.slice(1)
} else { } else {
if (!link.startsWith('/m')) { if (link.includes('/download/')) {
document.location.href = link
return
} else if (!link.startsWith('/m')) {
link = '/m' + link link = '/m' + link
} }
history.pushState({}, '', link); history.pushState({}, '', link);
@ -161,3 +164,59 @@ const getVideoURL = function(id, resolution, part, track, streamId) {
return prefix + '/' + getVideoURLName(id, resolution, part, track, streamId); return prefix + '/' + getVideoURLName(id, resolution, part, track, streamId);
}; };
const getSortValue = function(value) {
var sortValue = value;
function trim(value) {
return value.replace(/^\W+(?=\w)/, '');
}
if (
isEmpty(value)
|| isNull(value)
|| isUndefined(value)
) {
sortValue = null;
} else if (isString(value)) {
// make lowercase and remove leading non-word characters
sortValue = trim(value.toLowerCase());
// move leading articles to the end
// and remove leading non-word characters
['a', 'an', 'the'].forEach(function(article) {
if (new RegExp('^' + article + ' ').test(sortValue)) {
sortValue = trim(sortValue.slice(article.length + 1))
+ ', ' + sortValue.slice(0, article.length);
return false; // break
}
});
// remove thousand separators and pad numbers
sortValue = sortValue.replace(/(\d),(?=(\d{3}))/g, '$1')
.replace(/\d+/g, function(match) {
return match.padStart(64, '0')
});
}
return sortValue;
};
function sortBy(array, by, map) {
return array.sort(function(a, b) {
var aValue, bValue, index = 0, key, ret = 0;
while (ret == 0 && index < by.length) {
key = by[index].key;
aValue = getSortValue(
map && map[key] ? map[key](a[key], a) : a[key]
);
bValue = getSortValue(
map && map[key] ? map[key](b[key], b) : b[key]
);
if ((aValue === null) != (bValue === null)) {
ret = aValue === null ? 1 : -1;
} else if (aValue < bValue) {
ret = by[index].operator == '+' ? -1 : 1;
} else if (aValue > bValue) {
ret = by[index].operator == '+' ? 1 : -1;
} else {
index++;
}
}
return ret;
});
}

View file

@ -302,6 +302,10 @@ if __name__ == "__main__":
if old <= 6581: if old <= 6581:
run('./bin/pip', 'install', '-U', 'pip') run('./bin/pip', 'install', '-U', 'pip')
run('./bin/pip', 'install', '-r', 'requirements.txt') run('./bin/pip', 'install', '-r', 'requirements.txt')
if old <= 6659:
run('./bin/pip', 'install', '-r', 'requirements.txt')
if old <= 6688:
run('./bin/pip', 'install', '-r', 'requirements.txt')
else: else:
if len(sys.argv) == 1: if len(sys.argv) == 1:
branch = get_branch() branch = get_branch()
@ -322,6 +326,7 @@ if __name__ == "__main__":
current_branch = get_branch(path) current_branch = get_branch(path)
revno = get_version(path) revno = get_version(path)
if repo == 'pandora': if repo == 'pandora':
print("Pandora Version pre update: ", revno)
pandora_old_revno = revno pandora_old_revno = revno
current += revno current += revno
if current_branch != branch: if current_branch != branch:
@ -345,6 +350,7 @@ if __name__ == "__main__":
new += '+' new += '+'
os.chdir(join(base, 'pandora')) os.chdir(join(base, 'pandora'))
if pandora_old_revno != pandora_new_revno: if pandora_old_revno != pandora_new_revno:
print("Pandora Version post update: ", pandora_new_revno)
os.chdir(base) os.chdir(base)
run('./update.py', 'postupdate', pandora_old_revno, pandora_new_revno) run('./update.py', 'postupdate', pandora_old_revno, pandora_new_revno)
os.chdir(join(base, 'pandora')) os.chdir(join(base, 'pandora'))
@ -361,7 +367,7 @@ if __name__ == "__main__":
and row not in ['BEGIN;', 'COMMIT;'] and row not in ['BEGIN;', 'COMMIT;']
] ]
if diff: if diff:
print('Database has changed, please make a backup and run %s db' % sys.argv[0]) print('Database has changed, please make a backup and run: sudo pandoractl update db')
elif branch != 'master': elif branch != 'master':
print('pan.do/ra is at the latest release,\nyou can run "%s switch master" to switch to the development version' % sys.argv[0]) print('pan.do/ra is at the latest release,\nyou can run "%s switch master" to switch to the development version' % sys.argv[0])
elif current != new: elif current != new:

View file

@ -25,53 +25,20 @@ LXC=`grep -q lxc /proc/1/environ && echo 'yes' || echo 'no'`
if [ -e /etc/os-release ]; then if [ -e /etc/os-release ]; then
. /etc/os-release . /etc/os-release
fi fi
if [ -z "$UBUNTU_CODENAME" ]; then
UBUNTU_CODENAME=bionic
fi
if [ "$VERSION_CODENAME" = "bullseye" ]; then
UBUNTU_CODENAME=focal
fi
if [ "$VERSION_CODENAME" = "bookworm" ]; then
UBUNTU_CODENAME=lunar
fi
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
echo "deb http://ppa.launchpad.net/j/pandora/ubuntu ${UBUNTU_CODENAME} main" > /etc/apt/sources.list.d/j-pandora.list
apt-get install -y gnupg apt-get install -y gnupg curl
if [ -e /etc/apt/trusted.gpg.d ]; then distribution=bookworm
gpg --dearmor > /etc/apt/trusted.gpg.d/j-pandora.gpg <<EOF for version in bookworm trixie bionic focal jammy noble; do
-----BEGIN PGP PUBLIC KEY BLOCK----- if [ "$VERSION_CODENAME" = $version ]; then
Version: GnuPG v1 distribution=$VERSION_CODENAME
fi
done
mI0ESXYhEgEEALl9jDTdmgpApPbjN+7b85dC92HisPUp56ifEkKJOBj0X5HhRqxs curl -Ls https://code.0x2620.org/api/packages/0x2620/debian/repository.key -o /etc/apt/keyrings/pandora.asc
Wjx/zlP4/XJGrHnxJyrdPxjSwAXz7bNdeggkN4JWdusTkr5GOXvggQnng0X7f/rX echo "deb [signed-by=/etc/apt/keyrings/pandora.asc] https://code.0x2620.org/api/packages/0x2620/debian $distribution main" > /etc/apt/sources.list.d/pandora.list
oJwoEGtYOCODLPs6PC0qjh5yPzJVeiRsKUOZ7YVNnwNwdfS4D8RZvtCrABEBAAG0
FExhdW5jaHBhZCBQUEEgZm9yIGpeiLYEEwECACAFAkl2IRICGwMGCwkIBwMCBBUC
CAMEFgIDAQIeAQIXgAAKCRAohRM8AZde82FfA/9OB/64/YLaCpizHZ8f6DK3rGgF
e6mX3rFK8yOKGGL06316VhDzfzMiZSauUZ0t+lKHR/KZYeSaFwEoUoblTG/s4IIo
9aBMHWhVXJW6eifKUmTGqEn2/0UxoWQq2C3F6njMkCaP+ALOD5uzaSYGdjqAUAwS
pAAGSEQ4uz6bYSeM4Q==
=SM2a
-----END PGP PUBLIC KEY BLOCK-----
EOF
else
apt-key add - <<EOF
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v1
mI0ESXYhEgEEALl9jDTdmgpApPbjN+7b85dC92HisPUp56ifEkKJOBj0X5HhRqxs
Wjx/zlP4/XJGrHnxJyrdPxjSwAXz7bNdeggkN4JWdusTkr5GOXvggQnng0X7f/rX
oJwoEGtYOCODLPs6PC0qjh5yPzJVeiRsKUOZ7YVNnwNwdfS4D8RZvtCrABEBAAG0
FExhdW5jaHBhZCBQUEEgZm9yIGpeiLYEEwECACAFAkl2IRICGwMGCwkIBwMCBBUC
CAMEFgIDAQIeAQIXgAAKCRAohRM8AZde82FfA/9OB/64/YLaCpizHZ8f6DK3rGgF
e6mX3rFK8yOKGGL06316VhDzfzMiZSauUZ0t+lKHR/KZYeSaFwEoUoblTG/s4IIo
9aBMHWhVXJW6eifKUmTGqEn2/0UxoWQq2C3F6njMkCaP+ALOD5uzaSYGdjqAUAwS
pAAGSEQ4uz6bYSeM4Q==
=SM2a
-----END PGP PUBLIC KEY BLOCK-----
EOF
fi
echo 'Acquire::Languages "none";' > /etc/apt/apt.conf.d/99languages echo 'Acquire::Languages "none";' > /etc/apt/apt.conf.d/99languages
apt-get update -qq apt-get update -qq