diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 675ce3b2f77..8f5c6286636 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -27,7 +27,7 @@ jobs: cache-read-only: false - name: Run Gradle - run: ./gradlew assemblePrereleaseDebug lint + run: ./gradlew assemblePrereleaseDebug lint check - name: Upload Artifact uses: actions/upload-artifact@v7 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97145c3f81..d1d2bd8fc1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.20" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" +kotlinxDatetime = "0.8.0" lifecycleKtx = "2.10.0" material = "1.14.0-beta01" media3 = "1.9.3" @@ -81,8 +82,10 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinGradlePlugin" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 073e49e6483..7ab485a0165 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -57,12 +57,17 @@ kotlin { implementation(libs.nicehttp) // HTTP Lib implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) implementation(libs.fuzzywuzzy) // Match Extractors implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript implementation(libs.newpipeextractor) implementation(libs.tmdb.java) // TMDB API v3 Wrapper Made with RetroFit } + + commonTest.dependencies { + implementation(libs.kotlin.test) + } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index c590165a1ad..2959644a82f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -25,13 +25,24 @@ import com.lagradost.nicehttp.RequestBodyTypes import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.format.DateTimeComponents +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern +import kotlinx.datetime.format.char +import kotlinx.datetime.format.parse +import kotlinx.datetime.toInstant import java.net.URI -import java.text.SimpleDateFormat import java.util.* import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue import kotlin.math.roundToInt +import kotlin.time.Clock +import kotlin.time.Instant /** * API available only on prerelease builds. @@ -78,10 +89,10 @@ val mapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!! object APIHolder { - val unixTime: Long - get() = System.currentTimeMillis() / 1000L val unixTimeMS: Long - get() = System.currentTimeMillis() + get() = Clock.System.now().toEpochMilliseconds() + val unixTime: Long + get() = unixTimeMS / 1000L // ConcurrentModificationException is possible!!! val allProviders = threadSafeListOf() @@ -2535,14 +2546,33 @@ constructor( get() = score?.toInt(100) } +@OptIn(FormatStringsInDatetimeFormats::class) fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") { - try { - this.date = SimpleDateFormat(format, Locale.getDefault()).parse(date ?: return)?.time - } catch (e: Exception) { - logError(e) - } + if (date == null) return + this.date = runCatching { + val fmt = DateTimeComponents.Format { byUnicodePattern(format) } + val components = DateTimeComponents.parse(date, fmt) + runCatching { components.toInstantUsingOffset().toEpochMilliseconds() } + .recoverCatching { components.toLocalDateTime().toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() } + .getOrElse { components.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() } + }.onFailure { logError(it) }.getOrNull() +} + +@Prerelease +fun Episode.addDate(date: LocalDate?) { + this.date = date?.atStartOfDayIn(TimeZone.currentSystemDefault())?.toEpochMilliseconds() } +@Prerelease +fun Episode.addDate(date: Instant?) { + this.date = date?.toEpochMilliseconds() +} + +// Deprecate after next stable +/* @Deprecated( + message = "Use addDate with LocalDate, Instant, or String instead.", + level = DeprecationLevel.WARNING, +) */ fun Episode.addDate(date: Date?) { this.date = date?.time } @@ -2680,6 +2710,20 @@ fun fetchUrls(text: String?): List { return linkRegex.findAll(text).map { it.value.trim().removeSurrounding("\"") }.toList() } +@Prerelease +fun isUpcoming(dateString: String?): Boolean { + return runCatching { + val fmt = DateTimeComponents.Format { + year(); char('-'); monthNumber(); char('-'); day() + } + val components = DateTimeComponents.parse(dateString ?: return false, fmt) + val instant = runCatching { components.toInstantUsingOffset() } + .recoverCatching { components.toLocalDateTime().toInstant(TimeZone.currentSystemDefault()) } + .getOrElse { components.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()) } + Clock.System.now() < instant + }.onFailure { logError(it) }.getOrElse { false } +} + @Deprecated( "toRatingInt() is deprecated. Use new score API instead.", level = DeprecationLevel.ERROR diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt index 974549fcbbb..6b82ee454e8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.extractors import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi @@ -21,7 +22,7 @@ open class Vicloud : ExtractorApi() { ) { val id = Regex("\"apiQuery\":\"(.*?)\"").find(app.get(url).text)?.groupValues?.getOrNull(1) app.get( - "$mainUrl/api/?$id=&_=${System.currentTimeMillis()}", + "$mainUrl/api/?$id=&_=$unixTimeMS", headers = mapOf( "X-Requested-With" to "XMLHttpRequest" ), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt index cf3e28a8de0..e7e1175f032 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt @@ -18,6 +18,7 @@ import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.addDate import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.isUpcoming import com.lagradost.cloudstream3.mainPageOf import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.newEpisode @@ -30,8 +31,6 @@ import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import okhttp3.Interceptor import okhttp3.Response -import java.text.SimpleDateFormat -import java.util.Locale //Reference: https://mydramalist.github.io/MDL-API/ abstract class MyDramaListAPI : MainAPI() { @@ -192,17 +191,6 @@ abstract class MyDramaListAPI : MainAPI() { return this } - private fun isUpcoming(dateString: String?): Boolean { - return try { - val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - val dateTime = dateString?.let { format.parse(it)?.time } ?: return false - unixTimeMS < dateTime - } catch (t: Throwable) { - logError(t) - false - } - } - private fun getStatus(status: String?): ShowStatus? { return when (status) { "Airing" -> ShowStatus.Ongoing @@ -451,4 +439,4 @@ abstract class MyDramaListAPI : MainAPI() { @JsonProperty("date") val date: String? = null, @JsonProperty("airedDate") val airedDate: String? = null, ) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index 63f6d564c4b..277f811b552 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -22,6 +22,7 @@ import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.addDate import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.isUpcoming import com.lagradost.cloudstream3.mainPageOf import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.newEpisode @@ -33,8 +34,7 @@ import com.lagradost.cloudstream3.newTvSeriesLoadResponse import com.lagradost.cloudstream3.newTvSeriesSearchResponse import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson -import java.text.SimpleDateFormat -import java.util.Locale +import kotlin.time.Instant open class TraktProvider : MainAPI() { override var name = "Trakt" @@ -237,7 +237,7 @@ open class TraktProvider : MainAPI() { //this.rating = episode.rating?.times(10)?.roundToInt() this.score = Score.from10(episode.rating) - this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + this.addDate(episode.firstAired?.let { Instant.parse(it) }) if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) { nextAir = NextAiring( episode = this.episode!!, @@ -292,17 +292,6 @@ open class TraktProvider : MainAPI() { ).toString() } - private fun isUpcoming(dateString: String?): Boolean { - return try { - val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - val dateTime = dateString?.let { format.parse(it)?.time } ?: return false - unixTimeMS < dateTime - } catch (t: Throwable) { - logError(t) - false - } - } - private fun getStatus(t: String?): ShowStatus { return when (t) { "returning series" -> ShowStatus.Ongoing @@ -455,4 +444,4 @@ open class TraktProvider : MainAPI() { @JsonProperty("is_bollywood") val isBollywood: Boolean = false, @JsonProperty("is_cartoon") val isCartoon: Boolean = false, ) -} \ No newline at end of file +} diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/EpisodeDateTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/EpisodeDateTest.kt new file mode 100644 index 00000000000..60ad0806e6a --- /dev/null +++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/EpisodeDateTest.kt @@ -0,0 +1,138 @@ +package com.lagradost.cloudstream3 + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EpisodeDateTest { + + private val api = object : MainAPI() { + override var name = "Test" + override var mainUrl = "https://test.com" + } + + private fun episode() = api.newEpisode("") + + @Test + fun addDateDefaultFormatParsesIsoDate() { + val ep = episode() + ep.addDate("2026-05-17") + val expected = LocalDate(2026, 5, 17) + .atStartOfDayIn(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateNullDoesNotSetDate() { + val ep = episode() + ep.addDate(null as String?) + assertNull(ep.date) + } + + @Test + fun addDateInvalidStringLeavesDateNull() { + val ep = episode() + ep.addDate("not-a-date") + assertNull(ep.date) + } + + @Test + fun addDateCustomFormatParsesSlashDate() { + val ep = episode() + ep.addDate("17/05/2026", "dd/MM/yyyy") + val expected = LocalDate(2026, 5, 17) + .atStartOfDayIn(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateIsoDateTimeWithOffsetUsesExactInstant() { + val ep = episode() + ep.addDate("2026-05-17T10:30:00.000+05:00", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + val expected = Instant.parse("2026-05-17T10:30:00.000+05:00").toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateUtcDateTimeUsesExactInstant() { + val ep = episode() + ep.addDate("2026-05-17T10:30:00.000Z", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + val expected = Instant.parse("2026-05-17T10:30:00.000Z").toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateDateTimeNoOffsetUsesSystemTimezone() { + val ep = episode() + ep.addDate("2026-05-17T10:30:00", "yyyy-MM-dd'T'HH:mm:ss") + val expected = LocalDateTime(2026, 5, 17, 10, 30, 0) + .toInstant(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateLocalDateSetsCorrectEpochMillis() { + val ep = episode() + ep.addDate(LocalDate(2026, 5, 17)) + val expected = LocalDate(2026, 5, 17) + .atStartOfDayIn(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + assertEquals(expected, ep.date) + } + + @Test + fun addDateNullLocalDateLeavesDateNull() { + val ep = episode() + ep.addDate(null as LocalDate?) + assertNull(ep.date) + } + + @Test + fun addDateInstantSetsCorrectEpochMillis() { + val ep = episode() + val instant = Instant.parse("2026-05-17T10:30:00Z") + ep.addDate(instant) + assertEquals(instant.toEpochMilliseconds(), ep.date) + } + + @Test + fun addDateNullInstantLeavesDateNull() { + val ep = episode() + ep.addDate(null as Instant?) + assertNull(ep.date) + } +} + +class IsUpcomingTest { + + @Test + fun isUpcomingFutureDate() { + assertTrue(isUpcoming("2099-01-01")) + } + + @Test + fun isUpcomingPastDate() { + assertFalse(isUpcoming("2000-01-01")) + } + + @Test + fun isUpcomingNullReturnsFalse() { + assertFalse(isUpcoming(null)) + } + + @Test + fun isUpcomingInvalidStringReturnsFalse() { + assertFalse(isUpcoming("not-a-date")) + } +}