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)
+ }
+}