Add support for Moebooru-, Gelbooru- and Zerochan-based imageboards, and Wallhaven. Restructure how board type preference is saved. Restructure README.

This commit is contained in:
Kevin Alberts 2024-12-28 01:40:28 +01:00
parent 291307b121
commit 799151a720
6 changed files with 412 additions and 39 deletions

View file

@ -13,8 +13,8 @@ android {
applicationId = "nl.kurocon.plugin.wallpaperprovider.booru"
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "1.1"
}

View file

@ -1,39 +1,63 @@
package nl.kurocon.plugin.wallpaperprovider.booru
import android.content.Context
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.DANBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.GELBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.MOEBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.WALLHAVEN
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.ZEROCHAN
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.net.URLEncoder
class BooruAPI(context: Context) {
private val client = OkHttpClient()
private var booruPluginUserAgent = "ProjectivyBooruWallpaperPlugin"
init {
PreferencesManager.init(context)
val pluginName = context.getString(R.string.plugin_name)
val pluginVersion = context.getString(R.string.plugin_version)
booruPluginUserAgent = "$pluginName/$pluginVersion"
}
@Throws(IOException::class)
fun getImageUrls(limit: Int = 20): List<BooruImage> {
return when (PreferencesManager.booruType) {
"danbooru" -> getDanbooruImageUrls(limit = limit)
DANBOORU -> getDanbooruImageUrls(limit = limit)
GELBOORU -> getGelbooruImageUrls(limit = limit)
MOEBOORU -> getMoebooruImageUrls(limit = limit)
ZEROCHAN -> getZerochanImageUrls(limit = limit)
WALLHAVEN -> getWallhavenImageUrls(limit = limit)
else -> getDanbooruImageUrls(limit = limit)
}
}
private fun getRequestBuilder(url: String, addAuth: Boolean = false): Request.Builder {
// Add default headers (User-Agent) and set URL
var builder = Request.Builder()
.url(url)
.addHeader("User-Agent", booruPluginUserAgent)
if (addAuth && (PreferencesManager.booruUserId.isNotEmpty() && PreferencesManager.booruApiKey.isNotEmpty())) {
builder = builder.addHeader(
"Authorization",
okhttp3.Credentials.basic(PreferencesManager.booruUserId, PreferencesManager.booruApiKey)
)
}
return builder
}
@Throws(IOException::class)
fun getDanbooruImageUrls(limit: Int = 20): List<BooruImage> {
val tags = PreferencesManager.booruTagSearch
val encodedTags = URLEncoder.encode(tags, "UTF-8")
val url = "${PreferencesManager.booruUrl}/posts.json?tags=$encodedTags&limit=$limit"
var requestBuilder = Request.Builder()
.url(url)
if (PreferencesManager.booruUserId.isNotEmpty() && PreferencesManager.booruApiKey.isNotEmpty()) {
requestBuilder = requestBuilder.addHeader("Authorization", okhttp3.Credentials.basic(PreferencesManager.booruUserId, PreferencesManager.booruApiKey))
}
val request = requestBuilder.build()
val request = getRequestBuilder(url, addAuth=true).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
@ -44,7 +68,7 @@ class BooruAPI(context: Context) {
if (errorMessage.isNotEmpty()) {
throw IOException("Error ${response.code}. $errorMessage")
}
throw IOException("Unexpected code ${response.code} - ${response.body}")
throw IOException("Error ${response.code} - ${response.body}")
}
val responseBody = response.body?.string() ?: throw IOException("Response body is null")
@ -65,6 +89,222 @@ class BooruAPI(context: Context) {
return images
}
}
@Throws(IOException::class)
fun getMoebooruImageUrls(limit: Int = 20): List<BooruImage> {
val tags = PreferencesManager.booruTagSearch
val encodedTags = URLEncoder.encode(tags, "UTF-8")
var url = "${PreferencesManager.booruUrl}/post.json?tags=$encodedTags&limit=$limit"
if (PreferencesManager.booruUserId.isNotEmpty() && PreferencesManager.booruApiKey.isNotEmpty()) {
val encUsername = URLEncoder.encode(PreferencesManager.booruUserId, "UTF-8")
val encApiKey = URLEncoder.encode(PreferencesManager.booruApiKey, "UTF-8")
url = "$url&login=$encUsername&password_hash=$encApiKey"
}
val request = getRequestBuilder(url).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
// Try to parse error message from body
val responseBody = response.body?.string() ?: throw IOException("Unexpected code ${response.code}, and response body is empty")
val jsonBody = JSONObject(responseBody)
val errorMessage = jsonBody.optString("message")
if (errorMessage.isNotEmpty()) {
throw IOException("Error ${response.code}. $errorMessage")
}
throw IOException("Error ${response.code} - ${response.body}")
}
val responseBody = response.body?.string() ?: throw IOException("Response body is null")
val jsonArray = JSONArray(responseBody)
// Parse JSON response into BooruImage instances
val images = mutableListOf<BooruImage>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val imageUrl = jsonObject.optString("file_url")
val title = jsonObject.optString("tag_string")
val author = jsonObject.optString("tag_string_artist")
val sourceUri = jsonObject.optString("source")
if (imageUrl.isNotEmpty()) {
images.add(BooruImage(imageUrl, title, author, sourceUri))
}
}
return images
}
}
@Throws(IOException::class)
fun getGelbooruImageUrls(limit: Int = 20): List<BooruImage> {
val tags = PreferencesManager.booruTagSearch
val encodedTags = URLEncoder.encode(tags, "UTF-8")
var url = "${PreferencesManager.booruUrl}/index.php?page=dapi&s=post&q=index&tags=$encodedTags&limit=$limit&json=1"
if (PreferencesManager.booruUserId.isNotEmpty() && PreferencesManager.booruApiKey.isNotEmpty()) {
val encUsername = URLEncoder.encode(PreferencesManager.booruUserId, "UTF-8")
val encApiKey = URLEncoder.encode(PreferencesManager.booruApiKey, "UTF-8")
url = "$url&api_key=$encApiKey&user_id=$encUsername"
}
val request = getRequestBuilder(url).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
// Try to parse error message from body
val responseBody = response.body?.string() ?: throw IOException("Unexpected code ${response.code}, and response body is empty")
val jsonBody = JSONObject(responseBody)
val errorMessage = jsonBody.optString("message")
if (errorMessage.isNotEmpty()) {
throw IOException("Error ${response.code}. $errorMessage")
}
throw IOException("Error ${response.code} - ${response.body}")
}
val responseBody = response.body?.string() ?: throw IOException("Response body is null")
val jsonBody: JSONObject
try {
jsonBody = JSONObject(responseBody)
} catch (e: Exception) {
// gelbooru returns xml response if request was denied for some reason
// i.e. user hit a rate limit because he didn't include api key
throw IOException("Error. Unexpected response. You might be rate limited.")
}
// Parse JSON response into BooruImage instances
val images = mutableListOf<BooruImage>()
val jsonArray = jsonBody.getJSONArray("post")
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val imageUrl = jsonObject.optString("file_url")
val title = jsonObject.optString("tags")
// Gelbooru does not have an easily accessible Artist tag. Link to the source in a best-effort way.
val author = jsonObject.optString("source")
val sourceUri = jsonObject.optString("source")
if (imageUrl.isNotEmpty()) {
images.add(BooruImage(imageUrl, title, author, sourceUri))
}
}
return images
}
}
@Throws(IOException::class)
fun getZerochanImageUrls(limit: Int = 20): List<BooruImage> {
val tags = PreferencesManager.booruTagSearch
val encodedTags = URLEncoder.encode(tags, "UTF-8")
val url = "${PreferencesManager.booruUrl}/$encodedTags?json&l=$limit"
var requestBuilder = getRequestBuilder(url)
// Zerochan has no authentication, but asks to put the username in the User-Agent string.
// This header is already added by the `getRequestBuilder`, so need to override it.
if (PreferencesManager.booruUserId.isNotEmpty()) {
requestBuilder = requestBuilder.header(
"User-Agent", "$booruPluginUserAgent - ${PreferencesManager.booruUserId}"
)
}
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
// Try to parse error message from body
val responseBody = response.body?.string() ?: throw IOException("Unexpected code ${response.code}, and response body is empty")
val jsonBody = JSONObject(responseBody)
val errorMessage = jsonBody.optString("message")
if (errorMessage.isNotEmpty()) {
throw IOException("Error ${response.code}. $errorMessage")
}
throw IOException("Error ${response.code} - ${response.body}")
}
val responseBody = response.body?.string() ?: throw IOException("Response body is null")
val jsonArray = JSONObject(responseBody).getJSONArray("items")
// Parse JSON response into BooruImage instances
val images = mutableListOf<BooruImage>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val imageId = jsonObject.optInt("id")
if (imageId != 0) {
// Do a second request for the complete image information, because
// zerochan does not include the full image link in the list results...
var requestBuilder2 =
getRequestBuilder("${PreferencesManager.booruUrl}/$imageId?json")
if (PreferencesManager.booruUserId.isNotEmpty()) {
requestBuilder2 = requestBuilder2.header(
"User-Agent",
"$booruPluginUserAgent - ${PreferencesManager.booruUserId}"
)
}
val request2 = requestBuilder2.build()
client.newCall(request2).execute().use { response2 ->
if (response2.isSuccessful) {
val responseBody2 = response2.body?.string()
?: throw IOException("Response body is null")
val jsonBody: JSONObject
try {
jsonBody = JSONObject(responseBody2)
val imageUrl = jsonBody.optString("full")
val title1 = jsonBody.optString("primary")
val title2 = jsonBody.optJSONArray("tags")?.join(" ")
val title = "$title1 - $title2"
// Zerochan does not have an easily accessible Artist tag. Link to the source in a best-effort way.
val author = jsonBody.optString("source")
val sourceUri = jsonBody.optString("source")
if (imageUrl.isNotEmpty()) {
images.add(BooruImage(imageUrl, title, author, sourceUri))
}
} catch (e: JSONException) {
// Invalid response, do nothing (probably rate limited, HTML output)
}
}
}
}
}
return images
}
}
@Throws(IOException::class)
fun getWallhavenImageUrls(limit: Int = 20): List<BooruImage> {
val tags = PreferencesManager.booruTagSearch
val encodedTags = URLEncoder.encode(tags, "UTF-8")
var url = "${PreferencesManager.booruUrl}/api/v1/search?q=$encodedTags&sorting=random"
val requestBuilder = getRequestBuilder(url)
if (PreferencesManager.booruApiKey.isNotEmpty()) {
// Users can authenticate by including their API key either in a request URL by appending
// ?apikey=<API KEY>, or by including the X-API-Key: <API KEY> header with the request.
// API key grants access to NSFW images (but the purity=111 flag is not set by this plugin, default is SFW, 100).
requestBuilder.addHeader("X-API-Key", PreferencesManager.booruApiKey)
}
val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
// Try to parse error message from body
val responseBody = response.body?.string() ?: throw IOException("Unexpected code ${response.code}, and response body is empty")
val jsonBody = JSONObject(responseBody)
val errorMessage = jsonBody.optString("message")
if (errorMessage.isNotEmpty()) {
throw IOException("Error ${response.code}. $errorMessage")
}
throw IOException("Error ${response.code} - ${response.body}")
}
val responseBody = response.body?.string() ?: throw IOException("Response body is null")
val jsonBody = JSONObject(responseBody)
val jsonArray = jsonBody.getJSONArray("data")
// Parse JSON response into BooruImage instances
val images = mutableListOf<BooruImage>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val imageUrl = jsonObject.optString("path")
val title = jsonObject.optString("id")
val author = jsonObject.optString("source")
val sourceUri = jsonObject.optString("source")
if (imageUrl.isNotEmpty()) {
images.add(BooruImage(imageUrl, title, author, sourceUri))
}
}
return images
}
}
}
class BooruImage(

View file

@ -2,13 +2,40 @@ package nl.kurocon.plugin.wallpaperprovider.booru
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.IntDef
import androidx.preference.PreferenceManager
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.ToNumberPolicy
import com.google.gson.reflect.TypeToken
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.DANBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.MOEBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.GELBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.ZEROCHAN
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.WALLHAVEN
object PreferencesManager {
@Target(AnnotationTarget.TYPE)
@Retention(AnnotationRetention.SOURCE)
@IntDef(DANBOORU, MOEBOORU, GELBOORU, ZEROCHAN, WALLHAVEN)
annotation class BooruType {
companion object {
const val DANBOORU = 1
const val MOEBOORU = 2
const val GELBOORU = 3
const val ZEROCHAN = 4
const val WALLHAVEN = 5
}
}
private val BOORU_TYPE_NAMES = mapOf(
DANBOORU to "Danbooru",
MOEBOORU to "Moebooru",
GELBOORU to "Gelbooru",
ZEROCHAN to "Zerochan",
WALLHAVEN to "Wallhaven",
)
private const val BOORU_URL_KEY = "booru_url_key"
private const val BOORU_TYPE_KEY = "booru_type_key"
private const val BOORU_TAG_SEARCH_KEY = "booru_tag_search_key"
@ -27,6 +54,10 @@ object PreferencesManager {
editor.apply()
}
fun getBooruName(type: @BooruType Int): String {
return BOORU_TYPE_NAMES[type]!!
}
operator fun set(key: String, value: Any?) =
when (value) {
is String? -> preferences.edit { it.putString(key, value) }
@ -40,21 +71,30 @@ object PreferencesManager {
inline operator fun <reified T : Any> get(
key: String,
defaultValue: T? = null
): T =
when (T::class) {
String::class -> preferences.getString(key, defaultValue as String? ?: "") as T
Int::class -> preferences.getInt(key, defaultValue as? Int ?: -1) as T
Boolean::class -> preferences.getBoolean(key, defaultValue as? Boolean ?: false) as T
Float::class -> preferences.getFloat(key, defaultValue as? Float ?: -1f) as T
Long::class -> preferences.getLong(key, defaultValue as? Long ?: -1) as T
else -> throw UnsupportedOperationException("Not yet implemented")
): T {
try {
when (T::class) {
String::class -> return preferences.getString(key, defaultValue as String? ?: "") as T
Int::class -> return preferences.getInt(key, defaultValue as? Int ?: -1) as T
Boolean::class -> return preferences.getBoolean(
key,
defaultValue as? Boolean ?: false
) as T
Float::class -> return preferences.getFloat(key, defaultValue as? Float ?: -1f) as T
Long::class -> return preferences.getLong(key, defaultValue as? Long ?: -1) as T
else -> throw UnsupportedOperationException("Not yet implemented")
}
} catch (e: ClassCastException) {
return defaultValue as T
}
}
var booruUrl: String
get () = PreferencesManager[BOORU_URL_KEY, "https://danbooru.donmai.us"]
set(value) { PreferencesManager[BOORU_URL_KEY] = value }
var booruType: String
get () = PreferencesManager[BOORU_TYPE_KEY, "danbooru"]
var booruType: @BooruType Int
get () = PreferencesManager[BOORU_TYPE_KEY, DANBOORU]
set(value) { PreferencesManager[BOORU_TYPE_KEY] = value }
var booruTagSearch: String
get () = PreferencesManager[BOORU_TAG_SEARCH_KEY, "ratio:16:9 rating:general order:random"]
@ -99,4 +139,4 @@ object PreferencesManager {
}
return true
}
}
}

View file

@ -6,6 +6,11 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.leanback.app.GuidedStepSupportFragment
import androidx.leanback.widget.GuidanceStylist.Guidance
import androidx.leanback.widget.GuidedAction
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.DANBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.GELBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.MOEBOORU
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.ZEROCHAN
import nl.kurocon.plugin.wallpaperprovider.booru.PreferencesManager.BooruType.Companion.WALLHAVEN
import kotlin.CharSequence
class SettingsFragment : GuidedStepSupportFragment() {
@ -32,20 +37,50 @@ class SettingsFragment : GuidedStepSupportFragment() {
.build()
actions.add(actionBooruUrl)
// Booru Type setting (choice menu with only Danbooru currently)
val booruTypeSubActions: MutableList<GuidedAction> = mutableListOf();
val typeDanbooruSubAction = GuidedAction.Builder(context)
.id(SUBACTION_ID_BOORU_TYPE_DANBOORU)
.title(R.string.setting_booru_type_subtype_danbooru_title)
.description(R.string.setting_booru_type_subtype_danbooru_description)
.build()
booruTypeSubActions.add(typeDanbooruSubAction)
// Booru Type subaction list (choice menu)
val booruTypeSubActions: MutableList<GuidedAction> = mutableListOf(
// Danbooru
GuidedAction.Builder(context)
.id(SUBACTION_ID_BOORU_TYPE_DANBOORU)
.title(R.string.setting_booru_type_subtype_danbooru_title)
.description(R.string.setting_booru_type_subtype_danbooru_description)
.build(),
// Moebooru
GuidedAction.Builder(context)
.id(SUBACTION_ID_BOORU_TYPE_MOEBOORU)
.title(R.string.setting_booru_type_subtype_moebooru_title)
.description(R.string.setting_booru_type_subtype_moebooru_description)
.build(),
// Gelbooru
GuidedAction.Builder(context)
.id(SUBACTION_ID_BOORU_TYPE_GELBOORU)
.title(R.string.setting_booru_type_subtype_gelbooru_title)
.description(R.string.setting_booru_type_subtype_gelbooru_description)
.build(),
// Zerochan
GuidedAction.Builder(context)
.id(SUBACTION_ID_BOORU_TYPE_ZEROCHAN)
.title(R.string.setting_booru_type_subtype_zerochan_title)
.description(R.string.setting_booru_type_subtype_zerochan_description)
.build(),
// Wallhaven.cc
GuidedAction.Builder(context)
.id(SUBACTION_ID_BOORU_TYPE_WALLHAVEN)
.title(R.string.setting_booru_type_subtype_wallhaven_title)
.description(R.string.setting_booru_type_subtype_wallhaven_description)
.build(),
);
// Actual Booru Type choice button with subactions
val currentBooruType = PreferencesManager.booruType
val actionBooruType = GuidedAction.Builder(context)
.id(ACTION_ID_BOORU_TYPE)
.title(R.string.setting_booru_type_title)
.description(currentBooruType)
.description(PreferencesManager.getBooruName(currentBooruType))
.subActions(booruTypeSubActions)
.build()
actions.add(actionBooruType)
@ -104,10 +139,13 @@ class SettingsFragment : GuidedStepSupportFragment() {
override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
when (action.id) {
SUBACTION_ID_BOORU_TYPE_DANBOORU -> {
findActionById(ACTION_ID_BOORU_TYPE)?.description = "danbooru"
SUBACTION_ID_BOORU_TYPE_DANBOORU, SUBACTION_ID_BOORU_TYPE_MOEBOORU,
SUBACTION_ID_BOORU_TYPE_GELBOORU, SUBACTION_ID_BOORU_TYPE_ZEROCHAN,
SUBACTION_ID_BOORU_TYPE_WALLHAVEN -> {
val bType = BOORU_TYPE_ACTION_MAP[action.id]!!
findActionById(ACTION_ID_BOORU_TYPE)?.description = PreferencesManager.getBooruName(bType)
notifyActionChanged(findActionPositionById(ACTION_ID_BOORU_TYPE))
PreferencesManager.booruType = "danbooru"
PreferencesManager.booruType = bType
}
}
return true
@ -151,5 +189,17 @@ class SettingsFragment : GuidedStepSupportFragment() {
private const val ACTION_ID_BOORU_API_KEY = 6L
private const val SUBACTION_ID_BOORU_TYPE_DANBOORU = 7L
private const val SUBACTION_ID_BOORU_TYPE_MOEBOORU = 8L
private const val SUBACTION_ID_BOORU_TYPE_GELBOORU = 9L
private const val SUBACTION_ID_BOORU_TYPE_ZEROCHAN = 10L
private const val SUBACTION_ID_BOORU_TYPE_WALLHAVEN = 11L
private val BOORU_TYPE_ACTION_MAP = mapOf(
SUBACTION_ID_BOORU_TYPE_DANBOORU to DANBOORU,
SUBACTION_ID_BOORU_TYPE_MOEBOORU to MOEBOORU,
SUBACTION_ID_BOORU_TYPE_GELBOORU to GELBOORU,
SUBACTION_ID_BOORU_TYPE_ZEROCHAN to ZEROCHAN,
SUBACTION_ID_BOORU_TYPE_WALLHAVEN to WALLHAVEN,
)
}
}

View file

@ -1,5 +1,6 @@
<resources>
<string name="plugin_name">Booru Wallpaper Provider</string>
<string name="plugin_version">1.1</string>
<string name="plugin_description">
A wallpaper provider that can talk to Booru sites running Danbooru software.
</string>
@ -8,7 +9,7 @@
<!-- Setting parameters -->
<string name="settings">Settings</string>
<string name="setting_image_url_title">Image URL</string>
<string name="setting_booru_url_title">Booru URL - e.g. \'https://danbooru.donmai.us/\'</string>
<string name="setting_booru_url_title">Booru URL - e.g. \'https://danbooru.donmai.us\'</string>
<string name="setting_booru_type_title">Booru Type</string>
<string name="setting_booru_tag_search_title">Search query - e.g. \'ratio:16:9 rating:general\'</string>
<string name="setting_booru_user_id_title">Booru Username </string>
@ -20,5 +21,13 @@
</string>
<string name="setting_booru_type_subtype_danbooru_title">Danbooru</string>
<string name="setting_booru_type_subtype_danbooru_description">Danbooru-based (danbooru.donmai.us) - Currently the only supported type</string>
<string name="setting_booru_type_subtype_danbooru_description">Danbooru-based (danbooru.donmai.us)</string>
<string name="setting_booru_type_subtype_moebooru_title">Moebooru (Danbooru v1)</string>
<string name="setting_booru_type_subtype_moebooru_description">Moebooru-based (konachan.com, yande.re)</string>
<string name="setting_booru_type_subtype_gelbooru_title">Gelbooru</string>
<string name="setting_booru_type_subtype_gelbooru_description">Gelbooru-based (gelbooru.com)</string>
<string name="setting_booru_type_subtype_zerochan_title">Zerochan</string>
<string name="setting_booru_type_subtype_zerochan_description">Zerochan-based (zerochan.net, asiachan.com)</string>
<string name="setting_booru_type_subtype_wallhaven_title">Wallhaven</string>
<string name="setting_booru_type_subtype_wallhaven_description">Wallhaven-based (wallhaven.cc)</string>
</resources>