Files
PageManager/PageManager.Api/PageManager.Api/Services/FileOrganizerService.cs
T
2026-03-28 17:36:25 +02:00

143 lines
5.8 KiB
C#

using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Services;
public class FileOrganizerService(
AppDbContext db,
IFileSystem fileSystem,
IConfiguration config,
ILogger<FileOrganizerService> logger) : IFileOrganizerService
{
private static readonly FileFormat[] AudioFormats = [FileFormat.M4b, FileFormat.Mp3, FileFormat.Aac, FileFormat.Flac];
public async Task<OrganizeResult> OrganizeAsync(int fileId, CancellationToken ct = default)
{
var file = await db.BookFiles.FindAsync([fileId], ct);
if (file is null)
throw new KeyNotFoundException($"BookFile {fileId} not found.");
if (file.BookId is null)
return Skip(file, "File is not assigned to a book.");
var book = await db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.FirstOrDefaultAsync(b => b.Id == file.BookId, ct);
if (book is null)
return Skip(file, "Book not found.");
var isAudio = AudioFormats.Contains(file.Format);
var destRoot = isAudio ? config["LibraryPaths:Audiobooks"] : config["LibraryPaths:Books"];
if (string.IsNullOrWhiteSpace(destRoot))
return Skip(file, "Destination root not configured.");
var sourceRoot = ResolveSourceRoot(file);
if (string.IsNullOrWhiteSpace(sourceRoot))
return Skip(file, "Source root not found.");
var currentAbsPath = Path.Combine(sourceRoot, file.Path);
if (!fileSystem.FileExists(currentAbsPath))
return Skip(file, $"File not found on disk: {currentAbsPath}");
var ext = Path.GetExtension(file.Filename);
var canonicalRelPath = ComputeCanonicalRelativePath(book, ext, isAudio);
var canonicalAbsPath = Path.Combine(destRoot, canonicalRelPath);
if (string.Equals(
Path.GetFullPath(currentAbsPath),
Path.GetFullPath(canonicalAbsPath),
StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("File {Id} already at canonical path: {Path}", fileId, canonicalRelPath);
return new OrganizeResult(false, currentAbsPath, canonicalAbsPath, canonicalRelPath, Path.GetFileName(canonicalRelPath), "Already organized.");
}
canonicalAbsPath = ResolveCollision(canonicalAbsPath);
canonicalRelPath = Path.GetRelativePath(destRoot, canonicalAbsPath);
logger.LogInformation("Organizing file {Id}: {From} → {To}", fileId, currentAbsPath, canonicalAbsPath);
fileSystem.EnsureDirectoryExists(Path.GetDirectoryName(canonicalAbsPath)!);
await fileSystem.CopyFileAsync(currentAbsPath, canonicalAbsPath, ct);
fileSystem.DeleteFile(currentAbsPath);
file.Path = canonicalRelPath;
file.Filename = Path.GetFileName(canonicalRelPath);
file.SourceId = null;
await db.SaveChangesAsync(ct);
logger.LogInformation("Organized file {Id} → {Path}", fileId, canonicalRelPath);
return new OrganizeResult(true, currentAbsPath, canonicalAbsPath, canonicalRelPath, file.Filename);
}
// ── Helpers (internal for testing) ───────────────────────────────────────
internal static string ComputeCanonicalRelativePath(Book book, string ext, bool isAudio)
{
var authorName = book.BookAuthors.FirstOrDefault()?.Author.Name ?? "Unknown";
var titleSeg = SanitizePathComponent(book.Title);
var authorSeg = SanitizePathComponent(authorName);
var filename = titleSeg + ext;
if (isAudio)
{
// AudioBookShelf: Author/[Series/]Title/Title.ext
var series = book.SeriesEntries.FirstOrDefault()?.Series.Name;
return series is not null
? Path.Combine(authorSeg, SanitizePathComponent(series), titleSeg, filename)
: Path.Combine(authorSeg, titleSeg, filename);
}
else
{
// Ebook: Author/Title (Year)/Title.ext
var folderSeg = book.Year.HasValue
? $"{titleSeg} ({book.Year})"
: titleSeg;
return Path.Combine(authorSeg, folderSeg, filename);
}
}
internal static string SanitizePathComponent(string s)
{
var invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(s.Length);
foreach (var c in s)
sb.Append(invalid.Contains(c) ? '_' : c);
return sb.ToString().Trim().TrimEnd('.');
}
// ── Private helpers ───────────────────────────────────────────────────────
private string? ResolveSourceRoot(BookFile file)
{
if (string.IsNullOrEmpty(file.SourceId))
{
var isAudio = AudioFormats.Contains(file.Format);
return isAudio ? config["LibraryPaths:Audiobooks"] : config["LibraryPaths:Books"];
}
return db.ImportSources.Find(file.SourceId)?.Path;
}
private string ResolveCollision(string path)
{
if (!fileSystem.FileExists(path)) return path;
var dir = Path.GetDirectoryName(path)!;
var stem = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 2; i < 100; i++)
{
var candidate = Path.Combine(dir, $"{stem} ({i}){ext}");
if (!fileSystem.FileExists(candidate)) return candidate;
}
return path;
}
private static OrganizeResult Skip(BookFile file, string reason) =>
new(false, null, null, file.Path, file.Filename, reason);
}