Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
import org.schabi.newpipe.extractor.utils.JsonUtils;
import org.schabi.newpipe.extractor.utils.Utils;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
Expand All @@ -30,20 +35,24 @@
* The following features are currently not implemented because they have never been observed:
* <ul>
* <li>Shorts</li>
* <li>Premieres</li>
* <li>Paid content (Premium, members first or only)</li>
* </ul>
*/
public class YoutubeStreamInfoItemLockupExtractor implements StreamInfoItemExtractor {

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

private final JsonObject lockupViewModel;
private final TimeAgoParser timeAgoParser;

private StreamType cachedStreamType;
private String cachedName;
private Optional<String> cachedTextualUploadDate;
private Optional<String> cachedDateText;

private ChannelImageViewModel cachedChannelImageViewModel;
private JsonArray cachedMetadataRows;
Expand Down Expand Up @@ -137,7 +146,9 @@ public String getName() throws ParsingException {
@Override
public long getDuration() throws ParsingException {
// Duration cannot be extracted for live streams, but only for normal videos
if (isLive()) {
// Exact duration cannot be extracted for premieres, an approximation is only available in
// accessibility context label
if (isLive() || isPremiere()) {
return -1;
}

Expand Down Expand Up @@ -237,20 +248,27 @@ public boolean isUploaderVerified() throws ParsingException {
@Nullable
@Override
public String getTextualUploadDate() throws ParsingException {
if (cachedTextualUploadDate != null) {
return cachedTextualUploadDate.orElse(null);
}

// Live streams have no upload date
if (isLive()) {
cachedTextualUploadDate = Optional.empty();
return null;
}

// This might be null e.g. for live streams
this.cachedTextualUploadDate = metadataPart(1, 1)
.map(this::getTextContentFromMetadataPart);
return cachedTextualUploadDate.orElse(null);
Comment thread
Stypox marked this conversation as resolved.
// Date string might be null e.g. for live streams
final Optional<String> dateText = getDateText();

if (isPremiere()) {
return getDateFromPremiere(dateText);
}

return dateText.orElse(null);
}

@Nullable
private String getDateFromPremiere(final Optional<String> dateText) {
// This approach is language dependent
// Remove the premieres text from the upload date metadata part
return dateText.map(str -> str.replace(PREMIERES_TEXT, ""))
.orElse(null);
}

@Nullable
Expand All @@ -265,11 +283,32 @@ public DateWrapper getUploadDate() throws ParsingException {
if (textualUploadDate == null) {
return null;
}

if (isPremiere()) {
final String premiereDate = getDateFromPremiere(getDateText());
if (premiereDate == null) {
throw new ParsingException("Could not get upload date from premiere");
}

try {
// As we request a UTC offset of 0 minutes, we get the UTC data
Comment thread
Stypox marked this conversation as resolved.
Outdated
return new DateWrapper(OffsetDateTime.of(LocalDateTime.parse(
premiereDate, PREMIERES_DATE_FORMATTER), ZoneOffset.UTC));
} catch (final DateTimeParseException e) {
throw new ParsingException("Could not parse premiere upload date", e);
}
}

return timeAgoParser.parse(textualUploadDate);
}

@Override
public long getViewCount() throws ParsingException {
if (isPremiere()) {
// The number of people returned for premieres is the one currently waiting
return -1;
}

final Optional<String> optTextContent = metadataPart(1, 0)
.map(this::getTextContentFromMetadataPart);
// We could do this inline if the ParsingException would be a RuntimeException -.-
Expand Down Expand Up @@ -357,6 +396,20 @@ private boolean isLive() throws ParsingException {
return getStreamType() != StreamType.VIDEO_STREAM;
}

private Optional<String> getDateText() throws ParsingException {
if (cachedDateText == null) {
cachedDateText = metadataPart(1, 1)
.map(this::getTextContentFromMetadataPart);
}
return cachedDateText;
}

private boolean isPremiere() throws ParsingException {
return getDateText().map(dateText -> dateText.contains(PREMIERES_TEXT))
// If we can't get date text, assume it is not a premiere, it should be a livestream
.orElse(false);
}
Comment thread
Stypox marked this conversation as resolved.

abstract static class ChannelImageViewModel {
protected JsonObject viewModel;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

import java.util.Locale;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

public class DownloaderFactory {

private static final DownloaderType DEFAULT_DOWNLOADER = DownloaderType.REAL;
Expand Down Expand Up @@ -36,20 +39,31 @@ private static DownloaderType determineDownloaderType() {
}

public static Downloader getDownloader(final Class<?> clazz) {
return getDownloader(clazz, null);
return getDownloader(getMockPath(clazz, null));
}

public static Downloader getDownloader(final Class<?> clazz,
@Nullable final String specificUseCase) {
return getDownloader(getMockPath(clazz, specificUseCase));
}

public static Downloader getDownloader(final Class<?> clazz, final String specificUseCase) {
/**
* Always returns a path without a trailing '/', so that it can be used both as a folder name
* and as a filename. The {@link MockDownloader} will use it as a folder name, but other tests
* can use it as a filename, if only one custom mock file is needed for that test.
*/
public static String getMockPath(final Class<?> clazz,
@Nullable final String specificUseCase) {
String baseName = clazz.getName();
if (specificUseCase != null) {
baseName += "." + specificUseCase;
}
return getDownloader("src/test/resources/mocks/v1/"
+ baseName
.toLowerCase(Locale.ENGLISH)
.replace('$', '.')
.replace("test", "")
.replace('.', '/'));
return "src/test/resources/mocks/v1/"
+ baseName
.toLowerCase(Locale.ENGLISH)
.replace('$', '.')
.replace("test", "")
.replace('.', '/');
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.schabi.newpipe.extractor.services.youtube;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.schabi.newpipe.downloader.DownloaderFactory.getMockPath;

import com.grack.nanojson.JsonParser;
import com.grack.nanojson.JsonParserException;

import org.junit.jupiter.api.Test;
import org.schabi.newpipe.extractor.localization.Localization;
import org.schabi.newpipe.extractor.localization.TimeAgoPatternsManager;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamInfoItemExtractor;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamInfoItemLockupExtractor;
import org.schabi.newpipe.extractor.stream.StreamType;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;

public class YoutubeStreamInfoItemTest {
@Test
void videoRendererPremiere() throws FileNotFoundException, JsonParserException {
Comment thread
AudricV marked this conversation as resolved.
final var json = JsonParser.object().from(new FileInputStream(getMockPath(
YoutubeStreamInfoItemTest.class, "videoRendererPremiere") + ".json"));
final var timeAgoParser = TimeAgoPatternsManager.getTimeAgoParserFor(Localization.DEFAULT);
final var extractor = new YoutubeStreamInfoItemExtractor(json, timeAgoParser);
assertAll(
() -> assertEquals(StreamType.VIDEO_STREAM, extractor.getStreamType()),
() -> assertFalse(extractor.isAd()),
() -> assertEquals("https://www.youtube.com/watch?v=M_8QNw_JM4I", extractor.getUrl()),
() -> assertEquals("This video will premiere in 6 months.", extractor.getName()),
() -> assertEquals(33, extractor.getDuration()),
() -> assertEquals("Blunt Brothers Productions", extractor.getUploaderName()),
() -> assertEquals("https://www.youtube.com/channel/UCUPrbbdnot-aPgNM65svgOg", extractor.getUploaderUrl()),
() -> assertFalse(extractor.getUploaderAvatars().isEmpty()),
() -> assertTrue(extractor.isUploaderVerified()),
() -> assertEquals("2026-03-15 13:12", extractor.getTextualUploadDate()),
() -> {
assertNotNull(extractor.getUploadDate());
assertEquals(OffsetDateTime.of(2026, 3, 15, 13, 12, 0, 0, ZoneOffset.UTC), extractor.getUploadDate().offsetDateTime());
},
() -> assertEquals(-1, extractor.getViewCount()),
() -> assertFalse(extractor.getThumbnails().isEmpty()),
() -> assertEquals("Patience is key… MERCH SHOP : https://www.bluntbrosproductions.com Follow us on Instagram for early updates: ...", extractor.getShortDescription()),
() -> assertFalse(extractor.isShortFormContent())
);
}

@Test
void lockupViewModelPremiere()
throws FileNotFoundException, JsonParserException {
final var json = JsonParser.object().from(new FileInputStream(getMockPath(
YoutubeStreamInfoItemTest.class, "lockupViewModelPremiere") + ".json"));
final var timeAgoParser = TimeAgoPatternsManager.getTimeAgoParserFor(Localization.DEFAULT);
final var extractor = new YoutubeStreamInfoItemLockupExtractor(json, timeAgoParser);
assertAll(
() -> assertEquals(StreamType.VIDEO_STREAM, extractor.getStreamType()),
() -> assertFalse(extractor.isAd()),
() -> assertEquals("https://www.youtube.com/watch?v=VIDEO_ID", extractor.getUrl()),
() -> assertEquals("VIDEO_TITLE", extractor.getName()),
() -> assertEquals(-1, extractor.getDuration()),
() -> assertEquals("VIDEO_CHANNEL_NAME", extractor.getUploaderName()),
() -> assertEquals("https://www.youtube.com/channel/UCD_on7-zu7Zuc3zissQvrgw", extractor.getUploaderUrl()),
() -> assertFalse(extractor.getUploaderAvatars().isEmpty()),
() -> assertFalse(extractor.isUploaderVerified()),
() -> assertEquals("14/08/2025, 13:00", extractor.getTextualUploadDate()),
() -> {
assertNotNull(extractor.getUploadDate());
assertEquals(OffsetDateTime.of(2025, 8, 14, 13, 0, 0, 0, ZoneOffset.UTC), extractor.getUploadDate().offsetDateTime());
},
() -> assertEquals(-1, extractor.getViewCount()),
() -> assertFalse(extractor.getThumbnails().isEmpty()),
() -> assertNull(extractor.getShortDescription()),
() -> assertFalse(extractor.isShortFormContent())
);
}
}
Loading