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:
Joseph Milazzo 2022-03-19 11:13:30 -05:00 committed by GitHub
parent 50306a62ad
commit fb29d78c3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 106 additions and 53 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
.side-nav-toggle {
cursor: pointer;
margin-left: 13px;
font-size: 1.2rem;
i {
color: var(--navbar-fa-icon-color);

View File

@ -1,4 +1,4 @@
@media(max-width: $grid-breakpoints-xs) {
@media(max-width: $grid-breakpoints-sm) {
.phone-hidden {
display: none;
}