mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-10 22:34:33 -05:00
move all packages to 'suwayomi.tachidesk'
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.manga.impl.CategoryManga.removeMangaFromCategory
|
||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
|
||||
object Category {
|
||||
/**
|
||||
* The new category will be placed at the end of the list
|
||||
*/
|
||||
fun createCategory(name: String) {
|
||||
transaction {
|
||||
val count = CategoryTable.selectAll().count()
|
||||
if (CategoryTable.select { CategoryTable.name eq name }.firstOrNull() == null)
|
||||
CategoryTable.insert {
|
||||
it[CategoryTable.name] = name
|
||||
it[CategoryTable.order] = count.toInt() + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCategory(categoryId: Int, name: String?, isDefault: Boolean?) {
|
||||
transaction {
|
||||
CategoryTable.update({ CategoryTable.id eq categoryId }) {
|
||||
if (name != null) it[CategoryTable.name] = name
|
||||
if (isDefault != null) it[CategoryTable.isDefault] = isDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the category from position `from` to `to`
|
||||
*/
|
||||
fun reorderCategory(categoryId: Int, from: Int, to: Int) {
|
||||
transaction {
|
||||
val categories = CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).toMutableList()
|
||||
categories.add(to - 1, categories.removeAt(from - 1))
|
||||
categories.forEachIndexed { index, cat ->
|
||||
CategoryTable.update({ CategoryTable.id eq cat[CategoryTable.id].value }) {
|
||||
it[CategoryTable.order] = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCategory(categoryId: Int) {
|
||||
transaction {
|
||||
CategoryMangaTable.select { CategoryMangaTable.category eq categoryId }.forEach {
|
||||
removeMangaFromCategory(it[CategoryMangaTable.manga].value, categoryId)
|
||||
}
|
||||
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCategoryList(): List<CategoryDataClass> {
|
||||
return transaction {
|
||||
CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).map {
|
||||
CategoryTable.toDataClass(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
|
||||
object CategoryManga {
|
||||
fun addMangaToCategory(mangaId: Int, categoryId: Int) {
|
||||
transaction {
|
||||
if (CategoryMangaTable.select { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }.firstOrNull() == null) {
|
||||
CategoryMangaTable.insert {
|
||||
it[CategoryMangaTable.category] = categoryId
|
||||
it[CategoryMangaTable.manga] = mangaId
|
||||
}
|
||||
|
||||
MangaTable.update({ MangaTable.id eq mangaId }) {
|
||||
it[MangaTable.defaultCategory] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMangaFromCategory(mangaId: Int, categoryId: Int) {
|
||||
transaction {
|
||||
CategoryMangaTable.deleteWhere { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) }
|
||||
if (CategoryMangaTable.select { CategoryMangaTable.manga eq mangaId }.count() == 0L) {
|
||||
MangaTable.update({ MangaTable.id eq mangaId }) {
|
||||
it[MangaTable.defaultCategory] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* list of mangas that belong to a category
|
||||
*/
|
||||
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
|
||||
return transaction {
|
||||
CategoryMangaTable.innerJoin(MangaTable).select { CategoryMangaTable.category eq categoryId }.map {
|
||||
MangaTable.toDataClass(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* list of categories that a manga belongs to
|
||||
*/
|
||||
fun getMangaCategories(mangaId: Int): List<CategoryDataClass> {
|
||||
return transaction {
|
||||
CategoryMangaTable.innerJoin(CategoryTable).select { CategoryMangaTable.manga eq mangaId }.orderBy(CategoryTable.order to SortOrder.ASC).map {
|
||||
CategoryTable.toDataClass(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
263
server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt
Normal file
263
server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt
Normal file
@@ -0,0 +1,263 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.SortOrder.DESC
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.manga.impl.Manga.getManga
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.PageTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
import java.time.Instant
|
||||
|
||||
object Chapter {
|
||||
/** get chapter list when showing a manga */
|
||||
suspend fun getChapterList(mangaId: Int, onlineFetch: Boolean?): List<ChapterDataClass> {
|
||||
return if (onlineFetch == true) {
|
||||
getSourceChapters(mangaId)
|
||||
} else {
|
||||
transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }.orderBy(ChapterTable.chapterIndex to DESC)
|
||||
.map {
|
||||
ChapterTable.toDataClass(it)
|
||||
}
|
||||
}.ifEmpty {
|
||||
// If it was explicitly set to offline dont grab chapters
|
||||
if (onlineFetch == null) {
|
||||
getSourceChapters(mangaId)
|
||||
} else emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> {
|
||||
val mangaDetails = getManga(mangaId)
|
||||
val source = getHttpSource(mangaDetails.sourceId.toLong())
|
||||
val chapterList = source.fetchChapterList(
|
||||
SManga.create().apply {
|
||||
title = mangaDetails.title
|
||||
url = mangaDetails.url
|
||||
}
|
||||
).awaitSingle()
|
||||
|
||||
val chapterCount = chapterList.count()
|
||||
|
||||
transaction {
|
||||
chapterList.reversed().forEachIndexed { index, fetchedChapter ->
|
||||
val chapterEntry = ChapterTable.select { ChapterTable.url eq fetchedChapter.url }.firstOrNull()
|
||||
if (chapterEntry == null) {
|
||||
ChapterTable.insert {
|
||||
it[url] = fetchedChapter.url
|
||||
it[name] = fetchedChapter.name
|
||||
it[date_upload] = fetchedChapter.date_upload
|
||||
it[chapter_number] = fetchedChapter.chapter_number
|
||||
it[scanlator] = fetchedChapter.scanlator
|
||||
|
||||
it[chapterIndex] = index + 1
|
||||
it[manga] = mangaId
|
||||
}
|
||||
} else {
|
||||
ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) {
|
||||
it[name] = fetchedChapter.name
|
||||
it[date_upload] = fetchedChapter.date_upload
|
||||
it[chapter_number] = fetchedChapter.chapter_number
|
||||
it[scanlator] = fetchedChapter.scanlator
|
||||
|
||||
it[chapterIndex] = index + 1
|
||||
it[manga] = mangaId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clear any orphaned chapters that are in the db but not in `chapterList`
|
||||
val dbChapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() }
|
||||
if (dbChapterCount > chapterCount) { // we got some clean up due
|
||||
val dbChapterList = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.toList() }
|
||||
|
||||
dbChapterList.forEach {
|
||||
if (it[ChapterTable.chapterIndex] >= chapterList.size ||
|
||||
chapterList[it[ChapterTable.chapterIndex] - 1].url != it[ChapterTable.url]
|
||||
) {
|
||||
transaction {
|
||||
PageTable.deleteWhere { PageTable.chapter eq it[ChapterTable.id] }
|
||||
ChapterTable.deleteWhere { ChapterTable.id eq it[ChapterTable.id] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dbChapterMap = transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.associateBy({ it[ChapterTable.url] }, { it })
|
||||
}
|
||||
|
||||
return chapterList.mapIndexed { index, it ->
|
||||
|
||||
val dbChapter = dbChapterMap.getValue(it.url)
|
||||
|
||||
ChapterDataClass(
|
||||
it.url,
|
||||
it.name,
|
||||
it.date_upload,
|
||||
it.chapter_number,
|
||||
it.scanlator,
|
||||
mangaId,
|
||||
|
||||
dbChapter[ChapterTable.isRead],
|
||||
dbChapter[ChapterTable.isBookmarked],
|
||||
dbChapter[ChapterTable.lastPageRead],
|
||||
dbChapter[ChapterTable.lastReadAt],
|
||||
|
||||
chapterCount - index,
|
||||
dbChapter[ChapterTable.isDownloaded],
|
||||
|
||||
dbChapter[ChapterTable.pageCount],
|
||||
|
||||
chapterList.size,
|
||||
meta = getChapterMetaMap(dbChapter[ChapterTable.id])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** used to display a chapter, get a chapter in order to show it's pages */
|
||||
suspend fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass {
|
||||
val chapterEntry = transaction {
|
||||
ChapterTable.select {
|
||||
(ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId)
|
||||
}.first()
|
||||
}
|
||||
|
||||
return if (!chapterEntry[ChapterTable.isDownloaded]) {
|
||||
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
||||
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
|
||||
|
||||
val pageList = source.fetchPageList(
|
||||
SChapter.create().apply {
|
||||
url = chapterEntry[ChapterTable.url]
|
||||
name = chapterEntry[ChapterTable.name]
|
||||
}
|
||||
).awaitSingle()
|
||||
|
||||
val chapterId = chapterEntry[ChapterTable.id].value
|
||||
val chapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() }
|
||||
|
||||
// update page list for this chapter
|
||||
transaction {
|
||||
pageList.forEach { page ->
|
||||
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }.firstOrNull() }
|
||||
if (pageEntry == null) {
|
||||
PageTable.insert {
|
||||
it[index] = page.index
|
||||
it[url] = page.url
|
||||
it[imageUrl] = page.imageUrl
|
||||
it[chapter] = chapterId
|
||||
}
|
||||
} else {
|
||||
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq page.index) }) {
|
||||
it[url] = page.url
|
||||
it[imageUrl] = page.imageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pageCount = pageList.count()
|
||||
|
||||
transaction {
|
||||
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) {
|
||||
it[ChapterTable.pageCount] = pageCount
|
||||
}
|
||||
}
|
||||
return ChapterDataClass(
|
||||
chapterEntry[ChapterTable.url],
|
||||
chapterEntry[ChapterTable.name],
|
||||
chapterEntry[ChapterTable.date_upload],
|
||||
chapterEntry[ChapterTable.chapter_number],
|
||||
chapterEntry[ChapterTable.scanlator],
|
||||
mangaId,
|
||||
chapterEntry[ChapterTable.isRead],
|
||||
chapterEntry[ChapterTable.isBookmarked],
|
||||
chapterEntry[ChapterTable.lastPageRead],
|
||||
chapterEntry[ChapterTable.lastReadAt],
|
||||
|
||||
chapterEntry[ChapterTable.chapterIndex],
|
||||
chapterEntry[ChapterTable.isDownloaded],
|
||||
pageCount,
|
||||
chapterCount.toInt(),
|
||||
getChapterMetaMap(chapterEntry[ChapterTable.id])
|
||||
)
|
||||
} else {
|
||||
ChapterTable.toDataClass(chapterEntry)
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyChapter(mangaId: Int, chapterIndex: Int, isRead: Boolean?, isBookmarked: Boolean?, markPrevRead: Boolean?, lastPageRead: Int?) {
|
||||
transaction {
|
||||
if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
|
||||
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) { update ->
|
||||
isRead?.also {
|
||||
update[ChapterTable.isRead] = it
|
||||
}
|
||||
isBookmarked?.also {
|
||||
update[ChapterTable.isBookmarked] = it
|
||||
}
|
||||
lastPageRead?.also {
|
||||
update[ChapterTable.lastPageRead] = it
|
||||
update[ChapterTable.lastReadAt] = Instant.now().epochSecond
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markPrevRead?.let {
|
||||
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex less chapterIndex) }) {
|
||||
it[ChapterTable.isRead] = markPrevRead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getChapterMetaMap(chapter: EntityID<Int>): Map<String, String> {
|
||||
return transaction {
|
||||
ChapterMetaTable.select { ChapterMetaTable.ref eq chapter }
|
||||
.associate { it[ChapterMetaTable.key] to it[ChapterMetaTable.value] }
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyChapterMeta(mangaId: Int, chapterIndex: Int, key: String, value: String) {
|
||||
transaction {
|
||||
val chapter = ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }
|
||||
.first()[ChapterTable.id]
|
||||
val meta = transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapter) and (ChapterMetaTable.key eq key) } }.firstOrNull()
|
||||
if (meta == null) {
|
||||
ChapterMetaTable.insert {
|
||||
it[ChapterMetaTable.key] = key
|
||||
it[ChapterMetaTable.value] = value
|
||||
it[ChapterMetaTable.ref] = chapter
|
||||
}
|
||||
} else {
|
||||
ChapterMetaTable.update {
|
||||
it[ChapterMetaTable.value] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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.sql.and
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.manga.impl.Manga.getManga
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
|
||||
object Library {
|
||||
// TODO: `Category.isLanding` is to handle the default categories a new library manga gets,
|
||||
// ..implement that shit at some time...
|
||||
// ..also Consider to rename it to `isDefault`
|
||||
suspend fun addMangaToLibrary(mangaId: Int) {
|
||||
val manga = getManga(mangaId)
|
||||
if (!manga.inLibrary) {
|
||||
transaction {
|
||||
val defaultCategories = CategoryTable.select { CategoryTable.isDefault eq true }.toList()
|
||||
|
||||
MangaTable.update({ MangaTable.id eq manga.id }) {
|
||||
it[MangaTable.inLibrary] = true
|
||||
it[MangaTable.defaultCategory] = defaultCategories.isEmpty()
|
||||
}
|
||||
|
||||
defaultCategories.forEach { category ->
|
||||
CategoryMangaTable.insert {
|
||||
it[CategoryMangaTable.category] = category[CategoryTable.id].value
|
||||
it[CategoryMangaTable.manga] = mangaId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeMangaFromLibrary(mangaId: Int) {
|
||||
val manga = getManga(mangaId)
|
||||
if (manga.inLibrary) {
|
||||
transaction {
|
||||
MangaTable.update({ MangaTable.id eq manga.id }) {
|
||||
it[inLibrary] = false
|
||||
it[defaultCategory] = true
|
||||
}
|
||||
CategoryMangaTable.deleteWhere { CategoryMangaTable.manga eq mangaId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryMangas(): List<MangaDataClass> {
|
||||
return transaction {
|
||||
MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.map {
|
||||
MangaTable.toDataClass(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
171
server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt
Normal file
171
server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt
Normal file
@@ -0,0 +1,171 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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.GET
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
||||
import suwayomi.tachidesk.manga.impl.Source.getSource
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.impl.util.network.await
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.clearCachedImage
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import java.io.InputStream
|
||||
|
||||
object Manga {
|
||||
private fun truncate(text: String?, maxLength: Int): String? {
|
||||
return if (text?.length ?: 0 > maxLength)
|
||||
text?.take(maxLength - 3) + "..."
|
||||
else
|
||||
text
|
||||
}
|
||||
|
||||
suspend fun getManga(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass {
|
||||
var mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
||||
|
||||
return if (mangaEntry[MangaTable.initialized] && !onlineFetch) {
|
||||
MangaDataClass(
|
||||
mangaId,
|
||||
mangaEntry[MangaTable.sourceReference].toString(),
|
||||
|
||||
mangaEntry[MangaTable.url],
|
||||
mangaEntry[MangaTable.title],
|
||||
proxyThumbnailUrl(mangaId),
|
||||
|
||||
true,
|
||||
|
||||
mangaEntry[MangaTable.artist],
|
||||
mangaEntry[MangaTable.author],
|
||||
mangaEntry[MangaTable.description],
|
||||
mangaEntry[MangaTable.genre],
|
||||
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
|
||||
mangaEntry[MangaTable.inLibrary],
|
||||
getSource(mangaEntry[MangaTable.sourceReference]),
|
||||
getMangaMetaMap(mangaEntry[MangaTable.id]),
|
||||
false
|
||||
)
|
||||
} else { // initialize manga
|
||||
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
|
||||
val fetchedManga = source.fetchMangaDetails(
|
||||
SManga.create().apply {
|
||||
url = mangaEntry[MangaTable.url]
|
||||
title = mangaEntry[MangaTable.title]
|
||||
}
|
||||
).awaitSingle()
|
||||
|
||||
transaction {
|
||||
MangaTable.update({ MangaTable.id eq mangaId }) {
|
||||
|
||||
it[MangaTable.initialized] = true
|
||||
|
||||
it[MangaTable.artist] = fetchedManga.artist
|
||||
it[MangaTable.author] = fetchedManga.author
|
||||
it[MangaTable.description] = truncate(fetchedManga.description, 4096)
|
||||
it[MangaTable.genre] = fetchedManga.genre
|
||||
it[MangaTable.status] = fetchedManga.status
|
||||
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url.orEmpty().isNotEmpty())
|
||||
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
|
||||
}
|
||||
}
|
||||
|
||||
clearMangaThumbnail(mangaId)
|
||||
|
||||
mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
||||
|
||||
MangaDataClass(
|
||||
mangaId,
|
||||
mangaEntry[MangaTable.sourceReference].toString(),
|
||||
|
||||
mangaEntry[MangaTable.url],
|
||||
mangaEntry[MangaTable.title],
|
||||
proxyThumbnailUrl(mangaId),
|
||||
|
||||
true,
|
||||
|
||||
fetchedManga.artist,
|
||||
fetchedManga.author,
|
||||
fetchedManga.description,
|
||||
fetchedManga.genre,
|
||||
MangaStatus.valueOf(fetchedManga.status).name,
|
||||
mangaEntry[MangaTable.inLibrary],
|
||||
getSource(mangaEntry[MangaTable.sourceReference]),
|
||||
getMangaMetaMap(mangaEntry[MangaTable.id]),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getMangaMetaMap(manga: EntityID<Int>): Map<String, String> {
|
||||
return transaction {
|
||||
MangaMetaTable.select { MangaMetaTable.ref eq manga }
|
||||
.associate { it[MangaMetaTable.key] to it[MangaMetaTable.value] }
|
||||
}
|
||||
}
|
||||
|
||||
fun modifyMangaMeta(mangaId: Int, key: String, value: String) {
|
||||
transaction {
|
||||
val manga = MangaMetaTable.select { (MangaTable.id eq mangaId) }
|
||||
.first()[MangaTable.id]
|
||||
val meta = transaction { MangaMetaTable.select { (MangaMetaTable.ref eq manga) and (MangaMetaTable.key eq key) } }.firstOrNull()
|
||||
if (meta == null) {
|
||||
MangaMetaTable.insert {
|
||||
it[MangaMetaTable.key] = key
|
||||
it[MangaMetaTable.value] = value
|
||||
it[MangaMetaTable.ref] = manga
|
||||
}
|
||||
} else {
|
||||
MangaMetaTable.update {
|
||||
it[MangaMetaTable.value] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
|
||||
val saveDir = applicationDirs.mangaThumbnailsRoot
|
||||
val fileName = mangaId.toString()
|
||||
|
||||
return getCachedImageResponse(saveDir, fileName) {
|
||||
getManga(mangaId) // make sure is initialized
|
||||
|
||||
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
||||
|
||||
val sourceId = mangaEntry[MangaTable.sourceReference]
|
||||
val source = getHttpSource(sourceId)
|
||||
|
||||
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]!!
|
||||
|
||||
source.client.newCall(
|
||||
GET(thumbnailUrl, source.headers)
|
||||
).await()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearMangaThumbnail(mangaId: Int) {
|
||||
val saveDir = applicationDirs.mangaThumbnailsRoot
|
||||
val fileName = mangaId.toString()
|
||||
|
||||
clearCachedImage(saveDir, fileName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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.MangasPage
|
||||
import org.jetbrains.exposed.sql.insertAndGetId
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
|
||||
object MangaList {
|
||||
fun proxyThumbnailUrl(mangaId: Int): String {
|
||||
return "/api/v1/manga/$mangaId/thumbnail"
|
||||
}
|
||||
|
||||
suspend fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass {
|
||||
val source = getHttpSource(sourceId)
|
||||
val mangasPage = if (popular) {
|
||||
source.fetchPopularManga(pageNum).awaitSingle()
|
||||
} else {
|
||||
if (source.supportsLatest)
|
||||
source.fetchLatestUpdates(pageNum).awaitSingle()
|
||||
else
|
||||
throw Exception("Source $source doesn't support latest")
|
||||
}
|
||||
return mangasPage.processEntries(sourceId)
|
||||
}
|
||||
|
||||
fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass {
|
||||
val mangasPage = this
|
||||
val mangaList = transaction {
|
||||
return@transaction mangasPage.mangas.map { manga ->
|
||||
val mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.firstOrNull()
|
||||
if (mangaEntry == null) { // create manga entry
|
||||
val mangaId = MangaTable.insertAndGetId {
|
||||
it[url] = manga.url
|
||||
it[title] = manga.title
|
||||
|
||||
it[artist] = manga.artist
|
||||
it[author] = manga.author
|
||||
it[description] = manga.description
|
||||
it[genre] = manga.genre
|
||||
it[status] = manga.status
|
||||
it[thumbnail_url] = manga.thumbnail_url
|
||||
|
||||
it[sourceReference] = sourceId
|
||||
}.value
|
||||
|
||||
MangaDataClass(
|
||||
mangaId,
|
||||
sourceId.toString(),
|
||||
|
||||
manga.url,
|
||||
manga.title,
|
||||
proxyThumbnailUrl(mangaId),
|
||||
|
||||
manga.initialized,
|
||||
|
||||
manga.artist,
|
||||
manga.author,
|
||||
manga.description,
|
||||
manga.genre,
|
||||
MangaStatus.valueOf(manga.status).name
|
||||
)
|
||||
} else {
|
||||
val mangaId = mangaEntry[MangaTable.id].value
|
||||
MangaDataClass(
|
||||
mangaId,
|
||||
sourceId.toString(),
|
||||
|
||||
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,
|
||||
mangaEntry[MangaTable.inLibrary],
|
||||
meta = getMangaMetaMap(mangaEntry[MangaTable.id])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return PagedMangaListDataClass(
|
||||
mangaList,
|
||||
mangasPage.hasNextPage
|
||||
)
|
||||
}
|
||||
}
|
||||
95
server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt
Normal file
95
server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Page.kt
Normal file
@@ -0,0 +1,95 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 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 org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.SafePath
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.PageTable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
object Page {
|
||||
/**
|
||||
* A page might have a imageUrl ready from the get go, or we might need to
|
||||
* go an extra step and call fetchImageUrl to get it.
|
||||
*/
|
||||
suspend fun getTrueImageUrl(page: Page, source: HttpSource): String {
|
||||
if (page.imageUrl == null) {
|
||||
page.imageUrl = source.fetchImageUrl(page).awaitSingle()
|
||||
}
|
||||
return page.imageUrl!!
|
||||
}
|
||||
|
||||
suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int): Pair<InputStream, String> {
|
||||
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
||||
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
|
||||
val chapterEntry = transaction {
|
||||
ChapterTable.select {
|
||||
(ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId)
|
||||
}.first()
|
||||
}
|
||||
val chapterId = chapterEntry[ChapterTable.id].value
|
||||
|
||||
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.first() }
|
||||
|
||||
val tachiPage = Page(
|
||||
pageEntry[PageTable.index],
|
||||
pageEntry[PageTable.url],
|
||||
pageEntry[PageTable.imageUrl]
|
||||
)
|
||||
|
||||
if (pageEntry[PageTable.imageUrl] == null) {
|
||||
val trueImageUrl = getTrueImageUrl(tachiPage, source)
|
||||
transaction {
|
||||
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq index) }) {
|
||||
it[imageUrl] = trueImageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val saveDir = getChapterDir(mangaId, chapterId)
|
||||
File(saveDir).mkdirs()
|
||||
val fileName = String.format("%03d", index) // e.g. 001.jpeg
|
||||
|
||||
return getCachedImageResponse(saveDir, fileName) {
|
||||
source.fetchImage(tachiPage).awaitSingle()
|
||||
}
|
||||
}
|
||||
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
private fun getChapterDir(mangaId: Int, chapterId: Int): String {
|
||||
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
||||
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
|
||||
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.first() }
|
||||
|
||||
val sourceDir = source.toString()
|
||||
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
|
||||
val chapterDir = SafePath.buildValidFilename(
|
||||
when {
|
||||
chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
|
||||
else -> chapterEntry[ChapterTable.name]
|
||||
}
|
||||
)
|
||||
|
||||
return "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir/$chapterDir"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 suwayomi.tachidesk.manga.impl.MangaList.processEntries
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
||||
|
||||
object Search {
|
||||
// TODO
|
||||
fun sourceFilters(sourceId: Long) {
|
||||
val source = getHttpSource(sourceId)
|
||||
// source.getFilterList().toItems()
|
||||
}
|
||||
|
||||
suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass {
|
||||
val source = getHttpSource(sourceId)
|
||||
val searchManga = source.fetchSearchManga(pageNum, searchTerm, source.getFilterList()).awaitSingle()
|
||||
return searchManga.processEntries(sourceId)
|
||||
}
|
||||
|
||||
fun sourceGlobalSearch(searchTerm: String) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
data class FilterWrapper(
|
||||
val type: String,
|
||||
val filter: Any
|
||||
)
|
||||
|
||||
/**
|
||||
* Note: Exhentai had a filter serializer (now in SY) that we might be able to steal
|
||||
*/
|
||||
// private fun FilterList.toFilterWrapper(): List<FilterWrapper> {
|
||||
// return mapNotNull { filter ->
|
||||
// when (filter) {
|
||||
// is Filter.Header -> FilterWrapper("Header",filter)
|
||||
// is Filter.Separator -> FilterWrapper("Separator",filter)
|
||||
// is Filter.CheckBox -> FilterWrapper("CheckBox",filter)
|
||||
// is Filter.TriState -> FilterWrapper("TriState",filter)
|
||||
// is Filter.Text -> FilterWrapper("Text",filter)
|
||||
// is Filter.Select<*> -> FilterWrapper("Select",filter)
|
||||
// is Filter.Group<*> -> {
|
||||
// val group = GroupItem(filter)
|
||||
// val subItems = filter.state.mapNotNull {
|
||||
// when (it) {
|
||||
// is Filter.CheckBox -> FilterWrapper("CheckBox",filter)
|
||||
// is Filter.TriState -> FilterWrapper("TriState",filter)
|
||||
// is Filter.Text -> FilterWrapper("Text",filter)
|
||||
// is Filter.Select<*> -> FilterWrapper("Select",filter)
|
||||
// else -> null
|
||||
// } as? ISectionable<*, *>
|
||||
// }
|
||||
// subItems.forEach { it.header = group }
|
||||
// group.subItems = subItems
|
||||
// group
|
||||
// }
|
||||
// is Filter.Sort -> {
|
||||
// val group = SortGroup(filter)
|
||||
// val subItems = filter.values.map {
|
||||
// SortItem(it, group)
|
||||
// }
|
||||
// group.subItems = subItems
|
||||
// group
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package suwayomi.tachidesk.manga.impl
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 mu.KotlinLogging
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
|
||||
object Source {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
fun getSourceList(): List<SourceDataClass> {
|
||||
return transaction {
|
||||
SourceTable.selectAll().map {
|
||||
SourceDataClass(
|
||||
it[SourceTable.id].value.toString(),
|
||||
it[SourceTable.name],
|
||||
it[SourceTable.lang],
|
||||
getExtensionIconUrl(ExtensionTable.select { ExtensionTable.id eq it[SourceTable.extension] }.first()[ExtensionTable.apkName]),
|
||||
getHttpSource(it[SourceTable.id].value).supportsLatest
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSource(sourceId: Long): SourceDataClass {
|
||||
return transaction {
|
||||
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
|
||||
|
||||
SourceDataClass(
|
||||
sourceId.toString(),
|
||||
source?.get(SourceTable.name),
|
||||
source?.get(SourceTable.lang),
|
||||
source?.let { ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first()[ExtensionTable.iconUrl] },
|
||||
source?.let { getHttpSource(sourceId).supportsLatest }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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/. */
|
||||
|
||||
data class BackupFlags(
|
||||
val includeManga: Boolean,
|
||||
val includeCategories: Boolean,
|
||||
val includeChapters: Boolean,
|
||||
val includeTracking: Boolean,
|
||||
val includeHistory: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 com.github.salomonbrys.kotson.registerTypeAdapter
|
||||
import com.github.salomonbrys.kotson.registerTypeHierarchyAdapter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.models.DHistory
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.CategoryTypeAdapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.ChapterTypeAdapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.HistoryTypeAdapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.MangaTypeAdapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.serializer.TrackTypeAdapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
|
||||
import java.util.Date
|
||||
|
||||
open class LegacyBackupBase {
|
||||
protected val parser: Gson = when (version) {
|
||||
2 -> GsonBuilder()
|
||||
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
|
||||
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
|
||||
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
|
||||
.create()
|
||||
else -> throw Exception("Unknown backup version")
|
||||
}
|
||||
|
||||
protected var sourceMapping: Map<Long, String> = emptyMap()
|
||||
|
||||
protected val errors = mutableListOf<Pair<Date, String>>()
|
||||
|
||||
companion object {
|
||||
internal const val version = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 com.github.salomonbrys.kotson.set
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.source.LocalSource
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.Category.getCategoryList
|
||||
import suwayomi.tachidesk.manga.impl.CategoryManga.getMangaCategories
|
||||
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.models.Backup
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.models.Backup.CURRENT_VERSION
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.Manga
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
|
||||
object LegacyBackupExport : LegacyBackupBase() {
|
||||
|
||||
suspend fun createLegacyBackup(flags: BackupFlags): String? {
|
||||
// Create root object
|
||||
val root = JsonObject()
|
||||
|
||||
// Create manga array
|
||||
val mangaEntries = JsonArray()
|
||||
|
||||
// Create category array
|
||||
val categoryEntries = JsonArray()
|
||||
|
||||
// Create extension ID/name mapping
|
||||
val extensionEntries = JsonArray()
|
||||
|
||||
// Add values to root
|
||||
root[Backup.VERSION] = CURRENT_VERSION
|
||||
root[Backup.MANGAS] = mangaEntries
|
||||
root[Backup.CATEGORIES] = categoryEntries
|
||||
root[Backup.EXTENSIONS] = extensionEntries
|
||||
|
||||
transaction {
|
||||
val mangas = MangaTable.select { (MangaTable.inLibrary eq true) }
|
||||
|
||||
val extensions: MutableSet<String> = mutableSetOf()
|
||||
|
||||
// Backup library manga and its dependencies
|
||||
mangas.map {
|
||||
MangaImpl.fromQuery(it)
|
||||
}.forEach { manga ->
|
||||
|
||||
mangaEntries.add(backupMangaObject(manga, flags))
|
||||
|
||||
// Maintain set of extensions/sources used (excludes local source)
|
||||
if (manga.source != LocalSource.ID) {
|
||||
getHttpSource(manga.source).let {
|
||||
extensions.add("${it.id}:${it.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backup categories
|
||||
if (flags.includeCategories) {
|
||||
backupCategories(categoryEntries)
|
||||
}
|
||||
|
||||
// Backup extension ID/name mapping
|
||||
backupExtensionInfo(extensionEntries, extensions)
|
||||
}
|
||||
|
||||
return parser.toJson(root)
|
||||
}
|
||||
|
||||
private fun backupMangaObject(manga: Manga, options: BackupFlags): JsonElement {
|
||||
// Entry for this manga
|
||||
val entry = JsonObject()
|
||||
|
||||
// Backup manga fields
|
||||
entry[Backup.MANGA] = parser.toJsonTree(manga)
|
||||
val mangaId = manga.id!!.toInt()
|
||||
|
||||
// Check if user wants chapter information in backup
|
||||
if (options.includeChapters) {
|
||||
// Backup all the chapters
|
||||
val chapters = ChapterTable.select { ChapterTable.manga eq mangaId }.map { ChapterImpl.fromQuery(it) }
|
||||
if (chapters.count() > 0) {
|
||||
val chaptersJson = parser.toJsonTree(chapters)
|
||||
if (chaptersJson.asJsonArray.size() > 0) {
|
||||
entry[Backup.CHAPTERS] = chaptersJson
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user wants category information in backup
|
||||
if (options.includeCategories) {
|
||||
// Backup categories for this manga
|
||||
val categoriesForManga = getMangaCategories(mangaId)
|
||||
if (categoriesForManga.isNotEmpty()) {
|
||||
val categoriesNames = categoriesForManga.map { it.name }
|
||||
entry[Backup.CATEGORIES] = parser.toJsonTree(categoriesNames)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user wants track information in backup
|
||||
if (options.includeTracking) { // TODO
|
||||
// val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
||||
// if (tracks.isNotEmpty()) {
|
||||
// entry[TRACK] = parser.toJsonTree(tracks)
|
||||
// }
|
||||
}
|
||||
//
|
||||
// // Check if user wants history information in backup
|
||||
if (options.includeHistory) { // TODO
|
||||
// val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
|
||||
// if (historyForManga.isNotEmpty()) {
|
||||
// val historyData = historyForManga.mapNotNull { history ->
|
||||
// val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
|
||||
// url?.let { DHistory(url, history.last_read) }
|
||||
// }
|
||||
// val historyJson = parser.toJsonTree(historyData)
|
||||
// if (historyJson.asJsonArray.size() > 0) {
|
||||
// entry[HISTORY] = historyJson
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
private fun backupCategories(root: JsonArray) {
|
||||
val categories = getCategoryList().map {
|
||||
CategoryImpl().apply {
|
||||
name = it.name
|
||||
order = it.order
|
||||
}
|
||||
}
|
||||
categories.forEach { root.add(parser.toJsonTree(it)) }
|
||||
}
|
||||
|
||||
private fun backupExtensionInfo(root: JsonArray, extensions: Set<String>) {
|
||||
extensions.sorted().forEach {
|
||||
root.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy
|
||||
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.manga.impl.Category.createCategory
|
||||
import suwayomi.tachidesk.manga.impl.Category.getCategoryList
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.LegacyBackupValidator.ValidationResult
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.LegacyBackupValidator.validate
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.models.Backup
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.models.DHistory
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.Chapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.Manga
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.Track
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import java.io.InputStream
|
||||
import java.util.Date
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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/. */
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
object LegacyBackupImport : LegacyBackupBase() {
|
||||
suspend fun restoreLegacyBackup(sourceStream: InputStream): ValidationResult {
|
||||
val reader = sourceStream.bufferedReader()
|
||||
val json = JsonParser.parseReader(reader).asJsonObject
|
||||
|
||||
val validationResult = validate(json)
|
||||
|
||||
val mangasJson = json.get(Backup.MANGAS).asJsonArray
|
||||
|
||||
// Restore categories
|
||||
json.get(Backup.CATEGORIES)?.let { restoreCategories(it) }
|
||||
|
||||
// Store source mapping for error messages
|
||||
sourceMapping = LegacyBackupValidator.getSourceMapping(json)
|
||||
|
||||
// Restore individual manga
|
||||
mangasJson.forEach {
|
||||
restoreManga(it.asJsonObject)
|
||||
}
|
||||
|
||||
logger.info {
|
||||
"""
|
||||
Restore Errors:
|
||||
${ errors.joinToString("\n") { "${it.first} - ${it.second}" } }
|
||||
Restore Summary:
|
||||
- Missing Sources:
|
||||
${validationResult.missingSources.joinToString("\n")}
|
||||
- Missing Trackers:
|
||||
${validationResult.missingTrackers.joinToString("\n")}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
return validationResult
|
||||
}
|
||||
|
||||
private fun restoreCategories(jsonCategories: JsonElement) {
|
||||
val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
|
||||
val dbCategories = getCategoryList()
|
||||
|
||||
// Iterate over them and create missing categories
|
||||
backupCategories.forEach { category ->
|
||||
if (dbCategories.none { it.name == category.name }) {
|
||||
createCategory(category.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreManga(mangaJson: JsonObject) {
|
||||
val manga = parser.fromJson<MangaImpl>(
|
||||
mangaJson.get(
|
||||
Backup.MANGA
|
||||
)
|
||||
)
|
||||
val chapters = parser.fromJson<List<ChapterImpl>>(
|
||||
mangaJson.get(Backup.CHAPTERS)
|
||||
?: JsonArray()
|
||||
)
|
||||
val categories = parser.fromJson<List<String>>(
|
||||
mangaJson.get(Backup.CATEGORIES)
|
||||
?: JsonArray()
|
||||
)
|
||||
val history = parser.fromJson<List<DHistory>>(
|
||||
mangaJson.get(Backup.HISTORY)
|
||||
?: JsonArray()
|
||||
)
|
||||
val tracks = parser.fromJson<List<TrackImpl>>(
|
||||
mangaJson.get(Backup.TRACK)
|
||||
?: JsonArray()
|
||||
)
|
||||
|
||||
val source = try {
|
||||
getHttpSource(manga.source)
|
||||
} catch (e: NullPointerException) {
|
||||
null
|
||||
} catch (e: NoSuchElementException) {
|
||||
null
|
||||
}
|
||||
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
|
||||
|
||||
logger.debug("Restoring Manga: ${manga.title} from $sourceName")
|
||||
|
||||
try {
|
||||
if (source != null) {
|
||||
restoreMangaData(manga, source, chapters, categories, history, tracks)
|
||||
} else {
|
||||
errors.add(Date() to "${manga.title} [$sourceName]: Source not found: $sourceName (${manga.source})")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param manga manga data from json
|
||||
* @param source source to get manga data from
|
||||
* @param chapters chapters data from json
|
||||
* @param categories categories data from json
|
||||
* @param history history data from json
|
||||
* @param tracks tracking data from json
|
||||
*/
|
||||
private suspend fun restoreMangaData(
|
||||
manga: Manga,
|
||||
source: Source,
|
||||
chapters: List<Chapter>,
|
||||
categories: List<String>,
|
||||
history: List<DHistory>,
|
||||
tracks: List<Track>
|
||||
) {
|
||||
val fetchedManga = fetchManga(source, manga)
|
||||
|
||||
updateChapters(source, fetchedManga, chapters)
|
||||
|
||||
// TODO
|
||||
// backupManager.restoreCategoriesForManga(manga, categories)
|
||||
|
||||
// backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// backupManager.restoreTrackForManga(manga, tracks)
|
||||
|
||||
// updateTracking(fetchedManga, tracks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches manga information
|
||||
*
|
||||
* @param source source of manga
|
||||
* @param manga manga that needs updating
|
||||
* @return Updated manga.
|
||||
*/
|
||||
private suspend fun fetchManga(source: Source, manga: Manga): SManga {
|
||||
// make sure we have the manga record in library
|
||||
transaction {
|
||||
if (MangaTable.select { (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }.firstOrNull() == null) {
|
||||
MangaTable.insert {
|
||||
it[url] = manga.url
|
||||
it[title] = manga.title
|
||||
|
||||
it[sourceReference] = manga.source
|
||||
}
|
||||
}
|
||||
MangaTable.update({ (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }) {
|
||||
it[MangaTable.inLibrary] = true
|
||||
}
|
||||
}
|
||||
|
||||
// update manga details
|
||||
val fetchedManga = source.fetchMangaDetails(manga).awaitSingle()
|
||||
transaction {
|
||||
MangaTable.update({ (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq manga.source) }) {
|
||||
|
||||
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.orEmpty().isNotEmpty())
|
||||
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
|
||||
}
|
||||
}
|
||||
|
||||
return fetchedManga
|
||||
}
|
||||
|
||||
private fun updateChapters(source: Source, fetchedManga: SManga, chapters: List<Chapter>) {
|
||||
// TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 com.google.gson.JsonObject
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.models.Backup
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
|
||||
object LegacyBackupValidator {
|
||||
data class ValidationResult(val missingSources: List<String>, val missingTrackers: List<String>)
|
||||
|
||||
/**
|
||||
* Checks for critical backup file data.
|
||||
*
|
||||
* @throws Exception if version or manga cannot be found.
|
||||
* @return List of missing sources or missing trackers.
|
||||
*/
|
||||
fun validate(json: JsonObject): ValidationResult {
|
||||
val version = json.get(Backup.VERSION)
|
||||
val mangasJson = json.get(Backup.MANGAS)
|
||||
if (version == null || mangasJson == null) {
|
||||
throw Exception("File is missing data.")
|
||||
}
|
||||
|
||||
val mangas = mangasJson.asJsonArray
|
||||
if (mangas.size() == 0) {
|
||||
throw Exception("Backup does not contain any manga.")
|
||||
}
|
||||
|
||||
val sources = getSourceMapping(json)
|
||||
val missingSources = transaction {
|
||||
sources
|
||||
.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
|
||||
.map { "${it.value} (${it.key})" }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
val trackers = mangas
|
||||
.filter { it.asJsonObject.has("track") }
|
||||
.flatMap { it.asJsonObject["track"].asJsonArray }
|
||||
.map { it.asJsonObject["s"].asInt }
|
||||
.distinct()
|
||||
|
||||
val missingTrackers = listOf("")
|
||||
// val missingTrackers = trackers
|
||||
// .mapNotNull { trackManager.getService(it) }
|
||||
// .filter { !it.isLogged }
|
||||
// .map { context.getString(it.nameRes()) }
|
||||
// .sorted()
|
||||
|
||||
return ValidationResult(missingSources, missingTrackers)
|
||||
}
|
||||
|
||||
fun getSourceMapping(json: JsonObject): Map<Long, String> {
|
||||
val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap()
|
||||
|
||||
return extensionsMapping.asJsonArray
|
||||
.map {
|
||||
val items = it.asString.split(":")
|
||||
items[0].toLong() to items[1]
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy.models
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Json values
|
||||
*/
|
||||
object Backup {
|
||||
const val CURRENT_VERSION = 2
|
||||
const val MANGA = "manga"
|
||||
const val MANGAS = "mangas"
|
||||
const val TRACK = "track"
|
||||
const val CHAPTERS = "chapters"
|
||||
const val CATEGORIES = "categories"
|
||||
const val EXTENSIONS = "extensions"
|
||||
const val HISTORY = "history"
|
||||
const val VERSION = "version"
|
||||
|
||||
fun getDefaultFilename(): String {
|
||||
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
|
||||
return "tachiyomi_$date.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy.models
|
||||
|
||||
data class DHistory(val url: String, val lastRead: Long)
|
||||
@@ -0,0 +1,31 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.CategoryImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [CategoryImpl] to / from json
|
||||
*/
|
||||
object CategoryTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<CategoryImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginArray()
|
||||
value(it.name)
|
||||
value(it.order)
|
||||
endArray()
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val category = CategoryImpl()
|
||||
category.name = nextString()
|
||||
category.order = nextInt()
|
||||
endArray()
|
||||
category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonToken
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [ChapterImpl] to / from json
|
||||
*/
|
||||
object ChapterTypeAdapter {
|
||||
|
||||
private const val URL = "u"
|
||||
private const val READ = "r"
|
||||
private const val BOOKMARK = "b"
|
||||
private const val LAST_READ = "l"
|
||||
|
||||
fun build(): TypeAdapter<ChapterImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
if (it.read || it.bookmark || it.last_page_read != 0) {
|
||||
beginObject()
|
||||
name(URL)
|
||||
value(it.url)
|
||||
if (it.read) {
|
||||
name(READ)
|
||||
value(1)
|
||||
}
|
||||
if (it.bookmark) {
|
||||
name(BOOKMARK)
|
||||
value(1)
|
||||
}
|
||||
if (it.last_page_read != 0) {
|
||||
name(LAST_READ)
|
||||
value(it.last_page_read)
|
||||
}
|
||||
endObject()
|
||||
}
|
||||
}
|
||||
|
||||
read {
|
||||
val chapter = ChapterImpl()
|
||||
beginObject()
|
||||
while (hasNext()) {
|
||||
if (peek() == JsonToken.NAME) {
|
||||
when (nextName()) {
|
||||
URL -> chapter.url = nextString()
|
||||
READ -> chapter.read = nextInt() == 1
|
||||
BOOKMARK -> chapter.bookmark = nextInt() == 1
|
||||
LAST_READ -> chapter.last_page_read = nextInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
endObject()
|
||||
chapter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.legacy.models.DHistory
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [DHistory] to / from json
|
||||
*/
|
||||
object HistoryTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<DHistory> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
if (it.lastRead != 0L) {
|
||||
beginArray()
|
||||
value(it.url)
|
||||
value(it.lastRead)
|
||||
endArray()
|
||||
}
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val url = nextString()
|
||||
val lastRead = nextLong()
|
||||
endArray()
|
||||
DHistory(url, lastRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.MangaImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [MangaImpl] to / from json
|
||||
*/
|
||||
object MangaTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<MangaImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginArray()
|
||||
value(it.url)
|
||||
value(it.title)
|
||||
value(it.source)
|
||||
value(it.viewer)
|
||||
value(it.chapter_flags)
|
||||
endArray()
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val manga = MangaImpl()
|
||||
manga.url = nextString()
|
||||
manga.title = nextString()
|
||||
manga.source = nextLong()
|
||||
manga.viewer = nextInt()
|
||||
manga.chapter_flags = nextInt()
|
||||
endArray()
|
||||
manga
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.legacy.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonToken
|
||||
import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [TrackImpl] to / from json
|
||||
*/
|
||||
object TrackTypeAdapter {
|
||||
|
||||
private const val SYNC = "s"
|
||||
private const val MEDIA = "r"
|
||||
private const val LIBRARY = "ml"
|
||||
private const val TITLE = "t"
|
||||
private const val LAST_READ = "l"
|
||||
private const val TRACKING_URL = "u"
|
||||
|
||||
fun build(): TypeAdapter<TrackImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginObject()
|
||||
name(TITLE)
|
||||
value(it.title)
|
||||
name(SYNC)
|
||||
value(it.sync_id)
|
||||
name(MEDIA)
|
||||
value(it.media_id)
|
||||
name(LIBRARY)
|
||||
value(it.library_id)
|
||||
name(LAST_READ)
|
||||
value(it.last_chapter_read)
|
||||
name(TRACKING_URL)
|
||||
value(it.tracking_url)
|
||||
endObject()
|
||||
}
|
||||
|
||||
read {
|
||||
val track = TrackImpl()
|
||||
beginObject()
|
||||
while (hasNext()) {
|
||||
if (peek() == JsonToken.NAME) {
|
||||
when (nextName()) {
|
||||
TITLE -> track.title = nextString()
|
||||
SYNC -> track.sync_id = nextInt()
|
||||
MEDIA -> track.media_id = nextInt()
|
||||
LIBRARY -> track.library_id = nextLong()
|
||||
LAST_READ -> track.last_chapter_read = nextInt()
|
||||
TRACKING_URL -> track.tracking_url = nextString()
|
||||
}
|
||||
}
|
||||
}
|
||||
endObject()
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface Category : Serializable {
|
||||
|
||||
var id: Int?
|
||||
|
||||
var name: String
|
||||
|
||||
var order: Int
|
||||
|
||||
var flags: Int
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(name: String): Category = CategoryImpl().apply {
|
||||
this.name = name
|
||||
}
|
||||
|
||||
fun createDefault(): Category = create("Default").apply { id = 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class CategoryImpl : Category {
|
||||
|
||||
override var id: Int? = null
|
||||
|
||||
override lateinit var name: String
|
||||
|
||||
override var order: Int = 0
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
val category = other as Category
|
||||
return name == category.name
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return name.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import java.io.Serializable
|
||||
|
||||
interface Chapter : SChapter, Serializable {
|
||||
|
||||
var id: Long?
|
||||
|
||||
var manga_id: Long?
|
||||
|
||||
var read: Boolean
|
||||
|
||||
var bookmark: Boolean
|
||||
|
||||
var last_page_read: Int
|
||||
|
||||
var date_fetch: Long
|
||||
|
||||
var source_order: Int
|
||||
|
||||
val isRecognizedNumber: Boolean
|
||||
get() = chapter_number >= 0f
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(): Chapter = ChapterImpl().apply {
|
||||
chapter_number = -1f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
|
||||
class ChapterImpl : Chapter {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
override var manga_id: Long? = null
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var name: String
|
||||
|
||||
override var scanlator: String? = null
|
||||
|
||||
override var read: Boolean = false
|
||||
|
||||
override var bookmark: Boolean = false
|
||||
|
||||
override var last_page_read: Int = 0
|
||||
|
||||
override var date_fetch: Long = 0
|
||||
|
||||
override var date_upload: Long = 0
|
||||
|
||||
override var chapter_number: Float = 0f
|
||||
|
||||
override var source_order: Int = 0
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
val chapter = other as Chapter
|
||||
if (url != chapter.url) return false
|
||||
return id == chapter.id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return url.hashCode() + id.hashCode()
|
||||
}
|
||||
|
||||
// Tachidesk -->
|
||||
companion object {
|
||||
fun fromQuery(chapterRecord: ResultRow): ChapterImpl {
|
||||
return ChapterImpl().apply {
|
||||
url = chapterRecord[ChapterTable.url]
|
||||
read = chapterRecord[ChapterTable.isRead]
|
||||
bookmark = chapterRecord[ChapterTable.isBookmarked]
|
||||
last_page_read = chapterRecord[ChapterTable.lastPageRead]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tachidesk <--
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
interface History : Serializable {
|
||||
|
||||
/**
|
||||
* Id of history object.
|
||||
*/
|
||||
var id: Long?
|
||||
|
||||
/**
|
||||
* Chapter id of history object.
|
||||
*/
|
||||
var chapter_id: Long
|
||||
|
||||
/**
|
||||
* Last time chapter was read in time long format
|
||||
*/
|
||||
var last_read: Long
|
||||
|
||||
/**
|
||||
* Total time chapter was read - todo not yet implemented
|
||||
*/
|
||||
var time_read: Long
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* History constructor
|
||||
*
|
||||
* @param chapter chapter object
|
||||
* @return history object
|
||||
*/
|
||||
fun create(chapter: Chapter): History = HistoryImpl().apply {
|
||||
this.chapter_id = chapter.id!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
class HistoryImpl : History {
|
||||
|
||||
/**
|
||||
* Id of history object.
|
||||
*/
|
||||
override var id: Long? = null
|
||||
|
||||
/**
|
||||
* Chapter id of history object.
|
||||
*/
|
||||
override var chapter_id: Long = 0
|
||||
|
||||
/**
|
||||
* Last time chapter was read in time long format
|
||||
*/
|
||||
override var last_read: Long = 0
|
||||
|
||||
/**
|
||||
* Total time chapter was read - todo not yet implemented
|
||||
*/
|
||||
override var time_read: Long = 0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class LibraryManga : MangaImpl() {
|
||||
|
||||
var unread: Int = 0
|
||||
|
||||
var category: Int = 0
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
|
||||
// import tachiyomi.source.model.MangaInfo
|
||||
|
||||
interface Manga : SManga {
|
||||
|
||||
var id: Long?
|
||||
|
||||
var source: Long
|
||||
|
||||
/** is in library */
|
||||
var favorite: Boolean
|
||||
|
||||
var last_update: Long
|
||||
|
||||
var date_added: Long
|
||||
|
||||
var viewer: Int
|
||||
|
||||
var chapter_flags: Int
|
||||
|
||||
var cover_last_modified: Long
|
||||
|
||||
fun setChapterOrder(order: Int) {
|
||||
setFlags(order, SORT_MASK)
|
||||
}
|
||||
|
||||
fun sortDescending(): Boolean {
|
||||
return chapter_flags and SORT_MASK == SORT_DESC
|
||||
}
|
||||
|
||||
fun getGenres(): List<String>? {
|
||||
return genre?.split(", ")?.map { it.trim() }
|
||||
}
|
||||
|
||||
private fun setFlags(flag: Int, mask: Int) {
|
||||
chapter_flags = chapter_flags and mask.inv() or (flag and mask)
|
||||
}
|
||||
|
||||
// Used to display the chapter's title one way or another
|
||||
var displayMode: Int
|
||||
get() = chapter_flags and DISPLAY_MASK
|
||||
set(mode) = setFlags(mode, DISPLAY_MASK)
|
||||
|
||||
var readFilter: Int
|
||||
get() = chapter_flags and READ_MASK
|
||||
set(filter) = setFlags(filter, READ_MASK)
|
||||
|
||||
var downloadedFilter: Int
|
||||
get() = chapter_flags and DOWNLOADED_MASK
|
||||
set(filter) = setFlags(filter, DOWNLOADED_MASK)
|
||||
|
||||
var bookmarkedFilter: Int
|
||||
get() = chapter_flags and BOOKMARKED_MASK
|
||||
set(filter) = setFlags(filter, BOOKMARKED_MASK)
|
||||
|
||||
var sorting: Int
|
||||
get() = chapter_flags and SORTING_MASK
|
||||
set(sort) = setFlags(sort, SORTING_MASK)
|
||||
|
||||
companion object {
|
||||
|
||||
const val SORT_DESC = 0x00000000
|
||||
const val SORT_ASC = 0x00000001
|
||||
const val SORT_MASK = 0x00000001
|
||||
|
||||
// Generic filter that does not filter anything
|
||||
const val SHOW_ALL = 0x00000000
|
||||
|
||||
const val SHOW_UNREAD = 0x00000002
|
||||
const val SHOW_READ = 0x00000004
|
||||
const val READ_MASK = 0x00000006
|
||||
|
||||
const val SHOW_DOWNLOADED = 0x00000008
|
||||
const val SHOW_NOT_DOWNLOADED = 0x00000010
|
||||
const val DOWNLOADED_MASK = 0x00000018
|
||||
|
||||
const val SHOW_BOOKMARKED = 0x00000020
|
||||
const val SHOW_NOT_BOOKMARKED = 0x00000040
|
||||
const val BOOKMARKED_MASK = 0x00000060
|
||||
|
||||
const val SORTING_SOURCE = 0x00000000
|
||||
const val SORTING_NUMBER = 0x00000100
|
||||
const val SORTING_UPLOAD_DATE = 0x00000200
|
||||
const val SORTING_MASK = 0x00000300
|
||||
|
||||
const val DISPLAY_NAME = 0x00000000
|
||||
const val DISPLAY_NUMBER = 0x00100000
|
||||
const val DISPLAY_MASK = 0x00100000
|
||||
|
||||
fun create(source: Long): Manga = MangaImpl().apply {
|
||||
this.source = source
|
||||
}
|
||||
|
||||
fun create(pathUrl: String, title: String, source: Long = 0): Manga = MangaImpl().apply {
|
||||
url = pathUrl
|
||||
this.title = title
|
||||
this.source = source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fun Manga.toMangaInfo(): MangaInfo {
|
||||
// return MangaInfo(
|
||||
// artist = this.artist ?: "",
|
||||
// author = this.author ?: "",
|
||||
// cover = this.thumbnail_url ?: "",
|
||||
// description = this.description ?: "",
|
||||
// genres = this.getGenres() ?: emptyList(),
|
||||
// key = this.url,
|
||||
// status = this.status,
|
||||
// title = this.title
|
||||
// )
|
||||
// }
|
||||
@@ -0,0 +1,20 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class MangaCategory {
|
||||
|
||||
var id: Long? = null
|
||||
|
||||
var manga_id: Long = 0
|
||||
|
||||
var category_id: Int = 0
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(manga: Manga, category: Category): MangaCategory {
|
||||
val mc = MangaCategory()
|
||||
mc.manga_id = manga.id!!
|
||||
mc.category_id = category.id!!
|
||||
return mc
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class MangaChapter(val manga: Manga, val chapter: Chapter)
|
||||
@@ -0,0 +1,10 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
/**
|
||||
* Object containing manga, chapter and history
|
||||
*
|
||||
* @param manga object containing manga
|
||||
* @param chapter object containing chater
|
||||
* @param history object containing history
|
||||
*/
|
||||
data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History)
|
||||
@@ -0,0 +1,79 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
|
||||
open class MangaImpl : Manga {
|
||||
|
||||
override var id: Long? = 0
|
||||
|
||||
override var source: Long = -1
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var artist: String? = null
|
||||
|
||||
override var author: String? = null
|
||||
|
||||
override var description: String? = null
|
||||
|
||||
override var genre: String? = null
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var thumbnail_url: String? = null
|
||||
|
||||
override var favorite: Boolean = false
|
||||
|
||||
override var last_update: Long = 0
|
||||
|
||||
override var date_added: Long = 0
|
||||
|
||||
override var initialized: Boolean = false
|
||||
|
||||
/** Reader mode value
|
||||
* ref: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingModeType.kt#L8
|
||||
* 0 -> Default
|
||||
* 1 -> Left to Right
|
||||
* 2 -> Right to Left
|
||||
* 3 -> Vertical
|
||||
* 4 -> Webtoon
|
||||
* 5 -> Continues Vertical
|
||||
*/
|
||||
override var viewer: Int = 0
|
||||
|
||||
/** Contains some useful info about
|
||||
*/
|
||||
override var chapter_flags: Int = 0
|
||||
|
||||
override var cover_last_modified: Long = 0
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
val manga = other as Manga
|
||||
if (url != manga.url) return false
|
||||
return id == manga.id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return url.hashCode() + id.hashCode()
|
||||
}
|
||||
|
||||
// Tachidesk -->
|
||||
companion object {
|
||||
fun fromQuery(mangaRecord: ResultRow): MangaImpl {
|
||||
return MangaImpl().apply {
|
||||
url = mangaRecord[MangaTable.url]
|
||||
title = mangaRecord[MangaTable.title]
|
||||
source = mangaRecord[MangaTable.sourceReference]
|
||||
viewer = 0 // TODO: implement
|
||||
chapter_flags = 0 // TODO: implement
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tachidesk <--
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface Track : Serializable {
|
||||
|
||||
var id: Long?
|
||||
|
||||
var manga_id: Long
|
||||
|
||||
var sync_id: Int
|
||||
|
||||
var media_id: Int
|
||||
|
||||
var library_id: Long?
|
||||
|
||||
var title: String
|
||||
|
||||
var last_chapter_read: Int
|
||||
|
||||
var total_chapters: Int
|
||||
|
||||
var score: Float
|
||||
|
||||
var status: Int
|
||||
|
||||
var started_reading_date: Long
|
||||
|
||||
var finished_reading_date: Long
|
||||
|
||||
var tracking_url: String
|
||||
|
||||
fun copyPersonalFrom(other: Track) {
|
||||
last_chapter_read = other.last_chapter_read
|
||||
score = other.score
|
||||
status = other.status
|
||||
started_reading_date = other.started_reading_date
|
||||
finished_reading_date = other.finished_reading_date
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(serviceId: Int): Track = TrackImpl().apply {
|
||||
sync_id = serviceId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package suwayomi.tachidesk.manga.impl.backup.models
|
||||
|
||||
class TrackImpl : Track {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
override var manga_id: Long = 0
|
||||
|
||||
override var sync_id: Int = 0
|
||||
|
||||
override var media_id: Int = 0
|
||||
|
||||
override var library_id: Long? = null
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var last_chapter_read: Int = 0
|
||||
|
||||
override var total_chapters: Int = 0
|
||||
|
||||
override var score: Float = 0f
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var started_reading_date: Long = 0
|
||||
|
||||
override var finished_reading_date: Long = 0
|
||||
|
||||
override var tracking_url: String = ""
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
other as Track
|
||||
|
||||
if (manga_id != other.manga_id) return false
|
||||
if (sync_id != other.sync_id) return false
|
||||
return media_id == other.media_id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = (manga_id xor manga_id.ushr(32)).toInt()
|
||||
result = 31 * result + sync_id
|
||||
result = 31 * result + media_id
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package suwayomi.tachidesk.manga.impl.download
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 io.javalin.websocket.WsContext
|
||||
import io.javalin.websocket.WsMessageContext
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
object DownloadManager {
|
||||
private val clients = ConcurrentHashMap<String, WsContext>()
|
||||
private val downloadQueue = CopyOnWriteArrayList<DownloadChapter>()
|
||||
private var downloader: Downloader? = null
|
||||
|
||||
fun addClient(ctx: WsContext) {
|
||||
clients[ctx.sessionId] = ctx
|
||||
}
|
||||
|
||||
fun removeClient(ctx: WsContext) {
|
||||
clients.remove(ctx.sessionId)
|
||||
}
|
||||
|
||||
fun notifyClient(ctx: WsContext) {
|
||||
ctx.send(
|
||||
getStatus()
|
||||
)
|
||||
}
|
||||
|
||||
fun handleRequest(ctx: WsMessageContext) {
|
||||
when (ctx.message()) {
|
||||
"STATUS" -> notifyClient(ctx)
|
||||
else -> ctx.send(
|
||||
"""
|
||||
|Invalid command.
|
||||
|Supported commands are:
|
||||
| - STATUS
|
||||
| sends the current download status
|
||||
|""".trimMargin()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyAllClients() {
|
||||
val status = getStatus()
|
||||
clients.forEach {
|
||||
it.value.send(status)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatus(): DownloadStatus {
|
||||
return DownloadStatus(
|
||||
if (downloader == null ||
|
||||
downloadQueue.none { it.state == Downloading }
|
||||
) "Stopped" else "Started",
|
||||
downloadQueue
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueue(chapterIndex: Int, mangaId: Int) {
|
||||
if (downloadQueue.none { it.mangaId == mangaId && it.chapterIndex == chapterIndex }) {
|
||||
downloadQueue.add(
|
||||
DownloadChapter(
|
||||
chapterIndex,
|
||||
mangaId,
|
||||
chapter = ChapterTable.toDataClass(
|
||||
transaction {
|
||||
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }
|
||||
.first()
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
start()
|
||||
}
|
||||
notifyAllClients()
|
||||
}
|
||||
|
||||
fun unqueue(chapterIndex: Int, mangaId: Int) {
|
||||
downloadQueue.removeIf { it.mangaId == mangaId && it.chapterIndex == chapterIndex }
|
||||
notifyAllClients()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (downloader != null && !downloader?.isAlive!!) // doesn't exist or is dead
|
||||
downloader = null
|
||||
|
||||
if (downloader == null) {
|
||||
downloader = Downloader(downloadQueue) { notifyAllClients() }
|
||||
downloader!!.start()
|
||||
}
|
||||
|
||||
notifyAllClients()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
downloader?.let {
|
||||
synchronized(it.shouldStop) {
|
||||
it.shouldStop = true
|
||||
}
|
||||
}
|
||||
downloader = null
|
||||
notifyAllClients()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
stop()
|
||||
downloadQueue.clear()
|
||||
notifyAllClients()
|
||||
}
|
||||
}
|
||||
|
||||
enum class DownloaderState(val state: Int) {
|
||||
Stopped(0),
|
||||
Running(1),
|
||||
Paused(2),
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package suwayomi.tachidesk.manga.impl.download
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.manga.impl.Chapter.getChapter
|
||||
import suwayomi.tachidesk.manga.impl.Page.getPageImage
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Error
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Finished
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Queued
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter>, val notifier: () -> Unit) : Thread() {
|
||||
var shouldStop: Boolean = false
|
||||
|
||||
class DownloadShouldStopException : Exception()
|
||||
|
||||
fun step() {
|
||||
notifier()
|
||||
synchronized(shouldStop) {
|
||||
if (shouldStop) throw DownloadShouldStopException()
|
||||
}
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
do {
|
||||
val download = downloadQueue.firstOrNull {
|
||||
it.state == Queued ||
|
||||
(it.state == Error && it.tries < 3) // 3 re-tries per download
|
||||
} ?: break
|
||||
|
||||
try {
|
||||
download.state = Downloading
|
||||
step()
|
||||
|
||||
download.chapter = runBlocking { getChapter(download.chapterIndex, download.mangaId) }
|
||||
step()
|
||||
|
||||
val pageCount = download.chapter!!.pageCount
|
||||
for (pageNum in 0 until pageCount) {
|
||||
runBlocking { getPageImage(download.mangaId, download.chapterIndex, pageNum) }
|
||||
// TODO: retry on error with 2,4,8 seconds of wait
|
||||
// TODO: download multiple pages at once, possible solution: rx observer's strategy is used in Tachiyomi
|
||||
// TODO: fine grained download percentage
|
||||
download.progress = (pageNum + 1).toFloat() / pageCount
|
||||
step()
|
||||
}
|
||||
download.state = Finished
|
||||
transaction {
|
||||
ChapterTable.update({ (ChapterTable.manga eq download.mangaId) and (ChapterTable.chapterIndex eq download.chapterIndex) }) {
|
||||
it[isDownloaded] = true
|
||||
}
|
||||
}
|
||||
step()
|
||||
|
||||
downloadQueue.removeIf { it.mangaId == download.mangaId && it.chapterIndex == download.chapterIndex }
|
||||
step()
|
||||
} catch (e: DownloadShouldStopException) {
|
||||
println("Downloader was stopped")
|
||||
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Queued }
|
||||
} catch (e: Exception) {
|
||||
println("Downloader faced an exception")
|
||||
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Error; it.tries++ }
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
notifier()
|
||||
}
|
||||
} while (!shouldStop)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package suwayomi.tachidesk.manga.impl.download.model
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 suwayomi.tachidesk.manga.impl.download.model.DownloadState.Queued
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||
|
||||
class DownloadChapter(
|
||||
val chapterIndex: Int,
|
||||
val mangaId: Int,
|
||||
var state: DownloadState = Queued,
|
||||
var progress: Float = 0f,
|
||||
var tries: Int = 0,
|
||||
var chapter: ChapterDataClass? = null,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package suwayomi.tachidesk.manga.impl.download.model
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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/. */
|
||||
|
||||
enum class DownloadState(val state: Int) {
|
||||
Queued(0),
|
||||
Downloading(1),
|
||||
Finished(2),
|
||||
Error(3),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package suwayomi.tachidesk.manga.impl.download.model
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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/. */
|
||||
|
||||
data class DownloadStatus(
|
||||
val status: String,
|
||||
val queue: List<DownloadChapter>,
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
package suwayomi.tachidesk.manga.impl.extension
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import mu.KotlinLogging
|
||||
import okhttp3.Request
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.extensionTableAsDataClass
|
||||
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_NSFW
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.getSignatureHash
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.trustedSignatures
|
||||
import suwayomi.tachidesk.manga.impl.util.network.await
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
object Extension {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
|
||||
data class InstallableAPK(
|
||||
val apkFilePath: String,
|
||||
val pkgName: String
|
||||
)
|
||||
|
||||
suspend fun installExtension(pkgName: String): Int {
|
||||
logger.debug("Installing $pkgName")
|
||||
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
|
||||
|
||||
return installAPK {
|
||||
val apkURL = ExtensionGithubApi.getApkUrl(extensionRecord)
|
||||
val apkName = Uri.parse(apkURL).lastPathSegment!!
|
||||
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
|
||||
// download apk file
|
||||
downloadAPKFile(apkURL, apkSavePath)
|
||||
|
||||
apkSavePath
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun installAPK(fetcher: suspend () -> String): Int {
|
||||
val apkFilePath = fetcher()
|
||||
val apkName = File(apkFilePath).name
|
||||
|
||||
// check if we don't have the extension already installed
|
||||
// if it's installed and we want to update, it first has to be uninstalled
|
||||
val isInstalled = transaction {
|
||||
ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()
|
||||
}?.get(ExtensionTable.isInstalled) ?: false
|
||||
|
||||
if (!isInstalled) {
|
||||
val fileNameWithoutType = apkName.substringBefore(".apk")
|
||||
|
||||
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
|
||||
val jarFilePath = "$dirPathWithoutType.jar"
|
||||
val dexFilePath = "$dirPathWithoutType.dex"
|
||||
|
||||
val packageInfo = getPackageInfo(apkFilePath)
|
||||
val pkgName = packageInfo.packageName
|
||||
|
||||
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
|
||||
throw Exception("This apk is not a Tachiyomi extension")
|
||||
}
|
||||
|
||||
// Validate lib version
|
||||
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
|
||||
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||
throw Exception(
|
||||
"Lib version is $libVersion, while only versions " +
|
||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
|
||||
)
|
||||
}
|
||||
|
||||
val signatureHash = getSignatureHash(packageInfo)
|
||||
|
||||
if (signatureHash == null) {
|
||||
throw Exception("Package $pkgName isn't signed")
|
||||
} else if (signatureHash !in trustedSignatures) {
|
||||
// TODO: allow trusting keys
|
||||
throw Exception("This apk is not a signed with the official tachiyomi signature")
|
||||
}
|
||||
|
||||
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
|
||||
|
||||
val className = packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
||||
|
||||
logger.debug("Main class for extension is $className")
|
||||
|
||||
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
||||
|
||||
// clean up
|
||||
// File(apkFilePath).delete()
|
||||
File(dexFilePath).delete()
|
||||
|
||||
// collect sources from the extension
|
||||
val sources: List<CatalogueSource> = when (val instance = loadExtensionSources(jarFilePath, className)) {
|
||||
is Source -> listOf(instance)
|
||||
is SourceFactory -> instance.createSources()
|
||||
else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}")
|
||||
}.map { it as CatalogueSource }
|
||||
|
||||
val langs = sources.map { it.lang }.toSet()
|
||||
val extensionLang = when (langs.size) {
|
||||
0 -> ""
|
||||
1 -> langs.first()
|
||||
else -> "all"
|
||||
}
|
||||
|
||||
val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Tachiyomi: ")
|
||||
|
||||
// update extension info
|
||||
transaction {
|
||||
if (ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
|
||||
ExtensionTable.insert {
|
||||
it[this.apkName] = apkName
|
||||
it[name] = extensionName
|
||||
it[this.pkgName] = packageInfo.packageName
|
||||
it[versionName] = packageInfo.versionName
|
||||
it[versionCode] = packageInfo.versionCode
|
||||
it[lang] = extensionLang
|
||||
it[this.isNsfw] = isNsfw
|
||||
}
|
||||
}
|
||||
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||
it[this.isInstalled] = true
|
||||
it[this.classFQName] = className
|
||||
}
|
||||
|
||||
val extensionId = ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.first()[ExtensionTable.id].value
|
||||
|
||||
sources.forEach { httpSource ->
|
||||
SourceTable.insert {
|
||||
it[id] = httpSource.id
|
||||
it[name] = httpSource.name
|
||||
it[lang] = httpSource.lang
|
||||
it[extension] = extensionId
|
||||
}
|
||||
logger.debug("Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}")
|
||||
}
|
||||
}
|
||||
return 201 // we installed successfully
|
||||
} else {
|
||||
return 302 // extension was already installed
|
||||
}
|
||||
}
|
||||
|
||||
private val network: NetworkHelper by injectLazy()
|
||||
|
||||
private suspend fun downloadAPKFile(url: String, savePath: String) {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val response = network.client.newCall(request).await()
|
||||
|
||||
val downloadedFile = File(savePath)
|
||||
downloadedFile.sink().buffer().use { sink ->
|
||||
response.body!!.source().use { source ->
|
||||
sink.writeAll(source)
|
||||
sink.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallExtension(pkgName: String) {
|
||||
logger.debug("Uninstalling $pkgName")
|
||||
|
||||
val extensionRecord = transaction { ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.first() }
|
||||
val fileNameWithoutType = extensionRecord[ExtensionTable.apkName].substringBefore(".apk")
|
||||
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
||||
transaction {
|
||||
val extensionId = extensionRecord[ExtensionTable.id].value
|
||||
|
||||
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
|
||||
if (extensionRecord[ExtensionTable.isObsolete])
|
||||
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
|
||||
else
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||
it[isInstalled] = false
|
||||
}
|
||||
}
|
||||
|
||||
if (File(jarPath).exists()) {
|
||||
File(jarPath).delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateExtension(pkgName: String): Int {
|
||||
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
|
||||
uninstallExtension(pkgName)
|
||||
transaction {
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||
it[name] = targetExtension.name
|
||||
it[versionName] = targetExtension.versionName
|
||||
it[versionCode] = targetExtension.versionCode
|
||||
it[lang] = targetExtension.lang
|
||||
it[isNsfw] = targetExtension.isNsfw
|
||||
it[apkName] = targetExtension.apkName
|
||||
it[iconUrl] = targetExtension.iconUrl
|
||||
it[hasUpdate] = false
|
||||
}
|
||||
}
|
||||
return installExtension(pkgName)
|
||||
}
|
||||
|
||||
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
|
||||
val iconUrl = transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
|
||||
|
||||
val saveDir = "${applicationDirs.extensionsRoot}/icon"
|
||||
|
||||
return getCachedImageResponse(saveDir, apkName) {
|
||||
network.client.newCall(
|
||||
GET(iconUrl)
|
||||
).await()
|
||||
}
|
||||
}
|
||||
|
||||
fun getExtensionIconUrl(apkName: String): String {
|
||||
return "/api/v1/extension/icon/$apkName"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package suwayomi.tachidesk.manga.impl.extension
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 mu.KotlinLogging
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
|
||||
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
|
||||
import suwayomi.tachidesk.manga.impl.extension.github.OnlineExtension
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object ExtensionsList {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
var lastUpdateCheck: Long = 0
|
||||
var updateMap = ConcurrentHashMap<String, OnlineExtension>()
|
||||
|
||||
/** 60,000 milliseconds = 60 seconds */
|
||||
private const val ExtensionUpdateDelayTime = 60 * 1000
|
||||
|
||||
suspend fun getExtensionList(): List<ExtensionDataClass> {
|
||||
// update if {ExtensionUpdateDelayTime} seconds has passed or requested offline and database is empty
|
||||
if (lastUpdateCheck + ExtensionUpdateDelayTime < System.currentTimeMillis()) {
|
||||
logger.debug("Getting extensions list from the internet")
|
||||
lastUpdateCheck = System.currentTimeMillis()
|
||||
|
||||
val foundExtensions = ExtensionGithubApi.findExtensions()
|
||||
updateExtensionDatabase(foundExtensions)
|
||||
} else {
|
||||
logger.debug("used cached extension list")
|
||||
}
|
||||
|
||||
return extensionTableAsDataClass()
|
||||
}
|
||||
|
||||
fun extensionTableAsDataClass() = transaction {
|
||||
ExtensionTable.selectAll().map {
|
||||
ExtensionDataClass(
|
||||
it[ExtensionTable.apkName],
|
||||
getExtensionIconUrl(it[ExtensionTable.apkName]),
|
||||
it[ExtensionTable.name],
|
||||
it[ExtensionTable.pkgName],
|
||||
it[ExtensionTable.versionName],
|
||||
it[ExtensionTable.versionCode],
|
||||
it[ExtensionTable.lang],
|
||||
it[ExtensionTable.isNsfw],
|
||||
it[ExtensionTable.isInstalled],
|
||||
it[ExtensionTable.hasUpdate],
|
||||
it[ExtensionTable.isObsolete],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) {
|
||||
transaction {
|
||||
foundExtensions.forEach { foundExtension ->
|
||||
val extensionRecord = ExtensionTable.select { ExtensionTable.pkgName eq foundExtension.pkgName }.firstOrNull()
|
||||
if (extensionRecord != null) {
|
||||
if (extensionRecord[ExtensionTable.isInstalled]) {
|
||||
when {
|
||||
foundExtension.versionCode > extensionRecord[ExtensionTable.versionCode] -> {
|
||||
// there is an update
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) {
|
||||
it[hasUpdate] = true
|
||||
}
|
||||
updateMap.putIfAbsent(foundExtension.pkgName, foundExtension)
|
||||
}
|
||||
foundExtension.versionCode < extensionRecord[ExtensionTable.versionCode] -> {
|
||||
// some how the user installed an invalid version
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) {
|
||||
it[isObsolete] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// extension is not installed so we can overwrite the data without a care
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) {
|
||||
it[name] = foundExtension.name
|
||||
it[versionName] = foundExtension.versionName
|
||||
it[versionCode] = foundExtension.versionCode
|
||||
it[lang] = foundExtension.lang
|
||||
it[isNsfw] = foundExtension.isNsfw
|
||||
it[apkName] = foundExtension.apkName
|
||||
it[iconUrl] = foundExtension.iconUrl
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// insert new record
|
||||
ExtensionTable.insert {
|
||||
it[name] = foundExtension.name
|
||||
it[pkgName] = foundExtension.pkgName
|
||||
it[versionName] = foundExtension.versionName
|
||||
it[versionCode] = foundExtension.versionCode
|
||||
it[lang] = foundExtension.lang
|
||||
it[isNsfw] = foundExtension.isNsfw
|
||||
it[apkName] = foundExtension.apkName
|
||||
it[iconUrl] = foundExtension.iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deal with obsolete extensions
|
||||
ExtensionTable.selectAll().forEach { extensionRecord ->
|
||||
val foundExtension = foundExtensions.find { it.pkgName == extensionRecord[ExtensionTable.pkgName] }
|
||||
if (foundExtension == null) {
|
||||
// not in the repo, so this extensions is obsolete
|
||||
if (extensionRecord[ExtensionTable.isInstalled]) {
|
||||
// is installed so we should mark it as obsolete
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq extensionRecord[ExtensionTable.pkgName] }) {
|
||||
it[isObsolete] = true
|
||||
}
|
||||
} else {
|
||||
// is not installed so we can remove the record without a care
|
||||
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq extensionRecord[ExtensionTable.pkgName] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package suwayomi.tachidesk.manga.impl.extension.github
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 com.github.salomonbrys.kotson.int
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import okhttp3.Request
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
|
||||
import suwayomi.tachidesk.manga.impl.util.network.UnzippingInterceptor
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
object ExtensionGithubApi {
|
||||
const val BASE_URL = "https://raw.githubusercontent.com"
|
||||
const val REPO_URL_PREFIX = "$BASE_URL/tachiyomiorg/tachiyomi-extensions/repo"
|
||||
|
||||
private fun parseResponse(json: JsonArray): List<OnlineExtension> {
|
||||
return json
|
||||
.map { it.asJsonObject }
|
||||
.filter { element ->
|
||||
val versionName = element["version"].string
|
||||
val libVersion = versionName.substringBeforeLast('.').toDouble()
|
||||
libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX
|
||||
}
|
||||
.map { element ->
|
||||
val name = element["name"].string.substringAfter("Tachiyomi: ")
|
||||
val pkgName = element["pkg"].string
|
||||
val apkName = element["apk"].string
|
||||
val versionName = element["version"].string
|
||||
val versionCode = element["code"].int
|
||||
val lang = element["lang"].string
|
||||
val nsfw = element["nsfw"].int == 1
|
||||
val icon = "$REPO_URL_PREFIX/icon/${apkName.replace(".apk", ".png")}"
|
||||
|
||||
OnlineExtension(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findExtensions(): List<OnlineExtension> {
|
||||
val response = getRepo()
|
||||
return parseResponse(response)
|
||||
}
|
||||
|
||||
fun getApkUrl(extension: ExtensionDataClass): String {
|
||||
return "$REPO_URL_PREFIX/apk/${extension.apkName}"
|
||||
}
|
||||
|
||||
private val client by lazy {
|
||||
val network: NetworkHelper by injectLazy()
|
||||
network.client.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.header("Content-Encoding", "gzip")
|
||||
.header("Content-Type", "application/json")
|
||||
.build()
|
||||
}
|
||||
.addInterceptor(UnzippingInterceptor())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getRepo(): com.google.gson.JsonArray {
|
||||
val request = Request.Builder()
|
||||
.url("$REPO_URL_PREFIX/index.json.gz")
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute().use { response -> response.body!!.string() }
|
||||
return JsonParser.parseString(response).asJsonArray
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package suwayomi.tachidesk.manga.impl.extension.github
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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/. */
|
||||
|
||||
data class OnlineExtension(
|
||||
val name: String,
|
||||
val pkgName: String,
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val lang: String,
|
||||
val isNsfw: Boolean,
|
||||
val apkName: String,
|
||||
val iconUrl: String
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
package suwayomi.tachidesk.manga.impl.util
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object GetHttpSource {
|
||||
private val sourceCache = ConcurrentHashMap<Long, HttpSource>()
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
|
||||
fun getHttpSource(sourceId: Long): HttpSource {
|
||||
val cachedResult: HttpSource? = sourceCache[sourceId]
|
||||
if (cachedResult != null) {
|
||||
return cachedResult
|
||||
}
|
||||
|
||||
val sourceRecord = transaction {
|
||||
SourceTable.select { SourceTable.id eq sourceId }.first()
|
||||
}
|
||||
|
||||
val extensionId = sourceRecord[SourceTable.extension]
|
||||
val extensionRecord = transaction {
|
||||
ExtensionTable.select { ExtensionTable.id eq extensionId }.first()
|
||||
}
|
||||
|
||||
val apkName = extensionRecord[ExtensionTable.apkName]
|
||||
val className = extensionRecord[ExtensionTable.classFQName]
|
||||
val jarName = apkName.substringBefore(".apk") + ".jar"
|
||||
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
|
||||
|
||||
when (val instance = loadExtensionSources(jarPath, className)) {
|
||||
is Source -> listOf(instance)
|
||||
is SourceFactory -> instance.createSources()
|
||||
else -> throw Exception("Unknown source class type! ${instance.javaClass}")
|
||||
}.forEach {
|
||||
sourceCache[it.id] = it as HttpSource
|
||||
}
|
||||
return sourceCache[sourceId]!!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package suwayomi.tachidesk.manga.impl.util
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 android.content.pm.PackageInfo
|
||||
import android.content.pm.Signature
|
||||
import android.os.Bundle
|
||||
import com.googlecode.d2j.dex.Dex2jar
|
||||
import com.googlecode.d2j.reader.MultiDexFileReader
|
||||
import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
import mu.KotlinLogging
|
||||
import net.dongliu.apk.parser.ApkFile
|
||||
import net.dongliu.apk.parser.ApkParsers
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import org.w3c.dom.Element
|
||||
import org.w3c.dom.Node
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
|
||||
import xyz.nulldev.androidcompat.pm.toPackageInfo
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import java.net.URLClassLoader
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
object PackageTools {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
|
||||
const val EXTENSION_FEATURE = "tachiyomi.extension"
|
||||
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||
const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
|
||||
const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
||||
const val LIB_VERSION_MIN = 1.2
|
||||
const val LIB_VERSION_MAX = 1.2
|
||||
|
||||
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" // inorichi's key
|
||||
private const val unofficialSignature = "64feb21075ba97ebc9cc981243645b331595c111cef1b0d084236a0403b00581" // ArMor's key
|
||||
var trustedSignatures = mutableSetOf<String>() + officialSignature + unofficialSignature
|
||||
|
||||
/**
|
||||
* Convert dex to jar, a wrapper for the dex2jar library
|
||||
*/
|
||||
fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) {
|
||||
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
|
||||
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
|
||||
|
||||
val jarFilePath = File(jarFile).toPath()
|
||||
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
|
||||
val handler = BaksmaliBaseDexExceptionHandler()
|
||||
Dex2jar
|
||||
.from(reader)
|
||||
.withExceptionHandler(handler)
|
||||
.reUseReg(false)
|
||||
.topoLogicalSort()
|
||||
.skipDebug(true)
|
||||
.optimizeSynchronized(false)
|
||||
.printIR(false)
|
||||
.noCode(false)
|
||||
.skipExceptions(false)
|
||||
.to(jarFilePath)
|
||||
if (handler.hasException()) {
|
||||
val errorFile: Path = File(applicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt")
|
||||
logger.error(
|
||||
"""
|
||||
Detail Error Information in File $errorFile
|
||||
Please report this file to one of following link if possible (any one).
|
||||
https://sourceforge.net/p/dex2jar/tickets/
|
||||
https://bitbucket.org/pxb1988/dex2jar/issues
|
||||
https://github.com/pxb1988/dex2jar/issues
|
||||
dex2jar@googlegroups.com
|
||||
""".trimIndent()
|
||||
)
|
||||
handler.dump(errorFile, emptyArray<String>())
|
||||
}
|
||||
}
|
||||
|
||||
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
|
||||
fun getPackageInfo(apkFilePath: String): PackageInfo {
|
||||
val apk = File(apkFilePath)
|
||||
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
|
||||
val parsed = ApkFile(apk)
|
||||
val dbFactory = DocumentBuilderFactory.newInstance()
|
||||
val dBuilder = dbFactory.newDocumentBuilder()
|
||||
val doc = parsed.manifestXml.byteInputStream().use {
|
||||
dBuilder.parse(it)
|
||||
}
|
||||
|
||||
logger.debug(parsed.manifestXml)
|
||||
|
||||
applicationInfo.metaData = Bundle().apply {
|
||||
val appTag = doc.getElementsByTagName("application").item(0)
|
||||
|
||||
appTag?.childNodes?.toList()
|
||||
.orEmpty()
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.nodeType == Node.ELEMENT_NODE
|
||||
}.map {
|
||||
it as Element
|
||||
}.filter {
|
||||
it.tagName == "meta-data"
|
||||
}.forEach {
|
||||
putString(
|
||||
it.attributes.getNamedItem("android:name").nodeValue,
|
||||
it.attributes.getNamedItem("android:value").nodeValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
signatures = (
|
||||
parsed.apkSingers.flatMap { it.certificateMetas }
|
||||
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/
|
||||
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
|
||||
.map { Signature(it.data) }.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||
val signatures = pkgInfo.signatures
|
||||
return if (signatures != null && signatures.isNotEmpty()) {
|
||||
Hash.sha256(signatures.first().toByteArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* loads the extension main class called $className from the jar located at $jarPath
|
||||
* It may return an instance of HttpSource or SourceFactory depending on the extension.
|
||||
*/
|
||||
fun loadExtensionSources(jarPath: String, className: String): Any {
|
||||
val classLoader = URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
|
||||
val classToLoad = Class.forName(className, false, classLoader)
|
||||
return classToLoad.getDeclaredConstructor().newInstance()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.lang
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rx.Observable
|
||||
import rx.Subscriber
|
||||
import rx.Subscription
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
// source: https://github.com/jobobby04/TachiyomiSY/blob/9320221a4e8b118ef68deb60d8c4c32bcbb9e06f/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt
|
||||
/*
|
||||
* Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY.
|
||||
*/
|
||||
|
||||
suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne()
|
||||
|
||||
private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont ->
|
||||
cont.unsubscribeOnCancellation(
|
||||
subscribe(
|
||||
object : Subscriber<T>() {
|
||||
override fun onStart() {
|
||||
request(1)
|
||||
}
|
||||
|
||||
override fun onNext(t: T) {
|
||||
cont.resume(t)
|
||||
}
|
||||
|
||||
override fun onCompleted() {
|
||||
if (cont.isActive) cont.resumeWithException(
|
||||
IllegalStateException(
|
||||
"Should have invoked onNext"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
/*
|
||||
* Rx1 observable throws NoSuchElementException if cancellation happened before
|
||||
* element emission. To mitigate this we try to atomically resume continuation with exception:
|
||||
* if resume failed, then we know that continuation successfully cancelled itself
|
||||
*/
|
||||
val token = cont.tryResumeWithException(e)
|
||||
if (token != null) {
|
||||
cont.completeResume(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> CancellableContinuation<T>.unsubscribeOnCancellation(sub: Subscription) =
|
||||
invokeOnCancellation { sub.unsubscribe() }
|
||||
@@ -0,0 +1,50 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.network
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||
suspend fun Call.await(): Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (!response.isSuccessful) {
|
||||
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(response) {
|
||||
response.body?.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
cancel()
|
||||
} catch (ex: Throwable) {
|
||||
// Ignore cancel exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.network
|
||||
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Interceptor.Chain
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.http.RealResponseBody
|
||||
import okio.GzipSource
|
||||
import okio.buffer
|
||||
import java.io.IOException
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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/. */
|
||||
|
||||
// ref: https://stackoverflow.com/questions/51901333/okhttp-3-how-to-decompress-gzip-deflate-response-manually-using-java-android
|
||||
class UnzippingInterceptor : Interceptor {
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Chain): Response {
|
||||
val response: Response = chain.proceed(chain.request())
|
||||
return unzip(response)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun unzip(response: Response): Response {
|
||||
if (response.body == null) {
|
||||
return response
|
||||
}
|
||||
|
||||
// check if we have gzip response
|
||||
val contentEncoding: String? = response.headers["Content-Encoding"]
|
||||
|
||||
// this is used to decompress gzipped responses
|
||||
return if (contentEncoding != null && contentEncoding == "gzip") {
|
||||
val body = response.body!!
|
||||
val contentLength: Long = body.contentLength()
|
||||
val responseBody = GzipSource(body.source())
|
||||
val strippedHeaders: Headers = response.headers.newBuilder().build()
|
||||
response.newBuilder().headers(strippedHeaders)
|
||||
.body(RealResponseBody(body.contentType().toString(), contentLength, responseBody.buffer()))
|
||||
.build()
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.storage
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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 okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
object CachedImageResponse {
|
||||
private fun pathToInputStream(path: String): InputStream {
|
||||
return FileInputStream(path).buffered()
|
||||
}
|
||||
|
||||
private fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
|
||||
val target = "$fileName."
|
||||
File(directoryPath).listFiles().orEmpty().forEach { file ->
|
||||
if (file.name.startsWith(target))
|
||||
return "$directoryPath/${file.name}"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** fetch a cached image response, calls `fetcher` if cache fails */
|
||||
suspend fun getCachedImageResponse(saveDir: String, fileName: String, fetcher: suspend () -> Response): Pair<InputStream, String> {
|
||||
val cachedFile = findFileNameStartingWith(saveDir, fileName)
|
||||
val filePath = "$saveDir/$fileName"
|
||||
if (cachedFile != null) {
|
||||
val fileType = cachedFile.substringAfter("$filePath.")
|
||||
return Pair(
|
||||
pathToInputStream(cachedFile),
|
||||
"image/$fileType"
|
||||
)
|
||||
}
|
||||
|
||||
val response = fetcher()
|
||||
|
||||
if (response.code == 200) {
|
||||
val tmpSavePath = "$filePath.tmp"
|
||||
val tmpSaveFile = File(tmpSavePath)
|
||||
response.body!!.source().saveTo(tmpSaveFile)
|
||||
|
||||
// find image type
|
||||
val imageType = response.headers["content-type"]
|
||||
?: ImageUtil.findImageType { tmpSaveFile.inputStream() }?.mime
|
||||
?: "image/jpeg"
|
||||
|
||||
val actualSavePath = "$filePath.${imageType.substringAfter("/")}"
|
||||
|
||||
tmpSaveFile.renameTo(File(actualSavePath))
|
||||
|
||||
return pathToInputStream(actualSavePath) to imageType
|
||||
} else {
|
||||
response.closeQuietly()
|
||||
throw Exception("request error! ${response.code}")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCachedImage(saveDir: String, fileName: String) {
|
||||
val cachedFile = findFileNameStartingWith(saveDir, fileName)
|
||||
cachedFile?.also {
|
||||
File(it).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.storage
|
||||
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.GIF
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.JPG
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.PNG
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.WEBP
|
||||
import java.io.InputStream
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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/. */
|
||||
|
||||
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt
|
||||
object ImageUtil {
|
||||
|
||||
fun findImageType(openStream: () -> InputStream): ImageType? {
|
||||
return openStream().use { findImageType(it) }
|
||||
}
|
||||
|
||||
fun findImageType(stream: InputStream): ImageType? {
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
|
||||
val length = if (stream.markSupported()) {
|
||||
stream.mark(bytes.size)
|
||||
stream.read(bytes, 0, bytes.size).also { stream.reset() }
|
||||
} else {
|
||||
stream.read(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
if (length == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (bytes.compareWith(charByteArrayOf(0xFF, 0xD8, 0xFF))) {
|
||||
return JPG
|
||||
}
|
||||
if (bytes.compareWith(charByteArrayOf(0x89, 0x50, 0x4E, 0x47))) {
|
||||
return PNG
|
||||
}
|
||||
if (bytes.compareWith("GIF8".toByteArray())) {
|
||||
return GIF
|
||||
}
|
||||
if (bytes.compareWith("RIFF".toByteArray())) {
|
||||
return WEBP
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun ByteArray.compareWith(magic: ByteArray): Boolean {
|
||||
return magic.indices.none { this[it] != magic[it] }
|
||||
}
|
||||
|
||||
private fun charByteArrayOf(vararg bytes: Int): ByteArray {
|
||||
return ByteArray(bytes.size).apply {
|
||||
for (i in bytes.indices) {
|
||||
set(i, bytes[i].toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ImageType(val mime: String) {
|
||||
JPG("image/jpeg"),
|
||||
PNG("image/png"),
|
||||
GIF("image/gif"),
|
||||
WEBP("image/webp")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.storage
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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.File
|
||||
import java.io.OutputStream
|
||||
|
||||
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/util/storage/OkioExtensions.kt
|
||||
/**
|
||||
* Saves the given source to a file and closes it. Directories will be created if needed.
|
||||
*
|
||||
* @param file the file where the source is copied.
|
||||
*/
|
||||
fun BufferedSource.saveTo(file: File) {
|
||||
try {
|
||||
// Create parent dirs if needed
|
||||
file.parentFile.mkdirs()
|
||||
|
||||
// Copy to destination
|
||||
saveTo(file.outputStream())
|
||||
} catch (e: Exception) {
|
||||
close()
|
||||
file.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.storage
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* 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/. */
|
||||
|
||||
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/4cefbce7c34e724b409b6ba127f3c6c5c346ad8d/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt
|
||||
object SafePath {
|
||||
/**
|
||||
* Mutate the given filename to make it valid for a FAT filesystem,
|
||||
* replacing any invalid characters with "_". This method doesn't allow hidden files (starting
|
||||
* with a dot), but you can manually add it later.
|
||||
*/
|
||||
fun buildValidFilename(origName: String): String {
|
||||
val name = origName.trim('.', ' ')
|
||||
if (name.isEmpty()) {
|
||||
return "(invalid)"
|
||||
}
|
||||
val sb = StringBuilder(name.length)
|
||||
name.forEach { c ->
|
||||
if (isValidFatFilenameChar(c)) {
|
||||
sb.append(c)
|
||||
} else {
|
||||
sb.append('_')
|
||||
}
|
||||
}
|
||||
// Even though vfat allows 255 UCS-2 chars, we might eventually write to
|
||||
// ext4 through a FUSE layer, so use that limit minus 15 reserved characters.
|
||||
return sb.toString().take(240)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given character is a valid filename character, false otherwise.
|
||||
*/
|
||||
private fun isValidFatFilenameChar(c: Char): Boolean {
|
||||
if (0x00.toChar() <= c && c <= 0x1f.toChar()) {
|
||||
return false
|
||||
}
|
||||
return when (c) {
|
||||
'"', '*', '/', ':', '<', '>', '?', '\\', '|', 0x7f.toChar() -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user