Merge branch 'dev' into Refactor-date-parsing

# Conflicts:
#	extractor/src/main/java/org/schabi/newpipe/extractor/localization/DateWrapper.java
pull/1372/head
Isira Seneviratne 2025-10-08 06:54:39 +05:30
commit f4084ed483
39 zmienionych plików z 2326 dodań i 136 usunięć

Wyświetl plik

@ -20,7 +20,8 @@ If you're using Gradle, you could add NewPipe Extractor as a dependency with the
-dontwarn org.mozilla.javascript.tools.**
```
**Note:** To use NewPipe Extractor in Android projects with a `minSdk` below 33, [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring) with the `desugar_jdk_libs_nio` artifact is required.
> [!NOTE]
> To use NewPipe Extractor in Android projects with a `minSdk` below 33, [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring) with the `desugar_jdk_libs_nio` artifact is required.
### Testing changes
@ -41,6 +42,8 @@ Another approach would be to use the local Maven repository, here's a gist of ho
3. Run gradle's `ìnstall` task to deploy this library to your local repository (using the wrapper, present in the root of this project: `./gradlew install`)
4. Change the dependency version used in your project to match the one you chose in step 2 (`implementation 'com.github.teamnewpipe:NewPipeExtractor:LOCAL_SNAPSHOT'`)
> [!TIP]
> Tip for Android Studio users: After you make changes and run the `install` task, use the menu option `File → "Sync with File System"` to refresh the library in your project.
## Supported sites

Wyświetl plik

@ -29,8 +29,8 @@ allprojects {
ext {
nanojsonVersion = "e9d656ddb49a412a5a0a5d5ef20ca7ef09549996"
jsr305Version = "3.0.2"
junitVersion = "5.13.4"
checkstyleVersion = "10.4"
junitVersion = "5.14.0"
checkstyleVersion = "10.26.1"
}
}

Wyświetl plik

@ -28,7 +28,7 @@ checkstyleTest {
ext {
rhinoVersion = '1.8.0'
protobufVersion = '4.32.0'
protobufVersion = '4.32.1'
}
dependencies {
@ -51,7 +51,7 @@ dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-params'
testImplementation "com.squareup.okhttp3:okhttp:4.12.0"
testImplementation 'com.google.code.gson:gson:2.13.1'
testImplementation 'com.google.code.gson:gson:2.13.2'
}
protobuf {

Wyświetl plik

@ -82,6 +82,14 @@ public class DateWrapper implements Serializable {
return isApproximation;
}
@Override
public String toString() {
return "DateWrapper{"
+ "instant=" + instant
+ ", isApproximation=" + isApproximation
+ '}';
}
public static DateWrapper fromOffsetDateTime(final String date) throws ParsingException {
if (date == null) {
return null;

Wyświetl plik

@ -26,7 +26,6 @@ import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.extractor.utils.ExtractorHelper;
import org.schabi.newpipe.extractor.utils.JsonUtils;
import org.schabi.newpipe.extractor.utils.LocaleCompat;

Wyświetl plik

@ -6,7 +6,6 @@ import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.stream.StreamInfoItemExtractor;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.utils.ExtractorHelper;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;

Wyświetl plik

@ -119,7 +119,8 @@ public class SoundcloudStreamExtractor extends StreamExtractor {
@Override
public long getTimeStamp() throws ParsingException {
return getTimestampSeconds("(#t=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)");
final var timestamp = getTimestampSeconds("(#t=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)");
return timestamp == -2 ? 0 : timestamp;
}
@Override
@ -168,7 +169,7 @@ public class SoundcloudStreamExtractor extends StreamExtractor {
try {
final JsonArray transcodings = track.getObject("media")
.getArray("transcodings");
.getArray("transcodings");
if (!isNullOrEmpty(transcodings)) {
// Get information about what stream formats are available
extractAudioStreams(transcodings, audioStreams);

Wyświetl plik

@ -1,5 +1,7 @@
package org.schabi.newpipe.extractor.services.soundcloud.linkHandler;
import java.util.regex.Pattern;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory;
import org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper;
@ -9,11 +11,18 @@ import org.schabi.newpipe.extractor.utils.Utils;
public final class SoundcloudStreamLinkHandlerFactory extends LinkHandlerFactory {
private static final SoundcloudStreamLinkHandlerFactory INSTANCE
= new SoundcloudStreamLinkHandlerFactory();
private static final String URL_PATTERN = "^https?://(www\\.|m\\.|on\\.)?"
+ "soundcloud.com/[0-9a-z_-]+"
+ "/(?!(tracks|albums|sets|reposts|followers|following)/?$)[0-9a-z_-]+/?([#?].*)?$";
private static final String API_URL_PATTERN = "^https?://api-v2\\.soundcloud.com"
+ "/(tracks|albums|sets|reposts|followers|following)/([0-9a-z_-]+)/";
private static final Pattern URL_PATTERN = Pattern.compile(
"^https?://(?:www\\.|m\\.|on\\.)?"
+ "soundcloud.com/[0-9a-z_-]+"
+ "/(?!(?:tracks|albums|sets|reposts|followers|following)/?$)[0-9a-z_-]+/?(?:[#?].*)?$"
);
private static final Pattern API_URL_PATTERN = Pattern.compile(
"^https?://api-v2\\.soundcloud.com"
+ "/(tracks|albums|sets|reposts|followers|following)/([0-9a-z_-]+)/"
);
private SoundcloudStreamLinkHandlerFactory() {
}

Wyświetl plik

@ -7,7 +7,6 @@ import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.stream.StreamInfoItemExtractor;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.utils.ExtractorHelper;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;

Wyświetl plik

@ -35,6 +35,7 @@ import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory;
import org.schabi.newpipe.extractor.stream.StreamInfoItemExtractor;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.ContentAvailability;
import org.schabi.newpipe.extractor.utils.JsonUtils;
import org.schabi.newpipe.extractor.utils.Parser;
import org.schabi.newpipe.extractor.utils.Utils;
@ -472,4 +473,33 @@ public class YoutubeStreamInfoItemExtractor implements StreamInfoItemExtractor {
throw new ParsingException("Could not determine if this is short-form content", e);
}
}
private boolean isMembersOnly() throws ParsingException {
return videoInfo.getArray("badges")
.stream()
.filter(JsonObject.class::isInstance)
.map(JsonObject.class::cast)
.map(badge -> badge.getObject("metadataBadgeRenderer").getString("style"))
.anyMatch("BADGE_STYLE_TYPE_MEMBERS_ONLY"::equals);
}
@Nonnull
@Override
public ContentAvailability getContentAvailability() throws ParsingException {
if (isPremiere()) {
return ContentAvailability.UPCOMING;
}
if (isMembersOnly()) {
return ContentAvailability.MEMBERSHIP;
}
if (isPremium()) {
return ContentAvailability.PAID;
}
return ContentAvailability.AVAILABLE;
}
}

Wyświetl plik

@ -17,6 +17,11 @@ import org.schabi.newpipe.extractor.stream.StreamType;
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;
@ -30,20 +35,24 @@ import javax.annotation.Nullable;
* 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;
@ -137,7 +146,9 @@ public class YoutubeStreamInfoItemLockupExtractor implements StreamInfoItemExtra
@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;
}
@ -237,20 +248,27 @@ public class YoutubeStreamInfoItemLockupExtractor implements StreamInfoItemExtra
@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);
// 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
@ -265,11 +283,32 @@ public class YoutubeStreamInfoItemLockupExtractor implements StreamInfoItemExtra
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 date
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 -.-
@ -357,6 +396,20 @@ public class YoutubeStreamInfoItemLockupExtractor implements StreamInfoItemExtra
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);
}
abstract static class ChannelImageViewModel {
protected JsonObject viewModel;

Wyświetl plik

@ -88,7 +88,8 @@ public final class AudioStream extends Stream {
}
/**
* Set the identifier of the {@link AudioStream}.
* Set the identifier of the {@link AudioStream} which uniquely identifies the stream,
* e.g. for YouTube this would be the itag
*
* <p>
* It <b>must not be null</b> and should be non empty.
@ -108,14 +109,14 @@ public final class AudioStream extends Stream {
}
/**
* Set the content of the {@link AudioStream}.
*
* Set the content or the URL of the {@link AudioStream}, depending on whether isUrl is
* true
* <p>
* It must not be null, and should be non empty.
* </p>
*
* @param content the content of the {@link AudioStream}
* @param isUrl whether the content is a URL
* @param isUrl whether content is the URL or the actual content of e.g. a DASH manifest
* @return this {@link Builder} instance
*/
public Builder setContent(@Nonnull final String content,
@ -126,7 +127,7 @@ public final class AudioStream extends Stream {
}
/**
* Set the {@link MediaFormat} used by the {@link AudioStream}.
* Set the {@link MediaFormat} used by the {@link AudioStream}, which can be null
*
* <p>
* It should be one of the audio {@link MediaFormat}s ({@link MediaFormat#M4A M4A},
@ -278,16 +279,22 @@ public final class AudioStream extends Stream {
* Build an {@link AudioStream} using the builder's current values.
*
* <p>
* The identifier and the content (and so the {@code isUrl} boolean) properties must have
* The identifier and the content (and thus {@code isUrl}) properties must have
* been set.
* </p>
*
* @return a new {@link AudioStream} using the builder's current values
* @throws IllegalStateException if {@code id}, {@code content} (and so {@code isUrl}) or
* @throws IllegalStateException if {@code id}, {@code content} (and thus {@code isUrl}) or
* {@code deliveryMethod} have been not set, or have been set as {@code null}
*/
@Nonnull
public AudioStream build() {
validateBuild();
return new AudioStream(this);
}
void validateBuild() {
if (id == null) {
throw new IllegalStateException(
"The identifier of the audio stream has been not set or is null. If you "
@ -305,64 +312,39 @@ public final class AudioStream extends Stream {
"The delivery method of the audio stream has been set as null, which is "
+ "not allowed. Pass a valid one instead with setDeliveryMethod.");
}
return new AudioStream(id, content, isUrl, mediaFormat, deliveryMethod, averageBitrate,
manifestUrl, audioTrackId, audioTrackName, audioLocale, audioTrackType,
itagItem);
}
}
/**
* Create a new audio stream.
* Create a new audio stream using the given {@link Builder}.
*
* @param id the identifier which uniquely identifies the stream, e.g. for YouTube
* this would be the itag
* @param content the content or the URL of the stream, depending on whether isUrl is
* true
* @param isUrl whether content is the URL or the actual content of e.g. a DASH
* manifest
* @param format the {@link MediaFormat} used by the stream, which can be null
* @param deliveryMethod the {@link DeliveryMethod} of the stream
* @param averageBitrate the average bitrate of the stream (which can be unknown, see
* {@link #UNKNOWN_BITRATE})
* @param audioTrackId the id of the audio track
* @param audioTrackName the name of the audio track
* @param audioLocale the {@link Locale} of the audio stream, representing its language
* @param itagItem the {@link ItagItem} corresponding to the stream, which cannot be null
* @param manifestUrl the URL of the manifest this stream comes from (if applicable,
* otherwise null)
* @param builder The {@link Builder} to use to create the audio stream
*/
@SuppressWarnings("checkstyle:ParameterNumber")
private AudioStream(@Nonnull final String id,
@Nonnull final String content,
final boolean isUrl,
@Nullable final MediaFormat format,
@Nonnull final DeliveryMethod deliveryMethod,
final int averageBitrate,
@Nullable final String manifestUrl,
@Nullable final String audioTrackId,
@Nullable final String audioTrackName,
@Nullable final Locale audioLocale,
@Nullable final AudioTrackType audioTrackType,
@Nullable final ItagItem itagItem) {
super(id, content, isUrl, format, deliveryMethod, manifestUrl);
if (itagItem != null) {
this.itagItem = itagItem;
this.itag = itagItem.id;
this.quality = itagItem.getQuality();
this.bitrate = itagItem.getBitrate();
this.initStart = itagItem.getInitStart();
this.initEnd = itagItem.getInitEnd();
this.indexStart = itagItem.getIndexStart();
this.indexEnd = itagItem.getIndexEnd();
this.codec = itagItem.getCodec();
AudioStream(final Builder builder) {
super(builder.id,
builder.content,
builder.isUrl,
builder.mediaFormat,
builder.deliveryMethod,
builder.manifestUrl);
if (builder.itagItem != null) {
this.itagItem = builder.itagItem;
this.itag = builder.itagItem.id;
this.quality = builder.itagItem.getQuality();
this.bitrate = builder.itagItem.getBitrate();
this.initStart = builder.itagItem.getInitStart();
this.initEnd = builder.itagItem.getInitEnd();
this.indexStart = builder.itagItem.getIndexStart();
this.indexEnd = builder.itagItem.getIndexEnd();
this.codec = builder.itagItem.getCodec();
}
this.averageBitrate = averageBitrate;
this.audioTrackId = audioTrackId;
this.audioTrackName = audioTrackName;
this.audioLocale = audioLocale;
this.audioTrackType = audioTrackType;
this.averageBitrate = builder.averageBitrate;
this.audioTrackId = builder.audioTrackId;
this.audioTrackName = builder.audioTrackName;
this.audioLocale = builder.audioLocale;
this.audioTrackType = builder.audioTrackType;
}
/**

Wyświetl plik

@ -0,0 +1,48 @@
/*
* Created by FineFindus on 10.07.25.
*
* Copyright (C) 2025 FineFindus <FineFindus@proton.me>
* ContentAvailability.java is part of NewPipe Extractor.
*
* NewPipe Extractor is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe Extractor is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe Extractor. If not, see <https://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.extractor.stream;
/**
* Availability of the stream.
*
* <p>A stream may be available to all, restricted to a certain user group or time.</p>
*/
public enum ContentAvailability {
/**
* The availability of the stream is unknown (but clients may assume that it's available).
*/
UNKNOWN,
/**
* The stream is available to all users.
*/
AVAILABLE,
/**
* The stream is available to users with a membership.
*/
MEMBERSHIP,
/**
* The stream is behind a paywall.
*/
PAID,
/**
* The stream is only available in the future.
*/
UPCOMING,
}

Wyświetl plik

@ -17,11 +17,7 @@ public class Description implements Serializable {
public Description(@Nullable final String content, final int type) {
this.type = type;
if (content == null) {
this.content = "";
} else {
this.content = content;
}
this.content = Objects.requireNonNullElse(content, "");
}
public String getContent() {

Wyświetl plik

@ -68,7 +68,7 @@ public abstract class Stream implements Serializable {
* @param streamList the list of {@link Stream}s which will be compared
* @return whether the list already contains one stream with equals stats
*/
public static boolean containSimilarStream(final Stream stream,
public static boolean containSimilarStream(@Nonnull final Stream stream,
final List<? extends Stream> streamList) {
if (isNullOrEmpty(streamList)) {
return false;
@ -98,11 +98,9 @@ public abstract class Stream implements Serializable {
* @return whether the stream have the same stats or not, based on the criteria above
*/
public boolean equalStats(@Nullable final Stream other) {
if (other == null || mediaFormat == null || other.mediaFormat == null) {
return false;
}
return mediaFormat.id == other.mediaFormat.id && deliveryMethod == other.deliveryMethod
&& isUrl == other.isUrl;
return other != null && mediaFormat != null && other.mediaFormat != null
&& mediaFormat.id == other.mediaFormat.id && deliveryMethod == other.deliveryMethod
&& isUrl == other.isUrl;
}
/**
@ -137,6 +135,7 @@ public abstract class Stream implements Serializable {
*
* @return the content or URL
*/
@Nonnull
public String getContent() {
return content;
}

Wyświetl plik

@ -582,6 +582,17 @@ public abstract class StreamExtractor extends Extractor {
return false;
}
/**
* Get the availability of the stream.
*
* @return The stream's availability
* @throws ParsingException if there is an error in the extraction
*/
@Nonnull
public ContentAvailability getContentAvailability() throws ParsingException {
return ContentAvailability.UNKNOWN;
}
public enum Privacy {
PUBLIC,
UNLISTED,

Wyświetl plik

@ -330,6 +330,11 @@ public class StreamInfo extends Info {
} catch (final Exception e) {
streamInfo.addError(e);
}
try {
streamInfo.setContentAvailability(extractor.getContentAvailability());
} catch (final Exception e) {
streamInfo.addError(e);
}
streamInfo.setRelatedItems(ExtractorHelper.getRelatedItemsOrLogError(streamInfo,
extractor));
@ -381,6 +386,8 @@ public class StreamInfo extends Info {
private List<StreamSegment> streamSegments = List.of();
private List<MetaInfo> metaInfo = List.of();
private boolean shortFormContent = false;
@Nonnull
private ContentAvailability contentAvailability = ContentAvailability.AVAILABLE;
/**
* Preview frames, e.g. for the storyboard / seekbar thumbnail preview
@ -727,4 +734,13 @@ public class StreamInfo extends Info {
public void setShortFormContent(final boolean isShortFormContent) {
this.shortFormContent = isShortFormContent;
}
@Nonnull
public ContentAvailability getContentAvailability() {
return contentAvailability;
}
public void setContentAvailability(@Nonnull final ContentAvailability availability) {
this.contentAvailability = availability;
}
}

Wyświetl plik

@ -47,6 +47,8 @@ public class StreamInfoItem extends InfoItem {
private List<Image> uploaderAvatars = List.of();
private boolean uploaderVerified = false;
private boolean shortFormContent = false;
@Nonnull
private ContentAvailability contentAvailability = ContentAvailability.AVAILABLE;
public StreamInfoItem(final int serviceId,
final String url,
@ -143,6 +145,23 @@ public class StreamInfoItem extends InfoItem {
this.shortFormContent = shortFormContent;
}
/**
* Gets the availability of the content.
*
* @return The availability of the stream.
*/
@Nonnull
public ContentAvailability getContentAvailability() {
return contentAvailability;
}
/**
* Sets the availability of the Stream.
*/
public void setContentAvailability(@Nonnull final ContentAvailability availability) {
this.contentAvailability = availability;
}
@Override
public String toString() {
return "StreamInfoItem{"

Wyświetl plik

@ -147,4 +147,19 @@ public interface StreamInfoItemExtractor extends InfoItemExtractor {
default boolean isShortFormContent() throws ParsingException {
return false;
}
/**
* Get the availability of the stream.
*
* <p>
* The availability may not reflect the actual availability when requesting the stream.
* </p>
*
* @return The stream's availability
* @throws ParsingException if there is an error in the extraction
*/
@Nonnull
default ContentAvailability getContentAvailability() throws ParsingException {
return ContentAvailability.UNKNOWN;
}
}

Wyświetl plik

@ -103,6 +103,11 @@ public class StreamInfoItemsCollector
} catch (final Exception e) {
addError(e);
}
try {
resultItem.setContentAvailability(extractor.getContentAvailability());
} catch (final Exception e) {
addError(e);
}
return resultItem;
}

Wyświetl plik

@ -14,7 +14,7 @@ import java.util.Objects;
* <p>
* This class is used to construct {@link org.schabi.newpipe.extractor.Image Image}
* instances from a single base URL/path, in order to get all or most image resolutions provided,
* depending of the service and the resolutions provided.
* depending on the service and the resolutions provided.
* </p>
*
* <p>

Wyświetl plik

@ -44,50 +44,113 @@ public final class Parser {
}
}
@Nonnull
public static Matcher matchOrThrow(@Nonnull final Pattern pattern,
final String input) throws RegexException {
final Matcher matcher = pattern.matcher(input);
if (matcher.find()) {
return matcher;
} else {
String errorMessage = "Failed to find pattern \"" + pattern.pattern() + "\"";
if (input.length() <= 1024) {
errorMessage += " inside of \"" + input + "\"";
}
throw new RegexException(errorMessage);
}
}
/**
* Matches group 1 of the given pattern against the input
* and returns the matched group
*
* @param pattern The regex pattern to match.
* @param input The input string to match against.
* @return The matching group as a string.
* @throws RegexException If the pattern does not match the input or if the group is not found.
*/
@Nonnull
public static String matchGroup1(final String pattern, final String input)
throws RegexException {
return matchGroup(pattern, input, 1);
}
public static String matchGroup1(final Pattern pattern,
final String input) throws RegexException {
/**
* Matches group 1 of the given pattern against the input
* and returns the matched group
*
* @param pattern The regex pattern to match.
* @param input The input string to match against.
* @return The matching group as a string.
* @throws RegexException If the pattern does not match the input or if the group is not found.
*/
@Nonnull
public static String matchGroup1(final Pattern pattern, final String input)
throws RegexException {
return matchGroup(pattern, input, 1);
}
public static String matchGroup(final String pattern,
final String input,
final int group) throws RegexException {
/**
* Matches the specified group of the given pattern against the input,
* and returns the matched group
*
* @param pattern The regex pattern to match.
* @param input The input string to match against.
* @param group The group number to retrieve (1-based index).
* @return The matching group as a string.
* @throws RegexException If the pattern does not match the input or if the group is not found.
*/
@Nonnull
public static String matchGroup(final String pattern, final String input, final int group)
throws RegexException {
return matchGroup(Pattern.compile(pattern), input, group);
}
public static String matchGroup(@Nonnull final Pattern pat,
/**
* Matches the specified group of the given pattern against the input,
* and returns the matched group
*
* @param pattern The regex pattern to match.
* @param input The input string to match against.
* @param group The group number to retrieve (1-based index).
* @return The matching group as a string.
* @throws RegexException If the pattern does not match the input or if the group is not found.
*/
@Nonnull
public static String matchGroup(@Nonnull final Pattern pattern,
final String input,
final int group) throws RegexException {
final Matcher matcher = pat.matcher(input);
final boolean foundMatch = matcher.find();
if (foundMatch) {
return matcher.group(group);
} else {
// only pass input to exception message when it is not too long
if (input.length() > 1024) {
throw new RegexException("Failed to find pattern \"" + pat.pattern() + "\"");
} else {
throw new RegexException("Failed to find pattern \"" + pat.pattern()
+ "\" inside of \"" + input + "\"");
}
}
final int group)
throws RegexException {
return matchOrThrow(pattern, input).group(group);
}
/**
* Matches multiple patterns against the input string and
* returns the first successful matcher
*
* @param patterns The array of regex patterns to match.
* @param input The input string to match against.
* @return A {@code Matcher} for the first successful match.
* @throws RegexException If no patterns match the input or if {@code patterns} is empty.
*/
public static String matchGroup1MultiplePatterns(final Pattern[] patterns, final String input)
throws RegexException {
return matchMultiplePatterns(patterns, input).group(1);
}
/**
* Matches multiple patterns against the input string and
* returns the first successful matcher
*
* @param patterns The array of regex patterns to match.
* @param input The input string to match against.
* @return A {@code Matcher} for the first successful match.
* @throws RegexException If no patterns match the input or if {@code patterns} is empty.
*/
public static Matcher matchMultiplePatterns(final Pattern[] patterns, final String input)
throws RegexException {
Parser.RegexException exception = null;
for (final Pattern pattern : patterns) {
final Matcher matcher = pattern.matcher(input);
RegexException exception = null;
for (final var pattern : patterns) {
final var matcher = pattern.matcher(input);
if (matcher.find()) {
return matcher;
} else if (exception == null) {
@ -107,14 +170,11 @@ public final class Parser {
}
public static boolean isMatch(final String pattern, final String input) {
final Pattern pat = Pattern.compile(pattern);
final Matcher mat = pat.matcher(input);
return mat.find();
return isMatch(Pattern.compile(pattern), input);
}
public static boolean isMatch(@Nonnull final Pattern pattern, final String input) {
final Matcher mat = pattern.matcher(input);
return mat.find();
return pattern.matcher(input).find();
}
@Nonnull

Wyświetl plik

@ -110,12 +110,22 @@ public final class Utils {
* @param url the url to be tested
*/
public static void checkUrl(final String pattern, final String url) throws ParsingException {
checkUrl(Pattern.compile(pattern), url);
}
/**
* Check if the url matches the pattern.
*
* @param pattern the pattern that will be used to check the url
* @param url the url to be tested
*/
public static void checkUrl(final Pattern pattern, final String url) throws ParsingException {
if (isNullOrEmpty(url)) {
throw new IllegalArgumentException("Url can't be null or empty");
}
if (!Parser.isMatch(pattern, url.toLowerCase())) {
throw new ParsingException("Url don't match the pattern");
throw new ParsingException("Url doesn't match the pattern");
}
}

Wyświetl plik

@ -4,6 +4,9 @@ import org.schabi.newpipe.extractor.downloader.Downloader;
import java.util.Locale;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
public class DownloaderFactory {
private static final DownloaderType DEFAULT_DOWNLOADER = DownloaderType.REAL;
@ -36,20 +39,31 @@ public class DownloaderFactory {
}
public static Downloader getDownloader(final Class<?> clazz) {
return getDownloader(clazz, null);
return getDownloader(getMockPath(clazz, null));
}
public static Downloader getDownloader(final Class<?> clazz, final String specificUseCase) {
public static Downloader getDownloader(final Class<?> clazz,
@Nullable final String specificUseCase) {
return getDownloader(getMockPath(clazz, 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('.', '/');
}
/**

Wyświetl plik

@ -7,7 +7,6 @@ import org.schabi.newpipe.extractor.downloader.Response;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@ -35,7 +34,7 @@ public final class DownloaderTestImpl extends Downloader {
// Required for certain services
// For example Bandcamp otherwise fails on Windows with Java 17+
// as their Fastly-CDN returns 403
.connectionSpecs(Arrays.asList(ConnectionSpec.RESTRICTED_TLS))
.connectionSpecs(List.of(ConnectionSpec.RESTRICTED_TLS))
.build());
}

Wyświetl plik

@ -9,4 +9,6 @@ public interface BaseSearchExtractorTest extends BaseListExtractorTest {
void testSearchSuggestion() throws Exception;
@Test
void testSearchCorrected() throws Exception;
@Test
void testMetaInfo() throws Exception;
}

Wyświetl plik

@ -68,4 +68,10 @@ public interface BaseStreamExtractorTest extends BaseExtractorTest {
void testTags() throws Exception;
@Test
void testSupportInfo() throws Exception;
@Test
void testStreamSegmentsCount() throws Exception;
@Test
void testMetaInfo() throws Exception;
@Test
void testContentAvailability() throws Exception;
}

Wyświetl plik

@ -50,6 +50,7 @@ public abstract class DefaultSearchExtractorTest extends DefaultListExtractorTes
}
@Test
@Override
public void testSearchCorrected() throws Exception {
assertEquals(isCorrectedSearch(), extractor().isCorrectedSearch());
}
@ -58,6 +59,7 @@ public abstract class DefaultSearchExtractorTest extends DefaultListExtractorTes
* @see DefaultStreamExtractorTest#testMetaInfo()
*/
@Test
@Override
public void testMetaInfo() throws Exception {
final List<MetaInfo> metaInfoList = extractor().getMetaInfo();
final List<MetaInfo> expectedMetaInfoList = expectedMetaInfo();

Wyświetl plik

@ -6,6 +6,7 @@ import org.schabi.newpipe.extractor.InfoItemsCollector;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.MetaInfo;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.ContentAvailability;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.Frameset;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
@ -77,6 +78,7 @@ public abstract class DefaultStreamExtractorTest extends DefaultExtractorTest<St
public String expectedSupportInfo() { return ""; } // default: no support info available
public int expectedStreamSegmentsCount() { return -1; } // return 0 or greater to test (default is -1 to ignore)
public List<MetaInfo> expectedMetaInfo() throws MalformedURLException { return Collections.emptyList(); } // default: no metadata info available
public ContentAvailability expectedContentAvailability() { return ContentAvailability.UNKNOWN; } // default: unknown content availability
@Test
@Override
@ -429,6 +431,7 @@ public abstract class DefaultStreamExtractorTest extends DefaultExtractorTest<St
}
@Test
@Override
public void testStreamSegmentsCount() throws Exception {
if (expectedStreamSegmentsCount() >= 0) {
assertEquals(expectedStreamSegmentsCount(), extractor().getStreamSegments().size());
@ -439,6 +442,7 @@ public abstract class DefaultStreamExtractorTest extends DefaultExtractorTest<St
* @see DefaultSearchExtractorTest#testMetaInfo()
*/
@Test
@Override
public void testMetaInfo() throws Exception {
final List<MetaInfo> metaInfoList = extractor().getMetaInfo();
final List<MetaInfo> expectedMetaInfoList = expectedMetaInfo();
@ -463,6 +467,11 @@ public abstract class DefaultStreamExtractorTest extends DefaultExtractorTest<St
assertTrue(urls.contains(expectedUrl));
}
}
}
@Test
@Override
public void testContentAvailability() throws Exception {
assertEquals(expectedContentAvailability(), extractor().getContentAvailability());
}
}

Wyświetl plik

@ -135,7 +135,6 @@ public class SoundcloudStreamExtractorTest {
@Override public long expectedDislikeCountAtLeast() { return -1; }
@Override public boolean expectedHasAudioStreams() { return false; }
@Override public boolean expectedHasVideoStreams() { return false; }
@Override public boolean expectedHasRelatedItems() { return true; }
@Override public boolean expectedHasSubtitles() { return false; }
@Override public boolean expectedHasFrames() { return false; }
@Override public int expectedStreamSegmentsCount() { return 0; }

Wyświetl plik

@ -25,8 +25,13 @@ import org.schabi.newpipe.extractor.services.DefaultSimpleExtractorTest;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubePlaylistExtractor;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.ContentAvailability;
import org.schabi.newpipe.extractor.utils.Utils;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
/**
* Test for {@link YoutubePlaylistExtractor}
*/
@ -501,4 +506,28 @@ public class YoutubePlaylistExtractorTest {
assertFalse(page.hasNextPage(), "More items available when it shouldn't");
}
}
public static class MembersOnlyTests implements InitYoutubeTest {
@Test
void testOnlyMembersOnlyVideos() throws Exception {
final YoutubePlaylistExtractor extractor = (YoutubePlaylistExtractor) YouTube
.getPlaylistExtractor(
// auto-generated playlist with only membersOnly videos
"https://www.youtube.com/playlist?list=UUMOQuLXlFNAeDJMSmuzHU5axw");
extractor.fetchPage();
final List<StreamInfoItem> allItems = extractor.getInitialPage().getItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toUnmodifiableList());
final List<StreamInfoItem> membershipVideos = allItems.stream()
.filter(item -> item.getContentAvailability() != ContentAvailability.MEMBERSHIP)
.collect(Collectors.toUnmodifiableList());
assertFalse(allItems.isEmpty());
assertTrue(membershipVideos.isEmpty());
}
}
}

Wyświetl plik

@ -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 {
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())
);
}
}

Wyświetl plik

@ -43,7 +43,7 @@ public class YoutubeSearchQHTest {
}
@Test
public void testWithContentfilter() throws Exception {
public void testWithContentFilter() throws Exception {
assertEquals("https://www.youtube.com/results?search_query=asdf&sp=EgIQAfABAQ%253D%253D", YouTube.getSearchQHFactory()
.fromQuery("asdf", List.of(VIDEOS), "").getUrl());
assertEquals("https://www.youtube.com/results?search_query=asdf&sp=EgIQAvABAQ%253D%253D", YouTube.getSearchQHFactory()

Wyświetl plik

@ -0,0 +1,96 @@
{
"request": {
"httpMethod": "GET",
"url": "https://www.youtube.com/sw.js",
"headers": {
"Origin": [
"https://www.youtube.com"
],
"Referer": [
"https://www.youtube.com"
],
"Accept-Language": [
"en-GB, en;q\u003d0.9"
]
},
"localization": {
"languageCode": "en",
"countryCode": "GB"
}
},
"response": {
"responseCode": 200,
"responseMessage": "",
"responseHeaders": {
"access-control-allow-credentials": [
"true"
],
"access-control-allow-origin": [
"https://www.youtube.com"
],
"alt-svc": [
"h3\u003d\":443\"; ma\u003d2592000,h3-29\u003d\":443\"; ma\u003d2592000"
],
"cache-control": [
"private, max-age\u003d0"
],
"content-security-policy": [
"require-trusted-types-for \u0027script\u0027"
],
"content-security-policy-report-only": [
"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;report-uri /cspreport/allowlist"
],
"content-type": [
"text/javascript; charset\u003dutf-8"
],
"cross-origin-opener-policy": [
"same-origin; report-to\u003d\"youtube_main\""
],
"date": [
"Sat, 26 Jul 2025 08:56:33 GMT"
],
"document-policy": [
"include-js-call-stacks-in-crash-reports"
],
"expires": [
"Sat, 26 Jul 2025 08:56:33 GMT"
],
"origin-trial": [
"AmhMBR6zCLzDDxpW+HfpP67BqwIknWnyMOXOQGfzYswFmJe+fgaI6XZgAzcxOrzNtP7hEDsOo1jdjFnVr2IdxQ4AAAB4eyJvcmlnaW4iOiJodHRwczovL3lvdXR1YmUuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJWaWV3WFJlcXVlc3RlZFdpdGhEZXByZWNhdGlvbiIsImV4cGlyeSI6MTc1ODA2NzE5OSwiaXNTdWJkb21haW4iOnRydWV9"
],
"p3p": [
"CP\u003d\"This is not a P3P policy! See http://support.google.com/accounts/answer/151657?hl\u003den-GB for more info.\""
],
"permissions-policy": [
"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*"
],
"report-to": [
"{\"group\":\"youtube_main\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/youtube_main\"}]}"
],
"reporting-endpoints": [
"default\u003d\"/web-reports?context\u003deJwNzltIk3EYBnD_PmS6b-77_q9QlEKiQpEpOpuSZUGhmWgaFIlazsPmedrc5jwRlVqCSGczrfQmJVC0C8vMIgiKoALNC09FRYQkWheZtMTei9_d8zzvq3u8YSq4TNjWK0RYsk0MNZ4ROdvtwsttF4nvq0XaJodoS3KIlfsOMTDmEIvBLpEd4hK3plzi3qxLlH9yC89Xt7irZXpbt2Z6u5k5KNN7LEsHq1WH3506_JrToXZZh-xwBWqkgskUBYtHFYz0KRh7qGD-uwJnth4_zXqYnHpUruixkOGP8U5_TEz6Y9dxA_a1GdA_akDGKwOusKJ_BiytGdC4R0VsgorBwypcFhX95SoUl4rbrSqiO1QkMd9eFaF9Kja_UTEepsHXrEHHTDc0THRp6H6rYe6Phr1_NZwKkHDulHi6WyLPJNHOYuMlmg5IdJ-QOJIrMc_SiiSGSyQKSiV6ayUaGjjDos9yr5n7bRLv2MF2ieVrEsbrEvtvSjx_IDEzIBE4KJE-IrH2RGL9mYTfa4mADxLT0xJBCxJxyxJfViWKPXx_XeKyF0ERBIMfoWILYTSQcGsboYt9CyGkhxLGdxBiYggmttFEqI8nrLG-Q4S6JMIjNsPCkwnDbD2FkJVKyGGnmZnls0JmZcWslJUzG6tiduZgLuZmdayB3UkneB0jtLAfJwmebMLnHO7kEoqKeIN5mLua4OckJNYQZt38az2h7Bxh9TwhpIkzTDQTUlsISyzhImGSXbhE8GkloIOQzD72Ekjv-2Lo6ksfrWdiqQchEbWVTocz3xJZY8mPsNorbY4Ii60wosBe4igpyCvPNUYZTVFxxpjI6Kjcqqj_fHfPsg\""
],
"server": [
"ESF"
],
"set-cookie": [
"YSC\u003dz-zDeWiDX34; Domain\u003d.youtube.com; Path\u003d/; Secure; HttpOnly; SameSite\u003dnone",
"VISITOR_INFO1_LIVE\u003d; Domain\u003d.youtube.com; Expires\u003dSun, 30-Oct-2022 08:56:33 GMT; Path\u003d/; Secure; HttpOnly; SameSite\u003dnone"
],
"strict-transport-security": [
"max-age\u003d31536000"
],
"x-content-type-options": [
"nosniff"
],
"x-frame-options": [
"SAMEORIGIN"
],
"x-xss-protection": [
"0"
]
},
"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 ",
"latestUrl": "https://www.youtube.com/sw.js"
}
}

Wyświetl plik

@ -0,0 +1,133 @@
{
"contentImage": {
"thumbnailViewModel": {
"image": {
"sources": [
{
"url": "https://i.ytimg.com/vi/0_-Nh-nOhLQ/hqdefault.jpg?sqp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&rs=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"width": 168,
"height": 94
},
{
"url": "https://i.ytimg.com/vi/0_-Nh-nOhLQ/hqdefault.jpg?sqp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&rs=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"width": 336,
"height": 188
}
]
},
"overlays": [
{
"thumbnailOverlayBadgeViewModel": {
"thumbnailBadges": [
{
"thumbnailBadgeViewModel": {
"text": "Upcoming",
"badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
"animationActivationTargetId": "VIDEO_ID",
"animationActivationEntityKey": "REDACTED",
"lottieData": {
"url": "https://www.gstatic.com/youtube/img/lottie/audio_indicator/audio_indicator_v2.json",
"settings": {
"loop": true,
"autoplay": true
}
},
"animatedText": "Now playing",
"animationActivationEntitySelectorType": "THUMBNAIL_BADGE_ANIMATION_ENTITY_SELECTOR_TYPE_PLAYER_STATE"
}
}
],
"position": "THUMBNAIL_OVERLAY_BADGE_POSITION_BOTTOM_END"
}
},
{
"thumbnailHoverOverlayToggleActionsViewModel": {
"buttons": []
}
}
]
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {
"content": "VIDEO_TITLE"
},
"image": {
"decoratedAvatarViewModel": {
"avatar": {
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"width": 68,
"height": 68
}
]
},
"avatarImageSize": "AVATAR_SIZE_M"
}
},
"a11yLabel": "Go to channel",
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/@deep_flow_music_ua",
"webPageType": "WEB_PAGE_TYPE_CHANNEL",
"rootVe": 3611,
"apiUrl": "/youtubei/v1/browse"
}
},
"browseEndpoint": {
"browseId": "UCD_on7-zu7Zuc3zissQvrgw",
"canonicalBaseUrl": "/@deep_flow_music_ua"
}
}
}
}
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [
{
"text": {
"content": "VIDEO_CHANNEL_NAME",
"styleRuns": [],
"attachmentRuns": []
}
}
]
},
{
"metadataParts": [
{
"text": {
"content": "56 waiting"
}
},
{
"text": {
"content": "Premieres 14/08/2025, 13:00"
}
}
]
}
],
"delimiter": " • "
}
},
"menuButton": {}
}
},
"contentId": "VIDEO_ID",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO"
}

Wyświetl plik

@ -0,0 +1,586 @@
{
"videoId": "M_8QNw_JM4I",
"thumbnail": {
"thumbnails": [
{
"url": "https://i.ytimg.com/vi/M_8QNw_JM4I/hq720.jpg?sqp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&rs=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"width": 360,
"height": 202
},
{
"url": "https://i.ytimg.com/vi/M_8QNw_JM4I/hq720.jpg?sqp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&rs=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"width": 720,
"height": 404
}
]
},
"title": {
"runs": [
{
"text": "This video will premiere in 6 months."
}
],
"accessibility": {
"accessibilityData": {
"label": "This video will premiere in 6 months. 33 seconds"
}
}
},
"descriptionSnippet": {
"runs": [
{
"text": "Patience is key… MERCH SHOP : https://www.bluntbrosproductions.com Follow us on Instagram for early updates: ..."
}
]
},
"longBylineText": {
"runs": [
{
"text": "Blunt Brothers Productions",
"navigationEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/@BluntBrothersProductions",
"webPageType": "WEB_PAGE_TYPE_CHANNEL",
"rootVe": 3611,
"apiUrl": "/youtubei/v1/browse"
}
},
"browseEndpoint": {
"browseId": "UCUPrbbdnot-aPgNM65svgOg",
"canonicalBaseUrl": "/@BluntBrothersProductions"
}
}
}
]
},
"lengthText": {
"accessibility": {
"accessibilityData": {
"label": "33 seconds"
}
},
"simpleText": "0:33"
},
"navigationEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/watch?v=M_8QNw_JM4I&pp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"webPageType": "WEB_PAGE_TYPE_WATCH",
"rootVe": 3832
}
},
"watchEndpoint": {
"videoId": "M_8QNw_JM4I",
"params": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"playerParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"watchEndpointSupportedOnesieConfig": {
"html5PlaybackOnesieConfig": {
"commonConfig": {
"url": "https://rr4---sn-n0ogpnx-1gie.googlevideo.com/initplayback?source=youtube&oeis=1&c=WEB&oad=3200&ovd=3200&oaad=11000&oavd=11000&ocs=700&oewis=1&oputc=1&ofpcc=1&msp=1&odepv=1&oreouc=1&id=33ff10370fc93382&ip=1.1.1.1&initcwndbps=3320000&mt=1759654189&oweuc="
}
}
}
}
},
"ownerBadges": [
{
"metadataBadgeRenderer": {
"icon": {
"iconType": "CHECK_CIRCLE_THICK"
},
"style": "BADGE_STYLE_TYPE_VERIFIED",
"tooltip": "Verified",
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"accessibilityData": {
"label": "Verified"
}
}
}
],
"ownerText": {
"runs": [
{
"text": "Blunt Brothers Productions",
"navigationEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/@BluntBrothersProductions",
"webPageType": "WEB_PAGE_TYPE_CHANNEL",
"rootVe": 3611,
"apiUrl": "/youtubei/v1/browse"
}
},
"browseEndpoint": {
"browseId": "UCUPrbbdnot-aPgNM65svgOg",
"canonicalBaseUrl": "/@BluntBrothersProductions"
}
}
}
]
},
"upcomingEventData": {
"startTime": "1773580320",
"isReminderSet": false,
"upcomingEventText": {
"runs": [
{
"text": "Premieres "
},
{
"text": "DATE_PLACEHOLDER"
}
]
}
},
"shortBylineText": {
"runs": [
{
"text": "Blunt Brothers Productions",
"navigationEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/@BluntBrothersProductions",
"webPageType": "WEB_PAGE_TYPE_CHANNEL",
"rootVe": 3611,
"apiUrl": "/youtubei/v1/browse"
}
},
"browseEndpoint": {
"browseId": "UCUPrbbdnot-aPgNM65svgOg",
"canonicalBaseUrl": "/@BluntBrothersProductions"
}
}
}
]
},
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"showActionMenu": false,
"shortViewCountText": {
"runs": [
{
"text": "1"
},
{
"text": " waiting"
}
],
"accessibility": {
"accessibilityData": {
"label": "1 waiting"
}
}
},
"menu": {
"menuRenderer": {
"items": [
{
"menuServiceItemRenderer": {
"text": {
"runs": [
{
"text": "Add to queue"
}
]
},
"icon": {
"iconType": "ADD_TO_QUEUE_TAIL"
},
"serviceEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true
}
},
"signalServiceEndpoint": {
"signal": "CLIENT_SIGNAL",
"actions": [
{
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"addToPlaylistCommand": {
"openMiniplayer": true,
"videoId": "M_8QNw_JM4I",
"listType": "PLAYLIST_EDIT_LIST_TYPE_QUEUE",
"onCreateListCommand": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/playlist/create"
}
},
"createPlaylistServiceEndpoint": {
"videoIds": [
"M_8QNw_JM4I"
],
"params": "CAQ%3D"
}
},
"videoIds": [
"M_8QNw_JM4I"
],
"videoCommand": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/watch?v=M_8QNw_JM4I",
"webPageType": "WEB_PAGE_TYPE_WATCH",
"rootVe": 3832
}
},
"watchEndpoint": {
"videoId": "M_8QNw_JM4I",
"watchEndpointSupportedOnesieConfig": {
"html5PlaybackOnesieConfig": {
"commonConfig": {
"url": "https://rr4---sn-n0ogpnx-1gie.googlevideo.com/initplayback?source=youtube&oeis=1&c=WEB&oad=3200&ovd=3200&oaad=11000&oavd=11000&ocs=700&oewis=1&oputc=1&ofpcc=1&msp=1&odepv=1&oreouc=1&id=33ff10370fc93382&ip=1.1.1.1&initcwndbps=3320000&mt=1759654189&oweuc="
}
}
}
}
}
}
}
]
}
},
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
},
{
"menuServiceItemDownloadRenderer": {
"serviceEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"offlineVideoEndpoint": {
"videoId": "M_8QNw_JM4I",
"onAddCommand": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"getDownloadActionCommand": {
"videoId": "M_8QNw_JM4I",
"params": "CAIQAA%3D%3D"
}
}
}
},
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
},
{
"menuServiceItemRenderer": {
"text": {
"runs": [
{
"text": "Share"
}
]
},
"icon": {
"iconType": "SHARE"
},
"serviceEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/share/get_share_panel"
}
},
"shareEntityServiceEndpoint": {
"serializedShareEntity": "CgtNXzhRTndfSk00SQ%3D%3D",
"commands": [
{
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"openPopupAction": {
"popup": {
"unifiedSharePanelRenderer": {
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"showLoadingSpinner": true
}
},
"popupType": "DIALOG",
"beReused": true
}
}
]
}
},
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"hasSeparator": true
}
}
],
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"accessibility": {
"accessibilityData": {
"label": "Action menu"
}
}
}
},
"channelThumbnailSupportedRenderers": {
"channelThumbnailWithLinkRenderer": {
"thumbnail": {
"thumbnails": [
{
"url": "https://yt3.ggpht.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"width": 68,
"height": 68
}
]
},
"navigationEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/@BluntBrothersProductions",
"webPageType": "WEB_PAGE_TYPE_CHANNEL",
"rootVe": 3611,
"apiUrl": "/youtubei/v1/browse"
}
},
"browseEndpoint": {
"browseId": "UCUPrbbdnot-aPgNM65svgOg",
"canonicalBaseUrl": "/@BluntBrothersProductions"
}
},
"accessibility": {
"accessibilityData": {
"label": "Go to channel"
}
}
}
},
"thumbnailOverlays": [
{
"thumbnailOverlayTimeStatusRenderer": {
"text": {
"accessibility": {
"accessibilityData": {
"label": "Upcoming"
}
},
"simpleText": "UPCOMING"
},
"style": "UPCOMING"
}
},
{
"thumbnailOverlayToggleButtonRenderer": {
"isToggled": false,
"untoggledIcon": {
"iconType": "WATCH_LATER"
},
"toggledIcon": {
"iconType": "CHECK"
},
"untoggledTooltip": "Watch later",
"toggledTooltip": "Added",
"untoggledServiceEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/browse/edit_playlist"
}
},
"playlistEditEndpoint": {
"playlistId": "WL",
"actions": [
{
"addedVideoId": "M_8QNw_JM4I",
"action": "ACTION_ADD_VIDEO"
}
]
}
},
"toggledServiceEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/browse/edit_playlist"
}
},
"playlistEditEndpoint": {
"playlistId": "WL",
"actions": [
{
"action": "ACTION_REMOVE_VIDEO_BY_VIDEO_ID",
"removedVideoId": "M_8QNw_JM4I"
}
]
}
},
"untoggledAccessibility": {
"accessibilityData": {
"label": "Watch later"
}
},
"toggledAccessibility": {
"accessibilityData": {
"label": "Added"
}
},
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
},
{
"thumbnailOverlayToggleButtonRenderer": {
"untoggledIcon": {
"iconType": "ADD_TO_QUEUE_TAIL"
},
"toggledIcon": {
"iconType": "PLAYLIST_ADD_CHECK"
},
"untoggledTooltip": "Add to queue",
"toggledTooltip": "Added",
"untoggledServiceEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true
}
},
"signalServiceEndpoint": {
"signal": "CLIENT_SIGNAL",
"actions": [
{
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"addToPlaylistCommand": {
"openMiniplayer": true,
"videoId": "M_8QNw_JM4I",
"listType": "PLAYLIST_EDIT_LIST_TYPE_QUEUE",
"onCreateListCommand": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/playlist/create"
}
},
"createPlaylistServiceEndpoint": {
"videoIds": [
"M_8QNw_JM4I"
],
"params": "CAQ%3D"
}
},
"videoIds": [
"M_8QNw_JM4I"
]
}
}
]
}
},
"untoggledAccessibility": {
"accessibilityData": {
"label": "Add to queue"
}
},
"toggledAccessibility": {
"accessibilityData": {
"label": "Added"
}
},
"trackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
},
{
"thumbnailOverlayNowPlayingRenderer": {
"text": {
"runs": [
{
"text": "Now playing"
}
]
}
}
},
{
"thumbnailOverlayLoadingPreviewRenderer": {
"text": {
"runs": [
{
"text": "Keep hovering to play"
}
]
}
}
}
],
"inlinePlaybackEndpoint": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/watch?v=M_8QNw_JM4I&pp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"webPageType": "WEB_PAGE_TYPE_WATCH",
"rootVe": 3832
}
},
"watchEndpoint": {
"videoId": "M_8QNw_JM4I",
"params": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"playerParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"playerExtraUrlParams": [
{
"key": "inline",
"value": "1"
}
],
"watchEndpointSupportedOnesieConfig": {
"html5PlaybackOnesieConfig": {
"commonConfig": {
"url": "https://rr4---sn-n0ogpnx-1gie.googlevideo.com/initplayback?source=youtube&oeis=1&c=WEB&oad=3200&ovd=3200&oaad=11000&oavd=11000&ocs=700&oewis=1&oputc=1&ofpcc=1&msp=1&odepv=1&oreouc=1&id=33ff10370fc93382&ip=1.1.1.1&initcwndbps=3320000&mt=1759654189&oweuc="
}
}
}
}
},
"searchVideoResultEntityKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"avatar": {
"decoratedAvatarViewModel": {
"avatar": {
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"width": 68,
"height": 68
}
]
},
"avatarImageSize": "AVATAR_SIZE_M"
}
},
"a11yLabel": "Go to channel",
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"clickTrackingParams": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"commandMetadata": {
"webCommandMetadata": {
"url": "/@BluntBrothersProductions",
"webPageType": "WEB_PAGE_TYPE_CHANNEL",
"rootVe": 3611,
"apiUrl": "/youtubei/v1/browse"
}
},
"browseEndpoint": {
"browseId": "UCUPrbbdnot-aPgNM65svgOg",
"canonicalBaseUrl": "/@BluntBrothersProductions"
}
}
}
}
}
}
}
}