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>
318 lines
14 KiB
Python
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}'
|