diora-web/docs/youtube.md
mrwnslz 1194472572
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 10m56s
Test / e2e (push) Failing after 1m33s
Podcasts: Werbung und Schluss in YouTube-Folgen überspringen (SponsorBlock)
Die Folgen eines YouTube-Kanals werden gehört wie Podcasts; unterbrochen wird man
darin von der Werbung und vom Schluss. Beides markieren andere Hörer bei
SponsorBlock: podcasts/sponsor.py fragt je Folge einmal danach (nur sponsor und
outro, nur Teile, die übersprungen werden, kein „full“) und legt die Antwort an
der Folge ab (PodcastEpisode.sponsor); GET /podcasts/episodes/<id>/sponsor/
antwortet damit. Eine Antwort gilt eine Woche, ein Fehlschlag eine Stunde.

Der Player springt über solche Teile (podcasts-web.js für Browser und Desktop,
in der App später der eigene Player); der Schalter heißt skip_sponsor, steht bei
„Stille überspringen“ im Abspiel- und Tempomenü, ist an und wirkt nur bei
YouTube-Folgen (default on). e2e/sponsor.spec.js spielt es in Chromium durch;
sw v155
2026-10-25 16:18:24 +01:00

4.9 KiB

YouTube channels as podcasts

A YouTube channel (or playlist) can be subscribed to like a podcast. Only its sound is played, and the server never downloads or streams a video or its sound: each app finds the audio stream itself.

Server (diora-web, podcasts/youtube.py)

  • POST /podcasts/feeds/add/ takes a YouTube address as rss_url: a channel (/@name, /channel/UC…, /c/…, /user/…), a playlist (?list=…), a video (subscribes its channel) or the feed itself. The server reads the channel page once (feed address, name, picture) and stores the feed https://www.youtube.com/feeds/videos.xml?channel_id=UC… (or ?playlist_id=…). GET /podcasts/search/?q=<YouTube address> answers with that one channel.

  • From then on the feed is refreshed like any other — except that YouTube answers its channel feeds with a 404 (or 5xx) now and then for no reason (October 2026: two requests in three), so a YouTube feed is asked up to 12 times, 0.25 s apart, before its refresh counts as failed (YOUTUBE_FEED_TRIES in podcasts/views.py). It carries the latest 15 videos. Shorts are left out. There is no duration (duration_seconds 0) until a player knows it: POST /podcasts/progress/save/ takes an optional duration_seconds (number, 1 s … 7 days), stored only while the episode has none.

  • An episode from such a feed has

    • audio_url = https://www.youtube.com/watch?v=<11-character id>. This is the marker: a client recognises a YouTube episode by this prefix;
    • guid = yt:video:<id>, artwork_url = the video's thumbnail, description = the video description as HTML (links, line breaks; timestamps like 00:00 Intro work as usual).
  • Nothing on the server touches the sound of such an episode: no ID3 chapters, no transcripts, and /podcasts/episodes/<id>/silences/ answers {"ready": true, "spans": [], "error": "youtube"} (skip silence is up to the 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.

Ad and outro: SponsorBlock

What goes out of the sound — the advertisement and the closing words — is other people's work: SponsorBlock (sponsor.ajay.app) lets listeners mark a video's parts by category. podcasts/sponsor.py asks it once per episode (only sponsor and outro, only parts a listener skips, never a part marked full) and keeps the answer on the episode (PodcastEpisode.sponsor); GET /podcasts/episodes/<id>/sponsor/ answers with it, {"ready": true, "v": 1, "spans": [[from, to], …]}, empty for an episode that isn't a video and with an error beside empty spans when SponsorBlock couldn't be asked. Nothing else is fetched — no intro, self-promotion, interaction reminder or non-music section.

An answer counts for a week, a failure for an hour (segments are added later; a failure was probably the network). A player jumps over those parts and never runs them faster: the setting is skip_sponsor of this device (on by default, in the play and speed menus), and it does something only for YouTube episodes. Nothing of the sound leaves the device for this — the video id does, to sponsor.ajay.app, when the server asks (never the sound, and never for an episode that isn't a video).

Apps

A client that sees a watch?v= address resolves it itself, on the device, to an audio-only stream (no video track), and plays or downloads that.

Client How
Android NewPipeExtractor: the original audio track, progressive, opus before m4a, ≤ 160 kbit/s; resolved when the stream is opened and once more on a 403; downloads in 10 MB ranges (in one go YouTube throttles to ~50 KB/s)
Desktop yt-dlp in the main process (-f bestaudio), Electron itself as its JavaScript runtime; yt-dlp is fetched from its GitHub release on first use and kept up to date (src/main/youtube.js)
Browser can't (YouTube doesn't let a web page read its pages, and a stream address only works for the device that asked for it): the episode is listed but says it plays in the app

Stream addresses are bound to the device's IP address and run out after a few hours: resolve shortly before playing, and again when the player reports an error (podcasts-web.js reloads after a media error anyway).

Progress, queue, played state etc. work as for any episode: they belong to the episode on the server, not to the stream address.