From 5c7123a9975578fa2c58892066a3bf0afad81c51 Mon Sep 17 00:00:00 2001 From: Aria Moradi Date: Thu, 27 May 2021 17:13:22 +0430 Subject: [PATCH] Manga page Finished --- .../eu/kanade/tachiyomi/source/AnimeSource.kt | 6 +- .../main/kotlin/suwayomi/anime/AnimeAPI.kt | 182 +++++++------ .../main/kotlin/suwayomi/anime/impl/Anime.kt | 139 ++++++++++ .../kotlin/suwayomi/anime/impl/AnimeList.kt | 102 +++++++ .../kotlin/suwayomi/anime/impl/Episode.kt | 214 +++++++++++++++ .../main/kotlin/suwayomi/anime/impl/Source.kt | 50 ++++ .../anime/impl/util/GetAnimeHttpSource.kt | 57 ++++ .../anime/model/dataclass/AnimeDataClass.kt | 36 +++ .../anime/model/dataclass/EpisodeDataClass.kt | 35 +++ .../suwayomi/anime/model/table/AnimeTable.kt | 65 +++++ .../anime/model/table/EpisodeTable.kt | 43 +++ .../kotlin/suwayomi/server/ServerSetup.kt | 6 +- .../migration/M0005_AnimeTablesBatch2.kt | 45 +++ .../migration/M0006_AnimeTablesBatch3.kt | 41 +++ .../kotlin/suwayomi/tachidesk/impl/Manga.kt | 4 +- .../tachidesk/model/table/MangaTable.kt | 1 - webUI/react/src/App.tsx | 21 +- .../react/src/components/TemporaryDrawer.tsx | 12 +- .../react/src/components/anime/AnimeCard.tsx | 83 ++++++ .../src/components/anime/AnimeDetails.tsx | 257 ++++++++++++++++++ .../react/src/components/anime/AnimeGrid.tsx | 62 +++++ .../src/components/anime/EpisodeCard.tsx | 138 ++++++++++ .../react/src/components/anime/SourceCard.tsx | 86 ++++++ webUI/react/src/screens/anime/Anime.tsx | 118 ++++++++ .../react/src/screens/anime/AnimeSources.tsx | 84 ++++++ .../react/src/screens/anime/SourceAnimes.tsx | 51 ++++ webUI/react/src/screens/manga/Manga.tsx | 4 +- .../manga/{Sources.tsx => MangaSources.tsx} | 2 +- webUI/react/src/typings.d.ts | 16 ++ 29 files changed, 1857 insertions(+), 103 deletions(-) create mode 100644 server/src/main/kotlin/suwayomi/anime/impl/Anime.kt create mode 100644 server/src/main/kotlin/suwayomi/anime/impl/AnimeList.kt create mode 100644 server/src/main/kotlin/suwayomi/anime/impl/Episode.kt create mode 100644 server/src/main/kotlin/suwayomi/anime/impl/Source.kt create mode 100644 server/src/main/kotlin/suwayomi/anime/impl/util/GetAnimeHttpSource.kt create mode 100644 server/src/main/kotlin/suwayomi/anime/model/dataclass/AnimeDataClass.kt create mode 100644 server/src/main/kotlin/suwayomi/anime/model/dataclass/EpisodeDataClass.kt create mode 100644 server/src/main/kotlin/suwayomi/anime/model/table/AnimeTable.kt create mode 100644 server/src/main/kotlin/suwayomi/anime/model/table/EpisodeTable.kt create mode 100644 server/src/main/kotlin/suwayomi/server/database/migration/M0005_AnimeTablesBatch2.kt create mode 100644 server/src/main/kotlin/suwayomi/server/database/migration/M0006_AnimeTablesBatch3.kt create mode 100644 webUI/react/src/components/anime/AnimeCard.tsx create mode 100644 webUI/react/src/components/anime/AnimeDetails.tsx create mode 100644 webUI/react/src/components/anime/AnimeGrid.tsx create mode 100644 webUI/react/src/components/anime/EpisodeCard.tsx create mode 100644 webUI/react/src/components/anime/SourceCard.tsx create mode 100644 webUI/react/src/screens/anime/Anime.tsx create mode 100644 webUI/react/src/screens/anime/AnimeSources.tsx create mode 100644 webUI/react/src/screens/anime/SourceAnimes.tsx rename webUI/react/src/screens/manga/{Sources.tsx => MangaSources.tsx} (98%) diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/AnimeSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/AnimeSource.kt index e61132c..3ca42de 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/AnimeSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/AnimeSource.kt @@ -24,7 +24,7 @@ interface AnimeSource { * * @param anime the anime to update. */ - @Deprecated("Use getAnimeDetails instead") +// @Deprecated("Use getAnimeDetails instead") fun fetchAnimeDetails(anime: SAnime): Observable /** @@ -32,7 +32,7 @@ interface AnimeSource { * * @param anime the anime to update. */ - @Deprecated("Use getEpisodeList instead") +// @Deprecated("Use getEpisodeList instead") fun fetchEpisodeList(anime: SAnime): Observable> /** @@ -40,7 +40,7 @@ interface AnimeSource { * * @param episode the episode to get the link for. */ - @Deprecated("Use getEpisodeList instead") +// @Deprecated("Use getEpisodeList instead") fun fetchEpisodeLink(episode: SEpisode): Observable // /** diff --git a/server/src/main/kotlin/suwayomi/anime/AnimeAPI.kt b/server/src/main/kotlin/suwayomi/anime/AnimeAPI.kt index e4d0a34..0c13d52 100644 --- a/server/src/main/kotlin/suwayomi/anime/AnimeAPI.kt +++ b/server/src/main/kotlin/suwayomi/anime/AnimeAPI.kt @@ -8,6 +8,14 @@ package suwayomi.anime * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import io.javalin.Javalin +import suwayomi.anime.impl.Anime.getAnime +import suwayomi.anime.impl.Anime.getAnimeThumbnail +import suwayomi.anime.impl.AnimeList.getAnimeList +import suwayomi.anime.impl.Episode.getEpisode +import suwayomi.anime.impl.Episode.getEpisodeList +import suwayomi.anime.impl.Episode.modifyEpisode +import suwayomi.anime.impl.Source.getAnimeSource +import suwayomi.anime.impl.Source.getSourceList import suwayomi.anime.impl.extension.Extension.getExtensionIcon import suwayomi.anime.impl.extension.Extension.installExtension import suwayomi.anime.impl.extension.Extension.uninstallExtension @@ -70,63 +78,63 @@ object AnimeAPI { ) } -// // 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( -// JavalinSetup.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( -// JavalinSetup.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( -// JavalinSetup.future { -// getManga(mangaId, onlineFetch) -// } -// ) -// } -// -// // manga thumbnail -// app.get("api/v1/manga/:mangaId/thumbnail") { ctx -> -// val mangaId = ctx.pathParam("mangaId").toInt() -// -// ctx.result( -// JavalinSetup.future { getMangaThumbnail(mangaId) } -// .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( + JavalinSetup.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( + JavalinSetup.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( + JavalinSetup.future { + getAnime(animeId, onlineFetch) + } + ) + } + + // anime thumbnail + app.get("api/v1/anime/anime/:animeId/thumbnail") { ctx -> + val animeId = ctx.pathParam("animeId").toInt() + + ctx.result( + JavalinSetup.future { getAnimeThumbnail(animeId) } + .thenApply { + ctx.header("content-type", it.second) + it.first + } + ) + } // // // list manga's categories // app.get("api/v1/manga/:mangaId/category/") { ctx -> @@ -150,36 +158,36 @@ object AnimeAPI { // 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(JavalinSetup.future { getChapterList(mangaId, onlineFetch) }) -// } -// -// // 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(JavalinSetup.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) -// } + // 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(JavalinSetup.future { getEpisodeList(animeId, onlineFetch) }) + } + + // used to display a episode, get a episode in order to show it's + app.get("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx -> + val episodeIndex = ctx.pathParam("episodeIndex").toInt() + val animeId = ctx.pathParam("animeId").toInt() + ctx.json(JavalinSetup.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 -> diff --git a/server/src/main/kotlin/suwayomi/anime/impl/Anime.kt b/server/src/main/kotlin/suwayomi/anime/impl/Anime.kt new file mode 100644 index 0000000..68367fb --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/Anime.kt @@ -0,0 +1,139 @@ +package suwayomi.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.network.GET +import eu.kanade.tachiyomi.source.model.SAnime +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.anime.impl.AnimeList.proxyThumbnailUrl +import suwayomi.anime.impl.Source.getAnimeSource +import suwayomi.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource +import suwayomi.anime.model.dataclass.AnimeDataClass +import suwayomi.anime.model.table.AnimeStatus +import suwayomi.anime.model.table.AnimeTable +import suwayomi.server.ApplicationDirs +import suwayomi.tachidesk.impl.util.lang.awaitSingle +import suwayomi.tachidesk.impl.util.network.await +import suwayomi.tachidesk.impl.util.storage.CachedImageResponse.clearCachedImage +import suwayomi.tachidesk.impl.util.storage.CachedImageResponse.getCachedImageResponse +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, + false, + getAnimeSource(animeEntry[AnimeTable.sourceReference]), + true + ) + } + } + + private val applicationDirs by DI.global.instance() + suspend fun getAnimeThumbnail(animeId: Int): Pair { + 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) + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/impl/AnimeList.kt b/server/src/main/kotlin/suwayomi/anime/impl/AnimeList.kt new file mode 100644 index 0000000..36faaa3 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/AnimeList.kt @@ -0,0 +1,102 @@ +package suwayomi.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.source.model.AnimesPage +import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource +import suwayomi.anime.model.dataclass.AnimeDataClass +import suwayomi.anime.model.dataclass.PagedAnimeListDataClass +import suwayomi.anime.model.table.AnimeStatus +import suwayomi.anime.model.table.AnimeTable +import suwayomi.tachidesk.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 + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/impl/Episode.kt b/server/src/main/kotlin/suwayomi/anime/impl/Episode.kt new file mode 100644 index 0000000..827ccfb --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/Episode.kt @@ -0,0 +1,214 @@ +package suwayomi.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.source.model.SAnime +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.anime.impl.Anime.getAnime +import suwayomi.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource +import suwayomi.anime.model.dataclass.EpisodeDataClass +import suwayomi.anime.model.table.EpisodeTable +import suwayomi.anime.model.table.toDataClass +import suwayomi.tachidesk.impl.util.lang.awaitSingle + +object Episode { + /** get episode list when showing an anime */ + suspend fun getEpisodeList(animeId: Int, onlineFetch: Boolean?): List { + 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 { + 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] = source. + 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 { + return getEpisodeList(animeId, true).first { it.index == episodeIndex } + } + +// /** 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 + } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/impl/Source.kt b/server/src/main/kotlin/suwayomi/anime/impl/Source.kt new file mode 100644 index 0000000..8fd409e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/Source.kt @@ -0,0 +1,50 @@ +package suwayomi.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.anime.impl.extension.Extension.getExtensionIconUrl +import suwayomi.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource +import suwayomi.anime.model.dataclass.AnimeSourceDataClass +import suwayomi.anime.model.table.AnimeExtensionTable +import suwayomi.anime.model.table.AnimeSourceTable + +object Source { + private val logger = KotlinLogging.logger {} + + fun getSourceList(): List { + 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 } + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/impl/util/GetAnimeHttpSource.kt b/server/src/main/kotlin/suwayomi/anime/impl/util/GetAnimeHttpSource.kt new file mode 100644 index 0000000..6889df5 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/impl/util/GetAnimeHttpSource.kt @@ -0,0 +1,57 @@ +package suwayomi.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.source.AnimeSource +import eu.kanade.tachiyomi.source.AnimeSourceFactory +import eu.kanade.tachiyomi.source.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.anime.impl.util.PackageTools.loadExtensionSources +import suwayomi.anime.model.table.AnimeExtensionTable +import suwayomi.anime.model.table.AnimeSourceTable +import suwayomi.server.ApplicationDirs +import java.util.concurrent.ConcurrentHashMap + +object GetAnimeHttpSource { + private val sourceCache = ConcurrentHashMap() + private val applicationDirs by DI.global.instance() + + 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]!! + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/model/dataclass/AnimeDataClass.kt b/server/src/main/kotlin/suwayomi/anime/model/dataclass/AnimeDataClass.kt new file mode 100644 index 0000000..0b86539 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/model/dataclass/AnimeDataClass.kt @@ -0,0 +1,36 @@ +package suwayomi.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.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, + val hasNextPage: Boolean +) diff --git a/server/src/main/kotlin/suwayomi/anime/model/dataclass/EpisodeDataClass.kt b/server/src/main/kotlin/suwayomi/anime/model/dataclass/EpisodeDataClass.kt new file mode 100644 index 0000000..73c1c6a --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/model/dataclass/EpisodeDataClass.kt @@ -0,0 +1,35 @@ +package suwayomi.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 chapter count, used to calculate if there's a next and prev chapter */ + val chapterCount: Int? = null, + + /** used to construct pages in the front-end */ + val pageCount: Int? = null, +) diff --git a/server/src/main/kotlin/suwayomi/anime/model/table/AnimeTable.kt b/server/src/main/kotlin/suwayomi/anime/model/table/AnimeTable.kt new file mode 100644 index 0000000..2faa3c6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/model/table/AnimeTable.kt @@ -0,0 +1,65 @@ +package suwayomi.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.source.model.SAnime +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ResultRow +import suwayomi.tachidesk.impl.MangaList.proxyThumbnailUrl +import suwayomi.tachidesk.model.dataclass.MangaDataClass +import suwayomi.tachidesk.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 + } +} diff --git a/server/src/main/kotlin/suwayomi/anime/model/table/EpisodeTable.kt b/server/src/main/kotlin/suwayomi/anime/model/table/EpisodeTable.kt new file mode 100644 index 0000000..a4eaa48 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/anime/model/table/EpisodeTable.kt @@ -0,0 +1,43 @@ +package suwayomi.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 suwayomi.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], + ) diff --git a/server/src/main/kotlin/suwayomi/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/server/ServerSetup.kt index 85ab4ee..a5c2917 100644 --- a/server/src/main/kotlin/suwayomi/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/server/ServerSetup.kt @@ -29,7 +29,8 @@ class ApplicationDirs( val dataRoot: String = ApplicationRootDir ) { val extensionsRoot = "$dataRoot/extensions" - val thumbnailsRoot = "$dataRoot/thumbnails" + val mangaThumbnailsRoot = "$dataRoot/manga-thumbnails" + val animeThumbnailsRoot = "$dataRoot/anime-thumbnails" val mangaRoot = "$dataRoot/manga" } @@ -55,7 +56,8 @@ fun applicationSetup() { applicationDirs.dataRoot, applicationDirs.extensionsRoot, applicationDirs.extensionsRoot + "/icon", - applicationDirs.thumbnailsRoot + applicationDirs.mangaThumbnailsRoot, + applicationDirs.animeThumbnailsRoot, ).forEach { File(it).mkdirs() } diff --git a/server/src/main/kotlin/suwayomi/server/database/migration/M0005_AnimeTablesBatch2.kt b/server/src/main/kotlin/suwayomi/server/database/migration/M0005_AnimeTablesBatch2.kt new file mode 100644 index 0000000..a5321a2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/server/database/migration/M0005_AnimeTablesBatch2.kt @@ -0,0 +1,45 @@ +package suwayomi.server.database.migration + +import eu.kanade.tachiyomi.source.model.SAnime +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.server.database.migration.lib.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/. */ + +class M0005_AnimeTablesBatch2 : Migration() { + private 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 = enumeration("status", MangaStatus::class).default(MangaStatus.UNKNOWN) + 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") + } + + override fun run() { + transaction { + SchemaUtils.create( + AnimeTable + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/server/database/migration/M0006_AnimeTablesBatch3.kt b/server/src/main/kotlin/suwayomi/server/database/migration/M0006_AnimeTablesBatch3.kt new file mode 100644 index 0000000..891a708 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/server/database/migration/M0006_AnimeTablesBatch3.kt @@ -0,0 +1,41 @@ +package suwayomi.server.database.migration + +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.anime.model.table.AnimeTable +import suwayomi.server.database.migration.lib.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/. */ + +class M0006_AnimeTablesBatch3 : Migration() { + private 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 animeIndex = integer("index") + + val anime = reference("anime", AnimeTable) + } + + override fun run() { + transaction { + SchemaUtils.create( + EpisodeTable + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/impl/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/impl/Manga.kt index 08ff1ab..84b2e40 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/impl/Manga.kt @@ -111,7 +111,7 @@ object Manga { private val applicationDirs by DI.global.instance() suspend fun getMangaThumbnail(mangaId: Int): Pair { - val saveDir = applicationDirs.thumbnailsRoot + val saveDir = applicationDirs.mangaThumbnailsRoot val fileName = mangaId.toString() return getCachedImageResponse(saveDir, fileName) { @@ -131,7 +131,7 @@ object Manga { } private fun clearMangaThumbnail(mangaId: Int) { - val saveDir = applicationDirs.thumbnailsRoot + val saveDir = applicationDirs.mangaThumbnailsRoot val fileName = mangaId.toString() clearCachedImage(saveDir, fileName) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/model/table/MangaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/model/table/MangaTable.kt index 45d1e58..9792ef7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/model/table/MangaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/model/table/MangaTable.kt @@ -24,7 +24,6 @@ object MangaTable : IntIdTable() { val description = varchar("description", 4096).nullable() val genre = varchar("genre", 1024).nullable() - // val status = enumeration("status", MangaStatus::class).default(MangaStatus.UNKNOWN) val status = integer("status").default(SManga.UNKNOWN) val thumbnail_url = varchar("thumbnail_url", 2048).nullable() diff --git a/webUI/react/src/App.tsx b/webUI/react/src/App.tsx index aed406b..634e576 100644 --- a/webUI/react/src/App.tsx +++ b/webUI/react/src/App.tsx @@ -18,7 +18,8 @@ import NavBar from 'components/navbar/NavBar'; import NavbarContext from 'context/NavbarContext'; import DarkTheme from 'context/DarkTheme'; import useLocalStorage from 'util/useLocalStorage'; -import Sources from 'screens/manga/Sources'; +import MangaSources from 'screens/manga/MangaSources'; +import AnimeSources from 'screens/anime/AnimeSources'; import Settings from 'screens/Settings'; import About from 'screens/settings/About'; import Categories from 'screens/settings/Categories'; @@ -26,8 +27,10 @@ import Backup from 'screens/settings/Backup'; import Library from 'screens/manga/Library'; import SearchSingle from 'screens/manga/SearchSingle'; import Manga from 'screens/manga/Manga'; +import Anime from 'screens/anime/Anime'; import MangaExtensions from 'screens/manga/MangaExtensions'; import SourceMangas from 'screens/manga/SourceMangas'; +import SourceAnimes from 'screens/anime/SourceAnimes'; import Reader from 'screens/manga/Reader'; import AnimeExtensions from 'screens/anime/AnimeExtensions'; @@ -118,8 +121,8 @@ export default function App() { - - + + <> @@ -142,6 +145,18 @@ export default function App() { + + + + + + + + + + + + diff --git a/webUI/react/src/components/TemporaryDrawer.tsx b/webUI/react/src/components/TemporaryDrawer.tsx index 8873a6d..6701e6f 100644 --- a/webUI/react/src/components/TemporaryDrawer.tsx +++ b/webUI/react/src/components/TemporaryDrawer.tsx @@ -71,12 +71,20 @@ export default function TemporaryDrawer({ drawerOpen, setDrawerOpen }: IProps) { - + - + + + + + + + + + diff --git a/webUI/react/src/components/anime/AnimeCard.tsx b/webUI/react/src/components/anime/AnimeCard.tsx new file mode 100644 index 0000000..134374e --- /dev/null +++ b/webUI/react/src/components/anime/AnimeCard.tsx @@ -0,0 +1,83 @@ +/* + * 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 React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import Card from '@material-ui/core/Card'; +import CardActionArea from '@material-ui/core/CardActionArea'; +import CardMedia from '@material-ui/core/CardMedia'; +import Typography from '@material-ui/core/Typography'; +import { Link } from 'react-router-dom'; +import { Grid } from '@material-ui/core'; +import useLocalStorage from 'util/useLocalStorage'; + +const useStyles = makeStyles({ + root: { + height: '100%', + width: '100%', + display: 'flex', + }, + wrapper: { + position: 'relative', + height: '100%', + }, + gradient: { + position: 'absolute', + top: 0, + width: '100%', + height: '100%', + background: 'linear-gradient(to bottom, transparent, #000000)', + opacity: 0.5, + }, + title: { + position: 'absolute', + bottom: 0, + padding: '0.5em', + color: 'white', + }, + image: { + height: '100%', + width: '100%', + }, +}); + +interface IProps { + manga: IMangaCard +} +const AnimeCard = React.forwardRef((props: IProps, ref) => { + const { + manga: { + id, title, thumbnailUrl, + }, + } = props; + const classes = useStyles(); + const [serverAddress] = useLocalStorage('serverBaseURL', ''); + + return ( + + + + +
+ +
+ {title} +
+ + + + + ); +}); + +export default AnimeCard; diff --git a/webUI/react/src/components/anime/AnimeDetails.tsx b/webUI/react/src/components/anime/AnimeDetails.tsx new file mode 100644 index 0000000..a3c1f25 --- /dev/null +++ b/webUI/react/src/components/anime/AnimeDetails.tsx @@ -0,0 +1,257 @@ +/* + * 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 { makeStyles } from '@material-ui/core'; +import IconButton from '@material-ui/core/IconButton'; +import { Theme } from '@material-ui/core/styles'; +import FavoriteIcon from '@material-ui/icons/Favorite'; +import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'; +import FilterListIcon from '@material-ui/icons/FilterList'; +import PublicIcon from '@material-ui/icons/Public'; +import React, { useContext, useEffect, useState } from 'react'; +import NavbarContext from 'context/NavbarContext'; +import client from 'util/client'; +import useLocalStorage from 'util/useLocalStorage'; +import CategorySelect from 'components/manga/CategorySelect'; + +const useStyles = (inLibrary: string) => makeStyles((theme: Theme) => ({ + root: { + width: '100%', + [theme.breakpoints.up('md')]: { + position: 'sticky', + top: '64px', + left: '0px', + width: '50vw', + height: 'calc(100vh - 64px)', + alignSelf: 'flex-start', + overflowY: 'auto', + }, + }, + top: { + padding: '10px', + // [theme.breakpoints.up('md')]: { + // minWidth: '50%', + // }, + }, + leftRight: { + display: 'flex', + }, + leftSide: { + '& img': { + borderRadius: 4, + maxWidth: '100%', + minWidth: '100%', + height: 'auto', + }, + maxWidth: '50%', + // [theme.breakpoints.up('md')]: { + // minWidth: '100px', + // }, + }, + rightSide: { + marginLeft: 15, + maxWidth: '100%', + '& span': { + fontWeight: '400', + }, + [theme.breakpoints.up('lg')]: { + fontSize: '1.3em', + }, + }, + buttons: { + display: 'flex', + justifyContent: 'space-around', + '& button': { + color: inLibrary === 'In Library' ? '#2196f3' : 'inherit', + }, + '& span': { + display: 'block', + fontSize: '0.85em', + }, + '& a': { + textDecoration: 'none', + color: '#858585', + '& button': { + color: 'inherit', + }, + }, + }, + bottom: { + paddingLeft: '10px', + paddingRight: '10px', + [theme.breakpoints.up('md')]: { + fontSize: '1.2em', + // maxWidth: '50%', + }, + [theme.breakpoints.up('lg')]: { + fontSize: '1.3em', + }, + }, + description: { + '& h4': { + marginTop: '1em', + marginBottom: 0, + }, + '& p': { + textAlign: 'justify', + textJustify: 'inter-word', + }, + }, + genre: { + display: 'flex', + flexWrap: 'wrap', + '& h5': { + border: '2px solid #2196f3', + borderRadius: '1.13em', + marginRight: '1em', + marginTop: 0, + marginBottom: '10px', + padding: '0.3em', + color: '#2196f3', + }, + }, +})); + +interface IProps{ + manga: IManga +} + +function getSourceName(source: ISource) { + if (source.name !== null) { + return `${source.name} (${source.lang.toLocaleUpperCase()})`; + } + return source.id; +} + +function getValueOrUnknown(val: string) { + return val || 'UNKNOWN'; +} + +export default function AnimeDetails(props: IProps) { + const { setAction } = useContext(NavbarContext); + + const { manga } = props; + if (manga.genre == null) { + manga.genre = ''; + } + const [inLibrary, setInLibrary] = useState( + manga.inLibrary ? 'In Library' : 'Add To Library', + ); + + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + + useEffect(() => { + if (inLibrary === 'In Library') { + setAction( + <> + setCategoryDialogOpen(true)} + aria-label="display more actions" + edge="end" + color="inherit" + > + + + + , + + ); + } else { setAction(<>); } + }, [inLibrary, categoryDialogOpen]); + + const [serverAddress] = useLocalStorage('serverBaseURL', ''); + + const classes = useStyles(inLibrary)(); + + function addToLibrary() { + // setInLibrary('adding'); + client.get(`/api/v1/anime/anime/${manga.id}/library/`).then(() => { + setInLibrary('In Library'); + }); + } + + function removeFromLibrary() { + // setInLibrary('removing'); + client.delete(`/api/v1/anime/anime/${manga.id}/library/`).then(() => { + setInLibrary('Add To Library'); + }); + } + + function handleButtonClick() { + if (inLibrary === 'Add To Library') { + addToLibrary(); + } else { + removeFromLibrary(); + } + } + + return ( +
+
+
+
+ Manga Thumbnail +
+
+

+ {manga.title} +

+

+ Author: + {' '} + {getValueOrUnknown(manga.author)} +

+

+ Artist: + {' '} + {getValueOrUnknown(manga.artist)} +

+

+ Status: + {' '} + {manga.status} +

+

+ Source: + {' '} + {getSourceName(manga.source)} +

+
+
+
+
+ handleButtonClick()}> + {inLibrary === 'In Library' && } + {inLibrary !== 'In Library' && } + {inLibrary} + +
+ { /* eslint-disable-next-line react/jsx-no-target-blank */ } + + + + Open Site + + +
+
+
+
+

About

+

{manga.description}

+
+
+ {manga.genre.split(', ').map((g) =>
{g}
)} +
+
+
+ ); +} diff --git a/webUI/react/src/components/anime/AnimeGrid.tsx b/webUI/react/src/components/anime/AnimeGrid.tsx new file mode 100644 index 0000000..9086682 --- /dev/null +++ b/webUI/react/src/components/anime/AnimeGrid.tsx @@ -0,0 +1,62 @@ +/* + * 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 React, { useEffect, useRef } from 'react'; +import Grid from '@material-ui/core/Grid'; +import AnimeCard from './AnimeCard'; + +interface IProps{ + mangas: IMangaCard[] + message?: string + hasNextPage: boolean + lastPageNum: number + setLastPageNum: (lastPageNum: number) => void +} + +export default function AnimeGrid(props: IProps) { + const { + mangas, message, hasNextPage, lastPageNum, setLastPageNum, + } = props; + let mapped; + const lastManga = useRef(); + + const scrollHandler = () => { + if (lastManga.current) { + const rect = lastManga.current.getBoundingClientRect(); + if (((rect.y + rect.height) / window.innerHeight < 2) && hasNextPage) { + setLastPageNum(lastPageNum + 1); + } + } + }; + useEffect(() => { + window.addEventListener('scroll', scrollHandler, true); + return () => { + window.removeEventListener('scroll', scrollHandler, true); + }; + }, [hasNextPage, mangas]); + + if (mangas.length === 0) { + mapped =

{message}

; + } else { + mapped = mangas.map((it, idx) => { + if (idx === mangas.length - 1) { + return ; + } + return ; + }); + } + + return ( + + {mapped} + + ); +} + +AnimeGrid.defaultProps = { + message: 'loading...', +}; diff --git a/webUI/react/src/components/anime/EpisodeCard.tsx b/webUI/react/src/components/anime/EpisodeCard.tsx new file mode 100644 index 0000000..9aa4411 --- /dev/null +++ b/webUI/react/src/components/anime/EpisodeCard.tsx @@ -0,0 +1,138 @@ +/* + * 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 React from 'react'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import IconButton from '@material-ui/core/IconButton'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import Typography from '@material-ui/core/Typography'; +import { Link } from 'react-router-dom'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import BookmarkIcon from '@material-ui/icons/Bookmark'; +import client from 'util/client'; + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + }, + bullet: { + display: 'inline-block', + margin: '0 2px', + transform: 'scale(0.8)', + }, + title: { + fontSize: 14, + }, + pos: { + marginBottom: 12, + }, + icon: { + width: theme.spacing(7), + height: theme.spacing(7), + flex: '0 0 auto', + marginRight: 16, + }, +})); + +interface IProps{ + episode: IEpisode + triggerEpisodesUpdate: () => void +} + +export default function EpisodeCard(props: IProps) { + const classes = useStyles(); + const theme = useTheme(); + const { episode, triggerEpisodesUpdate } = props; + + const dateStr = episode.uploadDate && new Date(episode.uploadDate).toISOString().slice(0, 10); + + const [anchorEl, setAnchorEl] = React.useState(null); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const sendChange = (key: string, value: any) => { + handleClose(); + + const formData = new FormData(); + formData.append(key, value); + client.patch(`/api/v1/anime/anime/${episode.animeId}/episode/${episode.index}`, formData) + .then(() => triggerEpisodesUpdate()); + }; + + const readChapterColor = theme.palette.type === 'dark' ? '#acacac' : '#b0b0b0'; + return ( + <> +
  • + + + +
    +
    + + + {episode.bookmarked && } + + {episode.name} + {episode.episodeNumber > 0 && ` : ${episode.episodeNumber}`} + + + {episode.scanlator} + {episode.scanlator && ' '} + {dateStr} + +
    +
    + + + + + + + {/* Download */} + sendChange('bookmarked', !episode.bookmarked)}> + {episode.bookmarked && 'Remove bookmark'} + {!episode.bookmarked && 'Bookmark'} + + sendChange('read', !episode.read)}> + Mark as + {' '} + {episode.read && 'unread'} + {!episode.read && 'read'} + + sendChange('markPrevRead', true)}> + Mark previous as Read + + +
    +
    +
  • + + ); +} diff --git a/webUI/react/src/components/anime/SourceCard.tsx b/webUI/react/src/components/anime/SourceCard.tsx new file mode 100644 index 0000000..ecc08d8 --- /dev/null +++ b/webUI/react/src/components/anime/SourceCard.tsx @@ -0,0 +1,86 @@ +/* + * 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 React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import Button from '@material-ui/core/Button'; +import Avatar from '@material-ui/core/Avatar'; +import Typography from '@material-ui/core/Typography'; +import useLocalStorage from 'util/useLocalStorage'; +import { langCodeToName } from 'util/language'; + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + }, + bullet: { + display: 'inline-block', + margin: '0 2px', + transform: 'scale(0.8)', + }, + title: { + fontSize: 14, + }, + pos: { + marginBottom: 12, + }, + icon: { + width: theme.spacing(7), + height: theme.spacing(7), + flex: '0 0 auto', + marginRight: 16, + }, +})); + +interface IProps { + source: ISource +} + +export default function SourceCard(props: IProps) { + const { + source: { + id, name, lang, iconUrl, supportsLatest, + }, + } = props; + + const [serverAddress] = useLocalStorage('serverBaseURL', ''); + + const classes = useStyles(); + + return ( + + +
    + +
    + + {name} + + + {langCodeToName(lang)} + +
    +
    +
    + + {supportsLatest && } + +
    +
    +
    + ); +} diff --git a/webUI/react/src/screens/anime/Anime.tsx b/webUI/react/src/screens/anime/Anime.tsx new file mode 100644 index 0000000..44e6461 --- /dev/null +++ b/webUI/react/src/screens/anime/Anime.tsx @@ -0,0 +1,118 @@ +/* + * 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 React, { useEffect, useState, useContext } from 'react'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import { useParams } from 'react-router-dom'; +import { Virtuoso } from 'react-virtuoso'; +import EpisodeCard from 'components/anime/EpisodeCard'; +import AnimeDetails from 'components/anime/AnimeDetails'; +import NavbarContext from 'context/NavbarContext'; +import client from 'util/client'; +import LoadingPlaceholder from 'components/LoadingPlaceholder'; +import makeToast from 'components/Toast'; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + [theme.breakpoints.up('md')]: { + display: 'flex', + }, + }, + + chapters: { + listStyle: 'none', + padding: 0, + minHeight: '200px', + [theme.breakpoints.up('md')]: { + width: '50vw', + height: 'calc(100vh - 64px)', + margin: 0, + }, + }, + + loading: { + margin: '10px 0', + display: 'flex', + justifyContent: 'center', + }, +})); + +export default function Anime() { + const classes = useStyles(); + + const { setTitle } = useContext(NavbarContext); + useEffect(() => { setTitle('Anime'); }, []); // delegate setting topbar action to MangaDetails + + const { id } = useParams<{ id: string }>(); + + const [manga, setManga] = useState(); + const [episodes, setEpisodes] = useState([]); + const [fetchedEpisodes, setFetchedEpisodes] = useState(false); + const [noEpisodesFound, setNoEpisodesFound] = useState(false); + const [episodeUpdateTriggerer, setEpisodeUpdateTriggerer] = useState(0); + + function triggerEpisodesUpdate() { + setEpisodeUpdateTriggerer(episodeUpdateTriggerer + 1); + } + + useEffect(() => { + if (manga === undefined || !manga.freshData) { + client.get(`/api/v1/anime/anime/${id}/?onlineFetch=${manga !== undefined}`) + .then((response) => response.data) + .then((data: IManga) => { + setManga(data); + setTitle(data.title); + }); + } + }, [manga]); + + useEffect(() => { + const shouldFetchOnline = fetchedEpisodes && episodeUpdateTriggerer === 0; + client.get(`/api/v1/anime/anime/${id}/episodes?onlineFetch=${shouldFetchOnline}`) + .then((response) => response.data) + .then((data) => { + if (data.length === 0 && fetchedEpisodes) { + makeToast('No episodes found', 'warning'); + setNoEpisodesFound(true); + } + setEpisodes(data); + }) + .then(() => setFetchedEpisodes(true)); + }, [episodes.length, fetchedEpisodes, episodeUpdateTriggerer]); + + return ( +
    + + + 0 || noEpisodesFound} + > + ( + + )} + useWindowScroll={window.innerWidth < 960} + overscan={window.innerHeight * 0.5} + /> + + +
    + ); +} diff --git a/webUI/react/src/screens/anime/AnimeSources.tsx b/webUI/react/src/screens/anime/AnimeSources.tsx new file mode 100644 index 0000000..21d2590 --- /dev/null +++ b/webUI/react/src/screens/anime/AnimeSources.tsx @@ -0,0 +1,84 @@ +/* + * 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 React, { useContext, useEffect, useState } from 'react'; +import ExtensionLangSelect from 'components/manga/ExtensionLangSelect'; +import SourceCard from 'components/anime/SourceCard'; +import NavbarContext from 'context/NavbarContext'; +import client from 'util/client'; +import { defualtLangs, langCodeToName, langSortCmp } from 'util/language'; +import useLocalStorage from 'util/useLocalStorage'; + +function sourceToLangList(sources: ISource[]) { + const result: string[] = []; + + sources.forEach((source) => { + if (result.indexOf(source.lang) === -1) { result.push(source.lang); } + }); + + result.sort(langSortCmp); + return result; +} + +function groupByLang(sources: ISource[]) { + const result = {} as any; + sources.forEach((source) => { + if (result[source.lang] === undefined) { result[source.lang] = [] as ISource[]; } + result[source.lang].push(source); + }); + + return result; +} + +export default function AnimeSources() { + const { setTitle, setAction } = useContext(NavbarContext); + + const [shownLangs, setShownLangs] = useLocalStorage('shownSourceLangs', defualtLangs()); + + const [sources, setSources] = useState([]); + const [fetched, setFetched] = useState(false); + + useEffect(() => { + setTitle('Sources'); + setAction( + , + ); + }, [shownLangs, sources]); + + useEffect(() => { + client.get('/api/v1/anime/source/list') + .then((response) => response.data) + .then((data) => { setSources(data); setFetched(true); }); + }, []); + + if (sources.length === 0) { + if (fetched) return (

    No sources found. Install Some Extensions first.

    ); + return (

    loading...

    ); + } + return ( + <> + {/* eslint-disable-next-line max-len */} + {Object.entries(groupByLang(sources)).sort((a, b) => langSortCmp(a[0], b[0])).map(([lang, list]) => ( + shownLangs.indexOf(lang) !== -1 && ( + +

    {langCodeToName(lang)}

    + {(list as ISource[]).map((source) => ( + + ))} +
    + ) + ))} + + ); +} diff --git a/webUI/react/src/screens/anime/SourceAnimes.tsx b/webUI/react/src/screens/anime/SourceAnimes.tsx new file mode 100644 index 0000000..f94d929 --- /dev/null +++ b/webUI/react/src/screens/anime/SourceAnimes.tsx @@ -0,0 +1,51 @@ +/* + * 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 React, { useContext, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import AnimeGrid from 'components/anime/AnimeGrid'; +import NavbarContext from 'context/NavbarContext'; +import client from 'util/client'; + +export default function SourceAnimes(props: { popular: boolean }) { + const { setTitle, setAction } = useContext(NavbarContext); + useEffect(() => { setTitle('Source'); setAction(<>); }, []); + + const { sourceId } = useParams<{ sourceId: string }>(); + const [mangas, setMangas] = useState([]); + const [hasNextPage, setHasNextPage] = useState(false); + const [lastPageNum, setLastPageNum] = useState(1); + + useEffect(() => { + client.get(`/api/v1/anime/source/${sourceId}`) + .then((response) => response.data) + .then((data: { name: string }) => setTitle(data.name)); + }, []); + + useEffect(() => { + const sourceType = props.popular ? 'popular' : 'latest'; + client.get(`/api/v1/anime/source/${sourceId}/${sourceType}/${lastPageNum}`) + .then((response) => response.data) + .then((data: { mangaList: IManga[], hasNextPage: boolean }) => { + setMangas([ + ...mangas, + ...data.mangaList.map((it) => ({ + title: it.title, thumbnailUrl: it.thumbnailUrl, id: it.id, + }))]); + setHasNextPage(data.hasNextPage); + }); + }, [lastPageNum]); + + return ( + + ); +} diff --git a/webUI/react/src/screens/manga/Manga.tsx b/webUI/react/src/screens/manga/Manga.tsx index 0f87ebe..0bfa10d 100644 --- a/webUI/react/src/screens/manga/Manga.tsx +++ b/webUI/react/src/screens/manga/Manga.tsx @@ -26,7 +26,7 @@ const useStyles = makeStyles((theme: Theme) => ({ chapters: { listStyle: 'none', padding: 0, - minHeight: '50vh', + minHeight: '200px', [theme.breakpoints.up('md')]: { width: '50vw', height: 'calc(100vh - 64px)', @@ -98,7 +98,7 @@ export default function Manga() { ('shownSourceLangs', defualtLangs()); diff --git a/webUI/react/src/typings.d.ts b/webUI/react/src/typings.d.ts index dea9b25..864c509 100644 --- a/webUI/react/src/typings.d.ts +++ b/webUI/react/src/typings.d.ts @@ -70,6 +70,22 @@ interface IChapter { pageCount: number } +interface IEpisode { + id: number + url: string + name: string + uploadDate: number + episodeNumber: number + scanlator: String + animeId: number + read: boolean + bookmarked: boolean + lastPageRead: number + index: number + episodeCount: number + pageCount: number +} + interface IPartialChpter { pageCount: number index: number