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 : 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 . 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}'