diff --git a/src/org/kde/kdeconnect/Helpers/VideoUrlsHelper.java b/src/org/kde/kdeconnect/Helpers/VideoUrlsHelper.java deleted file mode 100644 index 65546343..00000000 --- a/src/org/kde/kdeconnect/Helpers/VideoUrlsHelper.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Soul Trace - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL -*/ - -package org.kde.kdeconnect.Helpers; - -import org.apache.commons.lang3.StringUtils; - -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Locale; - -public class VideoUrlsHelper { - private static final int SECONDS_IN_MINUTE = 60; - private static final int MINUTES_IN_HOUR = 60; - private static final int SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR; - - public static URL formatUriWithSeek(String address, long position) - throws MalformedURLException { - URL url = new URL(address); - position /= 1000; // Convert ms to seconds - if (position <= 0) { - return url; // nothing to do - } - String host = url.getHost().toLowerCase(); - - // Most common settings as defaults: - String parameter = "t="; // Characters before timestamp - String timestamp = Long.toString(position); // Timestamp itself - String trailer = ""; // Characters after timestamp - // true - search/add to query URL part (between ? and # signs), - // false - search/add timestamp to ref (anchor) URL part (after # sign), - boolean inQuery = true; - // true - We know how to format URL with seek timestamp, false - not - boolean seekUrl = false; - - // Override defaults if necessary - if (StringUtils.containsAny(host, "youtube.com", "youtu.be", "pornhub.com")) { - seekUrl = true; - url = stripTimestampS(url, parameter, trailer, inQuery); - } else if (host.contains("vimeo.com")) { - seekUrl = true; - trailer = "s"; - url = stripTimestampS(url, parameter, trailer, inQuery); - } else if (host.contains("dailymotion.com")) { - seekUrl = true; - parameter = "start="; - url = stripTimestampS(url, parameter, trailer, inQuery); - } else if (host.contains("twitch.tv")) { - seekUrl = true; - timestamp = formatTimestampHMS(position, true); - url = stripTimestampHMS(url, parameter, trailer, inQuery); - } - - if (seekUrl) { - url = formatUrlWithSeek(url, timestamp, parameter, trailer, inQuery); - } - return url; - } - - // Returns timestamp in 1h2m34s or 01h02m34s (according to padWithZeroes) - private static String formatTimestampHMS(long seconds, boolean padWithZeroes) { - if (seconds == 0) { - return "0s"; - } - - int sec = (int) (seconds % SECONDS_IN_MINUTE); - int min = (int) ((seconds / SECONDS_IN_MINUTE) % MINUTES_IN_HOUR); - int hour = (int) (seconds / SECONDS_IN_HOUR); - - String hours = hour > 0 ? hour + "h" : ""; - String mins = min > 0 || hour > 0 ? min + "m" : ""; - String secs = sec + "s"; - - String value; - if (padWithZeroes) { - String hoursPad = hour > 9 ? "" : "0"; - String minsPad = min > 9 ? "" : "0"; - String secsPad = sec > 9 ? "" : "0"; - value = hoursPad + hours + minsPad + mins + secsPad + secs; - } else { - value = hours + mins + secs; - } - return value; - - } - - // Remove timestamp in 01h02m34s or 1h2m34s or 02m34s or 2m34s or 01s or 1s format. - // Can also nandle rimestamps in 1234s format if called with 's' trailer - private static URL stripTimestampHMS(URL url, String parameter, String trailer, boolean inQuery) - throws MalformedURLException { - String regex = parameter + "([\\d]+[hH])?([\\d]+[mM])?[\\d]+[sS]" + trailer + "&?"; - return stripTimestampCommon(url, inQuery, regex); - } - - - // Remove timestamp in 1234 format - private static URL stripTimestampS(URL url, String parameter, String trailer, boolean inQuery) - throws MalformedURLException { - String regex = parameter + "[\\d]+" + trailer + "&?"; - return stripTimestampCommon(url, inQuery, regex); - } - - private static URL stripTimestampCommon(URL url, boolean inQuery, String regex) - throws MalformedURLException { - String value; - if (inQuery) { - value = url.getQuery(); - } else { - value = url.getRef(); - } - if (value == null) { - return url; - } - String newValue = value.replaceAll(regex, ""); - String replaced = url.toString().replaceFirst(value, newValue); - if (inQuery && replaced.endsWith("&")) { - replaced = replaced.substring(0, replaced.length() - 1); - } - return new URL(replaced); - } - - private static URL formatUrlWithSeek(URL url, String position, String parameter, String trailer, - boolean inQuery) throws MalformedURLException { - String value; - String separator; - String newValue; - if (inQuery) { - value = url.getQuery(); - separator = "?"; - } else { - value = url.getRef(); - separator = "#"; - } - if (value == null) { - newValue = String.format(Locale.getDefault(), "%s%s%s%s%s", - url, separator, parameter, position, trailer); - return new URL(newValue); - } - if (inQuery) { - newValue = String.format(Locale.getDefault(), "%s&%s%s%s", - value, parameter, position, trailer); - } else { - newValue = String.format(Locale.getDefault(), "%s%s%s", - parameter, position, trailer); - } - return new URL(url.toString().replaceFirst(value, newValue)); - } -} diff --git a/src/org/kde/kdeconnect/Helpers/VideoUrlsHelper.kt b/src/org/kde/kdeconnect/Helpers/VideoUrlsHelper.kt new file mode 100644 index 00000000..b9a29ddc --- /dev/null +++ b/src/org/kde/kdeconnect/Helpers/VideoUrlsHelper.kt @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2024 TPJ Schikhof + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL +*/ +package org.kde.kdeconnect.Helpers + +import java.net.MalformedURLException +import java.net.URL + +object VideoUrlsHelper { + private const val SECONDS_IN_MINUTE = 60 + private const val MINUTES_IN_HOUR = 60 + private const val SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR + + @Throws(MalformedURLException::class) + fun formatUriWithSeek(address: String, position: Long): URL { + val positionSeconds = position / 1000 // milliseconds to seconds + val url = URL(address) + if (positionSeconds <= 0) { + return url // nothing to do + } + val host = url.host.lowercase() + + return when { + listOf("youtube.com", "youtu.be", "pornhub.com").any { site -> site in host } -> { + url.editParameter("t", Regex("\\d+")) { "$positionSeconds" } + } + host.contains("vimeo.com") -> { + url.editParameter("t", Regex("\\d+s")) { "${positionSeconds}s" } + } + host.contains("dailymotion.com") -> { + url.editParameter("start", Regex("\\d+")) { "$positionSeconds" } + } + host.contains("twitch.tv") -> { + url.editParameter("t", Regex("(\\d+[hH])?(\\d+[mM])?\\d+[sS]")) { positionSeconds.formatTimestampHMS() } + } + else -> url + } + } + + private fun URL.editParameter(parameter: CharSequence, valuePattern: Regex?, parameterValueModifier: (String) -> String): URL { + // "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=13" -> ["https://www.youtube.com/watch", "v=ovX5G0O5ZvA&t=13"] + val urlSplit = this.toString().split("?") + if (urlSplit.size != 2) { + return this + } + val (urlBase, urlQuery) = urlSplit + val modifiedUrlQuery = urlQuery + .split("&") // "v=ovX5G0O5ZvA&t=13" -> ["v=ovX5G0O5ZvA", "t=13"] + .map { it.split("=", limit = 2) } // […, "t=13"] -> […, ["t", "13"]] + .map { Pair(it.first(), it.lastOrNull() ?: return this) } + .map { paramAndValue -> + // Modify matching parameter and optionally matches the old value with the provided pattern + if (paramAndValue.first == parameter && valuePattern?.matches(paramAndValue.second) != false) { + Pair(paramAndValue.first, parameterValueModifier(paramAndValue.second)) // ["t", "13"] -> ["t", result] + } else { + paramAndValue + } + } + .joinToString("&") { "${it.first}=${it.second}" } // [["v", "ovX5G0O5ZvA"], ["t", "14"]] -> "v=ovX5G0O5ZvA&t=14" + return URL("${urlBase}?${modifiedUrlQuery}") // -> "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=14" + } + + /** Formats timestamp to e.g. 01h02m34s */ + private fun Long.formatTimestampHMS(): String { + if (this == 0L) return "0s" + + val seconds: Long = this % SECONDS_IN_MINUTE + val minutes: Long = (this / SECONDS_IN_MINUTE) % MINUTES_IN_HOUR + val hours: Long = this / SECONDS_IN_HOUR + + fun pad(s: String) = s.padStart(3, '0') + val hoursText = if (hours > 0) pad("${hours}h") else "" + val minutesText = if (minutes > 0 || hours > 0) pad("${minutes}m") else "" + val secondsText = pad("${seconds}s") + + return "${hoursText}${minutesText}${secondsText}" + } +} diff --git a/tests/org/kde/kdeconnect/Helpers/VideoUrlsHelperTest.kt b/tests/org/kde/kdeconnect/Helpers/VideoUrlsHelperTest.kt new file mode 100644 index 00000000..deef9e31 --- /dev/null +++ b/tests/org/kde/kdeconnect/Helpers/VideoUrlsHelperTest.kt @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2024 TPJ Schikhof + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL +*/ +package org.kde.kdeconnect.Helpers + +import org.junit.Assert +import org.junit.Test + +class VideoUrlsHelperTest { + @Test + fun checkYoutubeURL() { + val url = "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=13" + val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L) + val expected = "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=51" + Assert.assertEquals(expected, formatted.toString()) + } + + @Test + fun checkYoutubeURLSubSecond() { + val url = "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=13" + val formatted = VideoUrlsHelper.formatUriWithSeek(url, 450L) + val expected = "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=13" + Assert.assertEquals(expected, formatted.toString()) + } + + @Test + fun checkVimeoURL() { + val url = "https://vimeo.com/347119375?foo=bar&t=13s" + val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L) + val expected = "https://vimeo.com/347119375?foo=bar&t=51s" + Assert.assertEquals(expected, formatted.toString()) + } + + @Test + fun checkVimeoURLSubSecond() { + val url = "https://vimeo.com/347119375?foo=bar&t=13s" + val formatted = VideoUrlsHelper.formatUriWithSeek(url, 450L) + val expected = "https://vimeo.com/347119375?foo=bar&t=13s" + Assert.assertEquals(expected, formatted.toString()) + } + + @Test + fun checkVimeoURLParamOrderCrash() { + val url = "https://vimeo.com/347119375?t=13s" + val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L) + val expected = "https://vimeo.com/347119375?t=51s" + Assert.assertEquals(expected, formatted.toString()) + } + + @Test + fun checkDailymotionURL() { + val url = "https://www.dailymotion.com/video/xnopyt?foo=bar&start=13" + val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L) + val expected = "https://www.dailymotion.com/video/xnopyt?foo=bar&start=51" + Assert.assertEquals(expected, formatted.toString()) + } + + @Test + fun checkTwitchURL() { + val url = "https://www.twitch.tv/videos/123?foo=bar&t=1h2m3s" + val formatted = VideoUrlsHelper.formatUriWithSeek(url, 10_000_000) + val expected = "https://www.twitch.tv/videos/123?foo=bar&t=02h46m40s" + Assert.assertEquals(expected, formatted.toString()) + } + + @Test + fun checkUnknownURL() { + val url = "https://example.org/cool_video.mp4" + val formatted = VideoUrlsHelper.formatUriWithSeek(url, 51_000L) + val expected = "https://example.org/cool_video.mp4" + Assert.assertEquals(expected, formatted.toString()) + } +} \ No newline at end of file