143 lines
5.8 KiB
C#
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);
|
|
}
|