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
76 lines
4.9 KiB
Markdown
76 lines
4.9 KiB
Markdown
# 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.
|