diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..75849be
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,26 @@
+version: 2
+registries:
+ maven-google:
+ type: maven-repository
+ url: https://maven.google.com
+ username: ""
+ password: ""
+ maven-center:
+ type: maven-repository
+ url: https://repo.maven.apache.org/maven2/
+ username: ""
+ password: ""
+updates:
+ - package-ecosystem: "gradle"
+ directory: "/"
+ registries:
+ - maven-center
+ - maven-google
+
+ schedule:
+ interval: "daily"
+
+ - package-ecosystem: "github-actions"
+ directory: ".github/workflows"
+ schedule:
+ interval: "daily"
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..6493eb8
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,33 @@
+name: Android Build
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: 'true'
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ - name: Build with Gradle
+ run: ./gradlew :app:assembleDebug
+ - name: Upload a Build Artifact
+ if: success()
+ uses: actions/upload-artifact@v4.4.3
+ with:
+ name: build-artifact
+ path: |
+ ./**/*.apk
\ No newline at end of file
diff --git a/.github/workflows/unitTest.yml.bak b/.github/workflows/unitTest.yml.bak
new file mode 100644
index 0000000..9c0e7f0
--- /dev/null
+++ b/.github/workflows/unitTest.yml.bak
@@ -0,0 +1,26 @@
+name: Unit Test
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: 'true'
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ - name: Build with Gradle
+ run: ./gradlew :app:test
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e2b8a1a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,112 @@
+# IntelliJ
+*.iml
+*.ipr
+.idea/
+out/
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### Android template
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+keystore.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
+# Android Profiling
+*.hprof
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Kotlin
+.kotlin/
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..bf449ed
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "TvBoxPlugin"]
+ path = TvBoxPlugin
+ url = https://github.com/muedsa/TvBoxPlugin.git
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..bbfc085
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 MUEDSA
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cc092ec
--- /dev/null
+++ b/README.md
@@ -0,0 +1,14 @@
+# TvBoxDemoPlugin
+[TvBox](https://github.com/muedsa/TvBox)的demo插件
+
+## Use this template(使用此仓库作为模板)
+本仓库使用**git submodule**,请在项目Clone后使用`git submodule update --init --recursive`拉取子模块。
+你需要修改以下位置
+- [ ] [settings.gradle.kts](settings.gradle.kts) 中的 `rootProject.name = "你的项目名称"`
+- [ ] [app/src/main/res/values/strings.xml](app/src/main/res/values/strings.xml) 中的 `你的插件名称`
+- [ ] [app/build.gradle.kts](app/build.gradle.kts) 中的 `namespace = "你的namespace"`
+- [ ] [app/build.gradle.kts](app/build.gradle.kts) 中的 `applicationId = "applicationId"`
+- [ ] [app/build.gradle.kts](app/build.gradle.kts) 中的 `signingConfigs { // 你的签名 }`
+- [ ] [app/src/main/res/mipmap-xxxx](app/src/main/res) 中的 [ic_launcher](app/src/main/res/mipmap-hdpi/ic_launcher.webp) 为你的Icon
+- [ ] 编写代码实现插件IPlugin的所有功能,并修改 [app/src/main/AndroidManifest.xml](app/src/main/AndroidManifest.xml) 中的 ``
+- [ ] [README.md](README.md)
diff --git a/TvBoxPlugin b/TvBoxPlugin
new file mode 160000
index 0000000..c784797
--- /dev/null
+++ b/TvBoxPlugin
@@ -0,0 +1 @@
+Subproject commit c78479741770c132012bfd37f2d695415fcf56fa
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..3698436
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,80 @@
+import java.io.FileInputStream
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+val keystorePropertiesFile: File = rootProject.file("keystore.properties")
+val keystoreProperties = Properties()
+if (keystorePropertiesFile.exists() && keystorePropertiesFile.canRead()) {
+ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
+}
+
+android {
+ namespace = "com.muedsa.tvbox.demoplugin"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.muedsa.tvbox.demoplugin"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.0.1"
+ }
+
+ signingConfigs {
+ create("release") {
+ if (keystoreProperties.containsKey("muedsa.signingConfig.storeFile")) {
+ storeFile = file(keystoreProperties["muedsa.signingConfig.storeFile"] as String)
+ storePassword = keystoreProperties["muedsa.signingConfig.storePassword"] as String
+ keyAlias = keystoreProperties["muedsa.signingConfig.keyAlias"] as String
+ keyPassword = keystoreProperties["muedsa.signingConfig.keyPassword"] as String
+ } else {
+ val debugSigningConfig = signingConfigs.getByName("debug")
+ storeFile = debugSigningConfig.storeFile
+ storePassword = debugSigningConfig.storePassword
+ keyAlias = debugSigningConfig.keyAlias
+ keyPassword = debugSigningConfig.keyPassword
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ // 修改APK文件名
+ applicationVariants.all {
+ outputs.all {
+ if (this is com.android.build.gradle.internal.api.ApkVariantOutputImpl) {
+ outputFileName = "${rootProject.name}-${versionName}-${buildType.name}.apk.tbp"
+ }
+ }
+ }
+}
+dependencies {
+ //implementation(libs.androidx.core.ktx)
+ compileOnly(project(":api"))
+ testImplementation(project(":api"))
+ testImplementation(libs.junit4)
+ testImplementation(libs.kotlinx.coroutines.test)
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..43e0af9
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/DemoPlugin.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/DemoPlugin.kt
new file mode 100644
index 0000000..7cb5dca
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/DemoPlugin.kt
@@ -0,0 +1,62 @@
+package com.muedsa.tvbox.demoplugin
+
+import com.muedsa.tvbox.api.plugin.IPlugin
+import com.muedsa.tvbox.api.plugin.PluginOptions
+import com.muedsa.tvbox.api.plugin.TvBoxContext
+import com.muedsa.tvbox.api.service.IMainScreenService
+import com.muedsa.tvbox.api.service.IMediaCatalogService
+import com.muedsa.tvbox.api.service.IMediaDetailService
+import com.muedsa.tvbox.api.service.IMediaSearchService
+import com.muedsa.tvbox.api.store.IPluginPerfStore
+import com.muedsa.tvbox.demoplugin.service.DanDanPlayApiService
+import com.muedsa.tvbox.demoplugin.service.MainScreenService
+import com.muedsa.tvbox.demoplugin.service.MediaCatalogService
+import com.muedsa.tvbox.demoplugin.service.MediaDetailService
+import com.muedsa.tvbox.demoplugin.service.MediaSearchService
+import com.muedsa.tvbox.tool.IPv6Checker
+import com.muedsa.tvbox.tool.PluginCookieJar
+import com.muedsa.tvbox.tool.SharedCookieSaver
+import com.muedsa.tvbox.tool.createJsonRetrofit
+import com.muedsa.tvbox.tool.createOkHttpClient
+import timber.log.Timber
+
+class DemoPlugin(tvBoxContext: TvBoxContext) : IPlugin(tvBoxContext = tvBoxContext) {
+
+ private val store: IPluginPerfStore = tvBoxContext.store
+
+ private val cookieSaver by lazy { SharedCookieSaver(store = store) }
+
+ override var options: PluginOptions = PluginOptions(enableDanDanPlaySearch = true)
+
+ override suspend fun onInit() {}
+
+ override suspend fun onLaunched() {
+ val count = store.getOrDefault(key = LAUNCH_COUNT_PREF_KEY, default = 0) + 1
+ Timber.i("DemoPlugin launched, count:$count")
+ store.update(key = LAUNCH_COUNT_PREF_KEY, value = count)
+ }
+
+ private val danDanPlayApiService by lazy {
+ createJsonRetrofit(
+ baseUrl = "https://api.dandanplay.net/api/",
+ service = DanDanPlayApiService::class.java,
+ okHttpClient = createOkHttpClient(
+ debug = tvBoxContext.debug,
+ cookieJar = PluginCookieJar(saver = cookieSaver),
+ onlyIpv4 = tvBoxContext.iPv6Status != IPv6Checker.IPv6Status.SUPPORTED
+ )
+ )
+ }
+ private val mainScreenService by lazy { MainScreenService(danDanPlayApiService) }
+ private val mediaDetailService by lazy { MediaDetailService(danDanPlayApiService) }
+ private val mediaSearchService by lazy { MediaSearchService(danDanPlayApiService) }
+ private val mediaCatalogService by lazy { MediaCatalogService(danDanPlayApiService) }
+
+ override fun provideMainScreenService(): IMainScreenService = mainScreenService
+
+ override fun provideMediaDetailService(): IMediaDetailService = mediaDetailService
+
+ override fun provideMediaSearchService(): IMediaSearchService = mediaSearchService
+
+ override fun provideMediaCatalogService(): IMediaCatalogService = mediaCatalogService
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/PluginPrefs.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/PluginPrefs.kt
new file mode 100644
index 0000000..8546c4d
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/PluginPrefs.kt
@@ -0,0 +1,5 @@
+package com.muedsa.tvbox.demoplugin
+
+import com.muedsa.tvbox.api.store.intPluginPerfKey
+
+val LAUNCH_COUNT_PREF_KEY = intPluginPerfKey("LAUNCH_COUNT")
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/helper/ListHelper.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/helper/ListHelper.kt
new file mode 100644
index 0000000..15bfa9a
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/helper/ListHelper.kt
@@ -0,0 +1,11 @@
+package com.muedsa.tvbox.demoplugin.helper
+
+fun splitListBySize(inputList: List, size: Int): List> {
+ val result = mutableListOf>()
+ var index = 0
+ while (index < inputList.size) {
+ result.add(inputList.subList(index, (index + size).coerceAtMost(inputList.size)))
+ index += size
+ }
+ return result
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiDetailsResp.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiDetailsResp.kt
new file mode 100644
index 0000000..1caee99
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiDetailsResp.kt
@@ -0,0 +1,12 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiDetailsResp(
+ @SerialName("success") val success: Boolean = false,
+ @SerialName("errorCode") val errorCode: Int = -1,
+ @SerialName("errorMessage") val errorMessage: String = "",
+ @SerialName("bangumi") val bangumi: BangumiInfo? = null
+)
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiEpisode.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiEpisode.kt
new file mode 100644
index 0000000..e792ce9
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiEpisode.kt
@@ -0,0 +1,13 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiEpisode(
+ @SerialName("episodeId") val episodeId: Long,
+ @SerialName("episodeTitle") val episodeTitle: String,
+ @SerialName("episodeNumber") val episodeNumber: String,
+ @SerialName("lastWatched") val lastWatched: String? = null,
+ @SerialName("airDate") val airDate: String? = null,
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiInfo.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiInfo.kt
new file mode 100644
index 0000000..9afa211
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiInfo.kt
@@ -0,0 +1,33 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class BangumiInfo(
+ @SerialName("type") val type: String,
+ @SerialName("typeDescription") val typeDescription: String,
+ @SerialName("titles") val titles: List,
+ @SerialName("episodes") val episodes: List,
+ @SerialName("summary") val summary: String,
+ @SerialName("metadata") val metadata: List,
+ @SerialName("bangumiUrl") val bangumiUrl: String,
+// @SerialName("userRating") val userRating: Int = 0,
+// @SerialName("favoriteStatus") val favoriteStatus: Boolean? = false,
+// @SerialName("comment") val comment: List? = null,
+ @SerialName("ratingDetails") val ratingDetails: Map,
+ // relateds
+ // similars
+ // tags
+ // onlineDatabases
+ @SerialName("animeId") val animeId: Int,
+ @SerialName("animeTitle") val animeTitle: String,
+ @SerialName("imageUrl") val imageUrl: String,
+ @SerialName("searchKeyword") val searchKeyword: String,
+ @SerialName("isOnAir") val isOnAir: Boolean,
+ @SerialName("airDay") val airDay: Int,
+ @SerialName("isFavorited") val isFavorited: Boolean = false,
+ @SerialName("isRestricted") val isRestricted: Boolean = false,
+ @SerialName("rating") val rating: Float,
+)
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSearch.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSearch.kt
new file mode 100644
index 0000000..c1da32d
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSearch.kt
@@ -0,0 +1,19 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiSearch(
+ @SerialName("animeId") val animeId: Int,
+ @SerialName("animeTitle") val animeTitle: String,
+ @SerialName("type") val type: String,
+ @SerialName("typeDescription") val typeDescription: String,
+ @SerialName("imageUrl") val imageUrl: String,
+ @SerialName("startDate") val startDate: String,
+ @SerialName("episodeCount") val episodeCount: Int,
+ @SerialName("rating") val rating: Float,
+ @SerialName("isFavorited") val isFavorited: Boolean
+) {
+ val startOnlyDate: String by lazy { startDate.substringBefore("T") }
+}
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSearchResp.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSearchResp.kt
new file mode 100644
index 0000000..cd02587
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSearchResp.kt
@@ -0,0 +1,13 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiSearchResp(
+ @SerialName("hasMore") val hasMore: Boolean = false,
+ @SerialName("success") val success: Boolean = false,
+ @SerialName("errorCode") val errorCode: Int = -1,
+ @SerialName("errorMessage") val errorMessage: String = "",
+ @SerialName("animes") val animes: List? = null
+)
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSeason.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSeason.kt
new file mode 100644
index 0000000..c41abd6
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSeason.kt
@@ -0,0 +1,11 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiSeason (
+ @SerialName("year") val year: Int,
+ @SerialName("month") val month: Int,
+ @SerialName("seasonName") val seasonName: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSeasonsResp.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSeasonsResp.kt
new file mode 100644
index 0000000..b43ea28
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiSeasonsResp.kt
@@ -0,0 +1,12 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiSeasonsResp(
+ @SerialName("seasons") val seasons: List,
+ @SerialName("success") val success: Boolean = false,
+ @SerialName("errorCode") val errorCode: Int = -1,
+ @SerialName("errorMessage") val errorMessage: String = "",
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiShin.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiShin.kt
new file mode 100644
index 0000000..ad0b70a
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiShin.kt
@@ -0,0 +1,17 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiShin(
+ @SerialName("animeId") val animeId: Int,
+ @SerialName("animeTitle") val animeTitle: String,
+ @SerialName("imageUrl") val imageUrl: String,
+ @SerialName("searchKeyword") val searchKeyword: String,
+ @SerialName("isOnAir") val isOnAir: Boolean,
+ @SerialName("airDay") val airDay: Int,
+ @SerialName("isFavorited") val isFavorited: Boolean = false,
+ @SerialName("isRestricted") val isRestricted: Boolean = false,
+ @SerialName("rating") val rating: Float,
+)
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiShinResp.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiShinResp.kt
new file mode 100644
index 0000000..9174ef5
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiShinResp.kt
@@ -0,0 +1,12 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiShinResp(
+ @SerialName("bangumiList") val bangumiList: List = emptyList(),
+ @SerialName("success") val success: Boolean = false,
+ @SerialName("errorCode") val errorCode: Int = -1,
+ @SerialName("errorMessage") val errorMessage: String = "",
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiTitle.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiTitle.kt
new file mode 100644
index 0000000..55c2532
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/model/BangumiTitle.kt
@@ -0,0 +1,10 @@
+package com.muedsa.tvbox.demoplugin.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BangumiTitle(
+ @SerialName("language") val language: String,
+ @SerialName("title") val title: String
+)
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/service/DanDanPlayApiService.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/DanDanPlayApiService.kt
new file mode 100644
index 0000000..7b1c67b
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/DanDanPlayApiService.kt
@@ -0,0 +1,36 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.demoplugin.model.BangumiSearchResp
+import com.muedsa.tvbox.demoplugin.model.BangumiDetailsResp
+import com.muedsa.tvbox.demoplugin.model.BangumiSearch
+import com.muedsa.tvbox.demoplugin.model.BangumiSeasonsResp
+import com.muedsa.tvbox.demoplugin.model.BangumiShinResp
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface DanDanPlayApiService {
+
+ @GET("v2/bangumi/shin")
+ suspend fun bangumiShin(): BangumiShinResp
+
+ @GET("v2/search/anime")
+ suspend fun searchAnime(
+ @Query("keyword") keyword: String,
+ @Query("type") type: String = ""
+ ): BangumiSearchResp
+
+ @GET("v2/bangumi/{animeId}")
+ suspend fun getAnime(
+ @Path("animeId") animeId: Int
+ ): BangumiDetailsResp
+
+ @GET("v2/bangumi/season/anime")
+ suspend fun getSeasonYearMonth(): BangumiSeasonsResp
+
+ @GET("v2/bangumi/season/anime/{year}/{month}")
+ suspend fun getSeasonAnime(
+ @Path("year") year: String,
+ @Path("month") month: String
+ ): BangumiShinResp
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MainScreenService.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MainScreenService.kt
new file mode 100644
index 0000000..b086615
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MainScreenService.kt
@@ -0,0 +1,38 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.api.data.MediaCard
+import com.muedsa.tvbox.api.data.MediaCardRow
+import com.muedsa.tvbox.api.service.IMainScreenService
+import com.muedsa.tvbox.demoplugin.helper.splitListBySize
+
+class MainScreenService(
+ private val danDanPlayApiService: DanDanPlayApiService
+) : IMainScreenService {
+
+ private var rowSize: Int = 30
+
+ override suspend fun getRowsData(): List {
+ val resp = danDanPlayApiService.bangumiShin()
+ if (resp.errorCode != 0) {
+ throw RuntimeException(resp.errorMessage)
+ }
+ if (resp.bangumiList.isEmpty())
+ return emptyList()
+ val rows = splitListBySize(resp.bangumiList, rowSize)
+ return rows.mapIndexed { index, row ->
+ MediaCardRow(
+ title = "新番列表 ${index + 1}",
+ cardWidth = 210 / 2,
+ cardHeight = 302 / 2,
+ list = row.map {
+ MediaCard(
+ id = it.animeId.toString(),
+ title = it.animeTitle,
+ detailUrl = it.animeId.toString(),
+ coverImageUrl = it.imageUrl
+ )
+ }
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaCatalogService.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaCatalogService.kt
new file mode 100644
index 0000000..a17b4fb
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaCatalogService.kt
@@ -0,0 +1,113 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.api.data.MediaCard
+import com.muedsa.tvbox.api.data.MediaCatalogConfig
+import com.muedsa.tvbox.api.data.MediaCatalogOption
+import com.muedsa.tvbox.api.data.MediaCatalogOptionItem
+import com.muedsa.tvbox.api.data.PagingResult
+import com.muedsa.tvbox.api.service.IMediaCatalogService
+import com.muedsa.tvbox.demoplugin.helper.splitListBySize
+
+class MediaCatalogService(
+ private val danDanPlayApiService: DanDanPlayApiService
+) : IMediaCatalogService {
+
+ override suspend fun getConfig(): MediaCatalogConfig {
+ val resp = danDanPlayApiService.getSeasonYearMonth()
+ if (resp.errorCode != 0) {
+ throw RuntimeException(resp.errorMessage)
+ }
+ return MediaCatalogConfig(
+ initKey = "1",
+ pageSize = 20,
+ cardWidth = 210 / 2,
+ cardHeight = 302 / 2,
+ catalogOptions = buildList {
+ if (resp.seasons.isNotEmpty()) {
+ add(
+ MediaCatalogOption(
+ name = "年份",
+ value = "year",
+ items = resp.seasons.map{ it.year }.distinct().mapIndexed { index, year ->
+ MediaCatalogOptionItem(
+ name = year.toString(),
+ value = year.toString(),
+ defaultChecked = index == 0
+ )
+ },
+ required = true
+ )
+ )
+ val firstMonth = resp.seasons.first().month
+ add(
+ MediaCatalogOption(
+ name = "月份",
+ value = "month",
+ items = buildList {
+ for (month in 1 .. 12) {
+ add(
+ MediaCatalogOptionItem(
+ name = month.toString(),
+ value = month.toString(),
+ defaultChecked = month == firstMonth
+ )
+ )
+ }
+ },
+ required = true
+ )
+ )
+
+ add(
+ MediaCatalogOption(
+ name = "Other",
+ value = "other",
+ items = buildList {
+ for (i in 0 .. 8) {
+ add(
+ MediaCatalogOptionItem(
+ name = "other$i",
+ value = i.toString(),
+ )
+ )
+ }
+ },
+ multiple = true
+ )
+ )
+ }
+ }
+ )
+ }
+
+ override suspend fun catalog(
+ options: List,
+ loadKey: String,
+ loadSize: Int
+ ): PagingResult {
+ val pageIndex = loadKey.toInt() - 1
+ val year = options.find { option -> option.value == "year" }?.items[0]?.value ?: throw RuntimeException("年份为必选项")
+ val month = options.find { option -> option.value == "month" }?.items[0]?.value ?: throw RuntimeException("月份为必选项")
+ val resp = danDanPlayApiService.getSeasonAnime(year, month)
+ if (resp.errorCode != 0) {
+ throw RuntimeException(resp.errorMessage)
+ }
+ val pages = splitListBySize(resp.bangumiList, loadSize)
+ println("${pages.size}")
+ pages.forEach { t -> println("${t.size}") }
+ return PagingResult(
+ list = if (pageIndex >= 0 && pageIndex < pages.size) {
+ pages[pageIndex].map {
+ MediaCard(
+ id = it.animeId.toString(),
+ title = it.animeTitle,
+ detailUrl = it.animeId.toString(),
+ coverImageUrl = it.imageUrl
+ )
+ }
+ } else emptyList(),
+ nextKey = if (pageIndex + 1 < pages.size) "${pageIndex + 2}" else null,
+ prevKey = if (pageIndex - 1 >= 0) "$pageIndex" else null
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaDetailService.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaDetailService.kt
new file mode 100644
index 0000000..8878137
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaDetailService.kt
@@ -0,0 +1,61 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.api.data.DanmakuData
+import com.muedsa.tvbox.api.data.DanmakuDataFlow
+import com.muedsa.tvbox.api.data.MediaDetail
+import com.muedsa.tvbox.api.data.MediaEpisode
+import com.muedsa.tvbox.api.data.MediaHttpSource
+import com.muedsa.tvbox.api.data.MediaPlaySource
+import com.muedsa.tvbox.api.data.SavedMediaCard
+import com.muedsa.tvbox.api.service.IMediaDetailService
+
+class MediaDetailService(
+ private val danDanPlayApiService: DanDanPlayApiService
+) : IMediaDetailService {
+
+ override suspend fun getDetailData(mediaId: String, detailUrl: String): MediaDetail {
+ val resp = danDanPlayApiService.getAnime(mediaId.toInt())
+ if (resp.errorCode != 0) {
+ throw RuntimeException(resp.errorMessage)
+ }
+ val bangumi = resp.bangumi ?: throw RuntimeException("bangumi not found")
+ return MediaDetail(
+ id = bangumi.animeId.toString(),
+ title = bangumi.animeTitle,
+ subTitle = bangumi.typeDescription,
+ description = bangumi.summary,
+ detailUrl = bangumi.animeId.toString(),
+ backgroundImageUrl = bangumi.imageUrl,
+ playSourceList = listOf(
+ MediaPlaySource(
+ id = "bangumi",
+ name = "bangumi",
+ episodeList = bangumi.episodes.map {
+ MediaEpisode(
+ id = it.episodeId.toString(),
+ name = it.episodeTitle
+ )
+ }
+ )
+ ),
+ favoritedMediaCard = SavedMediaCard(
+ id = bangumi.animeId.toString(),
+ title = bangumi.animeTitle,
+ detailUrl = bangumi.animeId.toString(),
+ coverImageUrl = bangumi.imageUrl,
+ cardWidth = 210 / 2,
+ cardHeight = 302 / 2,
+ )
+ )
+ }
+
+ override suspend fun getEpisodePlayInfo(
+ playSource: MediaPlaySource,
+ episode: MediaEpisode
+ ): MediaHttpSource = MediaHttpSource(url = "https://media.w3.org/2010/05/sintel/trailer.mp4")
+
+ override suspend fun getEpisodeDanmakuDataList(episode: MediaEpisode): List
+ = emptyList()
+
+ override suspend fun getEpisodeDanmakuDataFlow(episode: MediaEpisode): DanmakuDataFlow? = null
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaSearchService.kt b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaSearchService.kt
new file mode 100644
index 0000000..68ff917
--- /dev/null
+++ b/app/src/main/java/com/muedsa/tvbox/demoplugin/service/MediaSearchService.kt
@@ -0,0 +1,30 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.api.data.MediaCard
+import com.muedsa.tvbox.api.data.MediaCardRow
+import com.muedsa.tvbox.api.service.IMediaSearchService
+
+class MediaSearchService(
+ private val danDanPlayApiService: DanDanPlayApiService
+) : IMediaSearchService {
+ override suspend fun searchMedias(query: String): MediaCardRow {
+ val resp = danDanPlayApiService.searchAnime(keyword = query)
+ if (resp.errorCode != 0) {
+ throw RuntimeException(resp.errorMessage)
+ }
+ return MediaCardRow(
+ title = "search list",
+ cardWidth = 210 / 2,
+ cardHeight = 302 / 2,
+ list = resp.animes?.map {
+ MediaCard(
+ id = it.animeId.toString(),
+ title = it.animeTitle,
+ detailUrl = it.animeId.toString(),
+ coverImageUrl = it.imageUrl,
+ subTitle = it.startOnlyDate
+ )
+ } ?: emptyList()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..fe9cb27
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ TvBoxDemoPlugin
+
\ No newline at end of file
diff --git a/app/src/test/java/com/muedsa/tvbox/demoplugin/Checker.kt b/app/src/test/java/com/muedsa/tvbox/demoplugin/Checker.kt
new file mode 100644
index 0000000..928a6ab
--- /dev/null
+++ b/app/src/test/java/com/muedsa/tvbox/demoplugin/Checker.kt
@@ -0,0 +1,30 @@
+package com.muedsa.tvbox.demoplugin
+
+import com.muedsa.tvbox.api.data.MediaCard
+import com.muedsa.tvbox.api.data.MediaCardRow
+import com.muedsa.tvbox.api.data.MediaCardType
+
+fun checkMediaCardRows(rows: List) {
+ rows.forEach { checkMediaCardRow(it) }
+}
+
+fun checkMediaCardRow(row: MediaCardRow) {
+ check(row.title.isNotEmpty())
+ check(row.list.isNotEmpty())
+ check(row.cardWidth > 0)
+ check(row.cardHeight > 0)
+ row.list.forEach {
+ checkMediaCard(card = it, cardType = row.cardType)
+ }
+}
+
+fun checkMediaCard(card: MediaCard, cardType: MediaCardType) {
+ check(card.id.isNotEmpty())
+ check(card.title.isNotEmpty())
+ check(card.detailUrl.isNotEmpty())
+ if (cardType != MediaCardType.NOT_IMAGE) {
+ check(card.coverImageUrl.isNotEmpty())
+ } else {
+ check(card.backgroundColor > 0)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/muedsa/tvbox/demoplugin/FakePluginPrefStore.kt b/app/src/test/java/com/muedsa/tvbox/demoplugin/FakePluginPrefStore.kt
new file mode 100644
index 0000000..d06171a
--- /dev/null
+++ b/app/src/test/java/com/muedsa/tvbox/demoplugin/FakePluginPrefStore.kt
@@ -0,0 +1,27 @@
+package com.muedsa.tvbox.demoplugin
+
+import com.muedsa.tvbox.api.store.IPluginPerfStore
+import com.muedsa.tvbox.api.store.PluginPerfKey
+
+@Suppress("UNCHECKED_CAST")
+class FakePluginPrefStore : IPluginPerfStore {
+
+ private val store: MutableMap = mutableMapOf()
+
+ override suspend fun get(key: PluginPerfKey): T? =
+ store[key.name] as T?
+
+ override suspend fun getOrDefault(key: PluginPerfKey, default: T): T =
+ store[key.name] as T? ?: default
+
+ override suspend fun filter(predicate: (String) -> Boolean): Map =
+ store.filter { predicate(it.key) }
+
+ override suspend fun update(key: PluginPerfKey, value: T) {
+ store[key.name] = value as Any
+ }
+
+ override suspend fun remove(key: PluginPerfKey) {
+ store.remove(key.name)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/muedsa/tvbox/demoplugin/PluginProvider.kt b/app/src/test/java/com/muedsa/tvbox/demoplugin/PluginProvider.kt
new file mode 100644
index 0000000..047d473
--- /dev/null
+++ b/app/src/test/java/com/muedsa/tvbox/demoplugin/PluginProvider.kt
@@ -0,0 +1,16 @@
+package com.muedsa.tvbox.demoplugin
+
+import com.muedsa.tvbox.api.plugin.TvBoxContext
+import com.muedsa.tvbox.tool.IPv6Checker
+
+val TestPlugin by lazy {
+ DemoPlugin(
+ tvBoxContext = TvBoxContext(
+ screenWidth = 1920,
+ screenHeight = 1080,
+ debug = true,
+ store = FakePluginPrefStore(),
+ iPv6Status = IPv6Checker.checkIPv6Support()
+ )
+ )
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/muedsa/tvbox/demoplugin/PluginTest.kt b/app/src/test/java/com/muedsa/tvbox/demoplugin/PluginTest.kt
new file mode 100644
index 0000000..2a686ad
--- /dev/null
+++ b/app/src/test/java/com/muedsa/tvbox/demoplugin/PluginTest.kt
@@ -0,0 +1,42 @@
+package com.muedsa.tvbox.demoplugin
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class PluginTest {
+
+ @Test
+ fun create_test() {
+ TestPlugin
+ }
+
+ @Test
+ fun onInit_test() = runTest {
+ TestPlugin.onInit()
+ }
+
+ @Test
+ fun onLaunched_test() = runTest {
+ TestPlugin.onLaunched()
+ }
+
+ @Test
+ fun provideMainScreenService_test() {
+ TestPlugin.provideMainScreenService()
+ }
+
+ @Test
+ fun provideMediaDetailService_test() {
+ TestPlugin.provideMediaDetailService()
+ }
+
+ @Test
+ fun provideMediaSearchService_test() {
+ TestPlugin.provideMediaSearchService()
+ }
+
+ @Test
+ fun provideMediaCatalogService_test() {
+ TestPlugin.provideMediaCatalogService()
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MainScreenServiceTest.kt b/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MainScreenServiceTest.kt
new file mode 100644
index 0000000..1cceaa2
--- /dev/null
+++ b/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MainScreenServiceTest.kt
@@ -0,0 +1,18 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.demoplugin.TestPlugin
+import com.muedsa.tvbox.demoplugin.checkMediaCardRows
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class MainScreenServiceTest {
+
+ private val service = TestPlugin.provideMainScreenService()
+
+ @Test
+ fun getRowsDataTest() = runTest{
+ val rows = service.getRowsData()
+ checkMediaCardRows(rows = rows)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaCatalogServiceTest.kt b/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaCatalogServiceTest.kt
new file mode 100644
index 0000000..0ec7d24
--- /dev/null
+++ b/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaCatalogServiceTest.kt
@@ -0,0 +1,39 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.demoplugin.TestPlugin
+import com.muedsa.tvbox.demoplugin.checkMediaCard
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class MediaCatalogServiceTest {
+
+ private val service = TestPlugin.provideMediaCatalogService()
+
+ @Test
+ fun getConfig_test() = runTest {
+ val config = service.getConfig()
+ check(config.pageSize > 0)
+ check(config.catalogOptions.isNotEmpty())
+ check(config.catalogOptions.size == config.catalogOptions.distinctBy { it.value }.size)
+ for (option in config.catalogOptions) {
+ check(option.items.isNotEmpty())
+ check(option.items.size == option.items.distinctBy { it.value }.size)
+ }
+ check(config.cardWidth > 0)
+ }
+
+ @Test
+ fun catalog_test() = runTest {
+ val config = service.getConfig()
+ val pagingResult = service.catalog(
+ options = config.catalogOptions,
+ loadKey = config.initKey,
+ loadSize = config.pageSize
+ )
+ check(pagingResult.list.isNotEmpty())
+ pagingResult.list.forEach {
+ checkMediaCard(it, config.cardType)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaDetailServiceTest.kt b/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaDetailServiceTest.kt
new file mode 100644
index 0000000..0898d4e
--- /dev/null
+++ b/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaDetailServiceTest.kt
@@ -0,0 +1,50 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.api.data.MediaCardType
+import com.muedsa.tvbox.demoplugin.TestPlugin
+import com.muedsa.tvbox.demoplugin.checkMediaCard
+import com.muedsa.tvbox.demoplugin.checkMediaCardRow
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class MediaDetailServiceTest {
+
+ private val service = TestPlugin.provideMediaDetailService()
+
+ @Test
+ fun getDetailData_test() = runTest{
+ val detail = service.getDetailData("17998", "17998")
+ check(detail.id.isNotEmpty())
+ check(detail.title.isNotEmpty())
+ check(detail.detailUrl.isNotEmpty())
+ check(detail.backgroundImageUrl.isNotEmpty())
+ checkMediaCard(detail.favoritedMediaCard, cardType = MediaCardType.STANDARD)
+ check(detail.favoritedMediaCard.cardWidth > 0)
+ check(detail.favoritedMediaCard.cardHeight > 0)
+ check(detail.playSourceList.isNotEmpty())
+ detail.playSourceList.forEach { mediaPlaySource ->
+ check(mediaPlaySource.id.isNotEmpty())
+ check(mediaPlaySource.name.isNotEmpty())
+ check(mediaPlaySource.episodeList.isNotEmpty())
+ mediaPlaySource.episodeList.forEach {
+ check(it.id.isNotEmpty())
+ check(it.name.isNotEmpty())
+ }
+ }
+ detail.rows.forEach {
+ checkMediaCardRow(it)
+ }
+ }
+
+ @Test
+ fun getEpisodePlayInfo_test() = runTest{
+ val detail = service.getDetailData("17998", "17998")
+ check(detail.playSourceList.isNotEmpty())
+ check(detail.playSourceList.flatMap { it.episodeList }.isNotEmpty())
+ val mediaPlaySource = detail.playSourceList[0]
+ val mediaEpisode = mediaPlaySource.episodeList[0]
+ val playInfo = service.getEpisodePlayInfo(mediaPlaySource, mediaEpisode)
+ check(playInfo.url.isNotEmpty())
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaSearchServiceTest.kt b/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaSearchServiceTest.kt
new file mode 100644
index 0000000..7592851
--- /dev/null
+++ b/app/src/test/java/com/muedsa/tvbox/demoplugin/service/MediaSearchServiceTest.kt
@@ -0,0 +1,17 @@
+package com.muedsa.tvbox.demoplugin.service
+
+import com.muedsa.tvbox.demoplugin.TestPlugin
+import com.muedsa.tvbox.demoplugin.checkMediaCardRow
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class MediaSearchServiceTest {
+
+ private val service = TestPlugin.provideMediaSearchService()
+
+ @Test
+ fun searchMedias_test() = runTest {
+ val row = service.searchMedias("GIRLS BAND CRY")
+ checkMediaCardRow(row = row)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..26b575e
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..405f12d
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,15 @@
+[versions]
+agp = "8.7.3"
+kotlin = "2.1.0"
+junit4 = "4.13.2"
+kotlinxCoroutinesTest = "1.9.0"
+
+[libraries]
+junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..13d81c0
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+#Thu Oct 10 13:25:45 CST 2024
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..107acd3
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..34fd4fa
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,25 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "TvBoxDemoPlugin"
+include(":app")
+include(":api")
+project(":api").projectDir = rootDir.resolve("./TvBoxPlugin/api/")
\ No newline at end of file