diff --git a/src/all/pixiv/AndroidManifest.xml b/src/all/pixiv/AndroidManifest.xml new file mode 100644 index 000000000..c596df116 --- /dev/null +++ b/src/all/pixiv/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/pixiv/build.gradle b/src/all/pixiv/build.gradle index ffe790c56..798f5569d 100644 --- a/src/all/pixiv/build.gradle +++ b/src/all/pixiv/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Pixiv' extClass = '.PixivFactory' - extVersionCode = 9 + extVersionCode = 10 isNsfw = true } diff --git a/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/Pixiv.kt b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/Pixiv.kt index 1e3ff58cd..4df7423f8 100644 --- a/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/Pixiv.kt +++ b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/Pixiv.kt @@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.asJsoup import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -38,14 +39,26 @@ class Pixiv(override val lang: String) : HttpSource() { client.newCall(request.url(url.build()).build()).execute() } + class PixivApiException(message: String? = null) : Exception(message, null) + private inner class ApiCall(href: String?) : HttpCall(href) { init { url.addEncodedQueryParameter("lang", lang) request.addHeader("Accept", "application/json") } - inline fun executeApi(): T = - json.decodeFromString>(execute().body.string()).body!! + /** + * Sends the previously constructed API call to the Pixiv API. + * If the server reports an error, A [PixivApiException] will be + * returned as a [Result.failure]. + */ + inline fun executeApi(): Result { + val resp = json.decodeFromString(execute().body.string()) + if (resp.error) { + return Result.failure(PixivApiException(resp.message)) + } + return Result.success(json.decodeFromJsonElement(resp.body!!)) + } } private var popularMangaNextPage = 1 @@ -59,13 +72,13 @@ class Pixiv(override val lang: String) : HttpSource() { for (p in countUp(start = 1)) { call.url.setEncodedQueryParameter("page", p.toString()) - val entries = call.executeApi().ranking!! + val entries = call.executeApi().getOrThrow().ranking!! if (entries.isEmpty()) break val call = ApiCall("/touch/ajax/illust/details/many") entries.forEach { call.url.addEncodedQueryParameter("illust_ids[]", it.illustId!!) } - call.executeApi().illust_details!!.forEach { yield(it) } + call.executeApi().getOrThrow().illust_details!!.forEach { yield(it) } } } .toSManga() @@ -84,7 +97,39 @@ class Pixiv(override val lang: String) : HttpSource() { private var searchHash: Int? = null private lateinit var searchIterator: Iterator - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + override fun fetchSearchManga( + page: Int, + query: String, + filters: FilterList, + ): Observable { + val target = PixivTarget.fromUri(query) /*?: PixivTarget.fromSearchQuery(query)*/ + + val singleResult = { manga: SManga? -> + Observable.just( + MangasPage( + if (manga != null) { + listOf(manga) + } else { + emptyList() + }, + hasNextPage = false, + ), + ) + } + + // Deeplink selection of specific IDs: simply fetch the single object and return + when (target) { + is PixivTarget.Illustration -> + singleResult(getIllustCached(target.illustId)?.toSManga()) + is PixivTarget.Series -> { + // TODO: caching! + val series = ApiCall("/touch/ajax/illust/series/${target.seriesId}") + .executeApi().getOrNull()?.series + singleResult(series?.toSManga()) + } + else -> null + }?.let { return it } + val filters = filters.list as PixivFilters val hash = Pair(query, filters.toList()).hashCode() @@ -94,7 +139,18 @@ class Pixiv(override val lang: String) : HttpSource() { lateinit var searchSequence: Sequence lateinit var predicates: List<(PixivIllust) -> Boolean> - if (query.isNotBlank()) { + // TODO: it would be useful to allow multiple user: tags in the query + if (target is PixivTarget.User) { + searchSequence = makeUserIdIllustSearchSequence( + id = target.userId, + type = filters.type, + ) + + predicates = buildList { + filters.makeTagsPredicate()?.let(::add) + filters.makeRatingPredicate()?.let(::add) + } + } else if (query.isNotBlank()) { searchSequence = makeIllustSearchSequence( word = query, order = filters.order, @@ -169,7 +225,7 @@ class Pixiv(override val lang: String) : HttpSource() { for (p in countUp(start = 1)) { call.url.setEncodedQueryParameter("p", p.toString()) - val illusts = call.executeApi().illusts!! + val illusts = call.executeApi().getOrThrow().illusts!! if (illusts.isEmpty()) break for (illust in illusts) { @@ -185,9 +241,6 @@ class Pixiv(override val lang: String) : HttpSource() { val searchUsers = HttpCall("/search_user.php?s_mode=s_usr") .apply { url.addQueryParameter("nick", nick) } - val fetchUserIllusts = ApiCall("/touch/ajax/user/illusts") - .apply { type?.let { url.setEncodedQueryParameter("type", it) } } - for (p in countUp(start = 1)) { searchUsers.url.setEncodedQueryParameter("p", p.toString()) @@ -198,44 +251,71 @@ class Pixiv(override val lang: String) : HttpSource() { if (userIds.isEmpty()) break for (userId in userIds) { - fetchUserIllusts.url.setEncodedQueryParameter("id", userId) - - for (p in countUp(start = 1)) { - fetchUserIllusts.url.setEncodedQueryParameter("p", p.toString()) - - val illusts = fetchUserIllusts.executeApi().illusts!! - if (illusts.isEmpty()) break - - yieldAll(illusts) - } + yieldAll(makeUserIdIllustSearchSequence(userId, type)) } } } + private fun makeUserIdIllustSearchSequence(id: String, type: String?) = sequence { + val fetchUserIllusts = ApiCall("/touch/ajax/user/illusts") + .apply { + type?.let { url.setEncodedQueryParameter("type", it) } + url.setEncodedQueryParameter("id", id) + } + + for (p in countUp(start = 1)) { + fetchUserIllusts.url.setEncodedQueryParameter("p", p.toString()) + + val illusts = fetchUserIllusts.executeApi().getOrThrow().illusts!! + if (illusts.isEmpty()) break + + yieldAll(illusts) + } + } override fun getFilterList() = FilterList(PixivFilters()) - private fun Sequence.toSManga() = sequence { + private fun List.toSManga() = asSequence().toSManga().toList() + private fun Sequence.toSManga() = sequence { val seriesIdsSeen = mutableSetOf() forEach { illust -> - val series = illust.series - - if (series == null) { - val manga = SManga.create() - manga.setUrlWithoutDomain("/artworks/${illust.id!!}") - manga.title = illust.title ?: "(null)" - manga.thumbnail_url = illust.url - yield(manga) - } else if (seriesIdsSeen.add(series.id!!)) { - val manga = SManga.create() - manga.setUrlWithoutDomain("/user/${series.userId!!}/series/${series.id}") - manga.title = series.title ?: "(null)" - manga.thumbnail_url = series.coverImage ?: illust.url + val manga = illust.toSManga() + if (seriesIdsSeen.add(manga.url)) { yield(manga) } } } + private fun PixivSeries.toSearchResult() = PixivSearchResultSeries( + id = id, + title = title, + userId = userId, + coverImage = coverImage?.let { if (it.isString) it.content else null }, + ) + private fun PixivIllust.toSManga(): SManga { + if (series == null) { + val manga = SManga.create() + manga.setUrlWithoutDomain("/artworks/${id!!}") + manga.title = title ?: "(null)" + manga.thumbnail_url = url + return manga + } else { + val series = series.copy(userId = series.userId ?: author_details?.user_id) + val manga = series.toSManga().apply { + thumbnail_url = thumbnail_url ?: this@toSManga.url + } + return manga + } + } + private fun PixivSeries.toSManga() = toSearchResult().toSManga() + private fun PixivSearchResultSeries.toSManga(): SManga { + val manga = SManga.create() + manga.setUrlWithoutDomain("/user/${userId!!}/series/$id") + manga.title = title ?: "(null)" + manga.thumbnail_url = coverImage + return manga + } + private var latestMangaNextPage = 1 private lateinit var latestMangaIterator: Iterator @@ -247,7 +327,7 @@ class Pixiv(override val lang: String) : HttpSource() { for (p in countUp(start = 1)) { call.url.setEncodedQueryParameter("p", p.toString()) - val illusts = call.executeApi().illusts!! + val illusts = call.executeApi().getOrThrow().illusts!! if (illusts.isEmpty()) break for (illust in illusts) { @@ -269,14 +349,14 @@ class Pixiv(override val lang: String) : HttpSource() { } private val getIllustCached by lazy { - lruCached(25) { illustId -> + lruCached(25) { illustId -> val call = ApiCall("/touch/ajax/illust/details?illust_id=$illustId") - return@lruCached call.executeApi().illust_details!! + return@lruCached call.executeApi().getOrNull()?.illust_details } } private val getSeriesIllustsCached by lazy { - lruCached>(25) { seriesId -> + lruCached?>(25) { seriesId -> val call = ApiCall("/touch/ajax/illust/series_content/$seriesId") var lastOrder = 0 @@ -284,7 +364,8 @@ class Pixiv(override val lang: String) : HttpSource() { while (true) { call.url.setEncodedQueryParameter("last_order", lastOrder.toString()) - val illusts = call.executeApi().series_contents!! + val illusts = call.executeApi() + .getOrElse { return@lruCached null }.series_contents!! if (illusts.isEmpty()) break addAll(illusts) @@ -299,9 +380,9 @@ class Pixiv(override val lang: String) : HttpSource() { if (isSeries) { val series = ApiCall("/touch/ajax/illust/series/$id") - .executeApi().series!! + .executeApi().getOrThrow().series!! - val illusts = getSeriesIllustsCached(id) + val illusts = getSeriesIllustsCached(id)!! if (series.id != null && series.userId != null) { manga.setUrlWithoutDomain("/user/${series.userId}/series/${series.id}") @@ -321,7 +402,7 @@ class Pixiv(override val lang: String) : HttpSource() { val coverImage = series.coverImage?.let { if (it.isString) it.content else null } (coverImage ?: illusts.firstOrNull()?.url)?.let { manga.thumbnail_url = it } } else { - val illust = getIllustCached(id) + val illust = getIllustCached(id)!! illust.id?.let { manga.setUrlWithoutDomain("/artworks/$it") } illust.title?.let { manga.title = it } @@ -343,8 +424,8 @@ class Pixiv(override val lang: String) : HttpSource() { val (id, isSeries) = parseSMangaUrl(manga.url) val illusts = when (isSeries) { - true -> getSeriesIllustsCached(id) - false -> listOf(getIllustCached(id)) + true -> getSeriesIllustsCached(id)!! + false -> listOf(getIllustCached(id)!!) } val chapters = illusts.mapIndexed { i, illust -> @@ -363,7 +444,7 @@ class Pixiv(override val lang: String) : HttpSource() { val illustId = chapter.url.substringAfterLast('/') val pages = ApiCall("/ajax/illust/$illustId/pages") - .executeApi>() + .executeApi>().getOrThrow() .mapIndexed { i, it -> Page(i, chapter.url, it.urls!!.original!!) } return Observable.just(pages) diff --git a/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivConstants.kt b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivConstants.kt new file mode 100644 index 000000000..bf38cc0aa --- /dev/null +++ b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivConstants.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.extension.all.pixiv + +internal val KNOWN_LOCALES = listOf("en") diff --git a/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivLinkUtil.kt b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivLinkUtil.kt new file mode 100644 index 000000000..01dea31da --- /dev/null +++ b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivLinkUtil.kt @@ -0,0 +1,112 @@ +package eu.kanade.tachiyomi.extension.all.pixiv + +import android.net.Uri +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +interface PixivTargetCompanion { + val SEARCH_PREFIX: String + fun fromSearchQuery(query: String): T? { + if (!query.startsWith(SEARCH_PREFIX)) return null + val id = query.removePrefix(SEARCH_PREFIX) + if (!id.matches("\\d+".toRegex())) return null + return fromSearchQueryId(id) + } + + fun fromSearchQueryId(id: String): T? +} + +sealed class PixivTarget { + companion object { + val BASE_URI = "https://www.pixiv.net".toHttpUrl() + + fun fromSearchQuery(query: String) = + sequenceOf>(User, Series, Illustration) + .firstNotNullOfOrNull { it.fromSearchQuery(query) } + + fun fromUri(uri: String) = uri.toHttpUrlOrNull()?.let { fromUri(it) } + fun fromUri(uri: Uri) = fromUri(uri.toString()) + fun fromUri(uri: HttpUrl): PixivTarget? { + // if an absolute domain is specified, check if it matches. Tolerate relative urls as-is. + if (!( + uri.scheme in listOf(null, "http", "https") && + uri.host.let { "pixiv.net" == it.removePrefix("www.") } + ) + ) { + return null + } + + var pathSegments = uri.pathSegments.ifEmpty { null } ?: return null + + if (KNOWN_LOCALES.contains(pathSegments[0])) { + pathSegments = pathSegments.subList(1, pathSegments.size) + } + if (pathSegments.size < 2) return null + + with(pathSegments[0]) { + return when { + equals("artworks") -> Illustration(pathSegments[1]) + equals("users") -> User(pathSegments[1]) + equals("user") && + (pathSegments.size >= 4 && pathSegments[2].equals("series")) -> + Series(pathSegments[3], pathSegments[1]) + + else -> null + } + } + } + } + + abstract fun toHttpUrl(): HttpUrl + fun toUri() = Uri.parse(toHttpUrl().toString()) + + abstract fun toSearchQuery(): String + + data class User(val userId: String) : PixivTarget() { + companion object : PixivTargetCompanion { + override val SEARCH_PREFIX = "user:" + override fun fromSearchQueryId(id: String) = User(id) + } + + override fun toHttpUrl() = + BASE_URI.newBuilder() + .addPathSegment("users") + .addPathSegment(userId).build() + + override fun toSearchQuery(): String = SEARCH_PREFIX + userId + } + + data class Illustration(val illustId: String) : PixivTarget() { + companion object : PixivTargetCompanion { + override val SEARCH_PREFIX = "aid:" + override fun fromSearchQueryId(id: String) = Illustration(id) + } + + override fun toHttpUrl() = + BASE_URI.newBuilder() + .addPathSegment("artworks") + .addPathSegment(illustId).build() + + override fun toSearchQuery(): String = SEARCH_PREFIX + illustId + } + + data class Series(val seriesId: String, val authorUserId: String? = null) : PixivTarget() { + companion object : PixivTargetCompanion { + override val SEARCH_PREFIX = "sid:" + override fun fromSearchQueryId(id: String) = Series(id) + } + + override fun toHttpUrl() = + BASE_URI.newBuilder() + .addPathSegment("user") + .addPathSegment( + authorUserId + ?: throw UnsupportedOperationException("TBD what should be done in this case"), + ) + .addPathSegment("series") + .addPathSegment(seriesId).build() + + override fun toSearchQuery(): String = SEARCH_PREFIX + seriesId + } +} diff --git a/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivTypes.kt b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivTypes.kt index adfe178eb..05168e965 100644 --- a/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivTypes.kt +++ b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivTypes.kt @@ -1,11 +1,14 @@ package eu.kanade.tachiyomi.extension.all.pixiv import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive @Serializable -internal data class PixivApiResponse( - val body: T? = null, +internal data class PixivApiResponse( + val error: Boolean = false, + val message: String? = null, + val body: JsonElement? = null, ) @Serializable @@ -58,6 +61,7 @@ internal data class PixivIllustPageUrls( @Serializable internal data class PixivAuthorDetails( + val user_id: String? = null, val user_name: String? = null, ) @@ -72,6 +76,9 @@ internal data class PixivSeries( val coverImage: JsonPrimitive? = null, val id: String? = null, val title: String? = null, + /** + * CAUTION: sometimes this isn't passed! + */ val userId: String? = null, ) @@ -88,4 +95,5 @@ internal data class PixivRankings( @Serializable internal data class PixivRankingEntry( val illustId: String? = null, + val rank: Int? = null, ) diff --git a/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivUrlActivity.kt b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivUrlActivity.kt new file mode 100644 index 000000000..49ee0e064 --- /dev/null +++ b/src/all/pixiv/src/eu/kanade/tachiyomi/extension/all/pixiv/PixivUrlActivity.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.extension.all.pixiv + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +/** + * Springboard that accepts https://mangadex.com/title/xxx intents and redirects them to + * the main tachiyomi process. The idea is to not install the intent filter unless + * you have this extension installed, but still let the main tachiyomi app control + * things. + * + * Main goal was to make it easier to open manga in Tachiyomi in spite of the DDoS blocking + * the usual search screen from working. + */ +class PixivUrlActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (intent?.data != null) { + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", intent.data.toString()) + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("PixivUrlActivity", e.toString()) + } + } else { + Log.e("PixivUrlActivity", "Could not parse URI from intent $intent") + } + + finish() + exitProcess(0) + } +}