Skip to content

Commit

Permalink
feat(LocalFiles): recognise local files
Browse files Browse the repository at this point in the history
  • Loading branch information
DevYukine committed Feb 17, 2024
1 parent 2e5b719 commit bde105b
Show file tree
Hide file tree
Showing 12 changed files with 271 additions and 20 deletions.
8 changes: 8 additions & 0 deletions backend/MangaMagnet.Api/Controller/MangaController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ public async Task<IActionResult> DownloadSingleAsync([FromRoute] Guid id, double
return NoContent();
}

[HttpPost("{id:guid}/verify-local")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> VerifyLocalFilesAsync([FromRoute] Guid id)
{
await mangaService.VerifyLocalFilesAsync(id);
return NoContent();
}

[HttpDelete("{id:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(MangaResponse), (int)HttpStatusCode.OK)]
Expand Down
4 changes: 3 additions & 1 deletion backend/MangaMagnet.Api/Job/RefreshMetadataJob.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
using MangaMagnet.Api.Service;
using MangaMagnet.Core.Local;
using Quartz;

namespace MangaMagnet.Api.Job;

public class RefreshMetadataJob(MetadataService metadataService, ILogger<RefreshMetadataJob> logger) : IJob
public class RefreshMetadataJob(MetadataService metadataService, LocalFileService localFileService, ILogger<RefreshMetadataJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogDebug("RefreshMetadataJob Started");
await metadataService.UpdateAllMetadataAsync();
await localFileService.VerifyAllLocalVolumeAndChapters();
logger.LogDebug("RefreshMetadataJob Finished");
}
}
4 changes: 4 additions & 0 deletions backend/MangaMagnet.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
using MangaMagnet.Core.CBZ.ComicInfo;
using MangaMagnet.Core.Database;
using MangaMagnet.Core.Download;
using MangaMagnet.Core.Local;
using MangaMagnet.Core.Local.Parsing;
using MangaMagnet.Core.Progress;
using MangaMagnet.Core.Progress.Models;
using MangaMagnet.Core.Services;
Expand Down Expand Up @@ -56,10 +58,12 @@
builder.Services.AddSingleton<EntityConverterService>();
builder.Services.AddSingleton<CbzService>();
builder.Services.AddSingleton<ComicInfoService>();
builder.Services.AddSingleton<IFileNameParser, MangaFileNameParser>();
builder.Services.AddHostedService<BroadcastProgressService>();
builder.Services.AddScoped<MangaService>();
builder.Services.AddScoped<MetadataService>();
builder.Services.AddScoped<DownloadService>();
builder.Services.AddScoped<LocalFileService>();

// Polly
builder.Services.AddResiliencePipeline("MangaDex-Pipeline", pipelineBuilder =>
Expand Down
27 changes: 23 additions & 4 deletions backend/MangaMagnet.Api/Service/MangaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
using MangaMagnet.Api.Models.Response;
using MangaMagnet.Core.Database;
using MangaMagnet.Core.Download;
using MangaMagnet.Core.Local;
using MangaMagnet.Core.Progress;
using Microsoft.EntityFrameworkCore;

namespace MangaMagnet.Api.Service;

public class MangaService(ILogger<MangaService> logger, BaseDatabaseContext dbContext, EntityConverterService entityConverterService, MetadataService metadataService, DownloadService downloadService)
public class MangaService(ILogger<MangaService> logger, BaseDatabaseContext dbContext, EntityConverterService entityConverterService, MetadataService metadataService, DownloadService downloadService, LocalFileService localFileService, ProgressService progressService)
{
public async Task<MangaResponse> CreateAsync(string mangaDexId, string path, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -75,12 +77,29 @@ public async Task<MangaResponse> DeleteByIdAsync(Guid id, CancellationToken canc
return entityConverterService.ConvertLocalMangaToResponse(localManga);
}

public async Task DownloadChapterAsync(Guid id, double chapterNumber)
public async Task DownloadChapterAsync(Guid id, double chapterNumber, CancellationToken cancellationToken = default)
{
var localManga = await dbContext.LocalMangas
.Include(m => m.Metadata)
.FirstOrDefaultAsync(m => m.Id == id) ?? throw new NotFoundException("Manga not found");
.FirstOrDefaultAsync(m => m.Id == id, cancellationToken: cancellationToken)
?? throw new NotFoundException("Manga not found");

await downloadService.DownloadChapterAsCBZAsync(id, chapterNumber, localManga.Path);
await downloadService.DownloadChapterAsCBZAsync(id, chapterNumber, localManga.Path, cancellationToken);

await VerifyLocalFilesAsync(id, cancellationToken);
}

public async Task VerifyLocalFilesAsync(Guid id, CancellationToken cancellationToken = default)
{
var localManga = await dbContext.LocalMangas
.Include(m => m.Metadata)
.Include(m => m.Chapters)
.Include(m => m.Volumes)
.FirstOrDefaultAsync(m => m.Id == id, cancellationToken: cancellationToken)
?? throw new NotFoundException("Manga not found");

using var progressTask = progressService.CreateTask("Verify Local Files");

await localFileService.VerifyLocalVolumeAndChapters(localManga, progressTask, cancellationToken);
}
}
1 change: 1 addition & 0 deletions backend/MangaMagnet.Api/Service/MetadataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public async Task CheckAllMangaForNewChapterMetadataAsync(CancellationToken canc

var mangaMetadata = await dbContext.MangaMetadata
.Include(mangaMetadata => mangaMetadata.ChapterMetadata)
.Where(metadata => metadata.Status != MangaStatus.Completed)
.ToListAsync(cancellationToken);

progressTask.Total = mangaMetadata.Count;
Expand Down
4 changes: 2 additions & 2 deletions backend/MangaMagnet.Core/Database/LocalChapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public class LocalChapter : ICreatable, IUpdatable
{
public Guid Id { get; set; }

public float ChapterNumber { get; set; }
public double ChapterNumber { get; set; }

public string Path { get; set; } = default!;

Expand All @@ -21,4 +21,4 @@ public class LocalChapter : ICreatable, IUpdatable

/// <inheritdoc/>
public DateTimeOffset UpdatedAt { get; set; }
}
}
2 changes: 1 addition & 1 deletion backend/MangaMagnet.Core/Download/DownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public async Task DownloadChapterAsCBZAsync(Guid id, double chapterNumber, strin

task.Description = $"Compressing Pages into .cbz";

var fileName = $"{mangaMetadata.DisplayTitle} {chapterNumber}";
var fileName = $"{mangaMetadata.DisplayTitle} {chapterNumber} (Scan) ({metadata.ScanlationGroup})";

await cbzService.CreateAsync(tempPath, outputPath, fileName, cancellationToken);

Expand Down
159 changes: 159 additions & 0 deletions backend/MangaMagnet.Core/Local/LocalFileService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
using MangaMagnet.Core.Database;
using MangaMagnet.Core.Local.Parsing;
using MangaMagnet.Core.Local.Parsing.Exceptions;
using MangaMagnet.Core.Progress;
using MangaMagnet.Core.Progress.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace MangaMagnet.Core.Local;

public class LocalFileService(ILogger<LocalFileService> logger, BaseDatabaseContext dbContext, IFileNameParser fileNameParser, ProgressService progressService)
{
public async Task VerifyAllLocalVolumeAndChapters(CancellationToken cancellationToken = default)
{
logger.LogDebug("Verifying Local Volume and Chapters for all Mangas");

using var progressTask = progressService.CreateTask("Verify Local Files");

var localMangas = await dbContext.LocalMangas
.Include(m => m.Metadata)
.Include(m => m.Volumes)
.Include(m => m.Chapters)
.ToListAsync(cancellationToken: cancellationToken);

progressTask.Total = localMangas.Count;

foreach (var manga in localMangas)
{
await VerifyLocalVolumeAndChapters(manga, progressTask, cancellationToken);
progressTask.Increment();
}

logger.LogDebug("Finished Verifying Local Volume and Chapters for all Mangas");
}

public async Task VerifyLocalVolumeAndChapters(LocalManga manga, ProgressTask progressTask, CancellationToken cancellationToken = default)
{
logger.LogDebug("Verifying Local Volume and Chapters for {Manga}", manga.Metadata.DisplayTitle);

progressTask.Description = manga.Metadata.DisplayTitle;

foreach (var localVolume in manga.Volumes)
await VerifyLocalVolume(manga, localVolume, cancellationToken);

foreach (var localChapter in manga.Chapters)
await VerifyLocalChapter(manga, localChapter, cancellationToken);

await dbContext.SaveChangesAsync(cancellationToken);

logger.LogDebug("Finished Verifying Local Volume and Chapters for {Manga}", manga.Metadata.DisplayTitle);

var filePaths = await Task.Run(() => Directory.EnumerateFiles(manga.Path), cancellationToken);

foreach (var path in filePaths)
await VerifyLocalFile(manga, path, cancellationToken);

await dbContext.SaveChangesAsync(cancellationToken);
}

private async Task VerifyLocalFile(LocalManga manga, string path, CancellationToken cancellationToken = default)
{
var fileName = Path.GetFileName(path);

if (manga.Volumes.Any(x => x.Path == path) || manga.Chapters.Any(x => x.Path == path)) return;

logger.LogDebug("Found new file {FileName} in {Manga}", fileName, manga.Metadata.DisplayTitle);

if (!TryParseFileName(fileName, out var result)) return;
var parsedResult = result!;

logger.LogDebug("Parsed {FileName} as a {Type} release", fileName, parsedResult.ParsedType.ToString());

var fileSize = await Task.Run(() => new FileInfo(path).Length, cancellationToken);

switch (parsedResult.ParsedType)
{
case ParsedReleaseType.VOLUME:
{
var volume = new LocalVolume
{
Path = path,
VolumeNumber = (int)parsedResult.VolumeNumber!,
LocalManga = manga,
ChapterMetadata = await dbContext.ChapterMetadata
.Where(x => x.VolumeNumber != null && x.VolumeNumber == parsedResult.VolumeNumber)
.ToListAsync(cancellationToken: cancellationToken),
SizeInBytes = fileSize
};

manga.Volumes.Add(volume);
dbContext.LocalVolumes.Add(volume);
break;
}
case ParsedReleaseType.CHAPTER:
{
var chapterNumber = (double) parsedResult.ChapterNumber!;

var chapter = new LocalChapter
{
Path = path,
ChapterNumber = chapterNumber!,
LocalManga = manga,
Metadata = await dbContext.ChapterMetadata.FirstAsync(x => Math.Abs(chapterNumber - x.ChapterNumber) < 0.1, cancellationToken: cancellationToken),
SizeInBytes = fileSize
};

manga.Chapters.Add(chapter);
dbContext.LocalChapters.Add(chapter);
break;
}
case ParsedReleaseType.NONE:
throw new NotSupportedException("Non Volume or Chapters aren't supported yet.");
default:
throw new ArgumentOutOfRangeException();
}
}

private async Task VerifyLocalChapter(LocalManga manga, LocalChapter localChapter, CancellationToken cancellationToken = default)
{
var exists = await Task.Run(() => File.Exists(localChapter.Path), cancellationToken);

if (exists) return;

logger.LogWarning("Chapter {Chapter} from Manga {Manga} was not found anymore, removing from database", localChapter.ChapterNumber, manga.Metadata.DisplayTitle);
dbContext.LocalChapters.Remove(localChapter);
}

private async Task VerifyLocalVolume(LocalManga manga, LocalVolume localVolume, CancellationToken cancellationToken = default)
{
var exists = await Task.Run(() => File.Exists(localVolume.Path), cancellationToken);

if (exists)
{
var fileSize = await Task.Run(() => new FileInfo(localVolume.Path).Length, cancellationToken);

if (fileSize != localVolume.SizeInBytes)
localVolume.SizeInBytes = fileSize;

return;
}

logger.LogWarning("Volume {Volume} from Manga {Manga} was not found anymore, removing from database", localVolume.VolumeNumber, manga.Metadata.DisplayTitle);
dbContext.LocalVolumes.Remove(localVolume);
}

private bool TryParseFileName(string fileName, out FileParserResult? result)
{
try
{
result = fileNameParser.Parse(fileName);
return true;
}
catch (FileNameNotParsableException)
{
result = null;
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,20 @@ public MangaMetadataResult ConvertToMangaMetadataResult(MangaDexResponse<MangaDe
return author;
}

private static string? GetFirstCoverUrl(MangaDexMangaData manga, List<MangaDexCover> covers)
private static string? GetFirstCoverUrl(MangaDexMangaData manga, IEnumerable<MangaDexCover> covers)
{
var coverArtFileName =
covers.Where(c => c.Type == "cover_art").MinBy(a => a.Attributes.Volume)?.Attributes.FileName;
var coverArtFileName = covers
.Where(c => c.Type == "cover_art")
.OrderBy(c => c.Attributes.Volume)
.ThenBy(c =>
c.Attributes.Locale switch
{
"ja" => 0,
"en" => 1,
_ => 2,
})
.FirstOrDefault()?
.Attributes.FileName;

return string.IsNullOrEmpty(coverArtFileName)
? GetLatestCoverUrl(manga)
Expand Down
16 changes: 10 additions & 6 deletions frontend/src/components/manga/volume/VolumeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@ import React, { useState } from "react";
import { formatSizeUnits } from "@/components/manga/MangaHeader";
import { ChapterData } from "@/components/manga/volume/VolumeOverview";
import { ChapterColumn } from "@/components/manga/volume/ChapterColumn";
import { VolumeLocalFiles } from "@/components/manga/volume/VolumeLocalFiles";
import { VolumeResponse } from "@/services/openapi";

export function VolumeList({chapters, volume}: { chapters: ChapterData[], volume?: number | null }) {
export function VolumeList({chapters, volume, volumeNumber}: {
chapters: ChapterData[],
volume?: VolumeResponse,
volumeNumber?: number | null
}) {
const [visible, setVisible] = useState<boolean>(false);

const size = formatSizeUnits(chapters.reduce((v, c) => v + c.local?.sizeInBytes ?? 0, 0));

const exists = chapters.filter(c => c.local).length ?? 0;
const size = formatSizeUnits(chapters.map(c => c.local?.sizeInBytes ?? 0).reduce((v, c) => v + c, 0));

return (
<div className="bg-neutral-300/5 text-white border border-gray-300/20 rounded-lg m-4">
<div className="flex items-center justify-between p-4">
<h1 className="text-2xl font-bold">{volume ? `Volume ${volume}` : "No Volume"}</h1>
<h1 className="text-2xl font-bold">{volumeNumber ? `Volume ${volumeNumber}` : "No Volume"}</h1>
<div className="flex items-center space-x-4">
<span className="bg-[#313244] px-2 py-1 rounded">{exists}/{chapters.length}</span>
<VolumeLocalFiles chapters={chapters} volume={volume}/>
<span>{size}</span>
<div onClick={() => setVisible(!visible)}
className={`bg-gray-600 w-[2em] h-[2em] cursor-pointer flex items-center justify-center radius-100 arrow rotate ${visible && 'rotate-180'}`}>
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/components/manga/volume/VolumeLocalFiles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ChapterData } from "@/components/manga/volume/VolumeOverview";
import React from "react";
import { VolumeResponse } from "@/services/openapi";

export enum LocalFilesDisplay {
None,
Partial,
All
}

export const statusColors: {
[key in LocalFilesDisplay]: {
bgClass: string
}
} = {
[LocalFilesDisplay.None]: {bgClass: "bg-red-500"},
[LocalFilesDisplay.Partial]: {bgClass: "bg-yellow-500"},
[LocalFilesDisplay.All]: {bgClass: "bg-green-500"},
}

export function VolumeLocalFiles({chapters, volume}: { chapters: ChapterData[], volume?: VolumeResponse }) {
const localChapters = chapters.filter(c => c.local).length ?? 0;
const hasLocalVolume = volume != undefined;

const localFileDisplay = hasLocalVolume
? LocalFilesDisplay.All
: localChapters === chapters.length
? LocalFilesDisplay.All
: localChapters === 0
? LocalFilesDisplay.None
: LocalFilesDisplay.Partial;

const backgroundColor = statusColors[localFileDisplay].bgClass;

const localCount = hasLocalVolume ? 1 : localChapters;
const max = hasLocalVolume ? 1 : chapters.length;

return (
<span className={`${backgroundColor} px-2 py-1 rounded`}>{localCount}/{max}</span>
)
}
Loading

0 comments on commit bde105b

Please sign in to comment.