Skip to content

Commit 8dfb0d3

Browse files
authored
Merge pull request #1361 from AudricV/yt_premieres-lockups-support
2 parents 606bb93 + 9795725 commit 8dfb0d3

File tree

5 files changed

+889
-20
lines changed

5 files changed

+889
-20
lines changed

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

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
import org.schabi.newpipe.extractor.utils.JsonUtils;
1818
import org.schabi.newpipe.extractor.utils.Utils;
1919

20+
import java.time.LocalDateTime;
21+
import java.time.OffsetDateTime;
22+
import java.time.ZoneOffset;
23+
import java.time.format.DateTimeFormatter;
24+
import java.time.format.DateTimeParseException;
2025
import java.util.List;
2126
import java.util.Optional;
2227
import java.util.stream.Collectors;
@@ -30,20 +35,24 @@
3035
* The following features are currently not implemented because they have never been observed:
3136
* <ul>
3237
* <li>Shorts</li>
33-
* <li>Premieres</li>
3438
* <li>Paid content (Premium, members first or only)</li>
3539
* </ul>
3640
*/
3741
public class YoutubeStreamInfoItemLockupExtractor implements StreamInfoItemExtractor {
3842

3943
private static final String NO_VIEWS_LOWERCASE = "no views";
44+
// This approach is language dependant (en-GB)
45+
// Leading end space is voluntary included
46+
private static final String PREMIERES_TEXT = "Premieres ";
47+
private static final DateTimeFormatter PREMIERES_DATE_FORMATTER =
48+
DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm");
4049

4150
private final JsonObject lockupViewModel;
4251
private final TimeAgoParser timeAgoParser;
4352

4453
private StreamType cachedStreamType;
4554
private String cachedName;
46-
private Optional<String> cachedTextualUploadDate;
55+
private Optional<String> cachedDateText;
4756

4857
private ChannelImageViewModel cachedChannelImageViewModel;
4958
private JsonArray cachedMetadataRows;
@@ -137,7 +146,9 @@ public String getName() throws ParsingException {
137146
@Override
138147
public long getDuration() throws ParsingException {
139148
// Duration cannot be extracted for live streams, but only for normal videos
140-
if (isLive()) {
149+
// Exact duration cannot be extracted for premieres, an approximation is only available in
150+
// accessibility context label
151+
if (isLive() || isPremiere()) {
141152
return -1;
142153
}
143154

@@ -237,20 +248,27 @@ public boolean isUploaderVerified() throws ParsingException {
237248
@Nullable
238249
@Override
239250
public String getTextualUploadDate() throws ParsingException {
240-
if (cachedTextualUploadDate != null) {
241-
return cachedTextualUploadDate.orElse(null);
242-
}
243-
244251
// Live streams have no upload date
245252
if (isLive()) {
246-
cachedTextualUploadDate = Optional.empty();
247253
return null;
248254
}
249255

250-
// This might be null e.g. for live streams
251-
this.cachedTextualUploadDate = metadataPart(1, 1)
252-
.map(this::getTextContentFromMetadataPart);
253-
return cachedTextualUploadDate.orElse(null);
256+
// Date string might be null e.g. for live streams
257+
final Optional<String> dateText = getDateText();
258+
259+
if (isPremiere()) {
260+
return getDateFromPremiere(dateText);
261+
}
262+
263+
return dateText.orElse(null);
264+
}
265+
266+
@Nullable
267+
private String getDateFromPremiere(final Optional<String> dateText) {
268+
// This approach is language dependent
269+
// Remove the premieres text from the upload date metadata part
270+
return dateText.map(str -> str.replace(PREMIERES_TEXT, ""))
271+
.orElse(null);
254272
}
255273

256274
@Nullable
@@ -265,11 +283,32 @@ public DateWrapper getUploadDate() throws ParsingException {
265283
if (textualUploadDate == null) {
266284
return null;
267285
}
286+
287+
if (isPremiere()) {
288+
final String premiereDate = getDateFromPremiere(getDateText());
289+
if (premiereDate == null) {
290+
throw new ParsingException("Could not get upload date from premiere");
291+
}
292+
293+
try {
294+
// As we request a UTC offset of 0 minutes, we get the UTC date
295+
return new DateWrapper(OffsetDateTime.of(LocalDateTime.parse(
296+
premiereDate, PREMIERES_DATE_FORMATTER), ZoneOffset.UTC));
297+
} catch (final DateTimeParseException e) {
298+
throw new ParsingException("Could not parse premiere upload date", e);
299+
}
300+
}
301+
268302
return timeAgoParser.parse(textualUploadDate);
269303
}
270304

271305
@Override
272306
public long getViewCount() throws ParsingException {
307+
if (isPremiere()) {
308+
// The number of people returned for premieres is the one currently waiting
309+
return -1;
310+
}
311+
273312
final Optional<String> optTextContent = metadataPart(1, 0)
274313
.map(this::getTextContentFromMetadataPart);
275314
// We could do this inline if the ParsingException would be a RuntimeException -.-
@@ -357,6 +396,20 @@ private boolean isLive() throws ParsingException {
357396
return getStreamType() != StreamType.VIDEO_STREAM;
358397
}
359398

399+
private Optional<String> getDateText() throws ParsingException {
400+
if (cachedDateText == null) {
401+
cachedDateText = metadataPart(1, 1)
402+
.map(this::getTextContentFromMetadataPart);
403+
}
404+
return cachedDateText;
405+
}
406+
407+
private boolean isPremiere() throws ParsingException {
408+
return getDateText().map(dateText -> dateText.contains(PREMIERES_TEXT))
409+
// If we can't get date text, assume it is not a premiere, it should be a livestream
410+
.orElse(false);
411+
}
412+
360413
abstract static class ChannelImageViewModel {
361414
protected JsonObject viewModel;
362415

extractor/src/test/java/org/schabi/newpipe/downloader/DownloaderFactory.java

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
import java.util.Locale;
66

7+
import javax.annotation.Nonnull;
8+
import javax.annotation.Nullable;
9+
710
public class DownloaderFactory {
811

912
private static final DownloaderType DEFAULT_DOWNLOADER = DownloaderType.REAL;
@@ -36,20 +39,31 @@ private static DownloaderType determineDownloaderType() {
3639
}
3740

3841
public static Downloader getDownloader(final Class<?> clazz) {
39-
return getDownloader(clazz, null);
42+
return getDownloader(getMockPath(clazz, null));
43+
}
44+
45+
public static Downloader getDownloader(final Class<?> clazz,
46+
@Nullable final String specificUseCase) {
47+
return getDownloader(getMockPath(clazz, specificUseCase));
4048
}
4149

42-
public static Downloader getDownloader(final Class<?> clazz, final String specificUseCase) {
50+
/**
51+
* Always returns a path without a trailing '/', so that it can be used both as a folder name
52+
* and as a filename. The {@link MockDownloader} will use it as a folder name, but other tests
53+
* can use it as a filename, if only one custom mock file is needed for that test.
54+
*/
55+
public static String getMockPath(final Class<?> clazz,
56+
@Nullable final String specificUseCase) {
4357
String baseName = clazz.getName();
4458
if (specificUseCase != null) {
4559
baseName += "." + specificUseCase;
4660
}
47-
return getDownloader("src/test/resources/mocks/v1/"
48-
+ baseName
49-
.toLowerCase(Locale.ENGLISH)
50-
.replace('$', '.')
51-
.replace("test", "")
52-
.replace('.', '/'));
61+
return "src/test/resources/mocks/v1/"
62+
+ baseName
63+
.toLowerCase(Locale.ENGLISH)
64+
.replace('$', '.')
65+
.replace("test", "")
66+
.replace('.', '/');
5367
}
5468

5569
/**
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package org.schabi.newpipe.extractor.services.youtube;
2+
3+
import static org.junit.jupiter.api.Assertions.assertAll;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertFalse;
6+
import static org.junit.jupiter.api.Assertions.assertNotNull;
7+
import static org.junit.jupiter.api.Assertions.assertNull;
8+
import static org.junit.jupiter.api.Assertions.assertTrue;
9+
import static org.schabi.newpipe.downloader.DownloaderFactory.getMockPath;
10+
11+
import com.grack.nanojson.JsonParser;
12+
import com.grack.nanojson.JsonParserException;
13+
14+
import org.junit.jupiter.api.Test;
15+
import org.schabi.newpipe.extractor.localization.Localization;
16+
import org.schabi.newpipe.extractor.localization.TimeAgoPatternsManager;
17+
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamInfoItemExtractor;
18+
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamInfoItemLockupExtractor;
19+
import org.schabi.newpipe.extractor.stream.StreamType;
20+
21+
import java.io.FileInputStream;
22+
import java.io.FileNotFoundException;
23+
import java.time.OffsetDateTime;
24+
import java.time.ZoneOffset;
25+
26+
public class YoutubeStreamInfoItemTest {
27+
@Test
28+
void videoRendererPremiere() throws FileNotFoundException, JsonParserException {
29+
final var json = JsonParser.object().from(new FileInputStream(getMockPath(
30+
YoutubeStreamInfoItemTest.class, "videoRendererPremiere") + ".json"));
31+
final var timeAgoParser = TimeAgoPatternsManager.getTimeAgoParserFor(Localization.DEFAULT);
32+
final var extractor = new YoutubeStreamInfoItemExtractor(json, timeAgoParser);
33+
assertAll(
34+
() -> assertEquals(StreamType.VIDEO_STREAM, extractor.getStreamType()),
35+
() -> assertFalse(extractor.isAd()),
36+
() -> assertEquals("https://www.youtube.com/watch?v=M_8QNw_JM4I", extractor.getUrl()),
37+
() -> assertEquals("This video will premiere in 6 months.", extractor.getName()),
38+
() -> assertEquals(33, extractor.getDuration()),
39+
() -> assertEquals("Blunt Brothers Productions", extractor.getUploaderName()),
40+
() -> assertEquals("https://www.youtube.com/channel/UCUPrbbdnot-aPgNM65svgOg", extractor.getUploaderUrl()),
41+
() -> assertFalse(extractor.getUploaderAvatars().isEmpty()),
42+
() -> assertTrue(extractor.isUploaderVerified()),
43+
() -> assertEquals("2026-03-15 13:12", extractor.getTextualUploadDate()),
44+
() -> {
45+
assertNotNull(extractor.getUploadDate());
46+
assertEquals(OffsetDateTime.of(2026, 3, 15, 13, 12, 0, 0, ZoneOffset.UTC), extractor.getUploadDate().offsetDateTime());
47+
},
48+
() -> assertEquals(-1, extractor.getViewCount()),
49+
() -> assertFalse(extractor.getThumbnails().isEmpty()),
50+
() -> assertEquals("Patience is key… MERCH SHOP : https://www.bluntbrosproductions.com Follow us on Instagram for early updates: ...", extractor.getShortDescription()),
51+
() -> assertFalse(extractor.isShortFormContent())
52+
);
53+
}
54+
55+
@Test
56+
void lockupViewModelPremiere()
57+
throws FileNotFoundException, JsonParserException {
58+
final var json = JsonParser.object().from(new FileInputStream(getMockPath(
59+
YoutubeStreamInfoItemTest.class, "lockupViewModelPremiere") + ".json"));
60+
final var timeAgoParser = TimeAgoPatternsManager.getTimeAgoParserFor(Localization.DEFAULT);
61+
final var extractor = new YoutubeStreamInfoItemLockupExtractor(json, timeAgoParser);
62+
assertAll(
63+
() -> assertEquals(StreamType.VIDEO_STREAM, extractor.getStreamType()),
64+
() -> assertFalse(extractor.isAd()),
65+
() -> assertEquals("https://www.youtube.com/watch?v=VIDEO_ID", extractor.getUrl()),
66+
() -> assertEquals("VIDEO_TITLE", extractor.getName()),
67+
() -> assertEquals(-1, extractor.getDuration()),
68+
() -> assertEquals("VIDEO_CHANNEL_NAME", extractor.getUploaderName()),
69+
() -> assertEquals("https://www.youtube.com/channel/UCD_on7-zu7Zuc3zissQvrgw", extractor.getUploaderUrl()),
70+
() -> assertFalse(extractor.getUploaderAvatars().isEmpty()),
71+
() -> assertFalse(extractor.isUploaderVerified()),
72+
() -> assertEquals("14/08/2025, 13:00", extractor.getTextualUploadDate()),
73+
() -> {
74+
assertNotNull(extractor.getUploadDate());
75+
assertEquals(OffsetDateTime.of(2025, 8, 14, 13, 0, 0, 0, ZoneOffset.UTC), extractor.getUploadDate().offsetDateTime());
76+
},
77+
() -> assertEquals(-1, extractor.getViewCount()),
78+
() -> assertFalse(extractor.getThumbnails().isEmpty()),
79+
() -> assertNull(extractor.getShortDescription()),
80+
() -> assertFalse(extractor.isShortFormContent())
81+
);
82+
}
83+
}

0 commit comments

Comments
 (0)