From c9d1f446810005db38fcf753de8a16e78cc1cf8c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 14 Apr 2026 20:54:08 +0200 Subject: [PATCH 1/3] feat(youtube): add podcasts channel tab extraction support --- .../extractor/linkhandler/ChannelTabs.java | 1 + .../extractors/YoutubeChannelExtractor.java | 31 +++++- .../YoutubeChannelTabExtractor.java | 94 ++++++++++++++++--- .../YoutubeChannelTabLinkHandlerFactory.java | 2 + 4 files changed, 111 insertions(+), 17 deletions(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/linkhandler/ChannelTabs.java b/extractor/src/main/java/org/schabi/newpipe/extractor/linkhandler/ChannelTabs.java index 91322acc..9cb5bc86 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/linkhandler/ChannelTabs.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/linkhandler/ChannelTabs.java @@ -7,6 +7,7 @@ public final class ChannelTabs { public static final String LIVESTREAMS = "livestreams"; public static final String CHANNELS = "channels"; public static final String PLAYLISTS = "playlists"; + public static final String PODCASTS = "podcasts"; public static final String ALBUMS = "albums"; private ChannelTabs() { diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelExtractor.java index cd9b127f..ee5b285b 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelExtractor.java @@ -674,8 +674,7 @@ private JsonObject getVideoTab() throws ParsingException { .getObject("commandMetadata").getObject("webCommandMetadata") .getString("url"); if (tabUrl != null) { - final String[] urlParts = tabUrl.split("/"); - final String urlSuffix = urlParts[urlParts.length - 1]; + final String urlSuffix = normalizeTabSuffix(tabUrl); switch (urlSuffix) { case "videos": @@ -685,6 +684,9 @@ private JsonObject getVideoTab() throws ParsingException { case "playlists": addTab.accept(ChannelTabs.PLAYLISTS); break; + case "podcasts": + addTab.accept(ChannelTabs.PODCASTS); + break; case "streams": addTab.accept(ChannelTabs.LIVESTREAMS); break; @@ -723,4 +725,29 @@ private JsonObject getVideoTab() throws ParsingException { return foundVideoTab; } + @Nonnull + private static String normalizeTabSuffix(@Nonnull final String tabUrl) { + String normalized = tabUrl; + final int queryIndex = normalized.indexOf('?'); + if (queryIndex >= 0) { + normalized = normalized.substring(0, queryIndex); + } + + final int fragmentIndex = normalized.indexOf('#'); + if (fragmentIndex >= 0) { + normalized = normalized.substring(0, fragmentIndex); + } + + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + + final int slashIndex = normalized.lastIndexOf('/'); + if (slashIndex < 0 || slashIndex == normalized.length() - 1) { + return normalized; + } + + return normalized.substring(slashIndex + 1); + } + } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelTabExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelTabExtractor.java index 2b05d3be..370c283c 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelTabExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelTabExtractor.java @@ -69,6 +69,8 @@ private String getChannelTabsParameters() throws ParsingException { return "EghyZWxlYXNlc_IGBQoDsgEA"; case ChannelTabs.PLAYLISTS: return "EglwbGF5bGlzdHPyBgQKAkIA"; + case ChannelTabs.PODCASTS: + return "Eghwb2RjYXN0c_IGBQoDugEA"; default: throw new ParsingException("Unsupported channel tab: " + name); } @@ -197,9 +199,10 @@ private JsonObject getTabData() throws ParsingException { JsonObject foundTab = null; for (final Object tab : tabs) { if (((JsonObject) tab).has("tabRenderer")) { - if (((JsonObject) tab).getObject("tabRenderer").getObject("endpoint") + final String tabUrl = ((JsonObject) tab).getObject("tabRenderer").getObject("endpoint") .getObject("commandMetadata").getObject("webCommandMetadata") - .getString("url").endsWith(urlSuffix)) { + .getString("url"); + if (tabUrl != null && normalizeTabUrl(tabUrl).endsWith(urlSuffix)) { foundTab = ((JsonObject) tab).getObject("tabRenderer"); break; } @@ -309,6 +312,11 @@ public String getUploaderName() { return channelIds.get(0); } }); + } else if (richItem.has("lockupViewModel")) { + commitLockupItemIfSupported(collector, + richItem.getObject("lockupViewModel"), channelIds); + } else { + return collectItem(collector, richItem, channelIds); } } else if (item.has("gridPlaylistRenderer")) { collector.commit(new YoutubePlaylistInfoItemExtractor( @@ -318,6 +326,12 @@ public String getUploaderName() { return channelIds.get(0); } }); + } else if (item.has("playlistRenderer")) { + collector.commit(new YoutubeMixOrPlaylistInfoItemExtractor( + item.getObject("playlistRenderer"))); + } else if (item.has("radioRenderer")) { + collector.commit(new YoutubeMixOrPlaylistInfoItemExtractor( + item.getObject("radioRenderer"))); } else if (item.has("gridChannelRenderer")) { collector.commit(new YoutubeChannelInfoItemExtractor( item.getObject("gridChannelRenderer"))); @@ -336,23 +350,73 @@ public String getUploaderName() { } else if (item.has("continuationItemRenderer")) { return item.getObject("continuationItemRenderer"); } else if (item.has("lockupViewModel")) { - final JsonObject lockupViewModel = item.getObject("lockupViewModel"); - final String contentType = lockupViewModel.getString("contentType"); - if ("LOCKUP_CONTENT_TYPE_PLAYLIST".equals(contentType) - || "LOCKUP_CONTENT_TYPE_PODCAST".equals(contentType)) { - String channelName; - try { - channelName = getChannelName(); - } catch (Exception e) { - channelName = channelIds.get(0); - } - commitPlaylistLockup(collector, lockupViewModel, - channelName, null); - } + commitLockupItemIfSupported(collector, + item.getObject("lockupViewModel"), channelIds); } return null; } + private void commitLockupItemIfSupported(@Nonnull final MultiInfoItemsCollector collector, + @Nonnull final JsonObject lockupViewModel, + @Nonnull final List channelIds) { + final String contentType = lockupViewModel.getString("contentType"); + if ("LOCKUP_CONTENT_TYPE_PLAYLIST".equals(contentType) + || "LOCKUP_CONTENT_TYPE_PODCAST".equals(contentType)) { + String channelName; + try { + channelName = getChannelName(); + } catch (final Exception e) { + channelName = channelIds.get(0); + } + commitPlaylistLockup(collector, lockupViewModel, channelName, null); + return; + } + + if ("LOCKUP_CONTENT_TYPE_VIDEO".equals(contentType) + || "LOCKUP_CONTENT_TYPE_EPISODE".equals(contentType)) { + collector.commit(new YoutubeLockupStreamInfoItemExtractor(lockupViewModel, + getTimeAgoParser()) { + @Override + public String getUploaderName() throws ParsingException { + try { + return super.getUploaderName(); + } catch (final ParsingException e) { + return channelIds.get(0); + } + } + + @Override + public String getUploaderUrl() throws ParsingException { + try { + return super.getUploaderUrl(); + } catch (final ParsingException e) { + return channelIds.get(1); + } + } + }); + } + } + + @Nonnull + private static String normalizeTabUrl(@Nonnull final String tabUrl) { + String normalized = tabUrl; + final int queryIndex = normalized.indexOf('?'); + if (queryIndex >= 0) { + normalized = normalized.substring(0, queryIndex); + } + + final int fragmentIndex = normalized.indexOf('#'); + if (fragmentIndex >= 0) { + normalized = normalized.substring(0, fragmentIndex); + } + + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + + return normalized; + } + @Nullable private Page getNextPageFrom(final JsonObject continuations, final List channelIds) throws IOException, diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/linkHandler/YoutubeChannelTabLinkHandlerFactory.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/linkHandler/YoutubeChannelTabLinkHandlerFactory.java index 36863fd1..ac9ee16a 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/linkHandler/YoutubeChannelTabLinkHandlerFactory.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/linkHandler/YoutubeChannelTabLinkHandlerFactory.java @@ -28,6 +28,8 @@ public static String getUrlSuffix(final String tab) throws ParsingException { return "/videos"; case ChannelTabs.PLAYLISTS: return "/playlists"; + case ChannelTabs.PODCASTS: + return "/podcasts"; case ChannelTabs.LIVESTREAMS: return "/streams"; case ChannelTabs.SHORTS: From 6ac89c67ab1101485848e69a5e94b23923d518f5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 14 Apr 2026 20:54:25 +0200 Subject: [PATCH 2/3] test(youtube): cover podcasts tab parsing and lockup paths --- .../YouTubeChannelTabExtractorTest.java | 52 ++++++++ ...outubeChannelExtractorTabsParsingTest.java | 56 ++++++++ ...eChannelTabExtractorLockupParsingTest.java | 126 ++++++++++++++++++ ...utubeChannelTabLinkHandlerFactoryTest.java | 22 +++ 4 files changed, 256 insertions(+) create mode 100644 extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelExtractorTabsParsingTest.java create mode 100644 extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelTabExtractorLockupParsingTest.java create mode 100644 extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelTabLinkHandlerFactoryTest.java diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YouTubeChannelTabExtractorTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YouTubeChannelTabExtractorTest.java index a673afb7..92aac01e 100644 --- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YouTubeChannelTabExtractorTest.java +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YouTubeChannelTabExtractorTest.java @@ -187,4 +187,56 @@ public void testMoreRelatedItems() throws Exception { defaultTestMoreItems(extractor); } } + + public static class Podcasts { + private static final String CHANNEL_ID = "UCpodcastTabFixture0000000"; + private static YoutubeChannelTabExtractor extractor; + + @BeforeAll + public static void setUp() throws ExtractionException { + extractor = (YoutubeChannelTabExtractor) YouTube.getChannelTabExtractorFromId( + CHANNEL_ID, ChannelTabs.PODCASTS); + } + + @Test + public void testServiceId() { + assertEquals(YouTube.getServiceId(), extractor.getServiceId()); + } + + @Test + public void testTab() { + assertEquals(ChannelTabs.PODCASTS, extractor.getTab()); + } + + @Test + public void testId() throws ParsingException { + assertEquals(CHANNEL_ID, extractor.getId()); + } + + @Test + public void testUrl() throws ParsingException { + assertEquals( + "https://www.youtube.com/channel/" + CHANNEL_ID + "/podcasts", + extractor.getUrl()); + } + + @Test + public void testPodcastsParams() throws Exception { + final java.lang.reflect.Method method = YoutubeChannelTabExtractor.class + .getDeclaredMethod("getChannelTabsParameters"); + method.setAccessible(true); + final String params = (String) method.invoke(extractor); + assertEquals("Eghwb2RjYXN0c_IGBQoDugEA", params); + } + + @Test + public void testNormalizeTabUrl() throws Exception { + final java.lang.reflect.Method method = YoutubeChannelTabExtractor.class + .getDeclaredMethod("normalizeTabUrl", String.class); + method.setAccessible(true); + final String normalized = (String) method.invoke(null, + "/channel/UCpodcastTabFixture0000000/podcasts?view=57#fragment"); + assertEquals("/channel/UCpodcastTabFixture0000000/podcasts", normalized); + } + } } diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelExtractorTabsParsingTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelExtractorTabsParsingTest.java new file mode 100644 index 00000000..6184d823 --- /dev/null +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelExtractorTabsParsingTest.java @@ -0,0 +1,56 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import org.junit.jupiter.api.Test; +import org.schabi.newpipe.extractor.linkhandler.ChannelTabs; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeChannelExtractor; +import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelLinkHandlerFactory; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.schabi.newpipe.extractor.ServiceList.YouTube; + +public class YoutubeChannelExtractorTabsParsingTest { + private static final String CHANNEL_ID = "channel/UCpodcastTabFixture0000000"; + + @Test + public void testPodcastsTabIsRecognizedFromTabUrlSuffix() throws Exception { + final YoutubeChannelExtractor extractor = new YoutubeChannelExtractor( + YouTube, + YoutubeChannelLinkHandlerFactory.getInstance().fromId(CHANNEL_ID)); + + setPrivateField(extractor, "redirectedChannelId", CHANNEL_ID); + setPrivateField(extractor, "jsonResponse", buildTabsResponse()); + + final List tabs = extractor.getTabs(); + assertTrue(tabs.stream().anyMatch(tab -> tab.getUrl().endsWith("/podcasts"))); + assertTrue(tabs.stream().anyMatch(tab -> { + final List filters = tab.getContentFilters(); + return !filters.isEmpty() && ChannelTabs.PODCASTS.equals(filters.get(0).getName()); + })); + } + + private static JsonObject buildTabsResponse() throws Exception { + return JsonParser.object().from("{\"contents\":{\"twoColumnBrowseResultsRenderer\":{\"tabs\":[" + + "{\"tabRenderer\":{\"endpoint\":{\"commandMetadata\":{\"webCommandMetadata\":{\"url\":\"/" + + CHANNEL_ID + + "/podcasts?view=57\"}}}}}," + + "{\"tabRenderer\":{\"endpoint\":{\"commandMetadata\":{\"webCommandMetadata\":{\"url\":\"/" + + CHANNEL_ID + + "/playlists\"}}}}}" + + "]}}}"); + } + + private static void setPrivateField(final Object target, + final String fieldName, + final Object value) throws Exception { + final Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelTabExtractorLockupParsingTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelTabExtractorLockupParsingTest.java new file mode 100644 index 00000000..bb9e1369 --- /dev/null +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelTabExtractorLockupParsingTest.java @@ -0,0 +1,126 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import org.junit.jupiter.api.Test; +import org.schabi.newpipe.extractor.MultiInfoItemsCollector; +import org.schabi.newpipe.extractor.linkhandler.ChannelTabs; +import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeChannelTabExtractor; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.schabi.newpipe.extractor.ServiceList.YouTube; + +public class YoutubeChannelTabExtractorLockupParsingTest { + + @Test + public void testCollectItemHandlesRichItemLockupViewModel() throws Exception { + final YoutubeChannelTabExtractor extractor = (YoutubeChannelTabExtractor) YouTube + .getChannelTabExtractorFromId("UCpodcastTabFixture0000000", ChannelTabs.PODCASTS); + + final Method method = YoutubeChannelTabExtractor.class.getDeclaredMethod( + "collectItem", + MultiInfoItemsCollector.class, + JsonObject.class, + java.util.List.class); + method.setAccessible(true); + + final MultiInfoItemsCollector collector = new MultiInfoItemsCollector(YouTube.getServiceId()); + method.invoke(extractor, + collector, + buildRichItemLockupContent(), + Arrays.asList("Memoire Vive", "https://www.youtube.com/channel/UCTFUnkRlNBCHwE0oD_6PM7g")); + + assertEquals(1, collector.getItems().size()); + } + + @Test + public void testCollectItemHandlesVideoLockupViewModel() throws Exception { + final YoutubeChannelTabExtractor extractor = (YoutubeChannelTabExtractor) YouTube + .getChannelTabExtractorFromId("UCpodcastTabFixture0000000", ChannelTabs.PODCASTS); + + final Method method = YoutubeChannelTabExtractor.class.getDeclaredMethod( + "collectItem", + MultiInfoItemsCollector.class, + JsonObject.class, + java.util.List.class); + method.setAccessible(true); + + final MultiInfoItemsCollector collector = new MultiInfoItemsCollector(YouTube.getServiceId()); + method.invoke(extractor, + collector, + buildVideoLockupContent(), + Arrays.asList("Memoire Vive", "https://www.youtube.com/channel/UCTFUnkRlNBCHwE0oD_6PM7g")); + + assertEquals(1, collector.getItems().size()); + } + + @Test + public void testCollectItemHandlesNestedRichItemShelfContent() throws Exception { + final YoutubeChannelTabExtractor extractor = (YoutubeChannelTabExtractor) YouTube + .getChannelTabExtractorFromId("UCpodcastTabFixture0000000", ChannelTabs.PODCASTS); + + final Method method = YoutubeChannelTabExtractor.class.getDeclaredMethod( + "collectItem", + MultiInfoItemsCollector.class, + JsonObject.class, + java.util.List.class); + method.setAccessible(true); + + final MultiInfoItemsCollector collector = new MultiInfoItemsCollector(YouTube.getServiceId()); + method.invoke(extractor, + collector, + buildNestedRichItemShelfLockupContent(), + Arrays.asList("Memoire Vive", "https://www.youtube.com/channel/UCTFUnkRlNBCHwE0oD_6PM7g")); + + assertEquals(1, collector.getItems().size()); + } + + private static JsonObject buildRichItemLockupContent() throws Exception { + return JsonParser.object().from("{\"richItemRenderer\":{\"content\":{\"lockupViewModel\":{" + + "\"contentType\":\"LOCKUP_CONTENT_TYPE_PODCAST\"," + + "\"contentId\":\"PL1234567890ABCDE\"," + + "\"contentImage\":{\"collectionThumbnailViewModel\":{\"primaryThumbnail\":{" + + "\"thumbnailViewModel\":{\"image\":{\"sources\":[{\"url\":\"https://i.ytimg.com/vi/test/default.jpg\"}]}," + + "\"overlays\":[{\"thumbnailOverlayBadgeViewModel\":{\"thumbnailBadges\":[{\"thumbnailBadgeViewModel\":{\"text\":\"12\"}}]}}]" + + "}}}}," + + "\"metadata\":{\"lockupMetadataViewModel\":{" + + "\"title\":{\"content\":\"Test podcast\"}," + + "\"metadata\":{\"contentMetadataViewModel\":{\"metadataRows\":[{}]}}" + + "}}" + + "}}}}"); + } + + private static JsonObject buildVideoLockupContent() throws Exception { + return JsonParser.object().from("{\"lockupViewModel\":{" + + "\"contentType\":\"LOCKUP_CONTENT_TYPE_VIDEO\"," + + "\"contentId\":\"dQw4w9WgXcQ\"," + + "\"contentImage\":{\"thumbnailViewModel\":{" + + "\"image\":{\"sources\":[{\"url\":\"https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg\"}]}," + + "\"overlays\":[]" + + "}}," + + "\"metadata\":{\"lockupMetadataViewModel\":{" + + "\"title\":{\"content\":\"Episode 1\"}," + + "\"metadata\":{\"contentMetadataViewModel\":{\"metadataRows\":[]}}" + + "}}" + + "}}"); + } + + private static JsonObject buildNestedRichItemShelfLockupContent() throws Exception { + return JsonParser.object().from("{\"richItemRenderer\":{\"content\":{\"shelfRenderer\":{" + + "\"content\":{\"horizontalListRenderer\":{\"items\":[{\"lockupViewModel\":{" + + "\"contentType\":\"LOCKUP_CONTENT_TYPE_PODCAST\"," + + "\"contentId\":\"PL1234567890ABCDE\"," + + "\"contentImage\":{\"collectionThumbnailViewModel\":{\"primaryThumbnail\":{" + + "\"thumbnailViewModel\":{\"image\":{\"sources\":[{\"url\":\"https://i.ytimg.com/vi/test/default.jpg\"}]}," + + "\"overlays\":[{\"thumbnailOverlayBadgeViewModel\":{\"thumbnailBadges\":[{\"thumbnailBadgeViewModel\":{\"text\":\"12\"}}]}}]" + + "}}}}," + + "\"metadata\":{\"lockupMetadataViewModel\":{" + + "\"title\":{\"content\":\"Test podcast\"}," + + "\"metadata\":{\"contentMetadataViewModel\":{\"metadataRows\":[{}]}}" + + "}}" + + "}}]}}}}}}}"); + } +} diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelTabLinkHandlerFactoryTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelTabLinkHandlerFactoryTest.java new file mode 100644 index 00000000..452ca92d --- /dev/null +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelTabLinkHandlerFactoryTest.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import org.junit.jupiter.api.Test; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.linkhandler.ChannelTabs; +import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelTabLinkHandlerFactory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class YoutubeChannelTabLinkHandlerFactoryTest { + @Test + public void testPodcastsTabSuffix() throws ParsingException { + assertEquals("/podcasts", YoutubeChannelTabLinkHandlerFactory.getUrlSuffix(ChannelTabs.PODCASTS)); + } + + @Test + public void testUnsupportedTabSuffix() { + assertThrows(ParsingException.class, + () -> YoutubeChannelTabLinkHandlerFactory.getUrlSuffix("unsupported-tab")); + } +} From 9f69bb2b7580b1a963b1777b4f7084047436a667 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 14 Apr 2026 20:54:37 +0200 Subject: [PATCH 3/3] feat(youtube): add recommended podcasts kiosk and cap results --- .../services/youtube/YoutubeService.java | 11 ++++- .../extractors/YoutubeTrendingExtractor.java | 44 ++++++++++++++----- .../YoutubeTrendingLinkHandlerFactory.java | 38 +++++++++++++--- .../services/youtube/YoutubeServiceTest.java | 5 +++ ...rendingLinkHandlerFactoryPodcastsTest.java | 31 +++++++++++++ 5 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeTrendingLinkHandlerFactoryPodcastsTest.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeService.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeService.java index 7e917fbd..ecd0394e 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeService.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeService.java @@ -172,7 +172,16 @@ public KioskList getKioskList() throws ExtractionException { new YoutubeTrendingLinkHandlerFactory(), "Recommended Lives" ); - list.setDefaultKiosk("Recommended Lives"); + list.addKioskEntry( + (streamingService, url, id) -> new YoutubeTrendingExtractor( + YoutubeService.this, + new YoutubeTrendingLinkHandlerFactory().fromUrl(url), + id + ), + new YoutubeTrendingLinkHandlerFactory(), + "Recommended Podcasts" + ); + list.setDefaultKiosk("Recommended Podcasts"); } catch (final Exception e) { throw new ExtractionException(e); } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeTrendingExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeTrendingExtractor.java index 529a167c..6d45b66f 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeTrendingExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeTrendingExtractor.java @@ -47,6 +47,12 @@ import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; public class YoutubeTrendingExtractor extends KioskExtractor { + private static final String KIOSK_RECOMMENDED_PODCASTS = "Recommended Podcasts"; + private static final String RECOMMENDED_LIVES_BROWSE_ID = "UC4R8DWoMoI7CAwX8_LjQHig"; + private static final String RECOMMENDED_PODCASTS_BROWSE_ID = "FEpodcasts_destination"; + private static final String RECOMMENDED_PODCASTS_PARAMS = "qgcCCAM="; + private static final long RECOMMENDED_PODCASTS_MAX_ITEMS = 40; + private JsonObject initialData; public YoutubeTrendingExtractor(final StreamingService service, @@ -58,13 +64,21 @@ public YoutubeTrendingExtractor(final StreamingService service, @Override public void onFetchPage(@Nonnull final Downloader downloader) throws IOException, ExtractionException { - // @formatter:off - final byte[] body = JsonWriter.string(prepareDesktopJsonBuilder(getExtractorLocalization(), - getExtractorContentCountry()) - .value("browseId", "UC4R8DWoMoI7CAwX8_LjQHig") - .done()) - .getBytes(UTF_8); - // @formatter:on + final byte[] body; + if (KIOSK_RECOMMENDED_PODCASTS.equals(getId())) { + body = JsonWriter.string(prepareDesktopJsonBuilder(getExtractorLocalization(), + getExtractorContentCountry()) + .value("browseId", RECOMMENDED_PODCASTS_BROWSE_ID) + .value("params", RECOMMENDED_PODCASTS_PARAMS) + .done()) + .getBytes(UTF_8); + } else { + body = JsonWriter.string(prepareDesktopJsonBuilder(getExtractorLocalization(), + getExtractorContentCountry()) + .value("browseId", RECOMMENDED_LIVES_BROWSE_ID) + .done()) + .getBytes(UTF_8); + } initialData = getJsonPostResponse("browse", body, getExtractorLocalization()); } @@ -86,6 +100,8 @@ public String getName() throws ParsingException { } else if (header.has("carouselHeaderRenderer")) { name = header.getObject("carouselHeaderRenderer").getArray("contents").getObject(0) .getObject("topicChannelDetailsRenderer").getObject("title").getString("simpleText"); + } else if (header.has("pageHeaderRenderer")) { + name = header.getObject("pageHeaderRenderer").getString("pageTitle"); } if (isNullOrEmpty(name)) { @@ -99,6 +115,9 @@ public String getName() throws ParsingException { public InfoItemsPage getInitialPage() throws ParsingException { final StreamInfoItemsCollector collector = new StreamInfoItemsCollector(getServiceId()); final TimeAgoParser timeAgoParser = getTimeAgoParser(); + final long maximumItems = KIOSK_RECOMMENDED_PODCASTS.equals(getId()) + ? RECOMMENDED_PODCASTS_MAX_ITEMS + : Long.MAX_VALUE; final JsonObject tabContent = getTrendingTabRenderer().getObject("content"); if (tabContent.has("richGridRenderer")) { @@ -110,8 +129,10 @@ public InfoItemsPage getInitialPage() throws ParsingException { // Filter Trending shorts and Recently trending sections .filter(content -> content.has("richItemRenderer")) .map(content -> content.getObject("richItemRenderer") - .getObject("content") - .getObject("videoRenderer")) + .getObject("content")) + .filter(content -> content.has("videoRenderer")) + .map(content -> content.getObject("videoRenderer")) + .limit(maximumItems) .forEachOrdered(videoRenderer -> collector.commit( new YoutubeStreamInfoItemExtractor(videoRenderer, timeAgoParser))); } else if (tabContent.has("sectionListRenderer")) { @@ -136,6 +157,7 @@ public InfoItemsPage getInitialPage() throws ParsingException { .filter(JsonObject.class::isInstance) .map(JsonObject.class::cast) .map(item -> item.getObject("videoRenderer")) + .limit(maximumItems) .forEachOrdered(videoRenderer -> collector.commit( new YoutubeStreamInfoItemExtractor(videoRenderer, timeAgoParser))); } @@ -159,6 +181,7 @@ public InfoItemsPage getInitialPage() throws ParsingException { .filter(JsonObject.class::isInstance) .map(JsonObject.class::cast) .map(item -> item.getObject("gridVideoRenderer")) + .limit(maximumItems) .forEachOrdered(videoRenderer -> collector.commit( new YoutubeStreamInfoItemExtractor(videoRenderer, timeAgoParser))); } @@ -182,11 +205,12 @@ public InfoItemsPage getInitialPage() throws ParsingException { .map(content -> content.getObject("richItemRenderer") .getObject("content") .getObject("videoRenderer")) + .limit(maximumItems) .forEachOrdered(videoRenderer -> collector.commit( new YoutubeStreamInfoItemExtractor(videoRenderer, timeAgoParser))); } if (collector.getItems().isEmpty()) { - throw new ParsingException("Could not get trending page"); + throw new ParsingException("Could not get kiosk page: " + getId()); } if (ServiceList.YouTube.getFilterTypes().contains("recommendations")) { diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/linkHandler/YoutubeTrendingLinkHandlerFactory.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/linkHandler/YoutubeTrendingLinkHandlerFactory.java index 3f625562..4b118b9f 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/linkHandler/YoutubeTrendingLinkHandlerFactory.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/linkHandler/YoutubeTrendingLinkHandlerFactory.java @@ -34,21 +34,40 @@ public class YoutubeTrendingLinkHandlerFactory extends ListLinkHandlerFactory { + private static final String TRENDING_ID = "Trending"; + private static final String RECOMMENDED_LIVES_ID = "Recommended Lives"; + private static final String RECOMMENDED_PODCASTS_ID = "Recommended Podcasts"; + private static final String TRENDING_URL = "https://www.youtube.com/feed/trending"; + private static final String RECOMMENDED_LIVES_URL = + "https://www.youtube.com/channel/UC4R8DWoMoI7CAwX8_LjQHig"; + private static final String RECOMMENDED_PODCASTS_URL = "https://www.youtube.com/podcasts/videos"; + public String getUrl(final String id, final List contentFilters, final List sortFilter) { - if(!id.equals("Trending")){ - return "https://www.youtube.com/channel/UC4R8DWoMoI7CAwX8_LjQHig"; + if (TRENDING_ID.equals(id)) { + return TRENDING_URL; + } + + if (RECOMMENDED_PODCASTS_ID.equals(id)) { + return RECOMMENDED_PODCASTS_URL; } - return "https://www.youtube.com/feed/trending"; + + return RECOMMENDED_LIVES_URL; } @Override public String getId(final String url) { - if(url.equals("https://www.youtube.com/feed/trending")){ - return "Trending"; + if (TRENDING_URL.equals(url)) { + return TRENDING_ID; } - return "Recommended Lives"; + + if (RECOMMENDED_PODCASTS_URL.equals(url) + || "https://www.youtube.com/podcasts".equals(url)) { + return RECOMMENDED_PODCASTS_ID; + } + + return RECOMMENDED_LIVES_ID; } @Override @@ -62,6 +81,11 @@ public boolean onAcceptUrl(final String url) { final String urlPath = urlObj.getPath(); return Utils.isHTTP(urlObj) && (isYoutubeURL(urlObj) || isInvidiousURL(urlObj)) - && (urlPath.equals("/feed/trending") || urlPath.equals("/channel/UC4R8DWoMoI7CAwX8_LjQHig")); + && (urlPath.equals("/feed/trending") + || urlPath.equals("/channel/UC4R8DWoMoI7CAwX8_LjQHig") + || urlPath.equals("/podcasts") + || urlPath.equals("/podcasts/") + || urlPath.equals("/podcasts/videos") + || urlPath.equals("/podcasts/videos/")); } } diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeServiceTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeServiceTest.java index 8247506b..f9fbc979 100644 --- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeServiceTest.java +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeServiceTest.java @@ -54,6 +54,11 @@ void testGetKioskAvailableKiosks() { assertFalse(kioskList.getAvailableKiosks().isEmpty(), "No kiosk got returned"); } + @Test + void testRecommendedPodcastsKioskAvailable() { + assertTrue(kioskList.getAvailableKiosks().contains("Recommended Podcasts")); + } + @Test void testGetDefaultKiosk() throws Exception { assertEquals(kioskList.getDefaultKioskExtractor(null).getId(), "Trending"); diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeTrendingLinkHandlerFactoryPodcastsTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeTrendingLinkHandlerFactoryPodcastsTest.java new file mode 100644 index 00000000..c1987356 --- /dev/null +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeTrendingLinkHandlerFactoryPodcastsTest.java @@ -0,0 +1,31 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import org.junit.jupiter.api.Test; +import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeTrendingLinkHandlerFactory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class YoutubeTrendingLinkHandlerFactoryPodcastsTest { + + private static final YoutubeTrendingLinkHandlerFactory FACTORY = + new YoutubeTrendingLinkHandlerFactory(); + + @Test + public void testRecommendedPodcastsUrlFromId() throws Exception { + assertEquals("https://www.youtube.com/podcasts/videos", + FACTORY.fromId("Recommended Podcasts").getUrl()); + } + + @Test + public void testRecommendedPodcastsIdFromUrl() throws Exception { + assertEquals("Recommended Podcasts", + FACTORY.fromUrl("https://www.youtube.com/podcasts/videos").getId()); + } + + @Test + public void testAcceptPodcastsUrls() throws Exception { + assertTrue(FACTORY.acceptUrl("https://www.youtube.com/podcasts/videos")); + assertTrue(FACTORY.acceptUrl("https://www.youtube.com/podcasts")); + } +}