Abo umziehen: Feed-Adresse ändern, alles behalten
POST /podcasts/feeds/<id>/move/ gibt einem Abo eine neue Adresse und behält Einstellungen, Folgen, Stellen, Gespielt- und Favoriten-Markierungen und die Warteschlange. Bei einer YouTube-Adresse werden Folgen, deren Datei nach ihrem Video benannt ist (podsync-artige Server), zu diesem Video, damit der Kanal-Feed sie wiederfindet statt sie doppelt anzulegen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cde90bed8e
commit
3e843aea9f
5 changed files with 147 additions and 0 deletions
|
|
@ -27,6 +27,15 @@ sound**: each app finds the audio stream itself.
|
||||||
`{"ready": true, "spans": [], "error": "youtube"}` (skip silence is up to the
|
`{"ready": true, "spans": [], "error": "youtube"}` (skip silence is up to the
|
||||||
app, as on Android anyway).
|
app, as on Android anyway).
|
||||||
|
|
||||||
|
- `POST /podcasts/feeds/<id>/move/ {"rss_url": …}` gives an existing subscription a
|
||||||
|
new address and keeps everything (settings, episodes, places, played and favourite
|
||||||
|
marks, queue). Moved to a YouTube address, every episode whose file is named after
|
||||||
|
its video (`…/<11-character id>.mp3`, as podsync-like servers name them) becomes
|
||||||
|
that video (`guid` `yt:video:<id>`, `audio_url` its `watch?v=` page), so the
|
||||||
|
channel's feed finds it again instead of adding it twice. Used to move channels
|
||||||
|
that came through such a server (it downloaded the sound on the server) to the
|
||||||
|
channel itself.
|
||||||
|
|
||||||
## Apps
|
## Apps
|
||||||
|
|
||||||
A client that sees a `watch?v=` address resolves it **itself, on the device,
|
A client that sees a `watch?v=` address resolves it **itself, on the device,
|
||||||
|
|
|
||||||
|
|
@ -192,3 +192,68 @@ class YouTubeFeedTests(TestCase):
|
||||||
save({'position_seconds': 20, 'duration_seconds': 5}) # known now: not changed again
|
save({'position_seconds': 20, 'duration_seconds': 5}) # known now: not changed again
|
||||||
ep.refresh_from_db()
|
ep.refresh_from_db()
|
||||||
self.assertEqual(ep.duration_seconds, 929)
|
self.assertEqual(ep.duration_seconds, 929)
|
||||||
|
|
||||||
|
|
||||||
|
class MoveFeedTests(TestCase):
|
||||||
|
"""A channel fed through another server (podsync-like) moved to the channel itself."""
|
||||||
|
OLD = 'http://creamfresh.example:8001/feed/abc/rss.xml'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from .models import EpisodeProgress, PodcastQueue
|
||||||
|
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=self.OLD, title='Tages Schau',
|
||||||
|
playback_speed=1.2, http_etag='"x"')
|
||||||
|
mk = lambda vid, title, guid=None: PodcastEpisode.objects.create(
|
||||||
|
feed=self.feed, guid=guid or f'old-{vid}', title=title, duration_seconds=600,
|
||||||
|
audio_url=f'http://creamfresh.example:8001/feed/abc/audio/{vid}.mp3')
|
||||||
|
self.newest = mk('aaaaaaaaaaa', 'Video eins')
|
||||||
|
self.older = mk('zzzzzzzzzzz', 'Altes Video') # no longer in the channel's feed
|
||||||
|
self.trailer = mk('UCs8mbJ-M142ZskR5VR0gBig', 'Kanal - Videos')
|
||||||
|
EpisodeProgress.objects.create(user=self.user, episode=self.newest, position_seconds=0, played=True)
|
||||||
|
EpisodeProgress.objects.create(user=self.user, episode=self.older, position_seconds=300, favorite=True)
|
||||||
|
PodcastQueue.objects.create(user=self.user, episode=self.older, position=0)
|
||||||
|
|
||||||
|
def move(self, url):
|
||||||
|
def fetch(u, **kw):
|
||||||
|
self.assertEqual(kw.get('etag', ''), '') # the old address's validators are gone
|
||||||
|
return FeedFetch(200, feedparser.parse(FEED_XML))
|
||||||
|
with patch('podcasts.youtube.requests.get', lambda u, **kw: _Page(PAGE)), \
|
||||||
|
patch('podcasts.views._fetch_feed', fetch):
|
||||||
|
return self.client.post(f'/podcasts/feeds/{self.feed.pk}/move/', json.dumps({'rss_url': url}),
|
||||||
|
content_type='application/json')
|
||||||
|
|
||||||
|
def test_the_subscription_keeps_everything_and_its_episodes_become_videos(self):
|
||||||
|
from .models import EpisodeProgress, PodcastQueue
|
||||||
|
r = self.move('https://www.youtube.com/@tagesschau')
|
||||||
|
self.assertEqual(r.status_code, 200, r.content)
|
||||||
|
self.assertEqual(r.json()['videos'], 2)
|
||||||
|
self.feed.refresh_from_db()
|
||||||
|
self.assertEqual(self.feed.rss_url, f'{youtube.FEED}?channel_id={CHANNEL}')
|
||||||
|
self.assertEqual(self.feed.playback_speed, 1.2)
|
||||||
|
self.assertEqual(self.feed.artwork_url, 'https://yt3.example/pic.jpg')
|
||||||
|
self.newest.refresh_from_db()
|
||||||
|
self.older.refresh_from_db()
|
||||||
|
self.trailer.refresh_from_db()
|
||||||
|
self.assertEqual((self.newest.guid, self.newest.audio_url), ('yt:video:aaaaaaaaaaa', youtube.WATCH + 'aaaaaaaaaaa'))
|
||||||
|
self.assertEqual(self.older.audio_url, youtube.WATCH + 'zzzzzzzzzzz')
|
||||||
|
self.assertTrue(self.trailer.audio_url.endswith('.mp3')) # not a video: left as it was
|
||||||
|
# Video eins came again with the channel's feed: the same episode, not a second one.
|
||||||
|
self.assertEqual(PodcastEpisode.objects.filter(title='Video eins').count(), 1)
|
||||||
|
self.assertEqual(set(PodcastEpisode.objects.values_list('title', flat=True)),
|
||||||
|
{'Video eins', 'Video zwei', 'Altes Video', 'Kanal - Videos'})
|
||||||
|
self.assertTrue(EpisodeProgress.objects.get(episode=self.newest).played)
|
||||||
|
p = EpisodeProgress.objects.get(episode=self.older)
|
||||||
|
self.assertEqual((p.position_seconds, p.favorite), (300, True))
|
||||||
|
self.assertTrue(PodcastQueue.objects.filter(episode=self.older).exists())
|
||||||
|
|
||||||
|
def test_an_address_subscribed_already_is_refused(self):
|
||||||
|
PodcastFeed.objects.create(user=self.user, rss_url=f'{youtube.FEED}?channel_id={CHANNEL}', title='x')
|
||||||
|
self.assertEqual(self.move('https://www.youtube.com/@tagesschau').status_code, 409)
|
||||||
|
self.feed.refresh_from_db()
|
||||||
|
self.assertEqual(self.feed.rss_url, self.OLD)
|
||||||
|
|
||||||
|
def test_only_ones_own_feed(self):
|
||||||
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
||||||
|
self.client.force_login(bob)
|
||||||
|
self.assertEqual(self.move('https://www.youtube.com/@tagesschau').status_code, 404)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ urlpatterns = [
|
||||||
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
|
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
|
||||||
path('feeds/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
|
path('feeds/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
|
||||||
path('feeds/<int:pk>/settings/', views.feed_settings, name='podcast_feed_settings'),
|
path('feeds/<int:pk>/settings/', views.feed_settings, name='podcast_feed_settings'),
|
||||||
|
path('feeds/<int:pk>/move/', views.move_feed, name='podcast_move_feed'),
|
||||||
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
||||||
path('episodes/', views.episode_list, name='podcast_episode_list'),
|
path('episodes/', views.episode_list, name='podcast_episode_list'),
|
||||||
path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'),
|
path('episodes/favorite/', views.set_favorites, name='podcast_set_favorites'),
|
||||||
|
|
|
||||||
|
|
@ -1012,6 +1012,68 @@ FEED_SETTING_PARSERS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(['POST'])
|
||||||
|
def move_feed(request, pk):
|
||||||
|
"""Give a subscription a new address, keeping it: its settings, episodes, places,
|
||||||
|
played and favourite marks, queue entries. For a podcast that moved, or one that was
|
||||||
|
a YouTube channel fed through some other server (a podsync-like one): moved to the
|
||||||
|
channel itself (a YouTube address of any kind), each episode whose file was named
|
||||||
|
after its video becomes that video (docs/youtube.md), so the channel's feed finds it
|
||||||
|
again instead of adding it twice. {"ok", "rss_url", "videos": episodes made videos,
|
||||||
|
"new_episodes"}."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||||
|
feed = PodcastFeed.objects.filter(pk=pk, user=request.user).first()
|
||||||
|
if feed is None:
|
||||||
|
return JsonResponse({'error': 'not found'}, status=404)
|
||||||
|
rss_url = str(body.get('rss_url') or '').strip()
|
||||||
|
if not rss_url.startswith(('http://', 'https://')) or len(rss_url) > 1000:
|
||||||
|
return JsonResponse({'error': 'rss_url required'}, status=400)
|
||||||
|
artwork = ''
|
||||||
|
if youtube_mod.is_youtube(rss_url) and not youtube_mod.is_feed(rss_url):
|
||||||
|
try:
|
||||||
|
found = youtube_mod.resolve(rss_url)
|
||||||
|
except youtube_mod.NotAChannel as e:
|
||||||
|
return JsonResponse({'error': f'No YouTube channel there ({e})'}, status=400)
|
||||||
|
rss_url, artwork = found['feed'], found['artwork']
|
||||||
|
if rss_url != feed.rss_url and PodcastFeed.objects.filter(user=request.user, rss_url=rss_url).exists():
|
||||||
|
return JsonResponse({'error': 'already subscribed at that address'}, status=409)
|
||||||
|
|
||||||
|
videos = 0
|
||||||
|
with transaction.atomic():
|
||||||
|
if youtube_mod.is_feed(rss_url):
|
||||||
|
taken = set(feed.episodes.values_list('guid', flat=True))
|
||||||
|
changed = []
|
||||||
|
for ep in feed.episodes.only('id', 'guid', 'audio_url'):
|
||||||
|
vid = youtube_mod.video_id_in(ep.audio_url)
|
||||||
|
guid = f'yt:video:{vid}' if vid else ''
|
||||||
|
if not vid or (guid in taken and ep.guid != guid):
|
||||||
|
continue # not named after a video, or that video is here already
|
||||||
|
taken.add(guid)
|
||||||
|
ep.guid, ep.audio_url = guid, youtube_mod.WATCH + vid
|
||||||
|
changed.append(ep)
|
||||||
|
PodcastEpisode.objects.bulk_update(changed, ['guid', 'audio_url'], batch_size=200)
|
||||||
|
videos = len(changed)
|
||||||
|
feed.rss_url = rss_url
|
||||||
|
if artwork:
|
||||||
|
feed.artwork_url = artwork[:1000]
|
||||||
|
# Whatever the old address answered says nothing about the new one.
|
||||||
|
feed.http_etag = feed.http_last_modified = feed.http_body_hash = ''
|
||||||
|
feed.save()
|
||||||
|
try:
|
||||||
|
# Videos the old address didn't carry that came out before its last refresh aren't news.
|
||||||
|
new_episodes = _refresh_feed(feed, full=True)
|
||||||
|
except Exception as e: # moved all the same: the next refresh tries again
|
||||||
|
log.warning('feed %s moved, first refresh failed: %s', feed.pk, e)
|
||||||
|
new_episodes = 0
|
||||||
|
return JsonResponse({'ok': True, 'rss_url': feed.rss_url, 'videos': videos, 'new_episodes': new_episodes})
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def feed_settings(request, pk):
|
def feed_settings(request, pk):
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,16 @@ def video_url(entry):
|
||||||
return WATCH + vid
|
return WATCH + vid
|
||||||
|
|
||||||
|
|
||||||
|
def video_id_in(audio_url):
|
||||||
|
"""The video an episode is, judged by its address: a watch page, or a file named after
|
||||||
|
the video (as servers that turn channels into podcasts name them), '' otherwise."""
|
||||||
|
if is_video(audio_url):
|
||||||
|
return audio_url[len(WATCH):]
|
||||||
|
path = urllib.parse.urlsplit(audio_url).path
|
||||||
|
m = re.search(r'/([\w-]{11})\.(?:mp3|m4a|mp4|webm|opus|ogg|aac)$', path)
|
||||||
|
return m.group(1) if m else ''
|
||||||
|
|
||||||
|
|
||||||
def entry_artwork(entry):
|
def entry_artwork(entry):
|
||||||
thumbs = entry.get('media_thumbnail') or []
|
thumbs = entry.get('media_thumbnail') or []
|
||||||
return (thumbs[0].get('url') or '') if thumbs else ''
|
return (thumbs[0].get('url') or '') if thumbs else ''
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue