Repository: yt-dlp/yt-dlp
Pull request #9218 (2024-02-15 17:45:27)

[err] Add lasteekraan.err.ee url match
### Description of your *pull request* and other information

Add https://lasteekraan.err.ee support

Examples:
- https://lasteekraan.err.ee/1092243/patu
- https://lasteekraan.err.ee/1608163129/nukitsamees

It's a "channel" view for children, the same content seems to be available from jupiter URLs:
- https://jupiter.err.ee/1092243/patu
- https://jupiter.err.ee/1608163129/nukitsamees

<details open><summary>Template</summary> <!-- OPEN is intentional -->

<!--

# PLEASE FOLLOW THE GUIDE BELOW

- You will be asked some questions, please read them **carefully** and answer honestly
- Put an `x` into all the boxes `[ ]` relevant to your *pull request* (like [x])
- Use *Preview* tab to see how your *pull request* will actually look like

-->

### Before submitting a *pull request* make sure you have:
- [x] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [x] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests
- [x] Checked the code with [flake8](https://pypi.python.org/pypi/flake8) and [ran relevant tests](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions)

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check all of the following options that apply:
- [x] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)
- [ ] I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)

### What is the purpose of your *pull request*?
- [x] Fix or improvement to an extractor (Make sure to add/update tests)
- [ ] New extractor ([Piracy websites will not be accepted](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy))
- [ ] Core bug fix/improvement
- [ ] New feature (It is strongly [recommended to open an issue first](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#adding-new-feature-or-making-overarching-changes))

</details>



Patch:
diff --git a/Makefile b/Makefile
index 5dddaaecc..a03228b0e 100644
--- a/Makefile
+++ b/Makefile
@@ -37,12 +37,15 @@ BINDIR ?= $(PREFIX)/bin
 MANDIR ?= $(PREFIX)/man
 SHAREDIR ?= $(PREFIX)/share
 PYTHON ?= /usr/bin/env python3
+GNUTAR ?= tar
 
-# set SYSCONFDIR to /etc if PREFIX=/usr or PREFIX=/usr/local
-SYSCONFDIR = $(shell if [ $(PREFIX) = /usr -o $(PREFIX) = /usr/local ]; then echo /etc; else echo $(PREFIX)/etc; fi)
-
-# set markdown input format to "markdown-smart" for pandoc version 2 and to "markdown" for pandoc prior to version 2
-MARKDOWN = $(shell if [ `pandoc -v | head -n1 | cut -d" " -f2 | head -c1` -ge "2" ]; then echo markdown-smart; else echo markdown; fi)
+# set markdown input format to "markdown-smart" for pandoc version 2+ and to "markdown" for pandoc prior to version 2
+PANDOC_VERSION_CMD = pandoc -v 2>/dev/null | head -n1 | cut -d' ' -f2 | head -c1
+PANDOC_VERSION != $(PANDOC_VERSION_CMD)
+PANDOC_VERSION ?= $(shell $(PANDOC_VERSION_CMD))
+MARKDOWN_CMD = if [ "$(PANDOC_VERSION)" = "1" -o "$(PANDOC_VERSION)" = "0" ]; then echo markdown; else echo markdown-smart; fi
+MARKDOWN != $(MARKDOWN_CMD)
+MARKDOWN ?= $(shell $(MARKDOWN_CMD))
 
 install: lazy-extractors yt-dlp yt-dlp.1 completions
 	mkdir -p $(DESTDIR)$(BINDIR)
@@ -73,17 +76,21 @@ test:
 offlinetest: codetest
 	$(PYTHON) -m pytest -k "not download"
 
-CODE_FOLDERS := $(shell find yt_dlp -type d -not -name '__*' -exec sh -c 'test -e "$$1"/__init__.py' sh {} \; -print)
-CODE_FILES := $(shell for f in $(CODE_FOLDERS); do echo "$$f" | awk '{gsub(/\/[^\/]+/,"/*"); print $$1"/*.py"}'; done | sort -u)
+CODE_FOLDERS_CMD = find yt_dlp -type f -name '__init__.py' | sed 's,/__init__.py,,' | grep -v '/__' | sort
+CODE_FOLDERS != $(CODE_FOLDERS_CMD)
+CODE_FOLDERS ?= $(shell $(CODE_FOLDERS_CMD))
+CODE_FILES_CMD = for f in $(CODE_FOLDERS) ; do echo "$$f" | sed 's,$$,/*.py,' ; done
+CODE_FILES != $(CODE_FILES_CMD)
+CODE_FILES ?= $(shell $(CODE_FILES_CMD))
 yt-dlp: $(CODE_FILES)
 	mkdir -p zip
 	for d in $(CODE_FOLDERS) ; do \
 	  mkdir -p zip/$$d ;\
 	  cp -pPR $$d/*.py zip/$$d/ ;\
 	done
-	cd zip ; touch -t 200001010101 $(CODE_FILES)
+	(cd zip && touch -t 200001010101 $(CODE_FILES))
 	mv zip/yt_dlp/__main__.py zip/
-	cd zip ; zip -q ../yt-dlp $(CODE_FILES) __main__.py
+	(cd zip && zip -q ../yt-dlp $(CODE_FILES) __main__.py)
 	rm -rf zip
 	echo '#!$(PYTHON)' > yt-dlp
 	cat yt-dlp.zip >> yt-dlp
@@ -127,12 +134,14 @@ completions/fish/yt-dlp.fish: $(CODE_FILES) devscripts/fish-completion.in
 	mkdir -p completions/fish
 	$(PYTHON) devscripts/fish-completion.py
 
-_EXTRACTOR_FILES = $(shell find yt_dlp/extractor -name '*.py' -and -not -name 'lazy_extractors.py')
+_EXTRACTOR_FILES_CMD = find yt_dlp/extractor -name '*.py' -and -not -name 'lazy_extractors.py'
+_EXTRACTOR_FILES != $(_EXTRACTOR_FILES_CMD)
+_EXTRACTOR_FILES ?= $(shell $(_EXTRACTOR_FILES_CMD))
 yt_dlp/extractor/lazy_extractors.py: devscripts/make_lazy_extractors.py devscripts/lazy_load_template.py $(_EXTRACTOR_FILES)
 	$(PYTHON) devscripts/make_lazy_extractors.py $@
 
 yt-dlp.tar.gz: all
-	@tar -czf yt-dlp.tar.gz --transform "s|^|yt-dlp/|" --owner 0 --group 0 \
+	@$(GNUTAR) -czf yt-dlp.tar.gz --transform "s|^|yt-dlp/|" --owner 0 --group 0 \
 		--exclude '*.DS_Store' \
 		--exclude '*.kate-swp' \
 		--exclude '*.pyc' \
@@ -141,6 +150,7 @@ yt-dlp.tar.gz: all
 		--exclude '__pycache__' \
 		--exclude '.pytest_cache' \
 		--exclude '.git' \
+		--exclude '__pyinstaller' \
 		-- \
 		README.md supportedsites.md Changelog.md LICENSE \
 		CONTRIBUTING.md Collaborators.md CONTRIBUTORS AUTHORS \
diff --git a/yt_dlp/extractor/err.py b/yt_dlp/extractor/err.py
index 129f39ad6..abd00f2d5 100644
--- a/yt_dlp/extractor/err.py
+++ b/yt_dlp/extractor/err.py
@@ -9,7 +9,7 @@
 
 
 class ERRJupiterIE(InfoExtractor):
-    _VALID_URL = r'https?://jupiter(?:pluss)?\.err\.ee/(?P<id>\d+)'
+    _VALID_URL = r'https?://(?:jupiter(?:pluss)?|lasteekraan)\.err\.ee/(?P<id>\d+)'
     _TESTS = [{
         'note': 'Jupiter: Movie: siin-me-oleme',
         'url': 'https://jupiter.err.ee/1211107/siin-me-oleme',
@@ -145,6 +145,31 @@ class ERRJupiterIE(InfoExtractor):
             'season_number': 0,
             'series': 'Лесные истории | Аисты',
             'series_id': '1037497',
+        }
+    }, {
+        'note': 'Lasteekraan: Pätu',
+        'url': 'https://lasteekraan.err.ee/1092243/patu',
+        'md5': 'a67eb9b9bcb3d201718c15d1638edf77',
+        'info_dict': {
+            'id': '1092243',
+            'ext': 'mp4',
+            'title': 'Pätu',
+            'alt_title': '',
+            'description': 'md5:64a7b5a80afd7042d3f8ec48c77befd9',
+            'release_date': '20230614',
+            'upload_date': '20200520',
+            'modified_date': '20200520',
+            'release_timestamp': 1686745800,
+            'timestamp': 1589975640,
+            'modified_timestamp': 1589975640,
+            'release_year': 1990,
+            'episode': 'Episode 1',
+            'episode_id': '1092243',
+            'episode_number': 1,
+            'season': 'Season 1',
+            'season_number': 1,
+            'series': 'Pätu',
+            'series_id': '1092236',
         },
     }]
 
diff --git a/yt_dlp/extractor/facebook.py b/yt_dlp/extractor/facebook.py
index 830bbcc3c..834b1df18 100644
--- a/yt_dlp/extractor/facebook.py
+++ b/yt_dlp/extractor/facebook.py
@@ -500,6 +500,7 @@ def extract_metadata(webpage):
                 webpage, 'description', default=None)
             uploader_data = (
                 get_first(media, ('owner', {dict}))
+                or get_first(post, ('video', 'creation_story', 'attachments', ..., 'media', lambda k, v: k == 'owner' and v['name']))
                 or get_first(post, (..., 'video', lambda k, v: k == 'owner' and v['name']))
                 or get_first(post, ('node', 'actors', ..., {dict}))
                 or get_first(post, ('event', 'event_creator', {dict})) or {})
@@ -583,8 +584,8 @@ def extract_relay_data(_filter):
         def extract_relay_prefetched_data(_filter):
             return traverse_obj(extract_relay_data(_filter), (
                 'require', (None, (..., ..., ..., '__bbox', 'require')),
-                lambda _, v: 'RelayPrefetchedStreamCache' in v, ..., ...,
-                '__bbox', 'result', 'data', {dict}), get_all=False) or {}
+                lambda _, v: any(key.startswith('RelayPrefetchedStreamCache') for key in v),
+                ..., ..., '__bbox', 'result', 'data', {dict}), get_all=False) or {}
 
         if not video_data:
             server_js_data = self._parse_json(self._search_regex([
diff --git a/yt_dlp/extractor/linkedin.py b/yt_dlp/extractor/linkedin.py
index 2bf2e9a11..ad41c0e20 100644
--- a/yt_dlp/extractor/linkedin.py
+++ b/yt_dlp/extractor/linkedin.py
@@ -3,16 +3,15 @@
 
 from .common import InfoExtractor
 from ..utils import (
-    clean_html,
-    extract_attributes,
     ExtractorError,
+    extract_attributes,
     float_or_none,
-    get_element_by_class,
     int_or_none,
     srt_subtitles_timecode,
-    strip_or_none,
     mimetype2ext,
+    traverse_obj,
     try_get,
+    url_or_none,
     urlencode_postdata,
     urljoin,
 )
@@ -83,15 +82,29 @@ def _get_video_id(self, video_data, course_slug, video_slug):
 
 
 class LinkedInIE(LinkedInBaseIE):
-    _VALID_URL = r'https?://(?:www\.)?linkedin\.com/posts/.+?(?P<id>\d+)'
+    _VALID_URL = r'https?://(?:www\.)?linkedin\.com/posts/[^/?#]+-(?P<id>\d+)-\w{4}/?(?:[?#]|$)'
     _TESTS = [{
         'url': 'https://www.linkedin.com/posts/mishalkhawaja_sendinblueviews-toronto-digitalmarketing-ugcPost-6850898786781339649-mM20',
         'info_dict': {
             'id': '6850898786781339649',
             'ext': 'mp4',
-            'title': 'Mishal K. on LinkedIn: #sendinblueviews #toronto #digitalmarketing',
-            'description': 'md5:be125430bab1c574f16aeb186a4d5b19',
-            'creator': 'Mishal K.'
+            'title': 'Mishal K. on LinkedIn: #sendinblueviews #toronto #digitalmarketing #nowhiring #sendinblue…',
+            'description': 'md5:2998a31f6f479376dd62831f53a80f71',
+            'uploader': 'Mishal K.',
+            'thumbnail': 're:^https?://media.licdn.com/dms/image/.*$',
+            'like_count': int
+        },
+    }, {
+        'url': 'https://www.linkedin.com/posts/the-mathworks_2_what-is-mathworks-cloud-center-activity-7151241570371948544-4Gu7',
+        'info_dict': {
+            'id': '7151241570371948544',
+            'ext': 'mp4',
+            'title': 'MathWorks on LinkedIn: What Is MathWorks Cloud Center?',
+            'description': 'md5:95f9d4eeb6337882fb47eefe13d7a40c',
+            'uploader': 'MathWorks',
+            'thumbnail': 're:^https?://media.licdn.com/dms/image/.*$',
+            'like_count': int,
+            'subtitles': 'mincount:1'
         },
     }]
 
@@ -99,26 +112,30 @@ def _real_extract(self, url):
         video_id = self._match_id(url)
         webpage = self._download_webpage(url, video_id)
 
-        title = self._html_extract_title(webpage)
-        description = clean_html(get_element_by_class('share-update-card__update-text', webpage))
-        like_count = int_or_none(get_element_by_class('social-counts-reactions__social-counts-numRections', webpage))
-        creator = strip_or_none(clean_html(get_element_by_class('comment__actor-name', webpage)))
-
-        sources = self._parse_json(extract_attributes(self._search_regex(r'(<video[^>]+>)', webpage, 'video'))['data-sources'], video_id)
+        video_attrs = extract_attributes(self._search_regex(r'(<video[^>]+>)', webpage, 'video'))
+        sources = self._parse_json(video_attrs['data-sources'], video_id)
         formats = [{
             'url': source['src'],
             'ext': mimetype2ext(source.get('type')),
             'tbr': float_or_none(source.get('data-bitrate'), scale=1000),
         } for source in sources]
+        subtitles = {'en': [{
+            'url': video_attrs['data-captions-url'],
+            'ext': 'vtt',
+        }]} if url_or_none(video_attrs.get('data-captions-url')) else {}
 
         return {
             'id': video_id,
             'formats': formats,
-            'title': title,
-            'like_count': like_count,
-            'creator': creator,
+            'title': self._og_search_title(webpage, default=None) or self._html_extract_title(webpage),
+            'like_count': int_or_none(self._search_regex(
+                r'\bdata-num-reactions="(\d+)"', webpage, 'reactions', default=None)),
+            'uploader': traverse_obj(
+                self._yield_json_ld(webpage, video_id),
+                (lambda _, v: v['@type'] == 'SocialMediaPosting', 'author', 'name', {str}), get_all=False),
             'thumbnail': self._og_search_thumbnail(webpage),
-            'description': description,
+            'description': self._og_search_description(webpage, default=None),
+            'subtitles': subtitles,
         }
 
 
diff --git a/yt_dlp/extractor/nova.py b/yt_dlp/extractor/nova.py
index 8a7dfceeb..72884aaaa 100644
--- a/yt_dlp/extractor/nova.py
+++ b/yt_dlp/extractor/nova.py
@@ -135,14 +135,15 @@ class NovaIE(InfoExtractor):
     _VALID_URL = r'https?://(?:[^.]+\.)?(?P<site>tv(?:noviny)?|tn|novaplus|vymena|fanda|krasna|doma|prask)\.nova\.cz/(?:[^/]+/)+(?P<id>[^/]+?)(?:\.html|/|$)'
     _TESTS = [{
         'url': 'http://tn.nova.cz/clanek/tajemstvi-ukryte-v-podzemi-specialni-nemocnice-v-prazske-krci.html#player_13260',
-        'md5': '249baab7d0104e186e78b0899c7d5f28',
+        'md5': 'da8f3f1fcdaf9fb0f112a32a165760a3',
         'info_dict': {
-            'id': '1757139',
-            'display_id': 'tajemstvi-ukryte-v-podzemi-specialni-nemocnice-v-prazske-krci',
+            'id': '8OvQqEvV3MW',
+            'display_id': '8OvQqEvV3MW',
             'ext': 'mp4',
             'title': 'Podzemní nemocnice v pražské Krči',
             'description': 'md5:f0a42dd239c26f61c28f19e62d20ef53',
             'thumbnail': r're:^https?://.*\.(?:jpg)',
+            'duration': 151,
         }
     }, {
         'url': 'http://fanda.nova.cz/clanek/fun-and-games/krvavy-epos-zaklinac-3-divoky-hon-vychazi-vyhrajte-ho-pro-sebe.html',
@@ -210,7 +211,7 @@ def _real_extract(self, url):
 
         # novaplus
         embed_id = self._search_regex(
-            r'<iframe[^>]+\bsrc=["\'](?:https?:)?//media\.cms\.nova\.cz/embed/([^/?#&]+)',
+            r'<iframe[^>]+\bsrc=["\'](?:https?:)?//media(?:tn)?\.cms\.nova\.cz/embed/([^/?#&"\']+)',
             webpage, 'embed url', default=None)
         if embed_id:
             return {
diff --git a/yt_dlp/extractor/onefootball.py b/yt_dlp/extractor/onefootball.py
index 591d15732..e1b726830 100644
--- a/yt_dlp/extractor/onefootball.py
+++ b/yt_dlp/extractor/onefootball.py
@@ -1,4 +1,6 @@
 from .common import InfoExtractor
+from .jwplatform import JWPlatformIE
+from ..utils import make_archive_id
 
 
 class OneFootballIE(InfoExtractor):
@@ -7,41 +9,43 @@ class OneFootballIE(InfoExtractor):
     _TESTS = [{
         'url': 'https://onefootball.com/en/video/highlights-fc-zuerich-3-3-fc-basel-34012334',
         'info_dict': {
-            'id': '34012334',
+            'id': 'Y2VtcWAT',
             'ext': 'mp4',
             'title': 'Highlights: FC Zürich 3-3 FC Basel',
             'description': 'md5:33d9855cb790702c4fe42a513700aba8',
-            'thumbnail': 'https://photobooth-api.onefootball.com/api/screenshot/https:%2F%2Fperegrine-api.onefootball.com%2Fv2%2Fphotobooth%2Fcms%2Fen%2F34012334',
-            'timestamp': 1635874604,
-            'upload_date': '20211102'
+            'thumbnail': 'https://cdn.jwplayer.com/v2/media/Y2VtcWAT/poster.jpg?width=720',
+            'timestamp': 1635874895,
+            'upload_date': '20211102',
+            'duration': 375.0,
+            'tags': ['Football', 'Soccer', 'OneFootball'],
+            '_old_archive_ids': ['onefootball 34012334'],
         },
-        'params': {'skip_download': True}
+        'params': {'skip_download': True},
+        'expected_warnings': ['Failed to download m3u8 information'],
     }, {
         'url': 'https://onefootball.com/en/video/klopp-fumes-at-var-decisions-in-west-ham-defeat-34041020',
         'info_dict': {
-            'id': '34041020',
+            'id': 'leVJrMho',
             'ext': 'mp4',
             'title': 'Klopp fumes at VAR decisions in West Ham defeat',
             'description': 'md5:9c50371095a01ad3f63311c73d8f51a5',
-            'thumbnail': 'https://photobooth-api.onefootball.com/api/screenshot/https:%2F%2Fperegrine-api.onefootball.com%2Fv2%2Fphotobooth%2Fcms%2Fen%2F34041020',
-            'timestamp': 1636314103,
-            'upload_date': '20211107'
+            'thumbnail': 'https://cdn.jwplayer.com/v2/media/leVJrMho/poster.jpg?width=720',
+            'timestamp': 1636315232,
+            'upload_date': '20211107',
+            'duration': 93.0,
+            'tags': ['Football', 'Soccer', 'OneFootball'],
+            '_old_archive_ids': ['onefootball 34041020'],
         },
         'params': {'skip_download': True}
     }]
 
     def _real_extract(self, url):
-        id = self._match_id(url)
-        webpage = self._download_webpage(url, id)
-        data_json = self._search_json_ld(webpage, id)
-        m3u8_url = self._html_search_regex(r'(https://cdn\.jwplayer\.com/manifests/.+\.m3u8)', webpage, 'm3u8_url')
-        formats, subtitles = self._extract_m3u8_formats_and_subtitles(m3u8_url, id)
-        return {
-            'id': id,
-            'title': data_json.get('title'),
-            'description': data_json.get('description'),
-            'thumbnail': data_json.get('thumbnail'),
-            'timestamp': data_json.get('timestamp'),
-            'formats': formats,
-            'subtitles': subtitles,
-        }
+        video_id = self._match_id(url)
+        webpage = self._download_webpage(url, video_id)
+        data_json = self._search_json_ld(webpage, video_id, fatal=False)
+        data_json.pop('url', None)
+        m3u8_url = self._html_search_regex(r'(https://cdn\.jwplayer\.com/manifests/\w+\.m3u8)', webpage, 'm3u8_url')
+
+        return self.url_result(
+            m3u8_url, JWPlatformIE, video_id, _old_archive_ids=[make_archive_id(self, video_id)],
+            **data_json, url_transparent=True)
diff --git a/yt_dlp/extractor/pornhub.py b/yt_dlp/extractor/pornhub.py
index 999d038d4..29a3e43cc 100644
--- a/yt_dlp/extractor/pornhub.py
+++ b/yt_dlp/extractor/pornhub.py
@@ -87,8 +87,8 @@ def _login(self, host):
 
         def is_logged(webpage):
             return any(re.search(p, webpage) for p in (
-                r'class=["\']signOut',
-                r'>Sign\s+[Oo]ut\s*<'))
+                r'id="profileMenuDropdown"',
+                r'class="ph-icon-logout"'))
 
         if is_logged(login_page):
             self._logged_in = True
diff --git a/yt_dlp/extractor/rai.py b/yt_dlp/extractor/rai.py
index df4102a40..f6219c2db 100644
--- a/yt_dlp/extractor/rai.py
+++ b/yt_dlp/extractor/rai.py
@@ -1,6 +1,7 @@
 import re
 
 from .common import InfoExtractor
+from ..networking import HEADRequest
 from ..utils import (
     clean_html,
     determine_ext,
@@ -91,7 +92,7 @@ def fix_cdata(s):
             self.raise_geo_restricted(countries=self._GEO_COUNTRIES, metadata_available=True)
 
         if not audio_only and not is_live:
-            formats.extend(self._create_http_urls(media_url, relinker_url, formats))
+            formats.extend(self._create_http_urls(media_url, relinker_url, formats, video_id))
 
         return filter_dict({
             'is_live': is_live,
@@ -99,7 +100,7 @@ def fix_cdata(s):
             'formats': formats,
         })
 
-    def _create_http_urls(self, manifest_url, relinker_url, fmts):
+    def _create_http_urls(self, manifest_url, relinker_url, fmts, video_id):
         _MANIFEST_REG = r'/(?P<id>\w+)(?:_(?P<quality>[\d\,]+))?(?:\.mp4)?(?:\.csmil)?/playlist\.m3u8'
         _MP4_TMPL = '%s&overrideUserAgentRule=mp4-%s'
         _QUALITY = {
@@ -166,6 +167,14 @@ def get_format_info(tbr):
                 'fps': 25,
             }
 
+        # Check if MP4 download is available
+        try:
+            self._request_webpage(
+                HEADRequest(_MP4_TMPL % (relinker_url, '*')), video_id, 'Checking MP4 availability')
+        except ExtractorError as e:
+            self.to_screen(f'{video_id}: MP4 direct download is not available: {e.cause}')
+            return []
+
         # filter out single-stream formats
         fmts = [f for f in fmts
                 if not f.get('vcodec') == 'none' and not f.get('acodec') == 'none']
diff --git a/yt_dlp/extractor/screencastify.py b/yt_dlp/extractor/screencastify.py
index 136b8479b..3c43043de 100644
--- a/yt_dlp/extractor/screencastify.py
+++ b/yt_dlp/extractor/screencastify.py
@@ -5,7 +5,10 @@
 
 
 class ScreencastifyIE(InfoExtractor):
-    _VALID_URL = r'https?://watch\.screencastify\.com/v/(?P<id>[^/?#]+)'
+    _VALID_URL = [
+        r'https?://watch\.screencastify\.com/v/(?P<id>[^/?#]+)',
+        r'https?://app\.screencastify\.com/v[23]/watch/(?P<id>[^/?#]+)',
+    ]
     _TESTS = [{
         'url': 'https://watch.screencastify.com/v/sYVkZip3quLKhHw4Ybk8',
         'info_dict': {
@@ -19,6 +22,21 @@ class ScreencastifyIE(InfoExtractor):
         'params': {
             'skip_download': 'm3u8',
         },
+    }, {
+        'url': 'https://app.screencastify.com/v3/watch/J5N7H11wofDN1jZUCr3t',
+        'info_dict': {
+            'id': 'J5N7H11wofDN1jZUCr3t',
+            'ext': 'mp4',
+            'uploader': 'Scott Piesen',
+            'description': '',
+            'title': 'Lesson Recording 1-17 Burrr...',
+        },
+        'params': {
+            'skip_download': 'm3u8',
+        },
+    }, {
+        'url': 'https://app.screencastify.com/v2/watch/BQ26VbUdfbQLhKzkktOk',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/svt.py b/yt_dlp/extractor/svt.py
index 18da87534..573147a45 100644
--- a/yt_dlp/extractor/svt.py
+++ b/yt_dlp/extractor/svt.py
@@ -7,8 +7,6 @@
     determine_ext,
     dict_get,
     int_or_none,
-    str_or_none,
-    strip_or_none,
     traverse_obj,
     try_get,
     unified_timestamp,
@@ -388,15 +386,55 @@ def _real_extract(self, url):
             dict_get(series, ('longDescription', 'shortDescription')))
 
 
-class SVTPageIE(InfoExtractor):
-    _VALID_URL = r'https?://(?:www\.)?svt\.se/(?P<path>(?:[^/]+/)*(?P<id>[^/?&#]+))'
+class SVTPageIE(SVTBaseIE):
+    _VALID_URL = r'https?://(?:www\.)?svt\.se/(?:[^/?#]+/)*(?P<id>[^/?&#]+)'
     _TESTS = [{
+        'url': 'https://www.svt.se/nyheter/lokalt/skane/viktor-18-forlorade-armar-och-ben-i-sepsis-vill-ateruppta-karaten-och-bli-svetsare',
+        'info_dict': {
+            'title': 'Viktor, 18, förlorade armar och ben i sepsis – vill återuppta karaten och bli svetsare',
+            'id': 'viktor-18-forlorade-armar-och-ben-i-sepsis-vill-ateruppta-karaten-och-bli-svetsare',
+        },
+        'playlist_count': 2,
+    }, {
+        'url': 'https://www.svt.se/nyheter/lokalt/skane/forsvarsmakten-om-trafikkaoset-pa-e22-kunde-inte-varit-dar-snabbare',
+        'info_dict': {
+            'id': 'jXvk42E',
+            'title': 'Försvarsmakten om trafikkaoset på E22: Kunde inte varit där snabbare',
+            'ext': 'mp4',
+            "duration": 80,
+            'age_limit': 0,
+            'timestamp': 1704370009,
+            'episode': 'Försvarsmakten om trafikkaoset på E22: Kunde inte varit där snabbare',
+            'series': 'Lokala Nyheter Skåne',
+            'upload_date': '20240104'
+        },
+        'params': {
+            'skip_download': True,
+        }
+    }, {
+        'url': 'https://www.svt.se/nyheter/svtforum/2023-tungt-ar-for-svensk-media',
+        'info_dict': {
+            'title': '2023 tungt år för svensk media',
+            'id': 'ewqAZv4',
+            'ext': 'mp4',
+            "duration": 3074,
+            'age_limit': 0,
+            'series': '',
+            'timestamp': 1702980479,
+            'upload_date': '20231219',
+            'episode': 'Mediestudier'
+        },
+        'params': {
+            'skip_download': True,
+        }
+    }, {
         'url': 'https://www.svt.se/sport/ishockey/bakom-masken-lehners-kamp-mot-mental-ohalsa',
         'info_dict': {
             'id': '25298267',
             'title': 'Bakom masken – Lehners kamp mot mental ohälsa',
         },
         'playlist_count': 4,
+        'skip': 'Video is gone'
     }, {
         'url': 'https://www.svt.se/nyheter/utrikes/svenska-andrea-ar-en-mil-fran-branderna-i-kalifornien',
         'info_dict': {
@@ -404,6 +442,7 @@ class SVTPageIE(InfoExtractor):
             'title': 'Svenska Andrea redo att fly sitt hem i Kalifornien',
         },
         'playlist_count': 2,
+        'skip': 'Video is gone'
     }, {
         # only programTitle
         'url': 'http://www.svt.se/sport/ishockey/jagr-tacklar-giroux-under-intervjun',
@@ -414,6 +453,7 @@ class SVTPageIE(InfoExtractor):
             'duration': 27,
             'age_limit': 0,
         },
+        'skip': 'Video is gone'
     }, {
         'url': 'https://www.svt.se/nyheter/lokalt/vast/svt-testar-tar-nagon-upp-skrapet-1',
         'only_matching': True,
@@ -427,26 +467,23 @@ def suitable(cls, url):
         return False if SVTIE.suitable(url) or SVTPlayIE.suitable(url) else super(SVTPageIE, cls).suitable(url)
 
     def _real_extract(self, url):
-        path, display_id = self._match_valid_url(url).groups()
+        display_id = self._match_id(url)
 
-        article = self._download_json(
-            'https://api.svt.se/nss-api/page/' + path, display_id,
-            query={'q': 'articles'})['articles']['content'][0]
+        webpage = self._download_webpage(url, display_id)
+        title = self._og_search_title(webpage)
 
-        entries = []
+        urql_state = self._search_json(
+            r'window\.svt\.nyh\.urqlState\s*=', webpage, 'json data', display_id)
 
-        def _process_content(content):
-            if content.get('_type') in ('VIDEOCLIP', 'VIDEOEPISODE'):
-                video_id = compat_str(content['image']['svtId'])
-                entries.append(self.url_result(
-                    'svt:' + video_id, SVTPlayIE.ie_key(), video_id))
+        data = traverse_obj(urql_state, (..., 'data', {str}, {json.loads}), get_all=False) or {}
 
-        for media in article.get('media', []):
-            _process_content(media)
+        def entries():
+            for video_id in set(traverse_obj(data, (
+                'page', (('topMedia', 'svtId'), ('body', ..., 'video', 'svtId')), {str}
+            ))):
+                info = self._extract_video(
+                    self._download_json(f'https://api.svt.se/video/{video_id}', video_id), video_id)
+                info['title'] = title
+                yield info
 
-        for obj in article.get('structuredBody', []):
-            _process_content(obj.get('content') or {})
-
-        return self.playlist_result(
-            entries, str_or_none(article.get('id')),
-            strip_or_none(article.get('title')))
+        return self.playlist_result(entries(), display_id, title)
diff --git a/yt_dlp/extractor/utreon.py b/yt_dlp/extractor/utreon.py
index 8a9169101..12a7e4984 100644
--- a/yt_dlp/extractor/utreon.py
+++ b/yt_dlp/extractor/utreon.py
@@ -10,7 +10,8 @@
 
 
 class UtreonIE(InfoExtractor):
-    _VALID_URL = r'https?://(?:www\.)?utreon\.com/v/(?P<id>[\w-]+)'
+    IE_NAME = 'playeur'
+    _VALID_URL = r'https?://(?:www\.)?(?:utreon|playeur)\.com/v/(?P<id>[\w-]+)'
     _TESTS = [{
         'url': 'https://utreon.com/v/z_I7ikQbuDw',
         'info_dict': {
@@ -19,8 +20,9 @@ class UtreonIE(InfoExtractor):
             'title': 'Freedom Friday meditation - Rising in the wind',
             'description': 'md5:a9bf15a42434a062fe313b938343ad1b',
             'uploader': 'Heather Dawn Elemental Health',
-            'thumbnail': 'https://data-1.utreon.com/v/MG/M2/NT/z_I7ikQbuDw/z_I7ikQbuDw_preview.jpg',
+            'thumbnail': r're:^https?://.+\.jpg',
             'release_date': '20210723',
+            'duration': 586,
         }
     }, {
         'url': 'https://utreon.com/v/jerJw5EOOVU',
@@ -28,10 +30,11 @@ class UtreonIE(InfoExtractor):
             'id': 'jerJw5EOOVU',
             'ext': 'mp4',
             'title': 'When I\'m alone, I love to reflect in peace, to make my dreams come true... [Quotes and Poems]',
-            'description': 'md5:61ee6c2da98be51b04b969ca80273aaa',
+            'description': 'md5:4026aa3a2c10169c3649926ac8ef62b6',
             'uploader': 'Frases e Poemas Quotes and Poems',
-            'thumbnail': 'https://data-1.utreon.com/v/Mz/Zh/ND/jerJw5EOOVU/jerJw5EOOVU_89af85470a4b16eededde7f8674c96d9_cover.jpg',
+            'thumbnail': r're:^https?://.+\.jpg',
             'release_date': '20210723',
+            'duration': 60,
         }
     }, {
         'url': 'https://utreon.com/v/C4ZxXhYBBmE',
@@ -39,10 +42,11 @@ class UtreonIE(InfoExtractor):
             'id': 'C4ZxXhYBBmE',
             'ext': 'mp4',
             'title': 'Biden’s Capital Gains Tax Rate to Test World’s Highest',
-            'description': 'md5:fb5a6c2e506f013cc76f133f673bc5c8',
+            'description': 'md5:995aa9ad0733c0e5863ebdeff954f40e',
             'uploader': 'Nomad Capitalist',
-            'thumbnail': 'https://data-1.utreon.com/v/ZD/k1/Mj/C4ZxXhYBBmE/C4ZxXhYBBmE_628342076198c9c06dd6b2c665978584_cover.jpg',
+            'thumbnail': r're:^https?://.+\.jpg',
             'release_date': '20210723',
+            'duration': 884,
         }
     }, {
         'url': 'https://utreon.com/v/Y-stEH-FBm8',
@@ -52,15 +56,28 @@ class UtreonIE(InfoExtractor):
             'title': 'Creeper-Chan Pranks Steve! 💚 [MINECRAFT ANIME]',
             'description': 'md5:7a48450b0d761b96dec194be0c5ecb5f',
             'uploader': 'Merryweather Comics',
-            'thumbnail': 'https://data-1.utreon.com/v/MT/E4/Zj/Y-stEH-FBm8/Y-stEH-FBm8_5290676a41a4a1096db133b09f54f77b_cover.jpg',
+            'thumbnail': r're:^https?://.+\.jpg',
             'release_date': '20210718',
-        }},
-    ]
+            'duration': 151,
+        }
+    }, {
+        'url': 'https://playeur.com/v/Wzqp-UrxSeu',
+        'info_dict': {
+            'id': 'Wzqp-UrxSeu',
+            'ext': 'mp4',
+            'title': 'Update: Clockwork Basilisk Books on the Way!',
+            'description': 'md5:d9756b0b1884c904655b0e170d17cea5',
+            'uploader': 'Forgotten Weapons',
+            'release_date': '20240208',
+            'thumbnail': r're:^https?://.+\.jpg',
+            'duration': 262,
+        }
+    }]
 
     def _real_extract(self, url):
         video_id = self._match_id(url)
         json_data = self._download_json(
-            'https://api.utreon.com/v1/videos/' + video_id,
+            'https://api.playeur.com/v1/videos/' + video_id,
             video_id)
         videos_json = json_data['videos']
         formats = [{
diff --git a/yt_dlp/networking/_requests.py b/yt_dlp/networking/_requests.py
index 00e4bdb49..7b19029bf 100644
--- a/yt_dlp/networking/_requests.py
+++ b/yt_dlp/networking/_requests.py
@@ -258,10 +258,10 @@ def __init__(self, *args, **kwargs):
 
         # Forward urllib3 debug messages to our logger
         logger = logging.getLogger('urllib3')
-        handler = Urllib3LoggingHandler(logger=self._logger)
-        handler.setFormatter(logging.Formatter('requests: %(message)s'))
-        handler.addFilter(Urllib3LoggingFilter())
-        logger.addHandler(handler)
+        self.__logging_handler = Urllib3LoggingHandler(logger=self._logger)
+        self.__logging_handler.setFormatter(logging.Formatter('requests: %(message)s'))
+        self.__logging_handler.addFilter(Urllib3LoggingFilter())
+        logger.addHandler(self.__logging_handler)
         # TODO: Use a logger filter to suppress pool reuse warning instead
         logger.setLevel(logging.ERROR)
 
@@ -276,6 +276,9 @@ def __init__(self, *args, **kwargs):
 
     def close(self):
         self._clear_instances()
+        # Remove the logging handler that contains a reference to our logger
+        # See: https://github.com/yt-dlp/yt-dlp/issues/8922
+        logging.getLogger('urllib3').removeHandler(self.__logging_handler)
 
     def _check_extensions(self, extensions):
         super()._check_extensions(extensions)
diff --git a/yt_dlp/networking/_websockets.py b/yt_dlp/networking/_websockets.py
index ed64080d6..159793204 100644
--- a/yt_dlp/networking/_websockets.py
+++ b/yt_dlp/networking/_websockets.py
@@ -90,10 +90,12 @@ class WebsocketsRH(WebSocketRequestHandler):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        self.__logging_handlers = {}
         for name in ('websockets.client', 'websockets.server'):
             logger = logging.getLogger(name)
             handler = logging.StreamHandler(stream=sys.stdout)
             handler.setFormatter(logging.Formatter(f'{self.RH_NAME}: %(message)s'))
+            self.__logging_handlers[name] = handler
             logger.addHandler(handler)
             if self.verbose:
                 logger.setLevel(logging.DEBUG)
@@ -103,6 +105,12 @@ def _check_extensions(self, extensions):
         extensions.pop('timeout', None)
         extensions.pop('cookiejar', None)
 
+    def close(self):
+        # Remove the logging handler that contains a reference to our logger
+        # See: https://github.com/yt-dlp/yt-dlp/issues/8922
+        for name, handler in self.__logging_handlers.items():
+            logging.getLogger(name).removeHandler(handler)
+
     def _send(self, request):
         timeout = float(request.extensions.get('timeout') or self.timeout)
         headers = self._merge_headers(request.headers)


Test patch:
diff --git a/test/test_networking.py b/test/test_networking.py
index 8cadd86f5..10534242a 100644
--- a/test/test_networking.py
+++ b/test/test_networking.py
@@ -13,6 +13,7 @@
 import http.cookiejar
 import http.server
 import io
+import logging
 import pathlib
 import random
 import ssl
@@ -752,6 +753,25 @@ def test_certificate_nocombined_pass(self, handler):
         })
 
 
+class TestRequestHandlerMisc:
+    """Misc generic tests for request handlers, not related to request or validation testing"""
+    @pytest.mark.parametrize('handler,logger_name', [
+        ('Requests', 'urllib3'),
+        ('Websockets', 'websockets.client'),
+        ('Websockets', 'websockets.server')
+    ], indirect=['handler'])
+    def test_remove_logging_handler(self, handler, logger_name):
+        # Ensure any logging handlers, which may contain a YoutubeDL instance,
+        # are removed when we close the request handler
+        # See: https://github.com/yt-dlp/yt-dlp/issues/8922
+        logging_handlers = logging.getLogger(logger_name).handlers
+        before_count = len(logging_handlers)
+        rh = handler()
+        assert len(logging_handlers) == before_count + 1
+        rh.close()
+        assert len(logging_handlers) == before_count
+
+
 class TestUrllibRequestHandler(TestRequestHandlerBase):
     @pytest.mark.parametrize('handler', ['Urllib'], indirect=True)
     def test_file_urls(self, handler):
@@ -827,6 +847,7 @@ def test_httplib_validation_errors(self, handler, req, match, version_check):
             assert not isinstance(exc_info.value, TransportError)
 
 
+@pytest.mark.parametrize('handler', ['Requests'], indirect=True)
 class TestRequestsRequestHandler(TestRequestHandlerBase):
     @pytest.mark.parametrize('raised,expected', [
         (lambda: requests.exceptions.ConnectTimeout(), TransportError),
@@ -843,7 +864,6 @@ class TestRequestsRequestHandler(TestRequestHandlerBase):
         (lambda: requests.exceptions.RequestException(), RequestError)
         #  (lambda: requests.exceptions.TooManyRedirects(), HTTPError) - Needs a response object
     ])
-    @pytest.mark.parametrize('handler', ['Requests'], indirect=True)
     def test_request_error_mapping(self, handler, monkeypatch, raised, expected):
         with handler() as rh:
             def mock_get_instance(*args, **kwargs):
@@ -877,7 +897,6 @@ def request(self, *args, **kwargs):
             '3 bytes read, 5 more expected'
         ),
     ])
-    @pytest.mark.parametrize('handler', ['Requests'], indirect=True)
     def test_response_error_mapping(self, handler, monkeypatch, raised, expected, match):
         from requests.models import Response as RequestsResponse
         from urllib3.response import HTTPResponse as Urllib3Response
@@ -896,6 +915,21 @@ def mock_read(*args, **kwargs):
 
         assert exc_info.type is expected
 
+    def test_close(self, handler, monkeypatch):
+        rh = handler()
+        session = rh._get_instance(cookiejar=rh.cookiejar)
+        called = False
+        original_close = session.close
+
+        def mock_close(*args, **kwargs):
+            nonlocal called
+            called = True
+            return original_close(*args, **kwargs)
+
+        monkeypatch.setattr(session, 'close', mock_close)
+        rh.close()
+        assert called
+
 
 def run_validation(handler, error, req, **handler_kwargs):
     with handler(**handler_kwargs) as rh:
@@ -1205,6 +1239,19 @@ def some_preference(rh, request):
         assert director.send(Request('http://')).read() == b''
         assert director.send(Request('http://', headers={'prefer': '1'})).read() == b'supported'
 
+    def test_close(self, monkeypatch):
+        director = RequestDirector(logger=FakeLogger())
+        director.add_handler(FakeRH(logger=FakeLogger()))
+        called = False
+
+        def mock_close(*args, **kwargs):
+            nonlocal called
+            called = True
+
+        monkeypatch.setattr(director.handlers[FakeRH.RH_KEY], 'close', mock_close)
+        director.close()
+        assert called
+
 
 # XXX: do we want to move this to test_YoutubeDL.py?
 class TestYoutubeDLNetworking: