Compare commits

...

13 Commits

Author SHA1 Message Date
Aria Moradi a58aab9004 [CI RELEASE] v.0.1.4 2021-02-04 04:01:20 +03:30
Aria Moradi 61bd32f7f0 don't cancel me shit 2021-02-04 03:52:49 +03:30
Aria Moradi 63a444bd81 calling HttpSource.imageRequest now 2021-02-04 03:42:30 +03:30
Aria Moradi 8f28c3b74b eh missing shit from last commit 2021-02-04 00:38:23 +03:30
Aria Moradi d766206343 fix sqlite locking fuckery by replacing it with h2 2021-02-04 00:32:01 +03:30
Aria Moradi 172f83f5b3 Manga dir 2021-02-03 23:35:34 +03:30
Aria Moradi 9e308025c3 some initial code for MangaDex login 2021-02-03 22:07:39 +03:30
Aria Moradi aaa6a16778 Update README.md 2021-01-30 01:07:42 +03:30
Aria Moradi 2a21da2210 [SKIP CI] update README.md 2021-01-29 19:11:38 +03:30
Aria Moradi d1cd2cfc8c [RELEASE CI] bump version 2021-01-29 15:26:08 +03:30
Aria Moradi 832c224ed4 uninstalling extensions implemented 2021-01-29 15:23:29 +03:30
Aria Moradi 99316f4bd5 revert react changes 2021-01-29 14:25:18 +03:30
Aria Moradi 9caae5f1e5 thumbnail caching 2021-01-29 14:19:24 +03:30
26 changed files with 629 additions and 143 deletions
+5 -2
View File
@@ -49,14 +49,17 @@ This project has two components:
1. **server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run apk extensions. All this concludes to serving a REST API to `webUI`. 1. **server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run apk extensions. All this concludes to serving a REST API to `webUI`.
2. **webUI:** A react SPA project that works with the server to do the presentation. 2. **webUI:** A react SPA project that works with the server to do the presentation.
## Support
Join Tachidesk's [discord server](https://discord.gg/wgPyb7hE5d) to hang out with the community and receive support.
## Credit ## Credit
The `AndroidCompat` module and `scripts/getAndroid.sh` was originally developed by [@null-dev](https://github.com/null-dev) for [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server) and is licensed under `Apache License Version 2.0`. The `AndroidCompat` module and `scripts/getAndroid.sh` was originally developed by [@null-dev](https://github.com/null-dev) for [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server) and is licensed under `Apache License Version 2.0`.
Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0`. Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0`.
Changes to both codebases is licensed under `MPL v. 2.0` as the rest of this project. You can obtain a copy of `Apache License Version 2.0` from http://www.apache.org/licenses/LICENSE-2.0
You can obtain a copy of the license from http://www.apache.org/licenses/LICENSE-2.0 Changes to both codebases is licensed under `MPL v. 2.0` as the rest of this project.
## License ## License
+3 -2
View File
@@ -8,7 +8,7 @@ plugins {
id("org.jmailen.kotlinter") version "3.3.0" id("org.jmailen.kotlinter") version "3.3.0"
} }
val TachideskVersion = "v0.1.2" val TachideskVersion = "v0.1.4"
repositories { repositories {
@@ -79,7 +79,8 @@ dependencies {
implementation ("org.jetbrains.exposed:exposed-core:$exposed_version") implementation ("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation ("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation ("org.jetbrains.exposed:exposed-dao:$exposed_version")
implementation ("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation ("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation ("org.xerial:sqlite-jdbc:3.30.1") implementation ("com.h2database:h2:1.4.199")
// AndroidCompat // AndroidCompat
implementation(project(":AndroidCompat")) implementation(project(":AndroidCompat"))
@@ -0,0 +1,72 @@
package eu.kanade.tachiyomi.network
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
class MemoryCookieJar : CookieJar {
private val cache = mutableSetOf<WrappedCookie>()
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val cookiesToRemove = mutableSetOf<WrappedCookie>()
val validCookies = mutableSetOf<WrappedCookie>()
cache.forEach { cookie ->
if (cookie.isExpired()) {
cookiesToRemove.add(cookie)
} else if (cookie.matches(url)) {
validCookies.add(cookie)
}
}
cache.removeAll(cookiesToRemove)
return validCookies.toList().map(WrappedCookie::unwrap)
}
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val cookiesToAdd = cookies.map { WrappedCookie.wrap(it) }
cache.removeAll(cookiesToAdd)
cache.addAll(cookiesToAdd)
}
@Synchronized
fun clear() {
cache.clear()
}
}
class WrappedCookie private constructor(val cookie: Cookie) {
fun unwrap() = cookie
fun isExpired() = cookie.expiresAt < System.currentTimeMillis()
fun matches(url: HttpUrl) = cookie.matches(url)
override fun equals(other: Any?): Boolean {
if (other !is WrappedCookie) return false
return other.cookie.name == cookie.name &&
other.cookie.domain == cookie.domain &&
other.cookie.path == cookie.path &&
other.cookie.secure == cookie.secure &&
other.cookie.hostOnly == cookie.hostOnly
}
override fun hashCode(): Int {
var hash = 17
hash = 31 * hash + cookie.name.hashCode()
hash = 31 * hash + cookie.domain.hashCode()
hash = 31 * hash + cookie.path.hashCode()
hash = 31 * hash + if (cookie.secure) 0 else 1
hash = 31 * hash + if (cookie.hostOnly) 0 else 1
return hash
}
companion object {
fun wrap(cookie: Cookie) = WrappedCookie(cookie)
}
}
@@ -19,14 +19,17 @@ class NetworkHelper(context: Context) {
private val cacheSize = 5L * 1024 * 1024 // 5 MiB private val cacheSize = 5L * 1024 * 1024 // 5 MiB
// val cookieManager = AndroidCookieJar() val cookieManager = MemoryCookieJar()
val client by lazy { val client by lazy {
val builder = OkHttpClient.Builder() val builder = OkHttpClient.Builder()
// .cookieJar(cookieManager) .cookieJar(cookieManager)
// .cache(Cache(cacheDir, cacheSize)) // .cache(Cache(cacheDir, cacheSize))
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.MINUTES)
.writeTimeout(5, TimeUnit.MINUTES)
// .dispatcher(Dispatcher(Executors.newFixedThreadPool(1)))
// .addInterceptor(UserAgentInterceptor()) // .addInterceptor(UserAgentInterceptor())
// if (BuildConfig.DEBUG) { // if (BuildConfig.DEBUG) {
@@ -34,7 +34,7 @@ fun Call.asObservable(): Observable<Response> {
} }
override fun unsubscribe() { override fun unsubscribe() {
call.cancel() // call.cancel()
} }
override fun isUnsubscribed(): Boolean { override fun isUnsubscribed(): Boolean {
@@ -80,17 +80,18 @@ fun Call.asObservable(): Observable<Response> {
// } // }
fun Call.asObservableSuccess(): Observable<Response> { fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response -> return asObservable()
if (!response.isSuccessful) { .doOnNext { response ->
response.close() if (!response.isSuccessful) {
throw Exception("HTTP error ${response.code}") response.close()
throw Exception("HTTP error ${response.code}")
}
} }
}
} }
// fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { // fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
// val progressClient = newBuilder() // val progressClient = newBuilder()
// .cache(null) // .cache(nasObservableSuccessull)
// .addNetworkInterceptor { chain -> // .addNetworkInterceptor { chain ->
// val originalResponse = chain.proceed(chain.request()) // val originalResponse = chain.proceed(chain.request())
// originalResponse.newBuilder() // originalResponse.newBuilder()
@@ -104,7 +105,7 @@ fun Call.asObservableSuccess(): Observable<Response> {
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder() val progressClient = newBuilder()
.cache(null) // .cache(null)
// .addNetworkInterceptor { chain -> // .addNetworkInterceptor { chain ->
// val originalResponse = chain.proceed(chain.request()) // val originalResponse = chain.proceed(chain.request())
// originalResponse.newBuilder() // originalResponse.newBuilder()
@@ -29,7 +29,7 @@ abstract class HttpSource : CatalogueSource {
/** /**
* Network service. * Network service.
*/ */
protected val network: NetworkHelper by injectLazy() val network: NetworkHelper by injectLazy()
// /** // /**
// * Preferences that a source may need. // * Preferences that a source may need.
@@ -311,7 +311,7 @@ abstract class HttpSource : CatalogueSource {
* *
* @param page the chapter whose page list has to be fetched * @param page the chapter whose page list has to be fetched
*/ */
protected open fun imageRequest(page: Page): Request { open fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers) return GET(page.imageUrl!!, headers)
} }
@@ -9,4 +9,6 @@ import net.harawata.appdirs.AppDirsFactory
object Config { object Config {
val dataRoot = AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null) val dataRoot = AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
val extensionsRoot = "$dataRoot/extensions" val extensionsRoot = "$dataRoot/extensions"
val thumbnailsRoot = "$dataRoot/thumbnails"
val mangaRoot = "$dataRoot/manga"
} }
@@ -7,14 +7,17 @@ package ir.armor.tachidesk
import eu.kanade.tachiyomi.App import eu.kanade.tachiyomi.App
import io.javalin.Javalin import io.javalin.Javalin
import ir.armor.tachidesk.util.applicationSetup import ir.armor.tachidesk.util.applicationSetup
import ir.armor.tachidesk.util.getChapter
import ir.armor.tachidesk.util.getChapterList import ir.armor.tachidesk.util.getChapterList
import ir.armor.tachidesk.util.getExtensionList import ir.armor.tachidesk.util.getExtensionList
import ir.armor.tachidesk.util.getManga import ir.armor.tachidesk.util.getManga
import ir.armor.tachidesk.util.getMangaList import ir.armor.tachidesk.util.getMangaList
import ir.armor.tachidesk.util.getPages import ir.armor.tachidesk.util.getPageImage
import ir.armor.tachidesk.util.getSource import ir.armor.tachidesk.util.getSource
import ir.armor.tachidesk.util.getSourceList import ir.armor.tachidesk.util.getSourceList
import ir.armor.tachidesk.util.getThumbnail
import ir.armor.tachidesk.util.installAPK import ir.armor.tachidesk.util.installAPK
import ir.armor.tachidesk.util.removeExtension
import ir.armor.tachidesk.util.sourceFilters import ir.armor.tachidesk.util.sourceFilters
import ir.armor.tachidesk.util.sourceGlobalSearch import ir.armor.tachidesk.util.sourceGlobalSearch
import ir.armor.tachidesk.util.sourceSearch import ir.armor.tachidesk.util.sourceSearch
@@ -54,6 +57,8 @@ class Main {
// start app // start app
androidCompat.startApp(App()) androidCompat.startApp(App())
// Thread(getMangaUpdateQueueThread).start()
val app = Javalin.create { config -> val app = Javalin.create { config ->
try { try {
this::class.java.classLoader.getResource("/react/index.html") this::class.java.classLoader.getResource("/react/index.html")
@@ -75,11 +80,20 @@ class Main {
app.get("/api/v1/extension/install/:apkName") { ctx -> app.get("/api/v1/extension/install/:apkName") { ctx ->
val apkName = ctx.pathParam("apkName") val apkName = ctx.pathParam("apkName")
println(apkName) println("installing $apkName")
ctx.status( ctx.status(
installAPK(apkName) installAPK(apkName)
) )
} }
app.get("/api/v1/extension/uninstall/:apkName") { ctx ->
val apkName = ctx.pathParam("apkName")
println("uninstalling $apkName")
removeExtension(apkName)
ctx.status(200)
}
app.get("/api/v1/source/list") { ctx -> app.get("/api/v1/source/list") { ctx ->
ctx.json(getSourceList()) ctx.json(getSourceList())
} }
@@ -105,6 +119,14 @@ class Main {
ctx.json(getManga(mangaId)) ctx.json(getManga(mangaId))
} }
app.get("api/v1/manga/:mangaId/thumbnail") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt()
val result = getThumbnail(mangaId)
ctx.result(result.first)
ctx.header("content-type", result.second)
}
app.get("/api/v1/manga/:mangaId/chapters") { ctx -> app.get("/api/v1/manga/:mangaId/chapters") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getChapterList(mangaId)) ctx.json(getChapterList(mangaId))
@@ -113,7 +135,17 @@ class Main {
app.get("/api/v1/manga/:mangaId/chapter/:chapterId") { ctx -> app.get("/api/v1/manga/:mangaId/chapter/:chapterId") { ctx ->
val chapterId = ctx.pathParam("chapterId").toInt() val chapterId = ctx.pathParam("chapterId").toInt()
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(getPages(chapterId, mangaId)) ctx.json(getChapter(chapterId, mangaId))
}
app.get("/api/v1/manga/:mangaId/chapter/:chapterId/page/:index") { ctx ->
val chapterId = ctx.pathParam("chapterId").toInt()
val mangaId = ctx.pathParam("mangaId").toInt()
val index = ctx.pathParam("index").toInt()
val result = getPageImage(mangaId, chapterId, index)
ctx.result(result.first)
ctx.header("content-type", result.second)
} }
// global search // global search
@@ -8,6 +8,7 @@ import ir.armor.tachidesk.Config
import ir.armor.tachidesk.database.table.ChapterTable import ir.armor.tachidesk.database.table.ChapterTable
import ir.armor.tachidesk.database.table.ExtensionsTable import ir.armor.tachidesk.database.table.ExtensionsTable
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.database.table.MangaTable
import ir.armor.tachidesk.database.table.PageTable
import ir.armor.tachidesk.database.table.SourceTable import ir.armor.tachidesk.database.table.SourceTable
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.SchemaUtils
@@ -15,18 +16,21 @@ import org.jetbrains.exposed.sql.transactions.transaction
object DBMangaer { object DBMangaer {
val db by lazy { val db by lazy {
Database.connect("jdbc:sqlite:${Config.dataRoot}/database.db", "org.sqlite.JDBC") Database.connect("jdbc:h2:${Config.dataRoot}/database.h2", "org.h2.Driver")
} }
} }
fun makeDataBaseTables() { fun makeDataBaseTables() {
// mention db object to connect // mention db object to connect
DBMangaer.db DBMangaer.db
// val db = DBMangaer.db
// db.useNestedTransactions = true
transaction { transaction {
SchemaUtils.create(ExtensionsTable) SchemaUtils.create(ExtensionsTable)
SchemaUtils.create(SourceTable) SchemaUtils.create(SourceTable)
SchemaUtils.create(MangaTable) SchemaUtils.create(MangaTable)
SchemaUtils.create(ChapterTable) SchemaUtils.create(ChapterTable)
SchemaUtils.create(PageTable)
} }
} }
@@ -12,4 +12,5 @@ data class ChapterDataClass(
val chapter_number: Float, val chapter_number: Float,
val scanlator: String?, val scanlator: String?,
val mangaId: Int, val mangaId: Int,
val pageCount: Int? = null,
) )
@@ -1,5 +1,9 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.database.table
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
object ChapterTable : IntIdTable() { object ChapterTable : IntIdTable() {
@@ -1,5 +1,9 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.database.table
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
object ExtensionsTable : IntIdTable() { object ExtensionsTable : IntIdTable() {
@@ -1,5 +1,9 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.database.table
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
@@ -0,0 +1,15 @@
package ir.armor.tachidesk.database.table
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.dao.id.IntIdTable
object PageTable : IntIdTable() {
val index = integer("index")
val url = varchar("url", 2048)
val imageUrl = varchar("imageUrl", 2048).nullable()
val chapter = reference("chapter", ChapterTable)
}
@@ -1,5 +1,9 @@
package ir.armor.tachidesk.database.table package ir.armor.tachidesk.database.table
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.dao.id.IdTable import org.jetbrains.exposed.dao.id.IdTable
object SourceTable : IdTable<Long>() { object SourceTable : IdTable<Long>() {
@@ -4,14 +4,14 @@ package ir.armor.tachidesk.util
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import ir.armor.tachidesk.database.dataclass.ChapterDataClass import ir.armor.tachidesk.database.dataclass.ChapterDataClass
import ir.armor.tachidesk.database.dataclass.PageDataClass
import ir.armor.tachidesk.database.table.ChapterTable import ir.armor.tachidesk.database.table.ChapterTable
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.database.table.MangaTable
import ir.armor.tachidesk.database.table.PageTable
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@@ -57,14 +57,14 @@ fun getChapterList(mangaId: Int): List<ChapterDataClass> {
} }
} }
fun getPages(chapterId: Int, mangaId: Int): Pair<ChapterDataClass, List<PageDataClass>> { fun getChapter(chapterId: Int, mangaId: Int): ChapterDataClass {
return transaction { return transaction {
val chapterEntry = ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! val chapterEntry = ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!!
assert(mangaId == chapterEntry[ChapterTable.manga].value) // sanity check assert(mangaId == chapterEntry[ChapterTable.manga].value) // sanity check
val mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! val mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!!
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value) val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value)
val pagesList = source.fetchPageList( val pageList = source.fetchPageList(
SChapter.create().apply { SChapter.create().apply {
url = chapterEntry[ChapterTable.url] url = chapterEntry[ChapterTable.url]
name = chapterEntry[ChapterTable.name] name = chapterEntry[ChapterTable.name]
@@ -78,22 +78,24 @@ fun getPages(chapterId: Int, mangaId: Int): Pair<ChapterDataClass, List<PageData
chapterEntry[ChapterTable.date_upload], chapterEntry[ChapterTable.date_upload],
chapterEntry[ChapterTable.chapter_number], chapterEntry[ChapterTable.chapter_number],
chapterEntry[ChapterTable.scanlator], chapterEntry[ChapterTable.scanlator],
mangaId mangaId,
pageList.count()
) )
val pages = pagesList.map { pageList.forEach { page ->
PageDataClass( val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }.firstOrNull() }
it.index, if (pageEntry == null) {
getTrueImageUrl(it, source) transaction {
) PageTable.insert {
it[index] = page.index
it[url] = page.url
it[imageUrl] = page.imageUrl
it[this.chapter] = chapterId
}
}
}
} }
return@transaction Pair(chapter, pages) return@transaction chapter
} }
} }
fun getTrueImageUrl(page: Page, source: HttpSource): String {
return if (page.imageUrl == null) {
source.fetchImageUrl(page).toBlocking().first()!!
} else page.imageUrl!!
}
@@ -0,0 +1,52 @@
package ir.armor.tachidesk.util
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import okio.BufferedSource
import okio.buffer
import okio.sink
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Paths
fun writeStream(fileStream: InputStream, path: String) {
Files.newOutputStream(Paths.get(path)).use { os ->
val buffer = ByteArray(128 * 1024)
var len: Int
while (fileStream.read(buffer).also { len = it } > 0) {
os.write(buffer, 0, len)
}
}
}
fun pathToInputStream(path: String): InputStream {
return BufferedInputStream(FileInputStream(path))
}
fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
File(directoryPath).listFiles().forEach { file ->
if (file.name.startsWith(fileName))
return "$directoryPath/${file.name}"
}
return null
}
/**
* Saves the given source to an output stream and closes both resources.
*
* @param stream the stream where the source is copied.
*/
fun BufferedSource.saveTo(stream: OutputStream) {
use { input ->
stream.sink().buffer().use {
it.writeAll(input)
it.flush()
}
}
}
@@ -4,77 +4,135 @@ package ir.armor.tachidesk.util
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import ir.armor.tachidesk.Config
import ir.armor.tachidesk.database.dataclass.MangaDataClass import ir.armor.tachidesk.database.dataclass.MangaDataClass
import ir.armor.tachidesk.database.table.MangaStatus import ir.armor.tachidesk.database.table.MangaStatus
import ir.armor.tachidesk.database.table.MangaTable import ir.armor.tachidesk.database.table.MangaTable
import ir.armor.tachidesk.database.table.SourceTable
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import java.io.File
import java.io.InputStream
fun getManga(mangaId: Int): MangaDataClass { fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
return transaction { var mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
var mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!!
return@transaction if (mangaEntry[MangaTable.initialized]) { return if (mangaEntry[MangaTable.initialized]) {
MangaDataClass( MangaDataClass(
mangaId, mangaId,
mangaEntry[MangaTable.sourceReference].value, mangaEntry[MangaTable.sourceReference].value,
mangaEntry[MangaTable.url], mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title], mangaEntry[MangaTable.title],
mangaEntry[MangaTable.thumbnail_url], if (proxyThumbnail) proxyThumbnailUrl(mangaId) else mangaEntry[MangaTable.thumbnail_url],
true, true,
mangaEntry[MangaTable.artist], mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.author], mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description], mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre], mangaEntry[MangaTable.genre],
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
) )
} else { // initialize manga } else { // initialize manga
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value) val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value)
val fetchedManga = source.fetchMangaDetails( val fetchedManga = source.fetchMangaDetails(
SManga.create().apply { SManga.create().apply {
url = mangaEntry[MangaTable.url] url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title] title = mangaEntry[MangaTable.title]
}
).toBlocking().first()
// update database
MangaTable.update({ MangaTable.id eq mangaId }) {
// it[url] = fetchedManga.url
// it[title] = fetchedManga.title
it[initialized] = true
it[artist] = fetchedManga.artist
it[author] = fetchedManga.author
it[description] = fetchedManga.description
it[genre] = fetchedManga.genre
it[status] = fetchedManga.status
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url!!.isNotEmpty())
it[thumbnail_url] = fetchedManga.thumbnail_url
} }
).toBlocking().first()
mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! transaction {
MangaTable.update({ MangaTable.id eq mangaId }) {
MangaDataClass( it[MangaTable.initialized] = true
mangaId,
mangaEntry[MangaTable.sourceReference].value,
mangaEntry[MangaTable.url], it[MangaTable.artist] = fetchedManga.artist
mangaEntry[MangaTable.title], it[MangaTable.author] = fetchedManga.author
mangaEntry[MangaTable.thumbnail_url], it[MangaTable.description] = fetchedManga.description
it[MangaTable.genre] = fetchedManga.genre
true, it[MangaTable.status] = fetchedManga.status
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url!!.isNotEmpty())
mangaEntry[MangaTable.artist], it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
mangaEntry[MangaTable.author], }
mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre],
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
)
} }
mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val newThumbnail = mangaEntry[MangaTable.thumbnail_url]
MangaDataClass(
mangaId,
mangaEntry[MangaTable.sourceReference].value,
mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title],
if (proxyThumbnail) proxyThumbnailUrl(mangaId) else newThumbnail,
true,
fetchedManga.artist,
fetchedManga.author,
fetchedManga.description,
fetchedManga.genre,
MangaStatus.valueOf(fetchedManga.status).name,
)
} }
} }
fun getThumbnail(mangaId: Int): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
var filePath = "${Config.thumbnailsRoot}/$mangaId."
val potentialCache = findFileNameStartingWith(Config.thumbnailsRoot, mangaId.toString())
if (potentialCache != null) {
println("using cached thumbnail file")
return Pair(
pathToInputStream(potentialCache),
"image/${potentialCache.substringAfter(filePath)}"
)
}
val sourceId = mangaEntry[MangaTable.sourceReference].value
println("getting source for $mangaId")
val source = getHttpSource(sourceId)
var thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
if (thumbnailUrl == null || thumbnailUrl.isEmpty()) {
thumbnailUrl = getManga(mangaId, proxyThumbnail = false).thumbnailUrl!!
}
println(thumbnailUrl)
val response = source.client.newCall(
GET(thumbnailUrl, source.headers)
).execute()
if (response.code == 200) {
val contentType = response.headers["content-type"]!!
filePath += contentType.substringAfter("image/")
writeStream(response.body!!.byteStream(), filePath)
return Pair(
pathToInputStream(filePath),
contentType
)
} else {
throw Exception("request error! ${response.code}")
}
}
fun getMangaDir(mangaId: Int): String {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val sourceId = mangaEntry[MangaTable.sourceReference].value
val sourceEntry = transaction { SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! }
val mangaTitle = mangaEntry[MangaTable.title]
val sourceName = sourceEntry[SourceTable.name]
val mangaDir = "${Config.mangaRoot}/$sourceName/$mangaTitle"
// make sure dirs exist
File(mangaDir).mkdirs()
return mangaDir
}
@@ -0,0 +1,95 @@
package ir.armor.tachidesk.util
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.FormBody
import okhttp3.OkHttpClient
import java.net.URLEncoder
class MangaDexHelper(private val mangaDexSource: HttpSource) {
private fun clientBuilder(): OkHttpClient = clientBuilder(0)
private fun clientBuilder(
r18Toggle: Int,
okHttpClient: OkHttpClient = mangaDexSource.network.client
): OkHttpClient = okHttpClient.newBuilder()
.addNetworkInterceptor { chain ->
val originalCookies = chain.request().header("Cookie") ?: ""
val newReq = chain
.request()
.newBuilder()
.header("Cookie", "$originalCookies; ${cookiesHeader(r18Toggle)}")
.build()
chain.proceed(newReq)
}.build()
private fun cookiesHeader(r18Toggle: Int): String {
val cookies = mutableMapOf<String, String>()
cookies["mangadex_h_toggle"] = r18Toggle.toString()
return buildCookies(cookies)
}
private fun buildCookies(cookies: Map<String, String>) =
cookies.entries.joinToString(separator = "; ", postfix = ";") {
"${URLEncoder.encode(it.key, "UTF-8")}=${URLEncoder.encode(it.value, "UTF-8")}"
}
// fun isLogged(): Boolean {
// val httpUrl = mangaDexSource.baseUrl.toHttpUrlOrNull()!!
// return network.cookieManager.get(httpUrl).any { it.name == REMEMBER_ME }
// }
fun login(username: String, password: String, twoFactorCode: String = ""): Boolean {
val formBody = FormBody.Builder()
.add("login_username", username)
.add("login_password", password)
.add("no_js", "1")
.add("remember_me", "1")
twoFactorCode.let {
formBody.add("two_factor", it)
}
val response = clientBuilder().newCall(
POST(
"${mangaDexSource.baseUrl}/ajax/actions.ajax.php?function=login",
mangaDexSource.headers,
formBody.build()
)
).execute()
return response.body!!.string().isEmpty()
}
//
// fun logout(): Boolean {
// return withContext(Dispatchers.IO) {
// // https://mangadex.org/ajax/actions.ajax.php?function=logout
// val httpUrl = baseUrl.toHttpUrlOrNull()!!
// val listOfDexCookies = network.cookieManager.get(httpUrl)
// val cookie = listOfDexCookies.find { it.name == REMEMBER_ME }
// val token = cookie?.value
// if (token.isNullOrEmpty()) {
// return@withContext true
// }
// val result = clientBuilder().newCall(
// POSTWithCookie(
// "$baseUrl/ajax/actions.ajax.php?function=logout",
// REMEMBER_ME,
// token,
// headers
// )
// ).execute()
// val resultStr = result.body!!.string()
// if (resultStr.contains("success", true)) {
// network.cookieManager.remove(httpUrl)
// return@withContext true
// }
//
// false
// }
// }
}
@@ -13,6 +13,10 @@ import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
fun proxyThumbnailUrl(mangaId: Int): String {
return "http://127.0.0.1:4567/api/v1/manga/$mangaId/thumbnail"
}
fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass { fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass {
val source = getHttpSource(sourceId.toLong()) val source = getHttpSource(sourceId.toLong())
val mangasPage = if (popular) { val mangasPage = if (popular) {
@@ -31,8 +35,8 @@ fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
val mangaList = transaction { val mangaList = transaction {
return@transaction mangasPage.mangas.map { manga -> return@transaction mangasPage.mangas.map { manga ->
var mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.firstOrNull() var mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.firstOrNull()
var mangaEntityId = if (mangaEntry == null) { // create manga entry if (mangaEntry == null) { // create manga entry
MangaTable.insertAndGetId { val mangaId = MangaTable.insertAndGetId {
it[url] = manga.url it[url] = manga.url
it[title] = manga.title it[title] = manga.title
@@ -41,30 +45,46 @@ fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
it[description] = manga.description it[description] = manga.description
it[genre] = manga.genre it[genre] = manga.genre
it[status] = manga.status it[status] = manga.status
it[thumbnail_url] = manga.genre it[thumbnail_url] = manga.thumbnail_url
it[sourceReference] = sourceId it[sourceReference] = sourceId
}.value }.value
MangaDataClass(
mangaId,
sourceId,
manga.url,
manga.title,
proxyThumbnailUrl(mangaId),
manga.initialized,
manga.artist,
manga.author,
manga.description,
manga.genre,
MangaStatus.valueOf(manga.status).name,
)
} else { } else {
mangaEntry[MangaTable.id].value val mangaId = mangaEntry[MangaTable.id].value
MangaDataClass(
mangaId,
sourceId,
manga.url,
manga.title,
proxyThumbnailUrl(mangaId),
true,
mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre],
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
)
} }
MangaDataClass(
mangaEntityId,
sourceId,
manga.url,
manga.title,
manga.thumbnail_url,
manga.initialized,
manga.artist,
manga.author,
manga.description,
manga.genre,
MangaStatus.valueOf(manga.status).name,
)
} }
} }
return PagedMangaListDataClass( return PagedMangaListDataClass(
@@ -0,0 +1,81 @@
package ir.armor.tachidesk.util
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import ir.armor.tachidesk.database.table.ChapterTable
import ir.armor.tachidesk.database.table.MangaTable
import ir.armor.tachidesk.database.table.PageTable
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import java.io.File
import java.io.InputStream
import java.nio.file.Files
import java.nio.file.Paths
fun getTrueImageUrl(page: Page, source: HttpSource): String {
if (page.imageUrl == null) {
page.imageUrl = source.fetchImageUrl(page).toBlocking().first()!!
}
return page.imageUrl!!
}
fun getPageImage(mangaId: Int, chapterId: Int, index: Int): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value)
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.firstOrNull()!! }
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.firstOrNull()!! }
val tachiPage = Page(
pageEntry[PageTable.index],
pageEntry[PageTable.url],
pageEntry[PageTable.imageUrl]
)
if (pageEntry[PageTable.imageUrl] == null) {
transaction {
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq index) }) {
it[imageUrl] = getTrueImageUrl(tachiPage, source)
}
}
}
val saveDir = getMangaDir(mangaId) + "/" + chapterEntry[ChapterTable.chapter_number]
File(saveDir).mkdirs()
var filePath = "$saveDir/$index."
val potentialCache = findFileNameStartingWith(saveDir, index.toString())
if (potentialCache != null) {
println("using cached page file for $index")
return Pair(
pathToInputStream(potentialCache),
"image/${potentialCache.substringAfter("$filePath")}"
)
}
val response = source.fetchImage(tachiPage).toBlocking().first()
if (response.code == 200) {
val contentType = response.headers["content-type"]!!
filePath += contentType.substringAfter("image/")
Files.newOutputStream(Paths.get(filePath)).use { os ->
response.body!!.source().saveTo(os)
}
// writeStream(response.body!!.source(), filePath)
return Pair(
pathToInputStream(filePath),
contentType
)
} else {
throw Exception("request error! ${response.code}")
}
}
@@ -12,6 +12,7 @@ fun applicationSetup() {
// make dirs we need // make dirs we need
File(Config.dataRoot).mkdirs() File(Config.dataRoot).mkdirs()
File(Config.extensionsRoot).mkdirs() File(Config.extensionsRoot).mkdirs()
File(Config.thumbnailsRoot).mkdirs()
makeDataBaseTables() makeDataBaseTables()
} }
@@ -17,6 +17,7 @@ import kotlinx.coroutines.runBlocking
import okhttp3.Request import okhttp3.Request
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@@ -32,8 +33,8 @@ fun installAPK(apkName: String): Int {
val dirPathWithoutType = "${Config.extensionsRoot}/$fileNameWithoutType" val dirPathWithoutType = "${Config.extensionsRoot}/$fileNameWithoutType"
// check if we don't have the dex file already downloaded // check if we don't have the dex file already downloaded
val dexPath = "${Config.extensionsRoot}/$fileNameWithoutType.jar" val jarPath = "${Config.extensionsRoot}/$fileNameWithoutType.jar"
if (!File(dexPath).exists()) { if (!File(jarPath).exists()) {
runBlocking { runBlocking {
val api = ExtensionGithubApi() val api = ExtensionGithubApi()
val apkToDownload = api.getApkUrl(extensionRecord) val apkToDownload = api.getApkUrl(extensionRecord)
@@ -130,3 +131,21 @@ private fun downloadAPKFile(url: String, apkPath: String) {
sink.writeAll(response.body!!.source()) sink.writeAll(response.body!!.source())
sink.close() sink.close()
} }
fun removeExtension(pkgName: String) {
val extensionRecord = getExtensionList(true).first { it.apkName == pkgName }
val fileNameWithoutType = pkgName.substringBefore(".apk")
val jarPath = "${Config.extensionsRoot}/$fileNameWithoutType.jar"
transaction {
val extensionId = ExtensionsTable.select { ExtensionsTable.name eq extensionRecord.name }.first()[ExtensionsTable.id]
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
ExtensionsTable.update({ ExtensionsTable.name eq extensionRecord.name }) {
it[ExtensionsTable.installed] = false
}
}
if (File(jarPath).exists()) {
File(jarPath).delete()
}
}
+18 -3
View File
@@ -46,7 +46,7 @@ export default function ExtensionCard(props: IProps) {
name, lang, versionName, iconUrl, installed, apkName, name, lang, versionName, iconUrl, installed, apkName,
}, },
} = props; } = props;
const [installedState, setInstalledState] = useState<string>((installed ? 'installed' : 'install')); const [installedState, setInstalledState] = useState<string>((installed ? 'uninstall' : 'install'));
const classes = useStyles(); const classes = useStyles();
const langPress = lang === 'all' ? 'All' : lang.toUpperCase(); const langPress = lang === 'all' ? 'All' : lang.toUpperCase();
@@ -54,10 +54,25 @@ export default function ExtensionCard(props: IProps) {
function install() { function install() {
setInstalledState('installing'); setInstalledState('installing');
fetch(`http://127.0.0.1:4567/api/v1/extension/install/${apkName}`).then(() => { fetch(`http://127.0.0.1:4567/api/v1/extension/install/${apkName}`).then(() => {
setInstalledState('installed'); setInstalledState('uninstall');
}); });
} }
function uninstall() {
setInstalledState('uninstalling');
fetch(`http://127.0.0.1:4567/api/v1/extension/uninstall/${apkName}`).then(() => {
setInstalledState('install');
});
}
function handleButtonClick() {
if (installedState === 'install') {
install();
} else {
uninstall();
}
}
return ( return (
<Card> <Card>
<CardContent className={classes.root}> <CardContent className={classes.root}>
@@ -80,7 +95,7 @@ export default function ExtensionCard(props: IProps) {
</div> </div>
</div> </div>
<Button variant="outlined" onClick={() => install()}>{installedState}</Button> <Button variant="outlined" onClick={() => handleButtonClick()}>{installedState}</Button>
</CardContent> </CardContent>
</Card> </Card>
); );
+15 -23
View File
@@ -14,44 +14,36 @@ const style = {
backgroundColor: '#343a40', backgroundColor: '#343a40',
} as React.CSSProperties; } as React.CSSProperties;
interface IPage { const range = (n:number) => Array.from({ length: n }, (value, key) => key);
index: number
imageUrl: string
}
interface IData {
first: IChapter
second: IPage[]
}
export default function Reader() { export default function Reader() {
const { setTitle } = useContext(NavBarTitle); const { setTitle } = useContext(NavBarTitle);
const [pages, setPages] = useState<IPage[]>([]); const [pageCount, setPageCount] = useState<number>(-1);
const { chapterId, mangaId } = useParams<{chapterId: string, mangaId: string}>(); const { chapterId, mangaId } = useParams<{chapterId: string, mangaId: string}>();
useEffect(() => { useEffect(() => {
fetch(`http://127.0.0.1:4567/api/v1/manga/${mangaId}/chapter/${chapterId}`) fetch(`http://127.0.0.1:4567/api/v1/manga/${mangaId}/chapter/${chapterId}`)
.then((response) => response.json()) .then((response) => response.json())
.then((data:IData) => { .then((data:IChapter) => {
setTitle(data.first.name); setTitle(data.name);
setPages(data.second); setPageCount(data.pageCount);
}); });
}, []); }, []);
pages.sort((a, b) => (a.index - b.index)); if (pageCount === -1) {
return (
let mapped; <div style={style}>
if (pages.length === 0) { <h3>wait</h3>
mapped = <h3>wait</h3>;
} else {
mapped = pages.map(({ imageUrl }) => (
<div style={{ margin: '0 auto' }}>
<img src={imageUrl} alt="f" style={{ maxWidth: '100%' }} />
</div> </div>
)); );
} }
const mapped = range(pageCount).map((index) => (
<div style={{ margin: '0 auto' }}>
<img src={`http://127.0.0.1:4567/api/v1/manga/${mangaId}/chapter/${chapterId}/page/${index}`} alt="f" style={{ maxWidth: '100%' }} />
</div>
));
return ( return (
<div style={style}> <div style={style}>
{mapped} {mapped}
+1
View File
@@ -34,4 +34,5 @@ interface IChapter {
chapter_number: number chapter_number: number
scanlator: String scanlator: String
mangaId: number mangaId: number
pageCount: number
} }