diff --git a/app/shared/data/common/data/subject/PackedDate.kt b/app/shared/data/common/data/subject/PackedDate.kt new file mode 100644 index 0000000000..cb75c160bc --- /dev/null +++ b/app/shared/data/common/data/subject/PackedDate.kt @@ -0,0 +1,78 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package me.him188.ani.app.data.subject + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.serialization.Serializable + +@Immutable +@JvmInline +@Serializable +value class PackedDate @PublishedApi internal constructor( + @JvmField + @PublishedApi + internal val packed: Int +) { + inline val isValid: Boolean get() = packed != Int.MAX_VALUE + + inline val year: Int get() = if (isValid) DatePacker.unpack1(packed) else 0 + inline val rawMonth: Int get() = if (isValid) DatePacker.unpack2(packed) else 0 + inline val day: Int get() = if (isValid) DatePacker.unpack3(packed) else 0 + + @Stable + val coercedMonth: Int + get() = when (rawMonth) { + 12, in 1..2 -> 1 + in 3..5 -> 4 + in 6..8 -> 7 + in 9..11 -> 10 + else -> 0 + } + + companion object { + @JvmStatic + val Invalid = PackedDate(Int.MAX_VALUE) + + + /** + * @param date `2024-05-18` + */ + fun parseFromDate(date: String): PackedDate { + val split = date.split("-") + if (split.size != 3) return Invalid + return PackedDate( + split[0].toIntOrNull() ?: return Invalid, + split[1].toIntOrNull() ?: return Invalid, + split[2].toIntOrNull() ?: return Invalid + ) + } + } +} + +@Stable +inline fun PackedDate( + year: Int, + month: Int, + day: Int, +): PackedDate = if (year in 0..9999 && month in 1..12 && day in 1..31) { + PackedDate(DatePacker.pack(year, month, day)) +} else { + PackedDate.Invalid // invalid +} + +@Suppress("NOTHING_TO_INLINE") +@PublishedApi +internal object DatePacker { + inline fun pack( + val1: Int, // short + val2: Int, // byte + val3: Int, // byte + ): Int { + return val1.shl(16) or val2.shl(8) or val3 + } + + inline fun unpack1(value: Int): Int = value.shr(16).and(0xFFFF) + inline fun unpack2(value: Int): Int = value.shr(8).and(0xFF) + inline fun unpack3(value: Int): Int = value.and(0xFF) +} diff --git a/app/shared/data/common/data/subject/SubjectInfo.kt b/app/shared/data/common/data/subject/SubjectInfo.kt new file mode 100644 index 0000000000..d3d68c18f5 --- /dev/null +++ b/app/shared/data/common/data/subject/SubjectInfo.kt @@ -0,0 +1,120 @@ +package me.him188.ani.app.data.subject + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import org.openapitools.client.models.Subject + +/** + * 详细信息. + */ +@Immutable +@Serializable +class SubjectInfo( + // 可搜索 "吹响!悠风号 第三季.json" 看示例 + val name: String = "", + val nameCn: String = "", + val summary: String = "", + val nsfw: Boolean = false, + val locked: Boolean = false, + /* TV, Web, 欧美剧, PS4... */ + val platform: String = "", +// val images: Images, + /* 书籍条目的册数,由旧服务端从wiki中解析 */ + val volumes: Int = 0, + /* 由旧服务端从wiki中解析,对于书籍条目为`话数` */ + val eps: Int = 0, + /* 数据库中的章节数量 */ + val totalEpisodes: Int = 0, +// val rating: Rating, + val tags: List = emptyList(), + /* air date in `YYYY-MM-DD` format */ + val date: String? = null, + val infobox: List = emptyList(), +) { + val publishDate: PackedDate = if (date == null) PackedDate.Invalid else PackedDate.parseFromDate(date) + + /** + * 主要显示名称 + */ + val displayName: String get() = nameCn.takeIf { it.isNotBlank() } ?: name + + /** + * 主中文名, 主日文名, 以及所有别名 + */ + val allNames by lazy(LazyThreadSafetyMode.PUBLICATION) { + buildList { + add(name) + add(nameCn) + (infobox.firstOrNull { it.name == "别名" }?.value as? JsonArray) + ?.forEach { element -> // interesting fact, 如果 `element` 改名成 `name`, 编译器就会编译错 (runtime class cast exception) + when (element) { + is JsonPrimitive -> add(element.content) + is JsonObject -> (element["v"] as? JsonPrimitive)?.contentOrNull?.let { add(it) } + else -> {} + } + } + } + } + + companion object { + @Stable + @JvmStatic + val Empty = SubjectInfo() + } +} + + +@Serializable +@Immutable +class Tag( + val name: String, + val count: Int, +) + +@Serializable +@Immutable +class InfoboxItem( + val name: String, + val value: JsonElement, +) + +fun Subject.createSubjectInfo(): SubjectInfo { + return SubjectInfo( + name = name, + nameCn = nameCn, + summary = this.summary, + nsfw = this.nsfw, + locked = this.locked, + platform = this.platform, + volumes = this.volumes, + eps = this.eps, + totalEpisodes = this.totalEpisodes, + date = this.date, + tags = this.tags.map { Tag(it.name, it.count) }, + infobox = this.infobox?.map { InfoboxItem(it.key, convertToJsonElement(it.value)) }.orEmpty(), + ) +} + +private fun convertToJsonElement(value: Any?): JsonElement { + return when (value) { + null -> JsonNull + is String -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Array<*> -> JsonArray(value.map { it?.let { convertToJsonElement(it) } ?: JsonNull }) + is List<*> -> JsonArray(value.map { it?.let { convertToJsonElement(it) } ?: JsonNull }) + is Map<*, *> -> JsonObject( + value.map { (k, v) -> k.toString() to convertToJsonElement(v) }.toMap() + ) + + else -> throw IllegalArgumentException("Unsupported type: ${value::class.java}") + } +} + diff --git a/app/shared/data/common/data/subject/SubjectManager.kt b/app/shared/data/common/data/subject/SubjectManager.kt index 387abe1052..e36ab9ac9c 100644 --- a/app/shared/data/common/data/subject/SubjectManager.kt +++ b/app/shared/data/common/data/subject/SubjectManager.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.transform import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder @@ -120,7 +119,7 @@ interface SubjectManager { /** * 从缓存中获取条目, 若没有则从网络获取. */ - suspend fun getSubjectName(subjectId: Int): String + suspend fun getSubjectInfo(subjectId: Int): SubjectInfo /** * 从缓存中获取剧集, 若没有则从网络获取. @@ -206,11 +205,11 @@ class SubjectManagerImpl( } .flowOn(Dispatchers.Default) - override suspend fun getSubjectName(subjectId: Int): String { - findCachedSubjectCollection(subjectId)?.displayName?.let { return it } + override suspend fun getSubjectInfo(subjectId: Int): SubjectInfo { + findCachedSubjectCollection(subjectId)?.info?.let { return it } return runUntilSuccess { // TODO: we should unify how to compute display name from subject - subjectRepository.getSubject(subjectId)?.nameCNOrName() ?: error("Failed to get subject") + subjectRepository.getSubject(subjectId)?.createSubjectInfo() ?: error("Failed to get subject") } } @@ -270,7 +269,7 @@ class SubjectManagerImpl( cache.mutate { setEach({ it.subjectId == subjectId }) { copy( - episodes = _episodes.map { episode -> + _episodes = _episodes.map { episode -> episode.copy(type = EpisodeCollectionType.WATCHED) } ) @@ -294,7 +293,7 @@ class SubjectManagerImpl( cache.mutate { setEach({ it.subjectId == subjectId }) { - copy(episodes = _episodes.replaceAll({ it.episode.id == episodeId }) { + copy(_episodes = _episodes.replaceAll({ it.episode.id == episodeId }) { copy(type = collectionType.toEpisodeCollectionType()) }) } @@ -332,6 +331,7 @@ class SubjectManagerImpl( totalEps = episodes.size, _episodes = episodes, collectionType = type.toCollectionType(), + info = subject.createSubjectInfo(), ) } @@ -344,6 +344,7 @@ class SubjectManagerImpl( totalEps = episodes.size, _episodes = episodes, collectionType = type.toCollectionType(), + info = subject.createSubjectInfo(), ) } @@ -352,8 +353,8 @@ class SubjectManagerImpl( @Stable -@Serializable -class SubjectCollectionItem( +@Serializable(SubjectCollectionItem.Serializer::class) +data class SubjectCollectionItem( val subjectId: Int, val displayName: String, val image: String, @@ -364,20 +365,18 @@ class SubjectCollectionItem( val _episodes: List<@Serializable(UserEpisodeCollectionSerializer::class) UserEpisodeCollection>, val collectionType: UnifiedCollectionType, + val info: SubjectInfo, ) { - @Transient val isOnAir = run { _episodes.firstOrNull { it.episode.isOnAir() == true } != null } - @Transient val lastWatchedEpIndex = run { _episodes.indexOfLast { it.type == EpisodeCollectionType.WATCHED || it.type == EpisodeCollectionType.DISCARDED }.takeIf { it != -1 } } - @Transient val latestEp = run { _episodes.lastOrNull { it.episode.isOnAir() == false } ?: _episodes.lastOrNull { it.episode.isOnAir() != true } @@ -386,18 +385,14 @@ class SubjectCollectionItem( /** * 是否已经开播了第一集 */ - @Transient val hasStarted = _episodes.firstOrNull()?.episode?.isOnAir() == false - @Transient val episodes: List = _episodes.sortedBy { it.episode.sort } - @Transient val latestEpIndex: Int? = _episodes.indexOfFirst { it.episode.id == latestEp?.episode?.id } .takeIf { it != -1 } ?: _episodes.lastIndex.takeIf { it != -1 } - @Transient val onAirDescription = if (isOnAir) { if (latestEp == null) { "连载中" @@ -408,10 +403,8 @@ class SubjectCollectionItem( "已完结" } - @Transient val serialProgress = "全 $totalEps 话" - @Transient val continueWatchingStatus = run { val item = this when (item.lastWatchedEpIndex) { @@ -446,26 +439,28 @@ class SubjectCollectionItem( } } } - - fun copy( - subjectId: Int = this.subjectId, - displayName: String = this.displayName, - image: String = this.image, - rate: Int? = this.rate, - date: String? = this.date, - totalEps: Int = this.totalEps, - episodes: List = this._episodes, - collectionType: UnifiedCollectionType = this.collectionType, - ) = SubjectCollectionItem( - subjectId = subjectId, - displayName = displayName, - image = image, - rate = rate, - date = date, - totalEps = totalEps, - _episodes = episodes, - collectionType = collectionType, - ) +// +// fun copy( +// subjectId: Int = this.subjectId, +// displayName: String = this.displayName, +// image: String = this.image, +// rate: Int? = this.rate, +// date: String? = this.date, +// totalEps: Int = this.totalEps, +// episodes: List = this._episodes, +// collectionType: UnifiedCollectionType = this.collectionType, +// info: SubjectInfo = this.info, +// ) = SubjectCollectionItem( +// subjectId = subjectId, +// displayName = displayName, +// image = image, +// rate = rate, +// date = date, +// totalEps = totalEps, +// _episodes = episodes, +// collectionType = collectionType, +// info = info, +// ) override fun toString(): String { return "SubjectCollectionItem($displayName)" @@ -482,6 +477,7 @@ class SubjectCollectionItem( val totalEps: Int, val episodes: List<@Serializable(UserEpisodeCollectionSerializer::class) UserEpisodeCollection>, val collectionType: SubjectCollectionType?, + val info: SubjectInfo = SubjectInfo.Empty ) override val descriptor: SerialDescriptor get() = Delegate.serializer().descriptor @@ -497,6 +493,7 @@ class SubjectCollectionItem( totalEps = delegate.totalEps, _episodes = delegate.episodes, collectionType = delegate.collectionType.toCollectionType(), + info = delegate.info ) } @@ -512,6 +509,7 @@ class SubjectCollectionItem( totalEps = value.totalEps, episodes = value._episodes, collectionType = value.collectionType.toSubjectCollectionType(), + info = value.info ) ) } diff --git a/app/shared/data/desktopTest/data/subject/DatePackerTest.kt b/app/shared/data/desktopTest/data/subject/DatePackerTest.kt new file mode 100644 index 0000000000..b6eedb6386 --- /dev/null +++ b/app/shared/data/desktopTest/data/subject/DatePackerTest.kt @@ -0,0 +1,14 @@ +package me.him188.ani.app.data.subject + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DatePackerTest { + @Test + fun `can pack`() { + val pack = DatePacker.pack(2024, 5, 18) + assertEquals(2024, DatePacker.unpack1(pack)) + assertEquals(5, DatePacker.unpack2(pack)) + assertEquals(18, DatePacker.unpack3(pack)) + } +} diff --git "a/app/shared/data/desktopTest/testData/\345\220\271\345\223\215\357\274\201\346\202\240\351\243\216\345\217\267 \347\254\254\344\270\211\345\255\243.json" "b/app/shared/data/desktopTest/testData/\345\220\271\345\223\215\357\274\201\346\202\240\351\243\216\345\217\267 \347\254\254\344\270\211\345\255\243.json" new file mode 100644 index 0000000000..464c71b310 --- /dev/null +++ "b/app/shared/data/desktopTest/testData/\345\220\271\345\223\215\357\274\201\346\202\240\351\243\216\345\217\267 \347\254\254\344\270\211\345\255\243.json" @@ -0,0 +1,296 @@ +{ + "name": "響け!ユーフォニアム3", + "nameCn": "吹响!悠风号 第三季", + "summary": "春。北宇治高校吹奏楽部の3年、\r\n部長の黄前久美子は、期待と不安を抱いていた。\r\n\r\nどんな新入部員がやって来るのか、\r\n部長として皆をまとめていけるのか、\r\nそして悲願の目標である「全国大会金賞」を達成できるのか――。\r\n\r\nそんな思いを胸に久美子は部長として奔走する日々を送っているのだが、\r\n奏者としても久美子の前に大きな壁が現れる。\r\n\r\n全国大会出場常連の強豪校から転校してきた黒江真由。\r\n手にした楽器は久美子と同じ――ユーフォニアム。\r\n華やかな音色、卓越した演奏技術、\r\nそして穏やかな人柄で周囲からも慕われる、完璧な少女。\r\n\r\nしかし、そんな真由が時折のぞかせる影に\r\n久美子は何とも形容しがたい感情を抱く。\r\n\r\n「たかが部活なんだし、無理してしがみつくものじゃないと思う…」\r\n\r\n果たして久美子は真由にどう向き合うのか……。\r\n\r\nそして、吹奏楽に懸けた青春に“卒業”が近づいてきていた――。\r\n\r\n「いくよ、みんな」\r\n\r\n久美子、高校最後の1年が始まる!", + "platform": "TV", + "eps": 13, + "totalEpisodes": 13, + "tags": [ + { + "name": "京阿尼", + "count": 1313 + }, + { + "name": "2024年4月", + "count": 779 + }, + { + "name": "TV", + "count": 753 + }, + { + "name": "音乐", + "count": 693 + }, + { + "name": "京都动画", + "count": 600 + }, + { + "name": "校园", + "count": 587 + }, + { + "name": "轻小说改", + "count": 539 + }, + { + "name": "青春", + "count": 439 + }, + { + "name": "2024", + "count": 392 + }, + { + "name": "百合", + "count": 321 + }, + { + "name": "石原立也", + "count": 203 + }, + { + "name": "京都アニメーション", + "count": 198 + }, + { + "name": "有生之年", + "count": 158 + }, + { + "name": "小说改", + "count": 108 + }, + { + "name": "轻改", + "count": 92 + }, + { + "name": "花田十辉", + "count": 29 + }, + { + "name": "续作", + "count": 21 + }, + { + "name": "励志", + "count": 21 + }, + { + "name": "未定档", + "count": 16 + }, + { + "name": "吹响!悠风号", + "count": 16 + }, + { + "name": "京吹", + "count": 15 + }, + { + "name": "2024年", + "count": 11 + }, + { + "name": "花田十輝", + "count": 9 + }, + { + "name": "日本", + "count": 8 + }, + { + "name": "轻百合", + "count": 8 + }, + { + "name": "神作", + "count": 7 + }, + { + "name": "社团", + "count": 7 + }, + { + "name": "日常", + "count": 7 + }, + { + "name": "TVA", + "count": 6 + }, + { + "name": "治愈", + "count": 6 + } + ], + "date": "2024 年 4 月", + "infobox": [ + { + "name": "中文名", + "value": "吹响!悠风号 第三季" + }, + { + "name": "别名", + "value": [ + { + "v": "Hibike! Euphonium 3" + }, + { + "v": "吹响!上低音号 第三季" + }, + { + "v": "響け!ユーフォニアム 久美子3年生編" + }, + { + "v": "吹响!悠风号 久美子3年生篇" + }, + { + "v": "Sound! Euphonium 3" + }, + { + "v": "京吹部 第三季" + } + ] + }, + { + "name": "话数", + "value": "13" + }, + { + "name": "放送开始", + "value": "2024年4月7日" + }, + { + "name": "放送星期", + "value": "星期日" + }, + { + "name": "官方网站", + "value": "http://anime-eupho.com/" + }, + { + "name": "播放电视台", + "value": "NHK Eテレ" + }, + { + "name": "Copyright", + "value": "©武田綾乃・宝島社/『響け!』製作委員会2024" + }, + { + "name": "原作", + "value": "武田綾乃 (宝島社文庫『響け!ユーフォニアム 北宇治高校吹奏楽部、決意の最終楽章』)" + }, + { + "name": "导演", + "value": "石原立也" + }, + { + "name": "副导演", + "value": "小川太一" + }, + { + "name": "系列构成", + "value": "花田十輝" + }, + { + "name": "人物设定", + "value": "池田晶子、池田和美" + }, + { + "name": "总作画监督", + "value": "池田和美" + }, + { + "name": "道具设计", + "value": "高橋博行(乐器设定)" + }, + { + "name": "作画监督", + "value": "太田稔(乐器作画监督)" + }, + { + "name": "美术监督", + "value": "篠原睦雄" + }, + { + "name": "3DCG", + "value": "鵜ノ口穣二(3D美术)" + }, + { + "name": "色彩设计", + "value": "竹田明代" + }, + { + "name": "摄影监督", + "value": "髙尾一也" + }, + { + "name": "3DCG 导演", + "value": "冨板紀宏" + }, + { + "name": "剪辑", + "value": "重村建吾" + }, + { + "name": "动画制作", + "value": "京都アニメーション" + }, + { + "name": "音乐协力", + "value": "洗足学園音楽大学" + }, + { + "name": "演奏协力", + "value": "プログレッシブ!ウインド・オーケスト" + }, + { + "name": "监修", + "value": "大和田雅洋(吹奏乐监修)" + }, + { + "name": "主题歌演出", + "value": "TRUE(唐沢美帆) / 北宇治カルテット[黄前久美子(CV.黒沢ともよ), 加藤葉月(CV.朝井彩加), 川島緑輝(CV.豊田萌絵), 高坂麗奈(CV.安済知佳)]" + }, + { + "name": "主题歌作词", + "value": "唐沢美帆 / ZAQ" + }, + { + "name": "主题歌作曲", + "value": "トミタカズキ、hisakuni / ZAQ" + }, + { + "name": "主题歌编曲", + "value": "鈴木雅也、曽木琢磨、山本拓夫 / 白戸佑輔" + }, + { + "name": "製作", + "value": "『響け!』製作委員会2024(京都アニメーション、ポニーキャニオン、バンダイナムコミュージックライブ、楽音舎、ABCアニメーション);八田陽子、大熊一成、黒田学、鶴岡陽太、西出将之" + }, + { + "name": "企画", + "value": "企画协力:宇城卓秀、下村綾子、天野由衣子、中嶋嘉美、小林大介、福岡弘起" + }, + { + "name": "企划制作人", + "value": "八田英明" + }, + { + "name": "制片人", + "value": "瀬波里梨、中村伸一、臼倉竜太郎、安井一成、鎗水善史" + }, + { + "name": "音乐制作", + "value": "ランティス、ハートカンパニー" + }, + { + "name": "音乐制作人", + "value": "斎藤滋" + } + ] +} \ No newline at end of file diff --git a/app/shared/pages/episode-play/common/EpisodeViewModel.kt b/app/shared/pages/episode-play/common/EpisodeViewModel.kt index beb6138c82..7653813498 100644 --- a/app/shared/pages/episode-play/common/EpisodeViewModel.kt +++ b/app/shared/pages/episode-play/common/EpisodeViewModel.kt @@ -35,6 +35,7 @@ import me.him188.ani.app.data.media.resolver.UnsupportedMediaException import me.him188.ani.app.data.media.resolver.VideoSourceResolutionException import me.him188.ani.app.data.media.resolver.VideoSourceResolver import me.him188.ani.app.data.repositories.PreferencesRepository +import me.him188.ani.app.data.subject.SubjectInfo import me.him188.ani.app.data.subject.SubjectManager import me.him188.ani.app.navigation.BrowserNavigator import me.him188.ani.app.platform.Context @@ -74,12 +75,14 @@ import kotlin.time.Duration.Companion.milliseconds class SubjectPresentation( val title: String, val isPlaceholder: Boolean = false, + val info: SubjectInfo, ) { companion object { @Stable val Placeholder = SubjectPresentation( title = "placeholder", isPlaceholder = true, + info = SubjectInfo.Empty, ) } } @@ -202,8 +205,8 @@ private class EpisodeViewModelImpl( private val videoSourceResolver: VideoSourceResolver by inject() private val preferencesRepository: PreferencesRepository by inject() - private val subjectDisplayName = flowOf(subjectId).mapLatest { subjectId -> - subjectManager.getSubjectName(subjectId) + private val subjectInfo = flowOf(subjectId).mapLatest { subjectId -> + subjectManager.getSubjectInfo(subjectId) }.shareInBackground() // Media Selection @@ -278,9 +281,9 @@ private class EpisodeViewModelImpl( override val playerState: PlayerState = playerStateFactory.create(context, backgroundScope.coroutineContext) - override val subjectPresentation: SubjectPresentation by subjectDisplayName + override val subjectPresentation: SubjectPresentation by subjectInfo .map { - SubjectPresentation(title = it) + SubjectPresentation(title = it.displayName, info = it) } .produceState(SubjectPresentation.Placeholder) @@ -337,7 +340,9 @@ private class EpisodeViewModelImpl( danmakuManager.fetch( request = DanmakuSearchRequest( subjectId = subjectId, - subjectName = subject.title, + subjectPrimaryName = subject.info.displayName, + subjectNames = subject.info.allNames, + subjectPublishDate = subject.info.publishDate, episodeId = episodeId, episodeSort = EpisodeSort(episode.sort), episodeEp = EpisodeSort(episode.ep), diff --git a/danmaku/api/src/DanmakuProvider.kt b/danmaku/api/src/DanmakuProvider.kt index 93e736f4f9..01d82c7d7c 100644 --- a/danmaku/api/src/DanmakuProvider.kt +++ b/danmaku/api/src/DanmakuProvider.kt @@ -4,6 +4,7 @@ import io.ktor.client.HttpClientConfig import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging import kotlinx.serialization.Serializable +import me.him188.ani.app.data.subject.PackedDate import me.him188.ani.datasources.api.EpisodeSort import me.him188.ani.utils.ktor.createDefaultHttpClient import me.him188.ani.utils.logging.info @@ -32,7 +33,12 @@ interface DanmakuProvider : AutoCloseable { class DanmakuSearchRequest( val subjectId: Int, - val subjectName: String, + val subjectPrimaryName: String, + val subjectNames: List, + /** + * Cane be [PackedDate.Invalid] + */ + val subjectPublishDate: PackedDate, val episodeId: Int, val episodeSort: EpisodeSort, val episodeEp: EpisodeSort?, @@ -52,7 +58,11 @@ fun interface DanmakuMatcher { data class DanmakuEpisode( val id: String, val subjectName: String, - val episodeName: String + val episodeName: String, + /** + * 可能是系列内的, 也可能是单季的 + */ + val epOrSort: EpisodeSort?, ) object DanmakuMatchers { diff --git a/danmaku/dandanplay/src/DandanplayClient.kt b/danmaku/dandanplay/src/DandanplayClient.kt index fde243cbe5..8b80af943a 100644 --- a/danmaku/dandanplay/src/DandanplayClient.kt +++ b/danmaku/dandanplay/src/DandanplayClient.kt @@ -13,14 +13,39 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import me.him188.ani.danmaku.dandanplay.data.DandanplayDanmaku import me.him188.ani.danmaku.dandanplay.data.DandanplayDanmakuListResponse +import me.him188.ani.danmaku.dandanplay.data.DandanplayGetBangumiResponse import me.him188.ani.danmaku.dandanplay.data.DandanplayMatchVideoResponse import me.him188.ani.danmaku.dandanplay.data.DandanplaySearchEpisodeResponse +import me.him188.ani.danmaku.dandanplay.data.DandanplaySeasonSearchResponse import java.util.Locale import kotlin.time.Duration internal class DandanplayClient( private val client: HttpClient, ) { + suspend fun getSeasonAnimeList( + year: Int, + month: Int, + ): DandanplaySeasonSearchResponse { + // https://api.dandanplay.net/api/v2/bangumi/season/anime/2024/04 + val response = client.get("https://api.dandanplay.net/api/v2/bangumi/season/anime/$year/$month") { + accept(ContentType.Application.Json) + } + + return response.body() + } + + suspend fun searchSubject( + subjectName: String, + ): DandanplaySearchEpisodeResponse { + val response = client.get("https://api.dandanplay.net/api/v2/search/subject") { + accept(ContentType.Application.Json) + parameter("keyword", subjectName) + } + + return response.body() + } + suspend fun searchEpisode( subjectName: String, episodeName: String?, @@ -34,6 +59,16 @@ internal class DandanplayClient( return response.body() } + suspend fun getBangumiEpisodes( + bangumiId: Int, // 注意, 这是 dandanplay 的 id, 不是 Bangumi.tv 的 id + ): DandanplayGetBangumiResponse { + val response = client.get("https://api.dandanplay.net/api/v2/bangumi/$bangumiId") { + accept(ContentType.Application.Json) + } + + return response.body() + } + suspend fun matchVideo( filename: String, fileHash: String?, diff --git a/danmaku/dandanplay/src/DandanplayDanmakuProvider.kt b/danmaku/dandanplay/src/DandanplayDanmakuProvider.kt index 1438e8c5a4..953c1c444b 100644 --- a/danmaku/dandanplay/src/DandanplayDanmakuProvider.kt +++ b/danmaku/dandanplay/src/DandanplayDanmakuProvider.kt @@ -3,6 +3,7 @@ package me.him188.ani.danmaku.dandanplay import io.ktor.client.HttpClientConfig import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.HttpTimeout +import me.him188.ani.app.data.subject.SubjectInfo import me.him188.ani.danmaku.api.AbstractDanmakuProvider import me.him188.ani.danmaku.api.DanmakuEpisode import me.him188.ani.danmaku.api.DanmakuMatchers @@ -11,7 +12,10 @@ import me.him188.ani.danmaku.api.DanmakuProviderFactory import me.him188.ani.danmaku.api.DanmakuSearchRequest import me.him188.ani.danmaku.api.DanmakuSession import me.him188.ani.danmaku.api.TimeBasedDanmakuSession +import me.him188.ani.danmaku.dandanplay.data.SearchEpisodesAnime import me.him188.ani.danmaku.dandanplay.data.toDanmakuOrNull +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.logging.error import me.him188.ani.utils.logging.info import kotlin.coroutines.CoroutineContext @@ -49,36 +53,57 @@ class DandanplayDanmakuProvider( override suspend fun fetch( request: DanmakuSearchRequest, ): DanmakuSession? { - val searchEpisodeResponse = dandanplayClient.searchEpisode( - subjectName = request.subjectName.trim().substringBeforeLast(" "), - episodeName = null // 用我们的匹配算法 -// episodeName = "第${(request.episodeEp ?: request.episodeSort).toString().removePrefix("0")}话", - // 弹弹的是 EP 顺序 - // 弹弹数据库有时候会只有 "第x话" 没有具体标题, 所以不带标题搜索就够了 - ) - logger.info { "Ep search result: ${searchEpisodeResponse}}" } - val episodes = searchEpisodeResponse.animes.flatMap { it -> - it.episodes.map { ep -> - DanmakuEpisode( - id = ep.episodeId.toString(), - subjectName = it.animeTitle ?: "", - episodeName = ep.episodeTitle ?: "", - ) - } - } + // 获取剧集流程: + // + // 1. 获取该番剧所属季度的所有番的名字, 匹配 bangumi 条目所有别名 + // 2. 若失败, 用番剧名字搜索, 匹配 bangumi 条目所有别名 + // 3. 如果按别名精准匹配到了, 那就获取该番的所有剧集 + // 4. 如果没有, 那就提交条目名字给弹弹直接让弹弹获取相关剧集 (很不准) + // + // 匹配剧集流程: + // 1. 用剧集在系列中的序号 (sort) 匹配 + // 2. 用剧集在当前季度中的序号 (ep) 匹配 + // 3. 用剧集名字模糊匹配 + + val episodes: List? = + kotlin.runCatching { getEpisodesByExactSubjectMatch(request) } + .onFailure { + logger.error(it) { "Failed to fetch episodes by exact match" } + }.getOrNull() + ?: kotlin.runCatching { + getEpisodesByFuzzyEpisodeSearch(request) + }.onFailure { + logger.error(it) { "Failed to fetch episodes by fuzzy search" } + }.getOrNull() val matcher = DanmakuMatchers.mostRelevant( - request.subjectName, + request.subjectPrimaryName, "第${(request.episodeEp ?: request.episodeSort).toString().removePrefix("0")}话 " + request.episodeName ) - if (episodes.isNotEmpty()) { + // 用剧集编号匹配 + // 先用系列的, 因为系列的更大 + if (episodes != null) { + episodes.firstOrNull { it.epOrSort != null && it.epOrSort == request.episodeSort }?.let { + logger.info { "Matched episode by exact episodeSort: ${it.subjectName} - ${it.episodeName}" } + return createSession(it.id.toLong(), 0) + } + episodes.firstOrNull { it.epOrSort != null && it.epOrSort == request.episodeEp }?.let { + logger.info { "Matched episode by exact episodeEp: ${it.subjectName} - ${it.episodeName}" } + return createSession(it.id.toLong(), 0) + } + } + + // 用名字不精确匹配 + if (!episodes.isNullOrEmpty()) { matcher.match(episodes)?.let { logger.info { "Matched episode by ep search: ${it.subjectName} - ${it.episodeName}" } return createSession(it.id.toLong(), 0) } } + // 都不行, 那就用最不准的方法 + val resp = dandanplayClient.matchVideo( filename = request.filename, fileHash = request.fileHash, @@ -91,7 +116,9 @@ class DandanplayDanmakuProvider( matcher.match(resp.matches.map { DanmakuEpisode( it.episodeId.toString(), - it.animeTitle, it.episodeTitle, + it.animeTitle, + it.episodeTitle, + null ) })?.let { match -> resp.matches.first { it.episodeId.toString() == match.id } @@ -102,6 +129,86 @@ class DandanplayDanmakuProvider( return createSession(episodeId, (match.shift * 1000L).toLong()) } + /** + * 用尝试用 bangumi 给的名字 [SubjectInfo.allNames] 去精准匹配 + */ + private suspend fun DandanplayDanmakuProvider.getEpisodesByExactSubjectMatch( + request: DanmakuSearchRequest + ): List? { + if (!request.subjectPublishDate.isValid) return null + + // 将筛选范围缩小到季度 + val anime = getDandanplayAnimeIdOrNull(request) ?: return null + return dandanplayClient.getBangumiEpisodes(anime.bangumiId ?: anime.animeId) + .bangumi.episodes?.map { episode -> + DanmakuEpisode( + id = episode.episodeId.toString(), + subjectName = request.subjectPrimaryName, + episodeName = episode.episodeTitle, + epOrSort = episode.episodeNumber?.let { EpisodeSort(it) } + ) + } + } + + private suspend fun getEpisodesByFuzzyEpisodeSearch(request: DanmakuSearchRequest): List { + val searchEpisodeResponse = dandanplayClient.searchEpisode( + subjectName = request.subjectPrimaryName.trim().substringBeforeLast(" "), + episodeName = null // 用我们的匹配算法 + // episodeName = "第${(request.episodeEp ?: request.episodeSort).toString().removePrefix("0")}话", + // 弹弹的是 EP 顺序 + // 弹弹数据库有时候会只有 "第x话" 没有具体标题, 所以不带标题搜索就够了 + ) + logger.info { "Ep search result: ${searchEpisodeResponse}}" } + return searchEpisodeResponse.animes.flatMap { anime -> + anime.episodes.map { ep -> + DanmakuEpisode( + id = ep.episodeId.toString(), + subjectName = anime.animeTitle ?: "", + episodeName = ep.episodeTitle ?: "", + epOrSort = ep.episodeNumber?.let { EpisodeSort(it) } + ) + } + } + } + + private suspend fun getDandanplayAnimeIdOrNull(request: DanmakuSearchRequest): SearchEpisodesAnime? { + val date = request.subjectPublishDate + val mo = date.coercedMonth + if (mo == 0) return null + + val expectedNames = request.subjectNames.toSet() + + kotlin.runCatching { + // 搜索这个季度的所有的番, 然后用名字匹配 + // 不建议用名字去请求弹弹 play 搜索, 它的搜索很不准 + dandanplayClient.getSeasonAnimeList(date.year, date.coercedMonth) + }.onFailure { + logger.error(it) { "Failed to fetch season anime list" } + }.getOrNull() + ?.bangumiList + ?.firstOrNull { it.animeTitle in expectedNames } + ?.let { + logger.info { "Matched Dandanplay Anime in season using name: ${it.animeId} ${it.animeTitle}" } + return it + } + + + kotlin.runCatching { + // 用名字搜索 + dandanplayClient.searchSubject(request.subjectPrimaryName) + }.onFailure { + logger.error(it) { "Failed to fetch anime list by name" } + }.getOrNull() + ?.animes + ?.firstOrNull { it.animeTitle in expectedNames } + ?.let { + logger.info { "Matched Dandanplay Anime by search using name: ${it.animeId} ${it.animeTitle}" } + return it + } + + return null + } + private suspend fun createSession( episodeId: Long, shiftMillis: Long diff --git a/danmaku/dandanplay/src/data/DandanplayBangumi.kt b/danmaku/dandanplay/src/data/DandanplayBangumi.kt new file mode 100644 index 0000000000..90d9982a52 --- /dev/null +++ b/danmaku/dandanplay/src/data/DandanplayBangumi.kt @@ -0,0 +1,160 @@ +package me.him188.ani.danmaku.dandanplay.data + +import kotlinx.serialization.Serializable + +/* + * Generated by ChatGPT from https://api.dandanplay.net/swagger/ui/index#!/Bangumi/Bangumi_GetBangumiDetails + */ + +/** + * Bangumi Details Response Model + * @property bangumi 番剧详情 + * @property errorCode 错误代码,0表示没有发生错误,非0表示有错误,详细信息会包含在errorMessage属性中 + * @property success 接口是否调用成功 + * @property errorMessage 当发生错误时,说明错误具体原因 + */ +@Serializable +data class DandanplayBangumiDetailsResponse( + val bangumi: DandanplayBangumiDetails? = null, + val errorCode: Int, + val success: Boolean, + val errorMessage: String? = null +) + +/** + * Bangumi Details Model + * @property type 作品类型 = ['tvseries', 'tvspecial', 'ova', 'movie', 'musicvideo', 'web', 'other', 'jpmovie', 'jpdrama', 'unknown'] + * @property typeDescription 类型描述 + * @property titles 作品标题 + * @property episodes 剧集列表 + * @property summary 番剧简介 + * @property metadata 番剧元数据(名称、制作人员、配音人员等) + * @property bangumiUrl Bangumi.tv页面地址 + * @property userRating 用户个人评分(0-10) + * @property favoriteStatus 关注状态 = ['favorited', 'finished', 'abandoned'] + * @property comment 用户对此番剧的备注/评论/标签 + * @property ratingDetails 各个站点的评分详情 + * @property relateds 与此作品直接关联的其他作品(例如同一作品的不同季、剧场版、OVA等) + * @property similars 与此作品相似的其他作品 + * @property tags 标签列表 + * @property onlineDatabases 此作品在其他在线数据库/网站的对应url + * @property animeId 作品编号 + * @property animeTitle 作品标题 + * @property imageUrl 海报图片地址 + * @property searchKeyword 搜索关键词 + * @property isOnAir 是否正在连载中 + * @property airDay 周几上映,0代表周日,1-6代表周一至周六 + * @property isFavorited 当前用户是否已关注(无论是否为已弃番等附加状态) + * @property isRestricted 是否为限制级别的内容(例如属于R18分级) + * @property rating 番剧综合评分(综合多个来源的评分求出的加权平均值,0-10分) + */ +@Serializable +data class DandanplayBangumiDetails( + val type: String, + val typeDescription: String? = null, + val titles: List? = null, + val episodes: List? = null, + val summary: String? = null, + val metadata: List? = null, +// val bangumiUrl: String? = null, +// val userRating: Int, +// val favoriteStatus: String, +// val comment: String? = null, +// val ratingDetails: DandanplayInlineModel? = null, +// val relateds: List? = null, +// val similars: List? = null, +// val tags: List? = null, +// val onlineDatabases: List? = null, +// val animeId: Int, + val animeTitle: String? = null, +// val imageUrl: String? = null, +// val searchKeyword: String? = null, +// val isOnAir: Boolean, +// val airDay: Int, +// val isFavorited: Boolean, +// val isRestricted: Boolean, +// val rating: Double +) + +/** + * Bangumi Title Model + * @property language 语言 + * @property title 标题 + */ +@Serializable +data class DandanplayBangumiTitle( + val language: String? = null, + val title: String? = null +) + +/** + * Bangumi Episode Model + * @property episodeId 剧集ID(弹幕库编号) + * @property episodeTitle 剧集完整标题 + * @property episodeNumber 剧集短标题(可以用来排序,非纯数字,可能包含字母) + * @property lastWatched 上次观看时间(服务器时间,即北京时间) + * @property airDate 本集上映时间(当地时间) + */ +@Serializable +data class DandanplayBangumiEpisode( + val episodeId: Int, + val episodeTitle: String, + val episodeNumber: String?, + val lastWatched: String?, + val airDate: String? +) + +/** + * Inline Model for Rating Details + */ +@Serializable +class DandanplayInlineModel + +/** + * Bangumi Intro Model + * @property animeId 作品编号 + * @property animeTitle 作品标题 + * @property imageUrl 海报图片地址 + * @property searchKeyword 搜索关键词 + * @property isOnAir 是否正在连载中 + * @property airDay 周几上映,0代表周日,1-6代表周一至周六 + * @property isFavorited 当前用户是否已关注(无论是否为已弃番等附加状态) + * @property isRestricted 是否为限制级别的内容(例如属于R18分级) + * @property rating 番剧综合评分(综合多个来源的评分求出的加权平均值,0-10分) + */ +@Serializable +data class DandanplayBangumiIntro( + val animeId: Int, + val animeTitle: String? = null, + val imageUrl: String? = null, + val searchKeyword: String? = null, + val isOnAir: Boolean, + val airDay: Int, + val isFavorited: Boolean, + val isRestricted: Boolean, + val rating: Double +) + +/** + * Bangumi Tag Model + * @property id 标签编号 + * @property name 标签内容 + * @property count 观众为此标签+1次数 + */ +@Serializable +data class DandanplayBangumiTag( + val id: Int, + val name: String? = null, + val count: Int +) + +/** + * Bangumi Online Database Model + * @property name 网站名称 + * @property url 网址 + */ +@Serializable +data class DandanplayBangumiOnlineDatabase( + val name: String? = null, + val url: String? = null +) diff --git a/danmaku/dandanplay/src/data/MatchEpisode.kt b/danmaku/dandanplay/src/data/MatchEpisode.kt index 0900f2fc67..0757f3fcb8 100644 --- a/danmaku/dandanplay/src/data/MatchEpisode.kt +++ b/danmaku/dandanplay/src/data/MatchEpisode.kt @@ -32,11 +32,30 @@ data class DandanplaySearchEpisodeResponse( val errorMessage: String? = null, ) +@Serializable +data class DandanplayGetBangumiResponse( + val hasMore: Boolean = false, + val bangumi: DandanplayBangumiDetails, + val errorCode: Int = 0, + val success: Boolean = true, + val errorMessage: String? = null, +) + +@Serializable +data class DandanplaySeasonSearchResponse( + val hasMore: Boolean = false, + val bangumiList: List = listOf(), + val errorCode: Int = 0, + val success: Boolean = true, + val errorMessage: String? = null, +) + @Serializable data class SearchEpisodesAnime( val animeId: Int, + val bangumiId: Int? = null, val animeTitle: String? = null, - val type: String, +// val type: String? = null, val typeDescription: String? = null, val episodes: List = listOf(), ) @@ -45,4 +64,5 @@ data class SearchEpisodesAnime( data class SearchEpisodeDetails( val episodeId: Int, val episodeTitle: String? = null, + val episodeNumber: String? = null, // 可能没有, 我随便加的 ) \ No newline at end of file diff --git a/data-sources/api/src/EpisodeSort.kt b/data-sources/api/src/EpisodeSort.kt index 3e7008b601..1f0a7fa3a0 100644 --- a/data-sources/api/src/EpisodeSort.kt +++ b/data-sources/api/src/EpisodeSort.kt @@ -11,6 +11,10 @@ import java.math.BigDecimal * * - [Normal] 代表普通正片剧集, 例如 "01", "24.5". 注意, 只有整数和 ".5" 的浮点数会被解析为 Normal 类型. * - [Special] 代表任何其他剧集, 统称为特殊剧集, 例如 "OVA", "SP". + * + * + * - Sort: 在系列中的集数, 例如第二季的第一集为 26 + * - Ep: 在当前季度中的集数, 例如第二季的第一集为 01 */ @Serializable @Stable