diora-web/podcasts/test_player.py
mrwnslz a937ed8b3e Folgen haben einen Link: PodcastEpisode.link aus dem <link> des Feed-Eintrags (neu und beim Abruf nachgetragen), in episode_detail und allen Folgenlisten
Grundlage fürs Teilen einer Folge; Migration 0027.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:32:13 +01:00

393 lines
20 KiB
Python

import json
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone as dj_timezone
from accounts.models import ApiToken
from . import views
from .models import PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastQueue
STATION = {'url': 'https://example.org/stream', 'name': 'Example FM', 'id': 7}
class PlayerSyncTests(TestCase):
"""The shared player (podcasts/views.py, "Player sync"): reports, commands,
presence and the stream."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
self.ep = PodcastEpisode.objects.create(
feed=self.feed, guid='e1', title='E1', audio_url='https://example.org/e1.mp3', duration_seconds=3600,
pub_date=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
self.ep2 = PodcastEpisode.objects.create(
feed=self.feed, guid='e2', title='E2', audio_url='https://example.org/e2.mp3',
)
def post(self, path, body):
return self.client.post(f'/podcasts/player/{path}/', json.dumps(body), content_type='application/json')
def report(self, device, playing, kind='podcast', episode=None, pos=0, **extra):
body = {'device_id': device, 'device_name': device.upper(), 'kind': kind, 'position_ms': pos,
'playing': playing, **extra}
if kind == 'podcast':
body['episode_id'] = (episode or self.ep).id
elif kind == 'radio':
body['station'] = STATION
return self.post('report', body)
def command(self, device, action, value=None, to=None):
body = {'device_id': device, 'device_name': device.upper(), 'action': action}
if value is not None:
body['value'] = value
if to is not None:
body['to_device_id'] = to
return self.post('command', body)
def state(self):
return PlayerState.objects.get(user=self.user)
def go_offline(self, device, seconds=60):
PlayerDevice.objects.filter(user=self.user, device_id=device).update(
last_seen=dj_timezone.now() - timedelta(seconds=seconds))
def test_logged_out(self):
self.client.logout()
for r in (self.client.get('/podcasts/player/'), self.client.get('/podcasts/player/stream/'),
self.report('a', True), self.command('a', 'pause')):
self.assertEqual(r.status_code, 401)
self.assertEqual(r.json(), {'error': 'authentication required'})
def test_bearer_token(self):
self.client.logout()
token = ApiToken.objects.create(user=self.user)
r = self.client.get('/podcasts/player/', HTTP_AUTHORIZATION=f'Bearer {token.token}')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['kind'], '')
def test_empty_state(self):
d = self.client.get('/podcasts/player/').json()
self.assertEqual(d['seq'], 0)
self.assertEqual(d['kind'], '')
self.assertIsNone(d['episode'])
self.assertIsNone(d['station'])
self.assertIs(d['playing'], False)
self.assertIsNone(d['command'])
self.assertEqual(d['devices'], [])
self.assertIs(d['active_online'], False)
self.assertRegex(d['server_time'], r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$')
def test_play_makes_the_device_active(self):
PodcastQueue.objects.create(user=self.user, episode=self.ep, position=0)
d = self.report('a', True, pos=12345, speed=1.5).json()
self.assertEqual(d['seq'], 2) # the report, and device a coming online
self.assertEqual((d['kind'], d['playing'], d['position_ms'], d['speed']), ('podcast', True, 12345, 1.5))
self.assertEqual((d['device_id'], d['device_name']), ('a', 'A'))
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
'in_queue'})
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))
self.assertRegex(d['updated_at'], r'Z$')
self.assertEqual(d['devices'], [{'id': 'a', 'name': 'A', 'online': True, 'active': True}])
self.assertIs(d['active_online'], True)
self.assertEqual(self.client.get('/podcasts/player/').json()['seq'], 2)
def test_play_on_another_device_takes_over(self):
self.report('a', True)
self.command('b', 'pause')
d = self.report('b', True, kind='radio', start=True).json()
self.assertEqual((d['device_id'], d['kind'], d['playing']), ('b', 'radio', True))
self.assertEqual(d['station'], STATION)
self.assertIsNone(d['episode'])
self.assertIsNone(d['command']) # it was for device a
self.assertEqual([(x['id'], x['active']) for x in d['devices']], [('a', False), ('b', True)])
def test_late_periodic_report_of_the_old_device_is_ignored(self):
# b takes over; a's 10 s "playing" report, already on its way, comes after.
self.report('a', True, pos=1000)
self.report('b', True, pos=1500, start=True)
seq = self.state().seq
d = self.report('a', True, pos=11000).json()
self.assertEqual((d['seq'], d['device_id'], d['position_ms'], d['playing']), (seq, 'b', 1500, True))
# The user starts playback on a again: then a is the active device.
d = self.report('a', True, pos=12000, start=True).json()
self.assertEqual((d['device_id'], d['position_ms']), ('a', 12000))
def test_playing_without_start_wins_only_without_an_online_active_device(self):
self.report('a', False, pos=1000) # a is the active device, paused
self.assertEqual(self.report('b', True, pos=7).json()['device_id'], 'a')
self.go_offline('a')
self.assertEqual(self.report('b', True, pos=7).json()['device_id'], 'b')
def test_seq_only_goes_up(self):
seqs = [self.client.get('/podcasts/player/').json()['seq']]
seqs.append(self.report('a', True, start=True).json()['seq'])
seqs.append(self.report('b', True).json()['seq']) # ignored, b came online
seqs.append(self.command('b', 'pause').json()['seq'])
seqs.append(self.report('b', False).json()['seq']) # ignored
seqs.append(self.report('a', False).json()['seq'])
self.go_offline('a')
views._check_online(self.user)
seqs.append(self.client.get('/podcasts/player/').json()['seq'])
self.assertEqual(seqs, sorted(seqs))
self.assertEqual(seqs, [0, 2, 3, 4, 4, 5, 6])
def test_active_device_updates_and_stays_active_while_paused(self):
self.report('a', True, pos=1000)
d = self.report('a', False, episode=self.ep2, pos=5000).json()
self.assertEqual((d['device_id'], d['playing'], d['episode']['id'], d['position_ms']), ('a', False, self.ep2.id, 5000))
# Paused, but online: device b's paused choice doesn't count.
seq = d['seq']
d = self.report('b', False, episode=self.ep, pos=99).json()
self.assertEqual((d['seq'], d['device_id'], d['episode']['id'], d['position_ms']), (seq + 1, 'a', self.ep2.id, 5000))
# (seq + 1: b came online; the report itself changed nothing.)
self.assertIs(d['active_online'], True)
self.assertEqual(self.state().device_id, 'a')
def test_paused_report_ignored_while_the_active_device_plays(self):
self.report('a', True, pos=1000)
self.report('b', True, start=True)
self.report('a', True, pos=2000, start=True) # a plays again: a is active
seq = self.state().seq
d = self.report('b', False, kind='').json()
self.assertEqual((d['seq'], d['device_id'], d['playing'], d['kind']), (seq, 'a', True, 'podcast'))
def test_active_device_offline_newest_report_wins(self):
self.report('a', True, pos=1000)
self.report('b', False, pos=1) # b online too
self.go_offline('a')
d = self.report('b', False, kind='radio').json()
self.assertEqual((d['device_id'], d['kind'], d['playing']), ('b', 'radio', False))
self.assertIs(d['active_online'], True)
self.assertEqual([(x['id'], x['online']) for x in d['devices']], [('b', True), ('a', False)])
def test_online_is_presence_not_the_last_report(self):
# Rule 4: playing with the last report a minute old, but the device is
# still online (its stream pings): still active.
self.report('a', True, pos=1000)
PlayerState.objects.filter(user=self.user).update(updated_at=dj_timezone.now() - timedelta(seconds=60))
d = self.report('b', False, kind='radio').json()
self.assertEqual(d['device_id'], 'a')
# 45 s without a sign of life: gone.
self.go_offline('a', 44)
self.assertEqual(self.report('b', False, kind='radio').json()['device_id'], 'a')
self.go_offline('a', 46)
self.assertEqual(self.report('b', False, kind='radio').json()['device_id'], 'b')
def test_no_active_device_newest_report_wins(self):
d = self.report('a', False, pos=4000).json()
self.assertEqual((d['device_id'], d['position_ms'], d['playing']), ('a', 4000, False))
def test_empty_kind_clears_the_item(self):
self.report('a', True, kind='radio')
d = self.report('a', True, kind='').json()
self.assertEqual((d['kind'], d['station'], d['episode'], d['playing']), ('', None, None, False))
def test_invalid_reports(self):
other = User.objects.create_user(username='bob', password='pw12345678')
feed = PodcastFeed.objects.create(user=other, rss_url='https://example.org/feed', title='Bob')
theirs = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url='https://example.org/x.mp3')
r = self.report('a', True, episode=theirs)
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json()['error'], 'unknown episode')
for body in ({'kind': 'podcast', 'playing': True}, # no device
{'device_id': 'a', 'kind': 'tv', 'playing': True},
{'device_id': 'a', 'kind': 'podcast', 'playing': True}, # no episode
{'device_id': 'a', 'kind': 'radio', 'playing': True, 'station': {'name': 'x'}},
{'device_id': 'a', 'kind': '', 'playing': 'yes'},
{'device_id': 'a', 'kind': '', 'playing': True, 'position_ms': 'x'}):
with self.subTest(body=body):
self.assertEqual(self.post('report', body).status_code, 400)
self.assertFalse(PlayerState.objects.filter(user=self.user, seq__gt=0).exists())
def test_command_is_stored_and_cleared_by_done_command(self):
self.report('a', True, pos=1000)
r = self.command('b', 'seek', 61000)
self.assertEqual(r.status_code, 200)
d = r.json()
cmd = d['command']
self.assertEqual((cmd['action'], cmd['value'], cmd['from_device_id'], cmd['from_device_name'], cmd['to_device_id']),
('seek', 61000, 'b', 'B', 'a'))
self.assertRegex(cmd['id'], r'^[0-9a-f]{32}$')
self.assertRegex(cmd['at'], r'Z$')
seq = d['seq']
# A report without it (the 10 s one) leaves it.
d = self.report('a', True, pos=2000).json()
self.assertEqual((d['seq'], d['command']), (seq + 1, cmd))
d = self.report('a', True, pos=61000, done_command='nope').json()
self.assertEqual(d['command'], cmd)
d = self.report('a', True, pos=61000, done_command=cmd['id']).json()
self.assertIsNone(d['command'])
self.assertEqual(d['seq'], seq + 3)
self.assertEqual(self.command('b', 'skip', -15).json()['command']['value'], -15)
def test_commands_to_a_paused_active_device(self):
# Device 1 plays, device 2 pauses it; "play" on device 2 resumes on device 1.
self.report('d1', True, pos=1000)
pause = self.command('d2', 'pause').json()['command']
self.report('d1', False, pos=3000, done_command=pause['id'])
d = self.command('d2', 'play').json()
self.assertEqual((d['command']['action'], d['command']['to_device_id']), ('play', 'd1'))
self.assertEqual((d['device_id'], d['playing'], d['position_ms']), ('d1', False, 3000))
self.assertIs(d['active_online'], True)
def test_command_needs_an_online_device(self):
r = self.command('b', 'pause')
self.assertEqual(r.status_code, 409) # nothing active
self.report('a', True)
self.go_offline('a')
self.assertEqual(self.command('b', 'pause').status_code, 409)
self.assertEqual(self.command('b', 'transfer', to='zzz').status_code, 409)
# Device a gone: device b's play wins (even without start).
d = self.report('b', True, pos=5).json()
self.assertEqual((d['device_id'], d['playing']), ('b', True))
def test_invalid_commands(self):
self.report('a', True)
for body in ({'device_id': 'b', 'action': 'louder'},
{'device_id': 'b', 'action': 'seek'},
{'device_id': 'b', 'action': 'skip', 'value': 'x'},
{'action': 'pause'}):
with self.subTest(body=body):
self.assertEqual(self.post('command', body).status_code, 400)
def test_sleep_timer_travels_with_the_state(self):
self.report('phone', True, kind='radio', start=True, sleep={'left_ms': 900_000, 'episodes': 0})
d = self.client.get('/podcasts/player/').json()
self.assertEqual(d['sleep'], {'left_ms': 900_000, 'episodes': 0})
# Episodes left; nonsense is none; an older client without it: none.
self.report('phone', True, sleep={'left_ms': 0, 'episodes': 2})
self.assertEqual((self.state().sleep_left_ms, self.state().sleep_episodes), (0, 2))
self.report('phone', True, sleep={'left_ms': 'x', 'episodes': -4})
self.assertEqual((self.state().sleep_left_ms, self.state().sleep_episodes), (0, 0))
self.report('phone', True, sleep={'left_ms': 10 ** 12})
self.assertEqual(self.state().sleep_left_ms, views.SLEEP_MAX_MINUTES * 60_000)
self.report('phone', True)
self.assertEqual(self.state().sleep_left_ms, 0)
# Another device's ignored report doesn't change it.
self.report('phone', True, sleep={'left_ms': 60_000})
self.report('web', True, sleep={'left_ms': 5})
self.assertEqual(self.state().sleep_left_ms, 60_000)
def test_sleep_command(self):
self.report('phone', True, kind='radio', start=True)
r = self.command('web', 'sleep', 30)
self.assertEqual(r.status_code, 200)
self.assertEqual((r.json()['command']['action'], r.json()['command']['value']), ('sleep', 30))
self.assertEqual(self.command('web', 'sleep', -1).json()['command']['value'], -1)
self.assertEqual(self.command('web', 'sleep', 0).json()['command']['value'], 0)
for bad in (None, 'x', 601, -100):
self.assertEqual(self.command('web', 'sleep', bad).status_code, 400, bad)
def test_transfer_to_another_device(self):
self.report('a', True, pos=1000)
self.report('c', False, pos=0) # c is online (paused report: ignored)
d = self.command('b', 'transfer', to='c').json()
cmd = d['command']
self.assertEqual((cmd['action'], cmd['to_device_id'], cmd['from_device_id']), ('transfer', 'c', 'b'))
self.assertEqual(d['device_id'], 'a')
# a keeps reporting meanwhile: the command stays for c.
self.assertEqual(self.report('a', True, pos=2000).json()['command'], cmd)
# c plays it from the place: c is the active device, the command is done.
d = self.report('c', True, pos=2100, done_command=cmd['id'], start=True).json()
self.assertEqual((d['device_id'], d['playing'], d['command']), ('c', True, None))
def test_transfer_done_in_the_report_that_takes_over(self):
self.report('a', True, pos=1000)
self.report('c', False)
cmd = self.command('b', 'transfer', to='c').json()['command']
d = self.report('c', True, pos=1100, start=True, done_command=cmd['id']).json()
self.assertEqual((d['device_id'], d['command']), ('c', None))
# a's periodic report, sent before it heard of the transfer: ignored.
self.assertEqual(self.report('a', True, pos=11000).json()['device_id'], 'c')
def test_transfer_that_could_not_play_is_done_too(self):
self.report('a', True)
self.report('c', False)
cmd = self.command('b', 'transfer', to='c').json()['command']
d = self.report('c', False, done_command=cmd['id']).json()
self.assertEqual((d['device_id'], d['command']), ('a', None))
def test_devices_coming_and_going_change_seq(self):
self.report('a', True)
seq = self.state().seq
views._check_online(self.user)
self.assertEqual(self.state().seq, seq) # nothing changed
self.report('a', True)
self.assertEqual(self.state().seq, seq + 1) # a report, no new device
self.go_offline('a')
views._check_online(self.user)
self.assertEqual(self.state().seq, seq + 2) # a went away
self.assertIs(self.client.get('/podcasts/player/').json()['active_online'], False)
def test_device_list(self):
now = dj_timezone.now()
for i in range(12):
PlayerDevice.objects.create(user=self.user, device_id=f'old{i}', device_name=f'Old {i}',
last_seen=now - timedelta(hours=i + 1))
PlayerDevice.objects.create(user=self.user, device_id='ancient', last_seen=now - timedelta(days=8))
self.report('z', True)
self.report('m', False)
devices = self.client.get('/podcasts/player/').json()['devices']
self.assertEqual(len(devices), 10)
self.assertEqual([d['id'] for d in devices[:3]], ['m', 'z', 'old0'])
self.assertNotIn('ancient', [d['id'] for d in devices])
def stream(self, since=None, **params):
if since is not None:
params['since'] = since
r = self.client.get('/podcasts/player/stream/', params)
self.assertEqual(r['Content-Type'], 'text/event-stream')
self.assertEqual(r['Cache-Control'], 'no-cache')
self.assertEqual(r['X-Accel-Buffering'], 'no')
events = []
for chunk in r.streaming_content:
text = chunk.decode()
if text.startswith('data: '):
events.append(json.loads(text[len('data: '):]))
else:
self.assertEqual(text, ': ping\n\n')
return events
@patch.object(views, 'STREAM_SECONDS', 0.05)
@patch.object(views, 'STREAM_POLL_SECONDS', 0.01)
def test_stream_sends_newer_state_and_ends(self):
self.report('a', True, pos=777)
seq = self.state().seq
events = self.stream(seq - 1)
self.assertEqual(len(events), 1)
self.assertEqual((events[0]['seq'], events[0]['position_ms'], events[0]['device_id']), (seq, 777, 'a'))
self.assertEqual(self.stream(seq), []) # nothing newer: pings only
@patch.object(views, 'STREAM_SECONDS', 0.05)
@patch.object(views, 'STREAM_POLL_SECONDS', 0.01)
def test_stream_sends_changes(self):
self.report('a', True)
seq = self.state().seq
sleep = views.time.sleep
changes = []
def change_once(seconds):
if not changes:
changes.append(1)
self.report('a', False, pos=42)
sleep(seconds)
with patch.object(views.time, 'sleep', change_once):
events = self.stream(seq)
self.assertEqual([(e['playing'], e['position_ms']) for e in events], [(False, 42)])
@patch.object(views, 'STREAM_SECONDS', 0.05)
@patch.object(views, 'STREAM_POLL_SECONDS', 0.01)
def test_stream_counts_as_presence(self):
events = self.stream(0, device_id='web1', device_name='Browser (Firefox/Linux)', device_kind='web')
self.assertEqual(len(events), 1) # web1 came online: seq 1
self.assertEqual(events[0]['devices'], [{'id': 'web1', 'name': 'Browser (Firefox/Linux)', 'online': True,
'active': False}])
self.assertEqual(PlayerDevice.objects.get(device_id='web1').kind, 'web')