Compare commits
No commits in common. "3577fca9ba6d2e1c60f3e9ebb91507765a3851c4" and "262dd0d207a78322761c098a3ec340299f33af42" have entirely different histories.
3577fca9ba
...
262dd0d207
12 changed files with 131 additions and 1225 deletions
|
|
@ -43,7 +43,7 @@ SUBSCRIBED = 0 # Feeds.state
|
|||
MAX_FILESIZE = 4_000_000_000
|
||||
|
||||
SETTING_FIELDS = ('playback_speed', 'skip_intro', 'skip_outro', 'volume', 'include_words',
|
||||
'exclude_words', 'min_duration', 'new_episodes', 'auto_download', 'notify')
|
||||
'exclude_words', 'min_duration', 'new_episodes', 'auto_download')
|
||||
|
||||
# Columns read from the backup, with the value to assume if an older
|
||||
# AntennaPod didn't have the column yet.
|
||||
|
|
@ -52,7 +52,7 @@ FEED_COLUMNS = {
|
|||
'image_url': '', 'keep_updated': 1, 'include_filter': '', 'exclude_filter': '',
|
||||
'minimal_duration_filter': -1, 'new_episodes_action': 0, 'feed_playback_speed': -1,
|
||||
'feed_skip_intro': 0, 'feed_skip_ending': 0, 'feed_volume_adaption': 0, 'auto_download': 1,
|
||||
'episode_notification': 0, 'state': SUBSCRIBED,
|
||||
'state': SUBSCRIBED,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -191,10 +191,6 @@ def feed_settings(ap, global_auto_download=False):
|
|||
s['auto_download'] = bool(global_auto_download)
|
||||
elif code == AUTO_DOWNLOAD_OFF:
|
||||
s['auto_download'] = False
|
||||
# Feeds.episode_notification: 1 = a notification for each new episode
|
||||
# (0, AntennaPod's default, leaves diora's setting alone).
|
||||
if ap['episode_notification']:
|
||||
s['notify'] = True
|
||||
return s, not_kept_updated
|
||||
|
||||
|
||||
|
|
@ -773,7 +769,7 @@ class AntennaPodImport:
|
|||
SETTING_LABELS = {
|
||||
'playback_speed': 'speed', 'skip_intro': 'skip intro', 'skip_outro': 'skip outro', 'volume': 'volume',
|
||||
'include_words': 'include words', 'exclude_words': 'exclude words', 'min_duration': 'min duration',
|
||||
'new_episodes': 'new episodes', 'auto_download': 'auto-download', 'notify': 'notifications',
|
||||
'new_episodes': 'new episodes', 'auto_download': 'auto-download',
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0012_player_state'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='podcastfeed',
|
||||
name='notify',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
|
@ -51,9 +51,6 @@ class PodcastFeed(models.Model):
|
|||
# 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)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'rss_url')
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@ class Backup:
|
|||
minimal_duration_filter INTEGER DEFAULT -1, new_episodes_action INTEGER DEFAULT 0,
|
||||
feed_playback_speed REAL DEFAULT -1, feed_skip_intro INTEGER DEFAULT 0,
|
||||
feed_skip_ending INTEGER DEFAULT 0, feed_volume_adaption INTEGER DEFAULT 0,
|
||||
auto_download INTEGER DEFAULT 0, episode_notification INTEGER DEFAULT 0, state INTEGER DEFAULT 0,
|
||||
tags TEXT);
|
||||
auto_download INTEGER DEFAULT 0, state INTEGER DEFAULT 0, tags TEXT);
|
||||
CREATE TABLE FeedItems (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, pubDate INTEGER,
|
||||
read INTEGER, link TEXT, description TEXT, media INTEGER, feed INTEGER,
|
||||
item_identifier TEXT, image_url TEXT);
|
||||
|
|
@ -254,26 +253,6 @@ class FeedImportTests(ImportTestCase):
|
|||
self.assertIn('default for new subscriptions: on\n', self.run_import('--global-auto-download', 'on'))
|
||||
self.assertIn('default for new subscriptions: on\n', self.run_import())
|
||||
|
||||
def test_episode_notifications(self):
|
||||
self.diora_feed('https://example.org/diora-on', notify=True)
|
||||
self.diora_feed('https://example.org/on-both', notify=True)
|
||||
self.ap.feed('https://example.org/diora-on', episode_notification=0)
|
||||
self.ap.feed('https://example.org/on-both', episode_notification=1)
|
||||
self.ap.feed('https://example.org/on', episode_notification=1)
|
||||
self.ap.feed('https://example.org/off')
|
||||
out = self.run_import()
|
||||
values = dict(PodcastFeed.objects.filter(user=self.user).values_list('rss_url', 'notify'))
|
||||
# AntennaPod's "off" is its default: diora's own setting stays.
|
||||
self.assertEqual(values, {'https://example.org/diora-on': True, 'https://example.org/on-both': True,
|
||||
'https://example.org/on': True, 'https://example.org/off': False})
|
||||
self.assertIn('notifications 1', out)
|
||||
|
||||
def test_backup_without_episode_notification_column(self):
|
||||
self.ap.db.execute('ALTER TABLE Feeds DROP COLUMN episode_notification')
|
||||
self.ap.feed('https://example.org/old')
|
||||
self.run_import()
|
||||
self.assertFalse(PodcastFeed.objects.get(user=self.user, rss_url='https://example.org/old').notify)
|
||||
|
||||
def test_created_feeds_start_with_the_users_default(self):
|
||||
PodcastSettings.objects.create(user=self.user, auto_download_default=True)
|
||||
self.ap.feed('https://example.org/explicit-off', auto_download=0)
|
||||
|
|
|
|||
|
|
@ -837,67 +837,8 @@ class EpisodeListTests(TestCase):
|
|||
|
||||
def test_bad_parameters(self):
|
||||
self.assertEqual(self.client.get('/podcasts/episodes/', {'filter': 'new'}).status_code, 400)
|
||||
self.assertEqual(self.client.get('/podcasts/episodes/', {'filter': 'unplayed,new'}).status_code, 400)
|
||||
self.assertEqual(self.client.get('/podcasts/episodes/', {'limit': 'ten'}).status_code, 400)
|
||||
self.assertEqual(self.client.get('/podcasts/episodes/', {'feed_id': 'x'}).status_code, 400)
|
||||
self.assertEqual(self.client.get('/podcasts/episodes/', {'ids': '1,x'}).status_code, 400)
|
||||
self.assertEqual(self.client.get('/podcasts/episodes/', {'sort': 'random'}).status_code, 400)
|
||||
|
||||
def test_queued(self):
|
||||
self.assertEqual(self.ids(filter='queued'), [self.a2.id])
|
||||
|
||||
def test_filters_combine(self):
|
||||
# Several filters, joined by commas, all have to match.
|
||||
self.assertEqual(self.ids(filter='unplayed,queued'), [self.a2.id])
|
||||
self.assertEqual(self.ids(filter='in_progress,queued'), [self.a2.id])
|
||||
self.assertEqual(self.ids(filter='unplayed,favorites'), [self.a3.id])
|
||||
self.assertEqual(self.ids(filter='played,favorites'), [])
|
||||
self.assertEqual(self.ids(filter='history,in_progress'), [self.a2.id, self.b1.id])
|
||||
self.assertEqual(self.ids(filter='all,favorites'), [self.a3.id])
|
||||
self.assertEqual(self.ids(filter='unplayed,'), self.ids(filter='unplayed'))
|
||||
self.assertEqual(self.get(filter='unplayed,queued')['total'], 1)
|
||||
|
||||
def test_sorting(self):
|
||||
newest = [e.id for e in (self.b2, self.b1, self.a3, self.a2, self.a1, self.a4)]
|
||||
self.assertEqual(self.ids(sort='new'), newest)
|
||||
# Oldest first; without a date still last.
|
||||
self.assertEqual(self.ids(sort='old'), [e.id for e in (self.a1, self.a2, self.a3, self.b1, self.b2, self.a4)])
|
||||
PodcastEpisode.objects.filter(pk=self.a2.pk).update(duration_seconds=1200)
|
||||
PodcastEpisode.objects.filter(pk=self.b1.pk).update(duration_seconds=300)
|
||||
# By length; unknown lengths (0) last, newest first among them.
|
||||
self.assertEqual(self.ids(sort='long')[:3], [self.a2.id, self.a1.id, self.b1.id])
|
||||
self.assertEqual(self.ids(sort='short')[:3], [self.b1.id, self.a1.id, self.a2.id])
|
||||
self.assertEqual(self.ids(sort='short')[3:], [self.b2.id, self.a3.id, self.a4.id])
|
||||
self.assertEqual(self.ids(sort='title'), [e.id for e in (self.a1, self.a2, self.a3, self.a4, self.b2, self.b1)])
|
||||
# The history sorts by date too when asked; by default by the last listening.
|
||||
self.assertEqual(self.ids(filter='history', sort='new'), [self.b1.id, self.a2.id, self.a1.id])
|
||||
self.assertEqual(self.ids(filter='unplayed', sort='played'), [e.id for e in (self.a3, self.b2, self.a2, self.b1, self.a4)])
|
||||
|
||||
def test_ids(self):
|
||||
# The episodes downloaded on a device; someone else's stay out.
|
||||
bobs = PodcastEpisode.objects.get(feed__user__username='bob')
|
||||
ids = f'{self.a1.id},{self.b1.id},{bobs.id}'
|
||||
self.assertEqual(self.ids(ids=ids), [self.b1.id, self.a1.id])
|
||||
self.assertEqual(self.ids(ids=ids, filter='unplayed'), [self.b1.id])
|
||||
self.assertEqual(self.ids(ids=''), [])
|
||||
|
||||
def test_search_in_shownotes(self):
|
||||
PodcastEpisode.objects.filter(pk=self.a1.pk).update(description='<p>Heute mit dem Gast Bonusmaterial</p>')
|
||||
# By title only unless asked; with notes=1 title matches first.
|
||||
self.assertEqual(self.ids(q='bonus'), [self.b1.id])
|
||||
self.assertEqual(self.ids(q='bonus', notes='1'), [self.b1.id, self.a1.id])
|
||||
self.assertEqual(self.ids(q='gast', notes='1'), [self.a1.id])
|
||||
self.assertEqual(self.ids(q='gast', notes='1', filter='unplayed'), [])
|
||||
self.assertEqual(self.get(q='bonus', notes='1')['total'], 2)
|
||||
# The notes never come along in the list.
|
||||
self.assertNotIn('description', self.get(q='gast', notes='1')['episodes'][0])
|
||||
|
||||
def test_combined_query_count(self):
|
||||
from .views import _episode_list_data
|
||||
with self.assertNumQueries(2):
|
||||
episodes, total = _episode_list_data(self.user, ['unplayed', 'queued'], q='a', notes=True,
|
||||
sort='short', ids=[self.a2.id, self.a3.id])
|
||||
self.assertEqual(([e['id'] for e in episodes], total), ([self.a2.id], 1))
|
||||
|
||||
def test_login_required(self):
|
||||
self.client.logout()
|
||||
|
|
@ -1031,7 +972,6 @@ class OpmlExportTests(TestCase):
|
|||
FEED_DEFAULTS = {
|
||||
'playback_speed': None, 'skip_intro': 0, 'skip_outro': 0, 'volume': 1.0, 'new_episodes': 'inbox',
|
||||
'include_words': '', 'exclude_words': '', 'min_duration': 0, 'auto_queue': False, 'auto_download': False,
|
||||
'notify': False,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1057,7 +997,7 @@ class FeedSettingsTests(TestCase):
|
|||
self.assertEqual(r.json(), {
|
||||
'feed_id': self.feed.id, 'playback_speed': 1.5, 'skip_intro': 30, 'skip_outro': 600, 'volume': 2.0,
|
||||
'new_episodes': 'inbox', 'include_words': 'News, Talk', 'exclude_words': 'Teaser', 'min_duration': 10,
|
||||
'auto_queue': False, 'auto_download': False, 'notify': False,
|
||||
'auto_queue': False, 'auto_download': False,
|
||||
})
|
||||
self.feed.refresh_from_db()
|
||||
self.assertEqual((self.feed.playback_speed, self.feed.skip_intro, self.feed.volume, self.feed.min_duration),
|
||||
|
|
@ -1083,7 +1023,6 @@ class FeedSettingsTests(TestCase):
|
|||
('include_words', 'x' * 501), ('include_words', 5), ('exclude_words', ['a']),
|
||||
('min_duration', -1), ('min_duration', 601),
|
||||
('auto_download', 1), ('auto_download', 'true'), ('auto_download', None),
|
||||
('notify', 1), ('notify', 'false'), ('notify', None),
|
||||
]
|
||||
for name, value in bad:
|
||||
with self.subTest(name=name, value=value):
|
||||
|
|
@ -1118,17 +1057,6 @@ class FeedSettingsTests(TestCase):
|
|||
self.feed.refresh_from_db()
|
||||
self.assertFalse(self.feed.auto_download)
|
||||
|
||||
def test_notify_on_and_off(self):
|
||||
self.assertIs(self.post({'notify': True}).json()['notify'], True)
|
||||
self.feed.refresh_from_db()
|
||||
self.assertTrue(self.feed.notify)
|
||||
# Other settings (auto_download too) leave it alone.
|
||||
self.assertIs(self.post({'auto_download': True}).json()['notify'], True)
|
||||
self.assertIs(self.post({'notify': False}).json()['notify'], False)
|
||||
self.feed.refresh_from_db()
|
||||
self.assertFalse(self.feed.notify)
|
||||
self.assertTrue(self.feed.auto_download)
|
||||
|
||||
def test_new_episodes_and_auto_queue_stay_in_step(self):
|
||||
self.assertEqual(self.post({'new_episodes': 'queue'}).json()['auto_queue'], True)
|
||||
self.feed.refresh_from_db()
|
||||
|
|
@ -1157,12 +1085,12 @@ class FeedSettingsTests(TestCase):
|
|||
|
||||
def test_feeds_and_state_carry_the_settings(self):
|
||||
self.post({'playback_speed': 1.25, 'skip_outro': 20, 'volume': 0.5, 'new_episodes': 'queue',
|
||||
'exclude_words': 'Trailer', 'min_duration': 5, 'auto_download': True, 'notify': True})
|
||||
'exclude_words': 'Trailer', 'min_duration': 5, 'auto_download': True})
|
||||
PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/2', title='Zweiter')
|
||||
expected = {
|
||||
self.feed.id: {'feed_id': self.feed.id, **FEED_DEFAULTS, 'playback_speed': 1.25, 'skip_outro': 20,
|
||||
'volume': 0.5, 'new_episodes': 'queue', 'exclude_words': 'Trailer', 'min_duration': 5,
|
||||
'auto_queue': True, 'auto_download': True, 'notify': True},
|
||||
'auto_queue': True, 'auto_download': True},
|
||||
}
|
||||
for url in ('/podcasts/feeds/', '/podcasts/state/'):
|
||||
with self.subTest(url=url):
|
||||
|
|
@ -2262,125 +2190,3 @@ class AutoDownloadDefaultTests(TestCase):
|
|||
self.set_default(False)
|
||||
opml('https://example.org/a2')
|
||||
self.assertTrue(auto_download('https://example.org/a2'))
|
||||
|
||||
|
||||
class FeedCountsTests(TestCase):
|
||||
"""Per feed in feeds/ and state/: episodes, unplayed, new, last listened;
|
||||
and what a podcast's own page needs (feeds/<id>/episodes/)."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
|
||||
link='https://example.org/', description='Ein Podcast über A.')
|
||||
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
|
||||
self.empty = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Leer')
|
||||
eps = [PodcastEpisode.objects.create(feed=self.fa, guid=f'a{i}', title=f'A{i}', pub_date=_at(1, i + 1),
|
||||
audio_url=f'https://example.org/a{i}.mp3') for i in range(5)]
|
||||
self.b1 = PodcastEpisode.objects.create(feed=self.fb, guid='b1', title='B1', audio_url='https://example.org/b1.mp3')
|
||||
_progress(self.user, eps[0], _at(2, 1), played=True)
|
||||
_progress(self.user, eps[1], _at(2, 3), position_seconds=40)
|
||||
_progress(self.user, eps[2], _at(2, 5), dismissed=True)
|
||||
_progress(self.user, eps[3], _at(2, 9), favorite=True) # a star isn't listening
|
||||
self.eps = eps
|
||||
# Bob's progress on alice's episode (can't happen) and his own feed don't count.
|
||||
bob = User.objects.create_user(username='bob', password='pw12345678')
|
||||
_progress(bob, eps[4], _at(3, 1), played=True)
|
||||
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
|
||||
xb = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
|
||||
_progress(bob, xb, _at(3, 1), played=True)
|
||||
|
||||
def test_counts_in_feeds_and_state(self):
|
||||
for url in ('/podcasts/feeds/', '/podcasts/state/'):
|
||||
with self.subTest(url=url):
|
||||
feeds = {f['id']: f for f in self.client.get(url).json()['feeds']}
|
||||
a, b, c = feeds[self.fa.id], feeds[self.fb.id], feeds[self.empty.id]
|
||||
self.assertEqual((a['episode_count'], a['unplayed_count'], a['new_count']), (5, 4, 3))
|
||||
self.assertEqual(a['last_played_at'], '2026-02-03T09:00:00+00:00')
|
||||
self.assertEqual(a['link'], 'https://example.org/')
|
||||
self.assertEqual((b['episode_count'], b['unplayed_count'], b['new_count'], b['last_played_at']),
|
||||
(1, 1, 1, None))
|
||||
self.assertEqual((c['episode_count'], c['unplayed_count'], c['new_count'], c['last_played_at']),
|
||||
(0, 0, 0, None))
|
||||
# The shownotes-sized description stays out of the state.
|
||||
self.assertNotIn('description', a)
|
||||
|
||||
def test_new_count_matches_the_inbox(self):
|
||||
inbox = self.client.get('/podcasts/state/').json()['inbox']
|
||||
feeds = {f['id']: f for f in self.client.get('/podcasts/state/').json()['feeds']}
|
||||
for feed in (self.fa, self.fb):
|
||||
self.assertEqual(feeds[feed.id]['new_count'], sum(1 for e in inbox if e['feed__id'] == feed.id))
|
||||
|
||||
def test_two_queries_however_many_feeds(self):
|
||||
from .views import _feeds_data
|
||||
for i in range(10):
|
||||
f = PodcastFeed.objects.create(user=self.user, rss_url=f'https://example.org/n{i}', title=f'N{i}')
|
||||
e = PodcastEpisode.objects.create(feed=f, guid='e', title='E', audio_url='https://example.org/e.mp3')
|
||||
_progress(self.user, e, _at(2, 2), played=True)
|
||||
with self.assertNumQueries(2):
|
||||
feeds = _feeds_data(self.user)
|
||||
self.assertEqual(len(feeds), 13)
|
||||
self.assertEqual({f['unplayed_count'] for f in feeds if f['title'].startswith('N')}, {0})
|
||||
|
||||
def test_feed_page_has_favorites_and_info(self):
|
||||
data = self.client.get(f'/podcasts/feeds/{self.fa.id}/episodes/').json()
|
||||
favs = {e['id']: e['favorite'] for e in data['episodes']}
|
||||
self.assertEqual(favs[self.eps[3].id], True)
|
||||
self.assertEqual(favs[self.eps[0].id], False)
|
||||
self.assertEqual(favs[self.eps[4].id], False) # bob's row isn't alice's
|
||||
self.assertEqual({k: data['feed'][k] for k in ('description', 'link', 'rss_url', 'last_refreshed_at')}, {
|
||||
'description': 'Ein Podcast über A.', 'link': 'https://example.org/', 'rss_url': 'https://example.org/a',
|
||||
'last_refreshed_at': None,
|
||||
})
|
||||
|
||||
|
||||
class BulkFavoriteTests(TestCase):
|
||||
"""POST /podcasts/episodes/favorite/: star or unstar several at once."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
|
||||
self.a, self.b, self.c = (
|
||||
PodcastEpisode.objects.create(feed=feed, guid=g, title=g, audio_url=f'https://example.org/{g}.mp3')
|
||||
for g in 'abc'
|
||||
)
|
||||
bob = User.objects.create_user(username='bob', password='pw12345678')
|
||||
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/feed', title='Feed')
|
||||
self.bobs = PodcastEpisode.objects.create(feed=fbob, guid='x', title='X', audio_url='https://example.org/x.mp3')
|
||||
|
||||
def post(self, body):
|
||||
return self.client.post('/podcasts/episodes/favorite/', json.dumps(body), content_type='application/json')
|
||||
|
||||
def test_star_and_unstar(self):
|
||||
p = _progress(self.user, self.a, _at(2, 1), position_seconds=30)
|
||||
r = self.post({'episode_ids': [self.a.id, self.b.id, self.bobs.id, 999999], 'favorite': True})
|
||||
self.assertEqual(r.json(), {'ok': True, 'favorite': True, 'count': 2})
|
||||
self.assertEqual(set(EpisodeProgress.objects.filter(user=self.user, favorite=True)
|
||||
.values_list('episode_id', flat=True)), {self.a.id, self.b.id})
|
||||
# The listening time of an existing row stays (history, statistics).
|
||||
p.refresh_from_db()
|
||||
self.assertEqual((p.position_seconds, p.updated_at), (30, _at(2, 1)))
|
||||
self.assertFalse(EpisodeProgress.objects.filter(episode=self.bobs).exists())
|
||||
# Again: nothing doubles.
|
||||
self.post({'episode_ids': [self.a.id, self.b.id], 'favorite': True})
|
||||
self.assertEqual(EpisodeProgress.objects.filter(user=self.user).count(), 2)
|
||||
r = self.post({'episode_ids': [self.a.id, self.b.id, self.c.id], 'favorite': False})
|
||||
self.assertEqual(r.json(), {'ok': True, 'favorite': False, 'count': 3})
|
||||
self.assertFalse(EpisodeProgress.objects.filter(favorite=True).exists())
|
||||
# Unstarring doesn't make rows for episodes that had none.
|
||||
self.assertFalse(EpisodeProgress.objects.filter(episode=self.c).exists())
|
||||
# The list shows it.
|
||||
self.post({'episode_ids': [self.c.id], 'favorite': True})
|
||||
favs = self.client.get('/podcasts/episodes/', {'filter': 'favorites'}).json()['episodes']
|
||||
self.assertEqual([e['id'] for e in favs], [self.c.id])
|
||||
|
||||
def test_bad_requests(self):
|
||||
for body in ({}, {'episode_ids': [self.a.id]}, {'episode_ids': [], 'favorite': True},
|
||||
{'episode_ids': self.a.id, 'favorite': True}, {'episode_ids': ['x'], 'favorite': True},
|
||||
{'episode_ids': [self.a.id], 'favorite': 'yes'}, [1, 2]):
|
||||
with self.subTest(body=body):
|
||||
self.assertEqual(self.post(body).status_code, 400)
|
||||
self.assertEqual(self.client.get('/podcasts/episodes/favorite/').status_code, 405)
|
||||
self.client.logout()
|
||||
self.assertEqual(self.post({'episode_ids': [self.a.id], 'favorite': True}).status_code, 401)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ urlpatterns = [
|
|||
path('feeds/<int:pk>/settings/', views.feed_settings, name='podcast_feed_settings'),
|
||||
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
||||
path('episodes/', views.episode_list, name='podcast_episode_list'),
|
||||
path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'),
|
||||
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
|
||||
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
|
||||
path('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from django.db import IntegrityError, transaction
|
|||
from django.db.models import (
|
||||
Case, Count, Exists, F, FilteredRelation, IntegerField, Max, OuterRef, Q, Sum, Value, When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Lower
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
|
@ -725,7 +725,6 @@ def feed_list(request):
|
|||
FEED_SETTING_FIELDS = (
|
||||
'playback_speed', 'skip_intro', 'skip_outro', 'volume',
|
||||
'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_queue', 'auto_download',
|
||||
'notify',
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -734,37 +733,15 @@ def _feed_settings(feed):
|
|||
|
||||
|
||||
def _feeds_data(user):
|
||||
"""The user's feeds with their settings and, per feed, how many episodes
|
||||
it has, how many of them are unplayed and how many new (as in the inbox:
|
||||
neither played nor dismissed), and when the user last listened to one.
|
||||
Two queries, however many feeds: the feeds with their episode count, and
|
||||
the user's progress rows counted per feed."""
|
||||
feeds = list(
|
||||
user.podcast_feeds
|
||||
.annotate(latest_episode_at=Max('episodes__pub_date'), episode_count=Count('episodes'))
|
||||
.values('id', 'title', 'artwork_url', 'rss_url', 'link', 'last_refreshed_at', 'author', 'added_at',
|
||||
'latest_episode_at', 'episode_count', 'last_error', 'last_error_at', 'error_count',
|
||||
*FEED_SETTING_FIELDS)
|
||||
.annotate(latest_episode_at=Max('episodes__pub_date'))
|
||||
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at',
|
||||
'latest_episode_at', 'last_error', 'last_error_at', 'error_count', *FEED_SETTING_FIELDS)
|
||||
)
|
||||
listened = {
|
||||
row['episode__feed_id']: row
|
||||
for row in EpisodeProgress.objects.filter(user=user, episode__feed__user=user)
|
||||
.order_by()
|
||||
.values('episode__feed_id')
|
||||
.annotate(
|
||||
played_count=Count('id', filter=Q(played=True)),
|
||||
hidden_count=Count('id', filter=Q(played=True) | Q(dismissed=True)),
|
||||
last_played_at=Max('updated_at', filter=Q(played=True) | Q(position_seconds__gt=0)),
|
||||
)
|
||||
}
|
||||
|
||||
for f in feeds:
|
||||
f['feed_id'] = f['id'] # as in feeds/<id>/settings/
|
||||
mine = listened.get(f['id'], {})
|
||||
f['unplayed_count'] = max(f['episode_count'] - mine.get('played_count', 0), 0)
|
||||
f['new_count'] = max(f['episode_count'] - mine.get('hidden_count', 0), 0)
|
||||
last = mine.get('last_played_at')
|
||||
f['last_played_at'] = last.isoformat() if last else None
|
||||
if f['last_refreshed_at']:
|
||||
f['last_refreshed_at'] = f['last_refreshed_at'].isoformat()
|
||||
if f['last_error_at']:
|
||||
|
|
@ -919,7 +896,6 @@ FEED_SETTING_PARSERS = {
|
|||
'exclude_words': _word_list,
|
||||
'min_duration': lambda v: _whole(v, 0, 600),
|
||||
'auto_download': _boolean,
|
||||
'notify': _boolean,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -982,7 +958,7 @@ def feed_episodes(request, pk):
|
|||
for ep in EpisodeProgress.objects.filter(
|
||||
user=request.user,
|
||||
episode__feed=feed,
|
||||
).values('episode_id', 'position_seconds', 'played', 'favorite')
|
||||
).values('episode_id', 'position_seconds', 'played')
|
||||
}
|
||||
|
||||
# Batch-fetch queue membership
|
||||
|
|
@ -999,7 +975,6 @@ def feed_episodes(request, pk):
|
|||
prog = progress_map.get(ep['id'], {})
|
||||
ep['position_seconds'] = prog.get('position_seconds', 0)
|
||||
ep['played'] = prog.get('played', False)
|
||||
ep['favorite'] = prog.get('favorite', False)
|
||||
ep['in_queue'] = ep['id'] in queued_ids
|
||||
|
||||
return JsonResponse({
|
||||
|
|
@ -1009,11 +984,6 @@ def feed_episodes(request, pk):
|
|||
'artwork_url': feed.artwork_url,
|
||||
'author': feed.author,
|
||||
'auto_queue': feed.auto_queue,
|
||||
# For the podcast's info page.
|
||||
'description': feed.description,
|
||||
'link': feed.link,
|
||||
'rss_url': feed.rss_url,
|
||||
'last_refreshed_at': feed.last_refreshed_at.isoformat() if feed.last_refreshed_at else None,
|
||||
},
|
||||
'episodes': episodes,
|
||||
})
|
||||
|
|
@ -1184,43 +1154,6 @@ def set_favorite(request, pk):
|
|||
return JsonResponse({'ok': True, 'favorite': favorite})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST'])
|
||||
def set_favorites(request):
|
||||
"""Star or unstar several episodes at once (choosing several in a list):
|
||||
{"episode_ids": [...], "favorite": true}. Like set_favorite, it leaves
|
||||
the listening time (updated_at) of existing rows alone."""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
try:
|
||||
body = json.loads(request.body or b'{}')
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
if not isinstance(body, dict):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
episode_ids = body.get('episode_ids')
|
||||
favorite = body.get('favorite')
|
||||
if not isinstance(episode_ids, list) or not episode_ids or not isinstance(favorite, bool):
|
||||
return JsonResponse({'error': 'episode_ids (a list) and favorite (true/false) required'}, status=400)
|
||||
try:
|
||||
wanted = {int(i) for i in episode_ids[:EPISODE_IDS_MAX]}
|
||||
except (TypeError, ValueError):
|
||||
return JsonResponse({'error': 'episode_ids must be numbers'}, status=400)
|
||||
valid = set(
|
||||
PodcastEpisode.objects.filter(id__in=wanted, feed__user=request.user).values_list('id', flat=True)
|
||||
)
|
||||
with transaction.atomic():
|
||||
rows = EpisodeProgress.objects.filter(user=request.user, episode_id__in=valid)
|
||||
rows.exclude(favorite=favorite).update(favorite=favorite)
|
||||
if favorite:
|
||||
have = set(rows.values_list('episode_id', flat=True))
|
||||
EpisodeProgress.objects.bulk_create(
|
||||
[EpisodeProgress(user=request.user, episode_id=i, favorite=True) for i in sorted(valid - have)],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return JsonResponse({'ok': True, 'favorite': favorite, 'count': len(valid)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Episodes of all feeds: filters, history, search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1232,19 +1165,7 @@ EPISODE_FILTERS = {
|
|||
'played': Q(played=True),
|
||||
'favorites': Q(favorite=True),
|
||||
'history': Q(played=True) | Q(position_seconds__gt=0),
|
||||
'queued': Q(in_queue=True),
|
||||
}
|
||||
# Episodes of unknown length (0) come last when sorting by length.
|
||||
_UNKNOWN_LENGTH_LAST = Case(When(duration_seconds__gt=0, then=Value(0)), default=Value(1), output_field=IntegerField())
|
||||
EPISODE_SORTS = {
|
||||
'new': (F('pub_date').desc(nulls_last=True), '-id'),
|
||||
'old': (F('pub_date').asc(nulls_last=True), 'id'),
|
||||
'long': (_UNKNOWN_LENGTH_LAST, '-duration_seconds', F('pub_date').desc(nulls_last=True), '-id'),
|
||||
'short': (_UNKNOWN_LENGTH_LAST, 'duration_seconds', F('pub_date').desc(nulls_last=True), '-id'),
|
||||
'title': (Lower('title'), 'id'),
|
||||
'played': (F('last_played_at').desc(nulls_last=True), '-id'),
|
||||
}
|
||||
EPISODE_IDS_MAX = 1000
|
||||
EPISODE_LIST_LIMIT = 100
|
||||
EPISODE_LIST_MAX = 500
|
||||
EPISODE_LIST_FIELDS = (
|
||||
|
|
@ -1259,19 +1180,10 @@ def _int_param(request, name, default):
|
|||
return default if raw == '' else int(raw)
|
||||
|
||||
|
||||
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT,
|
||||
sort=None, ids=None, notes=False):
|
||||
def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, limit=EPISODE_LIST_LIMIT):
|
||||
"""(episodes, total) for the episode list. Two queries, whatever the
|
||||
page size: the count and the page. The user's progress row comes in by
|
||||
a join, queue membership by a subquery. No shownotes (up to 50 KB each).
|
||||
|
||||
filter_name: one of EPISODE_FILTERS, or several (a list, or joined by
|
||||
commas), which all have to match. sort: one of EPISODE_SORTS; by default
|
||||
the newest first, in the history the last listened first. ids: only these
|
||||
episodes (the ones downloaded on a device). notes: `q` also matches the
|
||||
shownotes; title matches come first."""
|
||||
names = filter_name.split(',') if isinstance(filter_name, str) else list(filter_name)
|
||||
names = [n for n in names if n and n != 'all']
|
||||
a join, queue membership by a subquery. No shownotes (up to 50 KB each)."""
|
||||
qs = (
|
||||
PodcastEpisode.objects.filter(feed__user=user)
|
||||
.annotate(mine=FilteredRelation('progress', condition=Q(progress__user=user)))
|
||||
|
|
@ -1282,22 +1194,16 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
|
|||
last_played_at=F('mine__updated_at'),
|
||||
in_queue=Exists(PodcastQueue.objects.filter(user=user, episode=OuterRef('pk'))),
|
||||
)
|
||||
.filter(EPISODE_FILTERS[filter_name])
|
||||
)
|
||||
for name in names:
|
||||
qs = qs.filter(EPISODE_FILTERS[name])
|
||||
if feed_id is not None:
|
||||
qs = qs.filter(feed_id=feed_id)
|
||||
if ids is not None:
|
||||
qs = qs.filter(id__in=ids)
|
||||
order = EPISODE_SORTS[sort or ('played' if 'history' in names else 'new')]
|
||||
if q and notes:
|
||||
qs = qs.filter(Q(title__icontains=q) | Q(description__icontains=q)).annotate(
|
||||
title_hit=Case(When(title__icontains=q, then=Value(0)), default=Value(1), output_field=IntegerField()),
|
||||
)
|
||||
order = ('title_hit', *order)
|
||||
elif q:
|
||||
if q:
|
||||
qs = qs.filter(title__icontains=q)
|
||||
qs = qs.order_by(*order)
|
||||
if filter_name == 'history':
|
||||
qs = qs.order_by(F('last_played_at').desc(nulls_last=True), '-id')
|
||||
else:
|
||||
qs = qs.order_by(F('pub_date').desc(nulls_last=True), '-id')
|
||||
|
||||
total = qs.count()
|
||||
episodes = list(qs.values(*EPISODE_LIST_FIELDS)[offset:offset + limit]) if limit else []
|
||||
|
|
@ -1314,29 +1220,20 @@ def _episode_list_data(user, filter_name='all', feed_id=None, q='', offset=0, li
|
|||
@require_http_methods(['GET'])
|
||||
def episode_list(request):
|
||||
"""Episodes of all the user's feeds, filtered (all, unplayed, in_progress,
|
||||
played, favorites, history, queued; several joined by commas all have to
|
||||
match), optionally of one feed, of some episodes (`ids`, comma-separated)
|
||||
or matching `q` (in the title; with notes=1 also in the shownotes), sorted
|
||||
by `sort` (new, old, long, short, title, played)."""
|
||||
played, favorites, history), optionally of one feed or matching `q`."""
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
names = [n.strip() for n in request.GET.get('filter', '').split(',') if n.strip()]
|
||||
if any(n not in EPISODE_FILTERS for n in names):
|
||||
filter_name = request.GET.get('filter', '').strip() or 'all'
|
||||
if filter_name not in EPISODE_FILTERS:
|
||||
return JsonResponse({'error': 'unknown filter'}, status=400)
|
||||
sort = request.GET.get('sort', '').strip() or None
|
||||
if sort is not None and sort not in EPISODE_SORTS:
|
||||
return JsonResponse({'error': 'unknown sort'}, status=400)
|
||||
try:
|
||||
offset = max(_int_param(request, 'offset', 0), 0)
|
||||
limit = min(max(_int_param(request, 'limit', EPISODE_LIST_LIMIT), 0), EPISODE_LIST_MAX)
|
||||
feed_id = _int_param(request, 'feed_id', None)
|
||||
raw_ids = request.GET.get('ids')
|
||||
ids = None if raw_ids is None else [int(i) for i in raw_ids.split(',') if i.strip()][:EPISODE_IDS_MAX]
|
||||
except ValueError:
|
||||
return JsonResponse({'error': 'offset, limit, feed_id and ids must be numbers'}, status=400)
|
||||
return JsonResponse({'error': 'offset, limit and feed_id must be numbers'}, status=400)
|
||||
episodes, total = _episode_list_data(
|
||||
request.user, names, feed_id, request.GET.get('q', '').strip(), offset, limit,
|
||||
sort=sort, ids=ids, notes=request.GET.get('notes') == '1',
|
||||
request.user, filter_name, feed_id, request.GET.get('q', '').strip(), offset, limit,
|
||||
)
|
||||
return JsonResponse({'episodes': episodes, 'offset': offset, 'limit': limit, 'total': total})
|
||||
|
||||
|
|
|
|||
|
|
@ -1352,26 +1352,10 @@ if ('serviceWorker' in navigator) {
|
|||
});
|
||||
});
|
||||
// When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets,
|
||||
// but never in the middle of something: not while a book is open, radio or a
|
||||
// podcast plays, or a text field has the focus — then as soon as that's over.
|
||||
let reloadPending = false;
|
||||
const busy = () => {
|
||||
if (currentBookId || isPlaying) return true;
|
||||
const host = window.DioraPodcastHost;
|
||||
if (host && host.playerState) {
|
||||
try { if (JSON.parse(host.playerState()).playing) return true; } catch (_) {}
|
||||
}
|
||||
const el = document.activeElement;
|
||||
return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
|
||||
};
|
||||
const reloadWhenIdle = () => {
|
||||
if (busy()) setTimeout(reloadWhenIdle, 5000);
|
||||
else window.location.reload();
|
||||
};
|
||||
// but only if the reader isn't open (would interrupt reading).
|
||||
navigator.serviceWorker.addEventListener('message', e => {
|
||||
if (e.data?.type === 'SW_ACTIVATED' && !reloadPending) {
|
||||
reloadPending = true;
|
||||
reloadWhenIdle();
|
||||
if (e.data?.type === 'SW_ACTIVATED' && !currentBookId) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -937,12 +937,11 @@
|
|||
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Web';
|
||||
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows'
|
||||
: /Macintosh|Mac OS X/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '';
|
||||
return os ? browser + ' · ' + os : browser;
|
||||
return os ? browser + '/' + os : browser;
|
||||
}
|
||||
|
||||
const DEVICE_ID = deviceId();
|
||||
// Short, it stands in the bar ("läuft auf Chrome · Windows").
|
||||
const DEVICE_NAME = browserName();
|
||||
const DEVICE_NAME = 'Browser (' + browserName() + ')';
|
||||
|
||||
const sync = {
|
||||
state: null, // the server's state, as last taken in
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v64';
|
||||
const CACHE = 'diora-v60';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app.js',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
/* Copied from diora-android 99528f2 2026-10-02: the podcast parts of web-native/static/native.css
|
||||
/* Copied from diora-android 57fc424 2026-10-02: the podcast parts of web-native/static/native.css
|
||||
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||
The web's own additions are in podcasts-web.css. */
|
||||
|
||||
|
|
@ -572,20 +572,3 @@ body.eink .nui-pod-feederr-box { border-color: #000; }
|
|||
body.eink .nui-pod-offer { border-color: #000; }
|
||||
body.eink .nui-pod-seg { color: #444; }
|
||||
body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }
|
||||
|
||||
/* lists: search sections, the number at a podcast, its info page */
|
||||
.nui-pod-sec { margin-top: 14px; padding: 4px 2px; font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--nui-muted); border-bottom: 1px solid var(--border); }
|
||||
.nui-pod-search + .nui-pod-sec { margin-top: 4px; }
|
||||
.nui-pod-sec-note { padding: 10px 2px; font-size: 13px; color: var(--nui-muted); }
|
||||
.nui-pod-feedcount { flex: none; }
|
||||
.nui-pod-infobtn { flex: none; font-size: 20px; color: var(--nui-muted); }
|
||||
.nui-pod-info-desc { font-size: 14px; font-weight: 400; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; padding: 4px 0 10px; border-bottom: 1px solid var(--border); }
|
||||
.nui-pod-facts { font-weight: 400; display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px; margin: 10px 0 4px; font-size: 14px; }
|
||||
.nui-pod-facts dt { color: var(--nui-muted); }
|
||||
.nui-pod-facts dd { margin: 0; min-width: 0; font-weight: 400; overflow-wrap: anywhere; }
|
||||
.nui-pod-facts a { color: var(--accent); }
|
||||
.nui-pod-info-url { display: block; font-size: 12px; color: var(--nui-muted); }
|
||||
.nui-pod-info-actions { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.nui-pod-info-actions .btn { min-height: 36px; padding: 0 14px; font-size: 14px; }
|
||||
body.eink .nui-pod-sec { color: #000; border-bottom-color: #000; }
|
||||
body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue