mirror of
https://github.com/tachiyomiorg/tachiyomi-extensions-inspector.git
synced 2024-12-27 01:01:48 +01:00
Rewrite Tachidesk to a Source Inspector
This commit is contained in:
parent
d06c3586fd
commit
1e38a38368
@ -1,18 +0,0 @@
|
|||||||
package xyz.nulldev.ts.config
|
|
||||||
|
|
||||||
import net.harawata.appdirs.AppDirsFactory
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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/. */
|
|
||||||
|
|
||||||
val ApplicationRootDir: String
|
|
||||||
get(): String {
|
|
||||||
return System.getProperty(
|
|
||||||
"suwayomi.tachidesk.server.rootDir",
|
|
||||||
AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
|
|
||||||
)
|
|
||||||
}
|
|
@ -43,32 +43,14 @@ open class ConfigManager {
|
|||||||
fun loadConfigs(): Config {
|
fun loadConfigs(): Config {
|
||||||
//Load reference configs
|
//Load reference configs
|
||||||
val compatConfig = ConfigFactory.parseResources("compat-reference.conf")
|
val compatConfig = ConfigFactory.parseResources("compat-reference.conf")
|
||||||
val serverConfig = ConfigFactory.parseResources("server-reference.conf")
|
|
||||||
val baseConfig =
|
|
||||||
ConfigFactory.parseMap(
|
|
||||||
mapOf(
|
|
||||||
"ts.server.rootDir" to ApplicationRootDir
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
//Load user config
|
|
||||||
val userConfig =
|
|
||||||
File(ApplicationRootDir, "server.conf").let {
|
|
||||||
ConfigFactory.parseFile(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
val config = ConfigFactory.empty()
|
val config = ConfigFactory.empty()
|
||||||
.withFallback(baseConfig)
|
|
||||||
.withFallback(userConfig)
|
|
||||||
.withFallback(compatConfig)
|
.withFallback(compatConfig)
|
||||||
.withFallback(serverConfig)
|
|
||||||
.resolve()
|
.resolve()
|
||||||
|
|
||||||
// set log level early
|
// set log level early
|
||||||
if (debugLogsEnabled(config)) {
|
|
||||||
setLogLevel(Level.DEBUG)
|
setLogLevel(Level.DEBUG)
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug {
|
logger.debug {
|
||||||
"Loaded config:\n" + config.root().render(ConfigRenderOptions.concise().setFormatted(true))
|
"Loaded config:\n" + config.root().render(ConfigRenderOptions.concise().setFormatted(true))
|
||||||
|
@ -15,6 +15,3 @@ import org.slf4j.Logger
|
|||||||
fun setLogLevel(level: Level) {
|
fun setLogLevel(level: Level) {
|
||||||
(KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger).level = level
|
(KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger).level = level
|
||||||
}
|
}
|
||||||
|
|
||||||
fun debugLogsEnabled(config: Config)
|
|
||||||
= System.getProperty("suwayomi.tachidesk.server.debugLogsEnabled", config.getString("server.debugLogsEnabled")).toBoolean()
|
|
77
README.md
77
README.md
@ -1,80 +1,7 @@
|
|||||||
|
# Inspector
|
||||||
| Build | Stable | Preview | Support Server |
|
This is a headless fork of [Tachidesk](https://github.com/AriaMoradi/Tachidesk) that is used to inspect Tachiyomi's extension sources.
|
||||||
|-------|----------|---------|---------|
|
|
||||||
| ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk-preview/raw/main/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) |
|
|
||||||
|
|
||||||
# Tachidesk
|
|
||||||
<img src="https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png" alt="drawing" width="200"/>
|
|
||||||
|
|
||||||
A free and open source manga reader that runs extensions built for [Tachiyomi](https://tachiyomi.org/).
|
|
||||||
|
|
||||||
Tachidesk is an independent Tachiyomi compatible software and is **not a Fork of** Tachiyomi.
|
|
||||||
|
|
||||||
Tachidesk is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions.
|
|
||||||
|
|
||||||
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature.
|
|
||||||
|
|
||||||
**Tachidesk needs serious front-end dev help for it's reader and other parts, if you like the app and want to see it become better please don't hesitate to contribute some code!**
|
|
||||||
|
|
||||||
## Is this application usable? Should I test it?
|
|
||||||
Here is a list of current features:
|
|
||||||
|
|
||||||
- Installing and executing Tachiyomi's Extensions, So you'll get the same sources.
|
|
||||||
- A library to save your mangas and categories to put them into.
|
|
||||||
- Searching and browsing installed sources.
|
|
||||||
- A decent chapter reader.
|
|
||||||
- Ability to download Mangas for offline read
|
|
||||||
- Backup and restore support powered by Tachiyomi Legacy Backups
|
|
||||||
|
|
||||||
**Note:** Keep in mind that Tachidesk is alpha software and can break rarely and/or with each update. See [Troubleshooting](https://github.com/Suwayomi/Tachidesk/wiki/Troubleshooting) if it happens.
|
|
||||||
|
|
||||||
## Downloading and Running the app
|
|
||||||
### All Operating Systems
|
|
||||||
You should have The Java Runtime Environment(JRE) 8 or newer and a modern browser installed(Google is your friend for seeking assitance). Also an internet connection is required as almost everything this app does is downloading stuff.
|
|
||||||
|
|
||||||
Download the latest "Stable" jar release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview jar build from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
|
|
||||||
|
|
||||||
Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` (or `java -jar Tachidesk-latest.jar` if you have the latest preview) from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk.
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
Download the latest "Stable" win32 or win64 (depending on your system, usually you want win64) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
|
|
||||||
|
|
||||||
The Windows specific build has java bundled inside, so you don't have to install java to use it. Unzip `Tachidesk-vX.Y.Z-rxxx-win64.zip` and run one of the Launcher files depending on what you want(see bellow). The rest works like the previous section.
|
|
||||||
#### Windows Launchers
|
|
||||||
- `Tachidesk Electron Launcher.bat`: Launches Tachidesk inside Electron as a desktop applicaton
|
|
||||||
- `Tachidesk Browser Launcher.bat`: Launches Tachidesk in a browser window
|
|
||||||
- `Tachidesk Debug Launcher.bat`: Launches Tachidesk with debug logs attached. If Tachidesk doesn't work for you, running this can give you insight into why.
|
|
||||||
|
|
||||||
### Arch Linux
|
|
||||||
You can install Tachidesk from the AUR
|
|
||||||
```
|
|
||||||
yay -S tachidesk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
Check our Offical Docker release [Tachidesk Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) or use [arbuilder's](https://github.com/arbuilder/Tachidesk-docker) tachidesk docker repo for installation. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk). By default the server will be running on http://localhost:4567 open this url in your browser.
|
|
||||||
|
|
||||||
Install from the command line:
|
|
||||||
```
|
|
||||||
$ docker pull ghcr.io/suwayomi/tachidesk
|
|
||||||
```
|
|
||||||
Run Container from the command line:
|
|
||||||
```
|
|
||||||
$ docker run -p 4567:4567 ghcr.io/suwayomi/tachidesk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Tachidesk Remotely
|
|
||||||
You can run Tachidesk on your computer or a server and connect to it remotely through the web interface with a web browser on any device including a mobile or tablet or even your smart TV!, this method of using Tachidesk is only recommended if you are a power user and know what you are doing.
|
|
||||||
|
|
||||||
## Troubleshooting and Support
|
|
||||||
See [this troubleshooting wiki page](https://github.com/Suwayomi/Tachidesk/wiki/Troubleshooting).
|
|
||||||
|
|
||||||
## Contributing and Technical info
|
|
||||||
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
||||||
|
|
||||||
## Credit
|
## Credit
|
||||||
This project is a spiritual successor of [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server), Many of the ideas and the groundwork adopted in this project comes from TachiWeb.
|
|
||||||
|
|
||||||
The `AndroidCompat` module was originally developed by [@null-dev](https://github.com/null-dev) for [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server) and is licensed under `Apache License Version 2.0`.
|
The `AndroidCompat` module was originally developed by [@null-dev](https://github.com/null-dev) for [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server) and is licensed under `Apache License Version 2.0`.
|
||||||
|
|
||||||
Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0`.
|
Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0`.
|
||||||
|
@ -76,9 +76,6 @@ configure(projects) {
|
|||||||
implementation("com.typesafe:config:1.4.1")
|
implementation("com.typesafe:config:1.4.1")
|
||||||
implementation("io.github.config4k:config4k:0.4.2")
|
implementation("io.github.config4k:config4k:0.4.2")
|
||||||
|
|
||||||
// to get application content root
|
|
||||||
implementation("net.harawata:appdirs:1.2.1")
|
|
||||||
|
|
||||||
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
|
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
|
||||||
implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
|
implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
|
||||||
|
|
||||||
|
@ -3,10 +3,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
|||||||
import org.jmailen.gradle.kotlinter.tasks.FormatTask
|
import org.jmailen.gradle.kotlinter.tasks.FormatTask
|
||||||
import org.jmailen.gradle.kotlinter.tasks.LintTask
|
import org.jmailen.gradle.kotlinter.tasks.LintTask
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
application
|
application
|
||||||
|
kotlin("plugin.serialization")
|
||||||
id("com.github.johnrengelman.shadow") version "7.0.0"
|
id("com.github.johnrengelman.shadow") version "7.0.0"
|
||||||
id("org.jmailen.kotlinter") version "3.4.3"
|
id("org.jmailen.kotlinter") version "3.4.3"
|
||||||
id("de.fuerstenau.buildconfig") version "1.1.8"
|
id("de.fuerstenau.buildconfig") version "1.1.8"
|
||||||
@ -29,25 +29,10 @@ dependencies {
|
|||||||
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion")
|
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion")
|
||||||
implementation("com.squareup.okio:okio:2.10.0")
|
implementation("com.squareup.okio:okio:2.10.0")
|
||||||
|
|
||||||
// Javalin api
|
|
||||||
implementation("io.javalin:javalin:3.13.6")
|
|
||||||
// jackson version is tied to javalin, ref: `io.javalin.core.util.OptionalDependency`
|
// jackson version is tied to javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3")
|
implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3")
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.10.3")
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.10.3")
|
||||||
|
|
||||||
// Exposed ORM
|
|
||||||
val exposedVersion = "0.31.1"
|
|
||||||
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
|
|
||||||
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
|
|
||||||
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
|
|
||||||
implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
|
|
||||||
// current database driver
|
|
||||||
implementation("com.h2database:h2:1.4.200")
|
|
||||||
|
|
||||||
// tray icon
|
|
||||||
implementation("com.dorkbox:SystemTray:4.1")
|
|
||||||
implementation("com.dorkbox:Utilities:1.9")
|
|
||||||
|
|
||||||
|
|
||||||
// dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference
|
// dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference
|
||||||
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
|
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
|
||||||
@ -118,11 +103,6 @@ buildConfig {
|
|||||||
buildConfigField("String", "NAME", rootProject.name)
|
buildConfigField("String", "NAME", rootProject.name)
|
||||||
buildConfigField("String", "VERSION", tachideskVersion)
|
buildConfigField("String", "VERSION", tachideskVersion)
|
||||||
buildConfigField("String", "REVISION", tachideskRevision)
|
buildConfigField("String", "REVISION", tachideskRevision)
|
||||||
buildConfigField("String", "BUILD_TYPE", if (System.getenv("ProductBuildType") == "Stable") "Stable" else "Preview")
|
|
||||||
buildConfigField("long", "BUILD_TIME", Instant.now().epochSecond.toString())
|
|
||||||
|
|
||||||
buildConfigField("String", "GITHUB", "https://github.com/Suwayomi/Tachidesk")
|
|
||||||
buildConfigField("String", "DISCORD", "https://discord.gg/DDZdqZWaHA")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks {
|
tasks {
|
||||||
@ -165,11 +145,6 @@ tasks {
|
|||||||
dependsOn("formatKotlin", "lintKotlin")
|
dependsOn("formatKotlin", "lintKotlin")
|
||||||
}
|
}
|
||||||
|
|
||||||
named<Copy>("processResources") {
|
|
||||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
|
||||||
mustRunAfter(":webUI:copyBuild")
|
|
||||||
}
|
|
||||||
|
|
||||||
withType<LintTask> {
|
withType<LintTask> {
|
||||||
source(files("src/kotlin"))
|
source(files("src/kotlin"))
|
||||||
}
|
}
|
||||||
@ -177,4 +152,8 @@ tasks {
|
|||||||
withType<FormatTask> {
|
withType<FormatTask> {
|
||||||
source(files("src/kotlin"))
|
source(files("src/kotlin"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withType<ProcessResources> {
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.WARN
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
53
server/src/main/kotlin/suwayomi/tachidesk/InspectorMain.kt
Normal file
53
server/src/main/kotlin/suwayomi/tachidesk/InspectorMain.kt
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package suwayomi.tachidesk
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.Extension.installAPK
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object InspectorMain {
|
||||||
|
suspend fun inspectorMain(args: Array<String>) {
|
||||||
|
if (args.size < 3) {
|
||||||
|
throw RuntimeException("Inspector must be given the path of apks directory, output json, and a tmp dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
val apksPath = args[0]
|
||||||
|
val outputPath = args[1]
|
||||||
|
val tmpDirPath = args[2]
|
||||||
|
|
||||||
|
val tmpDir = File(tmpDirPath, "tmp").also { it.mkdir() }
|
||||||
|
val extensions = File(apksPath).listFiles().orEmpty().mapNotNull {
|
||||||
|
if (it.extension == "apk") {
|
||||||
|
println("Installing ${it.absolutePath}")
|
||||||
|
|
||||||
|
val (pkgName, sources) = installAPK(tmpDir) {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
ExtensionJson(
|
||||||
|
pkgName,
|
||||||
|
sources.map { source -> SourceJson(source) }
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
|
||||||
|
File(outputPath).writeText(Json.encodeToString(extensions))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ExtensionJson(
|
||||||
|
val pkgName: String,
|
||||||
|
val sources: List<SourceJson>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SourceJson(
|
||||||
|
val name: String,
|
||||||
|
val lang: String,
|
||||||
|
val id: Long
|
||||||
|
) {
|
||||||
|
constructor(source: CatalogueSource) :
|
||||||
|
this(source.name, source.lang, source.id)
|
||||||
|
}
|
||||||
|
}
|
@ -7,10 +7,10 @@ package suwayomi.tachidesk
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.javalinSetup
|
import suwayomi.tachidesk.InspectorMain.inspectorMain
|
||||||
import suwayomi.tachidesk.server.applicationSetup
|
import suwayomi.tachidesk.server.applicationSetup
|
||||||
|
|
||||||
fun main() {
|
suspend fun main(args: Array<String>) {
|
||||||
applicationSetup()
|
applicationSetup()
|
||||||
javalinSetup()
|
inspectorMain(args)
|
||||||
}
|
}
|
||||||
|
@ -1,379 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.Javalin
|
|
||||||
import suwayomi.tachidesk.anime.impl.Anime.getAnime
|
|
||||||
import suwayomi.tachidesk.anime.impl.Anime.getAnimeThumbnail
|
|
||||||
import suwayomi.tachidesk.anime.impl.AnimeList.getAnimeList
|
|
||||||
import suwayomi.tachidesk.anime.impl.Episode.getEpisode
|
|
||||||
import suwayomi.tachidesk.anime.impl.Episode.getEpisodeList
|
|
||||||
import suwayomi.tachidesk.anime.impl.Episode.modifyEpisode
|
|
||||||
import suwayomi.tachidesk.anime.impl.Source.getAnimeSource
|
|
||||||
import suwayomi.tachidesk.anime.impl.Source.getSourceList
|
|
||||||
import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIcon
|
|
||||||
import suwayomi.tachidesk.anime.impl.extension.Extension.installExtension
|
|
||||||
import suwayomi.tachidesk.anime.impl.extension.Extension.uninstallExtension
|
|
||||||
import suwayomi.tachidesk.anime.impl.extension.Extension.updateExtension
|
|
||||||
import suwayomi.tachidesk.anime.impl.extension.ExtensionsList.getExtensionList
|
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
|
||||||
|
|
||||||
object AnimeAPI {
|
|
||||||
fun defineEndpoints(app: Javalin) {
|
|
||||||
// list all extensions
|
|
||||||
app.get("/api/v1/anime/extension/list") { ctx ->
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
getExtensionList()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// install extension identified with "pkgName"
|
|
||||||
app.get("/api/v1/anime/extension/install/:pkgName") { ctx ->
|
|
||||||
val pkgName = ctx.pathParam("pkgName")
|
|
||||||
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
installExtension(pkgName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// update extension identified with "pkgName"
|
|
||||||
app.get("/api/v1/anime/extension/update/:pkgName") { ctx ->
|
|
||||||
val pkgName = ctx.pathParam("pkgName")
|
|
||||||
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
updateExtension(pkgName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// uninstall extension identified with "pkgName"
|
|
||||||
app.get("/api/v1/anime/extension/uninstall/:pkgName") { ctx ->
|
|
||||||
val pkgName = ctx.pathParam("pkgName")
|
|
||||||
|
|
||||||
uninstallExtension(pkgName)
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// icon for extension named `apkName`
|
|
||||||
app.get("/api/v1/anime/extension/icon/:apkName") { ctx -> // TODO: move to pkgName
|
|
||||||
val apkName = ctx.pathParam("apkName")
|
|
||||||
|
|
||||||
ctx.result(
|
|
||||||
future { getExtensionIcon(apkName) }
|
|
||||||
.thenApply {
|
|
||||||
ctx.header("content-type", it.second)
|
|
||||||
it.first
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// list of sources
|
|
||||||
app.get("/api/v1/anime/source/list") { ctx ->
|
|
||||||
ctx.json(getSourceList())
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch source with id `sourceId`
|
|
||||||
app.get("/api/v1/anime/source/:sourceId") { ctx ->
|
|
||||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
ctx.json(getAnimeSource(sourceId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// popular animes from source with id `sourceId`
|
|
||||||
app.get("/api/v1/anime/source/:sourceId/popular/:pageNum") { ctx ->
|
|
||||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
val pageNum = ctx.pathParam("pageNum").toInt()
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
getAnimeList(sourceId, pageNum, popular = true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// latest animes from source with id `sourceId`
|
|
||||||
app.get("/api/v1/anime/source/:sourceId/latest/:pageNum") { ctx ->
|
|
||||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
val pageNum = ctx.pathParam("pageNum").toInt()
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
getAnimeList(sourceId, pageNum, popular = false)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// get anime info
|
|
||||||
app.get("/api/v1/anime/anime/:animeId/") { ctx ->
|
|
||||||
val animeId = ctx.pathParam("animeId").toInt()
|
|
||||||
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
|
|
||||||
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
getAnime(animeId, onlineFetch)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// anime thumbnail
|
|
||||||
app.get("api/v1/anime/anime/:animeId/thumbnail") { ctx ->
|
|
||||||
val animeId = ctx.pathParam("animeId").toInt()
|
|
||||||
|
|
||||||
ctx.result(
|
|
||||||
future { getAnimeThumbnail(animeId) }
|
|
||||||
.thenApply {
|
|
||||||
ctx.header("content-type", it.second)
|
|
||||||
it.first
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
//
|
|
||||||
// // list manga's categories
|
|
||||||
// app.get("api/v1/manga/:mangaId/category/") { ctx ->
|
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
// ctx.json(getMangaCategories(mangaId))
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // adds the manga to category
|
|
||||||
// app.get("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
|
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
// val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
// addMangaToCategory(mangaId, categoryId)
|
|
||||||
// ctx.status(200)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // removes the manga from the category
|
|
||||||
// app.delete("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
|
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
// val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
// removeMangaFromCategory(mangaId, categoryId)
|
|
||||||
// ctx.status(200)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// get episode list when showing a anime
|
|
||||||
app.get("/api/v1/anime/anime/:animeId/episodes") { ctx ->
|
|
||||||
val animeId = ctx.pathParam("animeId").toInt()
|
|
||||||
|
|
||||||
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean()
|
|
||||||
|
|
||||||
ctx.json(future { getEpisodeList(animeId, onlineFetch) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// used to display a episode, get a episode in order to show it's <Quality pending>
|
|
||||||
app.get("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
|
|
||||||
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
|
|
||||||
val animeId = ctx.pathParam("animeId").toInt()
|
|
||||||
ctx.json(future { getEpisode(episodeIndex, animeId) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// used to modify a episode's parameters
|
|
||||||
app.patch("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
|
|
||||||
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
|
|
||||||
val animeId = ctx.pathParam("animeId").toInt()
|
|
||||||
|
|
||||||
val read = ctx.formParam("read")?.toBoolean()
|
|
||||||
val bookmarked = ctx.formParam("bookmarked")?.toBoolean()
|
|
||||||
val markPrevRead = ctx.formParam("markPrevRead")?.toBoolean()
|
|
||||||
val lastPageRead = ctx.formParam("lastPageRead")?.toInt()
|
|
||||||
|
|
||||||
modifyEpisode(animeId, episodeIndex, read, bookmarked, markPrevRead, lastPageRead)
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
//
|
|
||||||
// // get page at index "index"
|
|
||||||
// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx ->
|
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
// val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
|
||||||
// val index = ctx.pathParam("index").toInt()
|
|
||||||
//
|
|
||||||
// ctx.result(
|
|
||||||
// JavalinSetup.future { getPageImage(mangaId, chapterIndex, index) }
|
|
||||||
// .thenApply {
|
|
||||||
// ctx.header("content-type", it.second)
|
|
||||||
// it.first
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // submit a chapter for download
|
|
||||||
// app.put("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
|
|
||||||
// // TODO
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // cancel a chapter download
|
|
||||||
// app.delete("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
|
|
||||||
// // TODO
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // global search, Not implemented yet
|
|
||||||
// app.get("/api/v1/search/:searchTerm") { ctx ->
|
|
||||||
// val searchTerm = ctx.pathParam("searchTerm")
|
|
||||||
// ctx.json(sourceGlobalSearch(searchTerm))
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // single source search
|
|
||||||
// app.get("/api/v1/source/:sourceId/search/:searchTerm/:pageNum") { ctx ->
|
|
||||||
// val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
// val searchTerm = ctx.pathParam("searchTerm")
|
|
||||||
// val pageNum = ctx.pathParam("pageNum").toInt()
|
|
||||||
// ctx.json(JavalinSetup.future { sourceSearch(sourceId, searchTerm, pageNum) })
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // source filter list
|
|
||||||
// app.get("/api/v1/source/:sourceId/filters/") { ctx ->
|
|
||||||
// val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
// ctx.json(sourceFilters(sourceId))
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // adds the manga to library
|
|
||||||
// app.get("api/v1/manga/:mangaId/library") { ctx ->
|
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
//
|
|
||||||
// ctx.result(
|
|
||||||
// JavalinSetup.future { addMangaToLibrary(mangaId) }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // removes the manga from the library
|
|
||||||
// app.delete("api/v1/manga/:mangaId/library") { ctx ->
|
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
//
|
|
||||||
// ctx.result(
|
|
||||||
// JavalinSetup.future { removeMangaFromLibrary(mangaId) }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // lists mangas that have no category assigned
|
|
||||||
// app.get("/api/v1/library/") { ctx ->
|
|
||||||
// ctx.json(getLibraryMangas())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // category list
|
|
||||||
// app.get("/api/v1/category/") { ctx ->
|
|
||||||
// ctx.json(Category.getCategoryList())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // category create
|
|
||||||
// app.post("/api/v1/category/") { ctx ->
|
|
||||||
// val name = ctx.formParam("name")!!
|
|
||||||
// Category.createCategory(name)
|
|
||||||
// ctx.status(200)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // returns some static info of the current app build
|
|
||||||
// app.get("/api/v1/about/") { ctx ->
|
|
||||||
// ctx.json(About.getAbout())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // category modification
|
|
||||||
// app.patch("/api/v1/category/:categoryId") { ctx ->
|
|
||||||
// val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
// val name = ctx.formParam("name")
|
|
||||||
// val isDefault = ctx.formParam("default")?.toBoolean()
|
|
||||||
// Category.updateCategory(categoryId, name, isDefault)
|
|
||||||
// ctx.status(200)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // category re-ordering
|
|
||||||
// app.patch("/api/v1/category/:categoryId/reorder") { ctx ->
|
|
||||||
// val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
// val from = ctx.formParam("from")!!.toInt()
|
|
||||||
// val to = ctx.formParam("to")!!.toInt()
|
|
||||||
// Category.reorderCategory(categoryId, from, to)
|
|
||||||
// ctx.status(200)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // category delete
|
|
||||||
// app.delete("/api/v1/category/:categoryId") { ctx ->
|
|
||||||
// val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
// Category.removeCategory(categoryId)
|
|
||||||
// ctx.status(200)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // returns the manga list associated with a category
|
|
||||||
// app.get("/api/v1/category/:categoryId") { ctx ->
|
|
||||||
// val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
// ctx.json(getCategoryMangaList(categoryId))
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // expects a Tachiyomi legacy backup json in the body
|
|
||||||
// app.post("/api/v1/backup/legacy/import") { ctx ->
|
|
||||||
// ctx.result(
|
|
||||||
// future {
|
|
||||||
// restoreLegacyBackup(ctx.bodyAsInputStream())
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // expects a Tachiyomi legacy backup json as a file upload, the file must be named "backup.json"
|
|
||||||
// app.post("/api/v1/backup/legacy/import/file") { ctx ->
|
|
||||||
// ctx.result(
|
|
||||||
// JavalinSetup.future {
|
|
||||||
// restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content)
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // returns a Tachiyomi legacy backup json created from the current database as a json body
|
|
||||||
// app.get("/api/v1/backup/legacy/export") { ctx ->
|
|
||||||
// ctx.contentType("application/json")
|
|
||||||
// ctx.result(
|
|
||||||
// JavalinSetup.future {
|
|
||||||
// createLegacyBackup(
|
|
||||||
// BackupFlags(
|
|
||||||
// includeManga = true,
|
|
||||||
// includeCategories = true,
|
|
||||||
// includeChapters = true,
|
|
||||||
// includeTracking = true,
|
|
||||||
// includeHistory = true,
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // returns a Tachiyomi legacy backup json created from the current database as a file
|
|
||||||
// app.get("/api/v1/backup/legacy/export/file") { ctx ->
|
|
||||||
// ctx.contentType("application/json")
|
|
||||||
// val sdf = SimpleDateFormat("yyyy-MM-dd_HH-mm")
|
|
||||||
// val currentDate = sdf.format(Date())
|
|
||||||
//
|
|
||||||
// ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"")
|
|
||||||
// ctx.result(
|
|
||||||
// JavalinSetup.future {
|
|
||||||
// createLegacyBackup(
|
|
||||||
// BackupFlags(
|
|
||||||
// includeManga = true,
|
|
||||||
// includeCategories = true,
|
|
||||||
// includeChapters = true,
|
|
||||||
// includeTracking = true,
|
|
||||||
// includeHistory = true,
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // Download queue stats
|
|
||||||
// app.ws("/api/v1/downloads") { ws ->
|
|
||||||
// ws.onConnect { ctx ->
|
|
||||||
// // TODO: send current stat
|
|
||||||
// // TODO: add to downlad subscribers
|
|
||||||
// }
|
|
||||||
// ws.onMessage {
|
|
||||||
// // TODO: send current stat
|
|
||||||
// }
|
|
||||||
// ws.onClose { ctx ->
|
|
||||||
// // TODO: remove from subscribers
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,139 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.animesource.model.SAnime
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
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.anime.impl.AnimeList.proxyThumbnailUrl
|
|
||||||
import suwayomi.tachidesk.anime.impl.Source.getAnimeSource
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
|
|
||||||
import suwayomi.tachidesk.anime.model.dataclass.AnimeDataClass
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeStatus
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeTable
|
|
||||||
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.server.ApplicationDirs
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
object Anime {
|
|
||||||
private fun truncate(text: String?, maxLength: Int): String? {
|
|
||||||
return if (text?.length ?: 0 > maxLength)
|
|
||||||
text?.take(maxLength - 3) + "..."
|
|
||||||
else
|
|
||||||
text
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAnime(animeId: Int, onlineFetch: Boolean = false): AnimeDataClass {
|
|
||||||
var animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
|
|
||||||
|
|
||||||
return if (animeEntry[AnimeTable.initialized] && !onlineFetch) {
|
|
||||||
AnimeDataClass(
|
|
||||||
animeId,
|
|
||||||
animeEntry[AnimeTable.sourceReference].toString(),
|
|
||||||
|
|
||||||
animeEntry[AnimeTable.url],
|
|
||||||
animeEntry[AnimeTable.title],
|
|
||||||
proxyThumbnailUrl(animeId),
|
|
||||||
|
|
||||||
true,
|
|
||||||
|
|
||||||
animeEntry[AnimeTable.artist],
|
|
||||||
animeEntry[AnimeTable.author],
|
|
||||||
animeEntry[AnimeTable.description],
|
|
||||||
animeEntry[AnimeTable.genre],
|
|
||||||
AnimeStatus.valueOf(animeEntry[AnimeTable.status]).name,
|
|
||||||
animeEntry[AnimeTable.inLibrary],
|
|
||||||
getAnimeSource(animeEntry[AnimeTable.sourceReference]),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
} else { // initialize anime
|
|
||||||
val source = getAnimeHttpSource(animeEntry[AnimeTable.sourceReference])
|
|
||||||
val fetchedAnime = source.fetchAnimeDetails(
|
|
||||||
SAnime.create().apply {
|
|
||||||
url = animeEntry[AnimeTable.url]
|
|
||||||
title = animeEntry[AnimeTable.title]
|
|
||||||
}
|
|
||||||
).awaitSingle()
|
|
||||||
|
|
||||||
transaction {
|
|
||||||
AnimeTable.update({ AnimeTable.id eq animeId }) {
|
|
||||||
|
|
||||||
it[AnimeTable.initialized] = true
|
|
||||||
|
|
||||||
it[AnimeTable.artist] = fetchedAnime.artist
|
|
||||||
it[AnimeTable.author] = fetchedAnime.author
|
|
||||||
it[AnimeTable.description] = truncate(fetchedAnime.description, 4096)
|
|
||||||
it[AnimeTable.genre] = fetchedAnime.genre
|
|
||||||
it[AnimeTable.status] = fetchedAnime.status
|
|
||||||
if (fetchedAnime.thumbnail_url != null && fetchedAnime.thumbnail_url.orEmpty().isNotEmpty())
|
|
||||||
it[AnimeTable.thumbnail_url] = fetchedAnime.thumbnail_url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAnimeThumbnail(animeId)
|
|
||||||
|
|
||||||
animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
|
|
||||||
|
|
||||||
AnimeDataClass(
|
|
||||||
animeId,
|
|
||||||
animeEntry[AnimeTable.sourceReference].toString(),
|
|
||||||
|
|
||||||
animeEntry[AnimeTable.url],
|
|
||||||
animeEntry[AnimeTable.title],
|
|
||||||
proxyThumbnailUrl(animeId),
|
|
||||||
|
|
||||||
true,
|
|
||||||
|
|
||||||
fetchedAnime.artist,
|
|
||||||
fetchedAnime.author,
|
|
||||||
fetchedAnime.description,
|
|
||||||
fetchedAnime.genre,
|
|
||||||
AnimeStatus.valueOf(fetchedAnime.status).name,
|
|
||||||
animeEntry[AnimeTable.inLibrary],
|
|
||||||
getAnimeSource(animeEntry[AnimeTable.sourceReference]),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
|
||||||
suspend fun getAnimeThumbnail(animeId: Int): Pair<InputStream, String> {
|
|
||||||
val saveDir = applicationDirs.animeThumbnailsRoot
|
|
||||||
val fileName = animeId.toString()
|
|
||||||
|
|
||||||
return getCachedImageResponse(saveDir, fileName) {
|
|
||||||
getAnime(animeId) // make sure is initialized
|
|
||||||
|
|
||||||
val animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
|
|
||||||
|
|
||||||
val sourceId = animeEntry[AnimeTable.sourceReference]
|
|
||||||
val source = getAnimeHttpSource(sourceId)
|
|
||||||
|
|
||||||
val thumbnailUrl = animeEntry[AnimeTable.thumbnail_url]!!
|
|
||||||
|
|
||||||
source.client.newCall(
|
|
||||||
GET(thumbnailUrl, source.headers)
|
|
||||||
).await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearAnimeThumbnail(animeId: Int) {
|
|
||||||
val saveDir = applicationDirs.animeThumbnailsRoot
|
|
||||||
val fileName = animeId.toString()
|
|
||||||
|
|
||||||
clearCachedImage(saveDir, fileName)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,102 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.animesource.model.AnimesPage
|
|
||||||
import org.jetbrains.exposed.sql.insertAndGetId
|
|
||||||
import org.jetbrains.exposed.sql.select
|
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
|
|
||||||
import suwayomi.tachidesk.anime.model.dataclass.AnimeDataClass
|
|
||||||
import suwayomi.tachidesk.anime.model.dataclass.PagedAnimeListDataClass
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeStatus
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeTable
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
|
||||||
|
|
||||||
object AnimeList {
|
|
||||||
fun proxyThumbnailUrl(animeId: Int): String {
|
|
||||||
return "/api/v1/anime/anime/$animeId/thumbnail"
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAnimeList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedAnimeListDataClass {
|
|
||||||
val source = getAnimeHttpSource(sourceId)
|
|
||||||
val animesPage = if (popular) {
|
|
||||||
source.fetchPopularAnime(pageNum).awaitSingle()
|
|
||||||
} else {
|
|
||||||
if (source.supportsLatest)
|
|
||||||
source.fetchLatestUpdates(pageNum).awaitSingle()
|
|
||||||
else
|
|
||||||
throw Exception("Source $source doesn't support latest")
|
|
||||||
}
|
|
||||||
return animesPage.processEntries(sourceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun AnimesPage.processEntries(sourceId: Long): PagedAnimeListDataClass {
|
|
||||||
val animesPage = this
|
|
||||||
val animeList = transaction {
|
|
||||||
return@transaction animesPage.animes.map { anime ->
|
|
||||||
val animeEntry = AnimeTable.select { AnimeTable.url eq anime.url }.firstOrNull()
|
|
||||||
if (animeEntry == null) { // create anime entry
|
|
||||||
val animeId = AnimeTable.insertAndGetId {
|
|
||||||
it[url] = anime.url
|
|
||||||
it[title] = anime.title
|
|
||||||
|
|
||||||
it[artist] = anime.artist
|
|
||||||
it[author] = anime.author
|
|
||||||
it[description] = anime.description
|
|
||||||
it[genre] = anime.genre
|
|
||||||
it[status] = anime.status
|
|
||||||
it[thumbnail_url] = anime.thumbnail_url
|
|
||||||
|
|
||||||
it[sourceReference] = sourceId
|
|
||||||
}.value
|
|
||||||
|
|
||||||
AnimeDataClass(
|
|
||||||
animeId,
|
|
||||||
sourceId.toString(),
|
|
||||||
|
|
||||||
anime.url,
|
|
||||||
anime.title,
|
|
||||||
proxyThumbnailUrl(animeId),
|
|
||||||
|
|
||||||
anime.initialized,
|
|
||||||
|
|
||||||
anime.artist,
|
|
||||||
anime.author,
|
|
||||||
anime.description,
|
|
||||||
anime.genre,
|
|
||||||
AnimeStatus.valueOf(anime.status).name
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val animeId = animeEntry[AnimeTable.id].value
|
|
||||||
AnimeDataClass(
|
|
||||||
animeId,
|
|
||||||
sourceId.toString(),
|
|
||||||
|
|
||||||
anime.url,
|
|
||||||
anime.title,
|
|
||||||
proxyThumbnailUrl(animeId),
|
|
||||||
|
|
||||||
true,
|
|
||||||
|
|
||||||
animeEntry[AnimeTable.artist],
|
|
||||||
animeEntry[AnimeTable.author],
|
|
||||||
animeEntry[AnimeTable.description],
|
|
||||||
animeEntry[AnimeTable.genre],
|
|
||||||
AnimeStatus.valueOf(animeEntry[AnimeTable.status]).name,
|
|
||||||
animeEntry[AnimeTable.inLibrary]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return PagedAnimeListDataClass(
|
|
||||||
animeList,
|
|
||||||
animesPage.hasNextPage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,241 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.animesource.model.SAnime
|
|
||||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
|
||||||
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.anime.impl.Anime.getAnime
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
|
|
||||||
import suwayomi.tachidesk.anime.model.dataclass.EpisodeDataClass
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeTable
|
|
||||||
import suwayomi.tachidesk.anime.model.table.EpisodeTable
|
|
||||||
import suwayomi.tachidesk.anime.model.table.toDataClass
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
|
||||||
|
|
||||||
object Episode {
|
|
||||||
/** get episode list when showing an anime */
|
|
||||||
suspend fun getEpisodeList(animeId: Int, onlineFetch: Boolean?): List<EpisodeDataClass> {
|
|
||||||
return if (onlineFetch == true) {
|
|
||||||
getSourceEpisodes(animeId)
|
|
||||||
} else {
|
|
||||||
transaction {
|
|
||||||
EpisodeTable.select { EpisodeTable.anime eq animeId }.orderBy(EpisodeTable.episodeIndex to DESC)
|
|
||||||
.map {
|
|
||||||
EpisodeTable.toDataClass(it)
|
|
||||||
}
|
|
||||||
}.ifEmpty {
|
|
||||||
// If it was explicitly set to offline dont grab episodes
|
|
||||||
if (onlineFetch == null) {
|
|
||||||
getSourceEpisodes(animeId)
|
|
||||||
} else emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getSourceEpisodes(animeId: Int): List<EpisodeDataClass> {
|
|
||||||
val animeDetails = getAnime(animeId)
|
|
||||||
val source = getAnimeHttpSource(animeDetails.sourceId.toLong())
|
|
||||||
val episodeList = source.fetchEpisodeList(
|
|
||||||
SAnime.create().apply {
|
|
||||||
title = animeDetails.title
|
|
||||||
url = animeDetails.url
|
|
||||||
}
|
|
||||||
).awaitSingle()
|
|
||||||
|
|
||||||
val episodeCount = episodeList.count()
|
|
||||||
|
|
||||||
transaction {
|
|
||||||
episodeList.reversed().forEachIndexed { index, fetchedEpisode ->
|
|
||||||
val episodeEntry = EpisodeTable.select { EpisodeTable.url eq fetchedEpisode.url }.firstOrNull()
|
|
||||||
if (episodeEntry == null) {
|
|
||||||
EpisodeTable.insert {
|
|
||||||
it[url] = fetchedEpisode.url
|
|
||||||
it[name] = fetchedEpisode.name
|
|
||||||
it[date_upload] = fetchedEpisode.date_upload
|
|
||||||
it[episode_number] = fetchedEpisode.episode_number
|
|
||||||
it[scanlator] = fetchedEpisode.scanlator
|
|
||||||
|
|
||||||
it[episodeIndex] = index + 1
|
|
||||||
it[anime] = animeId
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
EpisodeTable.update({ EpisodeTable.url eq fetchedEpisode.url }) {
|
|
||||||
it[name] = fetchedEpisode.name
|
|
||||||
it[date_upload] = fetchedEpisode.date_upload
|
|
||||||
it[episode_number] = fetchedEpisode.episode_number
|
|
||||||
it[scanlator] = fetchedEpisode.scanlator
|
|
||||||
|
|
||||||
it[episodeIndex] = index + 1
|
|
||||||
it[anime] = animeId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clear any orphaned episodes that are in the db but not in `episodeList`
|
|
||||||
val dbEpisodeCount = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId }.count() }
|
|
||||||
if (dbEpisodeCount > episodeCount) { // we got some clean up due
|
|
||||||
val dbEpisodeList = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId } }
|
|
||||||
|
|
||||||
dbEpisodeList.forEach {
|
|
||||||
if (it[EpisodeTable.episodeIndex] >= episodeList.size ||
|
|
||||||
episodeList[it[EpisodeTable.episodeIndex] - 1].url != it[EpisodeTable.url]
|
|
||||||
) {
|
|
||||||
transaction {
|
|
||||||
// PageTable.deleteWhere { PageTable.episode eq it[EpisodeTable.id] }
|
|
||||||
EpisodeTable.deleteWhere { EpisodeTable.id eq it[EpisodeTable.id] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val dbEpisodeMap = transaction {
|
|
||||||
EpisodeTable.select { EpisodeTable.anime eq animeId }
|
|
||||||
.associateBy({ it[EpisodeTable.url] }, { it })
|
|
||||||
}
|
|
||||||
|
|
||||||
return episodeList.mapIndexed { index, it ->
|
|
||||||
|
|
||||||
val dbEpisode = dbEpisodeMap.getValue(it.url)
|
|
||||||
|
|
||||||
EpisodeDataClass(
|
|
||||||
it.url,
|
|
||||||
it.name,
|
|
||||||
it.date_upload,
|
|
||||||
it.episode_number,
|
|
||||||
it.scanlator,
|
|
||||||
animeId,
|
|
||||||
|
|
||||||
dbEpisode[EpisodeTable.isRead],
|
|
||||||
dbEpisode[EpisodeTable.isBookmarked],
|
|
||||||
dbEpisode[EpisodeTable.lastPageRead],
|
|
||||||
|
|
||||||
episodeCount - index,
|
|
||||||
episodeList.size
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** used to display a episode, get a episode in order to show it's video */
|
|
||||||
suspend fun getEpisode(episodeIndex: Int, animeId: Int): EpisodeDataClass {
|
|
||||||
val episode = getEpisodeList(animeId, false)
|
|
||||||
.first { it.index == episodeIndex }
|
|
||||||
|
|
||||||
val animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
|
|
||||||
val source = getAnimeHttpSource(animeEntry[AnimeTable.sourceReference])
|
|
||||||
val fetchedLinkUrl = source.fetchEpisodeLink(
|
|
||||||
SEpisode.create().also {
|
|
||||||
it.url = episode.url
|
|
||||||
it.name = episode.name
|
|
||||||
}
|
|
||||||
).awaitSingle()
|
|
||||||
|
|
||||||
return EpisodeDataClass(
|
|
||||||
episode.url,
|
|
||||||
episode.name,
|
|
||||||
episode.uploadDate,
|
|
||||||
episode.episodeNumber,
|
|
||||||
episode.scanlator,
|
|
||||||
animeId,
|
|
||||||
episode.read,
|
|
||||||
episode.bookmarked,
|
|
||||||
episode.lastPageRead,
|
|
||||||
episode.index,
|
|
||||||
episode.episodeCount,
|
|
||||||
fetchedLinkUrl
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// /** used to display a episode, get a episode in order to show it's pages */
|
|
||||||
// suspend fun getEpisode(episodeIndex: Int, animeId: Int): EpisodeDataClass {
|
|
||||||
// val episodeEntry = transaction {
|
|
||||||
// EpisodeTable.select {
|
|
||||||
// (EpisodeTable.episodeIndex eq episodeIndex) and (EpisodeTable.anime eq animeId)
|
|
||||||
// }.first()
|
|
||||||
// }
|
|
||||||
// val animeEntry = transaction { MangaTable.select { MangaTable.id eq animeId }.first() }
|
|
||||||
// val source = getAnimeHttpSource(animeEntry[MangaTable.sourceReference])
|
|
||||||
//
|
|
||||||
// val pageList = source.fetchPageList(
|
|
||||||
// SEpisode.create().apply {
|
|
||||||
// url = episodeEntry[EpisodeTable.url]
|
|
||||||
// name = episodeEntry[EpisodeTable.name]
|
|
||||||
// }
|
|
||||||
// ).awaitSingle()
|
|
||||||
//
|
|
||||||
// val episodeId = episodeEntry[EpisodeTable.id].value
|
|
||||||
// val episodeCount = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId }.count() }
|
|
||||||
//
|
|
||||||
// // update page list for this episode
|
|
||||||
// transaction {
|
|
||||||
// pageList.forEach { page ->
|
|
||||||
// val pageEntry = transaction { PageTable.select { (PageTable.episode eq episodeId) 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[episode] = episodeId
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// PageTable.update({ (PageTable.episode eq episodeId) and (PageTable.index eq page.index) }) {
|
|
||||||
// it[url] = page.url
|
|
||||||
// it[imageUrl] = page.imageUrl
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return EpisodeDataClass(
|
|
||||||
// episodeEntry[EpisodeTable.url],
|
|
||||||
// episodeEntry[EpisodeTable.name],
|
|
||||||
// episodeEntry[EpisodeTable.date_upload],
|
|
||||||
// episodeEntry[EpisodeTable.episode_number],
|
|
||||||
// episodeEntry[EpisodeTable.scanlator],
|
|
||||||
// animeId,
|
|
||||||
// episodeEntry[EpisodeTable.isRead],
|
|
||||||
// episodeEntry[EpisodeTable.isBookmarked],
|
|
||||||
// episodeEntry[EpisodeTable.lastPageRead],
|
|
||||||
//
|
|
||||||
// episodeEntry[EpisodeTable.episodeIndex],
|
|
||||||
// episodeCount.toInt(),
|
|
||||||
// pageList.count()
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
fun modifyEpisode(animeId: Int, episodeIndex: Int, isRead: Boolean?, isBookmarked: Boolean?, markPrevRead: Boolean?, lastPageRead: Int?) {
|
|
||||||
transaction {
|
|
||||||
if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
|
|
||||||
EpisodeTable.update({ (EpisodeTable.anime eq animeId) and (EpisodeTable.episodeIndex eq episodeIndex) }) { update ->
|
|
||||||
isRead?.also {
|
|
||||||
update[EpisodeTable.isRead] = it
|
|
||||||
}
|
|
||||||
isBookmarked?.also {
|
|
||||||
update[EpisodeTable.isBookmarked] = it
|
|
||||||
}
|
|
||||||
lastPageRead?.also {
|
|
||||||
update[EpisodeTable.lastPageRead] = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markPrevRead?.let {
|
|
||||||
EpisodeTable.update({ (EpisodeTable.anime eq animeId) and (EpisodeTable.episodeIndex less episodeIndex) }) {
|
|
||||||
it[EpisodeTable.isRead] = markPrevRead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,50 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.anime.impl.extension.Extension.getExtensionIconUrl
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
|
|
||||||
import suwayomi.tachidesk.anime.model.dataclass.AnimeSourceDataClass
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
|
|
||||||
|
|
||||||
object Source {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
fun getSourceList(): List<AnimeSourceDataClass> {
|
|
||||||
return transaction {
|
|
||||||
AnimeSourceTable.selectAll().map {
|
|
||||||
AnimeSourceDataClass(
|
|
||||||
it[AnimeSourceTable.id].value.toString(),
|
|
||||||
it[AnimeSourceTable.name],
|
|
||||||
it[AnimeSourceTable.lang],
|
|
||||||
getExtensionIconUrl(AnimeExtensionTable.select { AnimeExtensionTable.id eq it[AnimeSourceTable.extension] }.first()[AnimeExtensionTable.apkName]),
|
|
||||||
getAnimeHttpSource(it[AnimeSourceTable.id].value).supportsLatest
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAnimeSource(sourceId: Long): AnimeSourceDataClass {
|
|
||||||
return transaction {
|
|
||||||
val source = AnimeSourceTable.select { AnimeSourceTable.id eq sourceId }.firstOrNull()
|
|
||||||
|
|
||||||
AnimeSourceDataClass(
|
|
||||||
sourceId.toString(),
|
|
||||||
source?.get(AnimeSourceTable.name),
|
|
||||||
source?.get(AnimeSourceTable.lang),
|
|
||||||
source?.let { AnimeExtensionTable.select { AnimeExtensionTable.id eq source[AnimeSourceTable.extension] }.first()[AnimeExtensionTable.iconUrl] },
|
|
||||||
source?.let { getAnimeHttpSource(sourceId).supportsLatest }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,251 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.animesource.AnimeCatalogueSource
|
|
||||||
import eu.kanade.tachiyomi.animesource.AnimeSource
|
|
||||||
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
|
||||||
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.anime.impl.extension.ExtensionsList.extensionTableAsDataClass
|
|
||||||
import suwayomi.tachidesk.anime.impl.extension.github.ExtensionGithubApi
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.EXTENSION_FEATURE
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MAX
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MIN
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.METADATA_NSFW
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.dex2jar
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.getPackageInfo
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.getSignatureHash
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.loadExtensionSources
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.trustedSignatures
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.network.await
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
|
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
|
||||||
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 {
|
|
||||||
AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.firstOrNull()
|
|
||||||
}?.get(AnimeExtensionTable.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<AnimeCatalogueSource> = when (val instance = loadExtensionSources(jarFilePath, className)) {
|
|
||||||
is AnimeSource -> listOf(instance)
|
|
||||||
is AnimeSourceFactory -> instance.createSources()
|
|
||||||
else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}")
|
|
||||||
}.map { it as AnimeCatalogueSource }
|
|
||||||
|
|
||||||
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 (AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
|
|
||||||
AnimeExtensionTable.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
|
|
||||||
it[this.isInstalled] = true
|
|
||||||
it[this.classFQName] = className
|
|
||||||
}
|
|
||||||
|
|
||||||
val extensionId = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first()[AnimeExtensionTable.id].value
|
|
||||||
|
|
||||||
sources.forEach { httpSource ->
|
|
||||||
AnimeSourceTable.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 { AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first() }
|
|
||||||
val fileNameWithoutType = extensionRecord[AnimeExtensionTable.apkName].substringBefore(".apk")
|
|
||||||
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
|
||||||
transaction {
|
|
||||||
val extensionId = extensionRecord[AnimeExtensionTable.id].value
|
|
||||||
|
|
||||||
AnimeSourceTable.deleteWhere { AnimeSourceTable.extension eq extensionId }
|
|
||||||
if (extensionRecord[AnimeExtensionTable.isObsolete])
|
|
||||||
AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq pkgName }
|
|
||||||
else
|
|
||||||
AnimeExtensionTable.update({ AnimeExtensionTable.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 {
|
|
||||||
AnimeExtensionTable.update({ AnimeExtensionTable.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 { AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.first() }[AnimeExtensionTable.iconUrl]
|
|
||||||
|
|
||||||
val saveDir = "${applicationDirs.extensionsRoot}/icon"
|
|
||||||
|
|
||||||
return getCachedImageResponse(saveDir, apkName) {
|
|
||||||
network.client.newCall(
|
|
||||||
GET(iconUrl)
|
|
||||||
).await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getExtensionIconUrl(apkName: String): String {
|
|
||||||
return "/api/v1/anime/extension/icon/$apkName"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,132 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.anime.impl.extension.Extension.getExtensionIconUrl
|
|
||||||
import suwayomi.tachidesk.anime.impl.extension.github.ExtensionGithubApi
|
|
||||||
import suwayomi.tachidesk.anime.impl.extension.github.OnlineExtension
|
|
||||||
import suwayomi.tachidesk.anime.model.dataclass.AnimeExtensionDataClass
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
|
|
||||||
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<AnimeExtensionDataClass> {
|
|
||||||
// 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 {
|
|
||||||
AnimeExtensionTable.selectAll().map {
|
|
||||||
AnimeExtensionDataClass(
|
|
||||||
it[AnimeExtensionTable.apkName],
|
|
||||||
getExtensionIconUrl(it[AnimeExtensionTable.apkName]),
|
|
||||||
it[AnimeExtensionTable.name],
|
|
||||||
it[AnimeExtensionTable.pkgName],
|
|
||||||
it[AnimeExtensionTable.versionName],
|
|
||||||
it[AnimeExtensionTable.versionCode],
|
|
||||||
it[AnimeExtensionTable.lang],
|
|
||||||
it[AnimeExtensionTable.isNsfw],
|
|
||||||
it[AnimeExtensionTable.isInstalled],
|
|
||||||
it[AnimeExtensionTable.hasUpdate],
|
|
||||||
it[AnimeExtensionTable.isObsolete],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) {
|
|
||||||
transaction {
|
|
||||||
foundExtensions.forEach { foundExtension ->
|
|
||||||
val extensionRecord = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq foundExtension.pkgName }.firstOrNull()
|
|
||||||
if (extensionRecord != null) {
|
|
||||||
if (extensionRecord[AnimeExtensionTable.isInstalled]) {
|
|
||||||
when {
|
|
||||||
foundExtension.versionCode > extensionRecord[AnimeExtensionTable.versionCode] -> {
|
|
||||||
// there is an update
|
|
||||||
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) {
|
|
||||||
it[hasUpdate] = true
|
|
||||||
}
|
|
||||||
updateMap.putIfAbsent(foundExtension.pkgName, foundExtension)
|
|
||||||
}
|
|
||||||
foundExtension.versionCode < extensionRecord[AnimeExtensionTable.versionCode] -> {
|
|
||||||
// some how the user installed an invalid version
|
|
||||||
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) {
|
|
||||||
it[isObsolete] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// extension is not installed so we can overwrite the data without a care
|
|
||||||
AnimeExtensionTable.update({ AnimeExtensionTable.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
|
|
||||||
AnimeExtensionTable.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
|
|
||||||
AnimeExtensionTable.selectAll().forEach { extensionRecord ->
|
|
||||||
val foundExtension = foundExtensions.find { it.pkgName == extensionRecord[AnimeExtensionTable.pkgName] }
|
|
||||||
if (foundExtension == null) {
|
|
||||||
// not in the repo, so this extensions is obsolete
|
|
||||||
if (extensionRecord[AnimeExtensionTable.isInstalled]) {
|
|
||||||
// is installed so we should mark it as obsolete
|
|
||||||
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq extensionRecord[AnimeExtensionTable.pkgName] }) {
|
|
||||||
it[isObsolete] = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// is not installed so we can remove the record without a care
|
|
||||||
AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq extensionRecord[AnimeExtensionTable.pkgName] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,79 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.anime.impl.util.PackageTools.LIB_VERSION_MAX
|
|
||||||
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MIN
|
|
||||||
import suwayomi.tachidesk.anime.model.dataclass.AnimeExtensionDataClass
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.network.UnzippingInterceptor
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
object ExtensionGithubApi {
|
|
||||||
private const val BASE_URL = "https://raw.githubusercontent.com"
|
|
||||||
private const val REPO_URL_PREFIX = "$BASE_URL/jmir1/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('.').toInt()
|
|
||||||
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: AnimeExtensionDataClass): 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(): 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
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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
|
|
||||||
)
|
|
@ -1,57 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.animesource.AnimeSource
|
|
||||||
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
|
|
||||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
|
||||||
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.anime.impl.util.PackageTools.loadExtensionSources
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
|
|
||||||
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
|
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
object GetAnimeHttpSource {
|
|
||||||
private val sourceCache = ConcurrentHashMap<Long, AnimeHttpSource>()
|
|
||||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
|
||||||
|
|
||||||
fun getAnimeHttpSource(sourceId: Long): AnimeHttpSource {
|
|
||||||
val cachedResult: AnimeHttpSource? = sourceCache[sourceId]
|
|
||||||
if (cachedResult != null) {
|
|
||||||
return cachedResult
|
|
||||||
}
|
|
||||||
|
|
||||||
val sourceRecord = transaction {
|
|
||||||
AnimeSourceTable.select { AnimeSourceTable.id eq sourceId }.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
val extensionId = sourceRecord[AnimeSourceTable.extension]
|
|
||||||
val extensionRecord = transaction {
|
|
||||||
AnimeExtensionTable.select { AnimeExtensionTable.id eq extensionId }.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
val apkName = extensionRecord[AnimeExtensionTable.apkName]
|
|
||||||
val className = extensionRecord[AnimeExtensionTable.classFQName]
|
|
||||||
val jarName = apkName.substringBefore(".apk") + ".jar"
|
|
||||||
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
|
|
||||||
|
|
||||||
when (val instance = loadExtensionSources(jarPath, className)) {
|
|
||||||
is AnimeSource -> listOf(instance)
|
|
||||||
is AnimeSourceFactory -> instance.createSources()
|
|
||||||
else -> throw Exception("Unknown source class type! ${instance.javaClass}")
|
|
||||||
}.forEach {
|
|
||||||
sourceCache[it.id] = it as AnimeHttpSource
|
|
||||||
}
|
|
||||||
return sourceCache[sourceId]!!
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,145 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.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.animeextension"
|
|
||||||
const val METADATA_SOURCE_CLASS = "tachiyomi.animeextension.class"
|
|
||||||
const val METADATA_SOURCE_FACTORY = "tachiyomi.animeextension.factory"
|
|
||||||
const val METADATA_NSFW = "tachiyomi.animeextension.nsfw"
|
|
||||||
const val LIB_VERSION_MIN = 10
|
|
||||||
const val LIB_VERSION_MAX = 10
|
|
||||||
|
|
||||||
private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" // jmir1's key
|
|
||||||
var trustedSignatures = mutableSetOf<String>() + officialSignature
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.anime.model.table.AnimeStatus
|
|
||||||
|
|
||||||
data class AnimeDataClass(
|
|
||||||
val id: Int,
|
|
||||||
val sourceId: String,
|
|
||||||
|
|
||||||
val url: String,
|
|
||||||
val title: String,
|
|
||||||
val thumbnailUrl: String? = null,
|
|
||||||
|
|
||||||
val initialized: Boolean = false,
|
|
||||||
|
|
||||||
val artist: String? = null,
|
|
||||||
val author: String? = null,
|
|
||||||
val description: String? = null,
|
|
||||||
val genre: String? = null,
|
|
||||||
val status: String = AnimeStatus.UNKNOWN.name,
|
|
||||||
val inLibrary: Boolean = false,
|
|
||||||
val source: AnimeSourceDataClass? = null,
|
|
||||||
|
|
||||||
val freshData: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PagedAnimeListDataClass(
|
|
||||||
val mangaList: List<AnimeDataClass>,
|
|
||||||
val hasNextPage: Boolean
|
|
||||||
)
|
|
@ -1,24 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 AnimeExtensionDataClass(
|
|
||||||
val apkName: String,
|
|
||||||
val iconUrl: String,
|
|
||||||
|
|
||||||
val name: String,
|
|
||||||
val pkgName: String,
|
|
||||||
val versionName: String,
|
|
||||||
val versionCode: Int,
|
|
||||||
val lang: String,
|
|
||||||
val isNsfw: Boolean,
|
|
||||||
|
|
||||||
val installed: Boolean,
|
|
||||||
val hasUpdate: Boolean,
|
|
||||||
val obsolete: Boolean,
|
|
||||||
)
|
|
@ -1,16 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 AnimeSourceDataClass(
|
|
||||||
val id: String,
|
|
||||||
val name: String?,
|
|
||||||
val lang: String?,
|
|
||||||
val iconUrl: String?,
|
|
||||||
val supportsLatest: Boolean?
|
|
||||||
)
|
|
@ -1,35 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 EpisodeDataClass(
|
|
||||||
val url: String,
|
|
||||||
val name: String,
|
|
||||||
val uploadDate: Long,
|
|
||||||
val episodeNumber: Float,
|
|
||||||
val scanlator: String?,
|
|
||||||
val animeId: Int,
|
|
||||||
|
|
||||||
/** chapter is read */
|
|
||||||
val read: Boolean,
|
|
||||||
|
|
||||||
/** chapter is bookmarked */
|
|
||||||
val bookmarked: Boolean,
|
|
||||||
|
|
||||||
/** last read page, zero means not read/no data */
|
|
||||||
val lastPageRead: Int,
|
|
||||||
|
|
||||||
/** this chapter's index, starts with 1 */
|
|
||||||
val index: Int,
|
|
||||||
|
|
||||||
/** total episode count, used to calculate if there's a next and prev episode */
|
|
||||||
val episodeCount: Int? = null,
|
|
||||||
|
|
||||||
/** used to construct pages in the front-end */
|
|
||||||
val linkUrl: String? = null,
|
|
||||||
)
|
|
@ -1,31 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IntIdTable
|
|
||||||
|
|
||||||
object AnimeExtensionTable : IntIdTable() {
|
|
||||||
val apkName = varchar("apk_name", 1024)
|
|
||||||
|
|
||||||
// default is the local source icon from tachiyomi
|
|
||||||
val iconUrl = varchar("icon_url", 2048)
|
|
||||||
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
|
|
||||||
|
|
||||||
val name = varchar("name", 128)
|
|
||||||
val pkgName = varchar("pkg_name", 128)
|
|
||||||
val versionName = varchar("version_name", 16)
|
|
||||||
val versionCode = integer("version_code")
|
|
||||||
val lang = varchar("lang", 10)
|
|
||||||
val isNsfw = bool("is_nsfw")
|
|
||||||
|
|
||||||
val isInstalled = bool("is_installed").default(false)
|
|
||||||
val hasUpdate = bool("has_update").default(false)
|
|
||||||
val isObsolete = bool("is_obsolete").default(false)
|
|
||||||
|
|
||||||
val classFQName = varchar("class_name", 1024).default("") // fully qualified name
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IdTable
|
|
||||||
|
|
||||||
object AnimeSourceTable : IdTable<Long>() {
|
|
||||||
override val id = long("id").entityId()
|
|
||||||
val name = varchar("name", 128)
|
|
||||||
val lang = varchar("lang", 10)
|
|
||||||
val extension = reference("extension", AnimeExtensionTable)
|
|
||||||
val partOfFactorySource = bool("part_of_factory_source").default(false)
|
|
||||||
}
|
|
@ -1,65 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.animesource.model.SAnime
|
|
||||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
|
||||||
import org.jetbrains.exposed.sql.ResultRow
|
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaStatus.Companion
|
|
||||||
|
|
||||||
object AnimeTable : IntIdTable() {
|
|
||||||
val url = varchar("url", 2048)
|
|
||||||
val title = varchar("title", 512)
|
|
||||||
val initialized = bool("initialized").default(false)
|
|
||||||
|
|
||||||
val artist = varchar("artist", 64).nullable()
|
|
||||||
val author = varchar("author", 64).nullable()
|
|
||||||
val description = varchar("description", 4096).nullable()
|
|
||||||
val genre = varchar("genre", 1024).nullable()
|
|
||||||
|
|
||||||
val status = integer("status").default(SAnime.UNKNOWN)
|
|
||||||
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
|
|
||||||
|
|
||||||
val inLibrary = bool("in_library").default(false)
|
|
||||||
val defaultCategory = bool("default_category").default(true)
|
|
||||||
|
|
||||||
// source is used by some ancestor of IntIdTable
|
|
||||||
val sourceReference = long("source")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun AnimeTable.toDataClass(mangaEntry: ResultRow) =
|
|
||||||
MangaDataClass(
|
|
||||||
mangaEntry[this.id].value,
|
|
||||||
mangaEntry[sourceReference].toString(),
|
|
||||||
|
|
||||||
mangaEntry[url],
|
|
||||||
mangaEntry[title],
|
|
||||||
proxyThumbnailUrl(mangaEntry[this.id].value),
|
|
||||||
|
|
||||||
mangaEntry[initialized],
|
|
||||||
|
|
||||||
mangaEntry[artist],
|
|
||||||
mangaEntry[author],
|
|
||||||
mangaEntry[description],
|
|
||||||
mangaEntry[genre],
|
|
||||||
Companion.valueOf(mangaEntry[status]).name,
|
|
||||||
mangaEntry[inLibrary]
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class AnimeStatus(val status: Int) {
|
|
||||||
UNKNOWN(0),
|
|
||||||
ONGOING(1),
|
|
||||||
COMPLETED(2),
|
|
||||||
LICENSED(3);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun valueOf(value: Int): AnimeStatus = values().find { it.status == value } ?: UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
package suwayomi.tachidesk.anime.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IntIdTable
|
|
||||||
import org.jetbrains.exposed.sql.ResultRow
|
|
||||||
import org.jetbrains.exposed.sql.select
|
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
|
||||||
import suwayomi.tachidesk.anime.model.dataclass.EpisodeDataClass
|
|
||||||
|
|
||||||
object EpisodeTable : IntIdTable() {
|
|
||||||
val url = varchar("url", 2048)
|
|
||||||
val name = varchar("name", 512)
|
|
||||||
val date_upload = long("date_upload").default(0)
|
|
||||||
val episode_number = float("episode_number").default(-1f)
|
|
||||||
val scanlator = varchar("scanlator", 128).nullable()
|
|
||||||
|
|
||||||
val isRead = bool("read").default(false)
|
|
||||||
val isBookmarked = bool("bookmark").default(false)
|
|
||||||
val lastPageRead = integer("last_page_read").default(0)
|
|
||||||
|
|
||||||
// index is reserved by a function
|
|
||||||
val episodeIndex = integer("index")
|
|
||||||
|
|
||||||
val anime = reference("anime", AnimeTable)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun EpisodeTable.toDataClass(episodeEntry: ResultRow) =
|
|
||||||
EpisodeDataClass(
|
|
||||||
episodeEntry[url],
|
|
||||||
episodeEntry[name],
|
|
||||||
episodeEntry[date_upload],
|
|
||||||
episodeEntry[episode_number],
|
|
||||||
episodeEntry[scanlator],
|
|
||||||
episodeEntry[anime].value,
|
|
||||||
episodeEntry[isRead],
|
|
||||||
episodeEntry[isBookmarked],
|
|
||||||
episodeEntry[lastPageRead],
|
|
||||||
episodeEntry[episodeIndex],
|
|
||||||
transaction { EpisodeTable.select { anime eq episodeEntry[anime] }.count().toInt() }
|
|
||||||
)
|
|
@ -1,466 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.Javalin
|
|
||||||
import suwayomi.tachidesk.manga.impl.Category
|
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga.addMangaToCategory
|
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga.getCategoryMangaList
|
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga.getMangaCategories
|
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga.removeMangaFromCategory
|
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter.getChapter
|
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter.getChapterList
|
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter.modifyChapter
|
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter.modifyChapterMeta
|
|
||||||
import suwayomi.tachidesk.manga.impl.Library.addMangaToLibrary
|
|
||||||
import suwayomi.tachidesk.manga.impl.Library.getLibraryMangas
|
|
||||||
import suwayomi.tachidesk.manga.impl.Library.removeMangaFromLibrary
|
|
||||||
import suwayomi.tachidesk.manga.impl.Manga.getManga
|
|
||||||
import suwayomi.tachidesk.manga.impl.Manga.getMangaThumbnail
|
|
||||||
import suwayomi.tachidesk.manga.impl.Manga.modifyMangaMeta
|
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.getMangaList
|
|
||||||
import suwayomi.tachidesk.manga.impl.Page.getPageImage
|
|
||||||
import suwayomi.tachidesk.manga.impl.Search.sourceFilters
|
|
||||||
import suwayomi.tachidesk.manga.impl.Search.sourceGlobalSearch
|
|
||||||
import suwayomi.tachidesk.manga.impl.Search.sourceSearch
|
|
||||||
import suwayomi.tachidesk.manga.impl.Source.getSource
|
|
||||||
import suwayomi.tachidesk.manga.impl.Source.getSourceList
|
|
||||||
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
|
||||||
import suwayomi.tachidesk.manga.impl.backup.legacy.LegacyBackupExport.createLegacyBackup
|
|
||||||
import suwayomi.tachidesk.manga.impl.backup.legacy.LegacyBackupImport.restoreLegacyBackup
|
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIcon
|
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
|
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
|
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.updateExtension
|
|
||||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.getExtensionList
|
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
|
||||||
import suwayomi.tachidesk.server.impl.About
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
object TachideskAPI {
|
|
||||||
fun defineEndpoints(app: Javalin) {
|
|
||||||
// list all extensions
|
|
||||||
app.get("/api/v1/extension/list") { ctx ->
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
getExtensionList()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// install extension identified with "pkgName"
|
|
||||||
app.get("/api/v1/extension/install/:pkgName") { ctx ->
|
|
||||||
val pkgName = ctx.pathParam("pkgName")
|
|
||||||
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
installExtension(pkgName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// update extension identified with "pkgName"
|
|
||||||
app.get("/api/v1/extension/update/:pkgName") { ctx ->
|
|
||||||
val pkgName = ctx.pathParam("pkgName")
|
|
||||||
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
updateExtension(pkgName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// uninstall extension identified with "pkgName"
|
|
||||||
app.get("/api/v1/extension/uninstall/:pkgName") { ctx ->
|
|
||||||
val pkgName = ctx.pathParam("pkgName")
|
|
||||||
|
|
||||||
uninstallExtension(pkgName)
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// icon for extension named `apkName`
|
|
||||||
app.get("/api/v1/extension/icon/:apkName") { ctx -> // TODO: move to pkgName
|
|
||||||
val apkName = ctx.pathParam("apkName")
|
|
||||||
|
|
||||||
ctx.result(
|
|
||||||
future { getExtensionIcon(apkName) }
|
|
||||||
.thenApply {
|
|
||||||
ctx.header("content-type", it.second)
|
|
||||||
it.first
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// list of sources
|
|
||||||
app.get("/api/v1/source/list") { ctx ->
|
|
||||||
ctx.json(getSourceList())
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch source with id `sourceId`
|
|
||||||
app.get("/api/v1/source/:sourceId") { ctx ->
|
|
||||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
ctx.json(getSource(sourceId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// popular mangas from source with id `sourceId`
|
|
||||||
app.get("/api/v1/source/:sourceId/popular/:pageNum") { ctx ->
|
|
||||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
val pageNum = ctx.pathParam("pageNum").toInt()
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
getMangaList(sourceId, pageNum, popular = true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// latest mangas from source with id `sourceId`
|
|
||||||
app.get("/api/v1/source/:sourceId/latest/:pageNum") { ctx ->
|
|
||||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
val pageNum = ctx.pathParam("pageNum").toInt()
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
getMangaList(sourceId, pageNum, popular = false)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// get manga info
|
|
||||||
app.get("/api/v1/manga/:mangaId/") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
|
|
||||||
|
|
||||||
ctx.json(
|
|
||||||
future {
|
|
||||||
getManga(mangaId, onlineFetch)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// manga thumbnail
|
|
||||||
app.get("api/v1/manga/:mangaId/thumbnail") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
ctx.result(
|
|
||||||
future { getMangaThumbnail(mangaId) }
|
|
||||||
.thenApply {
|
|
||||||
ctx.header("content-type", it.second)
|
|
||||||
it.first
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// list manga's categories
|
|
||||||
app.get("api/v1/manga/:mangaId/category/") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
ctx.json(getMangaCategories(mangaId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// adds the manga to category
|
|
||||||
app.get("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
addMangaToCategory(mangaId, categoryId)
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removes the manga from the category
|
|
||||||
app.delete("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
removeMangaFromCategory(mangaId, categoryId)
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// get chapter list when showing a manga
|
|
||||||
app.get("/api/v1/manga/:mangaId/chapters") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean()
|
|
||||||
|
|
||||||
ctx.json(future { getChapterList(mangaId, onlineFetch) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// used to modify a manga's meta paramaters
|
|
||||||
app.patch("/api/v1/manga/:mangaId/meta") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
val key = ctx.formParam("key")!!
|
|
||||||
val value = ctx.formParam("value")!!
|
|
||||||
|
|
||||||
modifyMangaMeta(mangaId, key, value)
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// used to display a chapter, get a chapter in order to show it's pages
|
|
||||||
app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx ->
|
|
||||||
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
ctx.json(future { getChapter(chapterIndex, mangaId) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// used to modify a chapter's parameters
|
|
||||||
app.patch("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx ->
|
|
||||||
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
val read = ctx.formParam("read")?.toBoolean()
|
|
||||||
val bookmarked = ctx.formParam("bookmarked")?.toBoolean()
|
|
||||||
val markPrevRead = ctx.formParam("markPrevRead")?.toBoolean()
|
|
||||||
val lastPageRead = ctx.formParam("lastPageRead")?.toInt()
|
|
||||||
|
|
||||||
modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead)
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// used to modify a chapter's meta paramaters
|
|
||||||
app.patch("/api/v1/manga/:mangaId/chapter/:chapterIndex/meta") { ctx ->
|
|
||||||
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
val key = ctx.formParam("key")!!
|
|
||||||
val value = ctx.formParam("value")!!
|
|
||||||
|
|
||||||
modifyChapterMeta(mangaId, chapterIndex, key, value)
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// get page at index "index"
|
|
||||||
app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
|
||||||
val index = ctx.pathParam("index").toInt()
|
|
||||||
|
|
||||||
ctx.result(
|
|
||||||
future { getPageImage(mangaId, chapterIndex, index) }
|
|
||||||
.thenApply {
|
|
||||||
ctx.header("content-type", it.second)
|
|
||||||
it.first
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// submit a chapter for download
|
|
||||||
app.put("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
// cancel a chapter download
|
|
||||||
app.delete("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
// global search, Not implemented yet
|
|
||||||
app.get("/api/v1/search/:searchTerm") { ctx ->
|
|
||||||
val searchTerm = ctx.pathParam("searchTerm")
|
|
||||||
ctx.json(sourceGlobalSearch(searchTerm))
|
|
||||||
}
|
|
||||||
|
|
||||||
// single source search
|
|
||||||
app.get("/api/v1/source/:sourceId/search/:searchTerm/:pageNum") { ctx ->
|
|
||||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
val searchTerm = ctx.pathParam("searchTerm")
|
|
||||||
val pageNum = ctx.pathParam("pageNum").toInt()
|
|
||||||
ctx.json(future { sourceSearch(sourceId, searchTerm, pageNum) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// source filter list
|
|
||||||
app.get("/api/v1/source/:sourceId/filters/") { ctx ->
|
|
||||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
|
||||||
ctx.json(sourceFilters(sourceId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// adds the manga to library
|
|
||||||
app.get("api/v1/manga/:mangaId/library") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
ctx.result(
|
|
||||||
future { addMangaToLibrary(mangaId) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removes the manga from the library
|
|
||||||
app.delete("api/v1/manga/:mangaId/library") { ctx ->
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
ctx.result(
|
|
||||||
future { removeMangaFromLibrary(mangaId) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lists mangas that have no category assigned
|
|
||||||
app.get("/api/v1/library/") { ctx ->
|
|
||||||
ctx.json(getLibraryMangas())
|
|
||||||
}
|
|
||||||
|
|
||||||
// category list
|
|
||||||
app.get("/api/v1/category/") { ctx ->
|
|
||||||
ctx.json(Category.getCategoryList())
|
|
||||||
}
|
|
||||||
|
|
||||||
// category create
|
|
||||||
app.post("/api/v1/category/") { ctx ->
|
|
||||||
val name = ctx.formParam("name")!!
|
|
||||||
Category.createCategory(name)
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// returns some static info of the current app build
|
|
||||||
app.get("/api/v1/about/") { ctx ->
|
|
||||||
ctx.json(About.getAbout())
|
|
||||||
}
|
|
||||||
|
|
||||||
// category modification
|
|
||||||
app.patch("/api/v1/category/:categoryId") { ctx ->
|
|
||||||
val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
val name = ctx.formParam("name")
|
|
||||||
val isDefault = ctx.formParam("default")?.toBoolean()
|
|
||||||
Category.updateCategory(categoryId, name, isDefault)
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// category re-ordering
|
|
||||||
app.patch("/api/v1/category/:categoryId/reorder") { ctx ->
|
|
||||||
val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
val from = ctx.formParam("from")!!.toInt()
|
|
||||||
val to = ctx.formParam("to")!!.toInt()
|
|
||||||
Category.reorderCategory(categoryId, from, to)
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// category delete
|
|
||||||
app.delete("/api/v1/category/:categoryId") { ctx ->
|
|
||||||
val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
Category.removeCategory(categoryId)
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// returns the manga list associated with a category
|
|
||||||
app.get("/api/v1/category/:categoryId") { ctx ->
|
|
||||||
val categoryId = ctx.pathParam("categoryId").toInt()
|
|
||||||
ctx.json(getCategoryMangaList(categoryId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// expects a Tachiyomi legacy backup json in the body
|
|
||||||
app.post("/api/v1/backup/legacy/import") { ctx ->
|
|
||||||
ctx.result(
|
|
||||||
future {
|
|
||||||
restoreLegacyBackup(ctx.bodyAsInputStream())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// expects a Tachiyomi legacy backup json as a file upload, the file must be named "backup.json"
|
|
||||||
app.post("/api/v1/backup/legacy/import/file") { ctx ->
|
|
||||||
ctx.result(
|
|
||||||
future {
|
|
||||||
restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// returns a Tachiyomi legacy backup json created from the current database as a json body
|
|
||||||
app.get("/api/v1/backup/legacy/export") { ctx ->
|
|
||||||
ctx.contentType("application/json")
|
|
||||||
ctx.result(
|
|
||||||
future {
|
|
||||||
createLegacyBackup(
|
|
||||||
BackupFlags(
|
|
||||||
includeManga = true,
|
|
||||||
includeCategories = true,
|
|
||||||
includeChapters = true,
|
|
||||||
includeTracking = true,
|
|
||||||
includeHistory = true,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// returns a Tachiyomi legacy backup json created from the current database as a file
|
|
||||||
app.get("/api/v1/backup/legacy/export/file") { ctx ->
|
|
||||||
ctx.contentType("application/json")
|
|
||||||
val sdf = SimpleDateFormat("yyyy-MM-dd_HH-mm")
|
|
||||||
val currentDate = sdf.format(Date())
|
|
||||||
|
|
||||||
ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"")
|
|
||||||
ctx.result(
|
|
||||||
future {
|
|
||||||
createLegacyBackup(
|
|
||||||
BackupFlags(
|
|
||||||
includeManga = true,
|
|
||||||
includeCategories = true,
|
|
||||||
includeChapters = true,
|
|
||||||
includeTracking = true,
|
|
||||||
includeHistory = true,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download queue stats
|
|
||||||
app.ws("/api/v1/downloads") { ws ->
|
|
||||||
ws.onConnect { ctx ->
|
|
||||||
DownloadManager.addClient(ctx)
|
|
||||||
DownloadManager.notifyClient(ctx)
|
|
||||||
}
|
|
||||||
ws.onMessage { ctx ->
|
|
||||||
DownloadManager.handleRequest(ctx)
|
|
||||||
}
|
|
||||||
ws.onClose { ctx ->
|
|
||||||
DownloadManager.removeClient(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the downloader
|
|
||||||
app.get("/api/v1/downloads/start") { ctx ->
|
|
||||||
DownloadManager.start()
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop the downloader
|
|
||||||
app.get("/api/v1/downloads/stop") { ctx ->
|
|
||||||
DownloadManager.stop()
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clear download queue
|
|
||||||
app.get("/api/v1/downloads/clear") { ctx ->
|
|
||||||
DownloadManager.clear()
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queue chapter for download
|
|
||||||
app.get("/api/v1/download/:mangaId/chapter/:chapterIndex") { ctx ->
|
|
||||||
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
DownloadManager.enqueue(chapterIndex, mangaId)
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete chapter from download queue
|
|
||||||
app.delete("/api/v1/download/:mangaId/chapter/:chapterIndex") { ctx ->
|
|
||||||
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
|
||||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
|
||||||
|
|
||||||
DownloadManager.unqueue(chapterIndex, mangaId)
|
|
||||||
|
|
||||||
ctx.status(200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,78 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,72 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,263 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,68 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,171 +0,0 @@
|
|||||||
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.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 suwayomi.tachidesk.server.ApplicationDirs
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,104 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,95 +0,0 @@
|
|||||||
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.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 suwayomi.tachidesk.server.ApplicationDirs
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
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
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
@ -1,50 +0,0 @@
|
|||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
@ -1,45 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,154 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,210 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.impl.backup.legacy.models
|
|
||||||
|
|
||||||
data class DHistory(val url: String, val lastRead: Long)
|
|
@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
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 <--
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
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!!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.impl.backup.models
|
|
||||||
|
|
||||||
class LibraryManga : MangaImpl() {
|
|
||||||
|
|
||||||
var unread: Int = 0
|
|
||||||
|
|
||||||
var category: Int = 0
|
|
||||||
}
|
|
@ -1,116 +0,0 @@
|
|||||||
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
|
|
||||||
// )
|
|
||||||
// }
|
|
@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.impl.backup.models
|
|
||||||
|
|
||||||
class MangaChapter(val manga: Manga, val chapter: Chapter)
|
|
@ -1,10 +0,0 @@
|
|||||||
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)
|
|
@ -1,79 +0,0 @@
|
|||||||
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 <--
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,129 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
@ -1,81 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
@ -1,15 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
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>,
|
|
||||||
)
|
|
@ -7,88 +7,28 @@ package suwayomi.tachidesk.manga.impl.extension
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import 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.CatalogueSource
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
import mu.KotlinLogging
|
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.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.EXTENSION_FEATURE
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
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.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.METADATA_SOURCE_CLASS
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
|
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.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 suwayomi.tachidesk.server.ApplicationDirs
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
object Extension {
|
object Extension {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
|
||||||
|
|
||||||
data class InstallableAPK(
|
suspend fun installAPK(tmpDir: File, fetcher: suspend () -> File): Pair<String, List<CatalogueSource>> {
|
||||||
val apkFilePath: String,
|
val apkFile = fetcher()
|
||||||
val pkgName: String
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun installExtension(pkgName: String): Int {
|
val jarFile = File(tmpDir, "${apkFile.nameWithoutExtension}.jar")
|
||||||
logger.debug("Installing $pkgName")
|
|
||||||
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
|
|
||||||
|
|
||||||
return installAPK {
|
val packageInfo = getPackageInfo(apkFile.absolutePath)
|
||||||
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 }) {
|
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
|
||||||
throw Exception("This apk is not a Tachiyomi extension")
|
throw Exception("This apk is not a Tachiyomi extension")
|
||||||
@ -103,149 +43,26 @@ object Extension {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val signatureHash = getSignatureHash(packageInfo)
|
/*val signatureHash = getSignatureHash(packageInfo)
|
||||||
|
|
||||||
if (signatureHash == null) {
|
if (signatureHash == null) {
|
||||||
throw Exception("Package $pkgName isn't signed")
|
throw Exception("Package $pkgName isn't signed")
|
||||||
} else if (signatureHash !in trustedSignatures) {
|
} else if (signatureHash !in trustedSignatures) {
|
||||||
// TODO: allow trusting keys
|
// TODO: allow trusting keys
|
||||||
throw Exception("This apk is not a signed with the official tachiyomi signature")
|
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)
|
val className = packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
||||||
|
|
||||||
logger.debug("Main class for extension is $className")
|
logger.debug("Main class for extension is $className")
|
||||||
|
|
||||||
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
dex2jar(apkFile, jarFile)
|
||||||
|
|
||||||
// clean up
|
|
||||||
// File(apkFilePath).delete()
|
|
||||||
File(dexFilePath).delete()
|
|
||||||
|
|
||||||
// collect sources from the extension
|
// collect sources from the extension
|
||||||
val sources: List<CatalogueSource> = when (val instance = loadExtensionSources(jarFilePath, className)) {
|
return packageInfo.packageName to when (val instance = loadExtensionSources(jarFile.absolutePath, className)) {
|
||||||
is Source -> listOf(instance)
|
is Source -> listOf(instance)
|
||||||
is SourceFactory -> instance.createSources()
|
is SourceFactory -> instance.createSources()
|
||||||
else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}")
|
else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}")
|
||||||
}.map { it as CatalogueSource }
|
}.filterIsInstance<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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,132 +0,0 @@
|
|||||||
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] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,76 +0,0 @@
|
|||||||
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.model.dataclass.ExtensionDataClass
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
object ExtensionGithubApi {
|
|
||||||
private const val BASE_URL = "https://raw.githubusercontent.com"
|
|
||||||
private 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-Type", "application/json")
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getRepo(): JsonArray {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$REPO_URL_PREFIX/index.min.json")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val response = client.newCall(request).execute().use { response -> response.body!!.string() }
|
|
||||||
return JsonParser.parseString(response).asJsonArray
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
@ -1,57 +0,0 @@
|
|||||||
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.manga.impl.util.PackageTools.loadExtensionSources
|
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
|
||||||
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]!!
|
|
||||||
}
|
|
||||||
}
|
|
@ -17,12 +17,8 @@ import eu.kanade.tachiyomi.util.lang.Hash
|
|||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import net.dongliu.apk.parser.ApkFile
|
import net.dongliu.apk.parser.ApkFile
|
||||||
import net.dongliu.apk.parser.ApkParsers
|
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.Element
|
||||||
import org.w3c.dom.Node
|
import org.w3c.dom.Node
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
|
||||||
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
|
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
|
||||||
import xyz.nulldev.androidcompat.pm.toPackageInfo
|
import xyz.nulldev.androidcompat.pm.toPackageInfo
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -34,7 +30,6 @@ import javax.xml.parsers.DocumentBuilderFactory
|
|||||||
|
|
||||||
object PackageTools {
|
object PackageTools {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
|
||||||
|
|
||||||
const val EXTENSION_FEATURE = "tachiyomi.extension"
|
const val EXTENSION_FEATURE = "tachiyomi.extension"
|
||||||
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||||
@ -50,12 +45,12 @@ object PackageTools {
|
|||||||
/**
|
/**
|
||||||
* Convert dex to jar, a wrapper for the dex2jar library
|
* Convert dex to jar, a wrapper for the dex2jar library
|
||||||
*/
|
*/
|
||||||
fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) {
|
fun dex2jar(dexFile: File, jarFile: File) {
|
||||||
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
|
// 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
|
// 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 jarFilePath = jarFile.toPath()
|
||||||
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
|
val reader = MultiDexFileReader.open(Files.readAllBytes(dexFile.toPath()))
|
||||||
val handler = BaksmaliBaseDexExceptionHandler()
|
val handler = BaksmaliBaseDexExceptionHandler()
|
||||||
Dex2jar
|
Dex2jar
|
||||||
.from(reader)
|
.from(reader)
|
||||||
@ -69,7 +64,7 @@ object PackageTools {
|
|||||||
.skipExceptions(false)
|
.skipExceptions(false)
|
||||||
.to(jarFilePath)
|
.to(jarFilePath)
|
||||||
if (handler.hasException()) {
|
if (handler.hasException()) {
|
||||||
val errorFile: Path = File(applicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt")
|
val errorFile: Path = jarFilePath.parent.resolve("${dexFile.nameWithoutExtension}-error.txt")
|
||||||
logger.error(
|
logger.error(
|
||||||
"""
|
"""
|
||||||
Detail Error Information in File $errorFile
|
Detail Error Information in File $errorFile
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
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() }
|
|
@ -1,50 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,73 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 CategoryDataClass(
|
|
||||||
val id: Int,
|
|
||||||
val order: Int,
|
|
||||||
val name: String,
|
|
||||||
val default: Boolean
|
|
||||||
)
|
|
@ -1,44 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 ChapterDataClass(
|
|
||||||
val url: String,
|
|
||||||
val name: String,
|
|
||||||
val uploadDate: Long,
|
|
||||||
val chapterNumber: Float,
|
|
||||||
val scanlator: String?,
|
|
||||||
val mangaId: Int,
|
|
||||||
|
|
||||||
/** chapter is read */
|
|
||||||
val read: Boolean,
|
|
||||||
|
|
||||||
/** chapter is bookmarked */
|
|
||||||
val bookmarked: Boolean,
|
|
||||||
|
|
||||||
/** last read page, zero means not read/no data */
|
|
||||||
val lastPageRead: Int,
|
|
||||||
|
|
||||||
/** last read page, zero means not read/no data */
|
|
||||||
val lastReadAt: Long,
|
|
||||||
|
|
||||||
/** this chapter's index, starts with 1 */
|
|
||||||
val index: Int,
|
|
||||||
|
|
||||||
/** is chapter downloaded */
|
|
||||||
val downloaded: Boolean,
|
|
||||||
|
|
||||||
/** used to construct pages in the front-end */
|
|
||||||
val pageCount: Int = -1,
|
|
||||||
|
|
||||||
/** total chapter count, used to calculate if there's a next and prev chapter */
|
|
||||||
val chapterCount: Int? = null,
|
|
||||||
|
|
||||||
/** used to store client specific values */
|
|
||||||
val meta: Map<String, String> = emptyMap(),
|
|
||||||
)
|
|
@ -1,24 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 ExtensionDataClass(
|
|
||||||
val apkName: String,
|
|
||||||
val iconUrl: String,
|
|
||||||
|
|
||||||
val name: String,
|
|
||||||
val pkgName: String,
|
|
||||||
val versionName: String,
|
|
||||||
val versionCode: Int,
|
|
||||||
val lang: String,
|
|
||||||
val isNsfw: Boolean,
|
|
||||||
|
|
||||||
val installed: Boolean,
|
|
||||||
val hasUpdate: Boolean,
|
|
||||||
val obsolete: Boolean,
|
|
||||||
)
|
|
@ -1,37 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.model.table.MangaStatus
|
|
||||||
|
|
||||||
data class MangaDataClass(
|
|
||||||
val id: Int,
|
|
||||||
val sourceId: String,
|
|
||||||
|
|
||||||
val url: String,
|
|
||||||
val title: String,
|
|
||||||
val thumbnailUrl: String? = null,
|
|
||||||
|
|
||||||
val initialized: Boolean = false,
|
|
||||||
|
|
||||||
val artist: String? = null,
|
|
||||||
val author: String? = null,
|
|
||||||
val description: String? = null,
|
|
||||||
val genre: String? = null,
|
|
||||||
val status: String = MangaStatus.UNKNOWN.name,
|
|
||||||
val inLibrary: Boolean = false,
|
|
||||||
val source: SourceDataClass? = null,
|
|
||||||
val meta: Map<String, String> = emptyMap(),
|
|
||||||
|
|
||||||
val freshData: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PagedMangaListDataClass(
|
|
||||||
val mangaList: List<MangaDataClass>,
|
|
||||||
val hasNextPage: Boolean
|
|
||||||
)
|
|
@ -1,13 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 PageDataClass(
|
|
||||||
val index: Int,
|
|
||||||
var imageUrl: String,
|
|
||||||
)
|
|
@ -1,16 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 SourceDataClass(
|
|
||||||
val id: String,
|
|
||||||
val name: String?,
|
|
||||||
val lang: String?,
|
|
||||||
val iconUrl: String?,
|
|
||||||
val supportsLatest: Boolean?
|
|
||||||
)
|
|
@ -1,15 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IntIdTable
|
|
||||||
|
|
||||||
object CategoryMangaTable : IntIdTable() {
|
|
||||||
val category = reference("category", CategoryTable)
|
|
||||||
val manga = reference("manga", MangaTable)
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IntIdTable
|
|
||||||
import org.jetbrains.exposed.sql.ResultRow
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
|
||||||
|
|
||||||
object CategoryTable : IntIdTable() {
|
|
||||||
val name = varchar("name", 64)
|
|
||||||
val order = integer("order").default(0)
|
|
||||||
val isDefault = bool("is_default").default(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun CategoryTable.toDataClass(categoryEntry: ResultRow) = CategoryDataClass(
|
|
||||||
categoryEntry[this.id].value,
|
|
||||||
categoryEntry[order],
|
|
||||||
categoryEntry[name],
|
|
||||||
categoryEntry[isDefault],
|
|
||||||
)
|
|
@ -1,10 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
|
||||||
import org.jetbrains.exposed.sql.ReferenceOption
|
|
||||||
|
|
||||||
object ChapterMetaTable : IntIdTable() {
|
|
||||||
val key = varchar("key", 256)
|
|
||||||
val value = varchar("value", 4096)
|
|
||||||
val ref = reference("chapter_ref", ChapterTable, ReferenceOption.CASCADE)
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IntIdTable
|
|
||||||
import org.jetbrains.exposed.sql.ResultRow
|
|
||||||
import org.jetbrains.exposed.sql.select
|
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
|
||||||
|
|
||||||
object ChapterTable : IntIdTable() {
|
|
||||||
val url = varchar("url", 2048)
|
|
||||||
val name = varchar("name", 512)
|
|
||||||
val date_upload = long("date_upload").default(0)
|
|
||||||
val chapter_number = float("chapter_number").default(-1f)
|
|
||||||
val scanlator = varchar("scanlator", 128).nullable()
|
|
||||||
|
|
||||||
val isRead = bool("read").default(false)
|
|
||||||
val isBookmarked = bool("bookmark").default(false)
|
|
||||||
val lastPageRead = integer("last_page_read").default(0)
|
|
||||||
val lastReadAt = long("last_read_at").default(0)
|
|
||||||
|
|
||||||
// index is reserved by a function
|
|
||||||
val chapterIndex = integer("index")
|
|
||||||
|
|
||||||
val isDownloaded = bool("is_downloaded").default(false)
|
|
||||||
|
|
||||||
val pageCount = integer("page_count").default(-1)
|
|
||||||
|
|
||||||
val manga = reference("manga", MangaTable)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
|
|
||||||
ChapterDataClass(
|
|
||||||
chapterEntry[url],
|
|
||||||
chapterEntry[name],
|
|
||||||
chapterEntry[date_upload],
|
|
||||||
chapterEntry[chapter_number],
|
|
||||||
chapterEntry[scanlator],
|
|
||||||
chapterEntry[manga].value,
|
|
||||||
chapterEntry[isRead],
|
|
||||||
chapterEntry[isBookmarked],
|
|
||||||
chapterEntry[lastPageRead],
|
|
||||||
chapterEntry[lastReadAt],
|
|
||||||
chapterEntry[chapterIndex],
|
|
||||||
chapterEntry[isDownloaded],
|
|
||||||
chapterEntry[pageCount],
|
|
||||||
transaction { ChapterTable.select { manga eq chapterEntry[manga].value }.count().toInt() },
|
|
||||||
getChapterMetaMap(chapterEntry[id]),
|
|
||||||
)
|
|
@ -1,31 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IntIdTable
|
|
||||||
|
|
||||||
object ExtensionTable : IntIdTable() {
|
|
||||||
val apkName = varchar("apk_name", 1024)
|
|
||||||
|
|
||||||
// default is the local source icon from tachiyomi
|
|
||||||
val iconUrl = varchar("icon_url", 2048)
|
|
||||||
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
|
|
||||||
|
|
||||||
val name = varchar("name", 128)
|
|
||||||
val pkgName = varchar("pkg_name", 128)
|
|
||||||
val versionName = varchar("version_name", 16)
|
|
||||||
val versionCode = integer("version_code")
|
|
||||||
val lang = varchar("lang", 10)
|
|
||||||
val isNsfw = bool("is_nsfw")
|
|
||||||
|
|
||||||
val isInstalled = bool("is_installed").default(false)
|
|
||||||
val hasUpdate = bool("has_update").default(false)
|
|
||||||
val isObsolete = bool("is_obsolete").default(false)
|
|
||||||
|
|
||||||
val classFQName = varchar("class_name", 1024).default("") // fully qualified name
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
|
||||||
import org.jetbrains.exposed.sql.ReferenceOption
|
|
||||||
|
|
||||||
object MangaMetaTable : IntIdTable() {
|
|
||||||
val key = varchar("key", 256)
|
|
||||||
val value = varchar("value", 4096)
|
|
||||||
val ref = reference("manga_ref", MangaTable, ReferenceOption.CASCADE)
|
|
||||||
}
|
|
@ -1,67 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.SManga
|
|
||||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
|
||||||
import org.jetbrains.exposed.sql.ResultRow
|
|
||||||
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
|
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaStatus.Companion
|
|
||||||
|
|
||||||
object MangaTable : IntIdTable() {
|
|
||||||
val url = varchar("url", 2048)
|
|
||||||
val title = varchar("title", 512)
|
|
||||||
val initialized = bool("initialized").default(false)
|
|
||||||
|
|
||||||
val artist = varchar("artist", 64).nullable()
|
|
||||||
val author = varchar("author", 64).nullable()
|
|
||||||
val description = varchar("description", 4096).nullable()
|
|
||||||
val genre = varchar("genre", 1024).nullable()
|
|
||||||
|
|
||||||
val status = integer("status").default(SManga.UNKNOWN)
|
|
||||||
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
|
|
||||||
|
|
||||||
val inLibrary = bool("in_library").default(false)
|
|
||||||
val defaultCategory = bool("default_category").default(true)
|
|
||||||
|
|
||||||
// source is used by some ancestor of IntIdTable
|
|
||||||
val sourceReference = long("source")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
|
|
||||||
MangaDataClass(
|
|
||||||
mangaEntry[this.id].value,
|
|
||||||
mangaEntry[sourceReference].toString(),
|
|
||||||
|
|
||||||
mangaEntry[url],
|
|
||||||
mangaEntry[title],
|
|
||||||
proxyThumbnailUrl(mangaEntry[this.id].value),
|
|
||||||
|
|
||||||
mangaEntry[initialized],
|
|
||||||
|
|
||||||
mangaEntry[artist],
|
|
||||||
mangaEntry[author],
|
|
||||||
mangaEntry[description],
|
|
||||||
mangaEntry[genre],
|
|
||||||
Companion.valueOf(mangaEntry[status]).name,
|
|
||||||
mangaEntry[inLibrary],
|
|
||||||
meta = getMangaMetaMap(mangaEntry[id])
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class MangaStatus(val status: Int) {
|
|
||||||
UNKNOWN(0),
|
|
||||||
ONGOING(1),
|
|
||||||
COMPLETED(2),
|
|
||||||
LICENSED(3);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun valueOf(value: Int): MangaStatus = values().find { it.status == value } ?: UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IntIdTable
|
|
||||||
|
|
||||||
object PageTable : IntIdTable() {
|
|
||||||
val index = integer("index")
|
|
||||||
val url = varchar("url", 2048)
|
|
||||||
val imageUrl = varchar("imageUrl", 2048).nullable()
|
|
||||||
|
|
||||||
val chapter = reference("chapter", ChapterTable)
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.model.table
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.dao.id.IdTable
|
|
||||||
|
|
||||||
object SourceTable : IdTable<Long>() {
|
|
||||||
override val id = long("id").entityId()
|
|
||||||
val name = varchar("name", 128)
|
|
||||||
val lang = varchar("lang", 10)
|
|
||||||
val extension = reference("extension", ExtensionTable)
|
|
||||||
val partOfFactorySource = bool("part_of_factory_source").default(false)
|
|
||||||
}
|
|
@ -1,81 +0,0 @@
|
|||||||
package suwayomi.tachidesk.server
|
|
||||||
|
|
||||||
import io.javalin.Javalin
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.future.future
|
|
||||||
import mu.KotlinLogging
|
|
||||||
import suwayomi.tachidesk.anime.AnimeAPI
|
|
||||||
import suwayomi.tachidesk.manga.TachideskAPI
|
|
||||||
import suwayomi.tachidesk.server.util.Browser
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.CompletableFuture
|
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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/. */
|
|
||||||
|
|
||||||
object JavalinSetup {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
fun <T> future(block: suspend CoroutineScope.() -> T): CompletableFuture<T> {
|
|
||||||
return scope.future(block = block)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun javalinSetup() {
|
|
||||||
var hasWebUiBundled = false
|
|
||||||
|
|
||||||
val app = Javalin.create { config ->
|
|
||||||
try {
|
|
||||||
// if the bellow line throws an exception then webUI is not bundled
|
|
||||||
this::class.java.getResource("/webUI/index.html")
|
|
||||||
|
|
||||||
// no exception so we can tell javalin to serve webUI
|
|
||||||
hasWebUiBundled = true
|
|
||||||
config.addStaticFiles("/webUI")
|
|
||||||
config.addSinglePageRoot("/", "/webUI/index.html")
|
|
||||||
} catch (e: RuntimeException) {
|
|
||||||
logger.warn("react build files are missing.")
|
|
||||||
hasWebUiBundled = false
|
|
||||||
}
|
|
||||||
config.enableCorsForAllOrigins()
|
|
||||||
}.events { event ->
|
|
||||||
event.serverStarted {
|
|
||||||
if (hasWebUiBundled && serverConfig.initialOpenInBrowserEnabled) {
|
|
||||||
Browser.openInBrowser()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.start(serverConfig.ip, serverConfig.port)
|
|
||||||
|
|
||||||
// when JVM is prompted to shutdown, stop javalin gracefully
|
|
||||||
Runtime.getRuntime().addShutdownHook(
|
|
||||||
thread(start = false) {
|
|
||||||
app.stop()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
app.exception(NullPointerException::class.java) { e, ctx ->
|
|
||||||
logger.error("NullPointerException while handling the request", e)
|
|
||||||
ctx.status(404)
|
|
||||||
}
|
|
||||||
app.exception(NoSuchElementException::class.java) { e, ctx ->
|
|
||||||
logger.error("NoSuchElementException while handling the request", e)
|
|
||||||
ctx.status(404)
|
|
||||||
}
|
|
||||||
app.exception(IOException::class.java) { e, ctx ->
|
|
||||||
logger.error("IOException while handling the request", e)
|
|
||||||
ctx.status(500)
|
|
||||||
ctx.result(e.message ?: "Internal Server Error")
|
|
||||||
}
|
|
||||||
|
|
||||||
TachideskAPI.defineEndpoints(app)
|
|
||||||
AnimeAPI.defineEndpoints(app)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
package suwayomi.tachidesk.server
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.typesafe.config.Config
|
|
||||||
import io.github.config4k.getValue
|
|
||||||
import xyz.nulldev.ts.config.ConfigModule
|
|
||||||
import xyz.nulldev.ts.config.GlobalConfigManager
|
|
||||||
import xyz.nulldev.ts.config.debugLogsEnabled
|
|
||||||
|
|
||||||
class ServerConfig(config: Config) : ConfigModule(config) {
|
|
||||||
val ip: String by config
|
|
||||||
val port: Int by config
|
|
||||||
|
|
||||||
// proxy
|
|
||||||
val socksProxyEnabled: Boolean by config
|
|
||||||
val socksProxyHost: String by config
|
|
||||||
val socksProxyPort: String by config
|
|
||||||
|
|
||||||
// misc
|
|
||||||
val debugLogsEnabled: Boolean = debugLogsEnabled(GlobalConfigManager.config)
|
|
||||||
val systemTrayEnabled: Boolean by config
|
|
||||||
val initialOpenInBrowserEnabled: Boolean by config
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun register(config: Config) = ServerConfig(config.getConfig("server"))
|
|
||||||
}
|
|
||||||
}
|
|
@ -10,66 +10,18 @@ package suwayomi.tachidesk.server
|
|||||||
import eu.kanade.tachiyomi.App
|
import eu.kanade.tachiyomi.App
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.kodein.di.DI
|
import org.kodein.di.DI
|
||||||
import org.kodein.di.bind
|
|
||||||
import org.kodein.di.conf.global
|
import org.kodein.di.conf.global
|
||||||
import org.kodein.di.singleton
|
|
||||||
import suwayomi.server.BuildConfig
|
import suwayomi.server.BuildConfig
|
||||||
import suwayomi.tachidesk.server.database.databaseUp
|
|
||||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
|
||||||
import suwayomi.tachidesk.server.util.SystemTray.systemTray
|
|
||||||
import xyz.nulldev.androidcompat.AndroidCompat
|
import xyz.nulldev.androidcompat.AndroidCompat
|
||||||
import xyz.nulldev.androidcompat.AndroidCompatInitializer
|
import xyz.nulldev.androidcompat.AndroidCompatInitializer
|
||||||
import xyz.nulldev.ts.config.ApplicationRootDir
|
|
||||||
import xyz.nulldev.ts.config.ConfigKodeinModule
|
import xyz.nulldev.ts.config.ConfigKodeinModule
|
||||||
import xyz.nulldev.ts.config.GlobalConfigManager
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
class ApplicationDirs(
|
|
||||||
val dataRoot: String = ApplicationRootDir
|
|
||||||
) {
|
|
||||||
val extensionsRoot = "$dataRoot/extensions"
|
|
||||||
val mangaThumbnailsRoot = "$dataRoot/manga-thumbnails"
|
|
||||||
val animeThumbnailsRoot = "$dataRoot/anime-thumbnails"
|
|
||||||
val mangaRoot = "$dataRoot/manga"
|
|
||||||
}
|
|
||||||
|
|
||||||
val serverConfig: ServerConfig by lazy { GlobalConfigManager.module() }
|
|
||||||
|
|
||||||
val systemTrayInstance by lazy { systemTray() }
|
|
||||||
|
|
||||||
val androidCompat by lazy { AndroidCompat() }
|
val androidCompat by lazy { AndroidCompat() }
|
||||||
|
|
||||||
fun applicationSetup() {
|
fun applicationSetup() {
|
||||||
logger.info("Running Tachidesk ${BuildConfig.VERSION} revision ${BuildConfig.REVISION}")
|
logger.info("Running Inspector ${BuildConfig.VERSION} revision ${BuildConfig.REVISION}")
|
||||||
|
|
||||||
// Application dirs
|
|
||||||
val applicationDirs = ApplicationDirs()
|
|
||||||
DI.global.addImport(
|
|
||||||
DI.Module("Server") {
|
|
||||||
bind<ApplicationDirs>() with singleton { applicationDirs }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// make dirs we need
|
|
||||||
listOf(
|
|
||||||
applicationDirs.dataRoot,
|
|
||||||
applicationDirs.extensionsRoot,
|
|
||||||
applicationDirs.extensionsRoot + "/icon",
|
|
||||||
applicationDirs.mangaThumbnailsRoot,
|
|
||||||
applicationDirs.animeThumbnailsRoot,
|
|
||||||
).forEach {
|
|
||||||
File(it).mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
// register Tachidesk's config which is dubbed "ServerConfig"
|
|
||||||
GlobalConfigManager.registerModule(
|
|
||||||
ServerConfig.register(GlobalConfigManager.config)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Make sure only one instance of the app is running
|
|
||||||
handleAppMutex()
|
|
||||||
|
|
||||||
// Load config API
|
// Load config API
|
||||||
DI.global.addImport(ConfigKodeinModule().create())
|
DI.global.addImport(ConfigKodeinModule().create())
|
||||||
@ -77,41 +29,4 @@ fun applicationSetup() {
|
|||||||
AndroidCompatInitializer().init()
|
AndroidCompatInitializer().init()
|
||||||
// start app
|
// start app
|
||||||
androidCompat.startApp(App())
|
androidCompat.startApp(App())
|
||||||
|
|
||||||
// create conf file if doesn't exist
|
|
||||||
try {
|
|
||||||
val dataConfFile = File("${applicationDirs.dataRoot}/server.conf")
|
|
||||||
if (!dataConfFile.exists()) {
|
|
||||||
JavalinSetup::class.java.getResourceAsStream("/server-reference.conf").use { input ->
|
|
||||||
dataConfFile.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error("Exception while creating initial server.conf:\n", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
databaseUp()
|
|
||||||
|
|
||||||
// create system tray
|
|
||||||
if (serverConfig.systemTrayEnabled) {
|
|
||||||
try {
|
|
||||||
systemTrayInstance
|
|
||||||
} catch (e: Throwable) { // cover both java.lang.Exception and java.lang.Error
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable jetty's logging
|
|
||||||
System.setProperty("org.eclipse.jetty.util.log.announce", "false")
|
|
||||||
System.setProperty("org.eclipse.jetty.util.log.class", "org.eclipse.jetty.util.log.StdErrLog")
|
|
||||||
System.setProperty("org.eclipse.jetty.LEVEL", "OFF")
|
|
||||||
|
|
||||||
// socks proxy settings
|
|
||||||
if (serverConfig.socksProxyEnabled) {
|
|
||||||
System.getProperties()["socksProxyHost"] = serverConfig.socksProxyHost
|
|
||||||
System.getProperties()["socksProxyPort"] = serverConfig.socksProxyPort
|
|
||||||
logger.info("Socks Proxy is enabled to ${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
package suwayomi.tachidesk.server.database
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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.Database
|
|
||||||
import org.kodein.di.DI
|
|
||||||
import org.kodein.di.conf.global
|
|
||||||
import org.kodein.di.instance
|
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
|
||||||
import suwayomi.tachidesk.server.database.migration.lib.loadMigrationsFrom
|
|
||||||
import suwayomi.tachidesk.server.database.migration.lib.runMigrations
|
|
||||||
|
|
||||||
object DBManager {
|
|
||||||
val db by lazy {
|
|
||||||
val applicationDirs by DI.global.instance<ApplicationDirs>()
|
|
||||||
Database.connect("jdbc:h2:${applicationDirs.dataRoot}/database", "org.h2.Driver")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun databaseUp() {
|
|
||||||
// must mention db object so the lazy block executes
|
|
||||||
val db = DBManager.db
|
|
||||||
db.useNestedTransactions = true
|
|
||||||
|
|
||||||
val migrations = loadMigrationsFrom("suwayomi.tachidesk.server.database.migration")
|
|
||||||
runMigrations(migrations)
|
|
||||||
}
|
|
@ -1,135 +0,0 @@
|
|||||||
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 eu.kanade.tachiyomi.source.model.SManga
|
|
||||||
import org.jetbrains.exposed.dao.id.IdTable
|
|
||||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
|
||||||
import org.jetbrains.exposed.sql.SchemaUtils
|
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
|
||||||
import suwayomi.tachidesk.server.database.migration.lib.Migration
|
|
||||||
|
|
||||||
@Suppress("ClassName", "unused")
|
|
||||||
class M0001_Initial : Migration() {
|
|
||||||
private class ExtensionTable : IntIdTable() {
|
|
||||||
init {
|
|
||||||
varchar("apk_name", 1024)
|
|
||||||
// default is the local source icon from tachiyomi
|
|
||||||
varchar("icon_url", 2048)
|
|
||||||
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
|
|
||||||
varchar("name", 128)
|
|
||||||
varchar("pkg_name", 128)
|
|
||||||
varchar("version_name", 16)
|
|
||||||
integer("version_code")
|
|
||||||
varchar("lang", 10)
|
|
||||||
bool("is_nsfw")
|
|
||||||
|
|
||||||
bool("is_installed").default(false)
|
|
||||||
bool("has_update").default(false)
|
|
||||||
bool("is_obsolete").default(false)
|
|
||||||
|
|
||||||
varchar("class_name", 1024).default("") // fully qualified name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SourceTable(extensionTable: ExtensionTable) : IdTable<Long>() {
|
|
||||||
override val id = long("id").entityId()
|
|
||||||
init {
|
|
||||||
varchar("name", 128)
|
|
||||||
varchar("lang", 10)
|
|
||||||
reference("extension", extensionTable)
|
|
||||||
bool("part_of_factory_source").default(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class MangaTable : IntIdTable() {
|
|
||||||
init {
|
|
||||||
varchar("url", 2048)
|
|
||||||
varchar("title", 512)
|
|
||||||
bool("initialized").default(false)
|
|
||||||
|
|
||||||
varchar("artist", 64).nullable()
|
|
||||||
varchar("author", 64).nullable()
|
|
||||||
varchar("description", 4096).nullable()
|
|
||||||
varchar("genre", 1024).nullable()
|
|
||||||
|
|
||||||
// val status = enumeration("status", MangaStatus::class).default(MangaStatus.UNKNOWN)
|
|
||||||
integer("status").default(SManga.UNKNOWN)
|
|
||||||
varchar("thumbnail_url", 2048).nullable()
|
|
||||||
|
|
||||||
bool("in_library").default(false)
|
|
||||||
bool("default_category").default(true)
|
|
||||||
|
|
||||||
// source is used by some ancestor of IntIdTable
|
|
||||||
long("source")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ChapterTable(mangaTable: MangaTable) : IntIdTable() {
|
|
||||||
init {
|
|
||||||
varchar("url", 2048)
|
|
||||||
varchar("name", 512)
|
|
||||||
long("date_upload").default(0)
|
|
||||||
float("chapter_number").default(-1f)
|
|
||||||
varchar("scanlator", 128).nullable()
|
|
||||||
|
|
||||||
bool("read").default(false)
|
|
||||||
bool("bookmark").default(false)
|
|
||||||
integer("last_page_read").default(0)
|
|
||||||
|
|
||||||
integer("number_in_list")
|
|
||||||
reference("manga", mangaTable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PageTable(chapterTable: ChapterTable) : IntIdTable() {
|
|
||||||
init {
|
|
||||||
integer("index")
|
|
||||||
varchar("url", 2048)
|
|
||||||
varchar("imageUrl", 2048).nullable()
|
|
||||||
reference("chapter", chapterTable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CategoryTable : IntIdTable() {
|
|
||||||
init {
|
|
||||||
varchar("name", 64)
|
|
||||||
bool("is_landing").default(false)
|
|
||||||
integer("order").default(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CategoryMangaTable(categoryTable: CategoryTable, mangaTable: MangaTable) : IntIdTable() {
|
|
||||||
init {
|
|
||||||
reference("category", categoryTable)
|
|
||||||
reference("manga", mangaTable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** initial migration, create all tables */
|
|
||||||
override fun run() {
|
|
||||||
transaction {
|
|
||||||
val extensionTable = ExtensionTable()
|
|
||||||
val sourceTable = SourceTable(extensionTable)
|
|
||||||
val mangaTable = MangaTable()
|
|
||||||
val chapterTable = ChapterTable(mangaTable)
|
|
||||||
val pageTable = PageTable(chapterTable)
|
|
||||||
val categoryTable = CategoryTable()
|
|
||||||
val categoryMangaTable = CategoryMangaTable(categoryTable, mangaTable)
|
|
||||||
SchemaUtils.create(
|
|
||||||
extensionTable,
|
|
||||||
sourceTable,
|
|
||||||
mangaTable,
|
|
||||||
chapterTable,
|
|
||||||
pageTable,
|
|
||||||
categoryTable,
|
|
||||||
categoryMangaTable,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user