Compare commits

..

4 Commits

Author SHA1 Message Date
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
10 changed files with 288 additions and 86 deletions
+1 -1
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.3"
repositories { repositories {
@@ -27,6 +27,8 @@ class NetworkHelper(context: Context) {
// .cache(Cache(cacheDir, cacheSize)) // .cache(Cache(cacheDir, cacheSize))
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
// .dispatcher(Dispatcher(Executors.newFixedThreadPool(1)))
// .addInterceptor(UserAgentInterceptor()) // .addInterceptor(UserAgentInterceptor())
// if (BuildConfig.DEBUG) { // if (BuildConfig.DEBUG) {
@@ -9,4 +9,5 @@ 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"
} }
@@ -11,10 +11,13 @@ 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.getMangaUpdateQueueThread
import ir.armor.tachidesk.util.getPages import ir.armor.tachidesk.util.getPages
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())
} }
@@ -116,6 +130,15 @@ class Main {
ctx.json(getPages(chapterId, mangaId)) ctx.json(getPages(chapterId, mangaId))
} }
app.get("api/v1/manga/:mangaId/thumbnail") { ctx ->
val mangaId = ctx.pathParam("mangaId").toInt()
println("got request for: $mangaId")
val result = getThumbnail(mangaId)
ctx.result(result.first)
ctx.header("content-type", result.second)
}
// global search // global search
app.get("/api/v1/search/:searchTerm") { ctx -> app.get("/api/v1/search/:searchTerm") { ctx ->
val searchTerm = ctx.pathParam("searchTerm") val searchTerm = ctx.pathParam("searchTerm")
@@ -0,0 +1,30 @@
package ir.armor.tachidesk.util
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
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(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
}
@@ -4,77 +4,168 @@ 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 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.InputStream
import java.util.concurrent.ArrayBlockingQueue
fun getManga(mangaId: Int): MangaDataClass { val getMangaUpdateQueue = ArrayBlockingQueue<Pair<Int, SManga?>>(1000)
return transaction { @Volatile
var mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! var getMangaCount = 0
return@transaction if (mangaEntry[MangaTable.initialized]) { val getMangaUpdateQueueThread = Runnable {
MangaDataClass( while (true) {
mangaId, val p = getMangaUpdateQueue.take()
mangaEntry[MangaTable.sourceReference].value, println("took ${p.first}")
while (getMangaCount > 0) {
println("count is $getMangaCount")
Thread.sleep(1000)
}
val mangaId = p.first
println("working on $mangaId")
val fetchedManga = p.second!!
try {
transaction {
println("transaction start $mangaId")
MangaTable.update({ MangaTable.id eq mangaId }) {
mangaEntry[MangaTable.url], it[MangaTable.initialized] = true
mangaEntry[MangaTable.title],
mangaEntry[MangaTable.thumbnail_url],
true, it[MangaTable.artist] = fetchedManga.artist
it[MangaTable.author] = fetchedManga.author
mangaEntry[MangaTable.artist], it[MangaTable.description] = fetchedManga.description
mangaEntry[MangaTable.author], it[MangaTable.genre] = fetchedManga.genre
mangaEntry[MangaTable.description], it[MangaTable.status] = fetchedManga.status
mangaEntry[MangaTable.genre], if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url!!.isNotEmpty())
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
)
} else { // initialize manga
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value)
val fetchedManga = source.fetchMangaDetails(
SManga.create().apply {
url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title]
} }
).toBlocking().first() println("transaction end $mangaId")
// 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
} }
} catch (e: Exception) {
mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!! println(e)
}
MangaDataClass( }
mangaId, }
mangaEntry[MangaTable.sourceReference].value,
fun getManga(mangaId: Int, proxyThumbnail: Boolean = true): MangaDataClass {
mangaEntry[MangaTable.url], synchronized(getMangaCount) {
mangaEntry[MangaTable.title], getMangaCount++
mangaEntry[MangaTable.thumbnail_url], }
return try {
true, transaction {
var mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!!
mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.author], return@transaction if (mangaEntry[MangaTable.initialized]) {
mangaEntry[MangaTable.description], println("${mangaEntry[MangaTable.title]} is initialized")
mangaEntry[MangaTable.genre], println("${mangaEntry[MangaTable.thumbnail_url]}")
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, MangaDataClass(
) mangaId,
mangaEntry[MangaTable.sourceReference].value,
mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title],
if (proxyThumbnail) proxyThumbnailUrl(mangaId) else mangaEntry[MangaTable.thumbnail_url],
true,
mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre],
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
)
} else { // initialize manga
val source = getHttpSource(mangaEntry[MangaTable.sourceReference].value)
val fetchedManga = source.fetchMangaDetails(
SManga.create().apply {
url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title]
}
).toBlocking().first()
// update database
// TODO: sqlite gets fucked here
println("putting $mangaId")
getMangaUpdateQueue.put(Pair(mangaId, fetchedManga))
// mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!!
val newThumbnail =
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url!!.isNotEmpty()) {
fetchedManga.thumbnail_url
} else 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,
)
}
}
} finally {
synchronized(getMangaCount) {
getMangaCount--
}
}
}
fun getThumbnail(mangaId: Int): Pair<InputStream, String> {
return transaction {
var filePath = Config.thumbnailsRoot + "/$mangaId"
var mangaEntry = MangaTable.select { MangaTable.id eq mangaId }.firstOrNull()!!
val potentialCache = findFileNameStartingWith(Config.thumbnailsRoot, mangaId.toString())
if (potentialCache != null) {
println("using cached thumbnail file")
return@transaction Pair(
pathToInputStream(potentialCache),
"image/${potentialCache.substringAfter("$mangaId.")}"
)
}
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()
println(response.code)
if (response.code == 200) {
val contentType = response.headers["content-type"]!!
filePath += "." + contentType.substringAfter("image/")
writeStream(response.body!!.byteStream(), filePath)
return@transaction Pair(
pathToInputStream(filePath),
contentType
)
} else {
throw Exception("request error! ${response.code}")
} }
} }
} }
@@ -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(
@@ -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>
); );