mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-11-22 21:02:16 +03:00
feat(tracker): Add Jellyfin enhanced tracker (#1341)
This commit is contained in:
parent
b505d5965e
commit
c2ab0db7a2
6 changed files with 319 additions and 9 deletions
|
@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import dev.icerock.moko.resources.StringResource
|
import dev.icerock.moko.resources.StringResource
|
||||||
import eu.kanade.domain.track.service.TrackPreferences
|
import eu.kanade.domain.track.service.TrackPreferences
|
||||||
import eu.kanade.presentation.more.settings.Preference
|
import eu.kanade.presentation.more.settings.Preference
|
||||||
|
import eu.kanade.tachiyomi.data.track.EnhancedAnimeTracker
|
||||||
import eu.kanade.tachiyomi.data.track.EnhancedMangaTracker
|
import eu.kanade.tachiyomi.data.track.EnhancedMangaTracker
|
||||||
import eu.kanade.tachiyomi.data.track.Tracker
|
import eu.kanade.tachiyomi.data.track.Tracker
|
||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
|
@ -57,6 +58,7 @@ import kotlinx.collections.immutable.toImmutableList
|
||||||
import tachiyomi.core.i18n.stringResource
|
import tachiyomi.core.i18n.stringResource
|
||||||
import tachiyomi.core.util.lang.launchIO
|
import tachiyomi.core.util.lang.launchIO
|
||||||
import tachiyomi.core.util.lang.withUIContext
|
import tachiyomi.core.util.lang.withUIContext
|
||||||
|
import tachiyomi.domain.source.anime.service.AnimeSourceManager
|
||||||
import tachiyomi.domain.source.manga.service.MangaSourceManager
|
import tachiyomi.domain.source.manga.service.MangaSourceManager
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
|
@ -86,7 +88,8 @@ object SettingsTrackingScreen : SearchableSettings {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val trackPreferences = remember { Injekt.get<TrackPreferences>() }
|
val trackPreferences = remember { Injekt.get<TrackPreferences>() }
|
||||||
val trackerManager = remember { Injekt.get<TrackerManager>() }
|
val trackerManager = remember { Injekt.get<TrackerManager>() }
|
||||||
val sourceManager = remember { Injekt.get<MangaSourceManager>() }
|
val mangaSourceManager = remember { Injekt.get<MangaSourceManager>() }
|
||||||
|
val animeSourceManager = remember { Injekt.get<AnimeSourceManager>() }
|
||||||
|
|
||||||
var dialog by remember { mutableStateOf<Any?>(null) }
|
var dialog by remember { mutableStateOf<Any?>(null) }
|
||||||
dialog?.run {
|
dialog?.run {
|
||||||
|
@ -111,15 +114,22 @@ object SettingsTrackingScreen : SearchableSettings {
|
||||||
.filter { it is EnhancedMangaTracker }
|
.filter { it is EnhancedMangaTracker }
|
||||||
.partition { service ->
|
.partition { service ->
|
||||||
val acceptedMangaSources = (service as EnhancedMangaTracker).getAcceptedSources()
|
val acceptedMangaSources = (service as EnhancedMangaTracker).getAcceptedSources()
|
||||||
sourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedMangaSources }
|
mangaSourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedMangaSources }
|
||||||
}
|
}
|
||||||
var enhancedMangaTrackerInfo = stringResource(MR.strings.enhanced_tracking_info)
|
val enhancedAnimeTrackers = trackerManager.trackers
|
||||||
if (enhancedMangaTrackers.second.isNotEmpty()) {
|
.filter { it is EnhancedAnimeTracker }
|
||||||
val missingMangaSourcesInfo = stringResource(
|
.partition { service ->
|
||||||
|
val acceptedAnimeSources = (service as EnhancedAnimeTracker).getAcceptedSources()
|
||||||
|
animeSourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedAnimeSources }
|
||||||
|
}
|
||||||
|
|
||||||
|
var enhancedTrackerInfo = stringResource(MR.strings.enhanced_tracking_info)
|
||||||
|
if (enhancedMangaTrackers.second.isNotEmpty() || enhancedAnimeTrackers.second.isNotEmpty()) {
|
||||||
|
val missingSourcesInfo = stringResource(
|
||||||
MR.strings.enhanced_services_not_installed,
|
MR.strings.enhanced_services_not_installed,
|
||||||
enhancedMangaTrackers.second.joinToString { it.name },
|
(enhancedMangaTrackers.second + enhancedAnimeTrackers.second).joinToString { it.name },
|
||||||
)
|
)
|
||||||
enhancedMangaTrackerInfo += "\n\n$missingMangaSourcesInfo"
|
enhancedTrackerInfo += "\n\n$missingSourcesInfo"
|
||||||
}
|
}
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
|
@ -219,7 +229,16 @@ object SettingsTrackingScreen : SearchableSettings {
|
||||||
login = { (service as EnhancedMangaTracker).loginNoop() },
|
login = { (service as EnhancedMangaTracker).loginNoop() },
|
||||||
logout = service::logout,
|
logout = service::logout,
|
||||||
)
|
)
|
||||||
} + listOf(Preference.PreferenceItem.InfoPreference(enhancedMangaTrackerInfo))
|
} +
|
||||||
|
enhancedAnimeTrackers.first
|
||||||
|
.map { service ->
|
||||||
|
Preference.PreferenceItem.TrackerPreference(
|
||||||
|
title = service.name,
|
||||||
|
tracker = service,
|
||||||
|
login = { (service as EnhancedAnimeTracker).loginNoop() },
|
||||||
|
logout = service::logout,
|
||||||
|
)
|
||||||
|
} + listOf(Preference.PreferenceItem.InfoPreference(enhancedTrackerInfo))
|
||||||
).toImmutableList(),
|
).toImmutableList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
||||||
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
|
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
|
||||||
|
import eu.kanade.tachiyomi.data.track.jellyfin.Jellyfin
|
||||||
import eu.kanade.tachiyomi.data.track.kavita.Kavita
|
import eu.kanade.tachiyomi.data.track.kavita.Kavita
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
||||||
import eu.kanade.tachiyomi.data.track.komga.Komga
|
import eu.kanade.tachiyomi.data.track.komga.Komga
|
||||||
|
@ -19,6 +20,7 @@ class TrackerManager(context: Context) {
|
||||||
const val KITSU = 3L
|
const val KITSU = 3L
|
||||||
const val KAVITA = 8L
|
const val KAVITA = 8L
|
||||||
const val SIMKL = 101L
|
const val SIMKL = 101L
|
||||||
|
const val JELLYFIN = 102L
|
||||||
}
|
}
|
||||||
|
|
||||||
val myAnimeList = MyAnimeList(1L)
|
val myAnimeList = MyAnimeList(1L)
|
||||||
|
@ -31,8 +33,12 @@ class TrackerManager(context: Context) {
|
||||||
val kavita = Kavita(KAVITA)
|
val kavita = Kavita(KAVITA)
|
||||||
val suwayomi = Suwayomi(9L)
|
val suwayomi = Suwayomi(9L)
|
||||||
val simkl = Simkl(SIMKL)
|
val simkl = Simkl(SIMKL)
|
||||||
|
val jellyfin = Jellyfin(JELLYFIN)
|
||||||
|
|
||||||
val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi, simkl)
|
val trackers = listOf(
|
||||||
|
myAnimeList, aniList, kitsu, shikimori, bangumi,
|
||||||
|
komga, mangaUpdates, kavita, suwayomi, simkl, jellyfin,
|
||||||
|
)
|
||||||
|
|
||||||
fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
|
fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,103 @@
|
||||||
|
package eu.kanade.tachiyomi.data.track.jellyfin
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import dev.icerock.moko.resources.StringResource
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.animesource.AnimeSource
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.anime.AnimeTrack
|
||||||
|
import eu.kanade.tachiyomi.data.track.AnimeTracker
|
||||||
|
import eu.kanade.tachiyomi.data.track.BaseTracker
|
||||||
|
import eu.kanade.tachiyomi.data.track.EnhancedAnimeTracker
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import okhttp3.Dns
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import tachiyomi.domain.entries.anime.model.Anime
|
||||||
|
import tachiyomi.i18n.MR
|
||||||
|
import tachiyomi.domain.track.anime.model.AnimeTrack as DomainTrack
|
||||||
|
|
||||||
|
class Jellyfin(id: Long) : BaseTracker(id, "Jellyfin"), EnhancedAnimeTracker, AnimeTracker {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val UNSEEN = 1
|
||||||
|
const val WATCHING = 2
|
||||||
|
const val COMPLETED = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
override val client: OkHttpClient =
|
||||||
|
networkService.client.newBuilder()
|
||||||
|
.dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val api by lazy { JellyfinApi(id, client) }
|
||||||
|
|
||||||
|
override fun getLogo() = R.drawable.ic_tracker_jellyfin
|
||||||
|
|
||||||
|
override fun getLogoColor() = Color.rgb(0, 11, 37)
|
||||||
|
|
||||||
|
override fun getStatusListAnime() = listOf(UNSEEN, WATCHING, COMPLETED)
|
||||||
|
|
||||||
|
override fun getStatus(status: Int): StringResource? = when (status) {
|
||||||
|
UNSEEN -> MR.strings.unseen
|
||||||
|
WATCHING -> MR.strings.watching
|
||||||
|
COMPLETED -> MR.strings.completed
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getWatchingStatus(): Int = WATCHING
|
||||||
|
|
||||||
|
override fun getRewatchingStatus(): Int = -1
|
||||||
|
|
||||||
|
override fun getCompletionStatus(): Int = COMPLETED
|
||||||
|
|
||||||
|
override fun getScoreList(): ImmutableList<String> = persistentListOf()
|
||||||
|
|
||||||
|
override fun displayScore(track: DomainTrack): String = ""
|
||||||
|
|
||||||
|
override suspend fun update(track: AnimeTrack, didWatchEpisode: Boolean): AnimeTrack {
|
||||||
|
return api.updateProgress(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun bind(track: AnimeTrack, hasSeenEpisodes: Boolean): AnimeTrack {
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun searchAnime(query: String): List<AnimeTrackSearch> =
|
||||||
|
throw Exception("Not used")
|
||||||
|
|
||||||
|
override suspend fun refresh(track: AnimeTrack): AnimeTrack {
|
||||||
|
val remoteTrack = api.getTrackSearch(track.tracking_url)
|
||||||
|
track.copyPersonalFrom(remoteTrack)
|
||||||
|
track.total_episodes = remoteTrack.total_episodes
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(username: String, password: String) {
|
||||||
|
saveCredentials("user", "pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loginNoop() {
|
||||||
|
saveCredentials("user", "pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedSources() = listOf("eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin")
|
||||||
|
|
||||||
|
override suspend fun match(anime: Anime): AnimeTrackSearch? =
|
||||||
|
try {
|
||||||
|
api.getTrackSearch(anime.url)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isTrackFrom(track: DomainTrack, anime: Anime, source: AnimeSource?): Boolean =
|
||||||
|
track.remoteUrl == anime.url && source?.let { accept(it) } == true
|
||||||
|
|
||||||
|
override fun migrateTrack(track: DomainTrack, anime: Anime, newSource: AnimeSource): DomainTrack? {
|
||||||
|
return if (accept(newSource)) {
|
||||||
|
track.copy(remoteUrl = anime.url)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,160 @@
|
||||||
|
package eu.kanade.tachiyomi.data.track.jellyfin
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.anime.AnimeTrack
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import logcat.LogPriority
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import tachiyomi.core.util.lang.withIOContext
|
||||||
|
import tachiyomi.core.util.system.logcat
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class JellyfinApi(
|
||||||
|
private val trackId: Long,
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
) {
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
suspend fun getTrackSearch(url: String): AnimeTrackSearch =
|
||||||
|
withIOContext {
|
||||||
|
try {
|
||||||
|
val httpUrl = url.toHttpUrl()
|
||||||
|
val fragment = httpUrl.fragment!!
|
||||||
|
|
||||||
|
val track = with(json) {
|
||||||
|
client.newCall(GET(url))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<ItemDto>()
|
||||||
|
.toTrack()
|
||||||
|
}.apply { tracking_url = url }
|
||||||
|
|
||||||
|
when {
|
||||||
|
fragment.startsWith("seriesId") -> {
|
||||||
|
getTrackFromSeries(track, httpUrl)
|
||||||
|
}
|
||||||
|
else -> track
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.WARN, e) { "Could not get item: $url" }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ItemDto.toTrack(): AnimeTrackSearch = AnimeTrackSearch.create(
|
||||||
|
trackId,
|
||||||
|
).also {
|
||||||
|
it.title = name
|
||||||
|
it.total_episodes = 1
|
||||||
|
if (userData.played) {
|
||||||
|
it.last_episode_seen = 1F
|
||||||
|
it.status = Jellyfin.COMPLETED
|
||||||
|
} else {
|
||||||
|
it.last_episode_seen = 0F
|
||||||
|
it.status = Jellyfin.UNSEEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEpisodesUrl(url: HttpUrl): HttpUrl {
|
||||||
|
val apiKey = url.queryParameter("api_key")!!
|
||||||
|
val fragment = url.fragment!!
|
||||||
|
|
||||||
|
return url.newBuilder().apply {
|
||||||
|
encodedPath("/")
|
||||||
|
fragment(null)
|
||||||
|
encodedQuery(null)
|
||||||
|
|
||||||
|
addPathSegment("Shows")
|
||||||
|
addPathSegment(fragment.split(",").last())
|
||||||
|
addPathSegment("Episodes")
|
||||||
|
addQueryParameter("api_key", apiKey)
|
||||||
|
addQueryParameter("seasonId", url.pathSegments.last())
|
||||||
|
addQueryParameter("userId", url.pathSegments[1])
|
||||||
|
addQueryParameter("Fields", "Overview,MediaSources")
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getTrackFromSeries(track: AnimeTrackSearch, url: HttpUrl): AnimeTrackSearch {
|
||||||
|
val episodesUrl = getEpisodesUrl(url)
|
||||||
|
|
||||||
|
val episodes = with(json) {
|
||||||
|
client.newCall(GET(episodesUrl))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<ItemsDto>()
|
||||||
|
}.items
|
||||||
|
|
||||||
|
val totalEpisodes = episodes.last().indexNumber!!
|
||||||
|
val firstUnwatched = episodes.indexOfFirst { !it.userData.played }
|
||||||
|
|
||||||
|
if (firstUnwatched == 0) {
|
||||||
|
return track.apply {
|
||||||
|
this.total_episodes = totalEpisodes
|
||||||
|
this.last_episode_seen = 0F
|
||||||
|
this.status = Jellyfin.UNSEEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstUnwatched == -1) {
|
||||||
|
return track.apply {
|
||||||
|
this.total_episodes = totalEpisodes
|
||||||
|
this.last_episode_seen = totalEpisodes.toFloat()
|
||||||
|
this.status = Jellyfin.COMPLETED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastContinuousSeen = episodes[firstUnwatched - 1].indexNumber!!
|
||||||
|
|
||||||
|
return track.apply {
|
||||||
|
this.total_episodes = totalEpisodes
|
||||||
|
this.last_episode_seen = lastContinuousSeen.toFloat()
|
||||||
|
this.status = Jellyfin.WATCHING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProgress(track: AnimeTrack): AnimeTrack {
|
||||||
|
val httpUrl = track.tracking_url.toHttpUrl()
|
||||||
|
val fragment = httpUrl.fragment!!
|
||||||
|
|
||||||
|
val itemId = if (fragment.startsWith("movie")) {
|
||||||
|
httpUrl.pathSegments.last()
|
||||||
|
} else {
|
||||||
|
val episodesUrl = getEpisodesUrl(httpUrl)
|
||||||
|
val episodes = with(json) {
|
||||||
|
client.newCall(GET(episodesUrl))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<ItemsDto>()
|
||||||
|
}.items
|
||||||
|
episodes.first {
|
||||||
|
it.indexNumber!!.equalsTo(track.last_episode_seen)
|
||||||
|
}.id
|
||||||
|
}
|
||||||
|
|
||||||
|
val time = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").format(Date())
|
||||||
|
val postUrl = httpUrl.newBuilder().apply {
|
||||||
|
fragment(null)
|
||||||
|
removePathSegment(3)
|
||||||
|
removePathSegment(2)
|
||||||
|
addPathSegment("PlayedItems")
|
||||||
|
addPathSegment(itemId)
|
||||||
|
addQueryParameter("DatePlayed", time)
|
||||||
|
}.build().toString()
|
||||||
|
|
||||||
|
client.newCall(
|
||||||
|
POST(postUrl),
|
||||||
|
).awaitSuccess()
|
||||||
|
|
||||||
|
return getTrackSearch(track.tracking_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.equalsTo(other: Float): Boolean {
|
||||||
|
return abs(this - other) < 0.001
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package eu.kanade.tachiyomi.data.track.jellyfin
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemDto(
|
||||||
|
@SerialName("Name") val name: String,
|
||||||
|
@SerialName("Id") val id: String,
|
||||||
|
@SerialName("UserData") val userData: UserDataDto,
|
||||||
|
@SerialName("IndexNumber") val indexNumber: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserDataDto(
|
||||||
|
@SerialName("Played") val played: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemsDto(
|
||||||
|
@SerialName("Items") val items: List<ItemDto>,
|
||||||
|
)
|
BIN
app/src/main/res/drawable-nodpi/ic_tracker_jellyfin.webp
Normal file
BIN
app/src/main/res/drawable-nodpi/ic_tracker_jellyfin.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
Loading…
Reference in a new issue