TranscriptionSettings (Token Fernet-verschluesselt wie SiteLogin), TranscriptionJob und EpisodeArticle in podcasts; Source.synthetic in feeds, das Refresh, Aufraeumen, OPML-Export, Kommando und Umstellen auf Nachrichten ueberspringt bzw. verweigert. GET/POST /podcasts/transcription/settings/ und POST /podcasts/transcription/test/ (SSRF-Pruefung ueber chapters._open, jetzt auch fuer POST; eingebaute Instanz aus DIORA_WHISPER_URL ausgenommen). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
532 lines
26 KiB
Python
532 lines
26 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); with the
|
|
# lists of docs/podcasts.md (PodcastSettings.lists_v2) these are the subscription levels:
|
|
# inbox = Strom, queue = Stamm (into the Merkliste), play = Stamm straight into the
|
|
# Wiedergabeliste, ignore = Auf Abruf. Without lists_v2, queue and play both queue.
|
|
NEW_INBOX = 'inbox'
|
|
NEW_QUEUE = 'queue'
|
|
NEW_PLAY = 'play'
|
|
NEW_IGNORE = 'ignore'
|
|
NEW_EPISODE_ACTIONS = [
|
|
(NEW_INBOX, 'Im Strom'),
|
|
(NEW_QUEUE, 'Stamm: in die Merkliste'),
|
|
(NEW_PLAY, 'Stamm: in die Wiedergabeliste'),
|
|
(NEW_IGNORE, 'Auf Abruf'),
|
|
]
|
|
# How often the feed is fetched: '' = by its rhythm (refresh_due()), or fixed.
|
|
REFRESH_INTERVALS = [('', 'Automatisch'), ('hourly', 'Stündlich'), ('daily', 'Täglich'),
|
|
('weekly', 'Wöchentlich')]
|
|
# 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)
|
|
# What it is: a podcast, or an audiobook (Hörbuch, Hörspiel), which lives in the
|
|
# library: its episodes are chapters in order, never news (docs/audiobooks.md).
|
|
PODCAST = 'podcast'
|
|
AUDIOBOOK = 'audiobook'
|
|
KINDS = [(PODCAST, 'Podcast'), (AUDIOBOOK, 'Hörbuch')]
|
|
|
|
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)
|
|
# Fingerprint of the last feed body that was read: many servers ignore the
|
|
# validators above and send the whole feed every time; the same body again
|
|
# then counts as unchanged and isn't parsed (views._body_hash).
|
|
http_body_hash = models.CharField(max_length=64, 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)
|
|
refresh_interval = models.CharField(max_length=10, choices=REFRESH_INTERVALS, default='', blank=True)
|
|
# A favourite subscription (★ in the Abos): only a mark and a filter, whatever its level —
|
|
# it puts nothing on the Merkliste by itself (docs/podcasts.md).
|
|
favorite = models.BooleanField(default=False)
|
|
# 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)
|
|
kind = models.CharField(max_length=10, choices=KINDS, default=PODCAST)
|
|
# Its folders (docs/library.md): like tags, any number; shown as filter chips. `folder`
|
|
# is the first of them, for older clients.
|
|
folders = models.JSONField(default=list, blank=True)
|
|
folder = models.CharField(max_length=100, 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 is 'queue' or 'play'). 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))
|
|
queues = (self.NEW_QUEUE, self.NEW_PLAY)
|
|
if self.new_episodes != new_before or self.auto_queue == auto_before:
|
|
self.auto_queue = self.new_episodes in queues
|
|
elif self.auto_queue:
|
|
self.new_episodes = self.NEW_QUEUE
|
|
elif self.new_episodes in queues:
|
|
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)
|
|
# The pauses for skipping silence in the browser (silence.py), found on
|
|
# first request: {"spans": [[start, end], …]} (+ "error"); null = not yet.
|
|
silences = models.JSONField(null=True, blank=True)
|
|
# The ad and the outro of a YouTube episode (sponsor.py, SponsorBlock), asked for
|
|
# on first request: {"spans": [[start, end], …], "at": when, (+ "error")}.
|
|
sponsor = models.JSONField(null=True, 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}'
|
|
|
|
|
|
class TranscriptionSettings(models.Model):
|
|
"""Where a user's episodes are transcribed (docs/transcription.md): the
|
|
built-in Whisper instance (DIORA_WHISPER_URL) or their own."""
|
|
BUILTIN = 'builtin'
|
|
CUSTOM = 'custom'
|
|
ENGINES = [(BUILTIN, 'Eingebaut'), (CUSTOM, 'Eigene Instanz')]
|
|
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='transcription_settings')
|
|
engine = models.CharField(max_length=10, choices=ENGINES, default=BUILTIN)
|
|
# The instance's base, e.g. https://whisper.example.org/v1 (custom only).
|
|
url = models.CharField(max_length=500, blank=True)
|
|
# The API token, Fernet-encrypted like feeds.SiteLogin.secret; never handed out.
|
|
token_encrypted = models.TextField(blank=True)
|
|
model = models.CharField(max_length=100, blank=True)
|
|
# '' = automatic, else an ISO code ("de").
|
|
language = models.CharField(max_length=10, blank=True)
|
|
parallel = models.PositiveSmallIntegerField(default=3) # 1 to 8 pieces at once
|
|
|
|
def __str__(self):
|
|
return f'Transcription settings of {self.user}'
|
|
|
|
|
|
class TranscriptionJob(models.Model):
|
|
"""One episode's transcription run, its Whisper result and the wish for an
|
|
article. Kept apart from EpisodeTranscript, which caches the feed's
|
|
transcripts and is rewritten when the feed changes them."""
|
|
NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR = (
|
|
'none', 'queued', 'downloading', 'transcribing', 'done', 'error')
|
|
STATES = [(s, s) for s in (NONE, QUEUED, DOWNLOADING, TRANSCRIBING, DONE, ERROR)]
|
|
|
|
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='transcription_job')
|
|
state = models.CharField(max_length=15, choices=STATES, default=NONE)
|
|
chunks_done = models.PositiveIntegerField(default=0)
|
|
chunks_total = models.PositiveIntegerField(default=0)
|
|
error = models.CharField(max_length=500, blank=True)
|
|
want_article = models.BooleanField(default=False)
|
|
# The device writing the article (a short name) and until when it holds the wish.
|
|
claimed_by = models.CharField(max_length=60, blank=True)
|
|
claimed_until = models.DateTimeField(null=True, blank=True)
|
|
# {"segments": [{start, end, speaker, text}], "source": {type: "whisper", model, language}}
|
|
data = models.JSONField(null=True, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
started_at = models.DateTimeField(null=True, blank=True)
|
|
finished_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
def __str__(self):
|
|
return f'Transcription of {self.episode} ({self.state})'
|
|
|
|
|
|
class EpisodeArticle(models.Model):
|
|
"""The article written from an episode's transcript; its text is the
|
|
feeds.Item (in the blog "Podcast-Artikel")."""
|
|
episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='article')
|
|
item = models.OneToOneField('feeds.Item', on_delete=models.CASCADE, related_name='episode_article')
|
|
model = models.CharField(max_length=100, blank=True)
|
|
kind = models.CharField(max_length=20, blank=True) # Interview, Gespräch, Monolog, Diskussion
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
def __str__(self):
|
|
return f'Article on {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 EpisodeBookmark(models.Model):
|
|
"""A place in an episode kept with a note (an audiobook's bookmarks)."""
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='episode_bookmarks')
|
|
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='bookmarks')
|
|
position_ms = models.PositiveIntegerField()
|
|
note = models.CharField(max_length=1000, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['position_ms', 'id']
|
|
|
|
def __str__(self):
|
|
return f'{self.user} - {self.episode} @ {self.position_ms} ms'
|
|
|
|
|
|
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 PodcastLater(models.Model):
|
|
"""The Merkliste (docs/podcasts.md): episodes the user wants to hear some day. It may
|
|
grow and never expires; shown grouped by podcast, so it has no order of its own. An
|
|
episode can be in it and in the Wiedergabeliste (PodcastQueue) at once; heard, it leaves
|
|
both. auto: put there by a Stamm subscription, not by hand."""
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_later')
|
|
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='later_by')
|
|
added_at = models.DateTimeField(auto_now_add=True)
|
|
auto = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
unique_together = ('user', 'episode')
|
|
|
|
def __str__(self):
|
|
return f'{self.user} Merkliste: {self.episode}'
|
|
|
|
|
|
class PodcastRadioHeard(models.Model):
|
|
"""An episode the Podcast-Radio is done with (docs/podcasts.md): it ran to its end there or
|
|
was skipped. Apart from the progress on purpose: it counts as heard nowhere else (no
|
|
history, no played mark, not in heard_365). Kept, so the radio never comes back to it."""
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='podcast_radio_heard')
|
|
episode = models.ForeignKey(PodcastEpisode, on_delete=models.CASCADE, related_name='radio_heard_by')
|
|
at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
unique_together = ('user', 'episode')
|
|
|
|
def __str__(self):
|
|
return f'{self.user} Radio: {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)
|
|
# The order of the podcast/audiobook folders (their chips), names.
|
|
folder_order = models.JSONField(default=list, blank=True)
|
|
# The two lists of docs/podcasts.md: PodcastQueue is the Wiedergabeliste (the player goes
|
|
# through it and stops at its end), PodcastLater the Merkliste, and Stamm subscriptions
|
|
# fill the Merkliste. Off: the one queue as before (apps not updated yet).
|
|
lists_v2 = models.BooleanField(default=False)
|
|
# When the user last looked at the Strom: episodes found after it carry a mark.
|
|
stream_seen_at = models.DateTimeField(null=True, 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='+')
|
|
# The episode plays in the Podcast-Radio (docs/podcasts.md): shown so elsewhere, and a
|
|
# transfer goes on with the radio there rather than playing it the usual way.
|
|
podcast_radio = models.BooleanField(default=False)
|
|
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)
|
|
# The active device's sleep timer at updated_at: playing time left (counts
|
|
# down while it plays), or episodes left; both 0: off.
|
|
sleep_left_ms = models.PositiveBigIntegerField(default=0)
|
|
sleep_episodes = models.PositiveSmallIntegerField(default=0)
|
|
|
|
@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}'
|