Repository: yt-dlp/yt-dlp

Issue:
Add Support for Anchor.FM
### 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 reporting a new site support request
- [X] I've verified that I'm running yt-dlp version **2023.01.06** ([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 checked that none of provided URLs [violate any copyrights](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy) or contain any [DRM](https://en.wikipedia.org/wiki/Digital_rights_management) to the best of my knowledge
- [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 am willing to share it if required

### Region

United States

### Example URLs

Anchor.FM Podcast: https://anchor.fm/lovelyti



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

Would love to see an extractor added for Anchor.FM podcasts.  Anchor is owned by Spotify, so please let me know if the Spotify extractor is already supporting Anchor.FM podcasts.

### 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
N/A
```



Discussion:
Use the Apple Podcasts link on the page.

The Spotify extractor is broken and it knows it. Although it finds the episodes its media links use a host that no longer exists.
@bryanfarris in the future, please follow the issue template/instructions:

> [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

It's also best to provide an example URL for a single podcast (or video, etc) instead of only for a channel.
Like this:

```
$ yt-dlp -vU --ignore-config "https://anchor.fm/lovelyti/episodes/Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d"
[debug] Command-line config: ['-vF', '--ignore-config', 'https://anchor.fm/lovelyti/episodes/Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2023.01.06 [6becd25]
[debug] Lazy loading extractors is disabled
[debug] Python 3.10.9 (CPython x86_64 64bit) - Linux-6.1.6-arch1-3-x86_64-with-glibc2.36 (OpenSSL 1.1.1q  5 Jul 2022, glibc 2.36)
[debug] exe versions: ffmpeg 5.1.2 (setts), ffprobe 5.1.2
[debug] Optional libraries: Cryptodome-3.12.0, brotlicffi-1.0.9.2, certifi-2022.12.07, mutagen-1.46.0, sqlite3-2.6.0, websockets-10.4
[debug] Proxy map: {}
[debug] Loaded 1761 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: 2023.01.06, Current version: 2023.01.06
yt-dlp is up to date (2023.01.06)
[generic] Extracting URL: https://anchor.fm/lovelyti/episodes/Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d
[generic] Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d: Downloading webpage
WARNING: [generic] Falling back on generic information extractor
[generic] Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d: Extracting information
[debug] Looking for embeds
ERROR: Unsupported URL: https://anchor.fm/lovelyti/episodes/Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d
Traceback (most recent call last):
  File "/home/bashonly/git/yt-dlp/yt_dlp/YoutubeDL.py", line 1502, in wrapper
    return func(self, *args, **kwargs)
  File "/home/bashonly/git/yt-dlp/yt_dlp/YoutubeDL.py", line 1578, in __extract_info
    ie_result = ie.extract(url)
  File "/home/bashonly/git/yt-dlp/yt_dlp/extractor/common.py", line 682, in extract
    ie_result = self._real_extract(url)
  File "/home/bashonly/git/yt-dlp/yt_dlp/extractor/generic.py", line 2523, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://anchor.fm/lovelyti/episodes/Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d
```

It looks like anchor.fm uses its own API, separate from Spotify, and it serves clear mp3 formats.

If we look at this example URL: https://anchor.fm/lovelyti/episodes/Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d

There is a ton of JSON in the webpage source (including the mp3 link), but IMO it would be cleaner/simpler to match the episode ID (`e1tpt3d`) from the URL and use that to call the API instead:
`https://anchor.fm/api/v3/episodes/e1tpt3d`

then in the JSON response data, the mp3 URL can be found in `data['episode']['episodeEnclosureUrl']`

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
@@ -101,6 +101,7 @@
     AmericasTestKitchenIE,
     AmericasTestKitchenSeasonIE,
 )
+from .anchorfm import AnchorFMEpisodeIE
 from .angel import AngelIE
 from .anvato import AnvatoIE
 from .aol import AolIE
diff --git a/yt_dlp/extractor/anchorfm.py b/yt_dlp/extractor/anchorfm.py
new file mode 100644
--- /dev/null
+++ b/yt_dlp/extractor/anchorfm.py
@@ -0,0 +1,98 @@
+from .common import InfoExtractor
+from ..utils import (
+    clean_html,
+    float_or_none,
+    int_or_none,
+    str_or_none,
+    traverse_obj,
+    unified_timestamp
+)
+
+
+class AnchorFMEpisodeIE(InfoExtractor):
+    _VALID_URL = r'https?://anchor\.fm/(?P<channel_name>\w+)/(?:embed/)?episodes/[\w-]+-(?P<episode_id>\w+)'
+    _EMBED_REGEX = [rf'<iframe[^>]+\bsrc=[\'"](?P<url>{_VALID_URL})']
+    _TESTS = [{
+        'url': 'https://anchor.fm/lovelyti/episodes/Chrisean-Rock-takes-to-twitter-to-announce-shes-pregnant--Blueface-denies-he-is-the-father-e1tpt3d',
+        'info_dict': {
+            'id': 'e1tpt3d',
+            'ext': 'mp3',
+            'title': ' Chrisean Rock takes to twitter to announce she\'s pregnant, Blueface denies he is the father!',
+            'description': 'md5:207d167de3e28ceb4ddc1ebf5a30044c',
+            'thumbnail': 'https://s3-us-west-2.amazonaws.com/anchor-generated-image-bank/production/podcast_uploaded_nologo/1034827/1034827-1658438968460-5f3bfdf3601e8.jpg',
+            'duration': 624.718,
+            'uploader': 'Lovelyti ',
+            'uploader_id': '991541',
+            'channel': 'lovelyti',
+            'modified_date': '20230121',
+            'modified_timestamp': 1674285178,
+            'release_date': '20230121',
+            'release_timestamp': 1674285179,
+            'episode_id': 'e1tpt3d',
+        }
+    }, {
+        # embed url
+        'url': 'https://anchor.fm/apakatatempo/embed/episodes/S2E75-Perang-Bintang-di-Balik-Kasus-Ferdy-Sambo-dan-Ismail-Bolong-e1shjqd',
+        'info_dict': {
+            'id': 'e1shjqd',
+            'ext': 'mp3',
+            'title': 'S2E75 Perang Bintang di Balik Kasus Ferdy Sambo dan Ismail Bolong',
+            'description': 'md5:9e95ad9293bf00178bf8d33e9cb92c41',
+            'duration': 1042.008,
+            'thumbnail': 'https://s3-us-west-2.amazonaws.com/anchor-generated-image-bank/production/podcast_uploaded_episode400/2627805/2627805-1671590688729-4db3882ac9e4b.jpg',
+            'release_date': '20221221',
+            'release_timestamp': 1671595916,
+            'modified_date': '20221221',
+            'modified_timestamp': 1671590834,
+            'channel': 'apakatatempo',
+            'uploader': 'Podcast Tempo',
+            'uploader_id': '2585461',
+            'season': 'Season 2',
+            'season_number': 2,
+            'episode_id': 'e1shjqd',
+        }
+    }]
+
+    _WEBPAGE_TESTS = [{
+        'url': 'https://podcast.tempo.co/podcast/192/perang-bintang-di-balik-kasus-ferdy-sambo-dan-ismail-bolong',
+        'info_dict': {
+            'id': 'e1shjqd',
+            'ext': 'mp3',
+            'release_date': '20221221',
+            'duration': 1042.008,
+            'season': 'Season 2',
+            'modified_timestamp': 1671590834,
+            'uploader_id': '2585461',
+            'modified_date': '20221221',
+            'description': 'md5:9e95ad9293bf00178bf8d33e9cb92c41',
+            'season_number': 2,
+            'title': 'S2E75 Perang Bintang di Balik Kasus Ferdy Sambo dan Ismail Bolong',
+            'release_timestamp': 1671595916,
+            'episode_id': 'e1shjqd',
+            'thumbnail': 'https://s3-us-west-2.amazonaws.com/anchor-generated-image-bank/production/podcast_uploaded_episode400/2627805/2627805-1671590688729-4db3882ac9e4b.jpg',
+            'uploader': 'Podcast Tempo',
+            'channel': 'apakatatempo',
+        }
+    }]
+
+    def _real_extract(self, url):
+        channel_name, episode_id = self._match_valid_url(url).group('channel_name', 'episode_id')
+        api_data = self._download_json(f'https://anchor.fm/api/v3/episodes/{episode_id}', episode_id)
+
+        return {
+            'id': episode_id,
+            'title': traverse_obj(api_data, ('episode', 'title')),
+            'url': traverse_obj(api_data, ('episode', 'episodeEnclosureUrl'), ('episodeAudios', 0, 'url')),
+            'ext': 'mp3',
+            'vcodec': 'none',
+            'thumbnail': traverse_obj(api_data, ('episode', 'episodeImage')),
+            'description': clean_html(traverse_obj(api_data, ('episode', ('description', 'descriptionPreview')), get_all=False)),
+            'duration': float_or_none(traverse_obj(api_data, ('episode', 'duration')), 1000),
+            'modified_timestamp': unified_timestamp(traverse_obj(api_data, ('episode', 'modified'))),
+            'release_timestamp': int_or_none(traverse_obj(api_data, ('episode', 'publishOnUnixTimestamp'))),
+            'episode_id': episode_id,
+            'uploader': traverse_obj(api_data, ('creator', 'name')),
+            'uploader_id': str_or_none(traverse_obj(api_data, ('creator', 'userId'))),
+            'season_number': int_or_none(traverse_obj(api_data, ('episode', 'podcastSeasonNumber'))),
+            'channel': channel_name or traverse_obj(api_data, ('creator', 'vanitySlug')),
+        }


Test patch: