- Neuer Befehl `manage.py import_antennapod <backup.db> --user <name> [--dry-run] [--global-auto-download on|off] [--backup-date ...]`: übernimmt aus einer AntennaPod-Datenbanksicherung die Abos (Abgleich über normalisierte URL, fehlende werden angelegt, lokale übersprungen), die Podcast-Einstellungen (Tempo, Intro/Outro, Lautstärke, Filter mit Phrasen in Anführungszeichen, Mindestlänge, neue Folgen, „nicht aktualisieren“ -> ignorieren, automatischer Download), den Zustand jeder Folge mit Zustand (gehört, neu, Position, Favorit, Zeitpunkt), die Warteschlange (AntennaPods Reihenfolge zuerst) und nimmt bekannte, dort nicht neue Folgen aus „Neu“. Neuere Änderungen in diora bleiben, Favoriten werden vereinigt. Alles in einer Transaktion; ein zweiter Lauf ändert nichts mehr. - PodcastFeed.auto_download (Einstellung pro Podcast, in feeds/, state/ und feeds/<id>/settings/) und PodcastSettings.auto_download_default (in settings/ und state/), mit dem neue Abos (Hinzufügen, OPML, gpodder, Import) starten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
11 KiB
Python
252 lines
11 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)
|
|
|
|
@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}'
|