From a5822b5b19e4a933c8ab28a313dbd660e7b2b788 Mon Sep 17 00:00:00 2001 From: da3dsoul Date: Fri, 12 Jan 2024 00:37:22 -0500 Subject: [PATCH] A bunch of refactoring for File Operations --- .../ShokoServiceImplementation_Entities.cs | 27 +- .../ShokoServiceImplementation_Utilities.cs | 38 +- .../API/v3/Controllers/FileController.cs | 14 +- .../Import/CommandRequest_HashFile.cs | 13 +- .../Import/CommandRequest_LinkFileManually.cs | 13 +- .../Import/CommandRequest_ProcessFile.cs | 15 +- .../Import/CommandRequest_ReadMediaInfo.cs | 11 +- Shoko.Server/Import/Importer.cs | 15 +- Shoko.Server/Models/SVR_AnimeEpisode.cs | 9 +- Shoko.Server/Models/SVR_VideoLocal_Place.cs | 1117 +---------------- Shoko.Server/Renamer/IFileOperationResult.cs | 11 + Shoko.Server/Renamer/MoveFileResult.cs | 12 + Shoko.Server/Renamer/RenameFileResult.cs | 12 + .../Cached/VideoLocalRepository.cs | 3 +- .../Scheduling/Jobs/Shoko/DiscoverFileJob.cs | 11 +- .../Scheduling/Jobs/Shoko/HashFileJob.cs | 14 +- Shoko.Server/Server/Startup.cs | 2 + .../Services/VideoLocal_PlaceService.cs | 833 ++++++++++++ Shoko.Server/Utilities/Scanner.cs | 5 +- 19 files changed, 985 insertions(+), 1190 deletions(-) create mode 100644 Shoko.Server/Renamer/IFileOperationResult.cs create mode 100644 Shoko.Server/Renamer/MoveFileResult.cs create mode 100644 Shoko.Server/Renamer/RenameFileResult.cs create mode 100644 Shoko.Server/Services/VideoLocal_PlaceService.cs diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs index e191ed308..9e924369f 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Entities.cs @@ -15,6 +15,7 @@ using Shoko.Server.Filters.Legacy; using Shoko.Server.Models; using Shoko.Server.Repositories; +using Shoko.Server.Services; using Shoko.Server.Tasks; using Shoko.Server.Utilities; @@ -974,7 +975,9 @@ public string DeleteVideoLocalPlaceAndFile(int videoplaceid) return "Database entry does not exist"; } - return place.RemoveAndDeleteFile().Item2; + var service = HttpContext.RequestServices.GetRequiredService(); + service.RemoveRecordAndDeletePhysicalFile(place); + return string.Empty; } catch (Exception ex) { @@ -999,7 +1002,9 @@ public string DeleteVideoLocalPlaceAndFileSkipFolder(int videoplaceid) return "Database entry does not exist"; } - return place.RemoveAndDeleteFile(false).Item2; + var service = HttpContext.RequestServices.GetRequiredService(); + service.RemoveRecordAndDeletePhysicalFile(place); + return string.Empty; } catch (Exception ex) { @@ -2609,6 +2614,7 @@ public string DeleteAnimeSeries(int animeSeriesID, bool deleteFiles, bool delete } var animeGroupID = ser.AnimeGroupID; + var service = HttpContext.RequestServices.GetRequiredService(); foreach (var ep in ser.GetAnimeEpisodes()) { @@ -2620,25 +2626,18 @@ public string DeleteAnimeSeries(int animeSeriesID, bool deleteFiles, bool delete var place = places[index]; if (deleteFiles) { - bool success; - string result; - if (index < places.Count - 1) + try { - (success, result) = place.RemoveAndDeleteFile(false); + service.RemoveRecordAndDeletePhysicalFile(place, index >= places.Count - 1); } - else - { - (success, result) = place.RemoveAndDeleteFile(); - } - - if (!success) + catch (Exception e) { - return result; + return e.Message; } } else { - place.RemoveRecord(); + service.RemoveRecord(place); } } } diff --git a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Utilities.cs b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Utilities.cs index ff56eb23e..7334db789 100644 --- a/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Utilities.cs +++ b/Shoko.Server/API/v1/Implementations/ShokoServiceImplementation/ShokoServiceImplementation_Utilities.cs @@ -24,6 +24,7 @@ using Shoko.Server.Providers.AniDB.Titles; using Shoko.Server.Repositories; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Utilities; @@ -184,9 +185,21 @@ public bool DeleteMultipleFilesWithPreferences(int userID) } var result = true; + var service = HttpContext.RequestServices.GetRequiredService(); foreach (var toDelete in videosToDelete) { - result &= toDelete.Places.All(a => a.RemoveAndDeleteFile().Item1); + result &= toDelete.Places.All(a => + { + try + { + service.RemoveRecordAndDeletePhysicalFile(a); + return true; + } + catch + { + return false; + } + }); } return result; } @@ -471,24 +484,25 @@ public CL_VideoLocal_Renamed RenameAndMoveFile(int videoLocalID, string scriptNa var errorCount = 0; var errorString = string.Empty; var name = Path.GetFileName(vid.GetBestVideoLocalPlace().FilePath); + var service = HttpContext.RequestServices.GetRequiredService(); foreach (var place in vid.Places) { if (move) { - var resultString = place.MoveWithResultString(scriptName); - if (!string.IsNullOrEmpty(resultString.Item2)) + var result = service.MoveFile(place, scriptName: scriptName); + if (!result.IsSuccess) { errorCount++; - errorString = resultString.Item2; + errorString = result.ErrorMessage; continue; } - ret.NewDestination = resultString.Item1; + ret.NewDestination = result.NewFolder; } - var output = place.RenameFile(false, scriptName); - var error = output.Item3; - if (string.IsNullOrEmpty(error)) name = output.Item2; + var output = service.RenameFile(place, scriptName: scriptName); + var error = output.ErrorMessage; + if (string.IsNullOrEmpty(error)) name = output.NewFilename; else { errorCount++; @@ -502,8 +516,8 @@ public CL_VideoLocal_Renamed RenameAndMoveFile(int videoLocalID, string scriptNa ret.NewFileName = errorString; return ret; } - if (ret.VideoLocal == null) - ret.VideoLocal = new CL_VideoLocal {VideoLocalID = videoLocalID }; + + ret.VideoLocal ??= new CL_VideoLocal { VideoLocalID = videoLocalID }; } catch (Exception ex) { @@ -1198,7 +1212,9 @@ public string DeleteDuplicateFile(int videoLocalPlaceID) try { var place = RepoFactory.VideoLocalPlace.GetByID(videoLocalPlaceID); - return place.RemoveAndDeleteFile().Item2; + var service = HttpContext.RequestServices.GetRequiredService(); + service.RemoveRecordAndDeletePhysicalFile(place, false); + return string.Empty; } catch (Exception ex) { diff --git a/Shoko.Server/API/v3/Controllers/FileController.cs b/Shoko.Server/API/v3/Controllers/FileController.cs index 78c715f5f..c5fdae58a 100644 --- a/Shoko.Server/API/v3/Controllers/FileController.cs +++ b/Shoko.Server/API/v3/Controllers/FileController.cs @@ -18,6 +18,7 @@ using Shoko.Server.Providers.TraktTV; using Shoko.Server.Commands; using Shoko.Server.Repositories; +using Shoko.Server.Services; using Shoko.Server.Settings; using CommandRequestPriority = Shoko.Server.Server.CommandRequestPriority; @@ -44,13 +45,14 @@ public class FileController : BaseController internal const string FileNotFoundWithFileID = "No File entry for the given fileID"; private readonly TraktTVHelper _traktHelper; - private readonly ICommandRequestFactory _commandFactory; + private readonly VideoLocal_PlaceService _vlPlaceService; - public FileController(TraktTVHelper traktHelper, ICommandRequestFactory commandFactory, ISettingsProvider settingsProvider) : base(settingsProvider) + public FileController(TraktTVHelper traktHelper, ICommandRequestFactory commandFactory, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService) : base(settingsProvider) { _traktHelper = traktHelper; _commandFactory = commandFactory; + _vlPlaceService = vlPlaceService; } internal const string FileForbiddenForUser = "Accessing File is not allowed for the current user"; @@ -145,9 +147,9 @@ public ActionResult DeleteFiles([FromBody] File.Input.BatchDeleteBody body = nul foreach (var place in file.Places) { if (body.removeFiles) - place.RemoveRecordAndDeletePhysicalFile(body.removeFolders); + _vlPlaceService.RemoveRecordAndDeletePhysicalFile(place, body.removeFolders); else - place.RemoveRecord(); + _vlPlaceService.RemoveRecord(place); } } @@ -193,9 +195,9 @@ public ActionResult DeleteFile([FromRoute] int fileID, [FromQuery] bool removeFi foreach (var place in file.Places) if (removeFiles) - place.RemoveRecordAndDeletePhysicalFile(removeFolder); + _vlPlaceService.RemoveRecordAndDeletePhysicalFile(place, removeFolder); else - place.RemoveRecord(); + _vlPlaceService.RemoveRecord(place); return Ok(); } diff --git a/Shoko.Server/Commands/Import/CommandRequest_HashFile.cs b/Shoko.Server/Commands/Import/CommandRequest_HashFile.cs index f07f5b07e..4af1ab9f3 100644 --- a/Shoko.Server/Commands/Import/CommandRequest_HashFile.cs +++ b/Shoko.Server/Commands/Import/CommandRequest_HashFile.cs @@ -15,6 +15,7 @@ using Shoko.Server.Repositories; using Shoko.Server.Repositories.Cached; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Utilities; @@ -26,6 +27,7 @@ public class CommandRequest_HashFile : CommandRequestImplementation { private readonly ICommandRequestFactory _commandFactory; private readonly ISettingsProvider _settingsProvider; + private readonly VideoLocal_PlaceService _vlPlaceService; public virtual string FileName { get; set; } public virtual bool ForceHash { get; set; } public virtual bool SkipMyList { get; set; } @@ -121,7 +123,7 @@ protected override void Process() if ((vlocal.Media?.GeneralStream?.Duration ?? 0) == 0 || vlocal.MediaVersion < SVR_VideoLocal.MEDIA_VERSION) { - if (vlocalplace.RefreshMediaInfo()) + if (_vlPlaceService.RefreshMediaInfo(vlocalplace)) { RepoFactory.VideoLocal.Save(vlocalplace.VideoLocal, true); } @@ -534,7 +536,7 @@ private bool ProcessDuplicates(SVR_VideoLocal vlocal, SVR_VideoLocal_Place vloca Logger.LogWarning("---------------------------------------------"); var settings = _settingsProvider.GetSettings(); - if (settings.Import.AutomaticallyDeleteDuplicatesOnImport) vlocalplace.RemoveRecordAndDeletePhysicalFile(); + if (settings.Import.AutomaticallyDeleteDuplicatesOnImport) _vlPlaceService.RemoveRecordAndDeletePhysicalFile(vlocalplace); return true; } @@ -584,14 +586,13 @@ protected override bool Load() return FileName.Trim().Length > 0; } - public CommandRequest_HashFile(ILoggerFactory loggerFactory, ICommandRequestFactory commandFactory, ISettingsProvider settingsProvider) : + public CommandRequest_HashFile(ILoggerFactory loggerFactory, ICommandRequestFactory commandFactory, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService) : base(loggerFactory) { _commandFactory = commandFactory; _settingsProvider = settingsProvider; + _vlPlaceService = vlPlaceService; } - protected CommandRequest_HashFile() - { - } + protected CommandRequest_HashFile() { } } diff --git a/Shoko.Server/Commands/Import/CommandRequest_LinkFileManually.cs b/Shoko.Server/Commands/Import/CommandRequest_LinkFileManually.cs index 8181a5542..077e5b156 100644 --- a/Shoko.Server/Commands/Import/CommandRequest_LinkFileManually.cs +++ b/Shoko.Server/Commands/Import/CommandRequest_LinkFileManually.cs @@ -12,6 +12,7 @@ using Shoko.Server.Models; using Shoko.Server.Repositories; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Utilities; @@ -23,6 +24,7 @@ public class CommandRequest_LinkFileManually : CommandRequestImplementation { private readonly ICommandRequestFactory _commandFactory; private readonly IServerSettings _settings; + private readonly VideoLocal_PlaceService _vlPlaceService; public virtual int VideoLocalID { get; set; } public virtual int EpisodeID { get; set; } public virtual int Percentage { get; set; } @@ -73,7 +75,7 @@ protected override void Process() ProcessFileQualityFilter(); - _vlocal.Places.ForEach(a => { a.RenameAndMoveAsRequired(); }); + _vlocal.Places.ForEach(a => { _vlPlaceService.RenameAndMoveAsRequired(a); }); // Set the import date. _vlocal.DateTimeImported = DateTime.Now; @@ -115,7 +117,7 @@ private void ProcessFileQualityFilter() videoLocals = videoLocals.Where(FileQualityFilter.CheckFileKeep).ToList(); - foreach (var toDelete in videoLocals) toDelete.Places.ForEach(a => a.RemoveRecordAndDeletePhysicalFile()); + foreach (var toDelete in videoLocals) toDelete.Places.ForEach(a => _vlPlaceService.RemoveRecordAndDeletePhysicalFile(a)); } /// @@ -156,14 +158,13 @@ protected override bool Load() return true; } - public CommandRequest_LinkFileManually(ILoggerFactory loggerFactory, ICommandRequestFactory commandFactory, ISettingsProvider settingsProvider) : + public CommandRequest_LinkFileManually(ILoggerFactory loggerFactory, ICommandRequestFactory commandFactory, ISettingsProvider settingsProvider, VideoLocal_PlaceService vlPlaceService) : base(loggerFactory) { _commandFactory = commandFactory; + _vlPlaceService = vlPlaceService; _settings = settingsProvider.GetSettings(); } - protected CommandRequest_LinkFileManually() - { - } + protected CommandRequest_LinkFileManually() { } } diff --git a/Shoko.Server/Commands/Import/CommandRequest_ProcessFile.cs b/Shoko.Server/Commands/Import/CommandRequest_ProcessFile.cs index c312ada06..0776e2e84 100644 --- a/Shoko.Server/Commands/Import/CommandRequest_ProcessFile.cs +++ b/Shoko.Server/Commands/Import/CommandRequest_ProcessFile.cs @@ -15,6 +15,7 @@ using Shoko.Server.Providers.AniDB.Interfaces; using Shoko.Server.Repositories; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Utilities; @@ -25,10 +26,9 @@ namespace Shoko.Server.Commands; public class CommandRequest_ProcessFile : CommandRequestImplementation { private readonly ICommandRequestFactory _commandFactory; - private readonly IServerSettings _settings; - private readonly IUDPConnectionHandler _udpConnectionHandler; + private readonly VideoLocal_PlaceService _vlPlaceService; public virtual int VideoLocalID { get; set; } @@ -84,7 +84,7 @@ protected override void Process() var aniFile = ProcessFile_AniDB(vlocal); // Rename and/or move the physical file(s) if needed. - vlocal.Places.ForEach(a => { a.RenameAndMoveAsRequired(); }); + vlocal.Places.ForEach(a => { _vlPlaceService.RenameAndMoveAsRequired(a); }); // Check if an AniDB file is now available and if the cross-references changed. var newXRefs = vlocal.EpisodeCrossRefs @@ -157,7 +157,7 @@ private SVR_AniDB_File ProcessFile_AniDB(SVR_VideoLocal vidLocal) videoLocals = videoLocals.Where(a => !FileQualityFilter.CheckFileKeep(a)).ToList(); - videoLocals.ForEach(a => a.Places.ForEach(b => b.RemoveRecordAndDeletePhysicalFile())); + videoLocals.ForEach(a => a.Places.ForEach(b => _vlPlaceService.RemoveRecordAndDeletePhysicalFile(b))); } // we have an AniDB File, so check the release group info @@ -453,15 +453,14 @@ protected override bool Load() return true; } - public CommandRequest_ProcessFile(ILoggerFactory loggerFactory, ICommandRequestFactory commandFactory, ISettingsProvider settingsProvider, IUDPConnectionHandler udpConnectionHandler) : + public CommandRequest_ProcessFile(ILoggerFactory loggerFactory, ICommandRequestFactory commandFactory, ISettingsProvider settingsProvider, IUDPConnectionHandler udpConnectionHandler, VideoLocal_PlaceService vlPlaceService) : base(loggerFactory) { _commandFactory = commandFactory; _settings = settingsProvider.GetSettings(); _udpConnectionHandler = udpConnectionHandler; + _vlPlaceService = vlPlaceService; } - protected CommandRequest_ProcessFile() - { - } + protected CommandRequest_ProcessFile() { } } diff --git a/Shoko.Server/Commands/Import/CommandRequest_ReadMediaInfo.cs b/Shoko.Server/Commands/Import/CommandRequest_ReadMediaInfo.cs index 9c7ec0544..e8db02b05 100644 --- a/Shoko.Server/Commands/Import/CommandRequest_ReadMediaInfo.cs +++ b/Shoko.Server/Commands/Import/CommandRequest_ReadMediaInfo.cs @@ -7,6 +7,7 @@ using Shoko.Server.Commands.Generic; using Shoko.Server.Repositories; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Utilities; namespace Shoko.Server.Commands; @@ -15,6 +16,7 @@ namespace Shoko.Server.Commands; [Command(CommandRequestType.ReadMediaInfo)] public class CommandRequest_ReadMediaInfo : CommandRequestImplementation { + private readonly VideoLocal_PlaceService _vlPlaceService; public virtual int VideoLocalID { get; set; } public override CommandRequestPriority DefaultPriority => CommandRequestPriority.Priority4; @@ -38,7 +40,7 @@ protected override void Process() return; } - if (place.RefreshMediaInfo()) + if (_vlPlaceService.RefreshMediaInfo(place)) { RepoFactory.VideoLocal.Save(place.VideoLocal, true); } @@ -68,11 +70,10 @@ protected override bool Load() return true; } - public CommandRequest_ReadMediaInfo(ILoggerFactory loggerFactory) : base(loggerFactory) + public CommandRequest_ReadMediaInfo(ILoggerFactory loggerFactory, VideoLocal_PlaceService vlPlaceService) : base(loggerFactory) { + _vlPlaceService = vlPlaceService; } - protected CommandRequest_ReadMediaInfo() - { - } + protected CommandRequest_ReadMediaInfo() { } } diff --git a/Shoko.Server/Import/Importer.cs b/Shoko.Server/Import/Importer.cs index b9a12e76f..a4136e772 100755 --- a/Shoko.Server/Import/Importer.cs +++ b/Shoko.Server/Import/Importer.cs @@ -23,6 +23,7 @@ using Shoko.Server.Repositories.Cached; using Shoko.Server.Scheduling.Jobs.Shoko; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Utilities; using Utils = Shoko.Server.Utilities.Utils; @@ -148,6 +149,7 @@ public static void RunImport_ScanFolder(int importFolderID, bool skipMyList = fa { var settings = Utils.SettingsProvider.GetSettings(); var commandFactory = Utils.ServiceContainer.GetRequiredService(); + var vlPlaceService = Utils.ServiceContainer.GetRequiredService(); // get a complete list of files var fileList = new List(); int filesFound = 0, videosFound = 0; @@ -194,11 +196,11 @@ public static void RunImport_ScanFolder(int importFolderID, bool skipMyList = fa { i++; - if (dictFilesExisting.ContainsKey(fileName)) + if (dictFilesExisting.TryGetValue(fileName, out var value)) { if (fldr.IsDropSource == 1) { - dictFilesExisting[fileName].RenameAndMoveAsRequired(); + vlPlaceService.RenameAndMoveAsRequired(value); } } @@ -294,6 +296,7 @@ public static void RunImport_NewFiles() { var settings = Utils.SettingsProvider.GetSettings(); var commandFactory = Utils.ServiceContainer.GetRequiredService(); + var vlPlaceService = Utils.ServiceContainer.GetRequiredService(); // first build a list of files that we already know about, as we don't want to process them again var filesAll = RepoFactory.VideoLocalPlace.GetAll(); var dictFilesExisting = new Dictionary(); @@ -304,7 +307,7 @@ public static void RunImport_NewFiles() if (vl.FullServerPath == null) { Logger.Info("Invalid File Path found. Removing: " + vl.VideoLocal_Place_ID); - vl.RemoveRecord(); + vlPlaceService.RemoveRecord(vl); continue; } @@ -899,6 +902,7 @@ public static void RunImport_UpdateAllAniDB() public static void RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) { var commandFactory = Utils.ServiceContainer.GetRequiredService(); + var vlPlaceService = Utils.ServiceContainer.GetRequiredService(); Logger.Info("Remove Missing Files: Start"); var seriesToUpdate = new HashSet(); using var session = DatabaseFactory.SessionFactory.OpenSession(); @@ -919,7 +923,7 @@ public static void RemoveRecordsWithoutPhysicalFiles(bool removeMyList = true) // delete video local record Logger.Info("Removing Missing File: {0}", vl.VideoLocalID); - vl.RemoveRecordWithOpenTransaction(session, seriesToUpdate); + vlPlaceService.RemoveRecordWithOpenTransaction(session, vl, seriesToUpdate); } } @@ -1110,11 +1114,12 @@ public static string DeleteImportFolder(int importFolderID, bool removeFromMyLis { try { + var vlPlaceService = Utils.ServiceContainer.GetRequiredService(); var affectedSeries = new HashSet(); var vids = RepoFactory.VideoLocalPlace.GetByImportFolder(importFolderID); Logger.Info($"Deleting {vids.Count} video local records"); using var session = DatabaseFactory.SessionFactory.OpenSession(); - vids.ForEach(vid => vid.RemoveRecordWithOpenTransaction(session, affectedSeries, removeFromMyList, false)); + vids.ForEach(vid => vlPlaceService.RemoveRecordWithOpenTransaction(session, vid, affectedSeries, removeFromMyList)); // delete the import folder RepoFactory.ImportFolder.Delete(importFolderID); diff --git a/Shoko.Server/Models/SVR_AnimeEpisode.cs b/Shoko.Server/Models/SVR_AnimeEpisode.cs index 499208c33..b478c6c3a 100644 --- a/Shoko.Server/Models/SVR_AnimeEpisode.cs +++ b/Shoko.Server/Models/SVR_AnimeEpisode.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.DependencyInjection; using Shoko.Commons.Extensions; using Shoko.Models.Client; using Shoko.Models.Enums; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; using Shoko.Server.Repositories; +using Shoko.Server.Services; using Shoko.Server.Utilities; using AnimeTitle = Shoko.Plugin.Abstractions.DataModels.AnimeTitle; using EpisodeType = Shoko.Models.Enums.EpisodeType; @@ -168,10 +170,11 @@ public void ToggleWatchedStatus(bool watched, bool updateOnline, DateTime? watch public void RemoveVideoLocals(bool deleteFiles) { - GetVideoLocals().SelectMany(a => a.Places).ForEach(place => + var service = Utils.ServiceContainer.GetRequiredService(); + GetVideoLocals().SelectMany(a => a.Places).Where(a => a != null).ForEach(place => { - if (!deleteFiles) place?.RemoveRecord(); - else place?.RemoveRecordAndDeletePhysicalFile(false); + if (deleteFiles) service.RemoveRecordAndDeletePhysicalFile(place, false); + else service.RemoveRecord(place); }); } diff --git a/Shoko.Server/Models/SVR_VideoLocal_Place.cs b/Shoko.Server/Models/SVR_VideoLocal_Place.cs index 988d82e5d..55c950203 100644 --- a/Shoko.Server/Models/SVR_VideoLocal_Place.cs +++ b/Shoko.Server/Models/SVR_VideoLocal_Place.cs @@ -1,38 +1,10 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using Microsoft.Extensions.DependencyInjection; -using NHibernate; -using NLog; -using Shoko.Commons.Extensions; -using Shoko.Models.MediaInfo; +using System.IO; using Shoko.Models.Server; using Shoko.Plugin.Abstractions.DataModels; -using Shoko.Server.Commands; -using Shoko.Server.Commands.AniDB; -using Shoko.Server.Databases; -using Shoko.Server.Extensions; -using Shoko.Server.FileHelper.Subtitles; using Shoko.Server.Repositories; -using Shoko.Server.Repositories.Cached; -using Shoko.Server.Server; -using Shoko.Server.Settings; -using Shoko.Server.Utilities; -using Directory = System.IO.Directory; -using MediaContainer = Shoko.Models.MediaInfo.MediaContainer; namespace Shoko.Server.Models; -public enum DELAY_IN_USE -{ - FIRST = 750, - SECOND = 3000, - THIRD = 5000 -} - public class SVR_VideoLocal_Place : VideoLocal_Place, IVideoFile { internal SVR_ImportFolder ImportFolder => RepoFactory.ImportFolder.GetByID(ImportFolderID); @@ -54,308 +26,6 @@ public string FullServerPath public SVR_VideoLocal VideoLocal => RepoFactory.VideoLocal.GetByID(VideoLocalID); - private static Logger logger = LogManager.GetCurrentClassLogger(); - - // returns false if we should try again after the timer - // TODO Generify this and Move and make a return model instead of tuple - public (bool, string, string) RenameFile(bool preview = false, string scriptName = null) - { - if (scriptName != null && scriptName.Equals(Shoko.Models.Constants.Renamer.TempFileName)) - { - return (true, string.Empty, "Error: Do not attempt to use a temp file to rename."); - } - - if (ImportFolder == null) - { - logger.Error( - $"Error: The renamer can't get the import folder for ImportFolderID: {ImportFolderID}, File: \"{FilePath}\""); - return (true, string.Empty, "Error: Could not find the file"); - } - - var renamed = RenameFileHelper.GetFilename(this, scriptName); - if (string.IsNullOrEmpty(renamed)) - { - logger.Error($"Error: The renamer returned a null or empty name for: \"{FullServerPath}\""); - return (true, string.Empty, "Error: The file renamer returned a null or empty value"); - } - - if (renamed.StartsWith("*Error: ")) - { - logger.Error($"Error: The renamer returned an error on file: \"{FullServerPath}\"\n {renamed}"); - return (true, string.Empty, renamed.Substring(1)); - } - - // actually rename the file - var fullFileName = FullServerPath; - - // check if the file exists - if (string.IsNullOrEmpty(fullFileName)) - { - logger.Error($"Error could not find the original file for renaming, or it is in use: \"{fullFileName}\""); - return (false, renamed, "Error: Could not access the file"); - } - - if (!File.Exists(fullFileName)) - { - logger.Error($"Error could not find the original file for renaming, or it is in use: \"{fullFileName}\""); - return (false, renamed, "Error: Could not access the file"); - } - - // actually rename the file - var path = Path.GetDirectoryName(fullFileName); - var newFullName = Path.Combine(path, renamed); - - try - { - if (fullFileName.Equals(newFullName, StringComparison.InvariantCultureIgnoreCase)) - { - logger.Info($"Renaming file SKIPPED! no change From \"{fullFileName}\" to \"{newFullName}\""); - return (true, renamed, string.Empty); - } - - if (File.Exists(newFullName)) - { - logger.Info($"Renaming file SKIPPED! Destination Exists \"{newFullName}\""); - return (true, renamed, "Error: The filename already exists"); - } - - if (preview) - { - return (false, renamed, string.Empty); - } - - Utils.ShokoServer.AddFileWatcherExclusion(newFullName); - - logger.Info($"Renaming file From \"{fullFileName}\" to \"{newFullName}\""); - try - { - var file = new FileInfo(fullFileName); - file.MoveTo(newFullName); - } - catch (Exception e) - { - logger.Info($"Renaming file FAILED! From \"{fullFileName}\" to \"{newFullName}\" - {e}"); - Utils.ShokoServer.RemoveFileWatcherExclusion(newFullName); - return (false, renamed, "Error: Failed to rename file"); - } - - // Rename external subs! - RenameExternalSubtitles(fullFileName, renamed); - - logger.Info($"Renaming file SUCCESS! From \"{fullFileName}\" to \"{newFullName}\""); - var (folder, filePath) = VideoLocal_PlaceRepository.GetFromFullPath(newFullName); - if (folder == null) - { - logger.Error($"Unable to LOCATE file \"{newFullName}\" inside the import folders"); - Utils.ShokoServer.RemoveFileWatcherExclusion(newFullName); - return (false, renamed, "Error: Unable to resolve new path"); - } - - // Rename hash xrefs - var filenameHash = RepoFactory.FileNameHash.GetByHash(VideoLocal.Hash); - if (!filenameHash.Any(a => a.FileName.Equals(renamed))) - { - var fnhash = new FileNameHash - { - DateTimeUpdated = DateTime.Now, - FileName = renamed, - FileSize = VideoLocal.FileSize, - Hash = VideoLocal.Hash - }; - RepoFactory.FileNameHash.Save(fnhash); - } - - FilePath = filePath; - RepoFactory.VideoLocalPlace.Save(this); - // just in case - VideoLocal.FileName = renamed; - RepoFactory.VideoLocal.Save(VideoLocal, false); - - ShokoEventHandler.Instance.OnFileRenamed(ImportFolder, Path.GetFileName(fullFileName), renamed, this); - } - catch (Exception ex) - { - logger.Info($"Renaming file FAILED! From \"{fullFileName}\" to \"{newFullName}\" - {ex.Message}"); - logger.Error(ex, ex.ToString()); - return (true, string.Empty, $"Error: {ex.Message}"); - } - - Utils.ShokoServer.RemoveFileWatcherExclusion(newFullName); - return (true, renamed, string.Empty); - } - - public void RemoveRecord(bool updateMyListStatus = true) - { - logger.Info("Removing VideoLocal_Place record for: {0}", FullServerPath ?? VideoLocal_Place_ID.ToString()); - var seriesToUpdate = new List(); - var v = VideoLocal; - var commandFactory = Utils.ServiceContainer.GetRequiredService(); - - using (var session = DatabaseFactory.SessionFactory.OpenSession()) - { - if (v?.Places?.Count <= 1) - { - if (updateMyListStatus) - { - if (RepoFactory.AniDB_File.GetByHash(v.Hash) == null) - { - var xrefs = RepoFactory.CrossRef_File_Episode.GetByHash(v.Hash); - foreach (var xref in xrefs) - { - var ep = RepoFactory.AniDB_Episode.GetByEpisodeID(xref.EpisodeID); - if (ep == null) continue; - - commandFactory.CreateAndSave( - c => - { - c.AnimeID = xref.AnimeID; - c.EpisodeType = ep.GetEpisodeTypeEnum(); - c.EpisodeNumber = ep.EpisodeNumber; - } - ); - } - } - else - { - commandFactory.CreateAndSave( - c => - { - c.Hash = v.Hash; - c.FileSize = v.FileSize; - } - ); - } - } - - try - { - ShokoEventHandler.Instance.OnFileDeleted(ImportFolder, this); - } - catch - { - // ignore - } - - BaseRepository.Lock(session, s => - { - using var transaction = s.BeginTransaction(); - RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(s, this); - - seriesToUpdate.AddRange(v.GetAnimeEpisodes().DistinctBy(a => a.AnimeSeriesID) - .Select(a => a.GetAnimeSeries())); - RepoFactory.VideoLocal.DeleteWithOpenTransaction(s, v); - transaction.Commit(); - }); - } - else - { - try - { - ShokoEventHandler.Instance.OnFileDeleted(ImportFolder, this); - } - catch - { - // ignore - } - - BaseRepository.Lock(session, s => - { - using var transaction = s.BeginTransaction(); - RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(s, this); - transaction.Commit(); - }); - } - } - - foreach (var ser in seriesToUpdate) - { - ser?.QueueUpdateStats(); - } - } - - - public void RemoveRecordWithOpenTransaction(ISession session, ICollection seriesToUpdate, - bool updateMyListStatus = true, bool removeDuplicateFileEntries = true) - { - logger.Info("Removing VideoLocal_Place record for: {0}", FullServerPath ?? VideoLocal_Place_ID.ToString()); - var v = VideoLocal; - var commandFactory = Utils.ServiceContainer.GetRequiredService(); - - if (v?.Places?.Count <= 1) - { - if (updateMyListStatus) - { - if (RepoFactory.AniDB_File.GetByHash(v.Hash) == null) - { - var xrefs = RepoFactory.CrossRef_File_Episode.GetByHash(v.Hash); - foreach (var xref in xrefs) - { - var ep = RepoFactory.AniDB_Episode.GetByEpisodeID(xref.EpisodeID); - if (ep == null) - { - continue; - } - - commandFactory.CreateAndSave(c => - { - c.AnimeID = xref.AnimeID; - c.EpisodeType = ep.GetEpisodeTypeEnum(); - c.EpisodeNumber = ep.EpisodeNumber; - }); - } - } - else - { - commandFactory.CreateAndSave( - c => - { - c.Hash = v.Hash; - c.FileSize = v.FileSize; - } - ); - } - } - - var eps = v?.GetAnimeEpisodes()?.Where(a => a != null).ToList(); - eps?.DistinctBy(a => a.AnimeSeriesID).Select(a => a.GetAnimeSeries()).ToList().ForEach(seriesToUpdate.Add); - - try - { - ShokoEventHandler.Instance.OnFileDeleted(ImportFolder, this); - } - catch - { - // ignore - } - - BaseRepository.Lock(() => - { - using var transaction = session.BeginTransaction(); - RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(session, this); - RepoFactory.VideoLocal.DeleteWithOpenTransaction(session, v); - transaction.Commit(); - }); - } - else - { - try - { - ShokoEventHandler.Instance.OnFileDeleted(ImportFolder, this); - } - catch - { - // ignore - } - - BaseRepository.Lock(() => - { - using var transaction = session.BeginTransaction(); - RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(session, this); - transaction.Commit(); - }); - } - } - public FileInfo GetFile() { if (!File.Exists(FullServerPath)) @@ -366,791 +36,6 @@ public FileInfo GetFile() return new FileInfo(FullServerPath); } - public bool RefreshMediaInfo() - { - try - { - logger.Trace("Getting media info for: {0}", FullServerPath ?? VideoLocal_Place_ID.ToString()); - MediaContainer m = null; - if (VideoLocal == null) - { - logger.Error( - $"VideoLocal for {FullServerPath ?? VideoLocal_Place_ID.ToString()} failed to be retrived for MediaInfo"); - return false; - } - - if (FullServerPath != null) - { - if (GetFile() == null) - { - logger.Error( - $"File {FullServerPath ?? VideoLocal_Place_ID.ToString()} failed to be retrived for MediaInfo"); - return false; - } - - var name = FullServerPath.Replace("/", $"{Path.DirectorySeparatorChar}"); - m = Utilities.MediaInfoLib.MediaInfo.GetMediaInfo(name); //Mediainfo should have libcurl.dll for http - var duration = m?.GeneralStream?.Duration ?? 0; - if (duration == 0) - { - m = null; - } - } - - - if (m != null) - { - var info = VideoLocal; - - var subs = SubtitleHelper.GetSubtitleStreams(FullServerPath); - if (subs.Count > 0) - { - m.media.track.AddRange(subs); - } - - info.Media = m; - return true; - } - - logger.Error($"File {FullServerPath ?? VideoLocal_Place_ID.ToString()} failed to read MediaInfo"); - } - catch (Exception e) - { - logger.Error( - $"Unable to read the media information of file {FullServerPath ?? VideoLocal_Place_ID.ToString()} ERROR: {e}"); - } - - return false; - } - - [Obsolete] - public (bool, string) RemoveAndDeleteFile(bool deleteFolder = true) - { - // TODO Make this take an argument to disable removing empty dirs. It's slow, and should only be done if needed - try - { - logger.Info("Deleting video local place record and file: {0}", - FullServerPath ?? VideoLocal_Place_ID.ToString()); - - if (!File.Exists(FullServerPath)) - { - logger.Info($"Unable to find file. Removing Record: {FullServerPath ?? FilePath}"); - RemoveRecord(); - return (true, string.Empty); - } - - try - { - File.Delete(FullServerPath); - DeleteExternalSubtitles(FullServerPath); - } - catch (Exception ex) - { - if (ex is FileNotFoundException) - { - if (deleteFolder) - { - RecursiveDeleteEmptyDirectories(ImportFolder?.ImportFolderLocation, true); - } - - RemoveRecord(); - return (true, string.Empty); - } - - logger.Error($"Unable to delete file '{FullServerPath}': {ex}"); - return (false, $"Unable to delete file '{FullServerPath}'"); - } - - if (deleteFolder) - { - RecursiveDeleteEmptyDirectories(ImportFolder?.ImportFolderLocation, true); - } - - RemoveRecord(); - // For deletion of files from Trakt, we will rely on the Daily sync - return (true, string.Empty); - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - return (false, ex.Message); - } - } - - public void RemoveRecordAndDeletePhysicalFile(bool deleteFolder = true) - { - logger.Info("Deleting video local place record and file: {0}", - FullServerPath ?? VideoLocal_Place_ID.ToString()); - - if (!File.Exists(FullServerPath)) - { - logger.Info($"Unable to find file. Removing Record: {FullServerPath ?? FilePath}"); - RemoveRecord(); - return; - } - - try - { - File.Delete(FullServerPath); - DeleteExternalSubtitles(FullServerPath); - } - catch (FileNotFoundException) - { - if (deleteFolder) - { - RecursiveDeleteEmptyDirectories(ImportFolder?.ImportFolderLocation, true); - } - - RemoveRecord(); - return; - } - catch (Exception ex) - { - logger.Error($"Unable to delete file '{FullServerPath}': {ex}"); - throw; - } - - if (deleteFolder) - { - RecursiveDeleteEmptyDirectories(ImportFolder?.ImportFolderLocation, true); - } - - RemoveRecord(); - } - - public void RemoveAndDeleteFileWithOpenTransaction(ISession session, HashSet seriesToUpdate) - { - // TODO Make this take an argument to disable removing empty dirs. It's slow, and should only be done if needed - try - { - logger.Info("Deleting video local place record and file: {0}", - FullServerPath ?? VideoLocal_Place_ID.ToString()); - - - if (!File.Exists(FullServerPath)) - { - logger.Info($"Unable to find file. Removing Record: {FullServerPath}"); - RemoveRecordWithOpenTransaction(session, seriesToUpdate); - return; - } - - try - { - File.Delete(FullServerPath); - DeleteExternalSubtitles(FullServerPath); - } - catch (Exception ex) - { - if (ex is FileNotFoundException) - { - RecursiveDeleteEmptyDirectories(ImportFolder?.ImportFolderLocation, true); - RemoveRecordWithOpenTransaction(session, seriesToUpdate); - return; - } - - logger.Error($"Unable to delete file '{FullServerPath}': {ex}"); - return; - } - - RecursiveDeleteEmptyDirectories(ImportFolder?.ImportFolderLocation, true); - RemoveRecordWithOpenTransaction(session, seriesToUpdate); - // For deletion of files from Trakt, we will rely on the Daily sync - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - public void RenameAndMoveAsRequired() - { - var settings = Utils.SettingsProvider.GetSettings(); - var invert = settings.Import.RenameThenMove; - // Move first so that we don't bother the filesystem watcher - var succeeded = invert ? RenameIfRequired() : MoveFileIfRequired(); - if (!succeeded) - { - Thread.Sleep((int)DELAY_IN_USE.FIRST); - succeeded = invert ? RenameIfRequired() : MoveFileIfRequired(); - if (!succeeded) - { - Thread.Sleep((int)DELAY_IN_USE.SECOND); - succeeded = invert ? RenameIfRequired() : MoveFileIfRequired(); - if (!succeeded) - { - Thread.Sleep((int)DELAY_IN_USE.THIRD); - succeeded = invert ? RenameIfRequired() : MoveFileIfRequired(); - if (!succeeded) - { - return; // Don't bother renaming if we couldn't move. It'll need user interaction - } - } - } - } - - succeeded = invert ? MoveFileIfRequired() : RenameIfRequired(); - if (!succeeded) - { - Thread.Sleep((int)DELAY_IN_USE.FIRST); - succeeded = invert ? MoveFileIfRequired() : RenameIfRequired(); - if (!succeeded) - { - Thread.Sleep((int)DELAY_IN_USE.SECOND); - succeeded = invert ? MoveFileIfRequired() : RenameIfRequired(); - if (!succeeded) - { - Thread.Sleep((int)DELAY_IN_USE.THIRD); - succeeded = invert ? MoveFileIfRequired() : RenameIfRequired(); - if (!succeeded) - { - return; - } - } - } - } - - try - { - LinuxFS.SetLinuxPermissions(FullServerPath, settings.Linux.UID, - settings.Linux.GID, settings.Linux.Permission); - } - catch (InvalidOperationException e) - { - logger.Error(e, $"Unable to set permissions ({settings.Linux.UID}:{settings.Linux.GID} {settings.Linux.Permission}) on file {FileName}: Access Denied"); - } - catch (Exception e) - { - logger.Error(e, "Error setting Linux Permissions: {0}", e); - } - } - - // returns false if we should retry - private bool RenameIfRequired() - { - if (!Utils.SettingsProvider.GetSettings().Import.RenameOnImport) - { - logger.Trace($"Skipping rename of \"{FullServerPath}\" as rename on import is disabled"); - return true; - } - - try - { - return RenameFile().Item1; - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - return true; - } - } - - // TODO Merge these, with proper logic depending on the scenario (import, force, etc) - public (string, string) MoveWithResultString(string scriptName, bool force = false) - { - // TODO Make this take an argument to disable removing empty dirs. It's slow, and should only be done if needed - if (FullServerPath == null) - { - logger.Error($"Could not find or access the file to move: {VideoLocal_Place_ID}"); - return (string.Empty, "ERROR: Unable to access file"); - } - - if (!File.Exists(FullServerPath)) - { - logger.Error($"Could not find or access the file to move: \"{FullServerPath}\""); - // this can happen due to file locks, so retry - return (string.Empty, "ERROR: Could not access the file"); - } - - var sourceFile = new FileInfo(FullServerPath); - - // There is a possibility of weird logic based on source of the file. Some handling should be made for it....later - var (destImpl, newFolderPath) = RenameFileHelper.GetDestination(this, scriptName); - - if (!(destImpl is SVR_ImportFolder destFolder)) - { - // In this case, an error string was returned, but we'll suppress it and give an error elsewhere - if (newFolderPath != null) - { - logger.Error($"Unable to find destination for: \"{FullServerPath}\""); - logger.Error($"The error message was: {newFolderPath}"); - return (string.Empty, "ERROR: " + newFolderPath); - } - - logger.Error($"Unable to find destination for: \"{FullServerPath}\""); - return (string.Empty, "ERROR: There was an error but no error code returned..."); - } - - // keep the original drop folder for later (take a copy, not a reference) - var dropFolder = ImportFolder; - - if (string.IsNullOrEmpty(newFolderPath)) - { - logger.Error($"Unable to find destination for: \"{FullServerPath}\""); - return (string.Empty, "ERROR: The returned path was null or empty"); - } - - // We've already resolved FullServerPath, so it doesn't need to be checked - var newFilePath = Path.Combine(newFolderPath, Path.GetFileName(FullServerPath)); - var newFullServerPath = Path.Combine(destFolder.ImportFolderLocation, newFilePath); - - var destFullTree = Path.Combine(destFolder.ImportFolderLocation, newFolderPath); - if (!Directory.Exists(destFullTree)) - { - try - { - Directory.CreateDirectory(destFullTree); - } - catch (Exception e) - { - logger.Error(e); - return (string.Empty, $"ERROR: Unable to create directory tree: \"{destFullTree}\""); - } - } - - // Last ditch effort to ensure we aren't moving a file unto itself - if (newFullServerPath.Equals(FullServerPath, StringComparison.InvariantCultureIgnoreCase)) - { - logger.Info($"Moving file SKIPPED! The file is already at its desired location: \"{FullServerPath}\""); - return (newFolderPath, string.Empty); - } - - if (File.Exists(newFullServerPath)) - { - logger.Error($"A file already exists at the desired location: \"{FullServerPath}\""); - return (string.Empty, "ERROR: A file already exists at the destination"); - } - - Utils.ShokoServer.AddFileWatcherExclusion(newFullServerPath); - - logger.Info($"Moving file from \"{FullServerPath}\" to \"{newFullServerPath}\""); - try - { - sourceFile.MoveTo(newFullServerPath); - } - catch (Exception e) - { - logger.Error($"Unable to MOVE file: \"{FullServerPath}\" to \"{newFullServerPath}\" error {e}"); - Utils.ShokoServer.RemoveFileWatcherExclusion(newFullServerPath); - return (newFullServerPath, "ERROR: " + e); - } - - // Save for later. Scan for subtitles while the vlplace is still set for the source location - var originalFileName = FullServerPath; - var oldPath = FilePath; - - ImportFolderID = destFolder.ImportFolderID; - FilePath = newFilePath; - RepoFactory.VideoLocalPlace.Save(this); - - MoveExternalSubtitles(newFullServerPath, originalFileName); - - // check for any empty folders in drop folder - // only for the drop folder - if (dropFolder.IsDropSource == 1) - { - RecursiveDeleteEmptyDirectories(dropFolder?.ImportFolderLocation, true); - } - - ShokoEventHandler.Instance.OnFileMoved(dropFolder, destFolder, oldPath, newFilePath, this); - Utils.ShokoServer.RemoveFileWatcherExclusion(newFullServerPath); - return (newFolderPath, string.Empty); - } - - // returns false if we should retry - private bool MoveFileIfRequired(bool deleteEmpty = true) - { - // TODO move A LOT of this into renamer helper methods. A renamer can do them optionally - if (!Utils.SettingsProvider.GetSettings().Import.MoveOnImport) - { - logger.Trace($"Skipping move of \"{FullServerPath}\" as move on import is disabled"); - return true; - } - - // TODO Make this take an argument to disable removing empty dirs. It's slow, and should only be done if needed - try - { - logger.Trace($"Attempting to MOVE file: \"{FullServerPath ?? VideoLocal_Place_ID.ToString()}\""); - - if (FullServerPath == null) - { - logger.Error($"Could not find or access the file to move: {VideoLocal_Place_ID}"); - return true; - } - - // check if this file is in the drop folder - // otherwise we don't need to move it - if (ImportFolder.IsDropSource == 0) - { - logger.Trace($"Not moving file as it is NOT in the drop folder: \"{FullServerPath}\""); - return true; - } - - if (!File.Exists(FullServerPath)) - { - logger.Error($"Could not find or access the file to move: \"{FullServerPath}\""); - // this can happen due to file locks, so retry - return false; - } - - var sourceFile = new FileInfo(FullServerPath); - - // find the default destination - var (destImpl, newFolderPath) = RenameFileHelper.GetDestination(this, null); - - if (!(destImpl is SVR_ImportFolder destFolder)) - { - // In this case, an error string was returned, but we'll suppress it and give an error elsewhere - if (newFolderPath != null) - { - return true; - } - - logger.Error($"Could not find a valid destination: \"{FullServerPath}\""); - return true; - } - - // keep the original drop folder for later (take a copy, not a reference) - var dropFolder = ImportFolder; - - if (string.IsNullOrEmpty(newFolderPath)) - { - return true; - } - - // We've already resolved FullServerPath, so it doesn't need to be checked - var newFilePath = Path.Combine(newFolderPath, Path.GetFileName(FullServerPath)); - var newFullServerPath = Path.Combine(destFolder.ImportFolderLocation, newFilePath); - - var destFullTree = Path.Combine(destFolder.ImportFolderLocation, newFolderPath); - if (!Directory.Exists(destFullTree)) - { - try - { - Utils.ShokoServer.AddFileWatcherExclusion(destFullTree); - Directory.CreateDirectory(destFullTree); - } - catch (Exception e) - { - logger.Error(e); - return true; - } - finally - { - Utils.ShokoServer.RemoveFileWatcherExclusion(destFullTree); - } - } - - // Last ditch effort to ensure we aren't moving a file unto itself - if (newFullServerPath.Equals(FullServerPath, StringComparison.InvariantCultureIgnoreCase)) - { - logger.Error($"Resolved to move \"{newFullServerPath}\" unto itself. NOT MOVING"); - return true; - } - - var originalFileName = FullServerPath; - var oldPath = FilePath; - - if (File.Exists(newFullServerPath)) - { - // A file with the same name exists at the destination. - // Handle Duplicate Files, A duplicate file record won't exist yet, - // so we'll check the old fashioned way - logger.Trace("A file already exists at the new location, checking it for duplicate"); - var destVideoLocalPlace = RepoFactory.VideoLocalPlace.GetByFilePathAndImportFolderID(newFilePath, - destFolder.ImportFolderID); - var destVideoLocal = destVideoLocalPlace?.VideoLocal; - if (destVideoLocal == null) - { - logger.Error("The existing file at the new location does not have a VideoLocal. Not moving"); - return true; - } - - if (destVideoLocal.Hash == VideoLocal.Hash) - { - logger.Info( - $"Not moving file as it already exists at the new location, deleting source file instead: \"{FullServerPath}\" --- \"{newFullServerPath}\""); - - // if the file already exists, we can just delete the source file instead - // this is safer than deleting and moving - try - { - sourceFile.Delete(); - } - catch (Exception e) - { - logger.Warn($"Unable to DELETE file: \"{FullServerPath}\" error {e}"); - RemoveRecord(false); - - // check for any empty folders in drop folder - // only for the drop folder - if (dropFolder.IsDropSource != 1) - { - return true; - } - - RecursiveDeleteEmptyDirectories(dropFolder?.ImportFolderLocation, true); - return true; - } - } - - // Not a dupe, don't delete it - logger.Trace("A file already exists at the new location, checking it for version and group"); - var destinationExistingAniDBFile = destVideoLocal.GetAniDBFile(); - if (destinationExistingAniDBFile == null) - { - logger.Error("The existing file at the new location does not have AniDB info. Not moving."); - return true; - } - - var aniDBFile = VideoLocal.GetAniDBFile(); - if (aniDBFile == null) - { - logger.Error("The file does not have AniDB info. Not moving."); - return true; - } - - if (destinationExistingAniDBFile.Anime_GroupName == aniDBFile.Anime_GroupName && - destinationExistingAniDBFile.FileVersion < aniDBFile.FileVersion) - { - // This is a V2 replacing a V1 with the same name. - // Normally we'd let the Multiple Files Utility handle it, but let's just delete the V1 - logger.Info("The existing file is a V1 from the same group. Replacing it."); - // Delete the destination - var (success, _) = destVideoLocalPlace.RemoveAndDeleteFile(); - if (!success) - { - return false; - } - - // Move - Utils.ShokoServer.AddFileWatcherExclusion(newFullServerPath); - logger.Info($"Moving file from \"{FullServerPath}\" to \"{newFullServerPath}\""); - try - { - sourceFile.MoveTo(newFullServerPath); - } - catch (Exception e) - { - logger.Error($"Unable to MOVE file: \"{FullServerPath}\" to \"{newFullServerPath}\" error {e}"); - Utils.ShokoServer.RemoveFileWatcherExclusion(newFullServerPath); - return false; - } - - ImportFolderID = destFolder.ImportFolderID; - FilePath = newFilePath; - RepoFactory.VideoLocalPlace.Save(this); - - // check for any empty folders in drop folder - // only for the drop folder - if (dropFolder.IsDropSource == 1 && deleteEmpty) - { - RecursiveDeleteEmptyDirectories(dropFolder?.ImportFolderLocation, true); - } - } - } - else - { - Utils.ShokoServer.AddFileWatcherExclusion(newFullServerPath); - logger.Info($"Moving file from \"{FullServerPath}\" to \"{newFullServerPath}\""); - try - { - sourceFile.MoveTo(newFullServerPath); - } - catch (Exception e) - { - logger.Error($"Unable to MOVE file: \"{FullServerPath}\" to \"{newFullServerPath}\" error {e}"); - Utils.ShokoServer.RemoveFileWatcherExclusion(newFullServerPath); - return false; - } - - ImportFolderID = destFolder.ImportFolderID; - FilePath = newFilePath; - RepoFactory.VideoLocalPlace.Save(this); - - // check for any empty folders in drop folder - // only for the drop folder - if (dropFolder.IsDropSource == 1 && deleteEmpty) - { - RecursiveDeleteEmptyDirectories(dropFolder?.ImportFolderLocation, true); - } - } - - MoveExternalSubtitles(newFullServerPath, originalFileName); - ShokoEventHandler.Instance.OnFileMoved(dropFolder, destFolder, oldPath, newFilePath, this); - } - catch (Exception ex) - { - logger.Error(ex, $"Could not MOVE file: \"{FullServerPath ?? VideoLocal_Place_ID.ToString()}\" -- {ex}"); - } - - return true; - } - - private static void MoveExternalSubtitles(string newFullServerPath, string originalFileName) - { - try - { - var textStreams = SubtitleHelper.GetSubtitleStreams(originalFileName); - // move any subtitle files - foreach (var subtitleFile in textStreams) - { - if (string.IsNullOrEmpty(subtitleFile.Filename)) continue; - - var newParent = Path.GetDirectoryName(newFullServerPath); - var srcParent = Path.GetDirectoryName(originalFileName); - if (string.IsNullOrEmpty(newParent) || string.IsNullOrEmpty(srcParent)) continue; - - var subPath = Path.Combine(srcParent, subtitleFile.Filename); - if (!File.Exists(subPath)) continue; - - var subFile = new FileInfo(subPath); - var newSubPath = Path.Combine(newParent, subFile.Name); - if (File.Exists(newSubPath)) - { - try - { - File.Delete(newSubPath); - } - catch (Exception e) - { - logger.Warn($"Unable to DELETE file: \"{subtitleFile}\" error {e}"); - } - } - - try - { - subFile.MoveTo(newSubPath); - } - catch (Exception e) - { - logger.Error($"Unable to MOVE file: \"{subtitleFile}\" to \"{newSubPath}\" error {e}"); - } - } - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - private static void RenameExternalSubtitles(string fullFileName, string renamed) - { - var textStreams = SubtitleHelper.GetSubtitleStreams(fullFileName); - var path = Path.GetDirectoryName(fullFileName); - var oldBasename = Path.GetFileNameWithoutExtension(fullFileName); - var newBasename = Path.GetFileNameWithoutExtension(renamed); - foreach (var sub in textStreams) - { - if (string.IsNullOrEmpty(sub.Filename)) - { - continue; - } - - var oldSubPath = Path.Combine(path, sub.Filename); - - if (!File.Exists(oldSubPath)) - { - logger.Error($"Unable to rename external subtitle \"{sub.Filename}\". Cannot access the file"); - continue; - } - - var newSub = Path.Combine(path, sub.Filename.Replace(oldBasename, newBasename)); - try - { - var file = new FileInfo(oldSubPath); - file.MoveTo(newSub); - } - catch (Exception e) - { - logger.Error($"Unable to rename external subtitle \"{sub.Filename}\" to \"{newSub}\". {e}"); - } - } - } - - private static void DeleteExternalSubtitles(string originalFileName) - { - try - { - var textStreams = SubtitleHelper.GetSubtitleStreams(originalFileName); - // move any subtitle files - foreach (var subtitleFile in textStreams) - { - if (string.IsNullOrEmpty(subtitleFile.Filename)) continue; - - var srcParent = Path.GetDirectoryName(originalFileName); - if (string.IsNullOrEmpty(srcParent)) continue; - - var subPath = Path.Combine(srcParent, subtitleFile.Filename); - if (!File.Exists(subPath)) continue; - - try - { - File.Delete(subPath); - } - catch (Exception e) - { - logger.Error(e, $"Unable to delete file: \"{subtitleFile}\""); - } - } - } - catch (Exception ex) - { - logger.Error(ex, ex.ToString()); - } - } - - private void RecursiveDeleteEmptyDirectories(string dir, bool importfolder) - { - try - { - if (string.IsNullOrEmpty(dir)) return; - if (!Directory.Exists(dir)) return; - if (Utils.SettingsProvider.GetSettings().Import.Exclude.Any(s => Regex.IsMatch(dir, s))) return; - - if (IsDirectoryEmpty(dir)) - { - if (importfolder) return; - - try - { - Directory.Delete(dir); - } - catch (Exception ex) - { - if (ex is DirectoryNotFoundException || ex is FileNotFoundException) return; - - logger.Warn("Unable to DELETE directory: {0} Error: {1}", dir, ex); - } - - return; - } - - // If it has folder, recurse - foreach (var d in Directory.EnumerateDirectories(dir)) - { - if (Utils.SettingsProvider.GetSettings().Import.Exclude.Any(s => Regex.IsMatch(d, s))) continue; - RecursiveDeleteEmptyDirectories(d, false); - } - } - catch (Exception e) - { - if (e is FileNotFoundException || e is DirectoryNotFoundException || e is UnauthorizedAccessException) return; - logger.Error($"There was an error removing the empty directory: {dir}\r\n{e}"); - } - } - - public bool IsDirectoryEmpty(string path) - { - try - { - return !Directory.EnumerateFileSystemEntries(path).Any(); - } - catch - { - return false; - } - } - int IVideoFile.VideoFileID => VideoLocalID; string IVideoFile.Filename => Path.GetFileName(FilePath); string IVideoFile.FilePath => FullServerPath; diff --git a/Shoko.Server/Renamer/IFileOperationResult.cs b/Shoko.Server/Renamer/IFileOperationResult.cs new file mode 100644 index 000000000..3de31fcfe --- /dev/null +++ b/Shoko.Server/Renamer/IFileOperationResult.cs @@ -0,0 +1,11 @@ +using System; + +namespace Shoko.Server.Renamer; + +public interface IFileOperationResult +{ + bool IsSuccess { get; set; } + bool CanRetry { get; set; } + string ErrorMessage { get; set; } + Exception Exception { get; set; } +} diff --git a/Shoko.Server/Renamer/MoveFileResult.cs b/Shoko.Server/Renamer/MoveFileResult.cs new file mode 100644 index 000000000..7d2bd0fc7 --- /dev/null +++ b/Shoko.Server/Renamer/MoveFileResult.cs @@ -0,0 +1,12 @@ +using System; + +namespace Shoko.Server.Renamer; + +public record MoveFileResult : IFileOperationResult +{ + public bool IsSuccess { get; set; } + public bool CanRetry { get; set; } + public string NewFolder { get; set; } + public string ErrorMessage { get; set; } + public Exception Exception { get; set; } +} diff --git a/Shoko.Server/Renamer/RenameFileResult.cs b/Shoko.Server/Renamer/RenameFileResult.cs new file mode 100644 index 000000000..3fe9b3788 --- /dev/null +++ b/Shoko.Server/Renamer/RenameFileResult.cs @@ -0,0 +1,12 @@ +using System; + +namespace Shoko.Server.Renamer; + +public record RenameFileResult : IFileOperationResult +{ + public bool IsSuccess { get; set; } + public bool CanRetry { get; set; } + public string NewFilename { get; set; } + public string ErrorMessage { get; set; } + public Exception Exception { get; set; } +} diff --git a/Shoko.Server/Repositories/Cached/VideoLocalRepository.cs b/Shoko.Server/Repositories/Cached/VideoLocalRepository.cs index 6d889b118..8313f5316 100644 --- a/Shoko.Server/Repositories/Cached/VideoLocalRepository.cs +++ b/Shoko.Server/Repositories/Cached/VideoLocalRepository.cs @@ -16,6 +16,7 @@ using Shoko.Server.LZ4; using Shoko.Server.Models; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Utilities; using Shoko.Server.Utilities.MediaInfoLib; @@ -234,7 +235,7 @@ private void UpdateMediaContracts(SVR_VideoLocal obj) } var place = obj.GetBestVideoLocalPlace(true); - place?.RefreshMediaInfo(); + if (place != null) Utils.ServiceContainer.GetRequiredService().RefreshMediaInfo(place); } public override void Delete(SVR_VideoLocal obj) diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/DiscoverFileJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/DiscoverFileJob.cs index 62f1b8b82..7d7c483c3 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/DiscoverFileJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/DiscoverFileJob.cs @@ -14,6 +14,7 @@ using Shoko.Server.Repositories.Cached; using Shoko.Server.Scheduling.Acquisition.Attributes; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Settings; namespace Shoko.Server.Scheduling.Jobs.Shoko; @@ -34,14 +35,18 @@ public class DiscoverFileJob : BaseJob private readonly ISettingsProvider _settingsProvider; private readonly ISchedulerFactory _schedulerFactory; + private readonly VideoLocal_PlaceService _vlPlaceService; - public DiscoverFileJob(ILoggerFactory loggerFactory, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory) : base(loggerFactory) + public DiscoverFileJob(ILoggerFactory loggerFactory, ISettingsProvider settingsProvider, ISchedulerFactory schedulerFactory, VideoLocal_PlaceService vlPlaceService) : base(loggerFactory) { _settingsProvider = settingsProvider; _schedulerFactory = schedulerFactory; + _vlPlaceService = vlPlaceService; } - protected DiscoverFileJob() { } + protected DiscoverFileJob(VideoLocal_PlaceService vlPlaceService) { + _vlPlaceService = vlPlaceService; + } public override async Task Process() { @@ -368,7 +373,7 @@ private async Task ProcessDuplicates(SVR_VideoLocal vlocal, SVR_VideoLocal var settings = _settingsProvider.GetSettings(); if (settings.Import.AutomaticallyDeleteDuplicatesOnImport) { - vlocalplace.RemoveRecordAndDeletePhysicalFile(); + _vlPlaceService.RemoveRecordAndDeletePhysicalFile(vlocalplace); return true; } return false; diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs index a1e73dbcb..8e8b037ef 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs @@ -5,7 +5,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Quartz; using QuartzJobFactory.Attributes; using Shoko.Commons.Queue; using Shoko.Models.Queue; @@ -19,6 +18,7 @@ using Shoko.Server.Scheduling.Acquisition.Attributes; using Shoko.Server.Scheduling.Concurrency; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Settings; using Shoko.Server.Utilities; @@ -42,14 +42,18 @@ public class HashFileJob : BaseJob private readonly ISettingsProvider _settingsProvider; private readonly ICommandRequestFactory _commandFactory; + private readonly VideoLocal_PlaceService _vlPlaceService; - public HashFileJob(ILoggerFactory loggerFactory, ISettingsProvider settingsProvider, ICommandRequestFactory commandFactory) : base(loggerFactory) + public HashFileJob(ILoggerFactory loggerFactory, ISettingsProvider settingsProvider, ICommandRequestFactory commandFactory, VideoLocal_PlaceService vlPlaceService) : base(loggerFactory) { _settingsProvider = settingsProvider; _commandFactory = commandFactory; + _vlPlaceService = vlPlaceService; } - protected HashFileJob() { + protected HashFileJob(VideoLocal_PlaceService vlPlaceService) + { + this._vlPlaceService = vlPlaceService; } public override async Task Process() @@ -109,7 +113,7 @@ public override async Task Process() if ((vlocal.Media?.GeneralStream?.Duration ?? 0) == 0 || vlocal.MediaVersion < SVR_VideoLocal.MEDIA_VERSION) { - if (vlocalplace.RefreshMediaInfo()) + if (_vlPlaceService.RefreshMediaInfo(vlocalplace)) { RepoFactory.VideoLocal.Save(vlocalplace.VideoLocal, true); } @@ -482,7 +486,7 @@ private async Task ProcessDuplicates(SVR_VideoLocal vlocal, SVR_VideoLocal Logger.LogWarning("---------------------------------------------"); var settings = _settingsProvider.GetSettings(); - if (settings.Import.AutomaticallyDeleteDuplicatesOnImport) vlocalplace.RemoveRecordAndDeletePhysicalFile(); + if (settings.Import.AutomaticallyDeleteDuplicatesOnImport) _vlPlaceService.RemoveRecordAndDeletePhysicalFile(vlocalplace); return true; } diff --git a/Shoko.Server/Server/Startup.cs b/Shoko.Server/Server/Startup.cs index c2caf5ed1..95756e6bb 100644 --- a/Shoko.Server/Server/Startup.cs +++ b/Shoko.Server/Server/Startup.cs @@ -18,6 +18,7 @@ using Shoko.Server.Providers.TraktTV; using Shoko.Server.Providers.TvDB; using Shoko.Server.Scheduling; +using Shoko.Server.Services; using Shoko.Server.Services.Connectivity; using Shoko.Server.Settings; using Shoko.Server.Utilities; @@ -53,6 +54,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(ShokoEventHandler.Instance); services.AddSingleton(); services.AddSingleton(); diff --git a/Shoko.Server/Services/VideoLocal_PlaceService.cs b/Shoko.Server/Services/VideoLocal_PlaceService.cs new file mode 100644 index 000000000..376b3ff79 --- /dev/null +++ b/Shoko.Server/Services/VideoLocal_PlaceService.cs @@ -0,0 +1,833 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using NHibernate; +using Polly; +using Shoko.Commons.Extensions; +using Shoko.Models.MediaInfo; +using Shoko.Models.Server; +using Shoko.Server.Commands; +using Shoko.Server.Commands.AniDB; +using Shoko.Server.Databases; +using Shoko.Server.FileHelper.Subtitles; +using Shoko.Server.Models; +using Shoko.Server.Renamer; +using Shoko.Server.Repositories; +using Shoko.Server.Repositories.Cached; +using Shoko.Server.Settings; +using Shoko.Server.Utilities; + +namespace Shoko.Server.Services; + +public class VideoLocal_PlaceService +{ + private readonly ILogger _logger; + private readonly ISettingsProvider _settingsProvider; + private readonly ICommandRequestFactory _commandFactory; + + public VideoLocal_PlaceService(ILogger logger, ISettingsProvider settingsProvider, ICommandRequestFactory commandRequestFactory) + { + _logger = logger; + _settingsProvider = settingsProvider; + _commandFactory = commandRequestFactory; + } + + private enum DelayInUse + { + First = 750, + Second = 3000, + Third = 5000 + } + + public void RenameAndMoveAsRequired(SVR_VideoLocal_Place place) + { + var settings = _settingsProvider.GetSettings(); + var invert = settings.Import.RenameThenMove; + + var retryPolicy = Policy + .HandleResult(a => a.CanRetry) + .WaitAndRetry(new[] + { + TimeSpan.FromMilliseconds((int)DelayInUse.First), + TimeSpan.FromMilliseconds((int)DelayInUse.Second), + TimeSpan.FromMilliseconds((int)DelayInUse.Third) + }); + + var result = retryPolicy.Execute(() => invert ? RenameIfRequired(place) : MoveFileIfRequired(place)); + + // Don't bother renaming if we couldn't move. It'll need user interaction + if (!result.IsSuccess) return; + + // Retry logic for the second attempt + result = retryPolicy.Execute(() => invert ? MoveFileIfRequired(place) : RenameIfRequired(place)); + + if (!result.IsSuccess) return; + + try + { + LinuxFS.SetLinuxPermissions(place.FullServerPath, settings.Linux.UID, settings.Linux.GID, settings.Linux.Permission); + } + catch (InvalidOperationException e) + { + _logger.LogError(e, "Unable to set permissions ({Uid}:{Gid} {Permission}) on file {FileName}: Access Denied", settings.Linux.UID, + settings.Linux.GID, settings.Linux.Permission, place.FileName); + } + catch (Exception e) + { + _logger.LogError(e, "Error setting Linux Permissions: {Ex}", e); + } + } + + private RenameFileResult RenameIfRequired(SVR_VideoLocal_Place place) + { + if (!_settingsProvider.GetSettings().Import.RenameOnImport) + { + _logger.LogTrace("Skipping rename of \"{FullServerPath}\" as rename on import is disabled", place.FullServerPath); + return new RenameFileResult { IsSuccess = true, NewFilename = string.Empty }; + } + + var result = RenameFile(place); + + return result; + } + + public RenameFileResult RenameFile(SVR_VideoLocal_Place place, bool preview = false, string scriptName = null) + { + if (scriptName != null && scriptName.Equals(Shoko.Models.Constants.Renamer.TempFileName)) + { + return new RenameFileResult { NewFilename = string.Empty, ErrorMessage = "Do not attempt to use a temp file to rename" }; + } + + if (place.ImportFolder == null) + { + _logger.LogError("The renamer can\'t get the import folder for ImportFolderID: {ImportFolderID}, File: \"{FilePath}\"", + place.ImportFolderID, place.FilePath); + return new RenameFileResult { NewFilename = string.Empty, ErrorMessage = "Could not find the file" }; + } + + string renamed; + try + { + renamed = RenameFileHelper.GetFilename(place, scriptName); + } + catch (Exception e) + { + return new RenameFileResult { NewFilename = string.Empty, ErrorMessage = e.Message, Exception = e }; + } + + if (string.IsNullOrEmpty(renamed)) + { + _logger.LogError("The renamer returned a null or empty name for: \"{FullServerPath}\"", place.FullServerPath); + return new RenameFileResult { NewFilename = string.Empty, ErrorMessage = "The file renamer returned a null or empty value" }; + } + + if (renamed.StartsWith("*Error: ")) + { + _logger.LogError("The renamer returned an error on file: \"{FullServerPath}\"\n {Renamed}", place.FullServerPath, renamed); + return new RenameFileResult { NewFilename = string.Empty, ErrorMessage = renamed[7..] }; + } + + // actually rename the file + var fullFileName = place.FullServerPath; + + // check if the file exists + if (string.IsNullOrEmpty(fullFileName)) + { + _logger.LogError("Could not find the original file for renaming, or it is in use: \"{FileName}\"", fullFileName); + return new RenameFileResult { CanRetry = true, NewFilename = renamed, ErrorMessage = "Could not access the file" }; + } + + if (!File.Exists(fullFileName)) + { + _logger.LogError("Error could not find the original file for renaming, or it is in use: \"{FileName}\"", fullFileName); + return new RenameFileResult { CanRetry = true, NewFilename = renamed, ErrorMessage = "Could not access the file" }; + } + + // actually rename the file + var path = Path.GetDirectoryName(fullFileName); + var newFullName = Path.Combine(path, renamed); + + try + { + if (fullFileName.Equals(newFullName, StringComparison.InvariantCultureIgnoreCase)) + { + _logger.LogInformation("Renaming file SKIPPED! no change From \"{FullFileName}\" to \"{NewFullName}\"", fullFileName, newFullName); + return new RenameFileResult { IsSuccess = true, NewFilename = renamed }; + } + + if (File.Exists(newFullName)) + { + _logger.LogInformation("Renaming file SKIPPED! Destination Exists \"{NewFullName}\"", newFullName); + return new RenameFileResult { NewFilename = renamed, ErrorMessage = "The filename already exists" }; + } + + if (preview) + { + return new RenameFileResult { IsSuccess = true, NewFilename = renamed }; + } + + Utils.ShokoServer.AddFileWatcherExclusion(newFullName); + + _logger.LogInformation("Renaming file From \"{FullFileName}\" to \"{NewFullName}\"", fullFileName, newFullName); + try + { + var file = new FileInfo(fullFileName); + file.MoveTo(newFullName); + } + catch (Exception e) + { + _logger.LogInformation(e, "Renaming file FAILED! From \"{FullFileName}\" to \"{NewFullName}\" - {Ex}", fullFileName, newFullName, e); + Utils.ShokoServer.RemoveFileWatcherExclusion(newFullName); + return new RenameFileResult { CanRetry = true, NewFilename = renamed, ErrorMessage = e.Message, Exception = e }; + } + + // Rename external subs! + RenameExternalSubtitles(fullFileName, renamed); + + _logger.LogInformation("Renaming file SUCCESS! From \"{FullFileName}\" to \"{NewFullName}\"", fullFileName, newFullName); + var (folder, filePath) = VideoLocal_PlaceRepository.GetFromFullPath(newFullName); + if (folder == null) + { + _logger.LogError("Unable to LOCATE file \"{NewFullName}\" inside the import folders", newFullName); + Utils.ShokoServer.RemoveFileWatcherExclusion(newFullName); + return new RenameFileResult { NewFilename = renamed, ErrorMessage = "Unable to resolve new path" }; + } + + // Rename hash xrefs + var filenameHash = RepoFactory.FileNameHash.GetByHash(place.VideoLocal.Hash); + if (!filenameHash.Any(a => a.FileName.Equals(renamed))) + { + var fnhash = new FileNameHash + { + DateTimeUpdated = DateTime.Now, + FileName = renamed, + FileSize = place.VideoLocal.FileSize, + Hash = place.VideoLocal.Hash + }; + RepoFactory.FileNameHash.Save(fnhash); + } + + place.FilePath = filePath; + RepoFactory.VideoLocalPlace.Save(place); + // just in case + place.VideoLocal.FileName = renamed; + RepoFactory.VideoLocal.Save(place.VideoLocal, false); + + ShokoEventHandler.Instance.OnFileRenamed(place.ImportFolder, Path.GetFileName(fullFileName), renamed, place); + } + catch (Exception ex) + { + _logger.LogInformation(ex, "Renaming file FAILED! From \"{FullFileName}\" to \"{NewFullName}\" - {ExMessage}", fullFileName, newFullName, ex); + return new RenameFileResult { CanRetry = true, NewFilename = renamed, ErrorMessage = ex.Message, Exception = ex }; + } + + Utils.ShokoServer.RemoveFileWatcherExclusion(newFullName); + return new RenameFileResult { IsSuccess = true, NewFilename = renamed }; + } + + private void RenameExternalSubtitles(string fullFileName, string renamed) + { + var textStreams = SubtitleHelper.GetSubtitleStreams(fullFileName); + var path = Path.GetDirectoryName(fullFileName); + var oldBasename = Path.GetFileNameWithoutExtension(fullFileName); + var newBasename = Path.GetFileNameWithoutExtension(renamed); + foreach (var sub in textStreams) + { + if (string.IsNullOrEmpty(sub.Filename)) + { + continue; + } + + var oldSubPath = Path.Combine(path, sub.Filename); + + if (!File.Exists(oldSubPath)) + { + _logger.LogError("Unable to rename external subtitle \"{SubFilename}\". Cannot access the file", sub.Filename); + continue; + } + + var newSub = Path.Combine(path, sub.Filename.Replace(oldBasename, newBasename)); + try + { + var file = new FileInfo(oldSubPath); + file.MoveTo(newSub); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to rename external subtitle \"{SubFilename}\" to \"{NewSub}\". {Ex}", sub.Filename, newSub, e); + } + } + } + + private MoveFileResult MoveFileIfRequired(SVR_VideoLocal_Place place, bool deleteEmpty = true) + { + if (!_settingsProvider.GetSettings().Import.MoveOnImport) + { + _logger.LogTrace("Skipping move of \"{FullServerPath}\" as move on import is disabled", place.FullServerPath); + return new MoveFileResult { IsSuccess = true, NewFolder = string.Empty }; + } + + if (place.ImportFolder.IsDropSource == 0) + { + _logger.LogTrace("Not moving file as it is NOT in the drop folder: \"{FullServerPath}\"", place.FullServerPath); + return new MoveFileResult { IsSuccess = true, NewFolder = string.Empty }; + } + + var result = MoveFile(place, deleteEmpty); + + return result; + } + + public MoveFileResult MoveFile(SVR_VideoLocal_Place videoLocalPlace, bool deleteEmpty = true, string scriptName = null) + { + try + { + if (videoLocalPlace.FullServerPath == null) + { + _logger.LogError("Could not find or access the file to move: {VideoLocalPlaceID}", videoLocalPlace.VideoLocal_Place_ID); + return new MoveFileResult { CanRetry = true, NewFolder = string.Empty, ErrorMessage = "Unable to access file" }; + } + + if (!File.Exists(videoLocalPlace.FullServerPath)) + { + _logger.LogError("Could not find or access the file to move: \"{FullServerPath}\"", videoLocalPlace.FullServerPath); + // Retry logic can be added here if needed + return new MoveFileResult { CanRetry = true, NewFolder = string.Empty, ErrorMessage = "Could not access the file" }; + } + + var sourceFile = new FileInfo(videoLocalPlace.FullServerPath); + + var (destImpl, newFolderPath) = RenameFileHelper.GetDestination(videoLocalPlace, scriptName); + + if (destImpl is not SVR_ImportFolder destFolder) + { + if (newFolderPath != null) + { + _logger.LogError("Unable to find destination for: \"{FullServerPath}\"", videoLocalPlace.FullServerPath); + _logger.LogError("The error message was: {NewFolderPath}", newFolderPath); + return new MoveFileResult { NewFolder = string.Empty, ErrorMessage = newFolderPath }; + } + + _logger.LogError("Unable to find destination for: \"{FullServerPath}\"", videoLocalPlace.FullServerPath); + return new MoveFileResult { NewFolder = string.Empty, ErrorMessage = "There was an error but no error code returned..." }; + } + + var dropFolder = videoLocalPlace.ImportFolder; + + if (string.IsNullOrEmpty(newFolderPath)) + { + _logger.LogError("Unable to find destination for: \"{FullServerPath}\"", videoLocalPlace.FullServerPath); + return new MoveFileResult { NewFolder = string.Empty, ErrorMessage = "The returned path was null or empty" }; + } + + var newFilePath = Path.Combine(newFolderPath, Path.GetFileName(videoLocalPlace.FullServerPath)); + var newFullServerPath = Path.Combine(destFolder.ImportFolderLocation, newFilePath); + + var destFullTree = Path.Combine(destFolder.ImportFolderLocation, newFolderPath); + if (!Directory.Exists(destFullTree)) + { + try + { + Directory.CreateDirectory(destFullTree); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to create directory tree: {Ex}", e); + return new MoveFileResult { CanRetry = true, NewFolder = string.Empty, ErrorMessage = $"Unable to create directory tree: \"{destFullTree}\"", Exception = e }; + } + } + + if (newFullServerPath.Equals(videoLocalPlace.FullServerPath, StringComparison.InvariantCultureIgnoreCase)) + { + _logger.LogInformation("Moving file SKIPPED! The file is already at its desired location: \"{FullServerPath}\"", videoLocalPlace.FullServerPath); + return new MoveFileResult { IsSuccess = true, NewFolder = newFolderPath }; + } + + if (File.Exists(newFullServerPath)) + { + _logger.LogError("A file already exists at the desired location: \"{FullServerPath}\"", videoLocalPlace.FullServerPath); + return new MoveFileResult { NewFolder = string.Empty, ErrorMessage = "A file already exists at the destination" }; + } + + Utils.ShokoServer.AddFileWatcherExclusion(newFullServerPath); + + _logger.LogInformation("Moving file from \"{FullServerPath}\" to \"{NewFullServerPath}\"", videoLocalPlace.FullServerPath, newFullServerPath); + try + { + sourceFile.MoveTo(newFullServerPath); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to MOVE file: \"{FullServerPath}\" to \"{NewFullServerPath}\" Error: {Ex}", videoLocalPlace.FullServerPath, + newFullServerPath, e); + Utils.ShokoServer.RemoveFileWatcherExclusion(newFullServerPath); + return new MoveFileResult { CanRetry = true, NewFolder = newFolderPath, ErrorMessage = e.Message, Exception = e }; + } + + var originalFileName = videoLocalPlace.FullServerPath; + var oldPath = videoLocalPlace.FilePath; + + videoLocalPlace.ImportFolderID = destFolder.ImportFolderID; + videoLocalPlace.FilePath = newFilePath; + RepoFactory.VideoLocalPlace.Save(videoLocalPlace); + + MoveExternalSubtitles(newFullServerPath, originalFileName); + + if (dropFolder.IsDropSource == 1 && deleteEmpty) + { + RecursiveDeleteEmptyDirectories(dropFolder.ImportFolderLocation, true); + } + + ShokoEventHandler.Instance.OnFileMoved(dropFolder, destFolder, oldPath, newFilePath, videoLocalPlace); + Utils.ShokoServer.RemoveFileWatcherExclusion(newFullServerPath); + + return new MoveFileResult { IsSuccess = true, NewFolder = newFolderPath }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not MOVE file: \"{FullServerPath}\" -- {Ex}", + videoLocalPlace.FullServerPath ?? videoLocalPlace.VideoLocal_Place_ID.ToString(), ex); + return new MoveFileResult { CanRetry = true, NewFolder = string.Empty, ErrorMessage = ex.Message, Exception = ex}; + } + } + + private void MoveExternalSubtitles(string newFullServerPath, string originalFileName) + { + try + { + var textStreams = SubtitleHelper.GetSubtitleStreams(originalFileName); + // move any subtitle files + foreach (var subtitleFile in textStreams) + { + if (string.IsNullOrEmpty(subtitleFile.Filename)) continue; + + var newParent = Path.GetDirectoryName(newFullServerPath); + var srcParent = Path.GetDirectoryName(originalFileName); + if (string.IsNullOrEmpty(newParent) || string.IsNullOrEmpty(srcParent)) continue; + + var subPath = Path.Combine(srcParent, subtitleFile.Filename); + if (!File.Exists(subPath)) continue; + + var subFile = new FileInfo(subPath); + var newSubPath = Path.Combine(newParent, subFile.Name); + if (File.Exists(newSubPath)) + { + try + { + File.Delete(newSubPath); + } + catch (Exception e) + { + _logger.LogWarning(e, "Unable to DELETE file: \"{SubtitleFile}\" error {Ex}", subtitleFile.Filename, e); + } + } + + try + { + subFile.MoveTo(newSubPath); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to MOVE file: \"{SubtitleFile}\" to \"{NewSubPath}\" error {Ex}", subtitleFile.Filename, newSubPath, e); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, ex.ToString()); + } + } + + private void RecursiveDeleteEmptyDirectories(string dir, bool importfolder) + { + try + { + if (string.IsNullOrEmpty(dir)) return; + if (!Directory.Exists(dir)) return; + if (_settingsProvider.GetSettings().Import.Exclude.Any(s => Regex.IsMatch(dir, s))) return; + + if (IsDirectoryEmpty(dir)) + { + if (importfolder) return; + + try + { + Directory.Delete(dir); + } + catch (Exception ex) + { + if (ex is DirectoryNotFoundException or FileNotFoundException) return; + _logger.LogWarning(ex, "Unable to DELETE directory: {Directory} Error: {Ex}", dir, ex); + } + + return; + } + + // If it has folder, recurse + foreach (var d in Directory.EnumerateDirectories(dir)) + { + if (_settingsProvider.GetSettings().Import.Exclude.Any(s => Regex.IsMatch(d, s))) continue; + RecursiveDeleteEmptyDirectories(d, false); + } + } + catch (Exception e) + { + if (e is FileNotFoundException or DirectoryNotFoundException or UnauthorizedAccessException) return; + _logger.LogError(e, "There was an error removing the empty directory: {Dir}\n{Ex}", dir, e); + } + } + + public static bool IsDirectoryEmpty(string path) + { + try + { + return !Directory.EnumerateFileSystemEntries(path).Any(); + } + catch + { + return false; + } + } + + public bool RefreshMediaInfo(SVR_VideoLocal_Place place) + { + try + { + _logger.LogTrace("Getting media info for: {Place}", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString()); + MediaContainer m = null; + if (place.VideoLocal == null) + { + _logger.LogError("VideoLocal for {Place} failed to be retrieved for MediaInfo", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString()); + return false; + } + + if (place.FullServerPath != null) + { + if (place.GetFile() == null) + { + _logger.LogError("File {Place} failed to be retrieved for MediaInfo", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString()); + return false; + } + + var name = place.FullServerPath.Replace("/", $"{Path.DirectorySeparatorChar}"); + m = Utilities.MediaInfoLib.MediaInfo.GetMediaInfo(name); //Mediainfo should have libcurl.dll for http + var duration = m?.GeneralStream?.Duration ?? 0; + if (duration == 0) + { + m = null; + } + } + + + if (m != null) + { + var info = place.VideoLocal; + + var subs = SubtitleHelper.GetSubtitleStreams(place.FullServerPath); + if (subs.Count > 0) + { + m.media.track.AddRange(subs); + } + + info.Media = m; + return true; + } + + _logger.LogError("File {Place} failed to read MediaInfo", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString()); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to read the media information of file {Place} ERROR: {Ex}", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString(), + e); + } + + return false; + } + + public void RemoveRecordAndDeletePhysicalFile(SVR_VideoLocal_Place place, bool deleteFolder = true) + { + _logger.LogInformation("Deleting video local place record and file: {Place}", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString()); + + if (!File.Exists(place.FullServerPath)) + { + _logger.LogInformation("Unable to find file. Removing Record: {Place}", place.FullServerPath ?? place.FilePath); + RemoveRecord(place); + return; + } + + try + { + File.Delete(place.FullServerPath); + DeleteExternalSubtitles(place.FullServerPath); + } + catch (FileNotFoundException) + { + if (deleteFolder) + { + RecursiveDeleteEmptyDirectories(place.ImportFolder?.ImportFolderLocation, true); + } + + RemoveRecord(place); + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to delete file \'{Place}\': {Ex}", place.FullServerPath, ex); + throw; + } + + if (deleteFolder) + { + RecursiveDeleteEmptyDirectories(place.ImportFolder?.ImportFolderLocation, true); + } + + RemoveRecord(place); + } + + public void RemoveAndDeleteFileWithOpenTransaction(ISession session, SVR_VideoLocal_Place place, HashSet seriesToUpdate) + { + // TODO Make this take an argument to disable removing empty dirs. It's slow, and should only be done if needed + try + { + _logger.LogInformation("Deleting video local place record and file: {Place}", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString()); + + if (!File.Exists(place.FullServerPath)) + { + _logger.LogInformation("Unable to find file. Removing Record: {FullServerPath}", place.FullServerPath); + RemoveRecordWithOpenTransaction(session, place, seriesToUpdate); + return; + } + + try + { + File.Delete(place.FullServerPath); + DeleteExternalSubtitles(place.FullServerPath); + } + catch (Exception ex) + { + if (ex is FileNotFoundException) + { + RecursiveDeleteEmptyDirectories(place.ImportFolder?.ImportFolderLocation, true); + RemoveRecordWithOpenTransaction(session, place, seriesToUpdate); + return; + } + + _logger.LogError(ex, "Unable to delete file \'{Place}\': {Ex}", place.FullServerPath, ex); + return; + } + + RecursiveDeleteEmptyDirectories(place.ImportFolder?.ImportFolderLocation, true); + RemoveRecordWithOpenTransaction(session, place, seriesToUpdate); + // For deletion of files from Trakt, we will rely on the Daily sync + } + catch (Exception ex) + { + _logger.LogError(ex, ex.ToString()); + } + } + + private void DeleteExternalSubtitles(string originalFileName) + { + try + { + var textStreams = SubtitleHelper.GetSubtitleStreams(originalFileName); + // move any subtitle files + foreach (var subtitleFile in textStreams) + { + if (string.IsNullOrEmpty(subtitleFile.Filename)) continue; + + var srcParent = Path.GetDirectoryName(originalFileName); + if (string.IsNullOrEmpty(srcParent)) continue; + + var subPath = Path.Combine(srcParent, subtitleFile.Filename); + if (!File.Exists(subPath)) continue; + + try + { + File.Delete(subPath); + } + catch (Exception e) + { + _logger.LogError(e, "Unable to delete file: \"{SubtitleFile}\"", subtitleFile.Filename); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error deleting external subtitles: {Ex}", ex); + } + } + + public void RemoveRecord(SVR_VideoLocal_Place place, bool updateMyListStatus = true) + { + _logger.LogInformation("Removing VideoLocal_Place record for: {Place}", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString()); + var seriesToUpdate = new List(); + var v = place.VideoLocal; + + using (var session = DatabaseFactory.SessionFactory.OpenSession()) + { + if (v?.Places?.Count <= 1) + { + if (updateMyListStatus) + { + if (RepoFactory.AniDB_File.GetByHash(v.Hash) == null) + { + var xrefs = RepoFactory.CrossRef_File_Episode.GetByHash(v.Hash); + foreach (var xref in xrefs) + { + var ep = RepoFactory.AniDB_Episode.GetByEpisodeID(xref.EpisodeID); + if (ep == null) continue; + + _commandFactory.CreateAndSave( + c => + { + c.AnimeID = xref.AnimeID; + c.EpisodeType = ep.GetEpisodeTypeEnum(); + c.EpisodeNumber = ep.EpisodeNumber; + } + ); + } + } + else + { + _commandFactory.CreateAndSave( + c => + { + c.Hash = v.Hash; + c.FileSize = v.FileSize; + } + ); + } + } + + try + { + ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder, place); + } + catch + { + // ignore + } + + BaseRepository.Lock(session, s => + { + using var transaction = s.BeginTransaction(); + RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(s, place); + + seriesToUpdate.AddRange(v.GetAnimeEpisodes().DistinctBy(a => a.AnimeSeriesID) + .Select(a => a.GetAnimeSeries())); + RepoFactory.VideoLocal.DeleteWithOpenTransaction(s, v); + transaction.Commit(); + }); + } + else + { + try + { + ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder, place); + } + catch + { + // ignore + } + + BaseRepository.Lock(session, s => + { + using var transaction = s.BeginTransaction(); + RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(s, place); + transaction.Commit(); + }); + } + } + + foreach (var ser in seriesToUpdate) + { + ser?.QueueUpdateStats(); + } + } + + + public void RemoveRecordWithOpenTransaction(ISession session, SVR_VideoLocal_Place place, ICollection seriesToUpdate, + bool updateMyListStatus = true) + { + _logger.LogInformation("Removing VideoLocal_Place record for: {Place}", place.FullServerPath ?? place.VideoLocal_Place_ID.ToString()); + var v = place.VideoLocal; + + if (v?.Places?.Count <= 1) + { + if (updateMyListStatus) + { + if (RepoFactory.AniDB_File.GetByHash(v.Hash) == null) + { + var xrefs = RepoFactory.CrossRef_File_Episode.GetByHash(v.Hash); + foreach (var xref in xrefs) + { + var ep = RepoFactory.AniDB_Episode.GetByEpisodeID(xref.EpisodeID); + if (ep == null) + { + continue; + } + + _commandFactory.CreateAndSave(c => + { + c.AnimeID = xref.AnimeID; + c.EpisodeType = ep.GetEpisodeTypeEnum(); + c.EpisodeNumber = ep.EpisodeNumber; + }); + } + } + else + { + _commandFactory.CreateAndSave( + c => + { + c.Hash = v.Hash; + c.FileSize = v.FileSize; + } + ); + } + } + + var eps = v?.GetAnimeEpisodes()?.Where(a => a != null).ToList(); + eps?.DistinctBy(a => a.AnimeSeriesID).Select(a => a.GetAnimeSeries()).ToList().ForEach(seriesToUpdate.Add); + + try + { + ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder, place); + } + catch + { + // ignore + } + + BaseRepository.Lock(() => + { + using var transaction = session.BeginTransaction(); + RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(session, place); + RepoFactory.VideoLocal.DeleteWithOpenTransaction(session, v); + transaction.Commit(); + }); + } + else + { + try + { + ShokoEventHandler.Instance.OnFileDeleted(place.ImportFolder, place); + } + catch + { + // ignore + } + + BaseRepository.Lock(() => + { + using var transaction = session.BeginTransaction(); + RepoFactory.VideoLocalPlace.DeleteWithOpenTransaction(session, place); + transaction.Commit(); + }); + } + } +} diff --git a/Shoko.Server/Utilities/Scanner.cs b/Shoko.Server/Utilities/Scanner.cs index 90f3068b2..016e8ce9b 100644 --- a/Shoko.Server/Utilities/Scanner.cs +++ b/Shoko.Server/Utilities/Scanner.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Threading; +using Microsoft.Extensions.DependencyInjection; using Shoko.Commons.Extensions; using Shoko.Commons.Notification; using Shoko.Commons.Queue; @@ -16,6 +17,7 @@ using Shoko.Server.Models; using Shoko.Server.Repositories; using Shoko.Server.Server; +using Shoko.Server.Services; namespace Shoko.Server.Utilities; @@ -177,12 +179,13 @@ public void DeleteAllErroredFiles() ActiveErrorFiles.Clear(); var episodesToUpdate = new HashSet(); var seriesToUpdate = new HashSet(); + var service = Utils.ServiceContainer.GetRequiredService(); using (var session = DatabaseFactory.SessionFactory.OpenSession()) { files.ForEach(file => { var place = RepoFactory.VideoLocalPlace.GetByID(file.VideoLocal_Place_ID); - place.RemoveAndDeleteFileWithOpenTransaction(session, seriesToUpdate); + service.RemoveAndDeleteFileWithOpenTransaction(session, place, seriesToUpdate); }); // update everything we modified foreach (var ser in seriesToUpdate)