diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 35306623..371e8555 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -62,6 +62,8 @@ dependencies { implementation("com.google.code.gson:gson:2.8.7") implementation("com.github.salomonbrys.kotson:kotson:2.5.0") + // Sort + implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1") // asm for ByteCodeEditor(fixing SimpleDateFormat) (must match Dex2Jar version) implementation("org.ow2.asm:asm:9.2") diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt index 7b36ee6b..277c4151 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/LocalSource.kt @@ -1,153 +1,185 @@ package eu.kanade.tachiyomi.source -import android.content.Context -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga -import rx.Observable - // import com.github.junrar.Archive -// import com.google.gson.JsonParser // import eu.kanade.tachiyomi.R -// import eu.kanade.tachiyomi.source.model.Filter -// import eu.kanade.tachiyomi.source.model.FilterList // import eu.kanade.tachiyomi.source.model.MangasPage // import eu.kanade.tachiyomi.source.model.Page // import eu.kanade.tachiyomi.source.model.SChapter // import eu.kanade.tachiyomi.source.model.SManga // import eu.kanade.tachiyomi.util.chapter.ChapterRecognition -// import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder // import eu.kanade.tachiyomi.util.storage.DiskUtil // import eu.kanade.tachiyomi.util.storage.EpubFile // import eu.kanade.tachiyomi.util.system.ImageUtil // import rx.Observable // import timber.log.Timber -// import java.io.File // import java.io.FileInputStream // import java.io.InputStream // import java.util.Locale -// import java.util.concurrent.TimeUnit // import java.util.zip.ZipFile +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.insertAndGetId +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 rx.Observable +import suwayomi.tachidesk.manga.model.table.ExtensionTable +import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.server.ApplicationDirs +import java.io.File +import java.io.FileNotFoundException +import java.net.URL +import java.util.Locale +import java.util.concurrent.TimeUnit -class LocalSource(private val context: Context) : CatalogueSource { +class LocalSource(override val baseUrl: String = "") : HttpSource() { companion object { const val ID = 0L -// const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/" -// -// private const val COVER_NAME = "cover.jpg" -// private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub") -// -// private val POPULAR_FILTERS = FilterList(OrderBy()) -// private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) }) -// private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) -// -// fun updateCover(context: Context, manga: SManga, input: InputStream): File? { + const val LANG = "localsourcelang" + const val NAME = "Local source" + + const val EXTENSION_NAME = "Local Source fake extension" + + const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" + + private val SUPPORTED_ARCHIVE_TYPES = setOf( +// "zip", +// "rar", +// "cbr", +// "cbz", +// "epub" + ) + + private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS) + + // fun updateCover(context: Context, manga: SManga, input: InputStream): File? { // val dir = getBaseDirectories(context).firstOrNull() // if (dir == null) { // input.close() // return null // } -// val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME) +// val cover = getCoverFile(File("${dir.absolutePath}/${manga.url}")) // -// // It might not exist if using the external SD card -// cover.parentFile?.mkdirs() -// input.use { -// cover.outputStream().use { -// input.copyTo(it) +// if (cover != null && cover.exists()) { +// // It might not exist if using the external SD card +// cover.parentFile?.mkdirs() +// input.use { +// cover.outputStream().use { +// input.copyTo(it) +// } // } // } // return cover // } // -// private fun getBaseDirectories(context: Context): List { -// val c = context.getString(R.string.app_name) + File.separator + "local" -// return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) } +// /** +// * Returns valid cover file inside [parent] directory. +// */ +// private fun getCoverFile(parent: File): File? { +// return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf { +// it.isFile && ImageUtil.isImage(it.name) { it.inputStream() } +// } // } +// + private val applicationDirs by DI.global.instance() + + fun addDbRecords() { + transaction { + val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull() + + if (sourceRecord == null) { + // must do this to avoid database integrity errors + val extensionId = ExtensionTable.insertAndGetId { + it[apkName] = "localSource" + it[name] = EXTENSION_NAME + it[pkgName] = "eu.kanade.tachiyomi.source.LocalSource" + it[versionName] = "1.2" + it[versionCode] = 0 + it[lang] = LANG + it[isNsfw] = false + it[isInstalled] = true + } + + SourceTable.insert { + it[id] = ID + it[name] = NAME + it[lang] = LANG + it[extension] = extensionId + it[isNsfw] = false + } + } + } + } } override val id = ID - override val name = "Local source" - override val lang = "" + override val name = NAME + override val lang = LANG override val supportsLatest = true - override fun fetchMangaDetails(manga: SManga): Observable { - TODO("Not yet implemented") - } + override val client: OkHttpClient = super.client.newBuilder() + .addInterceptor(FileSystemInterceptor) + .build() - override fun fetchChapterList(manga: SManga): Observable> { - TODO("Not yet implemented") - } + override fun toString() = name - override fun fetchPageList(chapter: SChapter): Observable> { - TODO("Not yet implemented") - } - override fun fetchPopularManga(page: Int): Observable { - TODO("Not yet implemented") - } + override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - TODO("Not yet implemented") - } + val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L - override fun fetchLatestUpdates(page: Int): Observable { - TODO("Not yet implemented") - } + var mangaDirs = File(applicationDirs.localMangaRoot).listFiles().orEmpty().toList() + .filter { it.isDirectory } + .filterNot { it.name.startsWith('.') } + .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } + .distinctBy { it.name } + + val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state + when (state?.index) { + 0 -> { + mangaDirs = if (state.ascending) { + mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) } + } else { + mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) } + } + } + 1 -> { + mangaDirs = if (state.ascending) { + mangaDirs.sortedBy(File::lastModified) + } else { + mangaDirs.sortedByDescending(File::lastModified) + } + } + } + + val mangas = mangaDirs.map { mangaDir -> + SManga.create().apply { + title = mangaDir.name + url = mangaDir.name + + // Try to find the cover + val cover = File("${applicationDirs.localMangaRoot}/$title/cover.jpg") + if (cover.exists()) { + thumbnail_url = "http://${cover.absolutePath}" + } - override fun getFilterList(): FilterList { - TODO("Not yet implemented") - } -// -// override fun toString() = context.getString(R.string.local_source) -// -// override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) -// -// override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { -// val baseDirs = getBaseDirectories(context) -// -// val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L -// var mangaDirs = baseDirs -// .asSequence() -// .mapNotNull { it.listFiles()?.toList() } -// .flatten() -// .filter { it.isDirectory } -// .filterNot { it.name.startsWith('.') } -// .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time } -// .distinctBy { it.name } -// -// val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state -// when (state?.index) { -// 0 -> { -// mangaDirs = if (state.ascending) { -// mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) } -// } else { -// mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) } -// } -// } -// 1 -> { -// mangaDirs = if (state.ascending) { -// mangaDirs.sortedBy(File::lastModified) -// } else { -// mangaDirs.sortedByDescending(File::lastModified) -// } -// } -// } -// -// val mangas = mangaDirs.map { mangaDir -> -// SManga.create().apply { -// title = mangaDir.name -// url = mangaDir.name -// -// // Try to find the cover -// for (dir in baseDirs) { -// val cover = File("${dir.absolutePath}/$url", COVER_NAME) -// if (cover.exists()) { -// thumbnail_url = cover.absolutePath -// break -// } -// } -// // val chapters = fetchChapterList(this).toBlocking().first() // if (chapters.isNotEmpty()) { // val chapter = chapters.last() @@ -168,117 +200,123 @@ class LocalSource(private val context: Context) : CatalogueSource { // } // } // } -// } -// } -// -// return Observable.just(MangasPage(mangas.toList(), false)) -// } -// -// override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) -// -// override fun fetchMangaDetails(manga: SManga): Observable { -// getBaseDirectories(context) -// .asSequence() -// .mapNotNull { File(it, manga.url).listFiles()?.toList() } -// .flatten() -// .firstOrNull { it.extension == "json" } -// ?.apply { -// val reader = this.inputStream().bufferedReader() -// val json = JsonParser.parseReader(reader).asJsonObject -// -// manga.title = json["title"]?.asString ?: manga.title -// manga.author = json["author"]?.asString ?: manga.author -// manga.artist = json["artist"]?.asString ?: manga.artist -// manga.description = json["description"]?.asString ?: manga.description -// manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } -// ?: manga.genre -// manga.status = json["status"]?.asInt ?: manga.status -// } -// -// return Observable.just(manga) -// } -// -// override fun fetchChapterList(manga: SManga): Observable> { -// val chapters = getBaseDirectories(context) -// .asSequence() -// .mapNotNull { File(it, manga.url).listFiles()?.toList() } -// .flatten() -// .filter { it.isDirectory || isSupportedFile(it.extension) } -// .map { chapterFile -> -// SChapter.create().apply { -// url = "${manga.url}/${chapterFile.name}" -// name = if (chapterFile.isDirectory) { -// chapterFile.name -// } else { -// chapterFile.nameWithoutExtension -// } -// date_upload = chapterFile.lastModified() -// + } + } + + return Observable.just(MangasPage(mangas.toList(), false)) + } + + override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) + + override fun fetchMangaDetails(manga: SManga): Observable { + File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList() + .firstOrNull { it.extension == "json" } + ?.apply { + val reader = this.inputStream().bufferedReader() + val json = JsonParser.parseReader(reader).asJsonObject + + manga.title = json["title"]?.asString ?: manga.title + manga.author = json["author"]?.asString ?: manga.author + manga.artist = json["artist"]?.asString ?: manga.artist + manga.description = json["description"]?.asString ?: manga.description + manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString } + ?: manga.genre + manga.status = json["status"]?.asInt ?: manga.status + } + + return Observable.just(manga) + } + + override fun fetchChapterList(manga: SManga): Observable> { + val chapters = File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList() + .filter { it.isDirectory || isSupportedFile(it.extension) } + .map { chapterFile -> + SChapter.create().apply { + url = "${manga.url}/${chapterFile.name}" + name = if (chapterFile.isDirectory) { + chapterFile.name + } else { + chapterFile.nameWithoutExtension + } + date_upload = chapterFile.lastModified() + // val format = getFormat(this) // if (format is Format.Epub) { // EpubFile(format.file).use { epub -> // epub.fillChapterMetadata(this) // } // } -// -// val chapNameCut = stripMangaTitle(name, manga.title) -// if (chapNameCut.isNotEmpty()) name = chapNameCut + + val chapNameCut = stripMangaTitle(name, manga.title) + if (chapNameCut.isNotEmpty()) name = chapNameCut // ChapterRecognition.parseChapterNumber(this, manga) -// } -// } -// .sortedWith( -// Comparator { c1, c2 -> -// val c = c2.chapter_number.compareTo(c1.chapter_number) -// if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c -// } -// ) -// .toList() -// -// return Observable.just(chapters) -// } -// -// /** -// * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace -// * characters. -// */ -// private fun stripMangaTitle(chapterName: String, mangaTitle: String): String { -// var chapterNameIndex = 0 -// var mangaTitleIndex = 0 -// while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) { -// val chapterChar = chapterName[chapterNameIndex] -// val mangaChar = mangaTitle[mangaTitleIndex] -// if (!chapterChar.equals(mangaChar, true)) { -// val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace() -// val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace() -// -// if (!invalidChapterChar && !invalidMangaChar) { -// return chapterName -// } -// -// if (invalidChapterChar) { -// chapterNameIndex++ -// } -// -// if (invalidMangaChar) { -// mangaTitleIndex++ -// } -// } else { -// chapterNameIndex++ -// mangaTitleIndex++ -// } -// } -// -// return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':') -// } -// -// override fun fetchPageList(chapter: SChapter): Observable> { -// return Observable.error(Exception("Unused")) -// } -// -// private fun isSupportedFile(extension: String): Boolean { -// return extension.toLowerCase() in SUPPORTED_ARCHIVE_TYPES -// } -// + } + } + .sortedWith { c1, c2 -> + val c = c2.chapter_number.compareTo(c1.chapter_number) + if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c + } + .toList() + + return Observable.just(chapters) + } + + /** + * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace + * characters. + */ + private fun stripMangaTitle(chapterName: String, mangaTitle: String): String { + var chapterNameIndex = 0 + var mangaTitleIndex = 0 + while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) { + val chapterChar = chapterName[chapterNameIndex] + val mangaChar = mangaTitle[mangaTitleIndex] + if (!chapterChar.equals(mangaChar, true)) { + val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace() + val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace() + + if (!invalidChapterChar && !invalidMangaChar) { + return chapterName + } + + if (invalidChapterChar) { + chapterNameIndex++ + } + + if (invalidMangaChar) { + mangaTitleIndex++ + } + } else { + chapterNameIndex++ + mangaTitleIndex++ + } + } + + return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':') + } + + private fun isSupportedFile(extension: String): Boolean { + return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val chapterFile = File(applicationDirs.localMangaRoot + File.separator + chapter.url) + + return Observable.just( + if (chapterFile.isDirectory) { + chapterFile.listFiles().sortedBy { it.name }.mapIndexed { index, page -> + Page( + index, + imageUrl = "http://" + applicationDirs.localMangaRoot + File.separator + chapter.url + File.separator + page.name + ) + } + } else { + throw Exception("Archive chapters are not supported.") + } + ) + } + + // // fun getFormat(chapter: SChapter): Format { // val baseDirs = getBaseDirectories(context) // @@ -288,7 +326,7 @@ class LocalSource(private val context: Context) : CatalogueSource { // // return getFormat(chapFile) // } -// throw Exception("Chapter not found") +// throw Exception(context.getString(R.string.chapter_not_found)) // } // // private fun getFormat(file: File): Format { @@ -302,7 +340,7 @@ class LocalSource(private val context: Context) : CatalogueSource { // } else if (extension.equals("epub", true)) { // Format.Epub(file) // } else { -// throw Exception("Invalid chapter format") +// throw Exception(context.getString(R.string.local_invalid_format)) // } // } // @@ -345,14 +383,73 @@ class LocalSource(private val context: Context) : CatalogueSource { // } // } // -// private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true)) -// -// override fun getFilterList() = FilterList(OrderBy()) -// -// sealed class Format { -// data class Directory(val file: File) : Format() -// data class Zip(val file: File) : Format() -// data class Rar(val file: File) : Format() -// data class Epub(val file: File) : Format() -// } + override fun getFilterList() = POPULAR_FILTERS + + private val POPULAR_FILTERS = FilterList(OrderBy()) + private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) }) + + private class OrderBy : Filter.Sort( + "Order by", + arrayOf("Title", "Date"), + Selection(0, true) + ) + + sealed class Format { + data class Directory(val file: File) : Format() + data class Zip(val file: File) : Format() + data class Rar(val file: File) : Format() + data class Epub(val file: File) : Format() + } + + // ///////////////////// Not used ///////////////////// // + + override fun mangaDetailsParse(response: Response): SManga = throw Exception("Not used") + + override fun chapterListParse(response: Response): List = throw Exception("Not used") + + override fun pageListParse(response: Response): List = throw Exception("Not used") + + override fun imageUrlParse(response: Response): String = throw Exception("Not used") + + override fun popularMangaRequest(page: Int): Request = throw Exception("Not used") + + override fun popularMangaParse(response: Response): MangasPage = throw Exception("Not used") + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = + throw Exception("Not used") + + override fun searchMangaParse(response: Response): MangasPage = throw Exception("Not used") + + override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used") + + override fun latestUpdatesParse(response: Response): MangasPage = throw Exception("Not used") +} + +private object FileSystemInterceptor : Interceptor { + private fun restoreFileUrl(markedFakeHttpUrl: String): String { + return markedFakeHttpUrl.replaceFirst("http:", "file:/") + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val url = request.url + val fileUrl = restoreFileUrl(url.toString()) + return try { + Response.Builder() + .body(URL(fileUrl).readBytes().toResponseBody()) + .code(200) + .message("Some file") + .protocol(Protocol.HTTP_1_0) + .request(request) + .build() + } catch (e: FileNotFoundException) { + Response.Builder() + .body("".toResponseBody()) + .code(404) + .message(e.message ?: "File not found ($fileUrl)") + .protocol(Protocol.HTTP_1_0) + .request(request) + .build() + } + } } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/StringExtensions.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/StringExtensions.kt new file mode 100644 index 00000000..ab8c1c93 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/StringExtensions.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.util.lang + +import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator +import kotlin.math.floor + +/** + * Replaces the given string to have at most [count] characters using [replacement] at its end. + * If [replacement] is longer than [count] an exception will be thrown when `length > count`. + */ +fun String.chop(count: Int, replacement: String = "…"): String { + return if (length > count) { + take(count - replacement.length) + replacement + } else { + this + } +} + +/** + * Replaces the given string to have at most [count] characters using [replacement] near the center. + * If [replacement] is longer than [count] an exception will be thrown when `length > count`. + */ +fun String.truncateCenter(count: Int, replacement: String = "..."): String { + if (length <= count) { + return this + } + + val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt() + + return "${take(pieceLength)}$replacement${takeLast(pieceLength)}" +} + +/** + * Case-insensitive natural comparator for strings. + */ +fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + return comparator.compare(this, other) +} + +/** + * Returns the size of the string as the number of bytes. + */ +fun String.byteSize(): Int { + return toByteArray(Charsets.UTF_8).size +} + +/** + * Returns a string containing the first [n] bytes from this string, or the entire string if this + * string is shorter. + */ +fun String.takeBytes(n: Int): String { + val bytes = toByteArray(Charsets.UTF_8) + return if (bytes.size <= n) { + this + } else { + bytes.decodeToString(endIndex = n).replace("\uFFFD", "") + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt index 6a33808d..c3010e27 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -75,7 +75,7 @@ object Manga { transaction { MangaTable.update({ MangaTable.id eq mangaId }) { - + it[MangaTable.title] = fetchedManga.title it[MangaTable.initialized] = true it[MangaTable.artist] = fetchedManga.artist @@ -86,7 +86,11 @@ object Manga { if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url.orEmpty().isNotEmpty()) it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url - it[MangaTable.realUrl] = try { source.mangaDetailsRequest(sManga).url.toString() } catch (e: Exception) { null } + it[MangaTable.realUrl] = try { + source.mangaDetailsRequest(sManga).url.toString() + } catch (e: Exception) { + null + } } } @@ -151,14 +155,20 @@ object Manga { 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]!! + val thumbnailUrl: String = mangaEntry[MangaTable.thumbnail_url] + ?: if (!mangaEntry[MangaTable.initialized]) { + // initialize then try again + getManga(mangaId) + transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }[MangaTable.thumbnail_url]!! + } else { + // source provides no thumbnail url for this manga + throw NullPointerException() + } source.client.newCall( GET(thumbnailUrl, source.headers) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt index aebeda6a..440f3540 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt @@ -11,6 +11,7 @@ import android.app.Application import android.content.Context import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.getPreferenceKey import mu.KotlinLogging import org.jetbrains.exposed.sql.select @@ -45,7 +46,8 @@ object Source { getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]), httpSource.supportsLatest, httpSource is ConfigurableSource, - it[SourceTable.isNsfw] + it[SourceTable.isNsfw], + httpSource.toString(), ) } } @@ -53,6 +55,11 @@ object Source { fun getSource(sourceId: Long): SourceDataClass { // all the data extracted fresh form the source instance return transaction { + if (sourceId == LocalSource.ID) { + // initialize local source + getHttpSource(sourceId) + } + val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull() val httpSource = source?.let { getHttpSource(sourceId) } val extension = source?.let { @@ -70,7 +77,8 @@ object Source { }, httpSource?.supportsLatest, httpSource?.let { it is ConfigurableSource }, - source?.get(SourceTable.isNsfw) + source?.get(SourceTable.isNsfw), + httpSource?.toString() ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt index 4a65ce15..e7160ffc 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt @@ -268,8 +268,8 @@ object Extension { } suspend fun getExtensionIcon(apkName: String): Pair { - val iconUrl = - transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl] + val iconUrl = if (apkName == "localSource") "" + else transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl] val saveDir = "${applicationDirs.extensionsRoot}/icon" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt index 5ce9aceb..019c9d2f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt @@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension * 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.LocalSource import mu.KotlinLogging import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert @@ -46,7 +47,7 @@ object ExtensionsList { } fun extensionTableAsDataClass() = transaction { - ExtensionTable.selectAll().map { + ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map { ExtensionDataClass( it[ExtensionTable.apkName], getExtensionIconUrl(it[ExtensionTable.apkName]), diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt index d3fe4e27..ec1ca167 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/GetHttpSource.kt @@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.util * 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.LocalSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.online.HttpSource @@ -35,6 +36,10 @@ object GetHttpSource { SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!! } + if (sourceId == LocalSource.ID) { + return LocalSource() + } + val extensionId = sourceRecord[SourceTable.extension] val extensionRecord = transaction { ExtensionTable.select { ExtensionTable.id eq extensionId }.first() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/SourceDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/SourceDataClass.kt index ea464815..1d6987c8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/SourceDataClass.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/SourceDataClass.kt @@ -23,4 +23,6 @@ data class SourceDataClass( /** The Source class has a @Nsfw annotation */ val isNsfw: Boolean?, + + val displayName: String?, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt index 2a50873f..4f22a94b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt @@ -20,7 +20,7 @@ object ExtensionTable : IntIdTable() { val pkgName = varchar("pkg_name", 128) val versionName = varchar("version_name", 16) val versionCode = integer("version_code") - val lang = varchar("lang", 10) + val lang = varchar("lang", 32) val isNsfw = bool("is_nsfw") val isInstalled = bool("is_installed").default(false) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceTable.kt index fc6e1883..85769ded 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceTable.kt @@ -12,7 +12,7 @@ import org.jetbrains.exposed.dao.id.IdTable object SourceTable : IdTable() { override val id = long("id").entityId() val name = varchar("name", 128) - val lang = varchar("lang", 10) + val lang = varchar("lang", 32) val extension = reference("extension", ExtensionTable) val isNsfw = bool("is_nsfw").default(false) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index d6b0235c..2837ea17 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -8,6 +8,7 @@ package suwayomi.tachidesk.server * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.source.LocalSource import mu.KotlinLogging import org.kodein.di.DI import org.kodein.di.bind @@ -33,6 +34,7 @@ class ApplicationDirs( val mangaThumbnailsRoot = "$dataRoot/manga-thumbnails" val animeThumbnailsRoot = "$dataRoot/anime-thumbnails" val mangaRoot = "$dataRoot/manga" + val localMangaRoot = "$dataRoot/manga-local" val webUIRoot = "$dataRoot/webUI" } @@ -63,6 +65,8 @@ fun applicationSetup() { applicationDirs.extensionsRoot + "/icon", applicationDirs.mangaThumbnailsRoot, applicationDirs.animeThumbnailsRoot, + applicationDirs.mangaRoot, + applicationDirs.localMangaRoot, ).forEach { File(it).mkdirs() } @@ -96,11 +100,27 @@ fun applicationSetup() { logger.error("Exception while creating initial server.conf:\n", e) } + // copy local source icon + try { + val localSourceIconFile = File("${applicationDirs.extensionsRoot}/icon/localSource.png") + if (!localSourceIconFile.exists()) { + JavalinSetup::class.java.getResourceAsStream("/icon/localSource.png").use { input -> + localSourceIconFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + } catch (e: Exception) { + logger.error("Exception while creating initial server.conf:\n", e) + } + // fixes #119 , ref: https://github.com/Suwayomi/Tachidesk-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase() Locale.setDefault(Locale.ENGLISH) databaseUp() + LocalSource.addDbRecords() + // create system tray if (serverConfig.systemTrayEnabled) { try { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0015_SourceAndExtensionLangAddLengthLimit.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0015_SourceAndExtensionLangAddLengthLimit.kt new file mode 100644 index 00000000..46af5fe3 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0015_SourceAndExtensionLangAddLengthLimit.kt @@ -0,0 +1,18 @@ +package suwayomi.tachidesk.server.database.migration + +/* + * 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 de.neonew.exposed.migrations.helpers.SQLMigration + +@Suppress("ClassName", "unused") +class M0015_SourceAndExtensionLangAddLengthLimit : SQLMigration() { + override val sql = """ + ALTER TABLE SOURCE ALTER COLUMN LANG VARCHAR(32); + ALTER TABLE EXTENSION ALTER COLUMN LANG VARCHAR(32); + """.trimIndent() +} diff --git a/server/src/main/resources/icon/localSource.png b/server/src/main/resources/icon/localSource.png new file mode 100644 index 00000000..27f70b91 Binary files /dev/null and b/server/src/main/resources/icon/localSource.png differ