diora-web/podcasts/models.py
mrwnslz 69442f4a4d Smarte Transkription B: Datenmodell, Einstellungen und Test-Endpunkt
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>
2026-11-09 09:27:01 +01:00

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