Compare commits
No commits in common. "7392bbcdccfd3b17441ac8d1f5864ca39c64997d" and "2bd83f631560677091464d30a2a6438569376740" have entirely different histories.
7392bbcdcc
...
2bd83f6315
26 changed files with 88 additions and 1336 deletions
|
|
@ -38,4 +38,3 @@ jobs:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: fg.creamfresh.xyz/mrwnslz/diora-web:${{ steps.tag.outputs.tag }}
|
tags: fg.creamfresh.xyz/mrwnslz/diora-web:${{ steps.tag.outputs.tag }}
|
||||||
build-args: BUILD_TIME=${{ github.event.head_commit.timestamp }}
|
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
ARG BUILD_TIME
|
|
||||||
ENV BUILD_TIME=${BUILD_TIME}
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN python manage.py collectstatic --noinput && mkdir -p /app/data
|
RUN python manage.py collectstatic --noinput && mkdir -p /app/data
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn diora.wsgi:application --bind 0.0.0.0:8000 --workers 4 --worker-class gevent --timeout 120"]
|
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn diora.wsgi:application --bind 0.0.0.0:8000 --workers 2"]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends cron && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends dcron && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
@ -10,12 +10,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Write cron job: refresh podcast feeds every hour
|
# Write cron job: refresh podcast feeds every hour
|
||||||
# The job sources /etc/environment so Docker env vars (DB path, secret key etc.) are available.
|
RUN echo "0 * * * * root cd /app && python manage.py refresh_feeds >> /var/log/cron.log 2>&1" \
|
||||||
RUN echo "0 * * * * root . /etc/environment; cd /app && python manage.py refresh_feeds >> /var/log/cron.log 2>&1" \
|
|
||||||
> /etc/cron.d/podcast-refresh && \
|
> /etc/cron.d/podcast-refresh && \
|
||||||
chmod 0644 /etc/cron.d/podcast-refresh && \
|
chmod 0644 /etc/cron.d/podcast-refresh && \
|
||||||
touch /var/log/cron.log
|
touch /var/log/cron.log
|
||||||
|
|
||||||
# Dump Docker env vars into /etc/environment at container start so cron jobs can read them,
|
CMD ["dcron", "-f"]
|
||||||
# then launch vixie cron in foreground.
|
|
||||||
CMD ["bash", "-c", "printenv > /etc/environment && cron -f"]
|
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,11 @@ def book_list(request):
|
||||||
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
||||||
# Include saved scroll_fraction for each book
|
# Include saved scroll_fraction for each book
|
||||||
progress_map = {
|
progress_map = {
|
||||||
p.book_id: (p.scroll_fraction, p.updated_at)
|
p.book_id: p.scroll_fraction
|
||||||
for p in EBookProgress.objects.filter(user=request.user)
|
for p in EBookProgress.objects.filter(user=request.user)
|
||||||
}
|
}
|
||||||
for b in books:
|
for b in books:
|
||||||
prog = progress_map.get(b['id'])
|
b['scroll_fraction'] = progress_map.get(b['id'], 0.0)
|
||||||
b['scroll_fraction'] = prog[0] if prog else 0.0
|
|
||||||
b['last_read'] = prog[1].isoformat() if prog else None
|
|
||||||
return JsonResponse(books, safe=False)
|
return JsonResponse(books, safe=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
def build_info(request):
|
|
||||||
return {'BUILD_TIME': getattr(settings, 'BUILD_TIME', '')}
|
|
||||||
|
|
@ -29,7 +29,6 @@ INSTALLED_APPS = [
|
||||||
'accounts',
|
'accounts',
|
||||||
'podcasts',
|
'podcasts',
|
||||||
'books',
|
'books',
|
||||||
'gpodder',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
@ -61,7 +60,6 @@ TEMPLATES = [
|
||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'diora.context_processors.build_info',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -115,5 +113,3 @@ LASTFM_API_SECRET = os.environ.get('LASTFM_API_SECRET', '')
|
||||||
# Amazon affiliate
|
# Amazon affiliate
|
||||||
AMAZON_AFFILIATE_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
|
AMAZON_AFFILIATE_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
|
||||||
AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == 'True'
|
AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == 'True'
|
||||||
|
|
||||||
BUILD_TIME = os.environ.get('BUILD_TIME', '')
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,5 @@ urlpatterns = [
|
||||||
path('accounts/', include('accounts.urls')),
|
path('accounts/', include('accounts.urls')),
|
||||||
path('podcasts/', include('podcasts.urls')),
|
path('podcasts/', include('podcasts.urls')),
|
||||||
path('books/', include('books.urls')),
|
path('books/', include('books.urls')),
|
||||||
path('api/2/', include('gpodder.urls')),
|
|
||||||
path('', include('radio.urls')),
|
path('', include('radio.urls')),
|
||||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class GpodderConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'gpodder'
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Generated by Django 4.2.29 on 2026-03-20 05:36
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='GpodderDevice',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('device_id', models.CharField(max_length=64)),
|
|
||||||
('caption', models.CharField(blank=True, max_length=100)),
|
|
||||||
('type', models.CharField(default='other', max_length=20)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gpodder_devices', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'unique_together': {('user', 'device_id')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='GpodderSubscriptionChange',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('url', models.URLField(max_length=1000)),
|
|
||||||
('action', models.CharField(max_length=6)),
|
|
||||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gpodder_sub_changes', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['timestamp'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='GpodderEpisodeAction',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('podcast', models.URLField(max_length=1000)),
|
|
||||||
('episode', models.URLField(max_length=1000)),
|
|
||||||
('action', models.CharField(max_length=10)),
|
|
||||||
('timestamp', models.DateTimeField()),
|
|
||||||
('started', models.IntegerField(blank=True, null=True)),
|
|
||||||
('position', models.IntegerField(blank=True, null=True)),
|
|
||||||
('total', models.IntegerField(blank=True, null=True)),
|
|
||||||
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='gpodder.gpodderdevice')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gpodder_episode_actions', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-timestamp'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
from django.db import models
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class GpodderDevice(models.Model):
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gpodder_devices')
|
|
||||||
device_id = models.CharField(max_length=64)
|
|
||||||
caption = models.CharField(max_length=100, blank=True)
|
|
||||||
type = models.CharField(max_length=20, default='other')
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ('user', 'device_id')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.user.username}/{self.device_id}"
|
|
||||||
|
|
||||||
|
|
||||||
class GpodderSubscriptionChange(models.Model):
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gpodder_sub_changes')
|
|
||||||
url = models.URLField(max_length=1000)
|
|
||||||
action = models.CharField(max_length=6) # 'add' or 'remove'
|
|
||||||
timestamp = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['timestamp']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.action} {self.url} ({self.user.username})"
|
|
||||||
|
|
||||||
|
|
||||||
class GpodderEpisodeAction(models.Model):
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gpodder_episode_actions')
|
|
||||||
device = models.ForeignKey(GpodderDevice, on_delete=models.SET_NULL, null=True, blank=True)
|
|
||||||
podcast = models.URLField(max_length=1000)
|
|
||||||
episode = models.URLField(max_length=1000)
|
|
||||||
action = models.CharField(max_length=10) # play, download, delete, new
|
|
||||||
timestamp = models.DateTimeField()
|
|
||||||
started = models.IntegerField(null=True, blank=True)
|
|
||||||
position = models.IntegerField(null=True, blank=True)
|
|
||||||
total = models.IntegerField(null=True, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-timestamp']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.action} {self.episode} ({self.user.username})"
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
from django.urls import path
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('auth/<str:username>/login.json', views.auth_login),
|
|
||||||
path('auth/<str:username>/logout.json', views.auth_logout),
|
|
||||||
path('devices/<str:username>.json', views.devices_list),
|
|
||||||
path('devices/<str:username>/<str:deviceid>.json', views.device_update),
|
|
||||||
path('subscriptions/<str:username>.json', views.subscriptions_all),
|
|
||||||
path('subscriptions/<str:username>/<str:deviceid>.json', views.subscriptions_by_device),
|
|
||||||
path('episodes/<str:username>.json', views.episode_actions),
|
|
||||||
]
|
|
||||||
272
gpodder/views.py
272
gpodder/views.py
|
|
@ -1,272 +0,0 @@
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from django.contrib.auth import authenticate, login, logout
|
|
||||||
from django.http import JsonResponse
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
|
|
||||||
from podcasts.models import PodcastFeed, PodcastEpisode, EpisodeProgress
|
|
||||||
from podcasts.views import _refresh_feed
|
|
||||||
from .models import GpodderDevice, GpodderSubscriptionChange, GpodderEpisodeAction
|
|
||||||
|
|
||||||
|
|
||||||
def _basic_auth(request):
|
|
||||||
auth = request.META.get('HTTP_AUTHORIZATION', '')
|
|
||||||
if not auth.startswith('Basic '):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
credentials = base64.b64decode(auth[6:]).decode('utf-8')
|
|
||||||
username, _, password = credentials.partition(':')
|
|
||||||
user = authenticate(request, username=username, password=password)
|
|
||||||
if user:
|
|
||||||
login(request, user)
|
|
||||||
return user
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_user(request, username):
|
|
||||||
if request.user.is_authenticated and request.user.username == username:
|
|
||||||
return request.user
|
|
||||||
user = _basic_auth(request)
|
|
||||||
if user and user.username == username:
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def gpodder_auth(func):
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(request, username, *args, **kwargs):
|
|
||||||
user = _resolve_user(request, username)
|
|
||||||
if not user:
|
|
||||||
return JsonResponse({'error': 'unauthorized'}, status=401)
|
|
||||||
return func(request, username, *args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def _now_ts():
|
|
||||||
return int(datetime.now(timezone.utc).timestamp())
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_device(user, device_id):
|
|
||||||
device, _ = GpodderDevice.objects.get_or_create(
|
|
||||||
user=user, device_id=device_id,
|
|
||||||
defaults={'caption': device_id, 'type': 'other'}
|
|
||||||
)
|
|
||||||
return device
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Auth
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(['GET', 'POST'])
|
|
||||||
def auth_login(request, username):
|
|
||||||
user = _resolve_user(request, username)
|
|
||||||
if not user:
|
|
||||||
return JsonResponse({'error': 'unauthorized'}, status=401)
|
|
||||||
return JsonResponse({'username': user.username})
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(['GET', 'POST'])
|
|
||||||
def auth_logout(request, username):
|
|
||||||
logout(request)
|
|
||||||
return JsonResponse({})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Devices
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@gpodder_auth
|
|
||||||
def devices_list(request, username):
|
|
||||||
devices = GpodderDevice.objects.filter(user=request.user)
|
|
||||||
return JsonResponse([
|
|
||||||
{'id': d.device_id, 'caption': d.caption, 'type': d.type, 'subscriptions': 0}
|
|
||||||
for d in devices
|
|
||||||
], safe=False)
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@gpodder_auth
|
|
||||||
def device_update(request, username, deviceid):
|
|
||||||
device = _get_or_create_device(request.user, deviceid)
|
|
||||||
if request.method == 'POST':
|
|
||||||
try:
|
|
||||||
body = json.loads(request.body or '{}')
|
|
||||||
if 'caption' in body:
|
|
||||||
device.caption = body['caption'][:100]
|
|
||||||
if 'type' in body:
|
|
||||||
device.type = body['type'][:20]
|
|
||||||
device.save()
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
return JsonResponse({'id': device.device_id, 'caption': device.caption, 'type': device.type})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Subscriptions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _subscription_changes_since(user, since_ts):
|
|
||||||
qs = GpodderSubscriptionChange.objects.filter(user=user)
|
|
||||||
if since_ts:
|
|
||||||
try:
|
|
||||||
since_dt = datetime.fromtimestamp(int(since_ts), tz=timezone.utc)
|
|
||||||
qs = qs.filter(timestamp__gt=since_dt)
|
|
||||||
except (ValueError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
added = list(qs.filter(action='add').values_list('url', flat=True).distinct())
|
|
||||||
removed = list(qs.filter(action='remove').values_list('url', flat=True).distinct())
|
|
||||||
# If a URL appears in both, the latest action wins — keep only consistent entries
|
|
||||||
added = [u for u in added if u not in removed]
|
|
||||||
removed = [u for u in removed if u not in added]
|
|
||||||
return added, removed
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@gpodder_auth
|
|
||||||
def subscriptions_by_device(request, username, deviceid):
|
|
||||||
_get_or_create_device(request.user, deviceid)
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
|
||||||
since = request.GET.get('since')
|
|
||||||
if since:
|
|
||||||
added, removed = _subscription_changes_since(request.user, since)
|
|
||||||
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
|
|
||||||
else:
|
|
||||||
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
|
|
||||||
return JsonResponse(urls, safe=False)
|
|
||||||
|
|
||||||
elif request.method == 'POST':
|
|
||||||
try:
|
|
||||||
body = json.loads(request.body or '{}')
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
||||||
|
|
||||||
for url in body.get('add', []):
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
PodcastFeed.objects.get_or_create(
|
|
||||||
user=request.user, rss_url=url,
|
|
||||||
defaults={'title': url}
|
|
||||||
)
|
|
||||||
GpodderSubscriptionChange.objects.create(user=request.user, url=url, action='add')
|
|
||||||
|
|
||||||
for url in body.get('remove', []):
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
PodcastFeed.objects.filter(user=request.user, rss_url=url).delete()
|
|
||||||
GpodderSubscriptionChange.objects.create(user=request.user, url=url, action='remove')
|
|
||||||
|
|
||||||
return JsonResponse({'timestamp': _now_ts(), 'update_urls': []})
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@gpodder_auth
|
|
||||||
def subscriptions_all(request, username):
|
|
||||||
since = request.GET.get('since')
|
|
||||||
if since:
|
|
||||||
added, removed = _subscription_changes_since(request.user, since)
|
|
||||||
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
|
|
||||||
else:
|
|
||||||
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
|
|
||||||
return JsonResponse(urls, safe=False)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Episode actions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@gpodder_auth
|
|
||||||
def episode_actions(request, username):
|
|
||||||
if request.method == 'POST':
|
|
||||||
try:
|
|
||||||
actions = json.loads(request.body or '[]')
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
||||||
|
|
||||||
if not isinstance(actions, list):
|
|
||||||
actions = [actions]
|
|
||||||
|
|
||||||
for a in actions:
|
|
||||||
if not isinstance(a, dict):
|
|
||||||
continue
|
|
||||||
podcast_url = a.get('podcast', '')
|
|
||||||
episode_url = a.get('episode', '')
|
|
||||||
action = a.get('action', '')
|
|
||||||
if not (podcast_url and episode_url and action):
|
|
||||||
continue
|
|
||||||
|
|
||||||
ts_str = a.get('timestamp', '')
|
|
||||||
try:
|
|
||||||
ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00')) if ts_str else datetime.now(timezone.utc)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
ts = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
device_id = a.get('device', '')
|
|
||||||
device = _get_or_create_device(request.user, device_id) if device_id else None
|
|
||||||
|
|
||||||
GpodderEpisodeAction.objects.create(
|
|
||||||
user=request.user,
|
|
||||||
device=device,
|
|
||||||
podcast=podcast_url,
|
|
||||||
episode=episode_url,
|
|
||||||
action=action,
|
|
||||||
timestamp=ts,
|
|
||||||
started=a.get('started'),
|
|
||||||
position=a.get('position'),
|
|
||||||
total=a.get('total'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sync playback position to EpisodeProgress
|
|
||||||
if action == 'play' and a.get('position') is not None:
|
|
||||||
try:
|
|
||||||
ep = PodcastEpisode.objects.filter(audio_url=episode_url).first()
|
|
||||||
if ep:
|
|
||||||
pos = int(a['position'])
|
|
||||||
dur = ep.duration_seconds or 0
|
|
||||||
played = dur > 0 and pos >= dur * 0.9
|
|
||||||
EpisodeProgress.objects.update_or_create(
|
|
||||||
user=request.user, episode=ep,
|
|
||||||
defaults={'position_seconds': pos, 'played': played}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return JsonResponse({'timestamp': _now_ts()})
|
|
||||||
|
|
||||||
elif request.method == 'GET':
|
|
||||||
since = request.GET.get('since')
|
|
||||||
qs = GpodderEpisodeAction.objects.filter(user=request.user)
|
|
||||||
if since:
|
|
||||||
try:
|
|
||||||
since_dt = datetime.fromtimestamp(int(since), tz=timezone.utc)
|
|
||||||
qs = qs.filter(timestamp__gt=since_dt)
|
|
||||||
except (ValueError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return JsonResponse({
|
|
||||||
'actions': [
|
|
||||||
{
|
|
||||||
'podcast': a.podcast,
|
|
||||||
'episode': a.episode,
|
|
||||||
'device': a.device.device_id if a.device else '',
|
|
||||||
'action': a.action,
|
|
||||||
'timestamp': a.timestamp.strftime('%Y-%m-%dT%H:%M:%S'),
|
|
||||||
'started': a.started,
|
|
||||||
'position': a.position,
|
|
||||||
'total': a.total,
|
|
||||||
}
|
|
||||||
for a in qs[:500]
|
|
||||||
],
|
|
||||||
'timestamp': _now_ts(),
|
|
||||||
})
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [('podcasts', '0001_initial')]
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='podcastfeed',
|
|
||||||
name='auto_queue',
|
|
||||||
field=models.BooleanField(default=False),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [('podcasts', '0002_podcastfeed_auto_queue')]
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='episodeprogress',
|
|
||||||
name='dismissed',
|
|
||||||
field=models.BooleanField(default=False),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -12,7 +12,6 @@ class PodcastFeed(models.Model):
|
||||||
link = models.URLField(max_length=1000, blank=True)
|
link = models.URLField(max_length=1000, blank=True)
|
||||||
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
||||||
added_at = models.DateTimeField(auto_now_add=True)
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
auto_queue = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('user', 'rss_url')
|
unique_together = ('user', 'rss_url')
|
||||||
|
|
@ -48,7 +47,6 @@ class EpisodeProgress(models.Model):
|
||||||
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='progress')
|
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='progress')
|
||||||
position_seconds = models.IntegerField(default=0)
|
position_seconds = models.IntegerField(default=0)
|
||||||
played = models.BooleanField(default=False)
|
played = models.BooleanField(default=False)
|
||||||
dismissed = models.BooleanField(default=False)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ urlpatterns = [
|
||||||
path('feeds/import/', views.import_opml, name='podcast_import_opml'),
|
path('feeds/import/', views.import_opml, name='podcast_import_opml'),
|
||||||
path('feeds/refresh/', views.refresh_feed_now, name='podcast_refresh_feed'),
|
path('feeds/refresh/', views.refresh_feed_now, name='podcast_refresh_feed'),
|
||||||
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
|
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
|
||||||
path('feeds/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
|
|
||||||
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
||||||
path('queue/', views.queue_get, name='podcast_queue_get'),
|
path('queue/', views.queue_get, name='podcast_queue_get'),
|
||||||
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
||||||
|
|
@ -16,6 +15,5 @@ urlpatterns = [
|
||||||
path('queue/reorder/', views.queue_reorder, name='podcast_queue_reorder'),
|
path('queue/reorder/', views.queue_reorder, name='podcast_queue_reorder'),
|
||||||
path('progress/save/', views.save_progress, name='podcast_save_progress'),
|
path('progress/save/', views.save_progress, name='podcast_save_progress'),
|
||||||
path('progress/mark-played/', views.mark_played, name='podcast_mark_played'),
|
path('progress/mark-played/', views.mark_played, name='podcast_mark_played'),
|
||||||
path('progress/dismiss/', views.dismiss_episodes, name='podcast_dismiss_episodes'),
|
|
||||||
path('inbox/', views.inbox, name='podcast_inbox'),
|
path('inbox/', views.inbox, name='podcast_inbox'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import xml.etree.ElementTree as ET
|
||||||
import feedparser
|
import feedparser
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Count, F, Max, Q
|
from django.db.models import Count, Q
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
@ -63,7 +63,6 @@ def _refresh_feed(feed_obj):
|
||||||
|
|
||||||
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
|
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
|
||||||
new_count = 0
|
new_count = 0
|
||||||
new_episodes_list = []
|
|
||||||
|
|
||||||
for entry in parsed.entries[:max_ep]:
|
for entry in parsed.entries[:max_ep]:
|
||||||
# Find audio enclosure
|
# Find audio enclosure
|
||||||
|
|
@ -104,7 +103,7 @@ def _refresh_feed(feed_obj):
|
||||||
|
|
||||||
artwork_url = entry.get('itunes_image', {}).get('href', '')[:1000]
|
artwork_url = entry.get('itunes_image', {}).get('href', '')[:1000]
|
||||||
|
|
||||||
episode_obj, created = PodcastEpisode.objects.get_or_create(
|
_, created = PodcastEpisode.objects.get_or_create(
|
||||||
feed=feed_obj,
|
feed=feed_obj,
|
||||||
guid=guid,
|
guid=guid,
|
||||||
defaults={
|
defaults={
|
||||||
|
|
@ -120,16 +119,6 @@ def _refresh_feed(feed_obj):
|
||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
new_count += 1
|
new_count += 1
|
||||||
new_episodes_list.append(episode_obj)
|
|
||||||
|
|
||||||
if feed_obj.auto_queue and new_episodes_list:
|
|
||||||
count = len(new_episodes_list)
|
|
||||||
PodcastQueue.objects.filter(user=feed_obj.user).update(position=F('position') + count)
|
|
||||||
for idx, ep_obj in enumerate(new_episodes_list):
|
|
||||||
PodcastQueue.objects.get_or_create(
|
|
||||||
user=feed_obj.user, episode=ep_obj,
|
|
||||||
defaults={'position': idx},
|
|
||||||
)
|
|
||||||
|
|
||||||
return new_count
|
return new_count
|
||||||
|
|
||||||
|
|
@ -176,17 +165,12 @@ def feed_list(request):
|
||||||
|
|
||||||
feeds = list(
|
feeds = list(
|
||||||
request.user.podcast_feeds
|
request.user.podcast_feeds
|
||||||
.annotate(latest_episode_at=Max('episodes__pub_date'))
|
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author')
|
||||||
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for f in feeds:
|
for f in feeds:
|
||||||
if f['last_refreshed_at']:
|
if f['last_refreshed_at']:
|
||||||
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
|
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
|
||||||
if f['added_at']:
|
|
||||||
f['added_at'] = f['added_at'].isoformat()
|
|
||||||
if f['latest_episode_at']:
|
|
||||||
f['latest_episode_at'] = f['latest_episode_at'].isoformat()
|
|
||||||
|
|
||||||
return JsonResponse({'feeds': feeds})
|
return JsonResponse({'feeds': feeds})
|
||||||
|
|
||||||
|
|
@ -252,28 +236,6 @@ def remove_feed(request, pk):
|
||||||
return JsonResponse({'error': 'not found'}, status=404)
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Set auto-queue
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(['POST'])
|
|
||||||
def set_auto_queue(request, pk):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
|
||||||
try:
|
|
||||||
body = json.loads(request.body)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
||||||
try:
|
|
||||||
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
|
|
||||||
except PodcastFeed.DoesNotExist:
|
|
||||||
return JsonResponse({'error': 'not found'}, status=404)
|
|
||||||
feed.auto_queue = bool(body.get('auto_queue', not feed.auto_queue))
|
|
||||||
feed.save(update_fields=['auto_queue'])
|
|
||||||
return JsonResponse({'ok': True, 'auto_queue': feed.auto_queue})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Feed episodes
|
# Feed episodes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -326,7 +288,6 @@ def feed_episodes(request, pk):
|
||||||
'title': feed.title,
|
'title': feed.title,
|
||||||
'artwork_url': feed.artwork_url,
|
'artwork_url': feed.artwork_url,
|
||||||
'author': feed.author,
|
'author': feed.author,
|
||||||
'auto_queue': feed.auto_queue,
|
|
||||||
},
|
},
|
||||||
'episodes': episodes,
|
'episodes': episodes,
|
||||||
})
|
})
|
||||||
|
|
@ -425,16 +386,6 @@ def queue_get(request):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ep_ids = [item['episode__id'] for item in items]
|
|
||||||
progress_map = {
|
|
||||||
p['episode_id']: p['position_seconds']
|
|
||||||
for p in EpisodeProgress.objects.filter(
|
|
||||||
user=request.user, episode_id__in=ep_ids,
|
|
||||||
).values('episode_id', 'position_seconds')
|
|
||||||
}
|
|
||||||
for item in items:
|
|
||||||
item['position_seconds'] = progress_map.get(item['episode__id'], 0)
|
|
||||||
|
|
||||||
return JsonResponse({'queue': items})
|
return JsonResponse({'queue': items})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -564,45 +515,6 @@ def mark_played(request):
|
||||||
return JsonResponse({'ok': True, 'played': played})
|
return JsonResponse({'ok': True, 'played': played})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dismiss episodes (hide from inbox without marking played)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(['POST'])
|
|
||||||
def dismiss_episodes(request):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
|
||||||
|
|
||||||
try:
|
|
||||||
body = json.loads(request.body)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
||||||
|
|
||||||
episode_ids = body.get('episode_ids', [])
|
|
||||||
dismissed = bool(body.get('dismissed', True))
|
|
||||||
|
|
||||||
if not isinstance(episode_ids, list) or not episode_ids:
|
|
||||||
return JsonResponse({'error': 'episode_ids required'}, status=400)
|
|
||||||
|
|
||||||
# Only allow dismissing episodes that belong to this user
|
|
||||||
valid_ids = list(
|
|
||||||
PodcastEpisode.objects.filter(
|
|
||||||
id__in=episode_ids, feed__user=request.user
|
|
||||||
).values_list('id', flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
for ep_id in valid_ids:
|
|
||||||
progress, _ = EpisodeProgress.objects.get_or_create(
|
|
||||||
user=request.user,
|
|
||||||
episode_id=ep_id,
|
|
||||||
)
|
|
||||||
progress.dismissed = dismissed
|
|
||||||
progress.save(update_fields=['dismissed', 'updated_at'])
|
|
||||||
|
|
||||||
return JsonResponse({'ok': True, 'count': len(valid_ids)})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Inbox
|
# Inbox
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -612,47 +524,27 @@ def inbox(request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
hidden_ids = set(
|
played_ids = set(
|
||||||
EpisodeProgress.objects.filter(
|
EpisodeProgress.objects.filter(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
).filter(
|
played=True,
|
||||||
Q(played=True) | Q(dismissed=True)
|
|
||||||
).values_list('episode_id', flat=True)
|
).values_list('episode_id', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
limit = min(int(request.GET.get('limit', 200)), 1000)
|
|
||||||
offset = max(int(request.GET.get('offset', 0)), 0)
|
|
||||||
|
|
||||||
episodes = list(
|
episodes = list(
|
||||||
PodcastEpisode.objects.filter(feed__user=request.user)
|
PodcastEpisode.objects.filter(feed__user=request.user)
|
||||||
.exclude(id__in=hidden_ids)
|
.exclude(id__in=played_ids)
|
||||||
.select_related('feed')
|
.select_related('feed')
|
||||||
.order_by('-pub_date')
|
.order_by('-pub_date')
|
||||||
.values(
|
.values(
|
||||||
'id', 'title', 'description', 'audio_url', 'duration_seconds',
|
'id', 'title', 'audio_url', 'duration_seconds',
|
||||||
'pub_date', 'artwork_url',
|
'pub_date', 'artwork_url',
|
||||||
'feed__id', 'feed__title', 'feed__artwork_url',
|
'feed__id', 'feed__title', 'feed__artwork_url',
|
||||||
)[offset:offset + limit]
|
)[:100]
|
||||||
)
|
)
|
||||||
|
|
||||||
progress_map = {
|
|
||||||
ep['episode_id']: ep
|
|
||||||
for ep in EpisodeProgress.objects.filter(
|
|
||||||
user=request.user,
|
|
||||||
episode_id__in=[e['id'] for e in episodes],
|
|
||||||
).values('episode_id', 'position_seconds')
|
|
||||||
}
|
|
||||||
queued_ids = set(
|
|
||||||
PodcastQueue.objects.filter(
|
|
||||||
user=request.user,
|
|
||||||
episode_id__in=[e['id'] for e in episodes],
|
|
||||||
).values_list('episode_id', flat=True)
|
|
||||||
)
|
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
if ep['pub_date']:
|
if ep['pub_date']:
|
||||||
ep['pub_date'] = ep['pub_date'].isoformat()
|
ep['pub_date'] = ep['pub_date'].isoformat()
|
||||||
prog = progress_map.get(ep['id'], {})
|
|
||||||
ep['position_seconds'] = prog.get('position_seconds', 0)
|
|
||||||
ep['in_queue'] = ep['id'] in queued_ids
|
|
||||||
|
|
||||||
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit})
|
return JsonResponse({'episodes': episodes})
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,10 @@ def index(request):
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'saved_stations': saved_stations,
|
'saved_stations': saved_stations,
|
||||||
'saved_stations_json': json.dumps(saved_stations, cls=DjangoJSONEncoder),
|
|
||||||
'history': history,
|
'history': history,
|
||||||
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
|
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
|
||||||
'featured_stations': featured,
|
'featured_stations': featured,
|
||||||
'featured_stations_json': json.dumps(featured, cls=DjangoJSONEncoder),
|
'initial_podcast_feeds': initial_podcast_feeds,
|
||||||
'initial_podcast_feeds': json.dumps(initial_podcast_feeds, cls=DjangoJSONEncoder),
|
|
||||||
'focus_station': focus_station,
|
'focus_station': focus_station,
|
||||||
'focus_station_json': json.dumps(focus_station, cls=DjangoJSONEncoder),
|
'focus_station_json': json.dumps(focus_station, cls=DjangoJSONEncoder),
|
||||||
'encrypted_bg': encrypted_bg,
|
'encrypted_bg': encrypted_bg,
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,3 @@ requests>=2.31
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
whitenoise>=6.6
|
whitenoise>=6.6
|
||||||
feedparser>=6.0
|
feedparser>=6.0
|
||||||
gevent>=24.0
|
|
||||||
|
|
|
||||||
|
|
@ -1030,7 +1030,6 @@ body.dnd-mode .timer-display {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip-btn {
|
.skip-btn {
|
||||||
|
|
@ -1234,7 +1233,6 @@ body.dnd-mode .timer-display {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.feed-refresh-btn { margin-left: auto; flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* Clickable episode title (episode list) */
|
/* Clickable episode title (episode list) */
|
||||||
.ep-clickable {
|
.ep-clickable {
|
||||||
|
|
@ -1325,90 +1323,6 @@ body.dnd-mode .timer-display {
|
||||||
margin: 12px 0 4px;
|
margin: 12px 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inbox toolbar + checkboxes */
|
|
||||||
.inbox-toolbar {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
padding: 0 0 8px; flex-wrap: wrap; min-height: 32px;
|
|
||||||
}
|
|
||||||
.inbox-select-all-label {
|
|
||||||
display: flex; align-items: center; gap: 4px;
|
|
||||||
font-size: 0.82rem; color: var(--text-muted, #888); cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.inbox-bulk-actions {
|
|
||||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.inbox-load-more {
|
|
||||||
display: flex; align-items: center; gap: 6px; margin-left: auto;
|
|
||||||
}
|
|
||||||
.inbox-checkbox-label {
|
|
||||||
display: flex; align-items: center; flex-shrink: 0;
|
|
||||||
padding-right: 4px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.inbox-checkbox-label input[type="checkbox"] {
|
|
||||||
width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent, #e63946);
|
|
||||||
}
|
|
||||||
.episode-item.inbox-selected { background: var(--surface-alt, #2a2a3e); }
|
|
||||||
|
|
||||||
/* Sleep timer */
|
|
||||||
.sleep-timer-btn {
|
|
||||||
font-size: 0.68rem; padding: 1px 5px; white-space: nowrap;
|
|
||||||
border: 1px solid var(--border, #444); border-radius: 3px; opacity: 0.7;
|
|
||||||
}
|
|
||||||
.sleep-timer-btn:hover { opacity: 1; }
|
|
||||||
.sleep-timer-menu {
|
|
||||||
position: absolute; background: var(--surface, #1e1e2e);
|
|
||||||
border: 1px solid var(--border, #444); border-radius: 6px; padding: 4px;
|
|
||||||
display: flex; flex-direction: column; gap: 2px; z-index: 200;
|
|
||||||
bottom: calc(100% + 4px); right: 0;
|
|
||||||
}
|
|
||||||
.sleep-timer-option {
|
|
||||||
background: none; border: none; color: var(--fg, #fff);
|
|
||||||
padding: 5px 12px; text-align: left; cursor: pointer;
|
|
||||||
font-size: 0.85rem; border-radius: 4px;
|
|
||||||
}
|
|
||||||
.sleep-timer-option:hover { background: var(--accent, #e63946); color: #fff; }
|
|
||||||
|
|
||||||
/* Episode filter */
|
|
||||||
.episode-search-bar { padding: 6px 0 8px; }
|
|
||||||
.episode-search-bar .search-input { width: 100%; box-sizing: border-box; }
|
|
||||||
|
|
||||||
/* Episode meta line */
|
|
||||||
.episode-meta {
|
|
||||||
display: flex; gap: 8px; font-size: 0.78rem;
|
|
||||||
color: var(--text-muted, #888); margin-top: 2px; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.episode-date { font-weight: 500; color: var(--fg, #ccc); }
|
|
||||||
.episode-feed-link { cursor: pointer; }
|
|
||||||
.episode-feed-link:hover { text-decoration: underline; color: var(--accent, #e63946); }
|
|
||||||
.episode-dur { color: var(--text-muted, #888); }
|
|
||||||
|
|
||||||
/* Episode progress bar */
|
|
||||||
.episode-progress-bar {
|
|
||||||
height: 3px; background: var(--border, #333);
|
|
||||||
border-radius: 2px; margin-top: 5px; overflow: hidden;
|
|
||||||
}
|
|
||||||
.episode-progress-fill {
|
|
||||||
height: 100%; background: var(--accent, #e63946); border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Queue drag-and-drop */
|
|
||||||
.drag-handle { cursor: grab; color: var(--text-muted, #666); padding: 0 6px; user-select: none; }
|
|
||||||
.drag-handle:active { cursor: grabbing; }
|
|
||||||
#podcast-queue-ol li.dragging { opacity: 0.4; border: 1px dashed var(--accent, #e63946); }
|
|
||||||
|
|
||||||
/* Feed list toolbar */
|
|
||||||
.feed-list-toolbar {
|
|
||||||
display: flex; gap: 8px; align-items: center;
|
|
||||||
padding: 0 0 10px; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.feed-list-toolbar .search-input { flex: 1; min-width: 140px; }
|
|
||||||
.feed-sort-select {
|
|
||||||
background: var(--surface, #1e1e2e); color: var(--fg, #fff);
|
|
||||||
border: 1px solid var(--border, #444); border-radius: 4px;
|
|
||||||
padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.sidebar { width: 100vw; }
|
.sidebar { width: 100vw; }
|
||||||
.podcast-seek-bar { padding: 0 6px; }
|
.podcast-seek-bar { padding: 0 6px; }
|
||||||
|
|
@ -1446,10 +1360,6 @@ body.dnd-mode .timer-display {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.enc-key-prompt {
|
|
||||||
padding: 24px 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-key-bar {
|
.book-key-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1501,11 +1411,11 @@ body.dnd-mode .timer-display {
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-page {
|
.pdf-page {
|
||||||
display: block;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1628,7 +1538,6 @@ body.dnd-mode .timer-display {
|
||||||
.reader-theme-sepia .reader-content { background:#f5e6c8; color:#3b2a1a; }
|
.reader-theme-sepia .reader-content { background:#f5e6c8; color:#3b2a1a; }
|
||||||
.reader-theme-bright .reader-content { background:#fff; color:#111; }
|
.reader-theme-bright .reader-content { background:#fff; color:#111; }
|
||||||
.reader-content > * { max-width:var(--reader-max-width,65ch); margin-left:auto; margin-right:auto; }
|
.reader-content > * { max-width:var(--reader-max-width,65ch); margin-left:auto; margin-right:auto; }
|
||||||
.reader-content > .pdf-page-wrapper { max-width: none; margin: 0 auto 1rem; }
|
|
||||||
|
|
||||||
/* Inline panels */
|
/* Inline panels */
|
||||||
.reader-settings-panel, .reader-search-bar {
|
.reader-settings-panel, .reader-search-bar {
|
||||||
|
|
@ -1637,9 +1546,6 @@ body.dnd-mode .timer-display {
|
||||||
}
|
}
|
||||||
.reader-settings-panel input[type="range"] { width:80px; }
|
.reader-settings-panel input[type="range"] { width:80px; }
|
||||||
|
|
||||||
/* PDF viewport wrapper — CSS zoom applied here for smooth zoom without re-render */
|
|
||||||
#pdf-viewport { transform-origin: top left; }
|
|
||||||
|
|
||||||
/* PDF inner container — shares origin with canvas so text layer aligns exactly */
|
/* PDF inner container — shares origin with canvas so text layer aligns exactly */
|
||||||
.pdf-page-inner { position:relative; display:inline-block; line-height:0; }
|
.pdf-page-inner { position:relative; display:inline-block; line-height:0; }
|
||||||
|
|
||||||
|
|
@ -1681,13 +1587,3 @@ mark.reader-search-match.active { background:rgba(230,57,70,.7); }
|
||||||
/* Toast */
|
/* Toast */
|
||||||
.reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; }
|
.reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; }
|
||||||
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }
|
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }
|
||||||
|
|
||||||
.build-time {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 4px;
|
|
||||||
right: 6px;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
color: #444;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
641
static/js/app.js
641
static/js/app.js
|
|
@ -25,12 +25,6 @@ let podcastCurrentView = 'feeds';
|
||||||
let podcastCurrentFeedId = null;
|
let podcastCurrentFeedId = null;
|
||||||
const podcastEpCache = {}; // id → episode data, avoids encoding strings in onclick attrs
|
const podcastEpCache = {}; // id → episode data, avoids encoding strings in onclick attrs
|
||||||
|
|
||||||
let sleepTimerInterval = null;
|
|
||||||
let sleepTimerEndSecs = 0;
|
|
||||||
let sleepTimerEndOfEp = false;
|
|
||||||
let _dragSrcEl = null;
|
|
||||||
let feedSortOrder = 'alpha';
|
|
||||||
|
|
||||||
const audio = new Audio();
|
const audio = new Audio();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -88,18 +82,6 @@ function playStation(url, name, stationId) {
|
||||||
startMetadataSSE(url);
|
startMetadataSSE(url);
|
||||||
startPlaySession(name, url);
|
startPlaySession(name, url);
|
||||||
maybeShowDonationHint(url, name);
|
maybeShowDonationHint(url, name);
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'});
|
|
||||||
navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; });
|
|
||||||
navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; });
|
|
||||||
navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true));
|
|
||||||
try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {}
|
|
||||||
try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {}
|
|
||||||
try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {}
|
|
||||||
try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {}
|
|
||||||
navigator.mediaSession.playbackState = 'playing';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPlayback(clearStation = true) {
|
function stopPlayback(clearStation = true) {
|
||||||
|
|
@ -112,10 +94,8 @@ function stopPlayback(clearStation = true) {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.src = '';
|
audio.src = '';
|
||||||
audio.ontimeupdate = null;
|
audio.ontimeupdate = null;
|
||||||
audio.onended = null;
|
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
podcastMode = false;
|
podcastMode = false;
|
||||||
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none';
|
|
||||||
|
|
||||||
const seekBar = $('podcast-seek-bar');
|
const seekBar = $('podcast-seek-bar');
|
||||||
if (seekBar) seekBar.style.display = 'none';
|
if (seekBar) seekBar.style.display = 'none';
|
||||||
|
|
@ -1166,52 +1146,11 @@ function renderFeedList() {
|
||||||
<div class="podcast-feed-actions">
|
<div class="podcast-feed-actions">
|
||||||
<button class="btn btn-sm" onclick="openFeed(${feed.id})">Episodes</button>
|
<button class="btn btn-sm" onclick="openFeed(${feed.id})">Episodes</button>
|
||||||
<button class="btn btn-sm" onclick="refreshFeed(${feed.id})" title="Refresh feed">↻</button>
|
<button class="btn btn-sm" onclick="refreshFeed(${feed.id})" title="Refresh feed">↻</button>
|
||||||
<button class="btn btn-sm ${feed.auto_queue ? 'active' : ''}" onclick="toggleFeedAutoQueue(${feed.id}, this)" title="${feed.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes'}">⚡Q</button>
|
|
||||||
<button class="btn btn-sm btn-danger" onclick="removeFeed(${feed.id})">Remove</button>
|
<button class="btn btn-sm btn-danger" onclick="removeFeed(${feed.id})">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterVal = ($('feed-filter-input') || {}).value || '';
|
|
||||||
if (filterVal) filterFeeds(filterVal);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterFeeds(query) {
|
|
||||||
const container = $('podcast-feed-list');
|
|
||||||
if (!container) return;
|
|
||||||
const q = query.toLowerCase().trim();
|
|
||||||
container.querySelectorAll('.podcast-feed-item').forEach(item => {
|
|
||||||
const title = (item.querySelector('.podcast-feed-title') || {}).textContent?.toLowerCase() || '';
|
|
||||||
const author = (item.querySelector('.muted') || {}).textContent?.toLowerCase() || '';
|
|
||||||
item.style.display = (!q || title.includes(q) || author.includes(q)) ? '' : 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortFeeds(order) {
|
|
||||||
feedSortOrder = order;
|
|
||||||
if (order === 'alpha') podcastFeeds.sort((a, b) => a.title.localeCompare(b.title));
|
|
||||||
if (order === 'alpha-desc') podcastFeeds.sort((a, b) => b.title.localeCompare(a.title));
|
|
||||||
if (order === 'added') podcastFeeds.sort((a, b) => (b.added_at || '').localeCompare(a.added_at || ''));
|
|
||||||
if (order === 'latest_episode') podcastFeeds.sort((a, b) => (b.latest_episode_at || '').localeCompare(a.latest_episode_at || ''));
|
|
||||||
renderFeedList();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleFeedAutoQueue(feedId, btn) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/podcasts/feeds/${feedId}/set-auto-queue/`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.ok) {
|
|
||||||
const feed = podcastFeeds.find(f => f.id === feedId);
|
|
||||||
if (feed) feed.auto_queue = data.auto_queue;
|
|
||||||
btn.classList.toggle('active', data.auto_queue);
|
|
||||||
btn.title = data.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes';
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFeed(feedId) {
|
async function openFeed(feedId) {
|
||||||
|
|
@ -1236,29 +1175,16 @@ async function openFeed(feedId) {
|
||||||
<div class="podcast-feed-title">${escapeHtml(feed.title)}</div>
|
<div class="podcast-feed-title">${escapeHtml(feed.title)}</div>
|
||||||
${feed.author ? `<div class="muted">${escapeHtml(feed.author)}</div>` : ''}
|
${feed.author ? `<div class="muted">${escapeHtml(feed.author)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm feed-refresh-btn" id="feed-refresh-btn" onclick="refreshOpenFeed(this)" title="Refresh feed">↻ Refresh</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderEpisodeList(episodes, feedId, listEl);
|
renderEpisodeList(episodes, feedId, listEl);
|
||||||
const filterBar = $('episode-search-bar');
|
|
||||||
if (filterBar) { filterBar.style.display = ''; $('episode-filter-input').value = ''; }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (headerEl) headerEl.innerHTML = '<p class="muted">Failed to load episodes.</p>';
|
if (headerEl) headerEl.innerHTML = '<p class="muted">Failed to load episodes.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterEpisodes(query) {
|
|
||||||
const listEl = $('podcast-episode-list');
|
|
||||||
if (!listEl) return;
|
|
||||||
const q = query.toLowerCase().trim();
|
|
||||||
listEl.querySelectorAll('.episode-item').forEach(item => {
|
|
||||||
const text = (item.querySelector('.episode-title') || {}).textContent?.toLowerCase() || '';
|
|
||||||
item.style.display = (!q || text.includes(q)) ? '' : 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEpisodeList(episodes, feedId, container) {
|
function renderEpisodeList(episodes, feedId, container) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
if (!episodes.length) {
|
if (!episodes.length) {
|
||||||
|
|
@ -1288,24 +1214,16 @@ function renderEpisodeList(episodes, feedId, container) {
|
||||||
const artSrc = ep.artwork_url || (feedId ? (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '' : '');
|
const artSrc = ep.artwork_url || (feedId ? (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '' : '');
|
||||||
const dur = formatDuration(ep.duration_seconds);
|
const dur = formatDuration(ep.duration_seconds);
|
||||||
const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
|
const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
|
||||||
const posStr = ep.position_seconds > 0 ? formatDuration(ep.position_seconds) + ' played' : '';
|
const posStr = ep.position_seconds > 0 ? ` · ${formatDuration(ep.position_seconds)} played` : '';
|
||||||
const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0)
|
|
||||||
? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0;
|
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
${artSrc ? `<img class="podcast-thumb" src="${escapeHtml(artSrc)}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
|
${artSrc ? `<img class="podcast-thumb" src="${escapeHtml(artSrc)}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
|
||||||
<div class="episode-info">
|
<div class="episode-info">
|
||||||
<div class="episode-title ep-clickable" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">${escapeHtml(ep.title)}</div>
|
<div class="episode-title ep-clickable" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">${escapeHtml(ep.title)}</div>
|
||||||
<div class="episode-meta">
|
<div class="muted">${escapeHtml(dateStr)} · ${escapeHtml(dur)}${escapeHtml(posStr)}</div>
|
||||||
${dateStr ? `<span class="episode-date">${escapeHtml(dateStr)}</span>` : ''}
|
|
||||||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
|
||||||
${posStr ? `<span class="episode-pos muted">${escapeHtml(posStr)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="episode-actions">
|
<div class="episode-actions">
|
||||||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
|
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
|
||||||
<button class="btn btn-sm" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">📋</button>
|
|
||||||
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${ep.in_queue ? 'In queue' : 'Add to queue'}">${ep.in_queue ? '✓Q' : '+Q'}</button>
|
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${ep.in_queue ? 'In queue' : 'Add to queue'}">${ep.in_queue ? '✓Q' : '+Q'}</button>
|
||||||
<button class="btn btn-sm" onclick="toggleMarkPlayed(${ep.id}, this)" title="Mark played">${ep.played ? '✓' : '○'}</button>
|
<button class="btn btn-sm" onclick="toggleMarkPlayed(${ep.id}, this)" title="Mark played">${ep.played ? '✓' : '○'}</button>
|
||||||
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
|
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
|
||||||
|
|
@ -1328,23 +1246,6 @@ function downloadEpisodeById(id, btn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) {
|
function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) {
|
||||||
// Auto-enqueue if not already in queue
|
|
||||||
const inQueue = podcastQueue.some(q => q['episode__id'] === id);
|
|
||||||
if (!inQueue) {
|
|
||||||
fetch('/podcasts/queue/add/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({episode_id: id}),
|
|
||||||
}).then(() => {
|
|
||||||
// update local queue state and any visible +Q buttons
|
|
||||||
if (podcastCurrentView === 'queue') loadAndRenderQueue();
|
|
||||||
const qBtn = document.querySelector(`#episode-item-${id} .episode-actions .btn-sm:nth-child(3)`);
|
|
||||||
if (qBtn && (qBtn.textContent === '+Q' || qBtn.textContent.includes('Q'))) {
|
|
||||||
qBtn.textContent = '✓Q'; qBtn.title = 'In queue';
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
stopPlayback(false);
|
stopPlayback(false);
|
||||||
|
|
||||||
podcastMode = true;
|
podcastMode = true;
|
||||||
|
|
@ -1388,26 +1289,17 @@ function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) {
|
||||||
setPlaybackRate(1);
|
setPlaybackRate(1);
|
||||||
|
|
||||||
audio.ontimeupdate = podcastTimeUpdate;
|
audio.ontimeupdate = podcastTimeUpdate;
|
||||||
audio.onended = podcastOnEnded;
|
|
||||||
|
|
||||||
// Media Session API — maps hardware media keys, lock-screen controls, and
|
// Media Session API — maps hardware media keys & lock-screen controls
|
||||||
// Windows taskbar thumbnail buttons (play/pause, previous, next)
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || '';
|
|
||||||
const artSrc = (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '';
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
title,
|
title: title,
|
||||||
artist: feedTitle,
|
artist: (podcastFeeds.find(f => f.id === feedId) || {}).title || '',
|
||||||
artwork: artSrc ? [{src: artSrc, sizes: '512x512', type: 'image/jpeg'}] : [],
|
|
||||||
});
|
});
|
||||||
navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; });
|
|
||||||
navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; });
|
|
||||||
navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true));
|
|
||||||
navigator.mediaSession.setActionHandler('seekbackward', () => skipBack());
|
navigator.mediaSession.setActionHandler('seekbackward', () => skipBack());
|
||||||
navigator.mediaSession.setActionHandler('seekforward', () => skipForward());
|
navigator.mediaSession.setActionHandler('seekforward', () => skipForward());
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => podcastOnEnded());
|
navigator.mediaSession.setActionHandler('play', () => { audio.play(); });
|
||||||
try { navigator.mediaSession.setActionHandler('previoustrack', () => skipBack()); } catch (_) {}
|
navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); });
|
||||||
navigator.mediaSession.playbackState = 'playing';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seekSaveTimer) clearInterval(seekSaveTimer);
|
if (seekSaveTimer) clearInterval(seekSaveTimer);
|
||||||
|
|
@ -1455,100 +1347,10 @@ function setPlaybackRate(rate) {
|
||||||
btn.classList.toggle('active', parseFloat(btn.textContent) === rate
|
btn.classList.toggle('active', parseFloat(btn.textContent) === rate
|
||||||
|| (rate === 0.75 && btn.textContent.startsWith('¾'))
|
|| (rate === 0.75 && btn.textContent.startsWith('¾'))
|
||||||
|| (rate === 1.25 && btn.textContent.startsWith('1¼'))
|
|| (rate === 1.25 && btn.textContent.startsWith('1¼'))
|
||||||
|| (rate === 1.5 && btn.textContent.startsWith('1½'))
|
|| (rate === 1.5 && btn.textContent.startsWith('1½')));
|
||||||
|| (rate === 1.75 && btn.textContent.startsWith('1¾'))
|
|
||||||
|| (rate === 2.5 && btn.textContent.startsWith('2½')));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function podcastOnEnded() {
|
|
||||||
if (!podcastMode || !currentEpisode) return;
|
|
||||||
|
|
||||||
await fetch('/podcasts/progress/mark-played/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({episode_id: currentEpisode.id, played: true}),
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
if (sleepTimerEndOfEp) { clearSleepTimer(); audio.pause(); return; }
|
|
||||||
|
|
||||||
const finishedId = currentEpisode.id;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/podcasts/queue/');
|
|
||||||
const data = await res.json();
|
|
||||||
const items = data.queue || [];
|
|
||||||
const currentIdx = items.findIndex(item => item['episode__id'] === finishedId);
|
|
||||||
const nextItem = currentIdx >= 0 ? items[currentIdx + 1] : null;
|
|
||||||
|
|
||||||
await fetch('/podcasts/queue/remove/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({episode_id: finishedId}),
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
if (nextItem) {
|
|
||||||
const nextEpId = nextItem['episode__id'];
|
|
||||||
const cached = podcastEpCache[nextEpId] || {};
|
|
||||||
playEpisode(nextEpId, nextItem['episode__title'], nextItem['episode__audio_url'],
|
|
||||||
nextItem['episode__duration_seconds'], cached.positionSeconds || 0, nextItem['episode__feed__id']);
|
|
||||||
} else {
|
|
||||||
stopPlayback(false);
|
|
||||||
}
|
|
||||||
if (podcastCurrentView === 'queue') loadAndRenderQueue();
|
|
||||||
} catch (e) {
|
|
||||||
stopPlayback(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSleepTimerMenu() {
|
|
||||||
const existing = document.getElementById('sleep-timer-menu');
|
|
||||||
if (existing) { existing.remove(); return; }
|
|
||||||
const options = [
|
|
||||||
{label: 'Off', value: 0}, {label: '5m', value: 5}, {label: '10m', value: 10},
|
|
||||||
{label: '15m', value: 15}, {label: '30m', value: 30}, {label: '45m', value: 45},
|
|
||||||
{label: '60m', value: 60}, {label: 'End of episode', value: -1},
|
|
||||||
];
|
|
||||||
const menu = document.createElement('div');
|
|
||||||
menu.id = 'sleep-timer-menu';
|
|
||||||
menu.className = 'sleep-timer-menu';
|
|
||||||
options.forEach(opt => {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'sleep-timer-option';
|
|
||||||
btn.textContent = opt.label;
|
|
||||||
btn.onclick = () => { setSleepTimer(opt.value); menu.remove(); };
|
|
||||||
menu.appendChild(btn);
|
|
||||||
});
|
|
||||||
document.getElementById('sleep-timer-btn').insertAdjacentElement('afterend', menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSleepTimer(minutes) {
|
|
||||||
clearSleepTimer();
|
|
||||||
const btn = document.getElementById('sleep-timer-btn');
|
|
||||||
if (minutes === 0) { if (btn) btn.textContent = 'Sleep'; return; }
|
|
||||||
if (minutes === -1) {
|
|
||||||
sleepTimerEndOfEp = true;
|
|
||||||
if (btn) btn.textContent = 'Sleep:EoE';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sleepTimerEndOfEp = false;
|
|
||||||
sleepTimerEndSecs = Math.floor(Date.now() / 1000) + minutes * 60;
|
|
||||||
sleepTimerInterval = setInterval(() => {
|
|
||||||
const remaining = sleepTimerEndSecs - Math.floor(Date.now() / 1000);
|
|
||||||
if (remaining <= 0) { clearSleepTimer(); audio.pause(); isPlaying = false; return; }
|
|
||||||
const m = Math.floor(remaining / 60);
|
|
||||||
const s = remaining % 60;
|
|
||||||
if (btn) btn.textContent = `${m}:${String(s).padStart(2, '0')}`;
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSleepTimer() {
|
|
||||||
if (sleepTimerInterval) { clearInterval(sleepTimerInterval); sleepTimerInterval = null; }
|
|
||||||
sleepTimerEndOfEp = false;
|
|
||||||
sleepTimerEndSecs = 0;
|
|
||||||
const btn = document.getElementById('sleep-timer-btn');
|
|
||||||
if (btn) btn.textContent = 'Sleep';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePodcastProgress() {
|
async function savePodcastProgress() {
|
||||||
if (!currentEpisode) return;
|
if (!currentEpisode) return;
|
||||||
const pos = Math.floor(audio.currentTime);
|
const pos = Math.floor(audio.currentTime);
|
||||||
|
|
@ -1561,29 +1363,19 @@ async function savePodcastProgress() {
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _inboxOffset = 0;
|
async function loadAndRenderInbox() {
|
||||||
const _inboxPageSize = 200;
|
|
||||||
|
|
||||||
async function loadAndRenderInbox(append = false) {
|
|
||||||
const listEl = $('podcast-inbox-list');
|
const listEl = $('podcast-inbox-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<p class="muted">Loading…</p>';
|
||||||
if (!append) {
|
|
||||||
_inboxOffset = 0;
|
|
||||||
listEl.innerHTML = '<p class="muted">Loading…</p>';
|
|
||||||
inboxUpdateBulkBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/podcasts/inbox/?limit=${_inboxPageSize}&offset=${_inboxOffset}`);
|
const res = await fetch('/podcasts/inbox/');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const episodes = data.episodes || [];
|
const episodes = data.episodes || [];
|
||||||
|
|
||||||
if (!append) listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
|
if (!episodes.length) {
|
||||||
if (!episodes.length && !append) {
|
|
||||||
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
|
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
|
||||||
$('inbox-load-more-bar').style.display = 'none';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1591,184 +1383,31 @@ async function loadAndRenderInbox(append = false) {
|
||||||
podcastEpCache[ep.id] = {
|
podcastEpCache[ep.id] = {
|
||||||
id: ep.id,
|
id: ep.id,
|
||||||
title: ep.title,
|
title: ep.title,
|
||||||
description: ep.description || '',
|
|
||||||
audioUrl: ep.audio_url,
|
audioUrl: ep.audio_url,
|
||||||
durationSeconds: ep.duration_seconds,
|
durationSeconds: ep.duration_seconds,
|
||||||
positionSeconds: ep.position_seconds || 0,
|
positionSeconds: 0,
|
||||||
feedId: ep['feed__id'],
|
feedId: ep['feed__id'],
|
||||||
played: false,
|
played: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0)
|
|
||||||
? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0;
|
|
||||||
const dur = formatDuration(ep.duration_seconds);
|
|
||||||
const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
|
|
||||||
const inQueue = ep.in_queue;
|
|
||||||
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'episode-item';
|
div.className = 'episode-item';
|
||||||
div.dataset.epId = ep.id;
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<label class="inbox-checkbox-label">
|
|
||||||
<input type="checkbox" class="inbox-cb" data-ep-id="${ep.id}" onchange="inboxOnCheck()">
|
|
||||||
</label>
|
|
||||||
${ep['feed__artwork_url'] ? `<img class="podcast-thumb" src="${escapeHtml(ep['feed__artwork_url'])}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
|
${ep['feed__artwork_url'] ? `<img class="podcast-thumb" src="${escapeHtml(ep['feed__artwork_url'])}" alt="">` : '<div class="podcast-thumb-placeholder"></div>'}
|
||||||
<div class="episode-info">
|
<div class="episode-info">
|
||||||
<div class="episode-title ep-clickable" onclick="openEpisodeSidebar(${ep.id})" title="Show notes">${escapeHtml(ep.title)}</div>
|
<div class="episode-title">${escapeHtml(ep.title)}</div>
|
||||||
<div class="episode-meta">
|
<div class="muted">${escapeHtml(ep['feed__title'])} · ${ep.pub_date ? ep.pub_date.slice(0,10) : ''} · ${formatDuration(ep.duration_seconds)}</div>
|
||||||
<span class="episode-date episode-feed-link" onclick="openFeed(${ep['feed__id']})" title="Open feed">${escapeHtml(ep['feed__title'])}</span>
|
|
||||||
${dateStr ? `<span class="episode-dur">${escapeHtml(dateStr)}</span>` : ''}
|
|
||||||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="episode-actions">
|
<div class="episode-actions">
|
||||||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
|
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${ep.id})">▶</button>
|
||||||
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="${inQueue ? 'In queue' : 'Add to queue'}">${inQueue ? '✓Q' : '+Q'}</button>
|
<button class="btn btn-sm" onclick="queueAddEpisode(${ep.id})" title="Add to queue">+Q</button>
|
||||||
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
|
<button class="btn btn-sm" onclick="downloadEpisodeById(${ep.id}, this)" title="Download">⬇</button>
|
||||||
<button class="btn btn-sm btn-danger" onclick="inboxDismissOne(${ep.id}, this)" title="Dismiss">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
listEl.appendChild(div);
|
listEl.appendChild(div);
|
||||||
});
|
});
|
||||||
|
|
||||||
_inboxOffset += episodes.length;
|
|
||||||
const moreBar = $('inbox-load-more-bar');
|
|
||||||
const countLabel = $('inbox-count-label');
|
|
||||||
if (episodes.length === _inboxPageSize) {
|
|
||||||
moreBar.style.display = '';
|
|
||||||
if (countLabel) countLabel.textContent = `${_inboxOffset} loaded`;
|
|
||||||
} else {
|
|
||||||
moreBar.style.display = 'none';
|
|
||||||
if (countLabel) countLabel.textContent = '';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!append) listEl.innerHTML = '<p class="muted">Failed to load inbox.</p>';
|
listEl.innerHTML = '<p class="muted">Failed to load inbox.</p>';
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function inboxLoadMore() {
|
|
||||||
loadAndRenderInbox(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inboxGetSelectedIds() {
|
|
||||||
return Array.from(document.querySelectorAll('.inbox-cb:checked'))
|
|
||||||
.map(cb => parseInt(cb.dataset.epId, 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
function inboxOnCheck() {
|
|
||||||
inboxUpdateBulkBar();
|
|
||||||
// sync select-all state
|
|
||||||
const all = document.querySelectorAll('.inbox-cb');
|
|
||||||
const checked = document.querySelectorAll('.inbox-cb:checked');
|
|
||||||
const selectAll = $('inbox-select-all');
|
|
||||||
if (selectAll) {
|
|
||||||
selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
|
|
||||||
selectAll.checked = all.length > 0 && checked.length === all.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function inboxSelectAll(checked) {
|
|
||||||
document.querySelectorAll('.inbox-cb').forEach(cb => { cb.checked = checked; });
|
|
||||||
inboxUpdateBulkBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function inboxUpdateBulkBar() {
|
|
||||||
const ids = inboxGetSelectedIds();
|
|
||||||
const bar = $('inbox-bulk-actions');
|
|
||||||
const countEl = $('inbox-selection-count');
|
|
||||||
if (!bar) return;
|
|
||||||
if (ids.length > 0) {
|
|
||||||
bar.style.display = '';
|
|
||||||
if (countEl) countEl.textContent = `${ids.length} selected`;
|
|
||||||
} else {
|
|
||||||
bar.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inboxBulkDismiss() {
|
|
||||||
const ids = inboxGetSelectedIds();
|
|
||||||
if (!ids.length) return;
|
|
||||||
try {
|
|
||||||
await fetch('/podcasts/progress/dismiss/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({episode_ids: ids, dismissed: true}),
|
|
||||||
});
|
|
||||||
ids.forEach(id => {
|
|
||||||
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
|
|
||||||
if (div) div.remove();
|
|
||||||
});
|
|
||||||
inboxUpdateBulkBar();
|
|
||||||
const selectAll = $('inbox-select-all');
|
|
||||||
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
|
|
||||||
if (!document.querySelector('.inbox-cb')) {
|
|
||||||
const listEl = $('podcast-inbox-list');
|
|
||||||
if (listEl && !listEl.querySelector('.episode-item')) {
|
|
||||||
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inboxDismissOne(epId, btn) {
|
|
||||||
try {
|
|
||||||
await fetch('/podcasts/progress/dismiss/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({episode_ids: [epId], dismissed: true}),
|
|
||||||
});
|
|
||||||
const div = document.querySelector(`.episode-item[data-ep-id="${epId}"]`);
|
|
||||||
if (div) div.remove();
|
|
||||||
if (!document.querySelector('.inbox-cb')) {
|
|
||||||
const listEl = $('podcast-inbox-list');
|
|
||||||
if (listEl && !listEl.querySelector('.episode-item')) {
|
|
||||||
listEl.innerHTML = '<p class="muted">Inbox empty — all caught up!</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inboxBulkQueueAdd() {
|
|
||||||
const ids = inboxGetSelectedIds();
|
|
||||||
for (const id of ids) {
|
|
||||||
await queueAddEpisode(id);
|
|
||||||
}
|
|
||||||
// Update queue button states
|
|
||||||
ids.forEach(id => {
|
|
||||||
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
|
|
||||||
if (div) {
|
|
||||||
const qBtn = div.querySelector('.episode-actions .btn-sm:nth-child(2)');
|
|
||||||
if (qBtn) { qBtn.textContent = '✓Q'; qBtn.title = 'In queue'; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inboxBulkMarkPlayed() {
|
|
||||||
const ids = inboxGetSelectedIds();
|
|
||||||
try {
|
|
||||||
for (const id of ids) {
|
|
||||||
await fetch('/podcasts/progress/mark-played/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({episode_id: id, played: true}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ids.forEach(id => {
|
|
||||||
const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
|
|
||||||
if (div) div.remove();
|
|
||||||
});
|
|
||||||
inboxUpdateBulkBar();
|
|
||||||
const selectAll = $('inbox-select-all');
|
|
||||||
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inboxBulkDownload() {
|
|
||||||
const ids = inboxGetSelectedIds();
|
|
||||||
for (const id of ids) {
|
|
||||||
const ep = podcastEpCache[id];
|
|
||||||
if (ep) await downloadEpisode(ep.audioUrl, ep.title, null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1796,28 +1435,17 @@ async function loadAndRenderQueue() {
|
||||||
title: item['episode__title'],
|
title: item['episode__title'],
|
||||||
audioUrl: item['episode__audio_url'],
|
audioUrl: item['episode__audio_url'],
|
||||||
durationSeconds: item['episode__duration_seconds'],
|
durationSeconds: item['episode__duration_seconds'],
|
||||||
positionSeconds: item['position_seconds'] || 0,
|
positionSeconds: 0,
|
||||||
feedId: item['episode__feed__id'],
|
feedId: item['episode__feed__id'],
|
||||||
played: false,
|
played: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressPct = (item['episode__duration_seconds'] > 0 && item['position_seconds'] > 0)
|
|
||||||
? Math.min(100, (item['position_seconds'] / item['episode__duration_seconds']) * 100) : 0;
|
|
||||||
const dur = formatDuration(item['episode__duration_seconds']);
|
|
||||||
|
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'episode-item';
|
li.className = 'episode-item';
|
||||||
li.draggable = true;
|
|
||||||
li.dataset.epId = epId;
|
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<span class="drag-handle">⠿</span>
|
|
||||||
<div class="episode-info">
|
<div class="episode-info">
|
||||||
<div class="episode-title">${escapeHtml(item['episode__title'])}</div>
|
<div class="episode-title">${escapeHtml(item['episode__title'])}</div>
|
||||||
<div class="episode-meta">
|
<div class="muted">${escapeHtml(item['episode__feed__title'])} · ${formatDuration(item['episode__duration_seconds'])}</div>
|
||||||
<span class="episode-date episode-feed-link" onclick="openFeed(${item['episode__feed__id']})" title="Open feed">${escapeHtml(item['episode__feed__title'])}</span>
|
|
||||||
${dur !== '0:00' ? `<span class="episode-dur">${escapeHtml(dur)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
${progressPct > 0 ? `<div class="episode-progress-bar"><div class="episode-progress-fill" style="width:${progressPct.toFixed(1)}%"></div></div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="episode-actions">
|
<div class="episode-actions">
|
||||||
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${epId})">▶</button>
|
<button class="btn btn-sm btn-play" onclick="playEpisodeById(${epId})">▶</button>
|
||||||
|
|
@ -1825,10 +1453,6 @@ async function loadAndRenderQueue() {
|
||||||
<button class="btn btn-sm btn-danger" onclick="queueRemoveEpisode(${epId})">✕</button>
|
<button class="btn btn-sm btn-danger" onclick="queueRemoveEpisode(${epId})">✕</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
li.addEventListener('dragstart', queueDragStart);
|
|
||||||
li.addEventListener('dragover', queueDragOver);
|
|
||||||
li.addEventListener('drop', queueDrop);
|
|
||||||
li.addEventListener('dragend', queueDragEnd);
|
|
||||||
ol.appendChild(li);
|
ol.appendChild(li);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -1836,36 +1460,6 @@ async function loadAndRenderQueue() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueDragStart(e) {
|
|
||||||
_dragSrcEl = this;
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
this.classList.add('dragging');
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueDragOver(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const ol = document.getElementById('podcast-queue-ol');
|
|
||||||
const dragging = ol.querySelector('.dragging');
|
|
||||||
if (!dragging || dragging === this) return;
|
|
||||||
const rect = this.getBoundingClientRect();
|
|
||||||
ol.insertBefore(dragging, e.clientY < rect.top + rect.height / 2 ? this : this.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueDrop(e) { e.preventDefault(); }
|
|
||||||
|
|
||||||
function queueDragEnd() {
|
|
||||||
this.classList.remove('dragging');
|
|
||||||
_dragSrcEl = null;
|
|
||||||
const ol = document.getElementById('podcast-queue-ol');
|
|
||||||
const newOrder = Array.from(ol.querySelectorAll('li[data-ep-id]'))
|
|
||||||
.map(li => parseInt(li.dataset.epId, 10));
|
|
||||||
fetch('/podcasts/queue/reorder/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({order: newOrder}),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queueAddEpisode(id) {
|
async function queueAddEpisode(id) {
|
||||||
try {
|
try {
|
||||||
await fetch('/podcasts/queue/add/', {
|
await fetch('/podcasts/queue/add/', {
|
||||||
|
|
@ -1920,64 +1514,6 @@ async function refreshFeed(feedId) {
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshOpenFeed(btn) {
|
|
||||||
if (!podcastCurrentFeedId) return;
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = '↻ …'; }
|
|
||||||
try {
|
|
||||||
const res = await fetch('/podcasts/feeds/refresh/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({feed_id: podcastCurrentFeedId}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.ok) {
|
|
||||||
await openFeed(podcastCurrentFeedId);
|
|
||||||
// openFeed re-renders the header, btn reference is stale — nothing to restore
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = '↻ Refresh'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAllFeedsBtn(btn) {
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = '↻ 0/' + podcastFeeds.length; }
|
|
||||||
let done = 0;
|
|
||||||
for (const feed of podcastFeeds) {
|
|
||||||
try {
|
|
||||||
await fetch('/podcasts/feeds/refresh/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({feed_id: feed.id}),
|
|
||||||
});
|
|
||||||
} catch (e) {}
|
|
||||||
done++;
|
|
||||||
if (btn) btn.textContent = `↻ ${done}/${podcastFeeds.length}`;
|
|
||||||
}
|
|
||||||
await loadFeedList();
|
|
||||||
if (podcastCurrentView === 'feeds') renderFeedList();
|
|
||||||
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
|
|
||||||
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = '↻ All'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAllFeeds() {
|
|
||||||
if (!IS_AUTHENTICATED || !podcastFeeds.length) return;
|
|
||||||
for (const feed of podcastFeeds) {
|
|
||||||
try {
|
|
||||||
await fetch('/podcasts/feeds/refresh/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({feed_id: feed.id}),
|
|
||||||
});
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
// Reload feed metadata and refresh the currently open view
|
|
||||||
await loadFeedList();
|
|
||||||
if (podcastCurrentView === 'feeds') renderFeedList();
|
|
||||||
if (podcastCurrentView === 'inbox') loadAndRenderInbox();
|
|
||||||
if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeFeed(feedId) {
|
async function removeFeed(feedId) {
|
||||||
if (!confirm('Remove this podcast?')) return;
|
if (!confirm('Remove this podcast?')) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -2277,20 +1813,16 @@ function hexToBytes(hex) {
|
||||||
|
|
||||||
async function getOrCreateEncKey() {
|
async function getOrCreateEncKey() {
|
||||||
if (_encKey) return _encKey;
|
if (_encKey) return _encKey;
|
||||||
const storageKey = `diora_enc_key_${window.USER_ID || 0}`;
|
const storageKey = `diora_enc_key_${typeof USER_ID !== 'undefined' ? USER_ID : 'anon'}`;
|
||||||
const stored = localStorage.getItem(storageKey);
|
const stored = localStorage.getItem(storageKey);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
const raw = base64ToBytes(stored);
|
const raw = base64ToBytes(stored);
|
||||||
_encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
_encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
||||||
return _encKey;
|
return _encKey;
|
||||||
} catch (e) { /* fall through, generate new */ }
|
} catch (e) { /* fall through */ }
|
||||||
}
|
}
|
||||||
// No key found — generate one and store it
|
throw new Error('No encryption key found. Please log out and log in again to unlock encrypted content.');
|
||||||
_encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
|
||||||
const raw = await crypto.subtle.exportKey('raw', _encKey);
|
|
||||||
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
|
||||||
return _encKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encryptBytes(key, plainBytes) {
|
async function encryptBytes(key, plainBytes) {
|
||||||
|
|
@ -2594,7 +2126,6 @@ let readerSearchOpen = false;
|
||||||
let pdfCurrentPage = 1;
|
let pdfCurrentPage = 1;
|
||||||
let pdfTotalPages = 0;
|
let pdfTotalPages = 0;
|
||||||
let _pdfPageTextBoxCache = {};
|
let _pdfPageTextBoxCache = {};
|
||||||
let _pdfRenderGen = 0;
|
|
||||||
let _touchStartX = 0;
|
let _touchStartX = 0;
|
||||||
|
|
||||||
if (typeof pdfjsLib !== 'undefined') {
|
if (typeof pdfjsLib !== 'undefined') {
|
||||||
|
|
@ -2609,56 +2140,32 @@ async function loadBookList() {
|
||||||
if (!IS_AUTHENTICATED) return;
|
if (!IS_AUTHENTICATED) return;
|
||||||
const listEl = $('book-list');
|
const listEl = $('book-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
listEl.innerHTML = '<p class="muted">Loading books…</p>';
|
listEl.innerHTML = '<p class="muted">Loading…</p>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
listEl.innerHTML = '<p class="muted">Fetching book list from server…</p>';
|
const res = await fetch('/books/');
|
||||||
const res = await fetch('/books/', {cache: 'no-store'});
|
|
||||||
if (!res.ok) {
|
|
||||||
listEl.innerHTML = `<p class="muted">Server error ${res.status} loading books.</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const books = await res.json();
|
const books = await res.json();
|
||||||
if (!Array.isArray(books)) {
|
|
||||||
listEl.innerHTML = `<p class="muted">Unexpected response from server (not an array).</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!books.length) {
|
if (!books.length) {
|
||||||
listEl.innerHTML = '<p class="muted">No books yet. Drop an .epub or .pdf above.</p>';
|
listEl.innerHTML = '<p class="muted">No books yet. Drop an .epub or .pdf above.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
listEl.innerHTML = `<p class="muted">Found ${books.length} book(s) on server. Decrypting…</p>`;
|
const key = await getOrCreateEncKey();
|
||||||
|
|
||||||
let key;
|
|
||||||
try {
|
|
||||||
key = await getOrCreateEncKey();
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = `<p class="muted">Encryption not available: ${e.message}. Make sure you are on HTTPS.</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decrypted = [];
|
const decrypted = [];
|
||||||
for (const b of books) {
|
for (const b of books) {
|
||||||
try {
|
try {
|
||||||
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||||||
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
||||||
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'};
|
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'};
|
||||||
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true});
|
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'};
|
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'};
|
||||||
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false});
|
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
decrypted.sort((a, b) => {
|
|
||||||
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
|
|
||||||
if (a.last_read) return -1;
|
|
||||||
if (b.last_read) return 1;
|
|
||||||
return b.uploaded_at.localeCompare(a.uploaded_at);
|
|
||||||
});
|
|
||||||
renderBookList(decrypted);
|
renderBookList(decrypted);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (listEl) listEl.innerHTML = `<p class="muted">Error loading books: ${e.message}</p>`;
|
if (listEl) listEl.innerHTML = '<p class="muted">Failed to load books.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2668,15 +2175,14 @@ function renderBookList(books) {
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const b of books) {
|
for (const b of books) {
|
||||||
const pct = Math.round((b.scroll_fraction || 0) * 100);
|
const pct = Math.round((b.scroll_fraction || 0) * 100);
|
||||||
const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠️ wrong key</span>' : '';
|
|
||||||
html += `<div class="book-item">
|
html += `<div class="book-item">
|
||||||
<div class="book-item-info">
|
<div class="book-item-info">
|
||||||
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}</strong>
|
<strong class="book-title">${escapeHtml(b.title)}</strong>
|
||||||
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
||||||
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="book-item-actions">
|
<div class="book-item-actions">
|
||||||
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
|
<button class="btn btn-sm" onclick="openBook(${b.id})">Open</button>
|
||||||
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
|
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -2692,13 +2198,6 @@ function bookFileSelected(input) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function initBookDropZone() {
|
function initBookDropZone() {
|
||||||
// Prevent Firefox from opening dragged files when dropped outside the zone
|
|
||||||
document.addEventListener('dragover', e => e.preventDefault());
|
|
||||||
document.addEventListener('drop', e => {
|
|
||||||
const zone = $('book-drop-zone');
|
|
||||||
if (!zone || !zone.contains(e.target)) e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
const zone = $('book-drop-zone');
|
const zone = $('book-drop-zone');
|
||||||
if (!zone) return;
|
if (!zone) return;
|
||||||
|
|
||||||
|
|
@ -2715,36 +2214,6 @@ function initBookDropZone() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deriveAndStoreKey() {
|
|
||||||
const pwInput = document.getElementById('enc-key-password');
|
|
||||||
const statusEl = $('enc-key-status');
|
|
||||||
const pw = pwInput ? pwInput.value : '';
|
|
||||||
if (!pw) { if (statusEl) statusEl.textContent = 'Please enter your password.'; return; }
|
|
||||||
|
|
||||||
if (statusEl) statusEl.textContent = 'Deriving key…';
|
|
||||||
try {
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const username = document.querySelector('meta[name="username"]')?.content || '';
|
|
||||||
const mat = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']);
|
|
||||||
const key = await crypto.subtle.deriveKey(
|
|
||||||
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
|
|
||||||
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
const raw = await crypto.subtle.exportKey('raw', key);
|
|
||||||
const storageKey = `diora_enc_key_${window.USER_ID || 0}`;
|
|
||||||
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
|
||||||
_encKey = null; // reset cached key
|
|
||||||
if (statusEl) statusEl.textContent = '✓ Unlocked';
|
|
||||||
const prompt = $('enc-key-prompt');
|
|
||||||
const uploadArea = $('book-upload-area');
|
|
||||||
if (prompt) prompt.style.display = 'none';
|
|
||||||
if (uploadArea) uploadArea.style.display = '';
|
|
||||||
loadBookList();
|
|
||||||
} catch (err) {
|
|
||||||
if (statusEl) statusEl.textContent = 'Error: ' + err.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadEbook(file) {
|
async function uploadEbook(file) {
|
||||||
const statusEl = $('book-upload-status');
|
const statusEl = $('book-upload-status');
|
||||||
const isPdf = /\.pdf$/i.test(file.name);
|
const isPdf = /\.pdf$/i.test(file.name);
|
||||||
|
|
@ -2843,9 +2312,7 @@ async function _parsePdfOutline(pdf, items, depth) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
|
async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
|
||||||
const myGen = ++_pdfRenderGen;
|
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer)}).promise;
|
||||||
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise;
|
|
||||||
if (_pdfRenderGen !== myGen) return null;
|
|
||||||
currentPdfDoc = pdf;
|
currentPdfDoc = pdf;
|
||||||
|
|
||||||
let pdfTitle = '', pdfAuthor = '';
|
let pdfTitle = '', pdfAuthor = '';
|
||||||
|
|
@ -2863,20 +2330,13 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
|
||||||
|
|
||||||
contentEl.innerHTML = '';
|
contentEl.innerHTML = '';
|
||||||
|
|
||||||
// Viewport wrapper: CSS zoom controls display scale without re-rendering
|
const containerWidth = contentEl.clientWidth - 32;
|
||||||
const pdfVp = document.createElement('div');
|
|
||||||
pdfVp.id = 'pdf-viewport';
|
|
||||||
if (scaleOverride == null) pdfVp.style.zoom = readerSettings.pdfZoom / 100;
|
|
||||||
contentEl.appendChild(pdfVp);
|
|
||||||
|
|
||||||
const containerWidth = Math.min(contentEl.clientWidth - 32, 900);
|
|
||||||
|
|
||||||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||||||
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
|
|
||||||
const page = await pdf.getPage(pageNum);
|
const page = await pdf.getPage(pageNum);
|
||||||
const naturalVp = page.getViewport({scale: 1});
|
const naturalVp = page.getViewport({scale: 1});
|
||||||
const scale = scaleOverride != null ? scaleOverride
|
const scale = scaleOverride != null ? scaleOverride
|
||||||
: Math.max(0.5, containerWidth / naturalVp.width);
|
: Math.max(0.5, (containerWidth / naturalVp.width) * (readerSettings.pdfZoom / 100));
|
||||||
const viewport = page.getViewport({scale});
|
const viewport = page.getViewport({scale});
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
|
|
@ -2894,7 +2354,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
inner.appendChild(canvas);
|
inner.appendChild(canvas);
|
||||||
wrapper.appendChild(inner);
|
wrapper.appendChild(inner);
|
||||||
pdfVp.appendChild(wrapper);
|
contentEl.appendChild(wrapper);
|
||||||
|
|
||||||
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
|
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
|
||||||
|
|
||||||
|
|
@ -3103,8 +2563,7 @@ async function openBook(bookId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
overlay.style.display = 'none';
|
contentEl.innerHTML = `<p class="muted">Failed to open book: ${escapeHtml(e.message)}</p>`;
|
||||||
alert(`Failed to open book: ${e.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3139,7 +2598,7 @@ function showImportKey() {
|
||||||
const raw = base64ToBytes(b64);
|
const raw = base64ToBytes(b64);
|
||||||
const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||||||
const re_exported = await crypto.subtle.exportKey('raw', importedKey);
|
const re_exported = await crypto.subtle.exportKey('raw', importedKey);
|
||||||
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported));
|
localStorage.setItem(`diora_enc_key_${USER_ID}`, bytesToBase64(re_exported));
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
if (statusEl) statusEl.textContent = '✓ Key imported — reloading books…';
|
if (statusEl) statusEl.textContent = '✓ Key imported — reloading books…';
|
||||||
await loadBookList();
|
await loadBookList();
|
||||||
|
|
@ -3440,16 +2899,11 @@ function toggleSettingsPanel() {
|
||||||
} else {
|
} else {
|
||||||
const zoomRange = panel.querySelector('#rs-zoom');
|
const zoomRange = panel.querySelector('#rs-zoom');
|
||||||
const zoomVal = panel.querySelector('#rs-zoom-val');
|
const zoomVal = panel.querySelector('#rs-zoom-val');
|
||||||
zoomRange.addEventListener('input', () => {
|
zoomRange.addEventListener('change', () => {
|
||||||
readerSettings.pdfZoom = parseInt(zoomRange.value, 10);
|
readerSettings.pdfZoom = parseInt(zoomRange.value, 10);
|
||||||
zoomVal.textContent = readerSettings.pdfZoom + '%';
|
zoomVal.textContent = readerSettings.pdfZoom + '%';
|
||||||
saveReaderSettings();
|
saveReaderSettings();
|
||||||
if (readerSettings.pdfPaginated) {
|
reRenderPdf();
|
||||||
pdfSmartZoomPage(pdfCurrentPage);
|
|
||||||
} else {
|
|
||||||
const vp = document.getElementById('pdf-viewport');
|
|
||||||
if (vp) vp.style.zoom = readerSettings.pdfZoom / 100;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
panel.querySelector('#rs-invert').addEventListener('click', function () {
|
panel.querySelector('#rs-invert').addEventListener('click', function () {
|
||||||
|
|
@ -3490,8 +2944,6 @@ function enterPdfPaginatedMode() {
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
contentEl.classList.add('pdf-paginated');
|
contentEl.classList.add('pdf-paginated');
|
||||||
contentEl.style.overflow = 'hidden';
|
contentEl.style.overflow = 'hidden';
|
||||||
const pdfVp = document.getElementById('pdf-viewport');
|
|
||||||
if (pdfVp) pdfVp.style.zoom = 1;
|
|
||||||
|
|
||||||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||||||
wrappers.forEach((w, i) => {
|
wrappers.forEach((w, i) => {
|
||||||
|
|
@ -3516,8 +2968,6 @@ function exitPdfPaginatedMode() {
|
||||||
const canvas = w.querySelector('canvas');
|
const canvas = w.querySelector('canvas');
|
||||||
if (canvas) canvas.style.transform = '';
|
if (canvas) canvas.style.transform = '';
|
||||||
});
|
});
|
||||||
const pdfVp = document.getElementById('pdf-viewport');
|
|
||||||
if (pdfVp) pdfVp.style.zoom = readerSettings.pdfZoom / 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pdfPaginatedClick(e) {
|
function _pdfPaginatedClick(e) {
|
||||||
|
|
@ -3574,7 +3024,7 @@ async function pdfSmartZoomPage(pageNum) {
|
||||||
const scale = Math.min(
|
const scale = Math.min(
|
||||||
(containerW - pad * 2) / contentW,
|
(containerW - pad * 2) / contentW,
|
||||||
(containerH - pad * 2) / contentH
|
(containerH - pad * 2) / contentH
|
||||||
) * (readerSettings.pdfZoom / 100);
|
);
|
||||||
|
|
||||||
// Re-render canvas at new scale if significantly different
|
// Re-render canvas at new scale if significantly different
|
||||||
const currentScale = canvas.width / naturalVp.width;
|
const currentScale = canvas.width / naturalVp.width;
|
||||||
|
|
@ -4371,10 +3821,10 @@ async function saveFocusStation(url, name) {
|
||||||
|
|
||||||
(function init() {
|
(function init() {
|
||||||
// Migrate PBKDF2-derived key stored by login/register form
|
// Migrate PBKDF2-derived key stored by login/register form
|
||||||
if (window.USER_ID) {
|
if (typeof USER_ID !== 'undefined' && USER_ID) {
|
||||||
const pending = localStorage.getItem('diora_pending_enc_key');
|
const pending = localStorage.getItem('diora_pending_enc_key');
|
||||||
if (pending) {
|
if (pending) {
|
||||||
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, pending);
|
localStorage.setItem(`diora_enc_key_${USER_ID}`, pending);
|
||||||
localStorage.removeItem('diora_pending_enc_key');
|
localStorage.removeItem('diora_pending_enc_key');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4440,12 +3890,7 @@ async function saveFocusStation(url, name) {
|
||||||
|
|
||||||
// Restore last active tab
|
// Restore last active tab
|
||||||
const savedTab = localStorage.getItem('diora_active_tab') || 'radio';
|
const savedTab = localStorage.getItem('diora_active_tab') || 'radio';
|
||||||
const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved';
|
const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'search';
|
||||||
showTab(savedTab);
|
showTab(savedTab);
|
||||||
showRadioTab(savedRadioTab);
|
showRadioTab(savedRadioTab);
|
||||||
|
|
||||||
// Hourly background feed refresh (only when authenticated)
|
|
||||||
if (IS_AUTHENTICATED) {
|
|
||||||
setInterval(refreshAllFeeds, 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v7';
|
const CACHE = 'diora-v2';
|
||||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
'/',
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app.js',
|
'/static/js/app.js',
|
||||||
'/static/manifest.json',
|
'/static/manifest.json',
|
||||||
|
|
@ -71,13 +72,19 @@ self.addEventListener('fetch', function (event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache-first only for pre-defined shell assets; everything else hits the network
|
event.respondWith(
|
||||||
const isShell = SHELL.some(function (s) { return url.pathname === s; });
|
caches.match(event.request).then(function (cached) {
|
||||||
if (isShell) {
|
if (cached) return cached;
|
||||||
event.respondWith(
|
return fetch(event.request).then(function (response) {
|
||||||
caches.match(event.request).then(function (cached) {
|
// Cache successful GET responses for shell assets
|
||||||
return cached || fetch(event.request);
|
if (response && response.status === 200 && response.type === 'basic') {
|
||||||
})
|
const clone = response.clone();
|
||||||
);
|
caches.open(CACHE).then(function (cache) {
|
||||||
}
|
cache.put(event.request, clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="diora">
|
<meta name="apple-mobile-web-app-title" content="diora">
|
||||||
<meta name="description" content="Internet radio player">
|
<meta name="description" content="Internet radio player">
|
||||||
{% if user.is_authenticated %}<meta name="username" content="{{ user.username }}">{% endif %}
|
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
|
@ -48,7 +47,6 @@
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% if BUILD_TIME %}<span class="build-time">{{ BUILD_TIME|slice:":16"|cut:"T" }}</span>{% endif %}
|
|
||||||
<script src="/static/js/jszip.min.js"></script>
|
<script src="/static/js/jszip.min.js"></script>
|
||||||
<script src="/static/js/pdf.min.js"></script>
|
<script src="/static/js/pdf.min.js"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,8 @@
|
||||||
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
|
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
|
||||||
<button class="speed-btn" onclick="setPlaybackRate(1.25)">1¼×</button>
|
<button class="speed-btn" onclick="setPlaybackRate(1.25)">1¼×</button>
|
||||||
<button class="speed-btn" onclick="setPlaybackRate(1.5)">1½×</button>
|
<button class="speed-btn" onclick="setPlaybackRate(1.5)">1½×</button>
|
||||||
<button class="speed-btn" onclick="setPlaybackRate(1.75)">1¾×</button>
|
|
||||||
<button class="speed-btn" onclick="setPlaybackRate(2)">2×</button>
|
<button class="speed-btn" onclick="setPlaybackRate(2)">2×</button>
|
||||||
<button class="speed-btn" onclick="setPlaybackRate(2.5)">2½×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon sleep-timer-btn" id="sleep-timer-btn" onclick="openSleepTimerMenu()" title="Sleep timer">Sleep</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="timer-widget" id="timer-widget">
|
<div class="timer-widget" id="timer-widget">
|
||||||
<span class="timer-phase" id="timer-phase-label">focus</span>
|
<span class="timer-phase" id="timer-phase-label">focus</span>
|
||||||
|
|
@ -71,13 +68,13 @@
|
||||||
<!-- ===== RADIO TAB ===== -->
|
<!-- ===== RADIO TAB ===== -->
|
||||||
<section class="tab-panel" id="tab-radio">
|
<section class="tab-panel" id="tab-radio">
|
||||||
<div class="tabs sub-tabs" id="radio-sub-tabs">
|
<div class="tabs sub-tabs" id="radio-sub-tabs">
|
||||||
<button class="tab-btn" onclick="showRadioTab('search')">Search</button>
|
<button class="tab-btn active" onclick="showRadioTab('search')">Search</button>
|
||||||
<button class="tab-btn active" onclick="showRadioTab('saved')">Saved</button>
|
<button class="tab-btn" onclick="showRadioTab('saved')">Saved</button>
|
||||||
<button class="tab-btn" onclick="showRadioTab('history')">History</button>
|
<button class="tab-btn" onclick="showRadioTab('history')">History</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== SEARCH SUB-PANEL ===== -->
|
<!-- ===== SEARCH SUB-PANEL ===== -->
|
||||||
<div class="sub-tab-panel" id="tab-search" style="display:none;">
|
<div class="sub-tab-panel" id="tab-search">
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()">
|
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()">
|
||||||
<button class="btn" onclick="doSearch()">Search</button>
|
<button class="btn" onclick="doSearch()">Search</button>
|
||||||
|
|
@ -100,7 +97,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== SAVED SUB-PANEL ===== -->
|
<!-- ===== SAVED SUB-PANEL ===== -->
|
||||||
<div class="sub-tab-panel" id="tab-saved">
|
<div class="sub-tab-panel" id="tab-saved" style="display:none;">
|
||||||
{% if featured_stations %}
|
{% if featured_stations %}
|
||||||
<div class="featured-section">
|
<div class="featured-section">
|
||||||
<p class="featured-label">★ Featured</p>
|
<p class="featured-label">★ Featured</p>
|
||||||
|
|
@ -231,7 +228,6 @@
|
||||||
<button class="btn btn-sm" onclick="showPodcastView('inbox')">Inbox</button>
|
<button class="btn btn-sm" onclick="showPodcastView('inbox')">Inbox</button>
|
||||||
<button class="btn btn-sm" onclick="showPodcastView('queue')">Queue</button>
|
<button class="btn btn-sm" onclick="showPodcastView('queue')">Queue</button>
|
||||||
<button class="btn btn-sm" onclick="podcastSearchOpen()">+ Search</button>
|
<button class="btn btn-sm" onclick="podcastSearchOpen()">+ Search</button>
|
||||||
<button class="btn btn-sm" id="refresh-all-btn" onclick="refreshAllFeedsBtn(this)" title="Refresh all feeds">↻ All</button>
|
|
||||||
<label class="btn btn-sm" for="opml-file-input">Import OPML</label>
|
<label class="btn btn-sm" for="opml-file-input">Import OPML</label>
|
||||||
<input type="file" id="opml-file-input" accept=".opml,.xml" style="display:none;" onchange="importOPML(this)">
|
<input type="file" id="opml-file-input" accept=".opml,.xml" style="display:none;" onchange="importOPML(this)">
|
||||||
<span id="opml-status" class="muted"></span>
|
<span id="opml-status" class="muted"></span>
|
||||||
|
|
@ -250,16 +246,6 @@
|
||||||
|
|
||||||
<!-- Feeds pane -->
|
<!-- Feeds pane -->
|
||||||
<div class="podcast-pane" id="podcast-feeds-pane">
|
<div class="podcast-pane" id="podcast-feeds-pane">
|
||||||
<div class="feed-list-toolbar">
|
|
||||||
<input type="text" id="feed-filter-input" class="search-input" placeholder="Search subscriptions…"
|
|
||||||
oninput="filterFeeds(this.value)">
|
|
||||||
<select id="feed-sort-select" onchange="sortFeeds(this.value)" class="feed-sort-select">
|
|
||||||
<option value="alpha">A–Z</option>
|
|
||||||
<option value="alpha-desc">Z–A</option>
|
|
||||||
<option value="added">Recently added</option>
|
|
||||||
<option value="latest_episode">Most recent episode</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="podcast-feed-list" class="podcast-feed-list">
|
<div id="podcast-feed-list" class="podcast-feed-list">
|
||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -267,32 +253,11 @@
|
||||||
|
|
||||||
<!-- Inbox pane -->
|
<!-- Inbox pane -->
|
||||||
<div class="podcast-pane" id="podcast-inbox-pane" style="display:none;">
|
<div class="podcast-pane" id="podcast-inbox-pane" style="display:none;">
|
||||||
<div class="inbox-toolbar">
|
|
||||||
<label class="inbox-select-all-label">
|
|
||||||
<input type="checkbox" id="inbox-select-all" onchange="inboxSelectAll(this.checked)">
|
|
||||||
<span>All</span>
|
|
||||||
</label>
|
|
||||||
<div class="inbox-bulk-actions" id="inbox-bulk-actions" style="display:none;">
|
|
||||||
<span id="inbox-selection-count" class="muted"></span>
|
|
||||||
<button class="btn btn-sm" onclick="inboxBulkQueueAdd()">+Queue</button>
|
|
||||||
<button class="btn btn-sm" onclick="inboxBulkMarkPlayed()">✓ Played</button>
|
|
||||||
<button class="btn btn-sm" onclick="inboxBulkDownload()">⬇ Download</button>
|
|
||||||
<button class="btn btn-sm btn-danger" onclick="inboxBulkDismiss()">✕ Dismiss</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="podcast-inbox-list" class="episode-list"></div>
|
<div id="podcast-inbox-list" class="episode-list"></div>
|
||||||
<div class="inbox-load-more" id="inbox-load-more-bar" style="display:none;">
|
|
||||||
<button class="btn btn-sm" onclick="inboxLoadMore()">Load more</button>
|
|
||||||
<span id="inbox-count-label" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Episodes pane -->
|
<!-- Episodes pane -->
|
||||||
<div class="podcast-pane" id="podcast-episodes-pane" style="display:none;">
|
<div class="podcast-pane" id="podcast-episodes-pane" style="display:none;">
|
||||||
<div class="episode-search-bar" id="episode-search-bar" style="display:none;">
|
|
||||||
<input type="text" id="episode-filter-input" class="search-input"
|
|
||||||
placeholder="Filter episodes…" oninput="filterEpisodes(this.value)">
|
|
||||||
</div>
|
|
||||||
<div id="podcast-feed-header" class="podcast-feed-header"></div>
|
<div id="podcast-feed-header" class="podcast-feed-header"></div>
|
||||||
<div id="podcast-episode-list" class="episode-list"></div>
|
<div id="podcast-episode-list" class="episode-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -313,12 +278,10 @@
|
||||||
<!-- ===== BOOKS TAB ===== -->
|
<!-- ===== BOOKS TAB ===== -->
|
||||||
<section class="tab-panel" id="tab-books" style="display:none;">
|
<section class="tab-panel" id="tab-books" style="display:none;">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div id="book-upload-area">
|
<div class="book-drop-zone" id="book-drop-zone">
|
||||||
<div class="book-drop-zone" id="book-drop-zone">
|
<span>Drop .epub or .pdf here or <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">browse</label></span>
|
||||||
<span>Drop .epub or .pdf here or <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">browse</label></span>
|
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
||||||
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
<span id="book-upload-status" class="muted"></span>
|
||||||
<span id="book-upload-status" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="book-list" class="book-list"></div>
|
<div id="book-list" class="book-list"></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -364,11 +327,11 @@
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
// Pass Django context into JS
|
// Pass Django context into JS
|
||||||
const INITIAL_SAVED = {{ saved_stations_json|safe }};
|
const INITIAL_SAVED = {{ saved_stations|safe }};
|
||||||
const INITIAL_FEATURED = {{ featured_stations_json|safe }};
|
const INITIAL_FEATURED = {{ featured_stations|safe }};
|
||||||
const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }};
|
const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }};
|
||||||
const INITIAL_PODCAST_FEEDS = {{ initial_podcast_feeds|safe }};
|
const INITIAL_PODCAST_FEEDS = {{ initial_podcast_feeds|safe }};
|
||||||
window.USER_ID = {{ user.id|default:"null" }};
|
const USER_ID = {{ user.id|default:"null" }};
|
||||||
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
|
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue