Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
5 changes: 5 additions & 0 deletions library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<MainAPI>()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -2680,6 +2710,20 @@ fun fetchUrls(text: String?): List<String> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -451,4 +439,4 @@ abstract class MyDramaListAPI : MainAPI() {
@JsonProperty("date") val date: String? = null,
@JsonProperty("airedDate") val airedDate: String? = null,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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!!,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -455,4 +444,4 @@ open class TraktProvider : MainAPI() {
@JsonProperty("is_bollywood") val isBollywood: Boolean = false,
@JsonProperty("is_cartoon") val isCartoon: Boolean = false,
)
}
}
Original file line number Diff line number Diff line change
@@ -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"))
}
}