Repository: yt-dlp/yt-dlp

Issue:
[orf:radio] add support for sound.orf.at
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I remove or skip any mandatory\* field

### Checklist

- [X] I'm requesting a site-specific feature
- [X] I've verified that I'm running yt-dlp version **2022.10.04** ([update instructions](https://github.com/yt-dlp/yt-dlp#update)) or later (specify commit)
- [X] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [X] I've searched the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues **including closed ones**. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [X] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

anywhere

### Example URLs

Test link:
https://sound.orf.at/kalender/oe1/20221017/0945


### Provide a description that is worded well enough to be understood

Looks like ORF have a new site, where they bundle all radios.
playlists seem to be stored in m3u files:
https://loopstream01.apa.at/playlist.m3u8?channel=oe1&id=2022-10-17_0820_tl_51_7DaysMon10_1945413.mp3&offset=0&offsetende=2101000&player=sound_v1&referer=sound.orf.at&userid=74f3331c-cb4f-4dc9-af71-27fa95e4aa49

or this one:
https://loopstream01.apa.at/chunklist.m3u8?channel=oe1&id=2022-10-17_0820_tl_51_7DaysMon10_1945413.mp3&offset=0&offsetende=2101000&player=sound_v1&referer=sound.orf.at&userid=74f3331c-cb4f-4dc9-af71-27fa95e4aa49&sessionid=20221017174538314450527239


Links for other domains work just fine https://oe1.orf.at/player/20221017/694819

### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [X] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
./yt-dlp -v -F "https://sound.orf.at/kalender/oe1/20221017/0945"
[debug] Command-line config: ['-v', '-F', 'https://sound.orf.at/kalender/oe1/20221017/0945']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2022.10.04 [4e0511f27] (zip)
[debug] Plugins: ['SamplePluginIE', 'SamplePluginPP']
[debug] Python 3.10.7 (CPython 64bit) - macOS-12.6-arm64-arm-64bit 
[debug] Checking exe version: ffmpeg -bsfs
[debug] Checking exe version: ffprobe -bsfs
[debug] exe versions: phantomjs 2.1.1
[debug] Optional libraries: sqlite3-2.6.0
[debug] Proxy map: {}
[debug] Loaded 1739 extractors
[debug] [generic] Extracting URL: https://sound.orf.at/kalender/oe1/20221017/0945
[generic] 0945: Downloading webpage
WARNING: [generic] Falling back on generic information extractor
[generic] 0945: Extracting information
[debug] Looking for embeds
ERROR: Unsupported URL: https://sound.orf.at/kalender/oe1/20221017/0945
Traceback (most recent call last):
  File "./yt-dlp/yt_dlp/YoutubeDL.py", line 1477, in wrapper
    return func(self, *args, **kwargs)
  File "./yt-dlp/yt_dlp/YoutubeDL.py", line 1553, in __extract_info
    ie_result = ie.extract(url)
  File "./yt-dlp/yt_dlp/extractor/common.py", line 674, in extract
    ie_result = self._real_extract(url)
  File "./yt-dlp/yt_dlp/extractor/generic.py", line 2755, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://sound.orf.at/kalender/oe1/20221017/0945
```



Discussion:
The test URL gives a JS single-page-app with no programme data in the non-JS page.

The SPA shows a programme guide for the channel/day/time as specified in the URL, focused on the time. The actual show URLs are like https://sound.orf.at/radio/oe1/sendung/158615/geigerkoenig-im-exil (indeed, Kreisler was the king fiddler), for which the extractor needs to be modified. The show data (with query parameter `items=true` in the [URL](https://audioapi.orf.at/oe1/api/json/5.0/broadcast/158615?items=true&_o=sound.orf.at)) also includes clip data for material used in the show, in this case the Kreisler recordings featured in the show.

The page loads a [station configuration JSON](https://orf.at/app-infos/sound/web/1.0/bundle.json?_o=sound.orf.at), then a [1MB JSON programme guide](https://audioapi.orf.at/oe1/api/json/5.0/broadcasts?_o=sound.orf.at), then some [per-programme JSON](https://audioapi.orf.at/oe1/api/json/5.0/broadcast/158615?items=true&_o=sound.orf.at) that appears similar to data available in the guide, and resets the page location to https://sound.orf.at/kalender/oe1/20221017/0900. Potentially the extractor could find the current programme for the URL time from the giant JSON EPG and redirect to the programme URL, but that seems too much work.

radiothek.orf.at URLs redirect to sound.orf.at: presumably the new site replaces the Radiothek site.
Could we add support for the podcasts as a first step?
E.g. `https://sound.orf.at/podcast/oe3/fruehstueck-bei-mir/nicolas-stockhammer-15102023` links to the file `https://podcast.orf.at/podcast/oe3/oe3_fruehstueck/items/di_oe3_fruehstueck_clk00008981_a6996a3f39ab49178267dc3ef5fe2e95.mp3`.

Figuring out the `di_oe3_fruehstueck_clk00008981` part from the url seems to be quite easy, what I did not find out is, where the `a6996a3f39ab49178267dc3ef5fe2e95` part comes from. 
Seems we could get the link to the file directly from the offered rss feeds, e.g. `https://podcast.orf.at/podcast/oe3/oe3_fruehstueck/oe3_fruehstueck.xml`
For that podcast, the site fetches JSON from https://audioapi.orf.at/radiothek/api/2.0/podcast/oe3/fruehstueck-bei-mir/nicolas-stockhammer-15102023?_o=sound.orf.at with Origin and Referer headers. This JSON includes as much data about the show as could be wanted and may be easier to parse than the XML feed (after all, why JSON was invented ...).

Resolving patch:
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -1416,6 +1416,7 @@
     ORFTVthekIE,
     ORFFM4StoryIE,
     ORFRadioIE,
+    ORFPodcastIE,
     ORFIPTVIE,
 )
 from .outsidetv import OutsideTVIE
diff --git a/yt_dlp/extractor/orf.py b/yt_dlp/extractor/orf.py
--- a/yt_dlp/extractor/orf.py
+++ b/yt_dlp/extractor/orf.py
@@ -4,15 +4,16 @@
 from .common import InfoExtractor
 from ..networking import HEADRequest
 from ..utils import (
+    InAdvancePagedList,
     clean_html,
     determine_ext,
     float_or_none,
-    InAdvancePagedList,
     int_or_none,
     join_nonempty,
+    make_archive_id,
+    mimetype2ext,
     orderedSet,
     remove_end,
-    make_archive_id,
     smuggle_url,
     strip_jsonp,
     try_call,
@@ -21,6 +22,7 @@
     unsmuggle_url,
     url_or_none,
 )
+from ..utils.traversal import traverse_obj
 
 
 class ORFTVthekIE(InfoExtractor):
@@ -334,6 +336,45 @@ def _real_extract(self, url):
             self._entries(data, station or station2), show_id, data.get('title'), clean_html(data.get('subtitle')))
 
 
+class ORFPodcastIE(InfoExtractor):
+    IE_NAME = 'orf:podcast'
+    _STATION_RE = '|'.join(map(re.escape, (
+        'bgl', 'fm4', 'ktn', 'noe', 'oe1', 'oe3',
+        'ooe', 'sbg', 'stm', 'tir', 'tv', 'vbg', 'wie')))
+    _VALID_URL = rf'https?://sound\.orf\.at/podcast/(?P<station>{_STATION_RE})/(?P<show>[\w-]+)/(?P<id>[\w-]+)'
+    _TESTS = [{
+        'url': 'https://sound.orf.at/podcast/oe3/fruehstueck-bei-mir/nicolas-stockhammer-15102023',
+        'md5': '526a5700e03d271a1505386a8721ab9b',
+        'info_dict': {
+            'id': 'nicolas-stockhammer-15102023',
+            'ext': 'mp3',
+            'title': 'Nicolas Stockhammer (15.10.2023)',
+            'duration': 3396.0,
+            'series': 'Frühstück bei mir',
+        },
+        'skip': 'ORF podcasts are only available for a limited time'
+    }]
+
+    def _real_extract(self, url):
+        station, show, show_id = self._match_valid_url(url).group('station', 'show', 'id')
+        data = self._download_json(
+            f'https://audioapi.orf.at/radiothek/api/2.0/podcast/{station}/{show}/{show_id}', show_id)
+
+        return {
+            'id': show_id,
+            'ext': 'mp3',
+            'vcodec': 'none',
+            **traverse_obj(data, ('payload', {
+                'url': ('enclosures', 0, 'url'),
+                'ext': ('enclosures', 0, 'type', {mimetype2ext}),
+                'title': 'title',
+                'description': ('description', {clean_html}),
+                'duration': ('duration', {functools.partial(float_or_none, scale=1000)}),
+                'series': ('podcast', 'title'),
+            })),
+        }
+
+
 class ORFIPTVIE(InfoExtractor):
     IE_NAME = 'orf:iptv'
     IE_DESC = 'iptv.ORF.at'


Test patch: