diora-web/podcasts/models.py
mrwnslz 3eb6a27ec3 Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.

Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00

318 lines
14 KiB
Python

from django.contrib.auth.models import User
from django.db import models
class PodcastFeed(models.Model):
# What happens to a new episode of this feed (one that passed the filter).
NEW_INBOX = 'inbox'
NEW_QUEUE = 'queue'
NEW_IGNORE = 'ignore'
NEW_EPISODE_ACTIONS = [
(NEW_INBOX, 'In „Neu“'),
(NEW_QUEUE, 'In die Warteschlange'),
(NEW_IGNORE, 'Nicht anzeigen'),
]
# Volume factors: below 1 quieter, above 1 louder (the apps boost those).
VOLUMES = (0.2, 0.5, 1.0, 1.5, 2.0, 2.5)
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)
# Always the same as new_episodes == 'queue' (see save()); kept for the
# web UI's toggle and older clients.
auto_queue = models.BooleanField(default=False)
# HTTP validators of the last full (200) response, sent back as
# If-None-Match / If-Modified-Since so an unchanged feed costs a 304.
http_etag = models.CharField(max_length=500, blank=True)
http_last_modified = models.CharField(max_length=100, blank=True)
# The last failed refresh (network, HTTP status, not a feed); cleared on
# success. error_count counts failures in a row and drives the backoff.
last_error = models.CharField(max_length=500, blank=True)
last_error_at = models.DateTimeField(null=True, blank=True)
error_count = models.PositiveIntegerField(default=0)
# Playback, for every device: null = the device's own speed.
playback_speed = models.FloatField(null=True, blank=True)
skip_intro = models.PositiveIntegerField(default=0) # seconds
skip_outro = models.PositiveIntegerField(default=0) # seconds
volume = models.FloatField(default=1.0) # one of VOLUMES
# New episodes: what happens to them, and which ones count at all.
new_episodes = models.CharField(max_length=10, choices=NEW_EPISODE_ACTIONS, default=NEW_INBOX)
# Comma-separated words, case-insensitive, matched against the title.
include_words = models.CharField(max_length=500, blank=True)
exclude_words = models.CharField(max_length=500, blank=True)
min_duration = models.PositiveIntegerField(default=0) # minutes, 0 = any
# Devices download this feed's new episodes (those in the inbox) on
# their own, for listening offline.
auto_download = models.BooleanField(default=False)
class Meta:
unique_together = ('user', 'rss_url')
ordering = ['title']
def __str__(self):
return self.title
@classmethod
def from_db(cls, *args, **kwargs):
obj = super().from_db(*args, **kwargs)
obj._remember_sync()
return obj
def refresh_from_db(self, *args, **kwargs):
super().refresh_from_db(*args, **kwargs)
self._remember_sync()
def _remember_sync(self):
self._synced = (self.__dict__.get('auto_queue'), self.__dict__.get('new_episodes'))
def _sync_auto_queue(self):
"""Keep auto_queue == (new_episodes == 'queue'). Whichever of the two
was changed since loading (or, for a new feed, set away from its
default) wins; new_episodes if both were."""
auto_before, new_before = getattr(self, '_synced', (False, self.NEW_INBOX))
if self.new_episodes != new_before or self.auto_queue == auto_before:
self.auto_queue = self.new_episodes == self.NEW_QUEUE
elif self.auto_queue:
self.new_episodes = self.NEW_QUEUE
elif self.new_episodes == self.NEW_QUEUE:
self.new_episodes = self.NEW_INBOX
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields')
if update_fields is not None and not {'auto_queue', 'new_episodes'} & set(update_fields):
return super().save(*args, **kwargs)
self._sync_auto_queue()
if update_fields is not None:
kwargs['update_fields'] = set(update_fields) | {'auto_queue', 'new_episodes'}
super().save(*args, **kwargs)
self._remember_sync()
def matches_filter(self, title, duration_seconds):
"""Whether a new episode passes this feed's filter: an include word
(if any are given), no exclude word, and long enough (if known)."""
title = (title or '').casefold()
include = _words(self.include_words)
if include and not any(w in title for w in include):
return False
if any(w in title for w in _words(self.exclude_words)):
return False
if self.min_duration and duration_seconds and duration_seconds < self.min_duration * 60:
return False
return True
def _words(raw):
return [w.strip().casefold() for w in (raw or '').split(',') if w.strip()]
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)
# Podcasting 2.0 <podcast:chapters url=…>: a JSON file, loaded on demand.
chapters_url = models.URLField(max_length=1000, blank=True)
# [{start: seconds, title, url?, img?}], from Podlove Simple Chapters in
# the feed, or once fetched: the JSON at chapters_url, else (or if that
# has none) the CHAP frames of the audio file's ID3v2 tag.
chapters = models.JSONField(default=list, blank=True)
# When chapters were fetched into `chapters` (from chapters_url or the
# audio file); null = not yet.
chapters_fetched_at = models.DateTimeField(null=True, blank=True)
# [{url, type, language}] from <podcast:transcript>.
transcripts = models.JSONField(default=list, blank=True)
class Meta:
unique_together = ('feed', 'guid')
ordering = ['-pub_date']
def __str__(self):
return self.title
class EpisodeTranscript(models.Model):
"""An episode's transcript, fetched and parsed once (transcripts.py).
A table of its own rather than fields on PodcastEpisode: a parsed
transcript runs to hundreds of KB, and episode lists load whole rows.
"""
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='transcript_cache')
# URLs of the transcripts it was made from, best first. When the feed
# offers different ones, the cache is stale.
sources = models.JSONField(default=list)
# {"segments": [...], "source": {...}}, or null with an error.
data = models.JSONField(null=True, blank=True)
# '' when data is there; else why there's none for good: 'unsupported'
# (no format with timing), 'invalid' (missing, broken, too large).
error = models.CharField(max_length=20, blank=True)
fetched_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'Transcript of {self.episode}'
# An episode counts as played this close to its end (AntennaPod's default),
# the same rule as the apps' "als gehört zählen".
PLAYED_BEFORE_END_SECONDS = 30
def counts_as_played(position_seconds, duration_seconds):
return bool(duration_seconds) and duration_seconds > 0 and \
position_seconds >= max(duration_seconds - PLAYED_BEFORE_END_SECONDS, 1)
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)
dismissed = models.BooleanField(default=False)
favorite = 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}'
class PodcastSettings(models.Model):
"""A user's podcast preferences. Where episodes go in the queue: when added
by hand (also when played from outside the queue), and when a feed that
queues its new episodes brings some; and whether the queue stays sorted."""
BACK = 'back'
FRONT = 'front'
AFTER_CURRENT = 'after_current'
POSITIONS = [
(BACK, 'Ans Ende'),
(FRONT, 'An den Anfang'),
(AFTER_CURRENT, 'Nach der laufenden Folge'),
]
# Keep the queue sorted: after every change the whole queue is sorted
# again by this rule ('' = off, the user's own order).
SORT_ORDERS = [
('', 'Aus'),
('new', 'Neueste zuerst'),
('old', 'Älteste zuerst'),
('short', 'Kürzeste zuerst'),
('long', 'Längste zuerst'),
('feed', 'Nach Podcast'),
]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='podcast_settings')
enqueue_position = models.CharField(max_length=20, choices=POSITIONS, default=BACK)
# front = how auto-queue always worked before this setting existed
auto_queue_position = models.CharField(max_length=20, choices=POSITIONS, default=FRONT)
keep_sorted = models.CharField(max_length=10, choices=SORT_ORDERS, default='', blank=True)
# What PodcastFeed.auto_download starts as for a new subscription.
auto_download_default = models.BooleanField(default=False)
# Episodes that were downloaded in AntennaPod when its backup was
# imported: [{episode_id, size (bytes, 0 = unknown)}]. The app offers once
# to download them again, then clears the list.
import_downloads = models.JSONField(default=list, blank=True)
@classmethod
def for_user(cls, user):
obj, _ = cls.objects.get_or_create(user=user)
return obj
@classmethod
def new_feed_defaults(cls, user):
"""Field values a new PodcastFeed of the user starts with."""
auto_download = cls.objects.filter(user=user).values_list('auto_download_default', flat=True).first()
return {'auto_download': bool(auto_download)}
def __str__(self):
return f'Podcast settings of {self.user}'
class PlayerState(models.Model):
"""What a user's player plays, shared by all their devices (player sync,
like Spotify Connect): the device that starts playing becomes the active
one and stays it, also while paused, as long as it is online; the others
stop and show it as playing (or paused) there, with remote controls.
position_ms is the place at updated_at, the time of the last accepted
report; while playing, clients extrapolate from there. seq counts every
change (report, command, command done, a device coming or going) so the
stream can tell what's new.
"""
PODCAST = 'podcast'
RADIO = 'radio'
KINDS = [('', 'Nichts'), (PODCAST, 'Podcast'), (RADIO, 'Radio')]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='player_state')
kind = models.CharField(max_length=10, choices=KINDS, default='', blank=True)
episode = models.ForeignKey(PodcastEpisode, null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
station_url = models.CharField(max_length=1000, blank=True)
station_name = models.CharField(max_length=300, blank=True)
station_id = models.IntegerField(null=True, blank=True)
position_ms = models.PositiveBigIntegerField(default=0)
playing = models.BooleanField(default=False)
speed = models.FloatField(default=1.0)
device_id = models.CharField(max_length=64, blank=True)
device_name = models.CharField(max_length=100, blank=True)
updated_at = models.DateTimeField(null=True, blank=True)
seq = models.BigIntegerField(default=0)
# A remote command for the active device, until it reports it done:
# {id, action, value, from_device_id, from_device_name, at}.
command = models.JSONField(null=True, blank=True)
# The ids of the devices online at the last change, sorted: when the set
# differs, seq goes up so every device sees others come and go.
online_devices = models.JSONField(default=list, blank=True)
@classmethod
def for_user(cls, user):
obj, _ = cls.objects.get_or_create(user=user)
return obj
def __str__(self):
return f'Player of {self.user}'
class PlayerDevice(models.Model):
"""A device that has the player sync open (its stream, its reports): online
while last seen at most PLAYER_ONLINE_SECONDS ago (podcasts/views.py)."""
KINDS = [('', 'Unbekannt'), ('app', 'App'), ('web', 'Browser')]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='player_devices')
device_id = models.CharField(max_length=64)
device_name = models.CharField(max_length=100, blank=True)
kind = models.CharField(max_length=10, choices=KINDS, default='', blank=True)
last_seen = models.DateTimeField()
class Meta:
unique_together = ('user', 'device_id')
def __str__(self):
return f'{self.device_name or self.device_id} of {self.user}'