diff --git a/src/all/peppercarrot/AndroidManifest.xml b/src/all/peppercarrot/AndroidManifest.xml
new file mode 100644
index 000000000..30deb7f79
--- /dev/null
+++ b/src/all/peppercarrot/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest package="eu.kanade.tachiyomi.extension" />
diff --git a/src/all/peppercarrot/build.gradle b/src/all/peppercarrot/build.gradle
new file mode 100644
index 000000000..d5ea88405
--- /dev/null
+++ b/src/all/peppercarrot/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+    extName = 'Pepper&Carrot'
+    pkgNameSuffix = 'all.peppercarrot'
+    extClass = '.PepperCarrot'
+    extVersionCode = 1
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/peppercarrot/res/mipmap-hdpi/ic_launcher.png b/src/all/peppercarrot/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..b7292fd5b
Binary files /dev/null and b/src/all/peppercarrot/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/peppercarrot/res/mipmap-mdpi/ic_launcher.png b/src/all/peppercarrot/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..56711959c
Binary files /dev/null and b/src/all/peppercarrot/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/peppercarrot/res/mipmap-xhdpi/ic_launcher.png b/src/all/peppercarrot/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..1752e9c7e
Binary files /dev/null and b/src/all/peppercarrot/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/peppercarrot/res/mipmap-xxhdpi/ic_launcher.png b/src/all/peppercarrot/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..940648ee9
Binary files /dev/null and b/src/all/peppercarrot/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/peppercarrot/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/peppercarrot/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..6a7fb407c
Binary files /dev/null and b/src/all/peppercarrot/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/peppercarrot/res/web_hi_res_512.png b/src/all/peppercarrot/res/web_hi_res_512.png
new file mode 100644
index 000000000..195c7d1bf
Binary files /dev/null and b/src/all/peppercarrot/res/web_hi_res_512.png differ
diff --git a/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Dto.kt b/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Dto.kt
new file mode 100644
index 000000000..af65e94ca
--- /dev/null
+++ b/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Dto.kt
@@ -0,0 +1,35 @@
+package eu.kanade.tachiyomi.extension.all.peppercarrot
+
+import kotlinx.serialization.Serializable
+
+typealias LangsDto = Map<String, LangDto>
+
+@Serializable
+class LangDto(
+    val translators: List<String>,
+    val local_name: String,
+    val iso_code: String,
+)
+
+// ProtoBuf: should not change field type and order
+@Serializable
+class LangData(
+    val key: String,
+    val name: String,
+    val progress: String,
+    val translators: String,
+    val title: String?,
+)
+
+class Lang(
+    val key: String,
+    val name: String,
+    val code: String,
+    val translators: String,
+    val translatedCount: Int,
+)
+
+@Serializable
+class EpisodeDto(
+    val translated_languages: List<String>,
+)
diff --git a/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Filters.kt b/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Filters.kt
new file mode 100644
index 000000000..2a4bad900
--- /dev/null
+++ b/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Filters.kt
@@ -0,0 +1,28 @@
+package eu.kanade.tachiyomi.extension.all.peppercarrot
+
+import android.content.SharedPreferences
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+
+fun getFilters(preferences: SharedPreferences): FilterList {
+    val langData = preferences.langData
+    val list: List<Filter<*>> = if (langData.isEmpty()) {
+        listOf(Filter.Header("Tap 'Reset' to load languages"))
+    } else buildList(langData.size + 1) {
+        add(Filter.Header("Languages"))
+        val lang = preferences.lang.toHashSet()
+        langData.mapTo(this) {
+            LangFilter(it.key, "${it.name} (${it.progress})", it.key in lang)
+        }
+    }
+    return FilterList(list)
+}
+
+fun SharedPreferences.saveFrom(filters: FilterList) {
+    val langFilters = filters.filterIsInstance<LangFilter>().ifEmpty { return }
+    val selected = langFilters.filter { it.state }.mapTo(LinkedHashSet()) { it.key }
+    val result = lang.filterTo(LinkedHashSet()) { it in selected }.apply { addAll(selected) }
+    edit().setLang(result).apply()
+}
+
+class LangFilter(val key: String, name: String, state: Boolean) : Filter.CheckBox(name, state)
diff --git a/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/PepperCarrot.kt b/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/PepperCarrot.kt
new file mode 100644
index 000000000..58f975dee
--- /dev/null
+++ b/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/PepperCarrot.kt
@@ -0,0 +1,228 @@
+package eu.kanade.tachiyomi.extension.all.peppercarrot
+
+import android.app.Application
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.source.ConfigurableSource
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.CacheControl
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.TextNode
+import org.jsoup.select.Evaluator
+import rx.Observable
+import rx.Single
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+class PepperCarrot : HttpSource(), ConfigurableSource {
+    override val name = TITLE
+    override val lang = "all"
+    override val supportsLatest = false
+
+    override val baseUrl = BASE_URL
+
+    private val preferences by lazy {
+        Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)!!
+    }
+
+    override fun fetchPopularManga(page: Int): Observable<MangasPage> = Single.create<MangasPage> {
+        updateLangData(client, headers, preferences)
+        val lang = preferences.lang.ifEmpty {
+            throw Exception("Please select language in the filter")
+        }
+        val langMap = preferences.langData.associateBy { langData -> langData.key }
+        val mangas = lang.map { key -> langMap[key]!!.toSManga() }
+        val result = MangasPage(mangas + getArtworkList(), false)
+        it.onSuccess(result)
+    }.toObservable()
+
+    private fun getArtworkList(): List<SManga> = arrayOf(
+        "artworks", "wallpapers", "sketchbook", "misc",
+        "book-publishing", "comissions", "eshop", "framasoft", "press", "references", "wiki"
+    ).map(::getArtworkEntry)
+
+    override fun getFilterList() = getFilters(preferences)
+
+    override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
+        if (filters.isNotEmpty()) preferences.saveFrom(filters)
+        return fetchPopularManga(page)
+    }
+
+    override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException()
+    override fun popularMangaParse(response: Response) = throw UnsupportedOperationException()
+    override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
+    override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
+    override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException()
+    override fun searchMangaParse(response: Response) = throw UnsupportedOperationException()
+
+    override fun fetchMangaDetails(manga: SManga): Observable<SManga> = Single.create<SManga> {
+        updateLangData(client, headers, preferences)
+        val key = manga.url
+        val result = if (key.startsWith('#')) {
+            getArtworkEntry(key.substring(1))
+        } else {
+            preferences.langData.find { lang -> lang.key == key }!!.toSManga()
+        }
+        it.onSuccess(result)
+    }.toObservable()
+
+    override fun mangaDetailsRequest(manga: SManga): Request {
+        val key = manga.url
+        val url = if (key.startsWith('#')) { // artwork
+            "$BASE_URL/en/files/${key.substring(1)}.html"
+        } else {
+            "$BASE_URL/$key/webcomics/index.html"
+        }
+        return GET(url, headers)
+    }
+
+    override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException()
+
+    private fun LangData.toSManga() = SManga.create().apply {
+        url = key
+        title = this@toSManga.title ?: if (key == "en") TITLE else "$TITLE (${key.uppercase()})"
+        author = AUTHOR
+        description = this@toSManga.run {
+            "Language: $name\nTranslators: $translators"
+        }
+        status = SManga.ONGOING
+        thumbnail_url = "$BASE_URL/0_sources/0ther/artworks/low-res/2016-02-24_vertical-cover_remake_by-David-Revoy.jpg"
+        initialized = true
+    }
+
+    private fun getArtworkEntry(key: String) = SManga.create().apply {
+        url = "#$key"
+        title = when (key) {
+            "comissions" -> "Commissions"
+            "eshop" -> "Shop"
+            else -> key.replaceFirstChar { it.uppercase() }
+        }
+        author = AUTHOR
+        status = SManga.ONGOING
+        thumbnail_url = "$BASE_URL/0_sources/0ther/press/low-res/2015-10-12_logo_by-David-Revoy.jpg"
+        initialized = true
+    }
+
+    override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Single.create<List<SChapter>> {
+        updateLangData(client, headers, preferences)
+        val response = client.newCall(chapterListRequest(manga)).execute()
+        it.onSuccess(chapterListParse(response))
+    }.toObservable()
+
+    override fun chapterListRequest(manga: SManga): Request {
+        val key = manga.url
+        val url = if (key.startsWith('#')) { // artwork
+            "$BASE_URL/0_sources/0ther/${key.substring(1)}/low-res/"
+        } else {
+            "$BASE_URL/$key/webcomics/index.html"
+        }
+        val lastUpdated = preferences.lastUpdated
+        if (lastUpdated == 0L) return GET(url, headers)
+
+        val seconds = System.currentTimeMillis() / 1000 - lastUpdated
+        val cache = CacheControl.Builder().maxStale(seconds.toInt(), TimeUnit.SECONDS).build()
+        return GET(url, headers, cache)
+    }
+
+    override fun chapterListParse(response: Response): List<SChapter> {
+        if (response.request.url.pathSegments[0] == "0_sources") return parseArtwork(response)
+
+        val translatedChapters = response.asJsoup()
+            .select(Evaluator.Tag("figure"))
+            .let { (it.size downTo 1) zip it }
+            .filter { it.second.hasClass("translated") }
+
+        return translatedChapters.map { (number, it) ->
+            SChapter.create().apply {
+                url = it.selectFirst(Evaluator.Tag("a")).attr("href").removePrefix(BASE_URL)
+                name = it.selectFirst(Evaluator.Tag("img")).attr("title").run {
+                    val index = lastIndexOf('(')
+                    when {
+                        index >= 0 -> substring(0, index).trimEnd()
+                        else -> substringBeforeLast('(').trimEnd()
+                    }
+                }
+                date_upload = it.selectFirst(Evaluator.Tag("figcaption")).ownText().let {
+                    val date = dateRegex.find(it)!!.value
+                    dateFormat.parse(date)!!.time
+                }
+                chapter_number = number.toFloat()
+            }
+        }
+    }
+
+    private fun parseArtwork(response: Response): List<SChapter> {
+        val baseDir = response.request.url.toString().removePrefix(BASE_URL)
+        return response.asJsoup().select(Evaluator.Tag("a")).asReversed().mapNotNull {
+            val filename = it.attr("href")!!
+            if (!filename.endsWith(".jpg")) return@mapNotNull null
+
+            val file = filename.removeSuffix(".jpg").removeSuffix("_by-David-Revoy")
+            val fileStripped: String
+            val date: Long
+            if (file.length >= 10 && dateRegex.matches(file.substring(0, 10))) {
+                fileStripped = file.substring(10)
+                date = dateFormat.parse(file.substring(0, 10))!!.time
+            } else {
+                fileStripped = file
+                val lastModified = it.nextSibling() as? TextNode
+                date = if (lastModified == null) 0 else dateFormat.parse(lastModified.text())!!.time
+            }
+            val fileNormalized = fileStripped
+                .replace('_', ' ')
+                .replace('-', ' ')
+                .trim()
+                .replaceFirstChar { char -> char.uppercase() }
+
+            SChapter.create().apply {
+                url = baseDir + filename
+                name = fileNormalized
+                date_upload = date
+                chapter_number = -2f
+            }
+        }
+    }
+
+    override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
+        val url = chapter.url
+        return if (url.endsWith(".jpg")) {
+            Observable.just(listOf(Page(0, imageUrl = BASE_URL + url)))
+        } else {
+            super.fetchPageList(chapter)
+        }
+    }
+
+    override fun pageListParse(response: Response): List<Page> {
+        val document = response.asJsoup()
+        val urls = document.select(Evaluator.Class("comicpage")).map { it.attr("src")!! }
+        val thumbnail = urls[0].replace("P00.jpg", ".jpg")
+        return (listOf(thumbnail) + urls).mapIndexed { index, imageUrl ->
+            Page(index, imageUrl = imageUrl)
+        }
+    }
+
+    override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
+
+    override fun imageRequest(page: Page): Request {
+        val url = page.imageUrl!!
+        val newUrl = if (preferences.isHiRes) url.replace("/low-res/", "/hi-res/") else url
+        return GET(newUrl, headers)
+    }
+
+    override fun setupPreferenceScreen(screen: PreferenceScreen) {
+        getPreferences(screen.context).forEach(screen::addPreference)
+    }
+
+    private val dateRegex by lazy { Regex("""\d{4}-\d{2}-\d{2}""") }
+    private val dateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) }
+}
diff --git a/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Preferences.kt b/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Preferences.kt
new file mode 100644
index 000000000..c03c22bd1
--- /dev/null
+++ b/src/all/peppercarrot/src/eu/kanade/tachiyomi/extension/all/peppercarrot/Preferences.kt
@@ -0,0 +1,145 @@
+package eu.kanade.tachiyomi.extension.all.peppercarrot
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Base64
+import androidx.preference.SwitchPreferenceCompat
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.decodeFromByteArray
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToByteArray
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.protobuf.ProtoBuf
+import okhttp3.Headers
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import org.jsoup.parser.Parser.unescapeEntities
+import org.jsoup.select.Evaluator
+import uy.kohesive.injekt.injectLazy
+import java.util.Locale
+
+fun getPreferences(context: Context) = arrayOf(
+    SwitchPreferenceCompat(context).apply {
+        key = HI_RES_PREF
+        title = "High resolution images"
+        summary = "Changes will not be applied to images that are already cached or downloaded " +
+            "until you clear the chapter cache or delete the chapter download."
+        setDefaultValue(false)
+    },
+)
+
+val SharedPreferences.isHiRes get() = getBoolean(HI_RES_PREF, false)
+val SharedPreferences.lastUpdated get() = getLong(LAST_UPDATED_PREF, 0)
+
+val SharedPreferences.lang: List<String>
+    get() {
+        val lang = getString(LANG_PREF, "")!!
+        if (lang.isEmpty()) return emptyList()
+        return lang.split(", ")
+    }
+
+fun SharedPreferences.Editor.setLang(value: Iterable<String>): SharedPreferences.Editor =
+    putString(LANG_PREF, value.joinToString())
+
+val SharedPreferences.langData: List<LangData>
+    get() {
+        val data = getString(LANG_DATA_PREF, "")!!
+        if (data.isEmpty()) return emptyList()
+        return ProtoBuf.decodeFromBase64(data)
+    }
+
+@Synchronized
+fun updateLangData(client: OkHttpClient, headers: Headers, preferences: SharedPreferences) {
+    val lastUpdated = client.newCall(GET("$BASE_URL/0_sources/last_updated.txt", headers))
+        .execute().body!!.string().substringBefore('\n').toLong()
+    if (lastUpdated <= preferences.lastUpdated) return
+
+    val editor = preferences.edit().putLong(LAST_UPDATED_PREF, lastUpdated)
+
+    val episodes = client.newCall(GET("$BASE_URL/0_sources/episodes.json", headers))
+        .execute().parseAs<List<EpisodeDto>>()
+    val total = episodes.size
+    val translatedCount = episodes.flatMap { it.translated_languages }
+        .groupingBy { it }.eachCount()
+
+    val titles = fetchTitles(client, headers)
+
+    val langs = client.newCall(GET("$BASE_URL/0_sources/langs.json", headers))
+        .execute().parseAs<LangsDto>().entries.map { (key, dto) ->
+            Lang(
+                key = key,
+                name = dto.local_name,
+                code = dto.iso_code.ifEmpty { key },
+                translators = dto.translators.joinToString(),
+                translatedCount = translatedCount[key] ?: 0
+            )
+        }
+        .filter { it.translatedCount > 0 }
+        .groupBy { it.code }.values
+        .flatMap { it.sortedByDescending { lang -> lang.translatedCount } }
+        .also { if (preferences.lang.isEmpty()) editor.chooseLang(it) }
+        .map {
+            val progress = "${it.translatedCount}/$total translated"
+            LangData(it.key, it.name, progress, it.translators, titles[it.key])
+        }
+
+    editor.putString(LANG_DATA_PREF, ProtoBuf.encodeToBase64(langs)).apply()
+}
+
+private fun SharedPreferences.Editor.chooseLang(langs: List<Lang>) {
+    val language = Locale.getDefault().language
+    val result = langs.filter { it.code == language }.mapTo(ArrayList()) { it.key }
+    if (result.isEmpty()) return
+    if (language != "en") result.add("en")
+    setLang(result)
+}
+
+private fun fetchTitles(client: OkHttpClient, headers: Headers): Map<String, String> {
+    val url = "https://framagit.org/search?project_id=76196&search=core/mod-header.php:4"
+    val document = client.newCall(GET(url, headers)).execute().asJsoup()
+    val result = hashMapOf<String, String>()
+    for (file in document.selectFirst(Evaluator.Class("search-results")).children()) {
+        val filename = file.selectFirst(Evaluator.Tag("strong")).ownText()
+        if (!filename.endsWith(".po") || !filename.startsWith("po/")) continue
+        val lang = filename.substring(3, filename.length - 3)
+
+        val lines = file.select(Evaluator.Class("line"))
+        for (i in lines.indices) {
+            if (lines[i].ownText() == "msgid \"Pepper&amp;Carrot\"" && i + 1 < lines.size) {
+                val title = lines[i + 1].ownText().removePrefix("msgstr \"").removeSuffix("\"")
+                val unescaped = unescapeEntities(title, false).trim()
+                if (unescaped.isNotEmpty() && unescaped != TITLE) result[lang] = unescaped
+                break
+            }
+        }
+    }
+
+    for (sameTitleList in result.entries.groupBy { it.value }.values) {
+        if (sameTitleList.size == 1) continue
+        for (entry in sameTitleList) {
+            entry.setValue("${entry.value} (${entry.key.uppercase()})")
+        }
+    }
+
+    return result
+}
+
+private inline fun <reified T> Response.parseAs(): T = json.decodeFromString(body!!.string())
+
+private inline fun <reified T> ProtoBuf.decodeFromBase64(base64: String): T =
+    decodeFromByteArray(Base64.decode(base64, Base64.NO_WRAP))
+
+private inline fun <reified T> ProtoBuf.encodeToBase64(value: T): String =
+    Base64.encodeToString(encodeToByteArray(value), Base64.NO_WRAP)
+
+private val json: Json by injectLazy()
+
+const val BASE_URL = "https://www.peppercarrot.com"
+const val TITLE = "Pepper&Carrot"
+const val AUTHOR = "David Revoy"
+
+private const val LANG_PREF = "LANG"
+private const val LANG_DATA_PREF = "LANG_DATA"
+private const val LAST_UPDATED_PREF = "LAST_UPDATED"
+private const val HI_RES_PREF = "HI_RES"