mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
OPDS Rework (#1164)
* Fixed a bug where the bottom of the page could be cut off * Adjusted all the headings to h2, which looks better * Refactored GetSeriesDetail to actually map the names inside the code so the UI just displays. * Put in some basic improvements to OPDS by using Series Detail type layout, but this only reduces one click. * Fixed a bug where offset from scrollbar fix causes readers to be cutoff. * Ensure the hamburger menu icon is aligned with side nav * Disable the image splitting dropdown in webtoon mode * Fixed broken progress/scroll code as we scroll on the body instead of window now * Fixed phone-hidden class not working due to a bad media query * Lots of changes to OPDS to provide a richer text experience. Uses Issues or Books based on library type. Cleans up the experience by providing Storyline from the get-go. * Updated OPDS-SE search description to include collections and reading lists. * Fixed up some title stuff * If a volume only has one file underneath it, flatten it and send a chapter as if it were the volume. * Code cleanup
This commit is contained in:
parent
50306a62ad
commit
fb29d78c3b
@ -317,18 +317,7 @@ public class OpdsController : BaseApiController
|
||||
foreach (var item in items)
|
||||
{
|
||||
feed.Entries.Add(CreateChapter(apiKey, $"{item.SeriesName} Chapter {item.ChapterNumber}", item.ChapterId, item.VolumeId, item.SeriesId));
|
||||
// new FeedEntry()
|
||||
// {
|
||||
// Id = item.ChapterId.ToString(),
|
||||
// Title = $"{item.SeriesName} Chapter {item.ChapterNumber}",
|
||||
// Links = new List<FeedLink>()
|
||||
// {
|
||||
// CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{item.SeriesId}/volume/{item.VolumeId}/chapter/{item.ChapterId}"),
|
||||
// CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={item.ChapterId}")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
@ -502,7 +491,7 @@ public class OpdsController : BaseApiController
|
||||
var feed = new OpenSearchDescription()
|
||||
{
|
||||
ShortName = "Search",
|
||||
Description = "Search for Series",
|
||||
Description = "Search for Series, Collections, or Reading Lists",
|
||||
Url = new SearchLink()
|
||||
{
|
||||
Type = FeedLinkType.AtomAcquisition,
|
||||
@ -529,11 +518,22 @@ public class OpdsController : BaseApiController
|
||||
SetFeedId(feed, $"series-{series.Id}");
|
||||
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
|
||||
|
||||
// NOTE: I want to try and use ReaderService to get SeriesDetails.
|
||||
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||
foreach (var volume in seriesDetail.Volumes)
|
||||
{
|
||||
feed.Entries.Add(CreateVolume(volume, seriesId, apiKey)); // We might want to emulate a volume but make this a chapter
|
||||
// If there is only one chapter to the Volume, we will emulate a volume to flatten the amount of hops a user must go through
|
||||
if (volume.Chapters.Count == 1)
|
||||
{
|
||||
var firstChapter = volume.Chapters.First();
|
||||
var chapter = CreateChapter(apiKey, volume.Name, firstChapter.Id, volume.Id, seriesId);
|
||||
chapter.Id = firstChapter.Id.ToString();
|
||||
feed.Entries.Add(chapter);
|
||||
}
|
||||
else
|
||||
{
|
||||
feed.Entries.Add(CreateVolume(volume, seriesId, apiKey));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
|
||||
@ -546,8 +546,6 @@ public class OpdsController : BaseApiController
|
||||
feed.Entries.Add(CreateChapter(apiKey, special.Title, special.Id, special.VolumeId, seriesId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
@ -559,19 +557,20 @@ public class OpdsController : BaseApiController
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var userId = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
||||
var chapters =
|
||||
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
|
||||
_chapterSortComparer);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapters");
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + $" - {SeriesService.FormatChapterName(libraryType)}s ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{SeriesService.FormatChapterName(libraryType)}s");
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Id = chapter.Id.ToString(),
|
||||
Title = "Chapter " + chapter.Number,
|
||||
Title = SeriesService.FormatChapterTitle(chapter, libraryType),
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapter.Id}"),
|
||||
@ -591,15 +590,16 @@ public class OpdsController : BaseApiController
|
||||
return BadRequest("OPDS is not enabled on this server");
|
||||
var userId = await GetUser(apiKey);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapter-{chapter.Id}-files");
|
||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + $" - {SeriesService.FormatChapterName(libraryType)}s", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
|
||||
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{SeriesService.FormatChapterName(libraryType)}-{chapterId}-files");
|
||||
foreach (var mangaFile in files)
|
||||
{
|
||||
feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey));
|
||||
feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey));
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
@ -706,19 +706,21 @@ public class OpdsController : BaseApiController
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = volumeDto.Id.ToString(),
|
||||
Title = "Volume " + volumeDto.Name,
|
||||
Title = volumeDto.Name,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}")
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||
Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
||||
$"/api/image/volume-cover?volumeId={volumeDto.Id}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
||||
$"/api/image/volume-cover?volumeId={volumeDto.Id}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static FeedEntry CreateChapter(string apiKey, string title, int chapterId, int volumeId, int seriesId)
|
||||
{
|
||||
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = chapterId.ToString(),
|
||||
@ -728,22 +730,36 @@ public class OpdsController : BaseApiController
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||
Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"),
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
||||
$"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
||||
$"/api/image/chapter-cover?chapterId={chapterId}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private FeedEntry CreateChapter(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, Volume volume, ChapterDto chapter, string apiKey)
|
||||
private async Task<FeedEntry> CreateChapterWithFile(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey)
|
||||
{
|
||||
var fileSize =
|
||||
DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List<string>()
|
||||
{mangaFile.FilePath}));
|
||||
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
|
||||
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty);
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
|
||||
|
||||
var title = $"{series.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}";
|
||||
|
||||
// Chunky requires a file at the end. Our API ignores this
|
||||
var accLink =
|
||||
CreateLink(FeedLinkRelation.Acquisition, fileType,
|
||||
$"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}",
|
||||
filename);
|
||||
accLink.TotalPages = chapter.Pages;
|
||||
|
||||
return new FeedEntry()
|
||||
{
|
||||
Id = mangaFile.Id.ToString(),
|
||||
Title = $"{series.Name} - Volume {volume.Name} - Chapter {chapter.Number}",
|
||||
Title = title,
|
||||
Extent = fileSize,
|
||||
Summary = $"{fileType.Split("/")[1]} - {fileSize}",
|
||||
Format = mangaFile.Format.ToString(),
|
||||
@ -751,8 +767,7 @@ public class OpdsController : BaseApiController
|
||||
{
|
||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||
// Chunky requires a file at the end. Our API ignores this
|
||||
CreateLink(FeedLinkRelation.Acquisition, fileType, $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}"),
|
||||
accLink,
|
||||
CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey)
|
||||
},
|
||||
Content = new FeedEntryContent()
|
||||
@ -839,13 +854,14 @@ public class OpdsController : BaseApiController
|
||||
return link;
|
||||
}
|
||||
|
||||
private static FeedLink CreateLink(string rel, string type, string href)
|
||||
private static FeedLink CreateLink(string rel, string type, string href, string title = null)
|
||||
{
|
||||
return new FeedLink()
|
||||
{
|
||||
Rel = rel,
|
||||
Href = href,
|
||||
Type = type
|
||||
Type = type,
|
||||
Title = string.IsNullOrEmpty(title) ? string.Empty : title
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -472,23 +472,11 @@ public class SeriesService : ISeriesService
|
||||
var specials = new List<ChapterDto>();
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
chapter.Title = FormatChapterTitle(chapter, libraryType);
|
||||
if (chapter.IsSpecial)
|
||||
{
|
||||
chapter.Title = Parser.Parser.CleanSpecialTitle(chapter.Title);
|
||||
specials.Add(chapter);
|
||||
}
|
||||
else
|
||||
{
|
||||
var title = libraryType switch
|
||||
{
|
||||
LibraryType.Book => $"Book {chapter.Title}",
|
||||
LibraryType.Comic => $"Issue #{chapter.Title}",
|
||||
LibraryType.Manga => $"Chapter {chapter.Title}",
|
||||
_ => "Chapter "
|
||||
};
|
||||
chapter.Title = title;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -528,4 +516,49 @@ public class SeriesService : ISeriesService
|
||||
{
|
||||
return !c.IsSpecial && !c.Number.Equals(Parser.Parser.DefaultChapter);
|
||||
}
|
||||
|
||||
public static string FormatChapterTitle(ChapterDto chapter, LibraryType libraryType)
|
||||
{
|
||||
if (chapter.IsSpecial)
|
||||
{
|
||||
return Parser.Parser.CleanSpecialTitle(chapter.Title);
|
||||
}
|
||||
return libraryType switch
|
||||
{
|
||||
LibraryType.Book => $"Book {chapter.Title}",
|
||||
LibraryType.Comic => $"Issue #{chapter.Title}",
|
||||
LibraryType.Manga => $"Chapter {chapter.Title}",
|
||||
_ => "Chapter "
|
||||
};
|
||||
}
|
||||
|
||||
public static string FormatChapterTitle(Chapter chapter, LibraryType libraryType)
|
||||
{
|
||||
if (chapter.IsSpecial)
|
||||
{
|
||||
return Parser.Parser.CleanSpecialTitle(chapter.Title);
|
||||
}
|
||||
return libraryType switch
|
||||
{
|
||||
LibraryType.Book => $"Book {chapter.Title}",
|
||||
LibraryType.Comic => $"Issue #{chapter.Title}",
|
||||
LibraryType.Manga => $"Chapter {chapter.Title}",
|
||||
_ => "Chapter "
|
||||
};
|
||||
}
|
||||
|
||||
public static string FormatChapterName(LibraryType libraryType, bool withHash = false)
|
||||
{
|
||||
switch (libraryType)
|
||||
{
|
||||
case LibraryType.Manga:
|
||||
return "Chapter";
|
||||
case LibraryType.Comic:
|
||||
return withHash ? "Issue #" : "Issue";
|
||||
case LibraryType.Book:
|
||||
return "Book";
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
||||
import { BehaviorSubject, fromEvent, merge, ReplaySubject, Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { ReaderService } from '../../_services/reader.service';
|
||||
@ -92,7 +93,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/**
|
||||
* The minimum width of images in webtoon. On image loading, this is checked and updated. All images will get this assigned to them for rendering.
|
||||
*/
|
||||
webtoonImageWidth: number = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
webtoonImageWidth: number = window.innerWidth || this.document.documentElement.clientWidth || this.document.body.clientWidth;
|
||||
/**
|
||||
* Used to tell if a scrollTo() operation is in progress
|
||||
*/
|
||||
@ -152,7 +153,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private readerService: ReaderService, private renderer: Renderer2) {
|
||||
constructor(private readerService: ReaderService, private renderer: Renderer2, @Inject(DOCUMENT) private document: Document) {
|
||||
// This will always exist at this point in time since this is used within manga reader
|
||||
const reader = document.querySelector('.reader');
|
||||
if (reader !== null) {
|
||||
@ -174,11 +175,11 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for binding the scroll handler to the correct event. On non-fullscreen, window is correct. However, on fullscreen, we must use the reader as that is what
|
||||
* Responsible for binding the scroll handler to the correct event. On non-fullscreen, body is correct. However, on fullscreen, we must use the reader as that is what
|
||||
* gets promoted to fullscreen.
|
||||
*/
|
||||
initScrollHandler() {
|
||||
fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : window, 'scroll')
|
||||
fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body, 'scroll')
|
||||
.pipe(debounceTime(20), takeUntil(this.onDestroy))
|
||||
.subscribe((event) => this.handleScrollEvent(event));
|
||||
}
|
||||
@ -233,7 +234,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
getVerticalOffset() {
|
||||
const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : window;
|
||||
const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
|
||||
|
||||
let offset = 0;
|
||||
if (reader instanceof Window) {
|
||||
|
@ -1213,6 +1213,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
updateForm() {
|
||||
|
||||
if ( this.readerMode === ReaderMode.Webtoon) {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.disable()
|
||||
this.generalSettingsForm.get('fittingOption')?.disable()
|
||||
this.generalSettingsForm.get('pageSplitOption')?.disable();
|
||||
this.generalSettingsForm.get('layoutMode')?.disable();
|
||||
@ -1220,6 +1221,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.generalSettingsForm.get('fittingOption')?.enable()
|
||||
this.generalSettingsForm.get('pageSplitOption')?.enable();
|
||||
this.generalSettingsForm.get('layoutMode')?.enable();
|
||||
this.generalSettingsForm.get('pageSplitOption')?.enable()
|
||||
|
||||
if (this.layoutMode !== LayoutMode.Single) {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.disable();
|
||||
|
@ -8,6 +8,7 @@
|
||||
|
||||
.side-nav-toggle {
|
||||
cursor: pointer;
|
||||
margin-left: 13px;
|
||||
font-size: 1.2rem;
|
||||
i {
|
||||
color: var(--navbar-fa-icon-color);
|
||||
|
@ -1,4 +1,4 @@
|
||||
@media(max-width: $grid-breakpoints-xs) {
|
||||
@media(max-width: $grid-breakpoints-sm) {
|
||||
.phone-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user