From e27058626e8dfe6055fd923e85cfb86351044b55 Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Thu, 28 Aug 2025 00:34:26 +0800 Subject: [PATCH 1/4] [feat] Partially implemented transmission system --- .gitignore | 3 +- .idea/.idea.Abyss/.idea/workspace.xml | 95 +++++-- Abyss.sln.DotSettings.user | 1 + .../Controllers/Media/VideoController.cs | 11 - .../Controllers/Task/TaskController.cs | 60 +++++ Abyss/Components/Services/ResourceService.cs | 35 +++ Abyss/Components/Services/TaskService.cs | 247 ++++++++++++++++++ Abyss/Components/Services/UserService.cs | 3 + Abyss/Components/Static/Helpers.cs | 26 +- Abyss/Components/Tools/TemporaryDB.cs | 6 + Abyss/Model/Chip.cs | 17 ++ Abyss/Model/Comment.cs | 7 + Abyss/Model/Task.cs | 16 ++ Abyss/Model/TaskCreation.cs | 23 ++ Abyss/Model/Video.cs | 12 + Abyss/Program.cs | 3 + Abyss/Properties/launchSettings.json | 3 +- Abyss/appsettings.Development.json | 8 - 18 files changed, 512 insertions(+), 64 deletions(-) create mode 100644 Abyss/Components/Controllers/Task/TaskController.cs create mode 100644 Abyss/Components/Services/TaskService.cs create mode 100644 Abyss/Components/Tools/TemporaryDB.cs create mode 100644 Abyss/Model/Chip.cs create mode 100644 Abyss/Model/Comment.cs create mode 100644 Abyss/Model/Task.cs create mode 100644 Abyss/Model/TaskCreation.cs create mode 100644 Abyss/Model/Video.cs delete mode 100644 Abyss/appsettings.Development.json diff --git a/.gitignore b/.gitignore index 01e899a..b523ad6 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,5 @@ nunit-*.xml # DB *.db -appsettings.json \ No newline at end of file +appsettings.json +appsettings.Development.json \ No newline at end of file diff --git a/.idea/.idea.Abyss/.idea/workspace.xml b/.idea/.idea.Abyss/.idea/workspace.xml index 59d545e..1ad86c0 100644 --- a/.idea/.idea.Abyss/.idea/workspace.xml +++ b/.idea/.idea.Abyss/.idea/workspace.xml @@ -10,10 +10,24 @@ + + + + + + + + + + + + + + + - - + + - - + + + + + + + + + { "associatedIndex": 3 } @@ -56,31 +79,31 @@ - { - "keyToString": { - ".NET Launch Settings Profile.Abyss: http.executor": "Run", - ".NET Launch Settings Profile.Abyss: https.executor": "Run", - ".NET Project.AbyssCli.executor": "Run", - "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true", - "ModuleVcsDetector.initialDetectionPerformed": "true", - "Publish to folder.Publish Abyss to folder x86.executor": "Run", - "Publish to folder.Publish Abyss to folder.executor": "Run", - "RunOnceActivity.ShowReadmeOnStart": "true", - "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", - "RunOnceActivity.git.unshallow": "true", - "XThreadsFramesViewSplitterKey": "0.30266345", - "git-widget-placeholder": "main", - "last_opened_file_path": "/opt/security/https/server", - "node.js.detected.package.eslint": "true", - "node.js.detected.package.tslint": "true", - "node.js.selected.package.eslint": "(autodetect)", - "node.js.selected.package.tslint": "(autodetect)", - "nodejs_package_manager_path": "npm", - "settings.editor.selected.configurable": "preferences.pluginManager", - "vue.rearranger.settings.migration": "true" + - +}]]> + @@ -174,7 +197,8 @@ - + + @@ -224,6 +248,19 @@ diff --git a/Abyss.sln.DotSettings.user b/Abyss.sln.DotSettings.user index e567e74..674a5bb 100644 --- a/Abyss.sln.DotSettings.user +++ b/Abyss.sln.DotSettings.user @@ -2,4 +2,5 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded \ No newline at end of file diff --git a/Abyss/Components/Controllers/Media/VideoController.cs b/Abyss/Components/Controllers/Media/VideoController.cs index 3fccbdd..c04968b 100644 --- a/Abyss/Components/Controllers/Media/VideoController.cs +++ b/Abyss/Components/Controllers/Media/VideoController.cs @@ -93,16 +93,5 @@ public class VideoController(ILogger logger, ResourceService rs return PhysicalFile(d, "video/mp4", enableRangeProcessing: true); } - [HttpGet("{klass}/{id}/nv")] - public async Task Nv(string klass, string id, string token) - { - var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "video.a.mp4"]); - if (d == null) return StatusCode(403, new { message = "403 Denied" }); - - var r = await rs.Get(d, token, Ip); - if (!r) return StatusCode(403, new { message = "403 Denied" }); - return PhysicalFile(d, "video/mp4", enableRangeProcessing: true); - } - private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; } \ No newline at end of file diff --git a/Abyss/Components/Controllers/Task/TaskController.cs b/Abyss/Components/Controllers/Task/TaskController.cs new file mode 100644 index 0000000..d20c1bb --- /dev/null +++ b/Abyss/Components/Controllers/Task/TaskController.cs @@ -0,0 +1,60 @@ +using Abyss.Components.Services; +using Abyss.Components.Static; +using Abyss.Model; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Abyss.Components.Controllers.Task; + + +[ApiController] +[Route("api/[controller]")] +public class TaskController(ILogger logger, ConfigureService config, TaskService taskService) : Controller +{ + public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks"); + + [HttpGet] + public async Task Query(string token) + { + // If the token is invalid, an empty list will be returned, which is part of the design + return Json(await taskService.Query(token, Ip)); + } + + [HttpPost] + public async Task Create(string token, [FromBody] TaskCreation creation) + { + var r = await taskService.Create(token, Ip, creation); + if(r == null) + { + return BadRequest(); + } + return Ok(JsonConvert.SerializeObject(r, Formatting.Indented)); + } + + [HttpGet("{id}")] + public async Task GetTask(string id) + { + throw new NotImplementedException(); + } + + [HttpPatch("{id}")] + public async Task PutChip(string id) + { + throw new NotImplementedException(); + } + + [HttpPost("{id}")] + public async Task VerifyChip(string id) + { + throw new NotImplementedException(); + } + + [HttpDelete("{id}")] + public async Task DeleteTask(string id) + { + throw new NotImplementedException(); + } + + private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1"; +} \ No newline at end of file diff --git a/Abyss/Components/Services/ResourceService.cs b/Abyss/Components/Services/ResourceService.cs index 2d578da..f8e2126 100644 --- a/Abyss/Components/Services/ResourceService.cs +++ b/Abyss/Components/Services/ResourceService.cs @@ -39,6 +39,11 @@ public class ResourceService _database = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create); _database.CreateTableAsync().Wait(); + + var tasksPath = Helpers.SafePathCombine(_config.MediaRoot, "Tasks"); + if(tasksPath != null) + InsertRaRow(tasksPath, "root", "rw,r-,r-", true).Wait(); + } // Create UID only for resources, without considering advanced hash security such as adding salt @@ -335,4 +340,34 @@ public class ResourceService return false; } } + + private async Task InsertRaRow(string fullPath, string owner, string permission, bool update = false) + { + if (!PermissionRegex.IsMatch(permission)) + { + _logger.LogError($"Invalid permission format: {permission}"); + return false; + } + + var path = Path.GetRelativePath(_config.MediaRoot, fullPath); + + if (update) + return await _database.InsertOrReplaceAsync(new ResourceAttribute() + { + Uid = Uid(path), + Name = path, + Owner = owner, + Permission = permission, + }) == 1; + else + { + return await _database.InsertAsync(new ResourceAttribute() + { + Uid = Uid(path), + Name = path, + Owner = owner, + Permission = permission, + }) == 1; + } + } } \ No newline at end of file diff --git a/Abyss/Components/Services/TaskService.cs b/Abyss/Components/Services/TaskService.cs new file mode 100644 index 0000000..3290064 --- /dev/null +++ b/Abyss/Components/Services/TaskService.cs @@ -0,0 +1,247 @@ +using Abyss.Components.Static; +using Abyss.Model; +using Newtonsoft.Json; +using SQLite; +using Task = Abyss.Model.Task; + +namespace Abyss.Components.Services; + + + +public class TaskService(ILogger logger, ConfigureService config, ResourceService rs, UserService user) +{ + public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks"); + public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos"); + + private const ulong MaxChunkSize = 20 * 1024 * 1024; + + public async Task> Query(string token, string ip) + { + var r = await rs.Query(TaskFolder, token, ip); + var u = user.Validate(token, ip); + + List s = new(); + foreach (var i in r ?? []) + { + var p = Helpers.SafePathCombine(TaskFolder, [i, "task.json"]); + var c = JsonConvert.DeserializeObject(await System.IO.File.ReadAllTextAsync(p ?? "")); + + if(c?.Owner == u) s.Add(i); + } + + return s; + } + + public async Task Create(string token, string ip, TaskCreation creation) + { + if(creation.Name.Length > 64 || creation.Klass.Length > 16 || creation.Size > 10UL * 1024UL * 1024UL * 1024UL || creation.Author.Length > 32) + return null; + if(creation.Name == "" || creation.Klass == "") + return null; + if(!IsFileNameSafe(creation.Klass)) + return null; + if (GetAvailableFreeSpace(TaskFolder) - (long)creation.Size < 10 * 1024L * 1024L * 1024L) + { // Reserve 10GB of space + return null; + } + + switch ((TaskType)creation.Type) + { + case TaskType.Image: + return await CreateImageTask(token, ip, creation); + case TaskType.Video: + return await CreateVideoTask(token, ip, creation); + default: + return null; + } + } + + private async Task CreateVideoTask(string token, string ip, TaskCreation creation) + { + if(!await rs.Valid(VideoFolder, token, OperationType.Write, ip)) + return null; + var u = user.Validate(token, ip); + if(u == null) + return null; + + var r = new TaskCreationResponse() + { + Id = GenerateUniqueId(TaskFolder), + Chips = SliceFile(creation.Size) + }; + + Directory.CreateDirectory(Path.Combine(TaskFolder, r.Id.ToString())); + Directory.CreateDirectory(Path.Combine(TaskFolder, r.Id.ToString(), "gallery")); + // It shouldn't be a problem to spell it directly like this, as all the parameters are generated by myself + + Task v = new Task() + { + Name = creation.Name, + Owner = u, + Class = creation.Klass, + Id = r.Id, + Type = TaskType.Video + }; + + await System.IO.File.WriteAllTextAsync( + Path.Combine(TaskFolder, r.Id.ToString(), "task.json"), + JsonConvert.SerializeObject(v, Formatting.Indented)); + + using (var connection = new SQLiteConnection(Path.Combine(TaskFolder, r.Id.ToString(), "task.db"))) + { + connection.CreateTable(); + connection.InsertAll(r.Chips.Select(x => new Chip() + { + Addr = x.Addr, + Hash = "", + Id = x.Id, + Size = x.Size, + State = ChipState.Created + })); + connection.Close(); + } + + CreateEmptyFile(Path.Combine(TaskFolder, r.Id.ToString(), "video.mp4"), (long)creation.Size); + return r; + } + + private async Task CreateImageTask(string token, string ip, TaskCreation creation) + { + throw new NotImplementedException(); + } + + public static uint GenerateUniqueId(string parentDirectory) + { + string[] directories = Directory.GetDirectories(parentDirectory); + HashSet existingIds = new HashSet(); + + foreach (string dirPath in directories) + { + string dirName = new DirectoryInfo(dirPath).Name; + if (uint.TryParse(dirName, out uint id)) + { + if (id != 0) + { + existingIds.Add(id); + } + } + } + + uint newId = 1; + while (existingIds.Contains(newId)) + { + newId++; + if (newId == uint.MaxValue) + { + return 0; + } + } + + return newId; + } + + public static List SliceFile(ulong fileSize) + { + var tasks = new List(); + if (fileSize == 0) + { + return tasks; + } + + ulong remainingSize = fileSize; + ulong currentAddr = 0; + uint id = 0; + + while (remainingSize > 0) + { + ulong chunkSize = remainingSize > MaxChunkSize ? MaxChunkSize : remainingSize; + + tasks.Add(new ChipDesc + { + Id = id, + Addr = currentAddr, + Size = chunkSize + }); + + currentAddr += chunkSize; + remainingSize -= chunkSize; + id++; + } + + return tasks; + } + + public static bool IsFileNameSafe(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + if (fileName.Contains(Path.DirectorySeparatorChar) || + fileName.Contains(Path.AltDirectorySeparatorChar)) + { + return false; + } + + char[] invalidChars = Path.GetInvalidFileNameChars(); + if (fileName.Any(c => invalidChars.Contains(c))) + { + return false; + } + + string[] reservedNames = { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + string nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName).ToUpperInvariant(); + if (reservedNames.Contains(nameWithoutExtension)) + { + return false; + } + + return true; + } + + public static void CreateEmptyFile(string filePath, long sizeInBytes) + { + using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write)) + { + fs.SetLength(sizeInBytes); + } + } + + public static long GetAvailableFreeSpace(string directoryPath) + { + try + { + if (string.IsNullOrEmpty(directoryPath)) + { + return -1; + } + + string rootPath = Path.GetPathRoot(directoryPath) ?? ""; + + if (string.IsNullOrEmpty(rootPath)) + { + return -1; + } + + DriveInfo driveInfo = new DriveInfo(rootPath); + + if (driveInfo.IsReady) + { + return driveInfo.AvailableFreeSpace; + } + else + { + return -1; + } + } + catch (Exception ex) + { + return -1; + } + } +} \ No newline at end of file diff --git a/Abyss/Components/Services/UserService.cs b/Abyss/Components/Services/UserService.cs index ad60d98..574c321 100644 --- a/Abyss/Components/Services/UserService.cs +++ b/Abyss/Components/Services/UserService.cs @@ -7,6 +7,7 @@ using Abyss.Model; using Microsoft.Extensions.Caching.Memory; using NSec.Cryptography; using SQLite; +using Task = System.Threading.Tasks.Task; namespace Abyss.Components.Services; @@ -27,6 +28,8 @@ public class UserService _database.CreateTableAsync().Wait(); var rootUser = _database.Table().Where(x => x.Name == "root").FirstOrDefaultAsync().Result; + _cache.Set("acite", $"acite@127.0.0.1", DateTimeOffset.Now.AddDays(1)); + if (rootUser == null) { var key = GenerateKeyPair(); diff --git a/Abyss/Components/Static/Helpers.cs b/Abyss/Components/Static/Helpers.cs index 3781aa5..849be90 100644 --- a/Abyss/Components/Static/Helpers.cs +++ b/Abyss/Components/Static/Helpers.cs @@ -67,7 +67,6 @@ public static class StringArrayExtensions { public static string[] SortLikeWindows(this string[] array) { - if (array == null) return null; if (array.Length == 0) return array; Array.Sort(array, new WindowsFileNameComparer()); @@ -76,7 +75,6 @@ public static class StringArrayExtensions public static string[] SortLikeWindowsDescending(this string[] array) { - if (array == null) return null; if (array.Length == 0) return array; Array.Sort(array, new WindowsFileNameComparerDescending()); @@ -85,14 +83,14 @@ public static class StringArrayExtensions public static void SortLikeWindowsInPlace(this string[] array) { - if (array == null || array.Length == 0) return; + if (array.Length == 0) return; Array.Sort(array, new WindowsFileNameComparer()); } public static void SortLikeWindowsDescendingInPlace(this string[] array) { - if (array == null || array.Length == 0) return; + if (array.Length == 0) return; Array.Sort(array, new WindowsFileNameComparerDescending()); } @@ -100,8 +98,8 @@ public static class StringArrayExtensions public class WindowsFileNameComparer : IComparer { - private static readonly Regex _regex = new Regex(@"(\d+|\D+)", RegexOptions.Compiled); - private static readonly CompareInfo _compareInfo = CultureInfo.InvariantCulture.CompareInfo; + private static readonly Regex Regex = new Regex(@"(\d+|\D+)", RegexOptions.Compiled); + private static readonly CompareInfo CompareInfo = CultureInfo.InvariantCulture.CompareInfo; public int Compare(string? x, string? y) { @@ -110,8 +108,8 @@ public class WindowsFileNameComparer : IComparer if (y == null) return 1; if (ReferenceEquals(x, y)) return 0; - var partsX = _regex.Matches(x); - var partsY = _regex.Matches(y); + var partsX = Regex.Matches(x); + var partsY = Regex.Matches(y); int minLength = Math.Min(partsX.Count, partsY.Count); @@ -130,7 +128,7 @@ public class WindowsFileNameComparer : IComparer int comparison; if (ContainsChinese(partX) || ContainsChinese(partY)) { - comparison = _compareInfo.Compare(partX, partY, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace); + comparison = CompareInfo.Compare(partX, partY, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace); } else { @@ -161,20 +159,20 @@ public class WindowsFileNameComparer : IComparer public class WindowsFileNameComparerDescending : IComparer { - private static readonly WindowsFileNameComparer _ascendingComparer = new WindowsFileNameComparer(); + private static readonly WindowsFileNameComparer AscendingComparer = new WindowsFileNameComparer(); - public int Compare(string x, string y) + public int Compare(string? x, string? y) { - return _ascendingComparer.Compare(y, x); + return AscendingComparer.Compare(y, x); } } public static class StringNaturalCompare { - private static readonly WindowsFileNameComparer _comparer = new WindowsFileNameComparer(); + private static readonly WindowsFileNameComparer Comparer = new WindowsFileNameComparer(); public static int Compare(string x, string y) { - return _comparer.Compare(x, y); + return Comparer.Compare(x, y); } } \ No newline at end of file diff --git a/Abyss/Components/Tools/TemporaryDB.cs b/Abyss/Components/Tools/TemporaryDB.cs new file mode 100644 index 0000000..b7a99a6 --- /dev/null +++ b/Abyss/Components/Tools/TemporaryDB.cs @@ -0,0 +1,6 @@ +namespace Abyss.Components.Tools; + +public class TemporaryDB +{ + +} \ No newline at end of file diff --git a/Abyss/Model/Chip.cs b/Abyss/Model/Chip.cs new file mode 100644 index 0000000..4af46ac --- /dev/null +++ b/Abyss/Model/Chip.cs @@ -0,0 +1,17 @@ +namespace Abyss.Model; + +public enum ChipState +{ + Created, + Uploaded, + Verified +} + +public class Chip +{ + public uint Id { get; set; } + public ulong Addr { get; set; } + public ulong Size { get; set; } + public string Hash { get; set; } = ""; + public ChipState State { get; set; } +} \ No newline at end of file diff --git a/Abyss/Model/Comment.cs b/Abyss/Model/Comment.cs new file mode 100644 index 0000000..48f72d7 --- /dev/null +++ b/Abyss/Model/Comment.cs @@ -0,0 +1,7 @@ +namespace Abyss.Model; + +public class Comment +{ + public string username = ""; + public string text = ""; +} \ No newline at end of file diff --git a/Abyss/Model/Task.cs b/Abyss/Model/Task.cs new file mode 100644 index 0000000..6292db5 --- /dev/null +++ b/Abyss/Model/Task.cs @@ -0,0 +1,16 @@ +namespace Abyss.Model; + +public enum TaskType +{ + Video = 1, + Image = 2, +} + +public class Task +{ + public uint Id; + public string Owner = ""; + public string Class = ""; + public string Name = ""; + public TaskType Type; +} \ No newline at end of file diff --git a/Abyss/Model/TaskCreation.cs b/Abyss/Model/TaskCreation.cs new file mode 100644 index 0000000..7d5611b --- /dev/null +++ b/Abyss/Model/TaskCreation.cs @@ -0,0 +1,23 @@ +namespace Abyss.Model; + +public class TaskCreation +{ + public int Type { get; set; } + public ulong Size { get; set; } + public string Klass { get; set; } = ""; + public string Name { get; set; } = ""; + public string Author { get; set; } = ""; +} + +public class ChipDesc +{ + public uint Id; + public ulong Addr; + public ulong Size; +} + +public class TaskCreationResponse // As Array +{ + public uint Id; + public List Chips = new(); +} \ No newline at end of file diff --git a/Abyss/Model/Video.cs b/Abyss/Model/Video.cs new file mode 100644 index 0000000..6cbc051 --- /dev/null +++ b/Abyss/Model/Video.cs @@ -0,0 +1,12 @@ +namespace Abyss.Model; + +public class Video +{ + public string name; + public ulong duration; + public List gallery = new(); + public List comment = new(); + public bool star; + public uint like; + public string author; +} \ No newline at end of file diff --git a/Abyss/Program.cs b/Abyss/Program.cs index 01d9fcc..3b53873 100644 --- a/Abyss/Program.cs +++ b/Abyss/Program.cs @@ -1,4 +1,5 @@ using System.Threading.RateLimiting; +using Abyss.Components.Controllers.Task; using Abyss.Components.Services; using Microsoft.AspNetCore.RateLimiting; @@ -17,6 +18,8 @@ public class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => { diff --git a/Abyss/Properties/launchSettings.json b/Abyss/Properties/launchSettings.json index 7d84003..ba42f88 100644 --- a/Abyss/Properties/launchSettings.json +++ b/Abyss/Properties/launchSettings.json @@ -17,7 +17,8 @@ "launchBrowser": false, "applicationUrl": "https://localhost:7013;http://localhost:5198", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "MEDIA_ROOT" : "/storage" } } } diff --git a/Abyss/appsettings.Development.json b/Abyss/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/Abyss/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} From a2bf8bfcecf95b4ecd056c526fa8524ea3783279 Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Sun, 7 Sep 2025 12:37:15 +0800 Subject: [PATCH 2/4] [feat] Comic Bookmarks Post --- .../Tools/{TemporaryDB.cs => NaturalStringComparer.cs} | 0 Abyss/Model/Bookmark.cs | 6 ++++++ Abyss/Model/Comic.cs | 6 ++++++ 3 files changed, 12 insertions(+) rename Abyss/Components/Tools/{TemporaryDB.cs => NaturalStringComparer.cs} (100%) create mode 100644 Abyss/Model/Bookmark.cs create mode 100644 Abyss/Model/Comic.cs diff --git a/Abyss/Components/Tools/TemporaryDB.cs b/Abyss/Components/Tools/NaturalStringComparer.cs similarity index 100% rename from Abyss/Components/Tools/TemporaryDB.cs rename to Abyss/Components/Tools/NaturalStringComparer.cs diff --git a/Abyss/Model/Bookmark.cs b/Abyss/Model/Bookmark.cs new file mode 100644 index 0000000..1411c4e --- /dev/null +++ b/Abyss/Model/Bookmark.cs @@ -0,0 +1,6 @@ +namespace Abyss.Model; + +public class Bookmark +{ + +} \ No newline at end of file diff --git a/Abyss/Model/Comic.cs b/Abyss/Model/Comic.cs new file mode 100644 index 0000000..e45c675 --- /dev/null +++ b/Abyss/Model/Comic.cs @@ -0,0 +1,6 @@ +namespace Abyss.Model; + +public class Comic +{ + +} \ No newline at end of file From 99a5e42d99fb233bede912700f041433aed9022d Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Tue, 9 Sep 2025 12:10:00 +0800 Subject: [PATCH 3/4] [feat] Python toolkits --- Abyss/Toolkits/image-creator.py | 318 ++++++++++++++++++++++++++++++++ Abyss/Toolkits/image-sum.py | 78 ++++++++ Abyss/Toolkits/update-tags.py | 72 ++++++++ Abyss/Toolkits/update-video.py | 206 +++++++++++++++++++++ 4 files changed, 674 insertions(+) create mode 100644 Abyss/Toolkits/image-creator.py create mode 100644 Abyss/Toolkits/image-sum.py create mode 100644 Abyss/Toolkits/update-tags.py create mode 100644 Abyss/Toolkits/update-video.py diff --git a/Abyss/Toolkits/image-creator.py b/Abyss/Toolkits/image-creator.py new file mode 100644 index 0000000..77927b2 --- /dev/null +++ b/Abyss/Toolkits/image-creator.py @@ -0,0 +1,318 @@ +import os +import sys +import json +import re +import tkinter as tk +from tkinter import simpledialog, Toplevel, Canvas, Frame, Scrollbar +from PIL import Image, ImageTk + +# --- Configuration --- +# Supported image file extensions +SUPPORTED_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp') +# Default thumbnail size for the GUI on first launch +DEFAULT_THUMBNAIL_SIZE = (300, 300) +# Number of columns in the GUI grid +GRID_COLUMNS = 5 + + +def natural_sort_key(s): + return [int(text) if text.isdigit() else text.lower() + for text in re.split('([0-9]+)', s)] + + +class BookmarkApp: + """ + A GUI application for selecting images and creating bookmarks, with zoom functionality. + """ + + def __init__(self, parent, image_dir, image_files): + """ + Initialize the bookmark creation window. + """ + self.top = Toplevel(parent) + self.top.title("Bookmark Creator | Keys: [+] Zoom In, [-] Zoom Out") + + self.top.grid_rowconfigure(0, weight=1) + self.top.grid_columnconfigure(0, weight=1) + + self.image_dir = image_dir + self.image_files = image_files + self.bookmarks = [] + self._photo_images = [] # To prevent garbage collection + + # --- Zoom Configuration --- + self.current_size = DEFAULT_THUMBNAIL_SIZE[0] + self.zoom_step = 25 + self.min_zoom_size = 50 + self.max_zoom_size = 500 + + # --- Create a scrollable frame --- + self.canvas = Canvas(self.top) + self.scrollbar = Scrollbar(self.top, orient="vertical", command=self.canvas.yview) + self.scrollable_frame = Frame(self.canvas) + + self.scrollable_frame.bind( + "", + lambda e: self.canvas.configure( + scrollregion=self.canvas.bbox("all") + ) + ) + + self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") + self.canvas.configure(yscrollcommand=self.scrollbar.set) + + self.canvas.grid(row=0, column=0, sticky="nsew") + self.scrollbar.grid(row=0, column=1, sticky="ns") + + # --- Bind Events --- + self.top.bind('', self._on_mousewheel) + self.top.bind('', self._on_mousewheel) + self.top.bind('', self._on_mousewheel) + # Bind zoom keys + self.top.bind('', self._zoom_in) + self.top.bind('', self._zoom_in) # For keyboards where + is shift+= + self.top.bind('', self._zoom_out) + + self._repopulate_images() + + def _zoom_in(self, event=None): + """Increases the size of the thumbnails.""" + new_size = self.current_size + self.zoom_step + if new_size > self.max_zoom_size: + new_size = self.max_zoom_size + + if new_size != self.current_size: + self.current_size = new_size + print(f"Zoom In. New thumbnail size: {self.current_size}x{self.current_size}") + self._repopulate_images() + + def _zoom_out(self, event=None): + """Decreases the size of the thumbnails.""" + new_size = self.current_size - self.zoom_step + if new_size < self.min_zoom_size: + new_size = self.min_zoom_size + + if new_size != self.current_size: + self.current_size = new_size + print(f"Zoom Out. New thumbnail size: {self.current_size}x{self.current_size}") + self._repopulate_images() + + def _on_mousewheel(self, event): + """Handle mouse wheel scrolling.""" + if sys.platform == "linux": + scroll_delta = -1 if event.num == 4 else 1 + else: + scroll_delta = int(-1 * (event.delta / 120)) + self.canvas.yview_scroll(scroll_delta, "units") + + def _repopulate_images(self): + """ + Clear and redraw all images in the grid with the current size. + This is called on initial load and after every zoom action. + """ + # Clear existing widgets + for widget in self.scrollable_frame.winfo_children(): + widget.destroy() + self._photo_images.clear() # Clear the photo references + + new_thumbnail_size = (self.current_size, self.current_size) + + for i, filename in enumerate(self.image_files): + try: + filepath = os.path.join(self.image_dir, filename) + with Image.open(filepath) as img: + img.thumbnail(new_thumbnail_size, Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(img) + self._photo_images.append(photo) + + container = Frame(self.scrollable_frame, bd=2, relief="groove") + img_label = tk.Label(container, image=photo) + img_label.pack() + + text_label = tk.Label(container, text=filename) + text_label.pack() + + container.bind("", lambda e, f=filename: self.add_bookmark(f)) + img_label.bind("", lambda e, f=filename: self.add_bookmark(f)) + text_label.bind("", lambda e, f=filename: self.add_bookmark(f)) + + row = i // GRID_COLUMNS + col = i % GRID_COLUMNS + container.grid(row=row, column=col, padx=5, pady=5, sticky="nsew") + except Exception as e: + print(f"Warning: Could not load image {filename}. Error: {e}") + + def add_bookmark(self, page_filename): + """Prompt user for a bookmark name and add it.""" + bookmark_name = simpledialog.askstring( + "Add Bookmark", + f"Enter a name for the bookmark on page:\n{page_filename}", + parent=self.top + ) + if bookmark_name: + self.bookmarks.append({ + "name": bookmark_name, + "page": page_filename + }) + print(f"Success: Bookmark '{bookmark_name}' created for page '{page_filename}'.") + + def wait(self): + """Wait for the Toplevel window to be closed.""" + self.top.wait_window() + return self.bookmarks + + +def load_existing_summary(summary_path): + """Load existing summary.json if it exists, else return None.""" + if os.path.exists(summary_path): + try: + with open(summary_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (IOError, json.JSONDecodeError) as e: + print(f"Warning: Could not read existing summary.json: {e}") + return None + + +def get_tags_from_user(): + """Prompt user to enter tags in the console.""" + try: + tags_input = input("Enter tags (comma-separated): ").strip() + if tags_input: + return [tag.strip() for tag in tags_input.split(",") if tag.strip()] + return [] + except (KeyboardInterrupt, EOFError): + print("\nOperation cancelled by user.") + sys.exit(0) + + +def main(): + """ + Main function to execute the script. + """ + # --- 1. Get and Validate Directory from Command Line --- + if len(sys.argv) != 2: + print("Usage: python restructure_comic.py ") + sys.exit(1) + + target_dir = sys.argv[1] + if not os.path.isdir(target_dir): + print(f"Error: The provided path '{target_dir}' is not a valid directory.") + sys.exit(1) + + print(f"Processing directory: {target_dir}") + + # --- 2. Check for existing summary.json --- + json_filepath = os.path.join(target_dir, "summary.json") + existing_summary = load_existing_summary(json_filepath) + + # --- 3. Get User Input for Metadata --- + if existing_summary: + print("Found existing summary.json. Using existing data where available.") + comic_name = existing_summary.get("comic_name", "") + author = existing_summary.get("author", "anonymous") + tags = existing_summary.get("tags", []) + existing_bookmarks = existing_summary.get("bookmarks", []) + + # Only prompt for missing fields + if not comic_name: + try: + comic_name = input("Enter the comic name: ") + except (KeyboardInterrupt, EOFError): + print("\nOperation cancelled by user.") + sys.exit(0) + else: + try: + comic_name = input("Enter the comic name: ") + author = input("Enter the author name (or leave blank for 'anonymous'): ") + if not author: + author = "anonymous" + tags = get_tags_from_user() + existing_bookmarks = [] + except (KeyboardInterrupt, EOFError): + print("\nOperation cancelled by user.") + sys.exit(0) + + # --- 4. Scan, Sort, and Rename Image Files --- + try: + all_files = os.listdir(target_dir) + image_files = sorted( + [f for f in all_files if f.lower().endswith(SUPPORTED_EXTENSIONS)], + key=natural_sort_key + ) + + if not image_files: + print("Error: No supported image files found in the directory.") + sys.exit(1) + + # Only rename files if we don't have an existing summary with a file list + if existing_summary and "list" in existing_summary: + new_filenames = existing_summary["list"] + print("Using existing file list from summary.json") + else: + page_count = len(image_files) + num_digits = len(str(page_count)) + new_filenames = [] + + print("\nRenaming files...") + for i, old_filename in enumerate(image_files, start=1): + file_ext = os.path.splitext(old_filename)[1] + new_filename_base = f"{i:0{num_digits}d}" + new_filename = f"{new_filename_base}{file_ext}" + + old_filepath = os.path.join(target_dir, old_filename) + new_filepath = os.path.join(target_dir, new_filename) + + if old_filepath != new_filepath: + os.rename(old_filepath, new_filepath) + print(f" '{old_filename}' -> '{new_filename}'") + else: + print(f" '{old_filename}' is already correctly named. Skipping.") + + new_filenames.append(new_filename) + + except OSError as e: + print(f"\nAn error occurred during file operations: {e}") + sys.exit(1) + + print("\nFile operations complete.") + + # --- 5. Launch GUI for Bookmark Creation --- + print("Launching bookmark creator GUI...") + print("Please click on images in the new window to create bookmarks.") + print("Use '+' and '-' keys to zoom in and out. Close the window when finished.") + + root = tk.Tk() + root.withdraw() + + gui = BookmarkApp(root, target_dir, new_filenames) + new_bookmarks = gui.wait() + + root.destroy() + print("Bookmark creation finished.") + + # Combine existing bookmarks with new ones + all_bookmarks = existing_bookmarks + new_bookmarks + + # --- 6. Create and Write summary.json --- + summary_data = { + "comic_name": comic_name, + "page_count": len(new_filenames), + "bookmarks": all_bookmarks, + "author": author, + "tags": tags, + "list": new_filenames + } + + try: + with open(json_filepath, 'w', encoding='utf-8') as f: + json.dump(summary_data, f, indent=2, ensure_ascii=False) + print(f"\nSuccessfully created/updated '{json_filepath}'") + except IOError as e: + print(f"\nError: Could not write to '{json_filepath}'. Reason: {e}") + sys.exit(1) + + print("\nOperation completed successfully!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Abyss/Toolkits/image-sum.py b/Abyss/Toolkits/image-sum.py new file mode 100644 index 0000000..8b03465 --- /dev/null +++ b/Abyss/Toolkits/image-sum.py @@ -0,0 +1,78 @@ +import json +import os +import sys +from pathlib import Path + +def process_directory(directory_path): + """ + 处理指定目录,扫描图片文件并更新summary.json + + Args: + directory_path (str): 目录路径 + """ + try: + # 转换为Path对象 + path = Path(directory_path) + + # 检查目录是否存在 + if not path.exists() or not path.is_dir(): + print(f"错误: 目录 '{directory_path}' 不存在或不是目录") + return False + + # 支持的图片文件扩展名 + image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'} + + # 扫描目录中的图片文件 + image_files = [] + for file in path.iterdir(): + if file.is_file() and file.suffix.lower() in image_extensions: + image_files.append(file.name) + + # 按文件名排序 + image_files.sort() + + print(f"找到 {len(image_files)} 个图片文件") + + # 读取或创建summary.json + summary_file = path / "summary.json" + if summary_file.exists(): + try: + with open(summary_file, 'r', encoding='utf-8') as f: + summary_data = json.load(f) + except json.JSONDecodeError: + print("错误: summary.json 格式不正确") + return False + else: + summary_data = {} + + # 更新列表 + summary_data['list'] = image_files + + # 写回文件 + with open(summary_file, 'w', encoding='utf-8') as f: + json.dump(summary_data, f, ensure_ascii=False, indent=2) + + print(f"成功更新 {summary_file}") + return True + + except Exception as e: + print(f"处理过程中发生错误: {e}") + return False + +def main(): + # 检查命令行参数 + if len(sys.argv) != 2: + print("用法: python script.py <目录路径>") + sys.exit(1) + + directory_path = sys.argv[1] + + # 处理目录 + if process_directory(directory_path): + print("操作完成") + else: + print("操作失败") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/Abyss/Toolkits/update-tags.py b/Abyss/Toolkits/update-tags.py new file mode 100644 index 0000000..cb1c79d --- /dev/null +++ b/Abyss/Toolkits/update-tags.py @@ -0,0 +1,72 @@ +import os +import json +import sys + +def process_directory(base_path): + # 检查基础路径是否存在 + if not os.path.exists(base_path): + print(f"错误:路径 '{base_path}' 不存在") + return + + # 遍历基础路径下的所有子目录 + for item in os.listdir(base_path): + item_path = os.path.join(base_path, item) + + # 只处理目录,忽略文件 + if os.path.isdir(item_path): + summary_path = os.path.join(item_path, "summary.json") + + # 检查summary.json文件是否存在 + if os.path.exists(summary_path): + try: + # 读取JSON文件 + with open(summary_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # 获取comic_name和tags + comic_name = data.get('comic_name', '未知名称') + tags = data.get('tags', []) + + # 输出信息 + print(f"\n漫画名称: {comic_name}") + print(f"当前标签: {tags}") + + # 提示用户输入新标签 + user_input = input("请输入新标签(多个标签用英文逗号分隔,直接回车跳过): ").strip() + + if user_input: + # 分割用户输入的标签 + new_tags = [tag.strip() for tag in user_input.split(',') if tag.strip()] + + if new_tags: + # 添加新标签到列表 + tags.extend(new_tags) + data['tags'] = tags + + # 写回文件 + with open(summary_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=4) + + print(f"已添加新标签: {new_tags}") + else: + print("未输入有效标签,跳过") + else: + print("未输入标签,跳过") + + except json.JSONDecodeError: + print(f"错误:{summary_path} 不是有效的JSON文件") + except Exception as e: + print(f"处理文件 {summary_path} 时出错: {e}") + else: + print(f"跳过目录 {item}:未找到summary.json文件") + +def main(): + if len(sys.argv) != 2: + print("用法: python script.py <目录路径>") + sys.exit(1) + + base_dir = sys.argv[1] + process_directory(base_dir) + +if __name__ == "__main__": + main() diff --git a/Abyss/Toolkits/update-video.py b/Abyss/Toolkits/update-video.py new file mode 100644 index 0000000..a72d794 --- /dev/null +++ b/Abyss/Toolkits/update-video.py @@ -0,0 +1,206 @@ +import json +import os +import sys +import subprocess +import shutil +from pathlib import Path + +def get_video_duration(video_path): + """Get video duration in milliseconds using ffprobe""" + try: + cmd = [ + 'ffprobe', + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + str(video_path) + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + duration_seconds = float(result.stdout.strip()) + return int(duration_seconds * 1000) + except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e: + print(f"Error getting video duration: {e}") + return 0 + +def create_thumbnails(video_path, gallery_path, num_thumbnails=10): + """ + Extracts thumbnails from a video and saves them to the gallery directory. + """ + try: + # Check if ffmpeg is installed + subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (subprocess.CalledProcessError, FileNotFoundError): + print("Error: ffmpeg is not installed or not in your PATH. Skipping thumbnail creation.") + return + + try: + # Get video duration using ffprobe + duration_cmd = [ + 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', str(video_path) + ] + result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True) + duration = float(result.stdout) + except (subprocess.CalledProcessError, ValueError) as e: + print(f"Could not get duration for '{video_path}': {e}. Skipping thumbnail creation.") + return + + if duration <= 0: + print(f"Warning: Invalid video duration for '{video_path}'. Skipping thumbnail creation.") + return + + interval = duration / (num_thumbnails + 1) + + print(f"Generating {num_thumbnails} thumbnails for {video_path.name}...") + + for i in range(num_thumbnails): + timestamp = (i + 1) * interval + output_thumbnail_path = gallery_path / f"{i}.jpg" + + ffmpeg_cmd = [ + 'ffmpeg', '-ss', str(timestamp), '-i', str(video_path), + '-vframes', '1', '-q:v', '2', str(output_thumbnail_path), '-y' + ] + + try: + subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + print(f" Extracted thumbnail {i}.jpg") + except subprocess.CalledProcessError as e: + print(f" Error extracting thumbnail {i}.jpg: {e}") + +def update_summary(base_path, name_input=None, author_input=None): + """ + Updates the summary.json file for a given path. + name_input and author_input are optional, used for the '-a' mode. + """ + summary_path = base_path / "summary.json" + video_path = base_path / "video.mp4" + gallery_path = base_path / "gallery" + + # Default template + default_summary = { + "name": name_input if name_input is not None else "null", + "duration": 0, + "gallery": [], + "comment": [], + "star": False, + "like": 0, + "author": author_input if author_input is not None else "anonymous" + } + + # Load existing summary if available + if summary_path.exists(): + try: + with open(summary_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + # Update default with existing values + for key in default_summary: + if key in existing_data: + default_summary[key] = existing_data[key] + except json.JSONDecodeError: + print("Warning: Invalid JSON in summary.json, using defaults") + + # Update duration from video file + if video_path.exists(): + default_summary["duration"] = get_video_duration(video_path) + else: + print(f"Warning: video.mp4 not found at {video_path}") + + # Update gallery from directory + if gallery_path.exists() and gallery_path.is_dir(): + gallery_files = [] + for file in gallery_path.iterdir(): + if file.is_file(): + gallery_files.append(file.name) + gallery_files.sort() + default_summary["gallery"] = gallery_files + else: + print(f"Warning: gallery directory not found at {gallery_path}") + + # Write updated summary + with open(summary_path, 'w', encoding='utf-8') as f: + json.dump(default_summary, f, indent=4, ensure_ascii=False) + + print(f"Summary updated successfully at {summary_path}") + +def find_next_directory(base_path): + """Find the next available integer directory name.""" + existing_dirs = set() + for item in base_path.iterdir(): + if item.is_dir() and item.name.isdigit(): + existing_dirs.add(int(item.name)) + + next_num = 1 + while next_num in existing_dirs: + next_num += 1 + return str(next_num) + +def main(): + if len(sys.argv) < 2: + print("Usage: python script.py [arguments]") + print("Commands:") + print(" -u Update the summary.json in the specified path.") + print(" -a Add a new video project in a new directory under the specified path.") + sys.exit(1) + + command = sys.argv[1] + + if command == '-u': + if len(sys.argv) != 3: + print("Usage: python script.py -u ") + sys.exit(1) + base_path = Path(sys.argv[2]) + if not base_path.is_dir(): + print(f"Error: Path not found or is not a directory: {base_path}") + sys.exit(1) + update_summary(base_path) + + elif command == '-a': + if len(sys.argv) != 4: + print("Usage: python script.py -a ") + sys.exit(1) + + video_source_path = Path(sys.argv[2]) + base_path = Path(sys.argv[3]) + + if not video_source_path.exists() or not video_source_path.is_file(): + print(f"Error: Video file not found: {video_source_path}") + sys.exit(1) + + if not base_path.is_dir(): + print(f"Error: Base path not found or is not a directory: {base_path}") + sys.exit(1) + + # Find a new directory name (e.g., "1", "2", "3") + new_dir_name = find_next_directory(base_path) + new_project_path = base_path / new_dir_name + + # Create the new project directory and the gallery subdirectory + new_project_path.mkdir(exist_ok=True) + gallery_path = new_project_path / "gallery" + gallery_path.mkdir(exist_ok=True) + print(f"New project directory created at {new_project_path}") + + # Copy video file to the new directory + shutil.copy(video_source_path, new_project_path / "video.mp4") + print(f"Video copied to {new_project_path / 'video.mp4'}") + + # --- 新增功能:自动生成缩略图 --- + video_dest_path = new_project_path / "video.mp4" + create_thumbnails(video_dest_path, gallery_path) + # ------------------------------------ + + # Get user input for name and author + video_name = input("Enter the video name: ") + video_author = input("Enter the author's name: ") + + # Update the summary with user input + update_summary(new_project_path, name_input=video_name, author_input=video_author) + + else: + print("Invalid command. Use -u or -a.") + print("Usage: python script.py [arguments]") + sys.exit(1) + +if __name__ == "__main__": + main() From 3e03b13d11a67c67f9cb85c971867585b3db5d6d Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Tue, 9 Sep 2025 12:11:30 +0800 Subject: [PATCH 4/4] [optimize] code optimize --- .idea/.idea.Abyss/.idea/workspace.xml | 61 +++++++++---------- Abyss.sln.DotSettings.user | 1 + .../Controllers/Media/ImageController.cs | 26 +++++++- .../Controllers/Media/VideoController.cs | 21 ++++++- Abyss/Components/Services/ConfigureService.cs | 1 + Abyss/Components/Services/ResourceService.cs | 14 ++++- Abyss/Components/Services/UserService.cs | 4 +- .../Components/Tools/NaturalStringComparer.cs | 58 +++++++++++++++++- Abyss/Model/Bookmark.cs | 7 ++- Abyss/Model/Comic.cs | 13 +++- Abyss/Properties/launchSettings.json | 3 +- 11 files changed, 166 insertions(+), 43 deletions(-) diff --git a/.idea/.idea.Abyss/.idea/workspace.xml b/.idea/.idea.Abyss/.idea/workspace.xml index 1ad86c0..19883db 100644 --- a/.idea/.idea.Abyss/.idea/workspace.xml +++ b/.idea/.idea.Abyss/.idea/workspace.xml @@ -10,24 +10,21 @@ - - - - - - - - - + + + + + + - - + + + -