- EpisodeProgress.discarded_at (Migration 0015); POST /podcasts/episodes/discard/
{"episode_ids": [...]}: aus der Warteschlange (_dequeue, auch von queue/remove benutzt)
und aus "Neu" (dismissed), discarded_at = jetzt; gehört, Stelle, Stern und updated_at
bleiben; fremde Folgen werden ausgelassen (count)
- state/ liefert "discarded": [{id, at}] der letzten 30 Tage: jedes Gerät löscht seine
ältere Datei; wieder eingereiht (_enqueue) wird discarded_at gelöscht
- Browser-Host: podcastDiscard (pausiert geladene Folge verlässt den Player, laufende spielt
zu Ende), podcastPlayEpisodeAt (Kapitel auf der Folgen-Seite)
- static/podcasts aus diora-android ee1a910 (Folgen-Seite, Folge löschen, Datei löschen,
Wischtexte und Icons, Ziehgriff)
- Tests: DiscardTests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
17 KiB
Python
375 lines
17 KiB
Python
from django.contrib.auth.models import User
|
|
from django.db import models
|
|
|
|
# Playback speeds everywhere (the apps, a podcast's own speed, the presets):
|
|
# AntennaPod goes up to 3.5x.
|
|
MIN_SPEED = 0.5
|
|
MAX_SPEED = 3.5
|
|
|
|
|
|
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)
|
|
# Devices tell about this feed's new episodes (a notification, like
|
|
# AntennaPod's per-podcast "episode notifications").
|
|
notify = models.BooleanField(default=False)
|
|
# The podcast's episode list as the user left it on any device: its
|
|
# order ('' = newest first) and the filters that are on (all must match).
|
|
episode_sort = models.CharField(max_length=10, blank=True)
|
|
episode_filter = models.JSONField(default=list, blank=True)
|
|
# Skip silence in this podcast's episodes: null = as the device is set.
|
|
skip_silence = models.BooleanField(null=True, blank=True)
|
|
|
|
# The vocabulary of episode_sort and episode_filter: the podcast page's
|
|
# sort menu and filter chips (native-podcast.js EP_SORTS and FILTERS).
|
|
EPISODE_SORTS = ('new', 'old', 'long', 'short', 'title')
|
|
EPISODE_FILTERS = ('unplayed', 'in_progress', 'favorites', 'queued', 'downloaded', 'played')
|
|
|
|
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)
|
|
# "Folge löschen": when the user threw the episode away (out of the queue
|
|
# and "Neu", not played). Every device deletes its download of it if that
|
|
# is older (state/ lists the recent ones); queueing it again clears it.
|
|
discarded_at = models.DateTimeField(null=True, blank=True)
|
|
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)
|
|
|
|
# Playback, the same on every device (AntennaPod's playback settings).
|
|
# Null = not set yet: each device keeps its own value; the first app that
|
|
# finds one unset sends its own ("initial" in POST settings/), so nobody's
|
|
# setting changes when it moves to the account.
|
|
SKIP_CHOICES = (5, 10, 15, 20, 30, 45, 60)
|
|
skip_back = models.PositiveSmallIntegerField(null=True, blank=True) # seconds
|
|
skip_forward = models.PositiveSmallIntegerField(null=True, blank=True) # seconds
|
|
# An episode counts as played this many seconds before its end.
|
|
smart_played = models.PositiveIntegerField(null=True, blank=True)
|
|
# The speeds the speed menu offers and the notification's speed button
|
|
# goes through, ascending; null = the apps' default list.
|
|
speed_presets = models.JSONField(null=True, blank=True)
|
|
MAX_PRESETS = 12
|
|
# Skipping to the next episode (player, notification, headset, car)
|
|
# leaves the skipped one unplayed and in the queue.
|
|
keep_skipped = models.BooleanField(default=True)
|
|
# Deleting a download takes its episode out of the queue too.
|
|
delete_dequeue = models.BooleanField(default=True)
|
|
# Downloads asked for by hand may use mobile data (else they wait for
|
|
# Wi-Fi, like automatic ones).
|
|
mobile_downloads = models.BooleanField(default=True)
|
|
# Streaming an episode over mobile data: allowed, asked once per episode,
|
|
# or never (radio streams aren't affected).
|
|
STREAMING = [('allow', 'Erlauben'), ('ask', 'Nachfragen'), ('never', 'Nie')]
|
|
mobile_streaming = models.CharField(max_length=10, choices=STREAMING, default='allow')
|
|
# Remaining times and lengths are shown at the playback speed.
|
|
times_by_speed = models.BooleanField(default=False)
|
|
|
|
# The playback settings above, as the API names them; the first four
|
|
# start out unset.
|
|
PLAYBACK_FIELDS = ('skip_back', 'skip_forward', 'smart_played', 'speed_presets', 'keep_skipped',
|
|
'delete_dequeue', 'mobile_downloads', 'mobile_streaming', 'times_by_speed')
|
|
UNSET_FIELDS = ('skip_back', 'skip_forward', 'smart_played', 'speed_presets')
|
|
|
|
@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}'
|