diora-web/podcasts/models.py

319 lines
14 KiB
Python
Raw Normal View History

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