diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 2a58d75f2..b81075c6a 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -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() - // { - // 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() { 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() { - 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 CreateChapterWithFile(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey) { var fileSize = DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List() {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 }; } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 30556ad26..14969884f 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -472,23 +472,11 @@ public class SeriesService : ISeriesService var specials = new List(); 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); + } + } } diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts index 652e386cf..4c4acd0e3 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts @@ -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(); - 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) { diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index 47b52f35e..0770fe344 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -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(); diff --git a/UI/Web/src/app/nav-header/nav-header.component.scss b/UI/Web/src/app/nav-header/nav-header.component.scss index 9491cfb52..1dc707375 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.scss +++ b/UI/Web/src/app/nav-header/nav-header.component.scss @@ -8,6 +8,7 @@ .side-nav-toggle { cursor: pointer; + margin-left: 13px; font-size: 1.2rem; i { color: var(--navbar-fa-icon-color); diff --git a/UI/Web/src/theme/utilities/_utilities.scss b/UI/Web/src/theme/utilities/_utilities.scss index 32bc0b195..c28240e2c 100644 --- a/UI/Web/src/theme/utilities/_utilities.scss +++ b/UI/Web/src/theme/utilities/_utilities.scss @@ -1,4 +1,4 @@ -@media(max-width: $grid-breakpoints-xs) { +@media(max-width: $grid-breakpoints-sm) { .phone-hidden { display: none; }