Skip to content

Commit c3362e9

Browse files
committed
[YouTube] Retrieve channel details for videos with multiple uploaders
Implements support for loading the channel details, of the channel that actually owns the video, for videos with multiple uploaders.
1 parent 56e41f2 commit c3362e9

File tree

10 files changed

+2477
-13
lines changed

10 files changed

+2477
-13
lines changed

extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,4 +1588,20 @@ public static JsonBuilder<JsonObject> prepareJsonBuilder(
15881588

15891589
return builder;
15901590
}
1591+
1592+
/**
1593+
* Gets the first collaborator, which is the channel that owns the video,
1594+
* i.e. the video is displayed on their channel page.
1595+
*
1596+
* @param navigationEndpoint JSON object for the navigationEndpoint
1597+
* @return The first collaborator in the JSON object or {@code null}
1598+
*/
1599+
@Nullable
1600+
public static JsonObject getFirstCollaborator(final JsonObject navigationEndpoint)
1601+
throws ParsingException {
1602+
// CHECKSTYLE:OFF
1603+
final JsonArray listItems = JsonUtils.getArray(navigationEndpoint, "showDialogCommand.panelLoadingStrategy.inlineContent.dialogViewModel.customContent.listViewModel.listItems");
1604+
// CHECKSTYLE:ON
1605+
return listItems.getObject(0).getObject("listItemViewModel");
1606+
}
15911607
}

extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -541,23 +541,44 @@ public String getUploaderName() throws ParsingException {
541541

542542
@Override
543543
public boolean isUploaderVerified() throws ParsingException {
544-
return YoutubeParsingHelper.isVerified(
545-
getVideoSecondaryInfoRenderer()
544+
final JsonObject videoOwnerRenderer = getVideoSecondaryInfoRenderer()
546545
.getObject("owner")
547-
.getObject("videoOwnerRenderer")
548-
.getArray("badges"));
546+
.getObject("videoOwnerRenderer");
547+
548+
if (videoOwnerRenderer.has("badges")) {
549+
return YoutubeParsingHelper.isVerified(videoOwnerRenderer
550+
.getArray("badges"));
551+
}
552+
553+
return YoutubeParsingHelper.hasArtistOrVerifiedIconBadgeAttachment(
554+
YoutubeParsingHelper.getFirstCollaborator(videoOwnerRenderer
555+
.getObject("navigationEndpoint"))
556+
.getObject("title")
557+
.getArray("attachmentRuns")
558+
);
549559
}
550560

551561
@Nonnull
552562
@Override
553563
public List<Image> getUploaderAvatars() throws ParsingException {
554564
assertPageFetched();
555-
556-
final List<Image> imageList = getImagesFromThumbnailsArray(
557-
getVideoSecondaryInfoRenderer().getObject("owner")
558-
.getObject("videoOwnerRenderer")
559-
.getObject("thumbnail")
560-
.getArray("thumbnails"));
565+
final JsonObject owner = getVideoSecondaryInfoRenderer().getObject("owner")
566+
.getObject("videoOwnerRenderer");
567+
568+
final List<Image> imageList;
569+
if (owner.has("avatarStack")) {
570+
imageList = getImagesFromThumbnailsArray(
571+
owner.getObject("avatarStack").getObject("avatarStackViewModel")
572+
.getArray("avatars")
573+
// only consider the first collaborator, which is the video owner
574+
.getObject(0)
575+
.getObject("avatarViewModel")
576+
.getObject("image")
577+
.getArray("sources"));
578+
} else {
579+
imageList = getImagesFromThumbnailsArray(
580+
owner.getObject("thumbnail").getArray("thumbnails"));
581+
}
561582

562583
if (imageList.isEmpty() && ageLimit == NO_AGE_LIMIT) {
563584
throw new ParsingException("Could not get uploader avatars");
@@ -570,12 +591,24 @@ public List<Image> getUploaderAvatars() throws ParsingException {
570591
public long getUploaderSubscriberCount() throws ParsingException {
571592
final JsonObject videoOwnerRenderer = JsonUtils.getObject(videoSecondaryInfoRenderer,
572593
"owner.videoOwnerRenderer");
573-
if (!videoOwnerRenderer.has("subscriberCountText")) {
594+
595+
String subscriberCountText = null;
596+
if (videoOwnerRenderer.has("subscriberCountText")) {
597+
subscriberCountText = getTextFromObject(videoOwnerRenderer
598+
.getObject("subscriberCountText"));
599+
} else {
600+
final String content = YoutubeParsingHelper.getFirstCollaborator(
601+
videoOwnerRenderer.getObject("navigationEndpoint")
602+
).getObject("subtitle").getString("content");
603+
subscriberCountText = content.split("•")[1];
604+
}
605+
606+
if (isNullOrEmpty(subscriberCountText)) {
574607
return UNKNOWN_SUBSCRIBER_COUNT;
575608
}
609+
576610
try {
577-
return Utils.mixedNumberWordToLong(getTextFromObject(videoOwnerRenderer
578-
.getObject("subscriberCountText")));
611+
return Utils.mixedNumberWordToLong(subscriberCountText);
579612
} catch (final NumberFormatException e) {
580613
throw new ParsingException("Could not get uploader subscriber count", e);
581614
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package org.schabi.newpipe.extractor.services.youtube.stream;
2+
3+
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
4+
import static org.schabi.newpipe.extractor.ExtractorAsserts.assertNotEmpty;
5+
import static org.schabi.newpipe.extractor.services.DefaultTests.defaultTestImageCollection;
6+
7+
import org.junit.jupiter.api.Disabled;
8+
import org.schabi.newpipe.extractor.Image;
9+
import org.schabi.newpipe.extractor.StreamingService;
10+
import org.schabi.newpipe.extractor.services.DefaultStreamExtractorTest;
11+
import org.schabi.newpipe.extractor.services.youtube.InitYoutubeTest;
12+
import org.schabi.newpipe.extractor.stream.StreamExtractor;
13+
import org.schabi.newpipe.extractor.stream.StreamType;
14+
15+
import java.util.Collections;
16+
import java.util.List;
17+
18+
import javax.annotation.Nullable;
19+
20+
public class YoutubeStreamExtractorCollaboratorsTest extends DefaultStreamExtractorTest
21+
implements InitYoutubeTest {
22+
private static final String ID = "3sbYbckT1VY";
23+
private static final String URL = YoutubeStreamExtractorDefaultTest.BASE_URL + ID;
24+
25+
@Override
26+
protected StreamExtractor createExtractor() throws Exception {
27+
return YouTube.getStreamExtractor(URL);
28+
}
29+
30+
@Override public StreamingService expectedService() { return YouTube; }
31+
@Override public String expectedName() { return "Engineers vs Pumpkin Carving 2.0"; }
32+
@Override public String expectedId() { return ID; }
33+
@Override public String expectedUrlContains() { return YoutubeStreamExtractorDefaultTest.BASE_URL + ID; }
34+
@Override public String expectedOriginalUrlContains() { return URL; }
35+
36+
@Override public StreamType expectedStreamType() { return StreamType.VIDEO_STREAM; }
37+
@Override public String expectedUploaderName() { return "CrunchLabs"; }
38+
@Override public String expectedUploaderUrl() { return "https://www.youtube.com/channel/UC513PdAP2-jWkJunTh5kXRw"; }
39+
@Override public long expectedUploaderSubscriberCountAtLeast() { return 227_0000; }
40+
@Override public boolean expectedUploaderVerified() { return true; }
41+
@Override public boolean expectedDescriptionIsEmpty() { return false; }
42+
@Override public List<String> expectedDescriptionContains() { return Collections.emptyList(); }
43+
@Override public long expectedLength() { return 696; }
44+
@Override public long expectedViewCountAtLeast() { return 1_400_000; }
45+
@Nullable @Override public String expectedUploadDate() { return "2025-10-25 15:33:05.000"; }
46+
@Nullable @Override public String expectedTextualUploadDate() { return "2025-10-25T08:33:05-07:00"; }
47+
@Override public long expectedLikeCountAtLeast() { return 20_000; }
48+
@Override public long expectedDislikeCountAtLeast() { return -1; }
49+
@Override public boolean expectedHasSubtitles() { return true; }
50+
@Override public boolean expectedHasFrames() { return true; }
51+
52+
@Override public String expectedCategory() { return "Science & Technology"; }
53+
54+
@Override public String expectedLicence() { return "YouTube licence"; }
55+
@Override
56+
public List<String> expectedTags() {
57+
return Collections.emptyList();
58+
}
59+
60+
@Override
61+
public void testUploaderAvatars() throws Exception {
62+
List<Image> avatars = extractor().getUploaderAvatars();
63+
assertNotEmpty(avatars);
64+
defaultTestImageCollection(avatars);
65+
}
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
{
2+
"request": {
3+
"httpMethod": "GET",
4+
"url": "https://www.youtube.com/sw.js",
5+
"headers": {
6+
"Referer": [
7+
"https://www.youtube.com"
8+
],
9+
"Origin": [
10+
"https://www.youtube.com"
11+
],
12+
"Accept-Language": [
13+
"en-GB, en;q\u003d0.9"
14+
]
15+
},
16+
"localization": {
17+
"languageCode": "en",
18+
"countryCode": "GB"
19+
}
20+
},
21+
"response": {
22+
"responseCode": 200,
23+
"responseMessage": "",
24+
"responseHeaders": {
25+
"accept-ch": [
26+
"Device-Memory"
27+
],
28+
"access-control-allow-credentials": [
29+
"true"
30+
],
31+
"access-control-allow-origin": [
32+
"https://www.youtube.com"
33+
],
34+
"alt-svc": [
35+
"h3\u003d\":443\"; ma\u003d2592000,h3-29\u003d\":443\"; ma\u003d2592000"
36+
],
37+
"cache-control": [
38+
"private, max-age\u003d0"
39+
],
40+
"content-security-policy": [
41+
"script-src \u0027unsafe-eval\u0027 \u0027self\u0027 \u0027unsafe-inline\u0027 https://www.google.com https://apis.google.com https://ssl.gstatic.com https://www.gstatic.com https://www.googletagmanager.com https://www.google-analytics.com https://*.youtube.com https://*.google.com https://*.gstatic.com https://youtube.com https://www.youtube.com https://google.com https://*.doubleclick.net https://*.googleapis.com https://www.googleadservices.com https://tpc.googlesyndication.com https://www.youtubekids.com https://www.youtube-nocookie.com https://www.youtubeeducation.com https://www-onepick-opensocial.googleusercontent.com;report-uri /cspreport/allowlist",
42+
"require-trusted-types-for \u0027script\u0027"
43+
],
44+
"content-type": [
45+
"text/javascript; charset\u003dutf-8"
46+
],
47+
"cross-origin-opener-policy": [
48+
"same-origin; report-to\u003d\"youtube_main\""
49+
],
50+
"date": [
51+
"Fri, 31 Oct 2025 18:29:12 GMT"
52+
],
53+
"document-policy": [
54+
"include-js-call-stacks-in-crash-reports"
55+
],
56+
"expires": [
57+
"Fri, 31 Oct 2025 18:29:12 GMT"
58+
],
59+
"origin-trial": [
60+
"AmhMBR6zCLzDDxpW+HfpP67BqwIknWnyMOXOQGfzYswFmJe+fgaI6XZgAzcxOrzNtP7hEDsOo1jdjFnVr2IdxQ4AAAB4eyJvcmlnaW4iOiJodHRwczovL3lvdXR1YmUuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJWaWV3WFJlcXVlc3RlZFdpdGhEZXByZWNhdGlvbiIsImV4cGlyeSI6MTc1ODA2NzE5OSwiaXNTdWJkb21haW4iOnRydWV9",
61+
"AiDEBptUfVeO93q48VdVMe/ubupazdAl8AaHP+NBzdnW8quUcHdzJUyGSfrmtpKJu7EOvwRp9ug2rEo3XU+WMAMAAAB2eyJvcmlnaW4iOiJodHRwczovL3lvdXR1YmUuY29tOjQ0MyIsImZlYXR1cmUiOiJEZXZpY2VCb3VuZFNlc3Npb25DcmVkZW50aWFsczIiLCJleHBpcnkiOjE3NzQzMTA0MDAsImlzU3ViZG9tYWluIjp0cnVlfQ\u003d\u003d"
62+
],
63+
"p3p": [
64+
"CP\u003d\"This is not a P3P policy! See http://support.google.com/accounts/answer/151657?hl\u003den-GB for more info.\""
65+
],
66+
"permissions-policy": [
67+
"ch-ua-arch\u003d*, ch-ua-bitness\u003d*, ch-ua-full-version\u003d*, ch-ua-full-version-list\u003d*, ch-ua-model\u003d*, ch-ua-wow64\u003d*, ch-ua-form-factors\u003d*, ch-ua-platform\u003d*, ch-ua-platform-version\u003d*"
68+
],
69+
"report-to": [
70+
"{\"group\":\"youtube_main\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/youtube_main\"}]}"
71+
],
72+
"reporting-endpoints": [
73+
"default\u003d\"/web-reports?context\u003deJwNz1tMVHcQx3EOX0vonmXPnv8cY5UmVgRNtLsC8RJr9UEj3rJoTDXKSgB1VyXIZd1dUWnVqIk2VmO8ArWtfXCN92AwUkLR-NB6xSgG20IxvljUqG0irpeW_h8-yUwm88uMq_uD8oG1RvbsSuP8lzVG8eiIMbNjnbG7IGr0H48aZ1qjxrNP4kYwK24cuR83Knprje-8S1Jrhy1JbS1yEQ67eFXv4u9uF7UvXATHmtyba_Ks0KR8mcnFhElrk0nPY5NY0M3LUjfjY26q-t30LcigrT6DT7_wcKLFw4JfPOzTnv_rITvPom6yxcSpFmdnWcRDFmbcomGXRd5hi_RjFlkJiyHXLdqyvXwW8JJe6sWl3W3U9Tkv3a-9THnrpVhsvl1k01NiE1hlU1dns12bt8Nm-h6baYds2k_aZJ61kU6bol6bj_tsHiVtPB8q5oxT_F6gGJirSCxUBIKKj-KKlC0KdUixOqEo1yq0ai2ibdS-0jZr32hX2xVTbysyOxSf31WMuK_I6dI5ScXX_ym60oROUxg3WJg_TAhkCqeGC5IlzM4RVmoJv-CbIPw2RaiZIZQFhNh8Iak1FgkzgsLIkDBHO71KcFUJ-7U_a4QdEd1vE8I7haUHBf9hYWO90NwgbD8qeH4Qjh4T9v4obEoIV04JnBauaW0XhIctQulPwvmrwvfX9PyGcOemsPCWzu0UUruEggfCBm3IH_pmrUsLdwspD_XOIyH0WNit5TwVDvyj_-sXMpLCG63gjfCz9pfW8Vaw3gnvNf97ITIgLB7kgNthq8chajtcdhxODHXYPNwhbaRDYJTD8TEOTZMcts10aC502JSZkqbc6cmB_b-meS_tfdJAlm9DVSwaWx7yrw8t94UjVZVRX6hypW9FZE10zYqyipL83PwJebn5k_15uSXVuf8D6xjhyA\""
74+
],
75+
"server": [
76+
"ESF"
77+
],
78+
"set-cookie": [
79+
"YSC\u003dcZjnCZBaO0U; Domain\u003d.youtube.com; Path\u003d/; Secure; HttpOnly; SameSite\u003dnone",
80+
"VISITOR_INFO1_LIVE\u003d; Domain\u003d.youtube.com; Expires\u003dSat, 04-Feb-2023 18:29:12 GMT; Path\u003d/; Secure; HttpOnly; SameSite\u003dnone"
81+
],
82+
"strict-transport-security": [
83+
"max-age\u003d31536000"
84+
],
85+
"vary": [
86+
"Device-Memory"
87+
],
88+
"x-content-type-options": [
89+
"nosniff"
90+
],
91+
"x-frame-options": [
92+
"SAMEORIGIN"
93+
],
94+
"x-xss-protection": [
95+
"0"
96+
]
97+
},
98+
"responseBody": "\n self.addEventListener(\u0027install\u0027, event \u003d\u003e {\n event.waitUntil(self.skipWaiting());\n });\n self.addEventListener(\u0027activate\u0027, event \u003d\u003e {\n event.waitUntil(\n self.clients.claim().then(() \u003d\u003e self.registration.unregister()));\n });\n ",
99+
"latestUrl": "https://www.youtube.com/sw.js"
100+
}
101+
}

extractor/src/test/resources/mocks/v1/org/schabi/newpipe/extractor/services/youtube/stream/youtubestreamextractorcollaborators/generated_mock_1.json

Lines changed: 93 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)