diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 09d4b0556..4dcb77dec 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -24,6 +24,7 @@ namespace API.Tests.Services public void GetFilesTest_Should_Be28() { var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Manga"); + // ReSharper disable once CollectionNeverQueried.Local var files = new List(); var fileCount = DirectoryService.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), API.Parser.Parser.ArchiveFileExtensions, _logger); diff --git a/API/Configurations/CustomOptions/StatsOptions.cs b/API/Configurations/CustomOptions/StatsOptions.cs deleted file mode 100644 index ac0cd0ac5..000000000 --- a/API/Configurations/CustomOptions/StatsOptions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; - -namespace API.Configurations.CustomOptions -{ - public class StatsOptions - { - public string ServerUrl { get; set; } - public string ServerSecret { get; set; } - public string SendDataAt { get; set; } - - private const char Separator = ':'; - - public short SendDataHour => GetValueFromSendAt(0); - public short SendDataMinute => GetValueFromSendAt(1); - - // The expected SendDataAt format is: Hour:Minute. Ex: 19:45 - private short GetValueFromSendAt(int index) - { - var key = $"{nameof(StatsOptions)}:{nameof(SendDataAt)}"; - - if (string.IsNullOrEmpty(SendDataAt)) - throw new InvalidOperationException($"{key} is invalid. Check the app settings file"); - - if (short.TryParse(SendDataAt.Split(Separator)[index], out var parsedValue)) - return parsedValue; - - throw new InvalidOperationException($"Could not parse {key}. Check the app settings file"); - } - } -} \ No newline at end of file diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index 6e7408fa4..1efcca35e 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -35,21 +35,21 @@ namespace API.Controllers var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath))); } - + [HttpGet("chapter-size")] public async Task> GetChapterSize(int chapterId) { var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId); return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath))); } - + [HttpGet("series-size")] public async Task> GetSeriesSize(int seriesId) { var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath))); } - + [HttpGet("volume")] public async Task DownloadVolume(int volumeId) { @@ -60,9 +60,9 @@ namespace API.Controllers { return await GetFirstFileDownload(files); } - var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), + var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), $"download_{User.GetUsername()}_v{volumeId}"); - return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); + return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); } catch (KavitaException ex) { @@ -74,7 +74,7 @@ namespace API.Controllers { var firstFile = files.Select(c => c.FilePath).First(); var fileProvider = new FileExtensionContentTypeProvider(); - // Figures out what the content type should be based on the file name. + // Figures out what the content type should be based on the file name. if (!fileProvider.TryGetContentType(firstFile, out var contentType)) { contentType = Path.GetExtension(firstFile).ToLowerInvariant() switch @@ -89,7 +89,7 @@ namespace API.Controllers }; } - return File(await _directoryService.ReadFileAsync(firstFile), contentType, Path.GetFileNameWithoutExtension(firstFile)); + return File(await _directoryService.ReadFileAsync(firstFile), contentType, Path.GetFileName(firstFile)); } [HttpGet("chapter")] @@ -102,9 +102,9 @@ namespace API.Controllers { return await GetFirstFileDownload(files); } - var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), + var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), $"download_{User.GetUsername()}_c{chapterId}"); - return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); + return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); } catch (KavitaException ex) { @@ -122,9 +122,9 @@ namespace API.Controllers { return await GetFirstFileDownload(files); } - var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), + var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), $"download_{User.GetUsername()}_s{seriesId}"); - return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); + return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip"); } catch (KavitaException ex) { @@ -132,4 +132,4 @@ namespace API.Controllers } } } -} \ No newline at end of file +} diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index bfdacf704..c9970b1e5 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -11,7 +11,6 @@ using API.Extensions; using API.Interfaces; using API.Interfaces.Services; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; namespace API.Controllers { @@ -19,17 +18,14 @@ namespace API.Controllers { private readonly IDirectoryService _directoryService; private readonly ICacheService _cacheService; - private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - public ReaderController(IDirectoryService directoryService, ICacheService cacheService, - ILogger logger, IUnitOfWork unitOfWork) + public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork) { _directoryService = directoryService; _cacheService = cacheService; - _logger = logger; _unitOfWork = unitOfWork; } @@ -238,35 +234,43 @@ namespace API.Controllers } - user.Progresses ??= new List(); - var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id); - - if (userProgress == null) + try { - user.Progresses.Add(new AppUserProgress - { - PagesRead = bookmarkDto.PageNum, - VolumeId = bookmarkDto.VolumeId, - SeriesId = bookmarkDto.SeriesId, - ChapterId = bookmarkDto.ChapterId, - BookScrollId = bookmarkDto.BookScrollId, - LastModified = DateTime.Now - }); + user.Progresses ??= new List(); + var userProgress = + user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id); + + if (userProgress == null) + { + user.Progresses.Add(new AppUserProgress + { + PagesRead = bookmarkDto.PageNum, + VolumeId = bookmarkDto.VolumeId, + SeriesId = bookmarkDto.SeriesId, + ChapterId = bookmarkDto.ChapterId, + BookScrollId = bookmarkDto.BookScrollId, + LastModified = DateTime.Now + }); + } + else + { + userProgress.PagesRead = bookmarkDto.PageNum; + userProgress.SeriesId = bookmarkDto.SeriesId; + userProgress.VolumeId = bookmarkDto.VolumeId; + userProgress.BookScrollId = bookmarkDto.BookScrollId; + userProgress.LastModified = DateTime.Now; + } + + _unitOfWork.UserRepository.Update(user); + + if (await _unitOfWork.CommitAsync()) + { + return Ok(); + } } - else + catch (Exception) { - userProgress.PagesRead = bookmarkDto.PageNum; - userProgress.SeriesId = bookmarkDto.SeriesId; - userProgress.VolumeId = bookmarkDto.VolumeId; - userProgress.BookScrollId = bookmarkDto.BookScrollId; - userProgress.LastModified = DateTime.Now; - } - - _unitOfWork.UserRepository.Update(user); - - if (await _unitOfWork.CommitAsync()) - { - return Ok(); + await _unitOfWork.RollbackAsync(); } return BadRequest("Could not save progress"); diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index c68416ba9..b93a1178b 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -6,7 +6,6 @@ using API.Extensions; using API.Interfaces.Services; using API.Services; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; diff --git a/API/Data/VolumeRepository.cs b/API/Data/VolumeRepository.cs index 78a078e03..3bcd092a9 100644 --- a/API/Data/VolumeRepository.cs +++ b/API/Data/VolumeRepository.cs @@ -20,7 +20,7 @@ namespace API.Data _context = context; _mapper = mapper; } - + public void Update(Volume volume) { _context.Entry(volume).State = EntityState.Modified; @@ -37,8 +37,8 @@ namespace API.Data .Include(c => c.Files) .SingleOrDefaultAsync(c => c.Id == chapterId); } - - + + /// /// Returns Chapters for a volume id. /// @@ -65,7 +65,7 @@ namespace API.Data .SingleOrDefaultAsync(); } - + public async Task GetChapterDtoAsync(int chapterId) @@ -82,11 +82,11 @@ namespace API.Data public async Task> GetFilesForChapter(int chapterId) { return await _context.MangaFile - .Where(c => chapterId == c.Id) + .Where(c => chapterId == c.ChapterId) .AsNoTracking() .ToListAsync(); } - + public async Task> GetFilesForVolume(int volumeId) { return await _context.Chapter @@ -97,4 +97,4 @@ namespace API.Data .ToListAsync(); } } -} \ No newline at end of file +} diff --git a/API/Services/Clients/StatsApiClient.cs b/API/Services/Clients/StatsApiClient.cs index 29a8cf8dd..478852db5 100644 --- a/API/Services/Clients/StatsApiClient.cs +++ b/API/Services/Clients/StatsApiClient.cs @@ -2,26 +2,21 @@ using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; - -using API.Configurations.CustomOptions; using API.DTOs; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace API.Services.Clients { public class StatsApiClient { private readonly HttpClient _client; - private readonly StatsOptions _options; private readonly ILogger _logger; private const string ApiUrl = "http://stats.kavitareader.com"; - public StatsApiClient(HttpClient client, IOptions options, ILogger logger) + public StatsApiClient(HttpClient client, ILogger logger) { _client = client; _logger = logger; - _options = options.Value ?? throw new ArgumentNullException(nameof(options)); } public async Task SendDataToStatsServer(UsageStatisticsDto data) diff --git a/API/Startup.cs b/API/Startup.cs index 5fdafb448..e443ae5d8 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -101,7 +101,7 @@ namespace API // Ordering is important. Cors, authentication, authorization if (env.IsDevelopment()) { - app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:4200")); + app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:4200").WithExposedHeaders("Content-Disposition")); } app.UseResponseCaching(); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab4a8a30e..c9befd8a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,12 +19,11 @@ Setup guides, FAQ, the more information we have on the [wiki](https://github.com 1. Fork Kavita 2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository-from-github) - - Kavita as of v0.4.2 requires Kavita-webui to be cloned next to the Kavita. Fork and clone this as well. 3. Install the required Node Packages - - cd kavita-webui + - cd Kavita/UI/Web - `npm install` - `npm install -g @angular/cli` -4. Start webui server `ng serve` +4. Start angular server `ng serve` 5. Build the project in Visual Studio/Rider, Setting startup project to `API` 6. Debug the project in Visual Studio/Rider 7. Open http://localhost:4200 @@ -41,10 +40,10 @@ Setup guides, FAQ, the more information we have on the [wiki](https://github.com - Commit with *nix line endings for consistency (We checkout Windows and commit *nix) - One feature/bug fix per pull request to keep things clean and easy to understand - Use 4 spaces instead of tabs, this is the default for VS 2019 and WebStorm (to my knowledge) - - Use 2 spaces for Kavita-webui files + - Use 2 spaces for UI files ### Pull Requesting ### -- Only make pull requests to develop, never master, if you make a PR to master we'll comment on it and close it +- Only make pull requests to develop, never main, if you make a PR to main we'll comment on it and close it - You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability - We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it - Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed) @@ -52,5 +51,7 @@ Setup guides, FAQ, the more information we have on the [wiki](https://github.com - fix-bug (Good) - patch (Bad) - develop (Bad) + - feature/parser-enhancements (Great) + - bugfix/book-issues (Great) If you have any questions about any of this, please let us know. diff --git a/UI/Web/src/app/_guards/auth.guard.ts b/UI/Web/src/app/_guards/auth.guard.ts index 4d4aae01b..bf3d5d576 100644 --- a/UI/Web/src/app/_guards/auth.guard.ts +++ b/UI/Web/src/app/_guards/auth.guard.ts @@ -10,6 +10,7 @@ import { AccountService } from '../_services/account.service'; providedIn: 'root' }) export class AuthGuard implements CanActivate { + public urlKey: string = 'kavita--auth-intersection-url'; constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {} canActivate(): Observable { @@ -19,6 +20,7 @@ export class AuthGuard implements CanActivate { return true; } this.toastr.error('You are not authorized to view this page.'); + localStorage.setItem(this.urlKey, window.location.pathname); this.router.navigateByUrl('/home'); return false; }) diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 941bd14b9..9f8645a39 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -15,6 +15,7 @@ import { environment } from 'src/environments/environment'; @Injectable() export class ErrorInterceptor implements HttpInterceptor { + public urlKey: string = 'kavita--no-connection-url'; constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {} @@ -50,7 +51,7 @@ export class ErrorInterceptor implements HttpInterceptor { // If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there if (this.router.url !== '/no-connection') { - localStorage.setItem('kavita--no-connection-url', this.router.url); + localStorage.setItem(this.urlKey, this.router.url); this.router.navigateByUrl('/no-connection'); } break; diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index b36160b9c..db5bead01 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -27,4 +27,4 @@ export interface Preferences { export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}]; export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}]; export const pageSplitOptions = [{text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}]; -export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}/*, {text: 'Webtoon', value: READER_MODE.WEBTOON}*/]; +export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}, {text: 'Webtoon', value: READER_MODE.WEBTOON}]; diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 056169644..9e13c80a7 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -1,6 +1,5 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -//import * as console.log from 'console.log'; import { environment } from 'src/environments/environment'; import { ChapterInfo } from '../manga-reader/_models/chapter-info'; import { UtilityService } from '../shared/_services/utility.service'; diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index 8f6d6c8ca..a4ffa2873 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -196,22 +196,30 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * After the page has loaded, setup the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the + * table of content) then we calculate what has already been reached and grab the last reached one to bookmark. If page anchors aren't setup (toc missing), then try to bookmark + * based on the last seen scroll part (xpath). + */ ngAfterViewInit() { // check scroll offset and if offset is after any of the "id" markers, bookmark it fromEvent(window, 'scroll') .pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => { if (this.isLoading) return; - if (Object.keys(this.pageAnchors).length === 0) return; - // get the height of the document so we can capture markers that are halfway on the document viewport - const verticalOffset = (window.pageYOffset - || document.documentElement.scrollTop - || document.body.scrollTop || 0) + (document.body.offsetHeight / 2); - - const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); - if (alreadyReached.length > 0) { - this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; - } else { - this.currentPageAnchor = ''; + if (Object.keys(this.pageAnchors).length !== 0) { + // get the height of the document so we can capture markers that are halfway on the document viewport + const verticalOffset = (window.pageYOffset + || document.documentElement.scrollTop + || document.body.scrollTop || 0) + (document.body.offsetHeight / 2); + + const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); + if (alreadyReached.length > 0) { + this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; + this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); + return; + } else { + this.currentPageAnchor = ''; + } } if (this.lastSeenScrollPartPath !== '') { @@ -253,7 +261,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const chapterId = this.route.snapshot.paramMap.get('chapterId'); if (libraryId === null || seriesId === null || chapterId === null) { - this.router.navigateByUrl('/home'); + this.router.navigateByUrl('/library'); return; } diff --git a/UI/Web/src/app/home/home.component.ts b/UI/Web/src/app/home/home.component.ts index 0ee803e1e..4de69832b 100644 --- a/UI/Web/src/app/home/home.component.ts +++ b/UI/Web/src/app/home/home.component.ts @@ -40,7 +40,6 @@ export class HomeComponent implements OnInit { .subscribe(resp => {/* No Operation */}); if (user) { - // User is logged in, redirect to libraries this.router.navigateByUrl('/library'); } else { this.router.navigateByUrl('/login'); diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html index a7daf12d0..d3e5f78fe 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html @@ -3,7 +3,7 @@ Captures Scroll Events: {{!this.isScrolling && this.allImagesLoaded}} Is Scrolling: {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}} All Images Loaded: {{this.allImagesLoaded}} - Prefetched {{minPrefetchedWebtoonImage}}-{{maxPrefetchedWebtoonImage}} + Prefetched {{minPageLoaded}}-{{maxPageLoaded}} Current Page:{{pageNum}} Width: {{webtoonImageWidth}} 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 c87bf8378..4a8e36dd0 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,8 +1,7 @@ import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core'; -import { BehaviorSubject, fromEvent, Subject } from 'rxjs'; +import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs'; import { debounceTime, takeUntil } from 'rxjs/operators'; -import { CircularArray } from 'src/app/shared/data-structures/circular-array'; -import { ReaderService } from 'src/app/_services/reader.service'; +import { ReaderService } from '../../_services/reader.service'; import { PAGING_DIRECTION } from '../_models/reader-enums'; import { WebtoonImage } from '../_models/webtoon-image'; @@ -20,7 +19,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { /** * Number of pages to prefetch ahead of position */ - @Input() buffferPages: number = 5; + @Input() bufferPages: number = 5; /** * Total number of pages */ @@ -30,6 +29,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { */ @Input() urlProvider!: (page: number) => string; @Output() pageNumberChange: EventEmitter = new EventEmitter(); + + @Input() goToPage: ReplaySubject = new ReplaySubject(); /** * Stores and emits all the src urls @@ -38,9 +39,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { /** * Responsible for calculating current page on screen and uses hooks to trigger prefetching. - * Note: threshold will fire differently due to size of images. 1 requires full image on screen. 0 means 1px on screen. + * Note: threshold will fire differently due to size of images. 1 requires full image on screen. 0 means 1px on screen. We use 0.01 as 0 does not work currently. */ - intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: [0] }); + intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: 0.01 }); /** * Direction we are scrolling. Controls calculations for prefetching */ @@ -53,19 +54,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * Temp variable to keep track of when the scrollTo() finishes, so we can start capturing scroll events again */ currentPageElem: Element | null = null; - /** - * The min page number that has been prefetched - */ - minPrefetchedWebtoonImage: number = Number.MAX_SAFE_INTEGER; - /** - * The max page number that has been prefetched - */ - maxPrefetchedWebtoonImage: number = Number.MIN_SAFE_INTEGER; /** * 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; - /** * Used to tell if a scrollTo() operation is in progress */ @@ -74,24 +66,22 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * Whether all prefetched images have loaded on the screen (not neccesarily in viewport) */ allImagesLoaded: boolean = false; - + /** + * Denotes each page that has been loaded or not. If pruning is implemented, the key will be deleted. + */ + imagesLoaded: {[key: number]: number} = {}; /** * Debug mode. Will show extra information */ - debug: boolean = true; + debug: boolean = false; - /** - * Timer to help detect when a scroll end event has occured (not used) - */ - scrollEndTimer: any; + get minPageLoaded() { + return Math.min(...Object.keys(this.imagesLoaded).map(key => parseInt(key, 10))); + } - - /** - * Each pages height mapped to page number as key (not used) - */ - pageHeights:{[key: number]: number} = {}; - - buffer: CircularArray = new CircularArray([], 0); + get maxPageLoaded() { + return Math.max(...Object.keys(this.imagesLoaded).map(key => parseInt(key, 10))); + } @@ -100,40 +90,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { constructor(private readerService: ReaderService, private renderer: Renderer2) { } ngOnChanges(changes: SimpleChanges): void { - let shouldInit = false; - console.log('Changes: ', changes); - if (changes.hasOwnProperty('totalPages') && changes['totalPages'].currentValue === 0) { - this.debugLog('Swallowing variable change due to totalPages being 0'); - return; - } - if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) { this.totalPages = changes['totalPages'].currentValue; - shouldInit = true; - } - if (changes.hasOwnProperty('pageNum') && changes['pageNum'].previousValue != changes['pageNum'].currentValue) { - // Manually update pageNum as we are getting notified from a parent component, hence we shouldn't invoke update - this.setPageNum(changes['pageNum'].currentValue); - if (Math.abs(changes['pageNum'].currentValue - changes['pageNum'].previousValue) > 2) { - // Go to page has occured - shouldInit = true; - } - } - - if (shouldInit) { this.initWebtoonReader(); } - - // This should only execute on initial load or from a gotopage update - const currentImage = document.querySelector('img#page-' + this.pageNum); - if (currentImage !== null && this.isElementVisible(currentImage)) { - - if ((changes.hasOwnProperty('pageNum') && Math.abs(changes['pageNum'].previousValue - changes['pageNum'].currentValue) <= 0) || !shouldInit) { - return; - } - this.debugLog('Scrolling to page', this.pageNum); - this.scrollToCurrentPage(); - } } ngOnDestroy(): void { @@ -146,24 +106,36 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { fromEvent(window, 'scroll') .pipe(debounceTime(20), takeUntil(this.onDestroy)) .subscribe((event) => this.handleScrollEvent(event)); + + if (this.goToPage) { + this.goToPage.pipe(takeUntil(this.onDestroy)).subscribe(page => { + this.debugLog('[GoToPage] jump has occured from ' + this.pageNum + ' to ' + page); + const isSamePage = this.pageNum === page; + if (isSamePage) { return; } + + if (this.pageNum < page) { + this.scrollingDirection = PAGING_DIRECTION.FORWARD; + } else { + this.scrollingDirection = PAGING_DIRECTION.BACKWARDS; + } + + this.setPageNum(page, true); + }); + } } + /** + * On scroll in document, calculate if the user/javascript has scrolled to the current image element (and it's visible), update that scrolling has ended completely, + * and calculate the direction the scrolling is occuring. This is used for prefetching. + * @param event Scroll Event + */ handleScrollEvent(event?: any) { const verticalOffset = (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0); - - clearTimeout(this.scrollEndTimer); - this.scrollEndTimer = setTimeout(() => this.handleScrollEnd(), 150); - - if (this.debug && this.isScrolling) { - this.debugLog('verticalOffset: ', verticalOffset); - this.debugLog('scroll to element offset: ', this.currentPageElem?.getBoundingClientRect().top); - } - - if (this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) { - this.debugLog('Image is visible from scroll, isScrolling is now false'); + if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) { + this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false'); this.isScrolling = false; } @@ -175,58 +147,36 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { this.prevScrollPosition = verticalOffset; } - // ! This will fire twice from an automatic scroll - handleScrollEnd() { - //console.log('!!! Scroll End Event !!!'); - } - - /** * Is any part of the element visible in the scrollport. Does not take into account * style properites, just scroll port visibility. - * Note: use && to ensure the whole images is visible * @param elem * @returns */ isElementVisible(elem: Element) { if (elem === null || elem === undefined) { return false; } - const docViewTop = window.pageYOffset; - const docViewBottom = docViewTop + window.innerHeight; + // NOTE: This will say an element is visible if it is 1 px offscreen on top + var rect = elem.getBoundingClientRect(); - const elemTop = elem.getBoundingClientRect().top; - const elemBottom = elemTop + elem.getBoundingClientRect().height; - - return ((elemBottom <= docViewBottom) || (elemTop >= docViewTop)); + return (rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= (window.innerHeight || document.documentElement.clientHeight) && + rect.left <= (window.innerWidth || document.documentElement.clientWidth) + ); } initWebtoonReader() { - - this.minPrefetchedWebtoonImage = this.pageNum; - this.maxPrefetchedWebtoonImage = Number.MIN_SAFE_INTEGER; + this.imagesLoaded = {}; this.webtoonImages.next([]); - - - - const prefetchStart = Math.max(this.pageNum - this.buffferPages, 0); - const prefetchMax = Math.min(this.pageNum + this.buffferPages, this.totalPages); + const prefetchStart = Math.max(this.pageNum - this.bufferPages, 0); + const prefetchMax = Math.min(this.pageNum + this.bufferPages, this.totalPages); this.debugLog('[INIT] Prefetching pages ' + prefetchStart + ' to ' + prefetchMax + '. Current page: ', this.pageNum); for(let i = prefetchStart; i < prefetchMax; i++) { - this.prefetchWebtoonImage(i); + this.loadWebtoonImage(i); } - - const images = []; - for (let i = prefetchStart; i < prefetchMax; i++) { - images.push(new Image()); - } - this.buffer = new CircularArray(images, this.buffferPages); - - this.minPrefetchedWebtoonImage = prefetchStart; - this.maxPrefetchedWebtoonImage = prefetchMax; - - this.debugLog('Buffer: ', this.buffer.arr.map(img => this.readerService.imageUrlToPageNum(img.src))); } /** @@ -236,15 +186,17 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { */ onImageLoad(event: any) { const imagePage = this.readerService.imageUrlToPageNum(event.target.src); - this.debugLog('Image loaded: ', imagePage); + this.debugLog('[Image Load] Image loaded: ', imagePage); + + if (!this.imagesLoaded.hasOwnProperty(imagePage)) { + this.imagesLoaded[imagePage] = imagePage; + } if (event.target.width < this.webtoonImageWidth) { this.webtoonImageWidth = event.target.width; } - this.pageHeights[imagePage] = event.target.getBoundingClientRect().height; - this.renderer.setAttribute(event.target, 'width', this.webtoonImageWidth + ''); this.renderer.setAttribute(event.target, 'height', event.target.height + ''); @@ -255,59 +207,99 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { .filter((img: any) => !img.complete) .map((img: any) => new Promise(resolve => { img.onload = img.onerror = resolve; }))) .then(() => { - this.debugLog('! Loaded current page !', this.pageNum); + this.debugLog('[Image Load] ! Loaded current page !', this.pageNum); + this.currentPageElem = document.querySelector('img#page-' + this.pageNum); + + if (this.currentPageElem && !this.isElementVisible(this.currentPageElem)) { + this.scrollToCurrentPage(); + } + this.allImagesLoaded = true; }); } } handleIntersection(entries: IntersectionObserverEntry[]) { - if (!this.allImagesLoaded || this.isScrolling) { - this.debugLog('Images are not loaded (or performing scrolling action), skipping any scroll calculations'); + this.debugLog('[Intersection] Images are not loaded (or performing scrolling action), skipping any scroll calculations'); return; } - entries.forEach(entry => { const imagePage = parseInt(entry.target.attributes.getNamedItem('page')?.value + '', 10); this.debugLog('[Intersection] Page ' + imagePage + ' is visible: ', entry.isIntersecting); if (entry.isIntersecting) { this.debugLog('[Intersection] ! Page ' + imagePage + ' just entered screen'); this.setPageNum(imagePage); - this.prefetchWebtoonImages(); } }); } - setPageNum(pageNum: number) { + /** + * Set the page number, invoke prefetching and optionally scroll to the new page. + * @param pageNum Page number to set to. Will trigger the pageNumberChange event emitter. + * @param scrollToPage Optional (default false) parameter to trigger scrolling to the newly set page + */ + setPageNum(pageNum: number, scrollToPage: boolean = false) { this.pageNum = pageNum; this.pageNumberChange.emit(this.pageNum); - //TODO: Perform check here to see if prefetching or DOM removal is needed + this.prefetchWebtoonImages(); + // TODO: We can prune DOM based on our buffer + // Note: Can i test if we can put this dom pruning async, so user doesn't feel it? (don't forget to unobserve image when purging) + // I can feel a noticable scroll spike from this code (commenting out pruning until rest of the bugs are sorted) + // const images = document.querySelectorAll('img').forEach(img => { + // const imagePageNum = this.readerService.imageUrlToPageNum(img.src); + // if (imagePageNum < this.pageNum - this.bufferPages) { // this.minPrefetchedWebtoonImage + // console.log('Image ' + imagePageNum + ' is outside minimum range, pruning from DOM'); + // } else if (imagePageNum > this.pageNum + 1 + this.bufferPages) { // this.maxPrefetchedWebtoonImage + // console.log('Image ' + imagePageNum + ' is outside maximum range, pruning from DOM'); + // } + // // NOTE: Max and Mins don't update as we scroll! + // }); + + + if (scrollToPage) { + const currentImage = document.querySelector('img#page-' + this.pageNum); + if (currentImage !== null && !this.isElementVisible(currentImage)) { + this.debugLog('[GoToPage] Scrolling to page', this.pageNum); + this.scrollToCurrentPage(); + } + } } isScrollingForwards() { return this.scrollingDirection === PAGING_DIRECTION.FORWARD; } + /** + * Performs the scroll for the current page element. Updates any state variables needed. + */ scrollToCurrentPage() { this.currentPageElem = document.querySelector('img#page-' + this.pageNum); if (!this.currentPageElem) { return; } + //if (this.isElementVisible(this.currentPageElem)) { return; } // Update prevScrollPosition, so the next scroll event properly calculates direction this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top; this.isScrolling = true; setTimeout(() => { if (this.currentPageElem) { + this.debugLog('[Scroll] Scrolling to page ', this.pageNum); this.currentPageElem.scrollIntoView({behavior: 'smooth'}); } }, 600); } - prefetchWebtoonImage(page: number) { + loadWebtoonImage(page: number) { let data = this.webtoonImages.value; + if (this.imagesLoaded.hasOwnProperty(page)) { + this.debugLog('\t[PREFETCH] Skipping prefetch of ', page); + return; + } + this.debugLog('\t[PREFETCH] Prefetching ', page); + data = data.concat({src: this.urlProvider(page), page}); data.sort((a: WebtoonImage, b: WebtoonImage) => { @@ -316,32 +308,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { else return 0; }); - if (page < this.minPrefetchedWebtoonImage) { - this.minPrefetchedWebtoonImage = page; - } - if (page > this.maxPrefetchedWebtoonImage) { - this.maxPrefetchedWebtoonImage = page; - } this.allImagesLoaded = false; - this.webtoonImages.next(data); - - let index = 1; - - this.buffer.applyFor((item, i) => { - const offsetIndex = this.pageNum + index; - const urlPageNum = this.readerService.imageUrlToPageNum(item.src); - if (urlPageNum === offsetIndex) { - index += 1; - return; - } - if (offsetIndex < this.totalPages - 1) { - item.src = this.urlProvider(offsetIndex); - index += 1; - } - }, this.buffer.size() - 3); - - } attachIntersectionObserverElem(elem: HTMLImageElement) { @@ -349,27 +317,23 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { this.intersectionObserver.observe(elem); this.debugLog('Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src)); } else { - console.error('Could not attach observer on elem'); + console.error('Could not attach observer on elem'); // This never happens } } - prefetchWebtoonImages() { + calculatePrefetchIndecies() { let startingIndex = 0; let endingIndex = 0; if (this.isScrollingForwards()) { - startingIndex = Math.min(this.maxPrefetchedWebtoonImage + 1, this.totalPages); - endingIndex = Math.min(this.maxPrefetchedWebtoonImage + 1 + this.buffferPages, this.totalPages); + startingIndex = Math.min(Math.max(this.pageNum - this.bufferPages, 0), this.totalPages); + endingIndex = Math.min(Math.max(this.pageNum + this.bufferPages, 0), this.totalPages); if (startingIndex === this.totalPages) { - return; + return [0, 0]; } } else { - startingIndex = Math.max(this.minPrefetchedWebtoonImage - 1, 0) ; - endingIndex = Math.max(this.minPrefetchedWebtoonImage - 1 - this.buffferPages, 0); - - if (startingIndex <= 0) { - return; - } + startingIndex = Math.max(this.pageNum - this.bufferPages, 0); + endingIndex = Math.max(this.pageNum + this.bufferPages, 0); } @@ -378,20 +342,33 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { startingIndex = endingIndex; endingIndex = temp; } - this.debugLog('[Prefetch] prefetching pages: ' + startingIndex + ' to ' + endingIndex); - this.debugLog(' [Prefetch] page num: ', this.pageNum); + + return [startingIndex, endingIndex]; + } + + range(size: number, startAt: number = 0): ReadonlyArray { + return [...Array(size).keys()].map(i => i + startAt); + } + + prefetchWebtoonImages() { + let [startingIndex, endingIndex] = this.calculatePrefetchIndecies(); + if (startingIndex === 0 && endingIndex === 0) { return; } + + // NOTE: This code isn't required now that we buffer around our current page. There will never be a request that is outside our bounds // If a request comes in to prefetch over current page +/- bufferPages (+ 1 due to requesting from next/prev page), then deny it - this.debugLog(' [Prefetch] Caps: ' + (this.pageNum - (this.buffferPages + 1)) + ' - ' + (this.pageNum + (this.buffferPages + 1))); - if (this.isScrollingForwards() && startingIndex > this.pageNum + (this.buffferPages + 1)) { - this.debugLog('[Prefetch] A request that is too far outside buffer range has been declined', this.pageNum); - return; - } - if (!this.isScrollingForwards() && endingIndex < (this.pageNum - (this.buffferPages + 1))) { - this.debugLog('[Prefetch] A request that is too far outside buffer range has been declined', this.pageNum); - return; - } + // if (this.isScrollingForwards() && startingIndex > this.pageNum + (this.bufferPages + 1)) { + // this.debugLog('\t[PREFETCH] A request that is too far outside buffer range has been declined', this.pageNum); + // return; + // } + // if (!this.isScrollingForwards() && endingIndex < (this.pageNum - (this.bufferPages + 1))) { + // this.debugLog('\t[PREFETCH] A request that is too far outside buffer range has been declined', this.pageNum); + // return; + // } + + this.debugLog('\t[PREFETCH] prefetching pages: ' + startingIndex + ' to ' + endingIndex); + for(let i = startingIndex; i < endingIndex; i++) { - this.prefetchWebtoonImage(i); + this.loadWebtoonImage(i); } Promise.all(Array.from(document.querySelectorAll('img')) diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index 281a443b2..7f5f31994 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -25,7 +25,7 @@ ondragstart="return false;" onselectstart="return false;">
- +
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 f1cacaa39..4eeb8f059 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -10,7 +10,7 @@ import { NavService } from '../_services/nav.service'; import { ReadingDirection } from '../_models/preferences/reading-direction'; import { ScalingOption } from '../_models/preferences/scaling-option'; import { PageSplitOption } from '../_models/preferences/page-split-option'; -import { forkJoin, Subject } from 'rxjs'; +import { forkJoin, ReplaySubject, Subject } from 'rxjs'; import { ToastrService } from 'ngx-toastr'; import { KEY_CODES } from '../shared/_services/utility.service'; import { CircularArray } from '../shared/data-structures/circular-array'; @@ -103,7 +103,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * @see Stack */ continuousChaptersStack: Stack = new Stack(); - + + /** + * An event emiter when a page change occurs. Used soley by the webtoon reader. + */ + goToPageEvent: ReplaySubject = new ReplaySubject(); /** * If the menu is open/visible. @@ -308,6 +312,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.readerService.resetOverrideStyles(); this.navService.showNavBar(); this.onDestroy.next(); + this.onDestroy.complete(); + this.goToPageEvent.complete(); } @HostListener('window:keyup', ['$event']) @@ -798,6 +804,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.setPageNum(page); this.refreshSlider.emit(); + this.goToPageEvent.next(page); this.render(); } @@ -896,19 +903,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } handleWebtoonPageChange(updatedPageNum: number) { - console.log('[MangaReader] Handling Page Change'); - - this.pageNum = updatedPageNum; - + this.setPageNum(updatedPageNum); this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); - if (this.pageNum >= this.maxPages - 10) { - // Tell server to cache the next chapter - if (this.nextChapterId > 0 && !this.nextChapterPrefetched) { - this.readerService.getChapterInfo(this.nextChapterId).pipe(take(1)).subscribe(res => { - this.nextChapterPrefetched = true; - }); - } - } } saveSettings() { diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 29701597d..8e32ffca4 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Series } from 'src/app/_models/series'; import { environment } from 'src/environments/environment'; @@ -35,15 +35,15 @@ export class DownloadService { } private downloadSeriesAPI(seriesId: number) { - return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + seriesId, {responseType: 'blob' as 'text'}); + return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + seriesId, {observe: 'response', responseType: 'blob' as 'text'}); } private downloadVolumeAPI(volumeId: number) { - return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volumeId, {responseType: 'blob' as 'text'}); + return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volumeId, {observe: 'response', responseType: 'blob' as 'text'}); } private downloadChapterAPI(chapterId: number) { - return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapterId, {responseType: 'blob' as 'text'}); + return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapterId, {observe: 'response', responseType: 'blob' as 'text'}); } downloadSeries(series: Series) { @@ -51,9 +51,10 @@ export class DownloadService { if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The series is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { return; } - this.downloadSeriesAPI(series.id).subscribe(res => { - const filename = series.name + '.zip'; - this.preformSave(res, filename); + this.downloadSeriesAPI(series.id).subscribe(resp => { + //const filename = series.name + '.zip'; + //this.preformSave(res, filename); + this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, series.name)); }); }); } @@ -63,9 +64,8 @@ export class DownloadService { if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { return; } - this.downloadChapterAPI(chapter.id).subscribe(res => { - const filename = seriesName + ' - Chapter ' + chapter.number + '.zip'; - this.preformSave(res, filename); + this.downloadChapterAPI(chapter.id).subscribe((resp: HttpResponse) => { + this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName + ' - Chapter ' + chapter.number)); }); }); } @@ -75,9 +75,8 @@ export class DownloadService { if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) { return; } - this.downloadVolumeAPI(volume.id).subscribe(res => { - const filename = seriesName + ' - Volume ' + volume.name + '.zip'; - this.preformSave(res, filename); + this.downloadVolumeAPI(volume.id).subscribe(resp => { + this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName + ' - Volume ' + volume.name)); }); }); } @@ -88,6 +87,23 @@ export class DownloadService { this.toastr.success('File downloaded successfully: ' + filename); } + /** + * Attempts to parse out the filename from Content-Disposition header. + * If it fails, will default to defaultName and add the correct extension. If no extension is found in header, will use zip. + * @param headers + * @param defaultName + * @returns + */ + private getFilenameFromHeader(headers: HttpHeaders, defaultName: string) { + const tokens = (headers.get('content-disposition') || '').split(';'); + let filename = tokens[1].replace('filename=', '').replace('"', '').trim(); + if (filename.startsWith('download_') || filename.startsWith('kavita_download_')) { + const ext = filename.substring(filename.lastIndexOf('.'), filename.length); + return defaultName + ext; + } + return filename; + } + /** * Format bytes as human-readable text. * diff --git a/UI/Web/src/app/user-login/user-login.component.ts b/UI/Web/src/app/user-login/user-login.component.ts index 9a9c44c07..b72344514 100644 --- a/UI/Web/src/app/user-login/user-login.component.ts +++ b/UI/Web/src/app/user-login/user-login.component.ts @@ -42,7 +42,15 @@ export class UserLoginComponent implements OnInit { this.accountService.login(this.model).subscribe(() => { this.loginForm.reset(); this.navService.showNavBar(); - this.router.navigateByUrl('/library'); + + // Check if user came here from another url, else send to library route + const pageResume = localStorage.getItem('kavita--auth-intersection-url'); + if (pageResume && pageResume !== '/no-connection') { + localStorage.setItem('kavita--auth-intersection-url', ''); + this.router.navigateByUrl(pageResume); + } else { + this.router.navigateByUrl('/library'); + } }, err => { this.toastr.error(err.error); });