Compare commits
2 commits
0d5ab9a177
...
2bd83f6315
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd83f6315 | ||
|
|
6d391587c8 |
41 changed files with 5188 additions and 171 deletions
18
Dockerfile.cron
Normal file
18
Dockerfile.cron
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends dcron && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Write cron job: refresh podcast feeds every hour
|
||||||
|
RUN echo "0 * * * * root cd /app && python manage.py refresh_feeds >> /var/log/cron.log 2>&1" \
|
||||||
|
> /etc/cron.d/podcast-refresh && \
|
||||||
|
chmod 0644 /etc/cron.d/podcast-refresh && \
|
||||||
|
touch /var/log/cron.log
|
||||||
|
|
||||||
|
CMD ["dcron", "-f"]
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Generated by Django 6.0.3 on 2026-03-19 09:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0002_userprofile_background_image'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='background_encrypted',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='background_iv',
|
||||||
|
field=models.CharField(blank=True, max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='background_mime',
|
||||||
|
field=models.CharField(blank=True, max_length=30),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='focus_station_name',
|
||||||
|
field=models.CharField(blank=True, max_length=300),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='focus_station_url',
|
||||||
|
field=models.URLField(blank=True, max_length=1000),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -9,7 +9,12 @@ class UserProfile(models.Model):
|
||||||
lastfm_session_key = models.CharField(max_length=100, blank=True)
|
lastfm_session_key = models.CharField(max_length=100, blank=True)
|
||||||
lastfm_username = models.CharField(max_length=100, blank=True)
|
lastfm_username = models.CharField(max_length=100, blank=True)
|
||||||
lastfm_scrobble = models.BooleanField(default=True)
|
lastfm_scrobble = models.BooleanField(default=True)
|
||||||
background_image_data = models.TextField(blank=True) # base64 data URL
|
background_image_data = models.TextField(blank=True) # base64 data URL (legacy)
|
||||||
|
background_encrypted = models.TextField(blank=True) # base64 AES-GCM ciphertext
|
||||||
|
background_iv = models.CharField(max_length=32, blank=True) # hex IV
|
||||||
|
background_mime = models.CharField(max_length=30, blank=True) # e.g. 'image/jpeg'
|
||||||
|
focus_station_url = models.URLField(max_length=1000, blank=True)
|
||||||
|
focus_station_name = models.CharField(max_length=300, blank=True)
|
||||||
|
|
||||||
def has_lastfm(self) -> bool:
|
def has_lastfm(self) -> bool:
|
||||||
return bool(self.lastfm_session_key)
|
return bool(self.lastfm_session_key)
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ urlpatterns = [
|
||||||
path('lastfm/disconnect/', views.lastfm_disconnect, name='lastfm_disconnect'),
|
path('lastfm/disconnect/', views.lastfm_disconnect, name='lastfm_disconnect'),
|
||||||
path('background/upload/', views.upload_background, name='upload_background'),
|
path('background/upload/', views.upload_background, name='upload_background'),
|
||||||
path('background/delete/', views.delete_background, name='delete_background'),
|
path('background/delete/', views.delete_background, name='delete_background'),
|
||||||
|
path('focus-station/', views.save_focus_station, name='save_focus_station'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import authenticate, login, get_user_model
|
from django.contrib.auth import authenticate, login, get_user_model
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
|
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from radio import lastfm as lastfm_module
|
from radio import lastfm as lastfm_module
|
||||||
|
|
@ -116,27 +118,36 @@ def lastfm_callback(request):
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@csrf_exempt
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def upload_background(request):
|
def upload_background(request):
|
||||||
f = request.FILES.get('file')
|
try:
|
||||||
if not f:
|
body = json.loads(request.body)
|
||||||
return JsonResponse({'error': 'no file'}, status=400)
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
ext = f.name.rsplit('.', 1)[-1].lower() if '.' in f.name else ''
|
iv = body.get('iv', '').strip()
|
||||||
mime_map = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'webp': 'image/webp'}
|
ciphertext = body.get('ciphertext', '').strip()
|
||||||
if ext not in mime_map:
|
mime_type = body.get('mime_type', '').strip()
|
||||||
return JsonResponse({'error': 'only jpg, png, or webp allowed'}, status=400)
|
file_size = int(body.get('file_size', 0))
|
||||||
|
|
||||||
if f.size > settings.BG_MAX_BYTES:
|
if not all([iv, ciphertext, mime_type]):
|
||||||
|
return JsonResponse({'error': 'iv, ciphertext, mime_type required'}, status=400)
|
||||||
|
|
||||||
|
allowed_mimes = {'image/jpeg', 'image/png', 'image/webp'}
|
||||||
|
if mime_type not in allowed_mimes:
|
||||||
|
return JsonResponse({'error': 'only jpeg, png, or webp allowed'}, status=400)
|
||||||
|
|
||||||
|
if file_size > settings.BG_MAX_BYTES:
|
||||||
return JsonResponse({'error': 'file too large (max 5 MB)'}, status=400)
|
return JsonResponse({'error': 'file too large (max 5 MB)'}, status=400)
|
||||||
|
|
||||||
data = base64.b64encode(f.read()).decode('ascii')
|
|
||||||
data_url = f"data:{mime_map[ext]};base64,{data}"
|
|
||||||
|
|
||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
profile.background_image_data = data_url
|
profile.background_image_data = ''
|
||||||
profile.save(update_fields=['background_image_data'])
|
profile.background_encrypted = ciphertext
|
||||||
return JsonResponse({'ok': True, 'url': data_url})
|
profile.background_iv = iv
|
||||||
|
profile.background_mime = mime_type
|
||||||
|
profile.save(update_fields=['background_image_data', 'background_encrypted', 'background_iv', 'background_mime'])
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|
@ -144,10 +155,32 @@ def upload_background(request):
|
||||||
def delete_background(request):
|
def delete_background(request):
|
||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
profile.background_image_data = ''
|
profile.background_image_data = ''
|
||||||
profile.save(update_fields=['background_image_data'])
|
profile.background_encrypted = ''
|
||||||
|
profile.background_iv = ''
|
||||||
|
profile.background_mime = ''
|
||||||
|
profile.save(update_fields=['background_image_data', 'background_encrypted', 'background_iv', 'background_mime'])
|
||||||
return redirect('settings')
|
return redirect('settings')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def save_focus_station(request):
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
url = body.get('url', '').strip()
|
||||||
|
name = body.get('name', '').strip()
|
||||||
|
|
||||||
|
profile = request.user.profile
|
||||||
|
profile.focus_station_url = url
|
||||||
|
profile.focus_station_name = name
|
||||||
|
profile.save(update_fields=['focus_station_url', 'focus_station_name'])
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def lastfm_disconnect(request):
|
def lastfm_disconnect(request):
|
||||||
|
|
|
||||||
0
books/__init__.py
Normal file
0
books/__init__.py
Normal file
5
books/admin.py
Normal file
5
books/admin.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import EBook, EBookProgress
|
||||||
|
|
||||||
|
admin.site.register(EBook)
|
||||||
|
admin.site.register(EBookProgress)
|
||||||
6
books/apps.py
Normal file
6
books/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BooksConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'books'
|
||||||
45
books/migrations/0001_initial.py
Normal file
45
books/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Generated by Django 6.0.3 on 2026-03-19 09:35
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EBook',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('meta_ct', models.TextField()),
|
||||||
|
('meta_iv', models.CharField(max_length=32)),
|
||||||
|
('data_ct', models.TextField()),
|
||||||
|
('data_iv', models.CharField(max_length=32)),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['uploaded_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EBookProgress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('scroll_fraction', models.FloatField(default=0.0)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='books.ebook')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_progress', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'book')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
44
books/migrations/0002_ebookbookmarks_ebookhighlights.py
Normal file
44
books/migrations/0002_ebookbookmarks_ebookhighlights.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Generated by Django 6.0.3 on 2026-03-19 11:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('books', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EBookBookmarks',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ct', models.TextField()),
|
||||||
|
('iv', models.CharField(max_length=32)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.ebook')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_bookmarks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'book')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EBookHighlights',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ct', models.TextField()),
|
||||||
|
('iv', models.CharField(max_length=32)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='highlights', to='books.ebook')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_highlights', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'book')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
books/migrations/__init__.py
Normal file
0
books/migrations/__init__.py
Normal file
58
books/models.py
Normal file
58
books/models.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class EBook(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebooks')
|
||||||
|
meta_ct = models.TextField() # base64 AES-GCM ciphertext of {title, author, filename}
|
||||||
|
meta_iv = models.CharField(max_length=32) # hex IV for metadata
|
||||||
|
data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
|
||||||
|
data_iv = models.CharField(max_length=32) # hex IV for EPUB data
|
||||||
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['uploaded_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"EBook #{self.pk} (user={self.user_id})"
|
||||||
|
|
||||||
|
|
||||||
|
class EBookProgress(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
|
||||||
|
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
|
||||||
|
scroll_fraction = models.FloatField(default=0.0)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'book')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Progress book={self.book_id} user={self.user_id} {self.scroll_fraction:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
class EBookHighlights(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_highlights')
|
||||||
|
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='highlights')
|
||||||
|
ct = models.TextField() # base64 AES-GCM ciphertext of JSON array
|
||||||
|
iv = models.CharField(max_length=32) # hex IV
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'book')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Highlights book={self.book_id} user={self.user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class EBookBookmarks(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_bookmarks')
|
||||||
|
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='bookmarks')
|
||||||
|
ct = models.TextField()
|
||||||
|
iv = models.CharField(max_length=32)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'book')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Bookmarks book={self.book_id} user={self.user_id}"
|
||||||
12
books/urls.py
Normal file
12
books/urls.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.book_list, name='book_list'),
|
||||||
|
path('upload/', views.upload_book, name='upload_book'),
|
||||||
|
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
||||||
|
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
||||||
|
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
||||||
|
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
|
||||||
|
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),
|
||||||
|
]
|
||||||
225
books/views.py
Normal file
225
books/views.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
|
||||||
|
|
||||||
|
|
||||||
|
def _require_auth(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def book_list(request):
|
||||||
|
err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
books = list(
|
||||||
|
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at')
|
||||||
|
)
|
||||||
|
for b in books:
|
||||||
|
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
||||||
|
# Include saved scroll_fraction for each book
|
||||||
|
progress_map = {
|
||||||
|
p.book_id: p.scroll_fraction
|
||||||
|
for p in EBookProgress.objects.filter(user=request.user)
|
||||||
|
}
|
||||||
|
for b in books:
|
||||||
|
b['scroll_fraction'] = progress_map.get(b['id'], 0.0)
|
||||||
|
return JsonResponse(books, safe=False)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def upload_book(request):
|
||||||
|
err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
meta_ct = body.get('meta_ct', '')
|
||||||
|
meta_iv = body.get('meta_iv', '')
|
||||||
|
data_ct = body.get('data_ct', '')
|
||||||
|
data_iv = body.get('data_iv', '')
|
||||||
|
|
||||||
|
if not all([meta_ct, meta_iv, data_ct, data_iv]):
|
||||||
|
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
|
||||||
|
|
||||||
|
# Enforce size limit: ciphertext is plaintext + 16-byte GCM tag
|
||||||
|
max_bytes = getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024) + 32
|
||||||
|
try:
|
||||||
|
raw_size = len(base64.b64decode(data_ct))
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
|
||||||
|
|
||||||
|
if raw_size > max_bytes:
|
||||||
|
return JsonResponse({'error': 'file too large (max 10 MB)'}, status=400)
|
||||||
|
|
||||||
|
book = EBook.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
meta_ct=meta_ct,
|
||||||
|
meta_iv=meta_iv,
|
||||||
|
data_ct=data_ct,
|
||||||
|
data_iv=data_iv,
|
||||||
|
)
|
||||||
|
return JsonResponse({'ok': True, 'id': book.id})
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def get_book_data(request, pk):
|
||||||
|
err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
book = EBook.objects.get(pk=pk, user=request.user)
|
||||||
|
except EBook.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def delete_book(request, pk):
|
||||||
|
err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
book = EBook.objects.get(pk=pk, user=request.user)
|
||||||
|
except EBook.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
book.delete()
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def save_progress(request, pk):
|
||||||
|
err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
book = EBook.objects.get(pk=pk, user=request.user)
|
||||||
|
except EBook.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
scroll_fraction = float(body.get('scroll_fraction', 0.0))
|
||||||
|
scroll_fraction = max(0.0, min(1.0, scroll_fraction))
|
||||||
|
|
||||||
|
progress, _ = EBookProgress.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
book=book,
|
||||||
|
)
|
||||||
|
progress.scroll_fraction = scroll_fraction
|
||||||
|
progress.save(update_fields=['scroll_fraction', 'updated_at'])
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET', 'POST'])
|
||||||
|
def book_highlights(request, pk):
|
||||||
|
err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
book = EBook.objects.get(pk=pk, user=request.user)
|
||||||
|
except EBook.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
try:
|
||||||
|
row = EBookHighlights.objects.get(user=request.user, book=book)
|
||||||
|
return JsonResponse({'ct': row.ct, 'iv': row.iv})
|
||||||
|
except EBookHighlights.DoesNotExist:
|
||||||
|
return JsonResponse({'ct': None, 'iv': None})
|
||||||
|
|
||||||
|
# POST — upsert
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
ct = body.get('ct', '')
|
||||||
|
iv = body.get('iv', '')
|
||||||
|
if not ct or not iv:
|
||||||
|
return JsonResponse({'error': 'ct and iv required'}, status=400)
|
||||||
|
|
||||||
|
# Size guard: highlights ≤ 700 KB base64
|
||||||
|
try:
|
||||||
|
raw_size = len(base64.b64decode(ct))
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
|
||||||
|
if raw_size > 700 * 1024:
|
||||||
|
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
|
||||||
|
|
||||||
|
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
|
||||||
|
row.ct = ct
|
||||||
|
row.iv = iv
|
||||||
|
row.save(update_fields=['ct', 'iv', 'updated_at'])
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['GET', 'POST'])
|
||||||
|
def book_bookmarks(request, pk):
|
||||||
|
err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
book = EBook.objects.get(pk=pk, user=request.user)
|
||||||
|
except EBook.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
try:
|
||||||
|
row = EBookBookmarks.objects.get(user=request.user, book=book)
|
||||||
|
return JsonResponse({'ct': row.ct, 'iv': row.iv})
|
||||||
|
except EBookBookmarks.DoesNotExist:
|
||||||
|
return JsonResponse({'ct': None, 'iv': None})
|
||||||
|
|
||||||
|
# POST — upsert
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
ct = body.get('ct', '')
|
||||||
|
iv = body.get('iv', '')
|
||||||
|
if not ct or not iv:
|
||||||
|
return JsonResponse({'error': 'ct and iv required'}, status=400)
|
||||||
|
|
||||||
|
# Size guard: bookmarks ≤ 100 KB base64
|
||||||
|
try:
|
||||||
|
raw_size = len(base64.b64decode(ct))
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
|
||||||
|
if raw_size > 100 * 1024:
|
||||||
|
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
|
||||||
|
|
||||||
|
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)
|
||||||
|
row.ct = ct
|
||||||
|
row.iv = iv
|
||||||
|
row.save(update_fields=['ct', 'iv', 'updated_at'])
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
mimetypes.add_type('application/javascript', '.js')
|
||||||
|
mimetypes.add_type('text/css', '.css')
|
||||||
|
|
||||||
# Load .env file from the project root
|
# Load .env file from the project root
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
load_dotenv(BASE_DIR / '.env')
|
load_dotenv(BASE_DIR / '.env')
|
||||||
|
|
@ -23,8 +27,15 @@ INSTALLED_APPS = [
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'radio',
|
'radio',
|
||||||
'accounts',
|
'accounts',
|
||||||
|
'podcasts',
|
||||||
|
'books',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
# Encrypted uploads are base64-encoded (~33% overhead) so allow ~25 MB body
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
|
|
@ -60,9 +71,12 @@ DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'data' / 'db.sqlite3',
|
'NAME': BASE_DIR / 'data' / 'db.sqlite3',
|
||||||
|
'OPTIONS': {'timeout': 20},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,7 @@ from django.urls import path, include
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('accounts/', include('accounts.urls')),
|
path('accounts/', include('accounts.urls')),
|
||||||
|
path('podcasts/', include('podcasts.urls')),
|
||||||
|
path('books/', include('books.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)
|
||||||
|
|
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
cron:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.cron
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
0
podcasts/__init__.py
Normal file
0
podcasts/__init__.py
Normal file
30
podcasts/admin.py
Normal file
30
podcasts/admin.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PodcastFeed)
|
||||||
|
class PodcastFeedAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('title', 'user', 'last_refreshed_at', 'added_at')
|
||||||
|
list_filter = ('user',)
|
||||||
|
search_fields = ('title', 'rss_url')
|
||||||
|
readonly_fields = ('added_at', 'last_refreshed_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PodcastEpisode)
|
||||||
|
class PodcastEpisodeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('title', 'feed', 'pub_date', 'duration_seconds')
|
||||||
|
list_filter = ('feed__user',)
|
||||||
|
search_fields = ('title', 'guid')
|
||||||
|
readonly_fields = ('discovered_at',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EpisodeProgress)
|
||||||
|
class EpisodeProgressAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'episode', 'position_seconds', 'played', 'updated_at')
|
||||||
|
list_filter = ('user', 'played')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PodcastQueue)
|
||||||
|
class PodcastQueueAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'episode', 'position', 'added_at')
|
||||||
|
list_filter = ('user',)
|
||||||
6
podcasts/apps.py
Normal file
6
podcasts/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'podcasts'
|
||||||
0
podcasts/management/__init__.py
Normal file
0
podcasts/management/__init__.py
Normal file
0
podcasts/management/commands/__init__.py
Normal file
0
podcasts/management/commands/__init__.py
Normal file
34
podcasts/management/commands/refresh_feeds.py
Normal file
34
podcasts/management/commands/refresh_feeds.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from podcasts.models import PodcastFeed
|
||||||
|
from podcasts.views import _refresh_feed
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Refresh podcast feeds (fetch new episodes from RSS)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--feed-id', type=int, help='Refresh only this feed ID')
|
||||||
|
parser.add_argument('--limit', type=int, default=0, help='Max number of feeds to refresh')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
qs = PodcastFeed.objects.all().order_by('last_refreshed_at')
|
||||||
|
|
||||||
|
if options['feed_id']:
|
||||||
|
qs = qs.filter(pk=options['feed_id'])
|
||||||
|
|
||||||
|
if options['limit']:
|
||||||
|
qs = qs[: options['limit']]
|
||||||
|
|
||||||
|
if not qs.exists():
|
||||||
|
self.stdout.write('No feeds to refresh.')
|
||||||
|
return
|
||||||
|
|
||||||
|
for feed in qs:
|
||||||
|
try:
|
||||||
|
new_ep = _refresh_feed(feed)
|
||||||
|
self.stdout.write(f'{feed.title}: +{new_ep} episodes')
|
||||||
|
except Exception as e:
|
||||||
|
self.stderr.write(f'ERROR refreshing "{feed.title}" ({feed.rss_url}): {e}')
|
||||||
86
podcasts/migrations/0001_initial.py
Normal file
86
podcasts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Generated by Django 6.0.3 on 2026-03-19 08:44
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PodcastFeed',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('rss_url', models.URLField(max_length=1000)),
|
||||||
|
('title', models.CharField(max_length=300)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('artwork_url', models.URLField(blank=True, max_length=1000)),
|
||||||
|
('author', models.CharField(blank=True, max_length=300)),
|
||||||
|
('link', models.URLField(blank=True, max_length=1000)),
|
||||||
|
('last_refreshed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_feeds', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['title'],
|
||||||
|
'unique_together': {('user', 'rss_url')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PodcastEpisode',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('guid', models.CharField(max_length=1000)),
|
||||||
|
('title', models.CharField(max_length=500)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('audio_url', models.URLField(max_length=1000)),
|
||||||
|
('duration_seconds', models.IntegerField(default=0)),
|
||||||
|
('pub_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('episode_number', models.IntegerField(blank=True, null=True)),
|
||||||
|
('season_number', models.IntegerField(blank=True, null=True)),
|
||||||
|
('artwork_url', models.URLField(blank=True, max_length=1000)),
|
||||||
|
('discovered_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('feed', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='podcasts.podcastfeed')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-pub_date'],
|
||||||
|
'unique_together': {('feed', 'guid')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EpisodeProgress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('position_seconds', models.IntegerField(default=0)),
|
||||||
|
('played', models.BooleanField(default=False)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episode_progress', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='podcasts.podcastepisode')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
|
'unique_together': {('user', 'episode')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PodcastQueue',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('position', models.PositiveIntegerField(default=0)),
|
||||||
|
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queued_by', to='podcasts.podcastepisode')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='podcast_queue', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['position'],
|
||||||
|
'unique_together': {('user', 'episode')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
podcasts/migrations/__init__.py
Normal file
0
podcasts/migrations/__init__.py
Normal file
71
podcasts/models.py
Normal file
71
podcasts/models.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastFeed(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_feeds')
|
||||||
|
rss_url = models.URLField(max_length=1000)
|
||||||
|
title = models.CharField(max_length=300)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
artwork_url = models.URLField(max_length=1000, blank=True)
|
||||||
|
author = models.CharField(max_length=300, blank=True)
|
||||||
|
link = models.URLField(max_length=1000, blank=True)
|
||||||
|
last_refreshed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'rss_url')
|
||||||
|
ordering = ['title']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastEpisode(models.Model):
|
||||||
|
feed = models.ForeignKey(PodcastFeed, on_delete=models.CASCADE, related_name='episodes')
|
||||||
|
guid = models.CharField(max_length=1000)
|
||||||
|
title = models.CharField(max_length=500)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
audio_url = models.URLField(max_length=1000)
|
||||||
|
duration_seconds = models.IntegerField(default=0)
|
||||||
|
pub_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
episode_number = models.IntegerField(null=True, blank=True)
|
||||||
|
season_number = models.IntegerField(null=True, blank=True)
|
||||||
|
artwork_url = models.URLField(max_length=1000, blank=True)
|
||||||
|
discovered_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('feed', 'guid')
|
||||||
|
ordering = ['-pub_date']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeProgress(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='episode_progress')
|
||||||
|
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='progress')
|
||||||
|
position_seconds = models.IntegerField(default=0)
|
||||||
|
played = models.BooleanField(default=False)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'episode')
|
||||||
|
ordering = ['-updated_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user} - {self.episode}'
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastQueue(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_queue')
|
||||||
|
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='queued_by')
|
||||||
|
position = models.PositiveIntegerField(default=0)
|
||||||
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'episode')
|
||||||
|
ordering = ['position']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user} queue pos {self.position}: {self.episode}'
|
||||||
19
podcasts/urls.py
Normal file
19
podcasts/urls.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('search/', views.podcast_search, name='podcast_search'),
|
||||||
|
path('feeds/', views.feed_list, name='podcast_feed_list'),
|
||||||
|
path('feeds/add/', views.add_feed, name='podcast_add_feed'),
|
||||||
|
path('feeds/import/', views.import_opml, name='podcast_import_opml'),
|
||||||
|
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>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
||||||
|
path('queue/', views.queue_get, name='podcast_queue_get'),
|
||||||
|
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
||||||
|
path('queue/remove/', views.queue_remove, name='podcast_queue_remove'),
|
||||||
|
path('queue/reorder/', views.queue_reorder, name='podcast_queue_reorder'),
|
||||||
|
path('progress/save/', views.save_progress, name='podcast_save_progress'),
|
||||||
|
path('progress/mark-played/', views.mark_played, name='podcast_mark_played'),
|
||||||
|
path('inbox/', views.inbox, name='podcast_inbox'),
|
||||||
|
]
|
||||||
550
podcasts/views.py
Normal file
550
podcasts/views.py
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Duration helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_duration(raw):
|
||||||
|
"""Return duration in seconds from itunes:duration string or int."""
|
||||||
|
if not raw:
|
||||||
|
return 0
|
||||||
|
if isinstance(raw, int):
|
||||||
|
return raw
|
||||||
|
raw = str(raw).strip()
|
||||||
|
parts = raw.split(':')
|
||||||
|
try:
|
||||||
|
if len(parts) == 3:
|
||||||
|
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
|
||||||
|
if len(parts) == 2:
|
||||||
|
return int(parts[0]) * 60 + int(parts[1])
|
||||||
|
return int(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared feed refresh helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_feed(feed_obj):
|
||||||
|
"""Parse rss_url and upsert episodes. Returns count of new episodes."""
|
||||||
|
parsed = feedparser.parse(feed_obj.rss_url)
|
||||||
|
channel = parsed.feed
|
||||||
|
|
||||||
|
feed_obj.title = channel.get('title', feed_obj.title or feed_obj.rss_url)[:300]
|
||||||
|
feed_obj.description = channel.get('subtitle', channel.get('description', ''))[:2000]
|
||||||
|
feed_obj.author = channel.get('author', channel.get('itunes_author', ''))[:300]
|
||||||
|
feed_obj.link = channel.get('link', '')[:1000]
|
||||||
|
|
||||||
|
# Artwork
|
||||||
|
image = channel.get('image', {})
|
||||||
|
feed_obj.artwork_url = (
|
||||||
|
channel.get('itunes_image', {}).get('href', '')
|
||||||
|
or image.get('href', '')
|
||||||
|
or image.get('url', '')
|
||||||
|
)[:1000]
|
||||||
|
|
||||||
|
feed_obj.last_refreshed_at = timezone.now()
|
||||||
|
feed_obj.save()
|
||||||
|
|
||||||
|
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
|
||||||
|
new_count = 0
|
||||||
|
|
||||||
|
for entry in parsed.entries[:max_ep]:
|
||||||
|
# Find audio enclosure
|
||||||
|
audio_url = ''
|
||||||
|
for enc in entry.get('enclosures', []):
|
||||||
|
mime = enc.get('type', '')
|
||||||
|
if mime.startswith('audio/') or enc.get('url', '').endswith(('.mp3', '.m4a', '.ogg', '.opus', '.aac')):
|
||||||
|
audio_url = enc.get('url', '')
|
||||||
|
break
|
||||||
|
if not audio_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
guid = entry.get('id') or entry.get('guid') or audio_url
|
||||||
|
guid = str(guid)[:1000]
|
||||||
|
title = entry.get('title', 'Untitled')[:500]
|
||||||
|
description = entry.get('summary', entry.get('description', ''))
|
||||||
|
|
||||||
|
duration_raw = entry.get('itunes_duration', 0)
|
||||||
|
duration_seconds = _parse_duration(duration_raw)
|
||||||
|
|
||||||
|
pub_date = None
|
||||||
|
if entry.get('published_parsed'):
|
||||||
|
import datetime as dt
|
||||||
|
t = entry.published_parsed
|
||||||
|
pub_date = dt.datetime(*t[:6], tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
ep_number = None
|
||||||
|
try:
|
||||||
|
ep_number = int(entry.get('itunes_episode', ''))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
season_number = None
|
||||||
|
try:
|
||||||
|
season_number = int(entry.get('itunes_season', ''))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
artwork_url = entry.get('itunes_image', {}).get('href', '')[:1000]
|
||||||
|
|
||||||
|
_, created = PodcastEpisode.objects.get_or_create(
|
||||||
|
feed=feed_obj,
|
||||||
|
guid=guid,
|
||||||
|
defaults={
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'audio_url': audio_url[:1000],
|
||||||
|
'duration_seconds': duration_seconds,
|
||||||
|
'pub_date': pub_date,
|
||||||
|
'episode_number': ep_number,
|
||||||
|
'season_number': season_number,
|
||||||
|
'artwork_url': artwork_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
return new_count
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def podcast_search(request):
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
if not q:
|
||||||
|
return JsonResponse({'results': []})
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20'
|
||||||
|
resp = requests.get(url, timeout=6)
|
||||||
|
resp.raise_for_status()
|
||||||
|
raw = resp.json().get('results', [])
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=502)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in raw:
|
||||||
|
results.append({
|
||||||
|
'id': r.get('collectionId'),
|
||||||
|
'title': r.get('collectionName', ''),
|
||||||
|
'author': r.get('artistName', ''),
|
||||||
|
'artwork_url': r.get('artworkUrl100', ''),
|
||||||
|
'rss_url': r.get('feedUrl', ''),
|
||||||
|
'genre': r.get('primaryGenreName', ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({'results': results})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feed list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def feed_list(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
feeds = list(
|
||||||
|
request.user.podcast_feeds
|
||||||
|
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author')
|
||||||
|
)
|
||||||
|
|
||||||
|
for f in feeds:
|
||||||
|
if f['last_refreshed_at']:
|
||||||
|
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
|
||||||
|
|
||||||
|
return JsonResponse({'feeds': feeds})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Add feed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def add_feed(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)
|
||||||
|
|
||||||
|
rss_url = body.get('rss_url', '').strip()
|
||||||
|
if not rss_url:
|
||||||
|
return JsonResponse({'error': 'rss_url required'}, status=400)
|
||||||
|
|
||||||
|
feed, created = PodcastFeed.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
rss_url=rss_url,
|
||||||
|
defaults={'title': body.get('title', rss_url)[:300]},
|
||||||
|
)
|
||||||
|
|
||||||
|
new_episodes = 0
|
||||||
|
if created:
|
||||||
|
try:
|
||||||
|
new_episodes = _refresh_feed(feed)
|
||||||
|
except Exception as e:
|
||||||
|
feed.delete()
|
||||||
|
return JsonResponse({'error': f'Failed to parse feed: {e}'}, status=400)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'created': created,
|
||||||
|
'feed_id': feed.id,
|
||||||
|
'title': feed.title,
|
||||||
|
'artwork_url': feed.artwork_url,
|
||||||
|
'new_episodes': new_episodes,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Remove feed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def remove_feed(request, pk):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
|
||||||
|
feed.delete()
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
except PodcastFeed.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feed episodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def feed_episodes(request, pk):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
feed = PodcastFeed.objects.get(pk=pk, user=request.user)
|
||||||
|
except PodcastFeed.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
episodes = list(
|
||||||
|
feed.episodes.values(
|
||||||
|
'id', 'title', 'description', 'audio_url', 'duration_seconds',
|
||||||
|
'pub_date', 'artwork_url', 'episode_number', 'season_number',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Batch-fetch progress
|
||||||
|
progress_map = {
|
||||||
|
ep['episode_id']: ep
|
||||||
|
for ep in EpisodeProgress.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
episode__feed=feed,
|
||||||
|
).values('episode_id', 'position_seconds', 'played')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Batch-fetch queue membership
|
||||||
|
queued_ids = set(
|
||||||
|
PodcastQueue.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
episode__feed=feed,
|
||||||
|
).values_list('episode_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
for ep in episodes:
|
||||||
|
if ep['pub_date']:
|
||||||
|
ep['pub_date'] = ep['pub_date'].isoformat()
|
||||||
|
prog = progress_map.get(ep['id'], {})
|
||||||
|
ep['position_seconds'] = prog.get('position_seconds', 0)
|
||||||
|
ep['played'] = prog.get('played', False)
|
||||||
|
ep['in_queue'] = ep['id'] in queued_ids
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'feed': {
|
||||||
|
'id': feed.id,
|
||||||
|
'title': feed.title,
|
||||||
|
'artwork_url': feed.artwork_url,
|
||||||
|
'author': feed.author,
|
||||||
|
},
|
||||||
|
'episodes': episodes,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import OPML
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def import_opml(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
f = request.FILES.get('file')
|
||||||
|
if not f:
|
||||||
|
return JsonResponse({'error': 'no file uploaded'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = f.read().decode('utf-8', errors='replace')
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'error': f'invalid OPML: {e}'}, status=400)
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
for outline in root.iter('outline'):
|
||||||
|
rss_url = outline.get('xmlUrl', '').strip()
|
||||||
|
if not rss_url:
|
||||||
|
continue
|
||||||
|
title = outline.get('title', outline.get('text', rss_url))[:300]
|
||||||
|
_, created = PodcastFeed.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
rss_url=rss_url,
|
||||||
|
defaults={'title': title},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Refresh feed now
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def refresh_feed_now(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)
|
||||||
|
|
||||||
|
feed_id = body.get('feed_id')
|
||||||
|
if not feed_id:
|
||||||
|
return JsonResponse({'error': 'feed_id required'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
feed = PodcastFeed.objects.get(pk=feed_id, user=request.user)
|
||||||
|
except PodcastFeed.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_episodes = _refresh_feed(feed)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=502)
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True, 'new_episodes': new_episodes})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Queue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def queue_get(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
items = list(
|
||||||
|
PodcastQueue.objects
|
||||||
|
.filter(user=request.user)
|
||||||
|
.select_related('episode__feed')
|
||||||
|
.values(
|
||||||
|
'id', 'position',
|
||||||
|
'episode__id', 'episode__title', 'episode__audio_url',
|
||||||
|
'episode__duration_seconds', 'episode__artwork_url',
|
||||||
|
'episode__feed__id', 'episode__feed__title',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({'queue': items})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def queue_add(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_id = body.get('episode_id')
|
||||||
|
try:
|
||||||
|
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
|
||||||
|
except PodcastEpisode.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
max_pos = PodcastQueue.objects.filter(user=request.user).count()
|
||||||
|
_, created = PodcastQueue.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
episode=episode,
|
||||||
|
defaults={'position': max_pos},
|
||||||
|
)
|
||||||
|
return JsonResponse({'ok': True, 'created': created})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def queue_remove(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_id = body.get('episode_id')
|
||||||
|
PodcastQueue.objects.filter(user=request.user, episode_id=episode_id).delete()
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def queue_reorder(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)
|
||||||
|
|
||||||
|
order = body.get('order', []) # list of episode ids
|
||||||
|
for pos, ep_id in enumerate(order):
|
||||||
|
PodcastQueue.objects.filter(user=request.user, episode_id=ep_id).update(position=pos)
|
||||||
|
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Progress
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def save_progress(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_id = body.get('episode_id')
|
||||||
|
position = int(body.get('position_seconds', 0))
|
||||||
|
|
||||||
|
try:
|
||||||
|
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
|
||||||
|
except PodcastEpisode.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
progress, _ = EpisodeProgress.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
episode=episode,
|
||||||
|
)
|
||||||
|
progress.position_seconds = position
|
||||||
|
|
||||||
|
# Auto-mark played at 90%
|
||||||
|
if episode.duration_seconds and episode.duration_seconds > 0:
|
||||||
|
if position >= episode.duration_seconds * 0.9:
|
||||||
|
progress.played = True
|
||||||
|
|
||||||
|
progress.save()
|
||||||
|
return JsonResponse({'ok': True, 'played': progress.played})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def mark_played(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_id = body.get('episode_id')
|
||||||
|
played = bool(body.get('played', True))
|
||||||
|
|
||||||
|
try:
|
||||||
|
episode = PodcastEpisode.objects.get(pk=episode_id, feed__user=request.user)
|
||||||
|
except PodcastEpisode.DoesNotExist:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
progress, _ = EpisodeProgress.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
episode=episode,
|
||||||
|
)
|
||||||
|
progress.played = played
|
||||||
|
progress.save(update_fields=['played', 'updated_at'])
|
||||||
|
return JsonResponse({'ok': True, 'played': played})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inbox
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def inbox(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
|
||||||
|
played_ids = set(
|
||||||
|
EpisodeProgress.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
played=True,
|
||||||
|
).values_list('episode_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
episodes = list(
|
||||||
|
PodcastEpisode.objects.filter(feed__user=request.user)
|
||||||
|
.exclude(id__in=played_ids)
|
||||||
|
.select_related('feed')
|
||||||
|
.order_by('-pub_date')
|
||||||
|
.values(
|
||||||
|
'id', 'title', 'audio_url', 'duration_seconds',
|
||||||
|
'pub_date', 'artwork_url',
|
||||||
|
'feed__id', 'feed__title', 'feed__artwork_url',
|
||||||
|
)[:100]
|
||||||
|
)
|
||||||
|
|
||||||
|
for ep in episodes:
|
||||||
|
if ep['pub_date']:
|
||||||
|
ep['pub_date'] = ep['pub_date'].isoformat()
|
||||||
|
|
||||||
|
return JsonResponse({'episodes': episodes})
|
||||||
|
|
@ -2,6 +2,7 @@ import json
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
@ -60,11 +61,37 @@ def index(request):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
initial_podcast_feeds = []
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
from podcasts.models import PodcastFeed
|
||||||
|
initial_podcast_feeds = list(
|
||||||
|
request.user.podcast_feeds.values('id', 'title', 'artwork_url', 'rss_url')
|
||||||
|
)
|
||||||
|
|
||||||
|
focus_station = None # null in JS means "never configured, use default"
|
||||||
|
encrypted_bg = {}
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
p = getattr(request.user, 'profile', None)
|
||||||
|
if p:
|
||||||
|
if p.focus_station_url or p.focus_station_name:
|
||||||
|
focus_station = {'url': p.focus_station_url, 'name': p.focus_station_name}
|
||||||
|
if p.background_encrypted:
|
||||||
|
encrypted_bg = {
|
||||||
|
'iv': p.background_iv,
|
||||||
|
'ciphertext': p.background_encrypted,
|
||||||
|
'mime': p.background_mime,
|
||||||
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'saved_stations': saved_stations,
|
'saved_stations': saved_stations,
|
||||||
'history': history,
|
'history': history,
|
||||||
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
|
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
|
||||||
'featured_stations': featured,
|
'featured_stations': featured,
|
||||||
|
'initial_podcast_feeds': initial_podcast_feeds,
|
||||||
|
'focus_station': focus_station,
|
||||||
|
'focus_station_json': json.dumps(focus_station, cls=DjangoJSONEncoder),
|
||||||
|
'encrypted_bg': encrypted_bg,
|
||||||
|
'encrypted_bg_json': json.dumps(encrypted_bg, cls=DjangoJSONEncoder) if encrypted_bg else '',
|
||||||
}
|
}
|
||||||
return render(request, 'radio/player.html', context)
|
return render(request, 'radio/player.html', context)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ pylast>=5.2
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
whitenoise>=6.6
|
whitenoise>=6.6
|
||||||
|
feedparser>=6.0
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,13 @@
|
||||||
--text-muted: var(--fg);
|
--text-muted: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* inverted: black on bright */
|
/* inverted: black on white */
|
||||||
body.bright-bg {
|
body.bright-bg {
|
||||||
|
--bg: #fff;
|
||||||
|
--bg-card: #f5f5f5;
|
||||||
|
--bg-alt: #f0f0f0;
|
||||||
|
--bg-row: #fafafa;
|
||||||
|
--bg-row-alt: #f5f5f5;
|
||||||
--fg: #000;
|
--fg: #000;
|
||||||
--fg-muted: #000;
|
--fg-muted: #000;
|
||||||
--outline: #fff;
|
--outline: #fff;
|
||||||
|
|
@ -314,6 +319,22 @@ a:hover {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-tabs {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tabs .tab-btn {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tabs .tab-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
SEARCH
|
SEARCH
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
@ -999,3 +1020,570 @@ body.dnd-mode .timer-display {
|
||||||
.volume-num::-webkit-inner-spin-button,
|
.volume-num::-webkit-inner-spin-button,
|
||||||
.volume-num::-webkit-outer-spin-button { display: none; }
|
.volume-num::-webkit-outer-spin-button { display: none; }
|
||||||
.volume-num { -moz-appearance: textfield; }
|
.volume-num { -moz-appearance: textfield; }
|
||||||
|
|
||||||
|
/* ===== PODCAST COMPONENTS ===== */
|
||||||
|
|
||||||
|
.podcast-seek-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-btn {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.skip-btn:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.speed-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border, #444);
|
||||||
|
color: var(--fg, #fff);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.speed-btn:hover { opacity: 1; }
|
||||||
|
.speed-btn.active {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--accent, #e63946);
|
||||||
|
color: var(--accent, #e63946);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seek-slider {
|
||||||
|
flex: 1;
|
||||||
|
accent-color: var(--accent, #e63946);
|
||||||
|
cursor: pointer;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seek-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border, #333);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-pane { }
|
||||||
|
|
||||||
|
.podcast-feed-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-feed-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border, #222);
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-feed-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-feed-title {
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-feed-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-thumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-thumb-lg {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-thumb-placeholder {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--surface, #1e1e2e);
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-unplayed-badge {
|
||||||
|
background: var(--accent, #e63946);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 0;
|
||||||
|
border-bottom: 1px solid var(--border, #222);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-item.episode-played {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-search-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-search-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border, #222);
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-search-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-queue-ol {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-feed-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-feed-header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable episode title (episode list) */
|
||||||
|
.ep-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ep-clickable:hover {
|
||||||
|
color: var(--accent, #e63946);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable episode title / feed name in the now-playing bar */
|
||||||
|
.podcast-track-link,
|
||||||
|
.podcast-station-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.podcast-track-link:hover,
|
||||||
|
.podcast-station-link:hover {
|
||||||
|
color: var(--accent, #e63946);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SIDEBAR ===== */
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 299;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 100vw;
|
||||||
|
/* stop above the now-playing bar */
|
||||||
|
bottom: 72px;
|
||||||
|
background: var(--surface, #111);
|
||||||
|
border-left: 1px solid var(--border, #333);
|
||||||
|
z-index: 300;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
border-bottom: 1px solid var(--border, #333);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style links and basic HTML inside shownotes */
|
||||||
|
.sidebar-body a { color: var(--accent, #e63946); }
|
||||||
|
.sidebar-body p { margin: 0 0 10px; }
|
||||||
|
.sidebar-body ul, .sidebar-body ol { margin: 0 0 10px; padding-left: 20px; }
|
||||||
|
.sidebar-body h1, .sidebar-body h2, .sidebar-body h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.sidebar { width: 100vw; }
|
||||||
|
.podcast-seek-bar { padding: 0 6px; }
|
||||||
|
.podcast-thumb { width: 40px; height: 40px; }
|
||||||
|
.podcast-thumb-lg { width: 60px; height: 60px; }
|
||||||
|
.podcast-feed-actions { flex-direction: column; }
|
||||||
|
.episode-actions { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Ebook reader + book list + focus station
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/* --- Drop zone --- */
|
||||||
|
.book-drop-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 12px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.book-drop-zone:hover,
|
||||||
|
.book-drop-zone.drag-over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.book-drop-zone label {
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-key-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Book list --- */
|
||||||
|
.book-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.book-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.book-item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.book-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.book-author,
|
||||||
|
.book-progress {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.book-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- PDF pages --- */
|
||||||
|
.pdf-page-wrapper {
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Reader overlay --- */
|
||||||
|
.reader-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--nav-h);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--bar-h);
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.reader-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.reader-title {
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.reader-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-progress-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.reader-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding: 24px 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.reader-content > * {
|
||||||
|
max-width: 65ch;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.reader-content p {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.reader-content h1, .reader-content h2, .reader-content h3 {
|
||||||
|
margin: 1.4em 0 0.6em;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Focus station sidebar --- */
|
||||||
|
.focus-preset-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.focus-preset-list li.focus-preset-active button {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.focus-custom-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Table of contents sidebar --- */
|
||||||
|
.toc-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.toc-entry {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.toc-entry:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus station pending (playing interrupted) */
|
||||||
|
#focus-station-btn.focus-pending {
|
||||||
|
color: var(--accent);
|
||||||
|
animation: focus-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes focus-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Reader feature additions
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/* Reader themes */
|
||||||
|
.reader-theme-sepia .reader-content { background:#f5e6c8; color:#3b2a1a; }
|
||||||
|
.reader-theme-bright .reader-content { background:#fff; color:#111; }
|
||||||
|
.reader-content > * { max-width:var(--reader-max-width,65ch); margin-left:auto; margin-right:auto; }
|
||||||
|
|
||||||
|
/* Inline panels */
|
||||||
|
.reader-settings-panel, .reader-search-bar {
|
||||||
|
display:flex; align-items:center; gap:12px; padding:8px 16px;
|
||||||
|
border-bottom:1px solid var(--border); flex-shrink:0; flex-wrap:wrap; background:var(--bg);
|
||||||
|
}
|
||||||
|
.reader-settings-panel input[type="range"] { width:80px; }
|
||||||
|
|
||||||
|
/* PDF inner container — shares origin with canvas so text layer aligns exactly */
|
||||||
|
.pdf-page-inner { position:relative; display:inline-block; line-height:0; }
|
||||||
|
|
||||||
|
/* PDF text layer — sits flush over the canvas inside pdf-page-inner */
|
||||||
|
.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }
|
||||||
|
.pdf-text-layer span { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; }
|
||||||
|
.pdf-text-layer span::selection { background:rgba(0,120,255,0.3); }
|
||||||
|
.pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); }
|
||||||
|
.pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); }
|
||||||
|
|
||||||
|
/* PDF invert */
|
||||||
|
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
|
||||||
|
|
||||||
|
/* PDF paginated */
|
||||||
|
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
|
||||||
|
.pdf-paginated .pdf-page-wrapper { margin:0; }
|
||||||
|
|
||||||
|
/* Highlight popover */
|
||||||
|
.highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); }
|
||||||
|
.hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; }
|
||||||
|
.hl-color-btn:hover { border-color:#fff; }
|
||||||
|
.hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; }
|
||||||
|
|
||||||
|
/* Highlight marks */
|
||||||
|
.epub-highlight { border-radius:2px; cursor:pointer; }
|
||||||
|
.epub-highlight[data-color="yellow"] { background:rgba(241,196,15,.4); }
|
||||||
|
.epub-highlight[data-color="green"] { background:rgba(46,204,113,.35); }
|
||||||
|
.epub-highlight[data-color="blue"] { background:rgba(52,152,219,.35); }
|
||||||
|
.epub-highlight[data-color="red"] { background:rgba(230,57,70,.35); }
|
||||||
|
|
||||||
|
/* Search matches */
|
||||||
|
mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; }
|
||||||
|
mark.reader-search-match.active { background:rgba(230,57,70,.7); }
|
||||||
|
#rs-search-count { font-size:12px; min-width:50px; }
|
||||||
|
|
||||||
|
/* Bookmarks sidebar */
|
||||||
|
.bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }
|
||||||
|
|
|
||||||
2796
static/js/app.js
2796
static/js/app.js
File diff suppressed because it is too large
Load diff
13
static/js/jszip.min.js
vendored
Normal file
13
static/js/jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
static/js/pdf.min.js
vendored
Normal file
22
static/js/pdf.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
static/js/pdf.worker.min.js
vendored
Normal file
22
static/js/pdf.worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -2,7 +2,8 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v1';
|
const CACHE = 'diora-v2';
|
||||||
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/',
|
'/',
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
@ -26,7 +27,7 @@ self.addEventListener('activate', function (event) {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then(function (keys) {
|
caches.keys().then(function (keys) {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
keys.filter(function (key) { return key !== CACHE; })
|
keys.filter(function (key) { return key !== CACHE && key !== PODCAST_CACHE; })
|
||||||
.map(function (key) { return caches.delete(key); })
|
.map(function (key) { return caches.delete(key); })
|
||||||
);
|
);
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
|
|
@ -40,12 +41,34 @@ self.addEventListener('fetch', function (event) {
|
||||||
// Only handle GET requests; let POST/SSE etc. pass through
|
// Only handle GET requests; let POST/SSE etc. pass through
|
||||||
if (event.request.method !== 'GET') return;
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
// Don't intercept SSE or API requests
|
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Bypass for API/mutation endpoints
|
||||||
if (url.pathname.startsWith('/radio/sse/') ||
|
if (url.pathname.startsWith('/radio/sse/') ||
|
||||||
url.pathname.startsWith('/radio/record/') ||
|
url.pathname.startsWith('/radio/record/') ||
|
||||||
url.pathname.startsWith('/radio/affiliate/') ||
|
url.pathname.startsWith('/radio/affiliate/') ||
|
||||||
url.pathname.startsWith('/admin/')) {
|
url.pathname.startsWith('/admin/') ||
|
||||||
|
url.pathname.startsWith('/podcasts/progress/') ||
|
||||||
|
url.pathname.startsWith('/podcasts/queue/') ||
|
||||||
|
url.pathname === '/podcasts/feeds/add' ||
|
||||||
|
url.pathname.startsWith('/podcasts/feeds/add') ||
|
||||||
|
url.pathname.includes('/remove') ||
|
||||||
|
url.pathname.startsWith('/podcasts/feeds/refresh')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Podcast audio: serve from podcast cache first, then network
|
||||||
|
if (event.request.destination === 'audio') {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(PODCAST_CACHE).then(function (cache) {
|
||||||
|
return cache.match(event.request).then(function (cached) {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(event.request).then(function (response) {
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,29 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.querySelector('.auth-form');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
var u = form.querySelector('[name=username]');
|
||||||
|
var p = form.querySelector('[name=password]');
|
||||||
|
if (!u || !p || !u.value || !p.value) return;
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
var enc = new TextEncoder();
|
||||||
|
var mat = await crypto.subtle.importKey('raw', enc.encode(p.value), 'PBKDF2', false, ['deriveKey']);
|
||||||
|
var key = await crypto.subtle.deriveKey(
|
||||||
|
{name: 'PBKDF2', salt: enc.encode('diora:' + u.value), iterations: 200000, hash: 'SHA-256'},
|
||||||
|
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
var raw = await crypto.subtle.exportKey('raw', key);
|
||||||
|
localStorage.setItem('diora_pending_enc_key', btoa(String.fromCharCode(...new Uint8Array(raw))));
|
||||||
|
} catch (err) {}
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -36,3 +36,29 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.querySelector('.auth-form');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
var u = form.querySelector('[name=username]');
|
||||||
|
var p = form.querySelector('[name=password1]');
|
||||||
|
if (!u || !p || !u.value || !p.value) return;
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
var enc = new TextEncoder();
|
||||||
|
var mat = await crypto.subtle.importKey('raw', enc.encode(p.value), 'PBKDF2', false, ['deriveKey']);
|
||||||
|
var key = await crypto.subtle.deriveKey(
|
||||||
|
{name: 'PBKDF2', salt: enc.encode('diora:' + u.value), iterations: 200000, hash: 'SHA-256'},
|
||||||
|
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
var raw = await crypto.subtle.exportKey('raw', key);
|
||||||
|
localStorage.setItem('diora_pending_enc_key', btoa(String.fromCharCode(...new Uint8Array(raw))));
|
||||||
|
} catch (err) {}
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,14 @@
|
||||||
<!-- Background section -->
|
<!-- Background section -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2>Background</h2>
|
<h2>Background</h2>
|
||||||
{% if request.user.profile.background_image_data %}
|
{% if request.user.profile.background_encrypted %}
|
||||||
|
<p class="lastfm-description">Encrypted background is set. <span class="muted">(Preview not available — decrypted in browser on main page.)</span></p>
|
||||||
|
<form method="post" action="{% url 'delete_background' %}" class="inline-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger">Remove background</button>
|
||||||
|
</form>
|
||||||
|
<p style="margin-top:12px;">Upload a new image to replace it:</p>
|
||||||
|
{% elif request.user.profile.background_image_data %}
|
||||||
<p>
|
<p>
|
||||||
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background">
|
<img src="{{ request.user.profile.background_image_data }}" class="bg-preview" alt="Your background">
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -50,9 +57,9 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-danger">Remove background</button>
|
<button type="submit" class="btn btn-danger">Remove background</button>
|
||||||
</form>
|
</form>
|
||||||
<p style="margin-top:12px;">Upload a new image to replace it:</p>
|
<p style="margin-top:12px;">Upload a new image to replace it (will be stored encrypted):</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB).</p>
|
<p class="lastfm-description">Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
|
<div style="margin-top:8px; display:flex; align-items:center; gap:10px;">
|
||||||
<label class="btn" for="bg-upload-input">Choose file</label>
|
<label class="btn" for="bg-upload-input">Choose file</label>
|
||||||
|
|
@ -81,20 +88,70 @@
|
||||||
function getCsrfToken() {
|
function getCsrfToken() {
|
||||||
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
|
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimal crypto helpers for settings page (duplicated from app.js — settings page doesn't load app.js)
|
||||||
|
function _bytesToBase64(buf) {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let str = '';
|
||||||
|
for (const b of bytes) str += String.fromCharCode(b);
|
||||||
|
return btoa(str);
|
||||||
|
}
|
||||||
|
function _bytesToHex(buf) {
|
||||||
|
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
function _base64ToBytes(b64) {
|
||||||
|
const str = atob(b64);
|
||||||
|
const buf = new Uint8Array(str.length);
|
||||||
|
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
async function _getOrCreateEncKey() {
|
||||||
|
const userId = {{ request.user.id }};
|
||||||
|
const storageKey = `diora_enc_key_${userId}`;
|
||||||
|
const stored = localStorage.getItem(storageKey);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const raw = _base64ToBytes(stored);
|
||||||
|
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||||||
|
const raw = await crypto.subtle.exportKey('raw', key);
|
||||||
|
localStorage.setItem(storageKey, _bytesToBase64(raw));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadBackground(input) {
|
async function uploadBackground(input) {
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const status = document.getElementById('bg-upload-status');
|
const status = document.getElementById('bg-upload-status');
|
||||||
status.textContent = 'Uploading…';
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
const form = new FormData();
|
if (!allowedTypes.includes(file.type)) {
|
||||||
form.append('file', file);
|
status.textContent = 'Only JPEG, PNG, or WebP images are allowed.';
|
||||||
form.append('csrfmiddlewaretoken', getCsrfToken());
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
status.textContent = 'Image must be 5 MB or smaller.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = 'Encrypting…';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/accounts/background/upload/', { method: 'POST', body: form });
|
const key = await _getOrCreateEncKey();
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, buf);
|
||||||
|
status.textContent = 'Uploading…';
|
||||||
|
const res = await fetch('/accounts/background/upload/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||||
|
body: JSON.stringify({iv: _bytesToHex(iv), ciphertext: _bytesToBase64(ct), mime_type: file.type, file_size: file.size}),
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) { location.reload(); }
|
if (data.ok) { location.reload(); }
|
||||||
else { status.textContent = data.error || 'Upload failed'; }
|
else { status.textContent = data.error || 'Upload failed'; }
|
||||||
} catch (e) { status.textContent = 'Upload failed'; }
|
} catch (e) {
|
||||||
|
status.textContent = 'Upload failed: ' + e.message;
|
||||||
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,11 @@
|
||||||
<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">
|
||||||
<title>{% block title %}diora{% endblock %}</title>
|
<title>{% block title %}diora{% endblock %}</title>
|
||||||
{% if user.is_authenticated and user.profile.background_image_data %}
|
{% if encrypted_bg_json %}
|
||||||
<style>
|
<script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script>
|
||||||
body {
|
|
||||||
background-image: url('{{ user.profile.background_image_data }}');
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body{% if user.is_authenticated and user.profile.background_image_data %} data-bg="1"{% endif %}>
|
<body>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<a href="/" class="navbar-brand">diora</a>
|
<a href="/" class="navbar-brand">diora</a>
|
||||||
<div class="navbar-links">
|
<div class="navbar-links">
|
||||||
|
|
@ -54,6 +47,8 @@
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/js/jszip.min.js"></script>
|
||||||
|
<script src="/static/js/pdf.min.js"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,21 @@
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
||||||
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
|
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
|
||||||
|
<button class="btn-icon" id="focus-station-btn" onclick="openFocusStationSidebar()" title="Focus station">📻</button>
|
||||||
|
</div>
|
||||||
|
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
|
||||||
|
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">⏪ 15</button>
|
||||||
|
<span class="seek-time" id="seek-current">0:00</span>
|
||||||
|
<input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0">
|
||||||
|
<span class="seek-time" id="seek-duration">0:00</span>
|
||||||
|
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30 ⏩</button>
|
||||||
|
<div class="speed-btns" id="speed-btns">
|
||||||
|
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</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.5)">1½×</button>
|
||||||
|
<button class="speed-btn" onclick="setPlaybackRate(2)">2×</button>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
|
@ -44,140 +59,149 @@
|
||||||
|
|
||||||
<!-- ===== TABS ===== -->
|
<!-- ===== TABS ===== -->
|
||||||
<div class="tabs" id="tabs">
|
<div class="tabs" id="tabs">
|
||||||
<button class="tab-btn active" onclick="showTab('search')">Search</button>
|
<button class="tab-btn active" onclick="showTab('radio')">Radio</button>
|
||||||
<button class="tab-btn" onclick="showTab('saved')">Saved</button>
|
|
||||||
<button class="tab-btn" onclick="showTab('history')">History</button>
|
|
||||||
<button class="tab-btn" onclick="showTab('focus')">Focus</button>
|
<button class="tab-btn" onclick="showTab('focus')">Focus</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('books')">Books</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== SEARCH TAB ===== -->
|
<!-- ===== RADIO TAB ===== -->
|
||||||
<section class="tab-panel" id="tab-search">
|
<section class="tab-panel" id="tab-radio">
|
||||||
<div class="search-bar">
|
<div class="tabs sub-tabs" id="radio-sub-tabs">
|
||||||
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()">
|
<button class="tab-btn active" onclick="showRadioTab('search')">Search</button>
|
||||||
<button class="btn" onclick="doSearch()">Search</button>
|
<button class="tab-btn" onclick="showRadioTab('saved')">Saved</button>
|
||||||
|
<button class="tab-btn" onclick="showRadioTab('history')">History</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mood-chips" id="mood-chips"></div>
|
|
||||||
<div id="curated-lists" class="curated-lists-container"></div>
|
|
||||||
<div id="search-status" class="status-msg"></div>
|
|
||||||
<table class="data-table" id="search-results-table" style="display:none;">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Bitrate</th>
|
|
||||||
<th>Country</th>
|
|
||||||
<th>Tags</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="search-results-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ===== SAVED TAB ===== -->
|
<!-- ===== SEARCH SUB-PANEL ===== -->
|
||||||
<section class="tab-panel" id="tab-saved" style="display:none;">
|
<div class="sub-tab-panel" id="tab-search">
|
||||||
{% if featured_stations %}
|
<div class="search-bar">
|
||||||
<div class="featured-section">
|
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()">
|
||||||
<p class="featured-label">★ Featured</p>
|
<button class="btn" onclick="doSearch()">Search</button>
|
||||||
<ul class="featured-list">
|
|
||||||
{% for s in featured_stations %}
|
|
||||||
<li>
|
|
||||||
{% if s.favicon_url %}<img src="{{ s.favicon_url }}" class="station-favicon" alt="">{% endif %}
|
|
||||||
<button class="btn btn-sm" onclick="playStation('{{ s.url|escapejs }}', '{{ s.name|escapejs }}', null)">
|
|
||||||
▶ {{ s.name }}
|
|
||||||
</button>
|
|
||||||
{% if s.description %}<span class="muted">{{ s.description }}</span>{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<div id="recommendations" class="recommendations-section">
|
|
||||||
<!-- populated by JS -->
|
|
||||||
</div>
|
</div>
|
||||||
<div class="import-bar">
|
<div class="mood-chips" id="mood-chips"></div>
|
||||||
<label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
|
<div id="curated-lists" class="curated-lists-container"></div>
|
||||||
⇧ Import M3U
|
<div id="search-status" class="status-msg"></div>
|
||||||
</label>
|
<table class="data-table" id="search-results-table" style="display:none;">
|
||||||
<input type="file" id="m3u-file-input" accept=".m3u,.m3u8" style="display:none;" onchange="importM3U(this)">
|
|
||||||
<span id="import-status" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
<table class="data-table" id="saved-table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>★</th>
|
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Bitrate</th>
|
<th>Bitrate</th>
|
||||||
<th>Country</th>
|
<th>Country</th>
|
||||||
<th title="Notes">✎</th>
|
<th>Tags</th>
|
||||||
<th></th>
|
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="saved-tbody">
|
<tbody id="search-results-body"></tbody>
|
||||||
{% for station in saved_stations %}
|
</table>
|
||||||
<tr id="saved-row-{{ station.id }}" data-id="{{ station.id }}" data-url="{{ station.url }}" data-name="{{ station.name }}">
|
</div>
|
||||||
<td>
|
|
||||||
<button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
|
<!-- ===== SAVED SUB-PANEL ===== -->
|
||||||
onclick="toggleFav({{ station.id }})"
|
<div class="sub-tab-panel" id="tab-saved" style="display:none;">
|
||||||
title="Toggle favorite">★</button>
|
{% if featured_stations %}
|
||||||
</td>
|
<div class="featured-section">
|
||||||
<td class="station-name-cell">{{ station.name }}</td>
|
<p class="featured-label">★ Featured</p>
|
||||||
<td>{{ station.bitrate }}</td>
|
<ul class="featured-list">
|
||||||
<td>{{ station.country }}</td>
|
{% for s in featured_stations %}
|
||||||
<td class="notes-cell" onclick="editNotes({{ station.id }}, this.textContent.trim())" title="{{ station.notes|default:'' }}" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ station.notes }}</td>
|
<li>
|
||||||
<td>
|
{% if s.favicon_url %}<img src="{{ s.favicon_url }}" class="station-favicon" alt="">{% endif %}
|
||||||
<button class="btn btn-sm"
|
<button class="btn btn-sm" onclick="playStation('{{ s.url|escapejs }}', '{{ s.name|escapejs }}', null)">
|
||||||
onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
|
▶ {{ s.name }}
|
||||||
▶ Play
|
</button>
|
||||||
</button>
|
{% if s.description %}<span class="muted">{{ s.description }}</span>{% endif %}
|
||||||
</td>
|
</li>
|
||||||
<td>
|
{% endfor %}
|
||||||
<button class="btn btn-sm btn-danger"
|
</ul>
|
||||||
onclick="removeStation({{ station.id }})">
|
</div>
|
||||||
Remove
|
{% endif %}
|
||||||
</button>
|
{% if user.is_authenticated %}
|
||||||
</td>
|
<div id="recommendations" class="recommendations-section">
|
||||||
|
<!-- populated by JS -->
|
||||||
|
</div>
|
||||||
|
<div class="import-bar">
|
||||||
|
<label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
|
||||||
|
⇧ Import M3U
|
||||||
|
</label>
|
||||||
|
<input type="file" id="m3u-file-input" accept=".m3u,.m3u8" style="display:none;" onchange="importM3U(this)">
|
||||||
|
<span id="import-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<table class="data-table" id="saved-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>★</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Bitrate</th>
|
||||||
|
<th>Country</th>
|
||||||
|
<th title="Notes">✎</th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="saved-tbody">
|
||||||
|
{% for station in saved_stations %}
|
||||||
|
<tr id="saved-row-{{ station.id }}" data-id="{{ station.id }}" data-url="{{ station.url }}" data-name="{{ station.name }}">
|
||||||
|
<td>
|
||||||
|
<button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
|
||||||
|
onclick="toggleFav({{ station.id }})"
|
||||||
|
title="Toggle favorite">★</button>
|
||||||
|
</td>
|
||||||
|
<td class="station-name-cell">{{ station.name }}</td>
|
||||||
|
<td>{{ station.bitrate }}</td>
|
||||||
|
<td>{{ station.country }}</td>
|
||||||
|
<td class="notes-cell" onclick="editNotes({{ station.id }}, this.textContent.trim())" title="{{ station.notes|default:'' }}" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ station.notes }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm"
|
||||||
|
onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
|
||||||
|
▶ Play
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger"
|
||||||
|
onclick="removeStation({{ station.id }})">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="auth-prompt">
|
||||||
|
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||||
|
to save stations and sync across devices.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== HISTORY SUB-PANEL ===== -->
|
||||||
|
<div class="sub-tab-panel" id="tab-history" style="display:none;">
|
||||||
|
<table class="data-table" id="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Station</th>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>♬</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-tbody">
|
||||||
|
{% for entry in history %}
|
||||||
|
<tr data-id="{{ entry.id }}">
|
||||||
|
<td class="history-time">{{ entry.played_at|slice:":16"|cut:"T" }}</td>
|
||||||
|
<td>{{ entry.station_name }}</td>
|
||||||
|
<td>{{ entry.track }}</td>
|
||||||
|
<td>{% if entry.scrobbled %}<span title="Scrobbled to Last.fm">✓</span>{% endif %}</td>
|
||||||
|
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove">✕</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
|
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
</div>
|
||||||
<p class="auth-prompt">
|
|
||||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
|
||||||
to save stations and sync across devices.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ===== HISTORY TAB ===== -->
|
|
||||||
<section class="tab-panel" id="tab-history" style="display:none;">
|
|
||||||
<table class="data-table" id="history-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>Station</th>
|
|
||||||
<th>Track</th>
|
|
||||||
<th>♬</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="history-tbody">
|
|
||||||
{% for entry in history %}
|
|
||||||
<tr data-id="{{ entry.id }}">
|
|
||||||
<td class="history-time">{{ entry.played_at|slice:":16"|cut:"T" }}</td>
|
|
||||||
<td>{{ entry.station_name }}</td>
|
|
||||||
<td>{{ entry.track }}</td>
|
|
||||||
<td>{% if entry.scrobbled %}<span title="Scrobbled to Last.fm">✓</span>{% endif %}</td>
|
|
||||||
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove">✕</button></td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===== FOCUS TAB ===== -->
|
<!-- ===== FOCUS TAB ===== -->
|
||||||
|
|
@ -196,6 +220,108 @@
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== PODCASTS TAB ===== -->
|
||||||
|
<section class="tab-panel" id="tab-podcasts" style="display:none;">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<div class="podcast-toolbar">
|
||||||
|
<button class="btn btn-sm" onclick="showPodcastView('feeds')">Feeds</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="podcastSearchOpen()">+ Search</button>
|
||||||
|
<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)">
|
||||||
|
<span id="opml-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search pane -->
|
||||||
|
<div class="podcast-pane" id="podcast-search-pane" style="display:none;">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="podcast-search-input" class="search-input" placeholder="Search podcasts…"
|
||||||
|
onkeydown="if(event.key==='Enter') doPodcastSearch()">
|
||||||
|
<button class="btn" onclick="doPodcastSearch()">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="podcast-search-status" class="status-msg"></div>
|
||||||
|
<div id="podcast-search-list" class="podcast-search-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feeds pane -->
|
||||||
|
<div class="podcast-pane" id="podcast-feeds-pane">
|
||||||
|
<div id="podcast-feed-list" class="podcast-feed-list">
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inbox pane -->
|
||||||
|
<div class="podcast-pane" id="podcast-inbox-pane" style="display:none;">
|
||||||
|
<div id="podcast-inbox-list" class="episode-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Episodes pane -->
|
||||||
|
<div class="podcast-pane" id="podcast-episodes-pane" style="display:none;">
|
||||||
|
<div id="podcast-feed-header" class="podcast-feed-header"></div>
|
||||||
|
<div id="podcast-episode-list" class="episode-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue pane -->
|
||||||
|
<div class="podcast-pane" id="podcast-queue-pane" style="display:none;">
|
||||||
|
<ol id="podcast-queue-ol" class="podcast-queue-ol"></ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p class="auth-prompt">
|
||||||
|
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||||
|
to subscribe to podcasts.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== BOOKS TAB ===== -->
|
||||||
|
<section class="tab-panel" id="tab-books" style="display:none;">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<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>
|
||||||
|
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
||||||
|
<span id="book-upload-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<div id="book-list" class="book-list"></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="auth-prompt">
|
||||||
|
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||||
|
to use the encrypted ebook reader.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== READER OVERLAY ===== -->
|
||||||
|
<div id="reader-overlay" class="reader-overlay" style="display:none;">
|
||||||
|
<div class="reader-header">
|
||||||
|
<span id="reader-title" class="reader-title"></span>
|
||||||
|
<div class="reader-header-actions">
|
||||||
|
<span class="reader-progress-wrap">
|
||||||
|
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;">
|
||||||
|
<span id="reader-progress-suffix" class="muted"></span>
|
||||||
|
</span>
|
||||||
|
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button>
|
||||||
|
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font & layout">⚙</button>
|
||||||
|
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
||||||
|
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">☰</button>
|
||||||
|
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
|
||||||
|
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="reader-content" class="reader-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SIDEBAR ===== -->
|
||||||
|
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()" style="display:none;"></div>
|
||||||
|
<aside id="sidebar" class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span id="sidebar-title" class="sidebar-title"></span>
|
||||||
|
<button class="btn-icon sidebar-close" onclick="closeSidebar()" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="sidebar-body" class="sidebar-body"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
|
@ -204,6 +330,9 @@
|
||||||
const INITIAL_SAVED = {{ saved_stations|safe }};
|
const INITIAL_SAVED = {{ saved_stations|safe }};
|
||||||
const INITIAL_FEATURED = {{ featured_stations|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 USER_ID = {{ user.id|default:"null" }};
|
||||||
|
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue