mirror of
https://github.com/tachiyomiorg/tachiyomi-extensions-inspector.git
synced 2025-03-09 10:35:51 +01:00
Manga page Finished
This commit is contained in:
parent
c17e3bd04f
commit
5c7123a997
@ -24,7 +24,7 @@ interface AnimeSource {
|
|||||||
*
|
*
|
||||||
* @param anime the anime to update.
|
* @param anime the anime to update.
|
||||||
*/
|
*/
|
||||||
@Deprecated("Use getAnimeDetails instead")
|
// @Deprecated("Use getAnimeDetails instead")
|
||||||
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime>
|
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,7 +32,7 @@ interface AnimeSource {
|
|||||||
*
|
*
|
||||||
* @param anime the anime to update.
|
* @param anime the anime to update.
|
||||||
*/
|
*/
|
||||||
@Deprecated("Use getEpisodeList instead")
|
// @Deprecated("Use getEpisodeList instead")
|
||||||
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>>
|
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,7 +40,7 @@ interface AnimeSource {
|
|||||||
*
|
*
|
||||||
* @param episode the episode to get the link for.
|
* @param episode the episode to get the link for.
|
||||||
*/
|
*/
|
||||||
@Deprecated("Use getEpisodeList instead")
|
// @Deprecated("Use getEpisodeList instead")
|
||||||
fun fetchEpisodeLink(episode: SEpisode): Observable<String>
|
fun fetchEpisodeLink(episode: SEpisode): Observable<String>
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
|
@ -8,6 +8,14 @@ package suwayomi.anime
|
|||||||
* 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 io.javalin.Javalin
|
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.getExtensionIcon
|
||||||
import suwayomi.anime.impl.extension.Extension.installExtension
|
import suwayomi.anime.impl.extension.Extension.installExtension
|
||||||
import suwayomi.anime.impl.extension.Extension.uninstallExtension
|
import suwayomi.anime.impl.extension.Extension.uninstallExtension
|
||||||
@ -70,63 +78,63 @@ object AnimeAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// // list of sources
|
// list of sources
|
||||||
// app.get("/api/v1/source/list") { ctx ->
|
app.get("/api/v1/anime/source/list") { ctx ->
|
||||||
// ctx.json(getSourceList())
|
ctx.json(getSourceList())
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// // fetch source with id `sourceId`
|
// fetch source with id `sourceId`
|
||||||
// app.get("/api/v1/source/:sourceId") { ctx ->
|
app.get("/api/v1/anime/source/:sourceId") { ctx ->
|
||||||
// val sourceId = ctx.pathParam("sourceId").toLong()
|
val sourceId = ctx.pathParam("sourceId").toLong()
|
||||||
// ctx.json(getSource(sourceId))
|
ctx.json(getAnimeSource(sourceId))
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// // popular mangas from source with id `sourceId`
|
// popular animes from source with id `sourceId`
|
||||||
// app.get("/api/v1/source/:sourceId/popular/:pageNum") { ctx ->
|
app.get("/api/v1/anime/source/:sourceId/popular/:pageNum") { ctx ->
|
||||||
// val sourceId = ctx.pathParam("sourceId").toLong()
|
val sourceId = ctx.pathParam("sourceId").toLong()
|
||||||
// val pageNum = ctx.pathParam("pageNum").toInt()
|
val pageNum = ctx.pathParam("pageNum").toInt()
|
||||||
// ctx.json(
|
ctx.json(
|
||||||
// JavalinSetup.future {
|
JavalinSetup.future {
|
||||||
// getMangaList(sourceId, pageNum, popular = true)
|
getAnimeList(sourceId, pageNum, popular = true)
|
||||||
// }
|
}
|
||||||
// )
|
)
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// // latest mangas from source with id `sourceId`
|
// latest animes from source with id `sourceId`
|
||||||
// app.get("/api/v1/source/:sourceId/latest/:pageNum") { ctx ->
|
app.get("/api/v1/anime/source/:sourceId/latest/:pageNum") { ctx ->
|
||||||
// val sourceId = ctx.pathParam("sourceId").toLong()
|
val sourceId = ctx.pathParam("sourceId").toLong()
|
||||||
// val pageNum = ctx.pathParam("pageNum").toInt()
|
val pageNum = ctx.pathParam("pageNum").toInt()
|
||||||
// ctx.json(
|
ctx.json(
|
||||||
// JavalinSetup.future {
|
JavalinSetup.future {
|
||||||
// getMangaList(sourceId, pageNum, popular = false)
|
getAnimeList(sourceId, pageNum, popular = false)
|
||||||
// }
|
}
|
||||||
// )
|
)
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// // get manga info
|
// get anime info
|
||||||
// app.get("/api/v1/manga/:mangaId/") { ctx ->
|
app.get("/api/v1/anime/anime/:animeId/") { ctx ->
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
val animeId = ctx.pathParam("animeId").toInt()
|
||||||
// val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
|
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
|
||||||
//
|
|
||||||
// ctx.json(
|
ctx.json(
|
||||||
// JavalinSetup.future {
|
JavalinSetup.future {
|
||||||
// getManga(mangaId, onlineFetch)
|
getAnime(animeId, onlineFetch)
|
||||||
// }
|
}
|
||||||
// )
|
)
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// // manga thumbnail
|
// anime thumbnail
|
||||||
// app.get("api/v1/manga/:mangaId/thumbnail") { ctx ->
|
app.get("api/v1/anime/anime/:animeId/thumbnail") { ctx ->
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
val animeId = ctx.pathParam("animeId").toInt()
|
||||||
//
|
|
||||||
// ctx.result(
|
ctx.result(
|
||||||
// JavalinSetup.future { getMangaThumbnail(mangaId) }
|
JavalinSetup.future { getAnimeThumbnail(animeId) }
|
||||||
// .thenApply {
|
.thenApply {
|
||||||
// ctx.header("content-type", it.second)
|
ctx.header("content-type", it.second)
|
||||||
// it.first
|
it.first
|
||||||
// }
|
}
|
||||||
// )
|
)
|
||||||
// }
|
}
|
||||||
//
|
//
|
||||||
// // list manga's categories
|
// // list manga's categories
|
||||||
// app.get("api/v1/manga/:mangaId/category/") { ctx ->
|
// app.get("api/v1/manga/:mangaId/category/") { ctx ->
|
||||||
@ -150,36 +158,36 @@ object AnimeAPI {
|
|||||||
// ctx.status(200)
|
// ctx.status(200)
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// // get chapter list when showing a manga
|
// get episode list when showing a anime
|
||||||
// app.get("/api/v1/manga/:mangaId/chapters") { ctx ->
|
app.get("/api/v1/anime/anime/:animeId/episodes") { ctx ->
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
val animeId = ctx.pathParam("animeId").toInt()
|
||||||
//
|
|
||||||
// val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean()
|
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean()
|
||||||
//
|
|
||||||
// ctx.json(JavalinSetup.future { getChapterList(mangaId, onlineFetch) })
|
ctx.json(JavalinSetup.future { getEpisodeList(animeId, onlineFetch) })
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// // used to display a chapter, get a chapter in order to show it's pages
|
// used to display a episode, get a episode in order to show it's <Quality pending>
|
||||||
// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx ->
|
app.get("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
|
||||||
// val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
val animeId = ctx.pathParam("animeId").toInt()
|
||||||
// ctx.json(JavalinSetup.future { getChapter(chapterIndex, mangaId) })
|
ctx.json(JavalinSetup.future { getEpisode(episodeIndex, animeId) })
|
||||||
// }
|
}
|
||||||
//
|
|
||||||
// // used to modify a chapter's parameters
|
// used to modify a episode's parameters
|
||||||
// app.patch("/api/v1/manga/:mangaId/chapter/:chapterIndex") { ctx ->
|
app.patch("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
|
||||||
// val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
|
||||||
// val mangaId = ctx.pathParam("mangaId").toInt()
|
val animeId = ctx.pathParam("animeId").toInt()
|
||||||
//
|
|
||||||
// val read = ctx.formParam("read")?.toBoolean()
|
val read = ctx.formParam("read")?.toBoolean()
|
||||||
// val bookmarked = ctx.formParam("bookmarked")?.toBoolean()
|
val bookmarked = ctx.formParam("bookmarked")?.toBoolean()
|
||||||
// val markPrevRead = ctx.formParam("markPrevRead")?.toBoolean()
|
val markPrevRead = ctx.formParam("markPrevRead")?.toBoolean()
|
||||||
// val lastPageRead = ctx.formParam("lastPageRead")?.toInt()
|
val lastPageRead = ctx.formParam("lastPageRead")?.toInt()
|
||||||
//
|
|
||||||
// modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead)
|
modifyEpisode(animeId, episodeIndex, read, bookmarked, markPrevRead, lastPageRead)
|
||||||
//
|
|
||||||
// ctx.status(200)
|
ctx.status(200)
|
||||||
// }
|
}
|
||||||
//
|
//
|
||||||
// // get page at index "index"
|
// // get page at index "index"
|
||||||
// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx ->
|
// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx ->
|
||||||
|
139
server/src/main/kotlin/suwayomi/anime/impl/Anime.kt
Normal file
139
server/src/main/kotlin/suwayomi/anime/impl/Anime.kt
Normal file
@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
102
server/src/main/kotlin/suwayomi/anime/impl/AnimeList.kt
Normal file
102
server/src/main/kotlin/suwayomi/anime/impl/AnimeList.kt
Normal file
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
214
server/src/main/kotlin/suwayomi/anime/impl/Episode.kt
Normal file
214
server/src/main/kotlin/suwayomi/anime/impl/Episode.kt
Normal file
@ -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<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] = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
server/src/main/kotlin/suwayomi/anime/impl/Source.kt
Normal file
50
server/src/main/kotlin/suwayomi/anime/impl/Source.kt
Normal file
@ -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<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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<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]!!
|
||||||
|
}
|
||||||
|
}
|
@ -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<AnimeDataClass>,
|
||||||
|
val hasNextPage: Boolean
|
||||||
|
)
|
@ -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,
|
||||||
|
)
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -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],
|
||||||
|
)
|
@ -29,7 +29,8 @@ class ApplicationDirs(
|
|||||||
val dataRoot: String = ApplicationRootDir
|
val dataRoot: String = ApplicationRootDir
|
||||||
) {
|
) {
|
||||||
val extensionsRoot = "$dataRoot/extensions"
|
val extensionsRoot = "$dataRoot/extensions"
|
||||||
val thumbnailsRoot = "$dataRoot/thumbnails"
|
val mangaThumbnailsRoot = "$dataRoot/manga-thumbnails"
|
||||||
|
val animeThumbnailsRoot = "$dataRoot/anime-thumbnails"
|
||||||
val mangaRoot = "$dataRoot/manga"
|
val mangaRoot = "$dataRoot/manga"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +56,8 @@ fun applicationSetup() {
|
|||||||
applicationDirs.dataRoot,
|
applicationDirs.dataRoot,
|
||||||
applicationDirs.extensionsRoot,
|
applicationDirs.extensionsRoot,
|
||||||
applicationDirs.extensionsRoot + "/icon",
|
applicationDirs.extensionsRoot + "/icon",
|
||||||
applicationDirs.thumbnailsRoot
|
applicationDirs.mangaThumbnailsRoot,
|
||||||
|
applicationDirs.animeThumbnailsRoot,
|
||||||
).forEach {
|
).forEach {
|
||||||
File(it).mkdirs()
|
File(it).mkdirs()
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -111,7 +111,7 @@ object Manga {
|
|||||||
|
|
||||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||||
suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
|
suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
|
||||||
val saveDir = applicationDirs.thumbnailsRoot
|
val saveDir = applicationDirs.mangaThumbnailsRoot
|
||||||
val fileName = mangaId.toString()
|
val fileName = mangaId.toString()
|
||||||
|
|
||||||
return getCachedImageResponse(saveDir, fileName) {
|
return getCachedImageResponse(saveDir, fileName) {
|
||||||
@ -131,7 +131,7 @@ object Manga {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun clearMangaThumbnail(mangaId: Int) {
|
private fun clearMangaThumbnail(mangaId: Int) {
|
||||||
val saveDir = applicationDirs.thumbnailsRoot
|
val saveDir = applicationDirs.mangaThumbnailsRoot
|
||||||
val fileName = mangaId.toString()
|
val fileName = mangaId.toString()
|
||||||
|
|
||||||
clearCachedImage(saveDir, fileName)
|
clearCachedImage(saveDir, fileName)
|
||||||
|
@ -24,7 +24,6 @@ object MangaTable : IntIdTable() {
|
|||||||
val description = varchar("description", 4096).nullable()
|
val description = varchar("description", 4096).nullable()
|
||||||
val genre = varchar("genre", 1024).nullable()
|
val genre = varchar("genre", 1024).nullable()
|
||||||
|
|
||||||
// val status = enumeration("status", MangaStatus::class).default(MangaStatus.UNKNOWN)
|
|
||||||
val status = integer("status").default(SManga.UNKNOWN)
|
val status = integer("status").default(SManga.UNKNOWN)
|
||||||
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
|
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
|
||||||
|
|
||||||
|
@ -18,7 +18,8 @@ import NavBar from 'components/navbar/NavBar';
|
|||||||
import NavbarContext from 'context/NavbarContext';
|
import NavbarContext from 'context/NavbarContext';
|
||||||
import DarkTheme from 'context/DarkTheme';
|
import DarkTheme from 'context/DarkTheme';
|
||||||
import useLocalStorage from 'util/useLocalStorage';
|
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 Settings from 'screens/Settings';
|
||||||
import About from 'screens/settings/About';
|
import About from 'screens/settings/About';
|
||||||
import Categories from 'screens/settings/Categories';
|
import Categories from 'screens/settings/Categories';
|
||||||
@ -26,8 +27,10 @@ import Backup from 'screens/settings/Backup';
|
|||||||
import Library from 'screens/manga/Library';
|
import Library from 'screens/manga/Library';
|
||||||
import SearchSingle from 'screens/manga/SearchSingle';
|
import SearchSingle from 'screens/manga/SearchSingle';
|
||||||
import Manga from 'screens/manga/Manga';
|
import Manga from 'screens/manga/Manga';
|
||||||
|
import Anime from 'screens/anime/Anime';
|
||||||
import MangaExtensions from 'screens/manga/MangaExtensions';
|
import MangaExtensions from 'screens/manga/MangaExtensions';
|
||||||
import SourceMangas from 'screens/manga/SourceMangas';
|
import SourceMangas from 'screens/manga/SourceMangas';
|
||||||
|
import SourceAnimes from 'screens/anime/SourceAnimes';
|
||||||
import Reader from 'screens/manga/Reader';
|
import Reader from 'screens/manga/Reader';
|
||||||
import AnimeExtensions from 'screens/anime/AnimeExtensions';
|
import AnimeExtensions from 'screens/anime/AnimeExtensions';
|
||||||
|
|
||||||
@ -118,8 +121,8 @@ export default function App() {
|
|||||||
<Route path="/sources/:sourceId/latest/">
|
<Route path="/sources/:sourceId/latest/">
|
||||||
<SourceMangas popular={false} />
|
<SourceMangas popular={false} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/sources">
|
<Route path="/manga/sources">
|
||||||
<Sources />
|
<MangaSources />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/manga/:mangaId/chapter/:chapterNum">
|
<Route path="/manga/:mangaId/chapter/:chapterNum">
|
||||||
<></>
|
<></>
|
||||||
@ -142,6 +145,18 @@ export default function App() {
|
|||||||
<Route path="/anime/extensions">
|
<Route path="/anime/extensions">
|
||||||
<AnimeExtensions />
|
<AnimeExtensions />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/anime/sources/:sourceId/popular/">
|
||||||
|
<SourceAnimes popular />
|
||||||
|
</Route>
|
||||||
|
<Route path="/anime/sources/:sourceId/latest/">
|
||||||
|
<SourceMangas popular={false} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/anime/sources">
|
||||||
|
<AnimeSources />
|
||||||
|
</Route>
|
||||||
|
<Route path="/anime/:id">
|
||||||
|
<Anime />
|
||||||
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Container>
|
</Container>
|
||||||
</NavbarContext.Provider>
|
</NavbarContext.Provider>
|
||||||
|
@ -71,12 +71,20 @@ export default function TemporaryDrawer({ drawerOpen, setDrawerOpen }: IProps) {
|
|||||||
<ListItemText primary="Anime Extensions" />
|
<ListItemText primary="Anime Extensions" />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/sources" style={{ color: 'inherit', textDecoration: 'none' }}>
|
<Link to="/manga/sources" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||||
<ListItem button key="Sources">
|
<ListItem button key="Sources">
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<ExploreIcon />
|
<ExploreIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Sources" />
|
<ListItemText primary="Manga Sources" />
|
||||||
|
</ListItem>
|
||||||
|
</Link>
|
||||||
|
<Link to="/anime/sources" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||||
|
<ListItem button key="Sources">
|
||||||
|
<ListItemIcon>
|
||||||
|
<ExploreIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Anime Sources" />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/settings" style={{ color: 'inherit', textDecoration: 'none' }}>
|
<Link to="/settings" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||||
|
83
webUI/react/src/components/anime/AnimeCard.tsx
Normal file
83
webUI/react/src/components/anime/AnimeCard.tsx
Normal file
@ -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<String>('serverBaseURL', '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid item xs={6} sm={4} md={3} lg={2}>
|
||||||
|
<Link to={`/anime/${id}/`}>
|
||||||
|
<Card className={classes.root} ref={ref}>
|
||||||
|
<CardActionArea>
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
<CardMedia
|
||||||
|
className={classes.image}
|
||||||
|
component="img"
|
||||||
|
alt={title}
|
||||||
|
image={serverAddress + thumbnailUrl}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
<div className={classes.gradient} />
|
||||||
|
<Typography className={classes.title} variant="h5" component="h2">{title}</Typography>
|
||||||
|
</div>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AnimeCard;
|
257
webUI/react/src/components/anime/AnimeDetails.tsx
Normal file
257
webUI/react/src/components/anime/AnimeDetails.tsx
Normal file
@ -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<string>(
|
||||||
|
manga.inLibrary ? 'In Library' : 'Add To Library',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [categoryDialogOpen, setCategoryDialogOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inLibrary === 'In Library') {
|
||||||
|
setAction(
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setCategoryDialogOpen(true)}
|
||||||
|
aria-label="display more actions"
|
||||||
|
edge="end"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
<FilterListIcon />
|
||||||
|
</IconButton>
|
||||||
|
<CategorySelect
|
||||||
|
open={categoryDialogOpen}
|
||||||
|
setOpen={setCategoryDialogOpen}
|
||||||
|
mangaId={manga.id}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
|
||||||
|
);
|
||||||
|
} else { setAction(<></>); }
|
||||||
|
}, [inLibrary, categoryDialogOpen]);
|
||||||
|
|
||||||
|
const [serverAddress] = useLocalStorage<String>('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 (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<div className={classes.top}>
|
||||||
|
<div className={classes.leftRight}>
|
||||||
|
<div className={classes.leftSide}>
|
||||||
|
<img src={`${serverAddress}${manga.thumbnailUrl}?x=${Math.random()}`} alt="Manga Thumbnail" />
|
||||||
|
</div>
|
||||||
|
<div className={classes.rightSide}>
|
||||||
|
<h1>
|
||||||
|
{manga.title}
|
||||||
|
</h1>
|
||||||
|
<h3>
|
||||||
|
Author:
|
||||||
|
{' '}
|
||||||
|
<span>{getValueOrUnknown(manga.author)}</span>
|
||||||
|
</h3>
|
||||||
|
<h3>
|
||||||
|
Artist:
|
||||||
|
{' '}
|
||||||
|
<span>{getValueOrUnknown(manga.artist)}</span>
|
||||||
|
</h3>
|
||||||
|
<h3>
|
||||||
|
Status:
|
||||||
|
{' '}
|
||||||
|
{manga.status}
|
||||||
|
</h3>
|
||||||
|
<h3>
|
||||||
|
Source:
|
||||||
|
{' '}
|
||||||
|
{getSourceName(manga.source)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.buttons}>
|
||||||
|
<div>
|
||||||
|
<IconButton onClick={() => handleButtonClick()}>
|
||||||
|
{inLibrary === 'In Library' && <FavoriteIcon />}
|
||||||
|
{inLibrary !== 'In Library' && <FavoriteBorderIcon />}
|
||||||
|
<span>{inLibrary}</span>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{ /* eslint-disable-next-line react/jsx-no-target-blank */ }
|
||||||
|
<a href={manga.url} target="_blank">
|
||||||
|
<IconButton>
|
||||||
|
<PublicIcon />
|
||||||
|
<span>Open Site</span>
|
||||||
|
</IconButton>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.bottom}>
|
||||||
|
<div className={classes.description}>
|
||||||
|
<h4>About</h4>
|
||||||
|
<p>{manga.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className={classes.genre}>
|
||||||
|
{manga.genre.split(', ').map((g) => <h5 key={g}>{g}</h5>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
62
webUI/react/src/components/anime/AnimeGrid.tsx
Normal file
62
webUI/react/src/components/anime/AnimeGrid.tsx
Normal file
@ -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<HTMLInputElement>();
|
||||||
|
|
||||||
|
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 = <h3>{message}</h3>;
|
||||||
|
} else {
|
||||||
|
mapped = mangas.map((it, idx) => {
|
||||||
|
if (idx === mangas.length - 1) {
|
||||||
|
return <AnimeCard manga={it} ref={lastManga} />;
|
||||||
|
}
|
||||||
|
return <AnimeCard manga={it} />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={1} style={{ margin: 0, width: '100%', padding: '5px' }}>
|
||||||
|
{mapped}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimeGrid.defaultProps = {
|
||||||
|
message: 'loading...',
|
||||||
|
};
|
138
webUI/react/src/components/anime/EpisodeCard.tsx
Normal file
138
webUI/react/src/components/anime/EpisodeCard.tsx
Normal file
@ -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 | HTMLElement>(null);
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Card>
|
||||||
|
<CardContent className={classes.root}>
|
||||||
|
<Link
|
||||||
|
to={`/anime/${episode.animeId}/episode/${episode.index}`}
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: episode.read ? readChapterColor : theme.palette.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography variant="h5" component="h2">
|
||||||
|
<span style={{ color: theme.palette.primary.dark }}>
|
||||||
|
{episode.bookmarked && <BookmarkIcon />}
|
||||||
|
</span>
|
||||||
|
{episode.name}
|
||||||
|
{episode.episodeNumber > 0 && ` : ${episode.episodeNumber}`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" display="block" gutterBottom>
|
||||||
|
{episode.scanlator}
|
||||||
|
{episode.scanlator && ' '}
|
||||||
|
{dateStr}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<IconButton aria-label="more" onClick={handleClick}>
|
||||||
|
<MoreVertIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
keepMounted
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
{/* <MenuItem onClick={handleClose}>Download</MenuItem> */}
|
||||||
|
<MenuItem onClick={() => sendChange('bookmarked', !episode.bookmarked)}>
|
||||||
|
{episode.bookmarked && 'Remove bookmark'}
|
||||||
|
{!episode.bookmarked && 'Bookmark'}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => sendChange('read', !episode.read)}>
|
||||||
|
Mark as
|
||||||
|
{' '}
|
||||||
|
{episode.read && 'unread'}
|
||||||
|
{!episode.read && 'read'}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => sendChange('markPrevRead', true)}>
|
||||||
|
Mark previous as Read
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
86
webUI/react/src/components/anime/SourceCard.tsx
Normal file
86
webUI/react/src/components/anime/SourceCard.tsx
Normal file
@ -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<String>('serverBaseURL', '');
|
||||||
|
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className={classes.root}>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<Avatar
|
||||||
|
variant="rounded"
|
||||||
|
className={classes.icon}
|
||||||
|
alt={name}
|
||||||
|
src={serverAddress + iconUrl}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography variant="h5" component="h2">
|
||||||
|
{name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" display="block" gutterBottom>
|
||||||
|
{langCodeToName(lang)}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<Button variant="outlined" style={{ marginLeft: 20 }} onClick={() => { window.location.href = `/anime/sources/${id}/search/`; }}>Search</Button>
|
||||||
|
{supportsLatest && <Button variant="outlined" style={{ marginLeft: 20 }} onClick={() => { window.location.href = `/anime/sources/${id}/latest/`; }}>Latest</Button>}
|
||||||
|
<Button variant="outlined" style={{ marginLeft: 20 }} onClick={() => { window.location.href = `/anime/sources/${id}/popular/`; }}>Browse</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
118
webUI/react/src/screens/anime/Anime.tsx
Normal file
118
webUI/react/src/screens/anime/Anime.tsx
Normal file
@ -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<IManga>();
|
||||||
|
const [episodes, setEpisodes] = useState<IEpisode[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<LoadingPlaceholder
|
||||||
|
shouldRender={manga !== undefined}
|
||||||
|
component={AnimeDetails}
|
||||||
|
componentProps={{ manga }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoadingPlaceholder
|
||||||
|
shouldRender={episodes.length > 0 || noEpisodesFound}
|
||||||
|
>
|
||||||
|
<Virtuoso
|
||||||
|
style={{ // override Virtuoso default values and set them with class
|
||||||
|
height: 'undefined',
|
||||||
|
overflowY: window.innerWidth < 960 ? 'visible' : 'auto',
|
||||||
|
}}
|
||||||
|
className={classes.chapters}
|
||||||
|
totalCount={episodes.length}
|
||||||
|
itemContent={(index:number) => (
|
||||||
|
<EpisodeCard
|
||||||
|
episode={episodes[index]}
|
||||||
|
triggerEpisodesUpdate={triggerEpisodesUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
useWindowScroll={window.innerWidth < 960}
|
||||||
|
overscan={window.innerHeight * 0.5}
|
||||||
|
/>
|
||||||
|
</LoadingPlaceholder>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
84
webUI/react/src/screens/anime/AnimeSources.tsx
Normal file
84
webUI/react/src/screens/anime/AnimeSources.tsx
Normal file
@ -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<string[]>('shownSourceLangs', defualtLangs());
|
||||||
|
|
||||||
|
const [sources, setSources] = useState<ISource[]>([]);
|
||||||
|
const [fetched, setFetched] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle('Sources');
|
||||||
|
setAction(
|
||||||
|
<ExtensionLangSelect
|
||||||
|
shownLangs={shownLangs}
|
||||||
|
setShownLangs={setShownLangs}
|
||||||
|
allLangs={sourceToLangList(sources)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}, [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 (<h3>No sources found. Install Some Extensions first.</h3>);
|
||||||
|
return (<h3>loading...</h3>);
|
||||||
|
}
|
||||||
|
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 && (
|
||||||
|
<React.Fragment key={lang}>
|
||||||
|
<h1 key={lang} style={{ marginLeft: 25 }}>{langCodeToName(lang)}</h1>
|
||||||
|
{(list as ISource[]).map((source) => (
|
||||||
|
<SourceCard
|
||||||
|
key={source.id}
|
||||||
|
source={source}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
51
webUI/react/src/screens/anime/SourceAnimes.tsx
Normal file
51
webUI/react/src/screens/anime/SourceAnimes.tsx
Normal file
@ -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<IMangaCard[]>([]);
|
||||||
|
const [hasNextPage, setHasNextPage] = useState<boolean>(false);
|
||||||
|
const [lastPageNum, setLastPageNum] = useState<number>(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 (
|
||||||
|
<AnimeGrid
|
||||||
|
mangas={mangas}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
lastPageNum={lastPageNum}
|
||||||
|
setLastPageNum={setLastPageNum}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -26,7 +26,7 @@ const useStyles = makeStyles((theme: Theme) => ({
|
|||||||
chapters: {
|
chapters: {
|
||||||
listStyle: 'none',
|
listStyle: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
minHeight: '50vh',
|
minHeight: '200px',
|
||||||
[theme.breakpoints.up('md')]: {
|
[theme.breakpoints.up('md')]: {
|
||||||
width: '50vw',
|
width: '50vw',
|
||||||
height: 'calc(100vh - 64px)',
|
height: 'calc(100vh - 64px)',
|
||||||
@ -98,7 +98,7 @@ export default function Manga() {
|
|||||||
<Virtuoso
|
<Virtuoso
|
||||||
style={{ // override Virtuoso default values and set them with class
|
style={{ // override Virtuoso default values and set them with class
|
||||||
height: 'undefined',
|
height: 'undefined',
|
||||||
overflowY: 'visible',
|
overflowY: window.innerWidth < 960 ? 'visible' : 'auto',
|
||||||
}}
|
}}
|
||||||
className={classes.chapters}
|
className={classes.chapters}
|
||||||
totalCount={chapters.length}
|
totalCount={chapters.length}
|
||||||
|
@ -34,7 +34,7 @@ function groupByLang(sources: ISource[]) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sources() {
|
export default function MangaSources() {
|
||||||
const { setTitle, setAction } = useContext(NavbarContext);
|
const { setTitle, setAction } = useContext(NavbarContext);
|
||||||
|
|
||||||
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', defualtLangs());
|
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', defualtLangs());
|
16
webUI/react/src/typings.d.ts
vendored
16
webUI/react/src/typings.d.ts
vendored
@ -70,6 +70,22 @@ interface IChapter {
|
|||||||
pageCount: number
|
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 {
|
interface IPartialChpter {
|
||||||
pageCount: number
|
pageCount: number
|
||||||
index: number
|
index: number
|
||||||
|
Loading…
x
Reference in New Issue
Block a user