diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/Extractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/Extractor.java index e973b44167..7d984e8338 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/Extractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/Extractor.java @@ -7,6 +7,7 @@ import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.localization.TimeAgoParser; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -15,6 +16,8 @@ import java.util.Objects; public abstract class Extractor { + private final String TAG = getClass().getSimpleName() + "@" + hashCode(); + /** * {@link StreamingService} currently related to this extractor.
* Useful for getting other things from a service (like the url handlers for @@ -54,7 +57,9 @@ public LinkHandler getLinkHandler() { * @throws ExtractionException if the pages content is not understood */ public void fetchPage() throws IOException, ExtractionException { + ExtractorLogger.d(TAG, "base fetchPage called"); if (pageFetched) { + ExtractorLogger.d(TAG, "Page already fetched; returning"); return; } onFetchPage(downloader); @@ -151,4 +156,9 @@ public ContentCountry getExtractorContentCountry() { public TimeAgoParser getTimeAgoParser() { return getService().getTimeAgoParser(getExtractorLocalization()); } + + @Override + public String toString() { + return getClass().getSimpleName(); + } } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/Info.java b/extractor/src/main/java/org/schabi/newpipe/extractor/Info.java index 78a15553b1..7ad5e8dd77 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/Info.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/Info.java @@ -2,6 +2,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.linkhandler.LinkHandler; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import java.io.Serializable; import java.util.ArrayList; @@ -10,6 +11,7 @@ public abstract class Info implements Serializable { + private static final String TAG = "Info"; private final int serviceId; /** * Id of this Info object
@@ -52,6 +54,7 @@ public Info(final int serviceId, this.url = url; this.originalUrl = originalUrl; this.name = name; + ExtractorLogger.d(TAG, "Base Created {}", this); } public Info(final int serviceId, final LinkHandler linkHandler, final String name) { diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/NewPipe.java b/extractor/src/main/java/org/schabi/newpipe/extractor/NewPipe.java index 7dfa4c4cde..77fe1667a8 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/NewPipe.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/NewPipe.java @@ -24,6 +24,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import java.util.List; @@ -34,6 +35,7 @@ * Provides access to streaming services supported by NewPipe. */ public final class NewPipe { + private static final String TAG = NewPipe.class.getSimpleName(); private static Downloader downloader; private static Localization preferredLocalization; private static ContentCountry preferredContentCountry; @@ -42,15 +44,19 @@ private NewPipe() { } public static void init(final Downloader d) { + ExtractorLogger.d(TAG, "Default init called"); init(d, Localization.DEFAULT); } public static void init(final Downloader d, final Localization l) { + ExtractorLogger.d(TAG, "Default init called with localization={}"); init(d, l, l.getCountryCode().isEmpty() ? ContentCountry.DEFAULT : new ContentCountry(l.getCountryCode())); } public static void init(final Downloader d, final Localization l, final ContentCountry c) { + ExtractorLogger.d(TAG, "Initializing with downloader={}, localization={}, country={}", + d, l, c); downloader = d; preferredLocalization = l; preferredContentCountry = c; diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/downloader/Downloader.java b/extractor/src/main/java/org/schabi/newpipe/extractor/downloader/Downloader.java index aa7987156d..218848d0ac 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/downloader/Downloader.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/downloader/Downloader.java @@ -243,4 +243,9 @@ public Response postWithContentTypeJson(final String url, */ public abstract Response execute(@Nonnull Request request) throws IOException, ReCaptchaException; + + @Override + public String toString() { + return getClass().getSimpleName(); + } } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java index 62fb6bbf74..9a0ea72f61 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java @@ -31,6 +31,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.DateWrapper; import org.schabi.newpipe.extractor.utils.ExtractorHelper; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import java.io.IOException; import java.util.List; @@ -44,7 +45,7 @@ * Info object for opened contents, i.e. the content ready to play. */ public class StreamInfo extends Info { - + private static final String TAG = StreamInfo.class.getSimpleName(); public static class StreamExtractException extends ExtractionException { StreamExtractException(final String message) { super(message); @@ -61,19 +62,36 @@ public StreamInfo(final int serviceId, super(serviceId, id, url, originalUrl, name); this.streamType = streamType; this.ageLimit = ageLimit; + ExtractorLogger.d(TAG, "Created {}", this); + } + + @Override + public String toString() { + return TAG + "[" + + "serviceId=" + getServiceId() + + ", url='" + getUrl() + '\'' + + ", originalUrl='" + getOriginalUrl() + '\'' + + ", id='" + getId() + '\'' + + ", name='" + getName() + '\'' + + ", streamType=" + streamType + + ", ageLimit=" + ageLimit + + ']'; } public static StreamInfo getInfo(final String url) throws IOException, ExtractionException { + ExtractorLogger.d(TAG, "getInfo({url})", url); return getInfo(NewPipe.getServiceByUrl(url), url); } public static StreamInfo getInfo(@Nonnull final StreamingService service, final String url) throws IOException, ExtractionException { + ExtractorLogger.d(TAG, "getInfo({service},{url})", service, url); return getInfo(service.getStreamExtractor(url)); } public static StreamInfo getInfo(@Nonnull final StreamExtractor extractor) throws ExtractionException, IOException { + ExtractorLogger.d(TAG, "getInfo({extractor})", extractor); extractor.fetchPage(); final StreamInfo streamInfo; try { diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ExtractorLogger.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ExtractorLogger.java new file mode 100644 index 0000000000..9004bfea95 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ExtractorLogger.java @@ -0,0 +1,240 @@ +package org.schabi.newpipe.extractor.utils; + +/** + * Logging class for outputting logs from the extractor to the desired output

+ * Intended to be used in the same manner as Android's {@code Log}:
+ * {@code ExtractorLogger.d("Hello my name Jeff")}
+ *
+ * Also supports formatted arguments:
+ * {@code ExtractorLogger.d("Hello my name is {Name} {}", name, surname)} + */ +public final class ExtractorLogger { + + private ExtractorLogger() { } + + private static final Logger EMPTY_LOGGER = new EmptyLogger(); + private static volatile Logger logger = EMPTY_LOGGER; + + /** + * Set the Logger that you want the extractor logs to be logged to + *
+ * Provide an implementation of the {@code Logger} interface for each method and whenever the + * extractor code calls {@code ExtractorLogger.d/w/e} + * it will be routed through to {@code customLogger} + *
+ *
+ * For NewPipe, this should be set at the start of the application ideally in + * {@code MainActivity.onCreate}, but absolutely before any extractor code can run + */ + public static void setLogger(final Logger customLogger) { + logger = customLogger != null ? customLogger : EMPTY_LOGGER; + } + + public enum Level { DEBUG, WARN, ERROR } + + @SuppressWarnings("checkstyle:NeedBraces") + private static void log(final Level level, + final String tag, + final String message, + final Throwable t) { + if (logger == EMPTY_LOGGER) return; + switch (level) { + case DEBUG: + if (t == null) { + logger.debug(tag, message); + } else { + logger.debug(tag, message, t); + } + break; + case WARN: + if (t == null) { + logger.warn(tag, message); + } else { + logger.warn(tag, message, t); + } + break; + case ERROR: + if (t == null) { + logger.error(tag, message); + } else { + logger.error(tag, message, t); + } + break; + } + } + + /*** + * Internal method for logging with formatting + */ + @SuppressWarnings("checkstyle:NeedBraces") + private static void logFormat(final Level level, + final String tag, + final Throwable t, + final String template, + final Object... args) { + if (logger == EMPTY_LOGGER) return; + log(level, tag, format(template, args), t); + } + + // DEBUG + public static void d(final String tag, final String msg) { + log(Level.DEBUG, tag, msg, null); + } + + public static void d(final String tag, final String msg, final Throwable t) { + log(Level.DEBUG, tag, msg, t); + } + + public static void d(final String tag, final String template, final Object... args) { + logFormat(Level.DEBUG, tag, null, template, args); + } + + public static void d(final String tag, + final Throwable t, + final String template, + final Object... args) { + logFormat(Level.DEBUG, tag, t, template, args); + } + + // WARN + public static void w(final String tag, final String msg) { + log(Level.WARN, tag, msg, null); + } + + public static void w(final String tag, final String msg, final Throwable t) { + log(Level.WARN, tag, msg, t); + } + + public static void w(final String tag, final String template, final Object... args) { + logFormat(Level.WARN, tag, null, template, args); + } + + public static void w(final String tag, + final Throwable t, + final String template, + final Object... args) { + logFormat(Level.WARN, tag, t, template, args); + } + + // ERROR + public static void e(final String tag, final String msg) { + log(Level.ERROR, tag, msg, null); + } + + public static void e(final String tag, final String msg, final Throwable t) { + log(Level.ERROR, tag, msg, t); + } + + public static void e(final String tag, final String template, final Object... args) { + logFormat(Level.ERROR, tag, null, template, args); + } + + public static void e(final String tag, + final Throwable t, + final String template, + final Object... args) { + logFormat(Level.ERROR, tag, t, template, args); + } + + /** + * Simple string format method for easier logging in the form of + * {@code ExtractorLogger.d("Hello my name {Name} {}", name, surname)}

+ * Braces can be escaped by double braces: + * {{ -> { and + * }} -> } + * @param template The template string to format + * @param args Arguments to replace identifiers with in {@code template} + * @return Formatted string with arguments replaced + */ + private static String format(final String template, final Object... args) { + if (template == null || args == null || args.length == 0) { + return template; + } + final var result = new StringBuilder(template.length() + Math.min(32, 16 * args.length)); + int cursorIndex = 0; + int argIndex = 0; + final int n = template.length(); + while (cursorIndex < n) { + final char ch = template.charAt(cursorIndex); + + // {{ -> { + // If current char is { and next char is {, append { and skip 2 chars + if (ch == '{' && cursorIndex + 1 < n && template.charAt(cursorIndex + 1) == '{') { + result.append('{'); + cursorIndex += 2; + continue; + } + + // }} -> } + if (ch == '}' && cursorIndex + 1 < n && template.charAt(cursorIndex + 1) == '}') { + result.append('}'); + cursorIndex += 2; + continue; + } + + // {name}, {} placeholders + if (ch == '{') { + // Find first } after the current { + final int closeBraceIndex = template.indexOf('}', cursorIndex + 1); + if (closeBraceIndex < 0) { + // If none found then there's no more arguments to replace + // Append rest of the string + result.append(template, cursorIndex, n); + break; + } + + // Found a } + if (argIndex < args.length) { + // Append the argument + result.append(args[argIndex++]); + } else { + // No args left to append; append text as is + result.append(template, cursorIndex, closeBraceIndex + 1); + } + cursorIndex = closeBraceIndex + 1; + continue; + } + + result.append(ch); + cursorIndex++; + } + return result.toString(); + } + + private static final class EmptyLogger implements Logger { + public void debug(final String tag, final String msg) { } + public void debug(final String tag, final String msg, final Throwable throwable) { } + public void warn(final String tag, final String msg) { } + public void warn(final String tag, final String msg, final Throwable t) { } + public void error(final String tag, final String msg) { } + public void error(final String tag, final String msg, final Throwable t) { } + } + + public static final class ConsoleLogger implements Logger { + public void debug(final String tag, final String msg) { + System.out.println("[DEBUG][" + tag + "] " + msg); + } + + public void debug(final String tag, final String msg, final Throwable throwable) { + debug(tag, msg); + throwable.printStackTrace(System.err); + } + public void warn(final String tag, final String msg) { + System.out.println("[WARN ][" + tag + "] " + msg); + } + + public void warn(final String tag, final String msg, final Throwable t) { + warn(tag, msg); + t.printStackTrace(System.err); + } + + public void error(final String tag, final String msg) { + System.err.println("[ERROR][" + tag + "] " + msg); + } + + public void error(final String tag, final String msg, final Throwable t) { + System.err.println("[ERROR][" + tag + "] " + msg); + t.printStackTrace(System.err); + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/Logger.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/Logger.java new file mode 100644 index 0000000000..c7cd9db49c --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/Logger.java @@ -0,0 +1,10 @@ +package org.schabi.newpipe.extractor.utils; + +public interface Logger { + void debug(String tag, String message); + void debug(String tag, String message, Throwable throwable); + void warn(String tag, String message); + void warn(String tag, String message, Throwable throwable); + void error(String tag, String message); + void error(String tag, String message, Throwable t); +} diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/LoggerExtension.java b/extractor/src/test/java/org/schabi/newpipe/extractor/LoggerExtension.java new file mode 100644 index 0000000000..573c02613a --- /dev/null +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/LoggerExtension.java @@ -0,0 +1,23 @@ +package org.schabi.newpipe.extractor; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; + +/** + * JUnit extension class for globally setting up the logger for all extractor tests
+ * See here on how this works + *
+ * To disable this, set {@code junit.jupiter.extensions.autodetection.enabled = false} + * in junit-platform.properties + */ +public class LoggerExtension implements BeforeAllCallback { + private static boolean set = false; + + @Override + public void beforeAll(ExtensionContext context) { + if (set) return; + set = true; + ExtractorLogger.setLogger(new ExtractorLogger.ConsoleLogger()); + } +} \ No newline at end of file diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/utils/ExtractorLoggerTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/utils/ExtractorLoggerTest.java new file mode 100644 index 0000000000..6116ab30e7 --- /dev/null +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/utils/ExtractorLoggerTest.java @@ -0,0 +1,233 @@ +package org.schabi.newpipe.extractor.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * See {@link org.schabi.newpipe.extractor.LoggerExtension} for how global logging is set up for tests + */ +class ExtractorLoggerTest { + + private CapturingLogger logger; + + @BeforeEach + void setup() { + logger = new CapturingLogger(); + ExtractorLogger.setLogger(logger); + } + + @Test + void replacesSinglePlaceholder() { + ExtractorLogger.d("T", "Hello {Name}", "Alice"); + assertEquals("Hello Alice", logger.lastDebug); + } + + @Test + void replacesMultiplePlaceholdersSequentially() { + ExtractorLogger.d("T", "A={A} B={B} C={C}", 1, 2, 3); + assertEquals("A=1 B=2 C=3", logger.lastDebug); + } + + @Test + void leavesExtraPlaceholdersWhenNotEnoughArgs() { + ExtractorLogger.d("T", "First={F} Second={S} Third={T}", "X", "Y"); + assertEquals("First=X Second=Y Third={T}", logger.lastDebug); + } + + @Test + void ignoresExtraArgs() { + ExtractorLogger.d("T", "Only {One}", "X", "Y", "Z"); + assertEquals("Only X", logger.lastDebug); + } + + @Test + void noArgsReturnsTemplateUnchanged() { + ExtractorLogger.d("T", "No placeholders {} here"); + assertEquals("No placeholders {} here", logger.lastDebug); + } + + @ParameterizedTest + @CsvSource(value = { + "Value {Unclosed,Value {Unclosed", + "{({test_one)}}},}", + "{{test},{test}", + "{{test_three}}},{test_three}}", + }) + void unmatchedBraceLeavesRemainder(final String message, final String expected) { + ExtractorLogger.d("T", message, ""); + assertEquals(expected, logger.lastDebug); + } + + @ParameterizedTest + @CsvSource({ + "{{,{", + "}},}", + "{{}},{}", + "}}{{,}{", + "hello},hello}", + "hello{,hello{", + }) + void doubleBraceEscaping(final String message, final String expected) { + ExtractorLogger.d("T", message, "unconsumed"); + assertEquals(expected, logger.lastDebug); + } + + @Test + void escapedBracesDoNotConsumeArg() { + ExtractorLogger.d("T", "{{Name}}", "Alice"); + assertEquals("{Name}", logger.lastDebug); + } + + @Test + void escapedBraceThenPlaceholder() { + ExtractorLogger.d("T", "{{literal}} {Value}", "Alice"); + assertEquals("{literal} Alice", logger.lastDebug); + } + + @ParameterizedTest + @CsvSource({ + "Value {Unclosed,Value {Unclosed", + "{({test_one)}}},}", + }) + void unclosedOrStrayBracesLeaveRemainderUnchanged(final String message, final String expected) { + ExtractorLogger.d("T", message, ""); + assertEquals(expected, logger.lastDebug); + } + + @ParameterizedTest + @CsvSource({ + "{{test},{test}", + "{{test_three}}},{test_three}}", + }) + void doubleBraceEscapesLiteralBraces(final String message, final String expected) { + ExtractorLogger.d("T", message, ""); + assertEquals(expected, logger.lastDebug); + } + + @Test + void tripleOpenBraceIsEscapedLiteralThenPlaceholder() { + ExtractorLogger.d("T", "{{{Name}}}", "Alice"); + assertEquals("{Alice}", logger.lastDebug); + } + + @Test + void mixedEscapedAndPlaceholders() { + ExtractorLogger.d("T", "{A} {{B}} {C}", "1", "2"); + assertEquals("1 {B} 2", logger.lastDebug); + } + + @Test + void nullArgFormatsAsNullString() { + ExtractorLogger.d("T", "value is {V}", (Object) null); + assertEquals("value is null", logger.lastDebug); + } + + @Test + void emptyTemplateWithArgsReturnsEmpty() { + ExtractorLogger.d("T", "", "X"); + assertEquals("", logger.lastDebug); + } + + @Test + void nullTemplatePrintsNull() { + ExtractorLogger.d("T", (String) null, "X"); + assertNull(logger.lastDebug); + } + + @Test + void escapedBracesWithThrowable() { + RuntimeException ex = new RuntimeException("boom"); + ExtractorLogger.d("T", ex, "{{literal}} {Value}", "Alice"); + assertEquals("{literal} Alice", logger.lastDebug); + assertSame(ex, logger.lastDebugThrowable); + } + + @Test + void debugFormatWithThrowable() { + RuntimeException ex = new RuntimeException("boom"); + ExtractorLogger.d("T", ex, "Failure {Code} at {Step}", 500, "init"); + assertEquals("Failure 500 at init", logger.lastDebug); + assertSame(ex, logger.lastDebugThrowable); + } + + @Test + void warnFormatWithThrowable() { + IllegalStateException ex = new IllegalStateException("warned"); + ExtractorLogger.w("T", ex, "Warn {What}", "disk"); + assertEquals("Warn disk", logger.lastWarn); + assertSame(ex, logger.lastWarnThrowable); + } + + @Test + void errorFormatWithThrowable() { + Exception ex = new Exception("fatal"); + ExtractorLogger.e("T", ex, "Error {Type} code={Code}", "IO", 42); + assertEquals("Error IO code=42", logger.lastError); + assertSame(ex, logger.lastErrorThrowable); + } + + @Test + void debugFormatWithThrowableNotEnoughArgsLeavesPlaceholder() { + RuntimeException ex = new RuntimeException("x"); + ExtractorLogger.d("T", ex, "Only one {A} and leftover {B}", "arg1"); + assertEquals("Only one arg1 and leftover {B}", logger.lastDebug); + assertSame(ex, logger.lastDebugThrowable); + } + + @Test + void errorFormatWithThrowableExtraArgsIgnored() { + Exception ex = new Exception("x"); + ExtractorLogger.e("T", ex, "Val {V}", 10, 20, 30); + assertEquals("Val 10", logger.lastError); + assertSame(ex, logger.lastErrorThrowable); + } + + private static final class CapturingLogger implements Logger { + String lastDebug; + Throwable lastDebugThrowable; + String lastWarn; + Throwable lastWarnThrowable; + String lastError; + Throwable lastErrorThrowable; + + @Override + public void debug(String tag, String message) { + lastDebug = message; + lastDebugThrowable = null; + } + + @Override + public void debug(String tag, String message, Throwable throwable) { + lastDebug = message; + lastDebugThrowable = throwable; + } + + @Override + public void warn(String tag, String message) { + lastWarn = message; + lastWarnThrowable = null; + } + + @Override + public void warn(String tag, String message, Throwable throwable) { + lastWarn = message; + lastWarnThrowable = throwable; + } + + @Override + public void error(String tag, String message) { + lastError = message; + lastErrorThrowable = null; + } + + @Override + public void error(String tag, String message, Throwable t) { + lastError = message; + lastErrorThrowable = t; + } + } +} \ No newline at end of file diff --git a/extractor/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/extractor/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 0000000000..ad03392dbd --- /dev/null +++ b/extractor/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +org.schabi.newpipe.extractor.LoggerExtension \ No newline at end of file diff --git a/extractor/src/test/resources/junit-platform.properties b/extractor/src/test/resources/junit-platform.properties new file mode 100644 index 0000000000..1cebb76d5a --- /dev/null +++ b/extractor/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.extensions.autodetection.enabled = true \ No newline at end of file