From 501ec30152642ad49ce0a1825410d200942d174c Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 1 Nov 2020 19:11:08 +0100 Subject: [PATCH] Implement youtube subscription import from Google takeout --- .../YoutubeSubscriptionExtractor.java | 129 +++-------- .../subscription/SubscriptionExtractor.java | 8 +- .../java/org/schabi/newpipe/FileUtils.java | 15 ++ .../YoutubeSubscriptionExtractorTest.java | 84 ++++--- .../test/resources/youtube_export_test.xml | 23 -- .../youtube_takeout_import_test.json | 211 ++++++++++++++++++ 6 files changed, 309 insertions(+), 161 deletions(-) delete mode 100644 extractor/src/test/resources/youtube_export_test.xml create mode 100644 extractor/src/test/resources/youtube_takeout_import_test.json diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeSubscriptionExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeSubscriptionExtractor.java index 55a9c7c1f..ffdde8142 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeSubscriptionExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeSubscriptionExtractor.java @@ -1,126 +1,71 @@ package org.schabi.newpipe.extractor.services.youtube.extractors; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; + import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.youtube.YoutubeService; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; -import org.schabi.newpipe.extractor.utils.Parser; -import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import javax.annotation.Nonnull; + import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.INPUT_STREAM; /** - * Extract subscriptions from a YouTube export (OPML format supported) + * Extract subscriptions from a Google takout export (the user has to get the JSON out of the zip) */ public class YoutubeSubscriptionExtractor extends SubscriptionExtractor { + private static final String BASE_CHANNEL_URL = "https://www.youtube.com/channel/"; - public YoutubeSubscriptionExtractor(YoutubeService service) { - super(service, Collections.singletonList(INPUT_STREAM)); + public YoutubeSubscriptionExtractor(final YoutubeService youtubeService) { + super(youtubeService, Collections.singletonList(INPUT_STREAM)); } @Override public String getRelatedUrl() { - return "https://www.youtube.com/subscription_manager?action_takeout=1"; + return "https://takeout.google.com/takeout/custom/youtube"; } @Override - public List fromInputStream(InputStream contentInputStream) throws ExtractionException { - if (contentInputStream == null) throw new InvalidSourceException("input stream is null"); - - return getItemsFromOPML(contentInputStream); - } - - /*////////////////////////////////////////////////////////////////////////// - // OPML implementation - //////////////////////////////////////////////////////////////////////////*/ - - private static final String ID_PATTERN = "/videos.xml\\?channel_id=([A-Za-z0-9_-]*)"; - private static final String BASE_CHANNEL_URL = "https://www.youtube.com/channel/"; - - private List getItemsFromOPML(InputStream contentInputStream) throws ExtractionException { - final List result = new ArrayList<>(); - - final String contentString = readFromInputStream(contentInputStream); - Document document = Jsoup.parse(contentString, "", org.jsoup.parser.Parser.xmlParser()); - - if (document.select("opml").isEmpty()) { - throw new InvalidSourceException("document does not have OPML tag"); - } - - if (document.select("outline").isEmpty()) { - throw new InvalidSourceException("document does not have at least one outline tag"); - } - - for (Element outline : document.select("outline[type=rss]")) { - String title = outline.attr("title"); - String xmlUrl = outline.attr("abs:xmlUrl"); - - try { - String id = Parser.matchGroup1(ID_PATTERN, xmlUrl); - result.add(new SubscriptionItem(service.getServiceId(), BASE_CHANNEL_URL + id, title)); - } catch (Parser.RegexException ignored) { /* ignore invalid subscriptions */ } - } - - return result; - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Throws an exception if the string does not have the right tag/string from a valid export. - */ - private void throwIfTagIsNotFound(String content) throws InvalidSourceException { - if (!content.trim().contains(" fromInputStream(@Nonnull final InputStream contentInputStream) + throws ExtractionException { + final JsonArray subscriptions; try { - byte[] buffer = new byte[16 * 1024]; - int read; - while ((read = inputStream.read(buffer)) != -1) { - String currentPartOfContent = new String(buffer, 0, read, "UTF-8"); - contentBuilder.append(currentPartOfContent); + subscriptions = JsonParser.array().from(contentInputStream); + } catch (JsonParserException e) { + throw new InvalidSourceException("Invalid json input stream", e); + } - // Fail-fast in case of reading a long unsupported input stream - if (!hasTag && contentBuilder.length() > 128) { - throwIfTagIsNotFound(contentBuilder.toString()); - hasTag = true; - } + boolean foundInvalidSubscription = false; + final List subscriptionItems = new ArrayList<>(); + for (final Object subscriptionObject : subscriptions) { + if (!(subscriptionObject instanceof JsonObject)) { + foundInvalidSubscription = true; + continue; } - } catch (InvalidSourceException e) { - throw e; - } catch (Throwable e) { - throw new InvalidSourceException(e); - } finally { - try { - inputStream.close(); - } catch (IOException ignored) { + + final JsonObject subscription = ((JsonObject) subscriptionObject).getObject("snippet"); + final String id = subscription.getObject("resourceId").getString("channelId", ""); + if (id.length() != 24) { // e.g. UCsXVk37bltHxD1rDPwtNM8Q + foundInvalidSubscription = true; + continue; } + + subscriptionItems.add(new SubscriptionItem(service.getServiceId(), + BASE_CHANNEL_URL + id, subscription.getString("title", ""))); } - final String fileContent = contentBuilder.toString().trim(); - if (fileContent.isEmpty()) { - throw new InvalidSourceException("Empty input stream"); + if (foundInvalidSubscription && subscriptionItems.isEmpty()) { + throw new InvalidSourceException("Found only invalid channel ids"); } - - if (!hasTag) { - throwIfTagIsNotFound(fileContent); - } - - return fileContent; + return subscriptionItems; } } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/subscription/SubscriptionExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/subscription/SubscriptionExtractor.java index ce91b1e69..8a31b0f75 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/subscription/SubscriptionExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/subscription/SubscriptionExtractor.java @@ -4,6 +4,7 @@ import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ParsingException; +import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; import java.io.InputStream; @@ -71,8 +72,9 @@ public abstract class SubscriptionExtractor { * * @throws InvalidSourceException when the content read from the InputStream is invalid and can not be parsed */ - @SuppressWarnings("RedundantThrows") - public List fromInputStream(InputStream contentInputStream) throws IOException, ExtractionException { - throw new UnsupportedOperationException("Service " + service.getServiceInfo().getName() + " doesn't support extracting from an InputStream"); + public List fromInputStream(@Nonnull final InputStream contentInputStream) + throws ExtractionException { + throw new UnsupportedOperationException("Service " + service.getServiceInfo().getName() + + " doesn't support extracting from an InputStream"); } } diff --git a/extractor/src/test/java/org/schabi/newpipe/FileUtils.java b/extractor/src/test/java/org/schabi/newpipe/FileUtils.java index 3242275d9..5c55a2fb4 100644 --- a/extractor/src/test/java/org/schabi/newpipe/FileUtils.java +++ b/extractor/src/test/java/org/schabi/newpipe/FileUtils.java @@ -61,6 +61,21 @@ public class FileUtils { writer.close(); } + /** + * Resolves the test resource file based on its filename. Looks in + * {@code extractor/src/test/resources/} and {@code src/test/resources/} + * @param filename the resource filename + * @return the resource file + */ + public static File resolveTestResource(final String filename) { + final File file = new File("extractor/src/test/resources/" + filename); + if (file.exists()) { + return file; + } else { + return new File("src/test/resources/" + filename); + } + } + /** * Convert a JSON object to String * toString() does not produce a valid JSON string diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeSubscriptionExtractorTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeSubscriptionExtractorTest.java index 80821d267..931e12de2 100644 --- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeSubscriptionExtractorTest.java +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeSubscriptionExtractorTest.java @@ -11,12 +11,16 @@ import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import java.io.ByteArrayInputStream; -import java.io.File; import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.schabi.newpipe.FileUtils.resolveTestResource; /** * Test for {@link YoutubeSubscriptionExtractor} @@ -34,54 +38,48 @@ public class YoutubeSubscriptionExtractorTest { @Test public void testFromInputStream() throws Exception { - File testFile = new File("extractor/src/test/resources/youtube_export_test.xml"); - if (!testFile.exists()) testFile = new File("src/test/resources/youtube_export_test.xml"); + final List subscriptionItems = subscriptionExtractor.fromInputStream( + new FileInputStream(resolveTestResource("youtube_takeout_import_test.json"))); + assertEquals(7, subscriptionItems.size()); - List subscriptionItems = subscriptionExtractor.fromInputStream(new FileInputStream(testFile)); - assertTrue("List doesn't have exactly 8 items (had " + subscriptionItems.size() + ")", subscriptionItems.size() == 8); - - for (SubscriptionItem item : subscriptionItems) { + for (final SubscriptionItem item : subscriptionItems) { assertNotNull(item.getName()); assertNotNull(item.getUrl()); assertTrue(urlHandler.acceptUrl(item.getUrl())); - assertFalse(item.getServiceId() == -1); + assertEquals(ServiceList.YouTube.getServiceId(), item.getServiceId()); } } @Test public void testEmptySourceException() throws Exception { - String emptySource = "" + - "" + - ""; - - List items = subscriptionExtractor.fromInputStream(new ByteArrayInputStream(emptySource.getBytes("UTF-8"))); + final List items = subscriptionExtractor.fromInputStream( + new ByteArrayInputStream("[]".getBytes(StandardCharsets.UTF_8))); assertTrue(items.isEmpty()); } @Test public void testSubscriptionWithEmptyTitleInSource() throws Exception { - String channelId = "AA0AaAa0AaaaAAAAAA0aa0AA"; - String source = "" + - "" + - ""; + final String source = "[{\"snippet\":{\"resourceId\":{\"channelId\":\"UCEOXxzW2vU0P-0THehuIIeg\"}}}]"; + final List items = subscriptionExtractor.fromInputStream( + new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8))); - List items = subscriptionExtractor.fromInputStream(new ByteArrayInputStream(source.getBytes("UTF-8"))); - assertTrue("List doesn't have exactly 1 item (had " + items.size() + ")", items.size() == 1); - assertTrue("Item does not have an empty title (had \"" + items.get(0).getName() + "\")", items.get(0).getName().isEmpty()); - assertTrue("Item does not have the right channel id \"" + channelId + "\" (the whole url is \"" + items.get(0).getUrl() + "\")", items.get(0).getUrl().endsWith(channelId)); + assertEquals(1, items.size()); + assertEquals(ServiceList.YouTube.getServiceId(), items.get(0).getServiceId()); + assertEquals("https://www.youtube.com/channel/UCEOXxzW2vU0P-0THehuIIeg", items.get(0).getUrl()); + assertEquals("", items.get(0).getName()); } @Test public void testSubscriptionWithInvalidUrlInSource() throws Exception { - String source = "" + - "" + - "" + - "" + - "" + - ""; + final String source = "[{\"snippet\":{\"resourceId\":{\"channelId\":\"gibberish\"},\"title\":\"name1\"}}," + + "{\"snippet\":{\"resourceId\":{\"channelId\":\"UCEOXxzW2vU0P-0THehuIIeg\"},\"title\":\"name2\"}}]"; + final List items = subscriptionExtractor.fromInputStream( + new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8))); - List items = subscriptionExtractor.fromInputStream(new ByteArrayInputStream(source.getBytes("UTF-8"))); - assertTrue(items.isEmpty()); + assertEquals(1, items.size()); + assertEquals(ServiceList.YouTube.getServiceId(), items.get(0).getServiceId()); + assertEquals("https://www.youtube.com/channel/UCEOXxzW2vU0P-0THehuIIeg", items.get(0).getUrl()); + assertEquals("name2", items.get(0).getName()); } @Test @@ -89,26 +87,26 @@ public class YoutubeSubscriptionExtractorTest { List invalidList = Arrays.asList( "", "", - "", + "{\"a\":\"b\"}", + "[{}]", + "[\"\", 5]", + "[{\"snippet\":{\"title\":\"name\"}}]", + "[{\"snippet\":{\"resourceId\":{\"channelId\":\"gibberish\"}}}]", "", - null, "\uD83D\uDC28\uD83D\uDC28\uD83D\uDC28", "gibberish"); for (String invalidContent : invalidList) { try { - if (invalidContent != null) { - byte[] bytes = invalidContent.getBytes("UTF-8"); - subscriptionExtractor.fromInputStream(new ByteArrayInputStream(bytes)); - fail("Extracting from \"" + invalidContent + "\" didn't throw an exception"); - } else { - subscriptionExtractor.fromInputStream(null); - fail("Extracting from null String didn't throw an exception"); + byte[] bytes = invalidContent.getBytes(StandardCharsets.UTF_8); + subscriptionExtractor.fromInputStream(new ByteArrayInputStream(bytes)); + fail("Extracting from \"" + invalidContent + "\" didn't throw an exception"); + } catch (final Exception e) { + boolean correctType = e instanceof SubscriptionExtractor.InvalidSourceException; + if (!correctType) { + e.printStackTrace(); } - } catch (Exception e) { - // System.out.println(" -> " + e); - boolean isExpectedException = e instanceof SubscriptionExtractor.InvalidSourceException; - assertTrue("\"" + e.getClass().getSimpleName() + "\" is not the expected exception", isExpectedException); + assertTrue(e.getClass().getSimpleName() + " is not InvalidSourceException", correctType); } } } diff --git a/extractor/src/test/resources/youtube_export_test.xml b/extractor/src/test/resources/youtube_export_test.xml deleted file mode 100644 index 4092f98aa..000000000 --- a/extractor/src/test/resources/youtube_export_test.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/extractor/src/test/resources/youtube_takeout_import_test.json b/extractor/src/test/resources/youtube_takeout_import_test.json new file mode 100644 index 000000000..0dbc5c723 --- /dev/null +++ b/extractor/src/test/resources/youtube_takeout_import_test.json @@ -0,0 +1,211 @@ +[ { + "contentDetails" : { + "activityType" : "all", + "newItemCount" : 0, + "totalItemCount" : 229 + }, + "etag" : "WRftgrOZw2rsNjNYhHG3s-VObgs", + "id" : "qUAzBV8xkoLYOP-1gwzy6yVWWYc7mBJxLNUEVlkNk8Y", + "kind" : "youtube#subscription", + "snippet" : { + "channelId" : "UC-lHJZR3Gqxm24_Vd_AJ5Yw", + "description" : "The official YouTube home of Gorillaz.", + "publishedAt" : "2020-11-01T17:24:34.498Z", + "resourceId" : { + "channelId" : "UCfIXdjDQH9Fau7y99_Orpjw", + "kind" : "youtube#channel" + }, + "thumbnails" : { + "default" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "high" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s800-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "medium" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s240-c-k-no-mo-rj-c0xffffff/photo.jpg" + } + }, + "title" : "Gorillaz" + } +}, { + "contentDetails" : { + "activityType" : "all", + "newItemCount" : 0, + "totalItemCount" : 3502 + }, + "etag" : "wUgip-X0qBlnjj0frSTwP6B8XoY", + "id" : "qUAzBV8xkoLYOP-1gwzy63zpjj8SMTtDReGwIa2sHp8", + "kind" : "youtube#subscription", + "snippet" : { + "channelId" : "UC-lHJZR3Gqxm24_Vd_AJ5Yw", + "description" : "The TED Talks channel features the best talks and performances from the TED Conference, where the world's leading thinkers and doers give the talk of their lives in 18 minutes (or less). Look for talks on Technology, Entertainment and Design -- plus science, business, global issues, the arts and more. You're welcome to link to or embed these videos, forward them to others and share these ideas with people you know.\n\nTED's videos may be used for non-commercial purposes under a Creative Commons License, Attribution–Non Commercial–No Derivatives (or the CC BY – NC – ND 4.0 International) and in accordance with our TED Talks Usage Policy (https://www.ted.com/about/our-organization/our-policies-terms/ted-talks-usage-policy). For more information on using TED for commercial purposes (e.g. employee learning, in a film or online course), please submit a Media Request at https://media-requests.ted.com", + "publishedAt" : "2020-11-01T17:24:11.769Z", + "resourceId" : { + "channelId" : "UCAuUUnT6oDeKwE6v1NGQxug", + "kind" : "youtube#channel" + }, + "thumbnails" : { + "default" : { + "url" : "https://yt3.ggpht.com/-bonZt347bMc/AAAAAAAAAAI/AAAAAAAAAAA/lR8QEKnqqHk/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "high" : { + "url" : "https://yt3.ggpht.com/-bonZt347bMc/AAAAAAAAAAI/AAAAAAAAAAA/lR8QEKnqqHk/s800-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "medium" : { + "url" : "https://yt3.ggpht.com/-bonZt347bMc/AAAAAAAAAAI/AAAAAAAAAAA/lR8QEKnqqHk/s240-c-k-no-mo-rj-c0xffffff/photo.jpg" + } + }, + "title" : "TED" + } +}, { + "contentDetails" : { + "activityType" : "all", + "newItemCount" : 0, + "totalItemCount" : 98 + }, + "etag" : "M3Hl6FQUAD3e-fH9pcvcE9aPSWQ", + "id" : "qUAzBV8xkoLYOP-1gwzy64Vo-PpWMPDyIYBM1JUfepk", + "kind" : "youtube#subscription", + "snippet" : { + "channelId" : "UC-lHJZR3Gqxm24_Vd_AJ5Yw", + "description" : "In a world where the content of digital images and videos can no longer be taken at face value, an unlikely hero fights for the acceptance of truth.\r\n\r\nCaptain Disillusion guides children of all ages through the maze of visual fakery to the open spaces of reality and peace of mind.\r\n\r\nSubscribe to get fun and detailed explanations of current \"unbelievable\" viral videos that fool the masses!", + "publishedAt" : "2020-11-01T17:23:52.909Z", + "resourceId" : { + "channelId" : "UCEOXxzW2vU0P-0THehuIIeg", + "kind" : "youtube#channel" + }, + "thumbnails" : { + "default" : { + "url" : "https://yt3.ggpht.com/-7f3hUYZP5Sc/AAAAAAAAAAI/AAAAAAAAAAA/4cpBHKDlbYQ/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "high" : { + "url" : "https://yt3.ggpht.com/-7f3hUYZP5Sc/AAAAAAAAAAI/AAAAAAAAAAA/4cpBHKDlbYQ/s800-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "medium" : { + "url" : "https://yt3.ggpht.com/-7f3hUYZP5Sc/AAAAAAAAAAI/AAAAAAAAAAA/4cpBHKDlbYQ/s240-c-k-no-mo-rj-c0xffffff/photo.jpg" + } + }, + "title" : "Captain Disillusion" + } +}, { + "contentDetails" : { + "activityType" : "all", + "newItemCount" : 0, + "totalItemCount" : 130 + }, + "etag" : "crkTVZbDHS3arRZErMaLMnNqtac", + "id" : "qUAzBV8xkoLYOP-1gwzy66EVopYHE34m06PVw8Pvheg", + "kind" : "youtube#subscription", + "snippet" : { + "channelId" : "UC-lHJZR3Gqxm24_Vd_AJ5Yw", + "description" : "Videos explaining things with optimistic nihilism. \n\nWe are a small team who want to make science look beautiful. Because it is beautiful. \n\nCurrently we make one animation video per month. Follow us on Twitter, Facebook to get notified when a new one comes out.\n\nFAQ:\n \n- We do the videos with After Effects and Illustrator.", + "publishedAt" : "2020-11-01T17:23:39.659Z", + "resourceId" : { + "channelId" : "UCsXVk37bltHxD1rDPwtNM8Q", + "kind" : "youtube#channel" + }, + "thumbnails" : { + "default" : { + "url" : "https://yt3.ggpht.com/-UwENvFjc4vI/AAAAAAAAAAI/AAAAAAAAAAA/04dXvZ_jl0I/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "high" : { + "url" : "https://yt3.ggpht.com/-UwENvFjc4vI/AAAAAAAAAAI/AAAAAAAAAAA/04dXvZ_jl0I/s800-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "medium" : { + "url" : "https://yt3.ggpht.com/-UwENvFjc4vI/AAAAAAAAAAI/AAAAAAAAAAA/04dXvZ_jl0I/s240-c-k-no-mo-rj-c0xffffff/photo.jpg" + } + }, + "title" : "Kurzgesagt – In a Nutshell" + } +}, { + "contentDetails" : { + "activityType" : "all", + "newItemCount" : 0, + "totalItemCount" : 229 + }, + "etag" : "WRftgrOZw2rsNjNYhHG3s-VObgs", + "id" : "qUAzBV8xkoLYOP-1gwzy6yVWWYc7mBJxLNUEVlkNk8Y", + "kind" : "youtube#subscription", + "snippet" : { + "channelId" : "UC-lHJZR3Gqxm24_Vd_AJ5Yw", + "description" : "ⓤⓝⓘⓒⓞⓓⓔ", + "publishedAt" : "2020-11-01T17:24:34.498Z", + "resourceId" : { + "channelId" : "UCfIXdjDQH9Fau7y99_Orpjw", + "kind" : "youtube#channel" + }, + "thumbnails" : { + "default" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "high" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s800-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "medium" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s240-c-k-no-mo-rj-c0xffffff/photo.jpg" + } + }, + "title" : "ⓤⓝⓘⓒⓞⓓⓔ" + } +}, { + "contentDetails" : { + "activityType" : "all", + "newItemCount" : 0, + "totalItemCount" : 229 + }, + "etag" : "WRftgrOZw2rsNjNYhHG3s-VObgs", + "id" : "qUAzBV8xkoLYOP-1gwzy6yVWWYc7mBJxLNUEVlkNk8Y", + "kind" : "youtube#subscription", + "snippet" : { + "channelId" : "UC-lHJZR3Gqxm24_Vd_AJ5Yw", + "description" : "中文", + "publishedAt" : "2020-11-01T17:24:34.498Z", + "resourceId" : { + "channelId" : "UCfIXdjDQH9Fau7y99_Orpjw", + "kind" : "youtube#channel" + }, + "thumbnails" : { + "default" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "high" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s800-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "medium" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s240-c-k-no-mo-rj-c0xffffff/photo.jpg" + } + }, + "title" : "中文" + } +}, { + "contentDetails" : { + "activityType" : "all", + "newItemCount" : 0, + "totalItemCount" : 229 + }, + "etag" : "WRftgrOZw2rsNjNYhHG3s-VObgs", + "id" : "qUAzBV8xkoLYOP-1gwzy6yVWWYc7mBJxLNUEVlkNk8Y", + "kind" : "youtube#subscription", + "snippet" : { + "channelId" : "UC-lHJZR3Gqxm24_Vd_AJ5Yw", + "description" : "हिंदी", + "publishedAt" : "2020-11-01T17:24:34.498Z", + "resourceId" : { + "channelId" : "UCfIXdjDQH9Fau7y99_Orpjw", + "kind" : "youtube#channel" + }, + "thumbnails" : { + "default" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "high" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s800-c-k-no-mo-rj-c0xffffff/photo.jpg" + }, + "medium" : { + "url" : "https://yt3.ggpht.com/-UXcxdSDLo08/AAAAAAAAAAI/AAAAAAAAAAA/FP5NbxM7TzU/s240-c-k-no-mo-rj-c0xffffff/photo.jpg" + } + }, + "title" : "हिंदी" + } +} ] \ No newline at end of file