Manga page Finished

This commit is contained in:
Aria Moradi 2021-05-27 17:13:22 +04:30
parent c17e3bd04f
commit 5c7123a997
29 changed files with 1857 additions and 103 deletions

View File

@ -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>
// /** // /**

View File

@ -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 ->

View 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)
}
}

View 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
)
}
}

View 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
}
}
}
}
}

View 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 }
)
}
}
}

View File

@ -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]!!
}
}

View File

@ -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
)

View File

@ -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,
)

View File

@ -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
}
}

View File

@ -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],
)

View File

@ -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()
} }

View File

@ -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
)
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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)

View File

@ -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()

View File

@ -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>

View File

@ -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' }}>

View 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;

View 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>
);
}

View 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...',
};

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
))}
</>
);
}

View 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}
/>
);
}

View File

@ -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}

View File

@ -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());

View File

@ -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