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) foreach (var item in items)
{ {
feed.Entries.Add(CreateChapter(apiKey, $"{item.SeriesName} Chapter {item.ChapterNumber}", item.ChapterId, item.VolumeId, item.SeriesId)); 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)); return CreateXmlResult(SerializeXml(feed));
} }
@ -502,7 +491,7 @@ public class OpdsController : BaseApiController
var feed = new OpenSearchDescription() var feed = new OpenSearchDescription()
{ {
ShortName = "Search", ShortName = "Search",
Description = "Search for Series", Description = "Search for Series, Collections, or Reading Lists",
Url = new SearchLink() Url = new SearchLink()
{ {
Type = FeedLinkType.AtomAcquisition, Type = FeedLinkType.AtomAcquisition,
@ -529,11 +518,22 @@ public class OpdsController : BaseApiController
SetFeedId(feed, $"series-{series.Id}"); SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}")); 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); var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes) 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)) 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)); feed.Entries.Add(CreateChapter(apiKey, special.Title, special.Id, special.VolumeId, seriesId));
} }
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
} }
@ -559,19 +557,20 @@ public class OpdsController : BaseApiController
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey); var userId = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapters = var chapters =
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number), (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
_chapterSortComparer); _chapterSortComparer);
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey); 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}-chapters"); SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{SeriesService.FormatChapterName(libraryType)}s");
foreach (var chapter in chapters) foreach (var chapter in chapters)
{ {
feed.Entries.Add(new FeedEntry() feed.Entries.Add(new FeedEntry()
{ {
Id = chapter.Id.ToString(), Id = chapter.Id.ToString(),
Title = "Chapter " + chapter.Number, Title = SeriesService.FormatChapterTitle(chapter, libraryType),
Links = new List<FeedLink>() Links = new List<FeedLink>()
{ {
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapter.Id}"), 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"); return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey); var userId = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(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); 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-{volume.Id}-chapter-{chapter.Id}-files"); SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{SeriesService.FormatChapterName(libraryType)}-{chapterId}-files");
foreach (var mangaFile in 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)); return CreateXmlResult(SerializeXml(feed));
@ -706,19 +706,21 @@ public class OpdsController : BaseApiController
return new FeedEntry() return new FeedEntry()
{ {
Id = volumeDto.Id.ToString(), Id = volumeDto.Id.ToString(),
Title = "Volume " + volumeDto.Name, Title = volumeDto.Name,
Links = new List<FeedLink>() Links = new List<FeedLink>()
{ {
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"), CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}"), Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={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) private static FeedEntry CreateChapter(string apiKey, string title, int chapterId, int volumeId, int seriesId)
{ {
return new FeedEntry() return new FeedEntry()
{ {
Id = chapterId.ToString(), Id = chapterId.ToString(),
@ -728,22 +730,36 @@ public class OpdsController : BaseApiController
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"), Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"/api/image/chapter-cover?chapterId={chapterId}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"/api/image/chapter-cover?chapterId={chapterId}") $"/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 = var fileSize =
DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List<string>() DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List<string>()
{mangaFile.FilePath})); {mangaFile.FilePath}));
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty); 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() return new FeedEntry()
{ {
Id = mangaFile.Id.ToString(), Id = mangaFile.Id.ToString(),
Title = $"{series.Name} - Volume {volume.Name} - Chapter {chapter.Number}", Title = title,
Extent = fileSize, Extent = fileSize,
Summary = $"{fileType.Split("/")[1]} - {fileSize}", Summary = $"{fileType.Split("/")[1]} - {fileSize}",
Format = mangaFile.Format.ToString(), 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.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
CreateLink(FeedLinkRelation.Thumbnail, 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 accLink,
CreateLink(FeedLinkRelation.Acquisition, fileType, $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}"),
CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey) CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey)
}, },
Content = new FeedEntryContent() Content = new FeedEntryContent()
@ -839,13 +854,14 @@ public class OpdsController : BaseApiController
return link; 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() return new FeedLink()
{ {
Rel = rel, Rel = rel,
Href = href, 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>(); var specials = new List<ChapterDto>();
foreach (var chapter in chapters) foreach (var chapter in chapters)
{ {
chapter.Title = FormatChapterTitle(chapter, libraryType);
if (chapter.IsSpecial) if (chapter.IsSpecial)
{ {
chapter.Title = Parser.Parser.CleanSpecialTitle(chapter.Title);
specials.Add(chapter); 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); 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 { BehaviorSubject, fromEvent, merge, ReplaySubject, Subject } from 'rxjs';
import { debounceTime, take, takeUntil } from 'rxjs/operators'; import { debounceTime, take, takeUntil } from 'rxjs/operators';
import { ReaderService } from '../../_services/reader.service'; 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. * 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 * 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>(); 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 // This will always exist at this point in time since this is used within manga reader
const reader = document.querySelector('.reader'); const reader = document.querySelector('.reader');
if (reader !== null) { 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. * gets promoted to fullscreen.
*/ */
initScrollHandler() { 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)) .pipe(debounceTime(20), takeUntil(this.onDestroy))
.subscribe((event) => this.handleScrollEvent(event)); .subscribe((event) => this.handleScrollEvent(event));
} }
@ -233,7 +234,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
} }
getVerticalOffset() { getVerticalOffset() {
const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : window; const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
let offset = 0; let offset = 0;
if (reader instanceof Window) { if (reader instanceof Window) {

View File

@ -1213,6 +1213,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
updateForm() { updateForm() {
if ( this.readerMode === ReaderMode.Webtoon) { if ( this.readerMode === ReaderMode.Webtoon) {
this.generalSettingsForm.get('pageSplitOption')?.disable()
this.generalSettingsForm.get('fittingOption')?.disable() this.generalSettingsForm.get('fittingOption')?.disable()
this.generalSettingsForm.get('pageSplitOption')?.disable(); this.generalSettingsForm.get('pageSplitOption')?.disable();
this.generalSettingsForm.get('layoutMode')?.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('fittingOption')?.enable()
this.generalSettingsForm.get('pageSplitOption')?.enable(); this.generalSettingsForm.get('pageSplitOption')?.enable();
this.generalSettingsForm.get('layoutMode')?.enable(); this.generalSettingsForm.get('layoutMode')?.enable();
this.generalSettingsForm.get('pageSplitOption')?.enable()
if (this.layoutMode !== LayoutMode.Single) { if (this.layoutMode !== LayoutMode.Single) {
this.generalSettingsForm.get('pageSplitOption')?.disable(); this.generalSettingsForm.get('pageSplitOption')?.disable();

View File

@ -8,6 +8,7 @@
.side-nav-toggle { .side-nav-toggle {
cursor: pointer; cursor: pointer;
margin-left: 13px;
font-size: 1.2rem; font-size: 1.2rem;
i { i {
color: var(--navbar-fa-icon-color); 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 { .phone-hidden {
display: none; display: none;
} }