Migrationen hintereinander statt nebeneinander: 0007_feed_settings, dann 0008_feed_http_cache. Tests der Podcast-Regeln an die neue _fetch_feed-Schnittstelle (FeedFetch) angepasst; Feeds-Liste trägt Einstellungen und Fehlerfelder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
9.2 KiB
Python
218 lines
9.2 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
|
|
|
|
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 the JSON at chapters_url once it has been fetched.
|
|
chapters = models.JSONField(default=list, blank=True)
|
|
# When chapters_url was fetched into `chapters`; 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
|
|
|
|
|
|
# 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)
|
|
|
|
@classmethod
|
|
def for_user(cls, user):
|
|
obj, _ = cls.objects.get_or_create(user=user)
|
|
return obj
|
|
|
|
def __str__(self):
|
|
return f'Podcast settings of {self.user}'
|