diff --git a/.gitignore b/.gitignore index 58164f3..e658aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .godot/ /android/ .idea/ -data.json \ No newline at end of file +data.json +config.json \ No newline at end of file diff --git a/Component/MainTreePanel.cs b/Component/MainTreePanel.cs index 70bd5be..c147f8c 100644 --- a/Component/MainTreePanel.cs +++ b/Component/MainTreePanel.cs @@ -1,8 +1,11 @@ +using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using Godot; using Learn.Models; +using Learn.Parsers; using Newtonsoft.Json; namespace Learn.Component; @@ -95,15 +98,22 @@ public partial class MainTreePanel : Tree private void OnMultiSelected(TreeItem item, long column, bool selected) { - if(selected) _selectingNodes.Add(_mapper[item]); - else _selectingNodes.Remove(_mapper[item]); + for (int i = 0; i < Columns; i++) + { + if (i == column ? selected : item.IsSelected(i)) + { + _selectingNodes.Add(_mapper[item]); + return; + } + } + _selectingNodes.Remove(_mapper[item]); } private TreeItem CreateNode(TreeItem father, Item item) { var node = CreateItem(father); - var name = Path.GetFileName(item.MainInfo["Path"]); + var name = Path.GetFileName(item.MainInfo[ItemFields.MainKey_Path]); node.SetText(0, name); return node; } @@ -118,8 +128,8 @@ public partial class MainTreePanel : Tree { MainInfo = { - ["IsDir"] = isDir ? "True" : "False", - ["Path"] = path + [ItemFields.MainKey_IsDir] = isDir ? "True" : "False", + [ItemFields.MainKey_Path] = path } }; _items.Add(path, item); diff --git a/Component/NodeInfoEditPanel.cs b/Component/NodeInfoEditPanel.cs index 1c3b1be..b0222c5 100644 --- a/Component/NodeInfoEditPanel.cs +++ b/Component/NodeInfoEditPanel.cs @@ -39,14 +39,15 @@ public partial class NodeInfoEditPanel : Tree private void OnMultiSelected(TreeItem item, long column, bool selected) { - if (selected) + for (int i = 0; i < Columns; i++) { - _selectingNodes.Add(item); - } - else - { - _selectingNodes.Remove(item); + if (i == column ? selected : item.IsSelected(i)) + { + _selectingNodes.Add(item); + return; + } } + _selectingNodes.Remove(item); } private void OnItemEdited() diff --git a/Config/ProxyConfig.cs b/Config/ProxyConfig.cs new file mode 100644 index 0000000..9c1d178 --- /dev/null +++ b/Config/ProxyConfig.cs @@ -0,0 +1,9 @@ +using Learn.Utils; + +namespace Learn.Config; + +[ConfigItem("Proxy")] +public class ProxyConfig : IConfigItem +{ + public string HttpProxy = ""; +} \ No newline at end of file diff --git a/Config/ProxyConfig.cs.uid b/Config/ProxyConfig.cs.uid new file mode 100644 index 0000000..db66392 --- /dev/null +++ b/Config/ProxyConfig.cs.uid @@ -0,0 +1 @@ +uid://k75xslv0u3pm diff --git a/Config/TMDBConfig.cs b/Config/TMDBConfig.cs new file mode 100644 index 0000000..bde84e2 --- /dev/null +++ b/Config/TMDBConfig.cs @@ -0,0 +1,9 @@ +using Learn.Utils; + +namespace Learn.Config; + +[ConfigItem("TMDB")] +public class TMDBConfig : IConfigItem +{ + public string ApiKey = ""; +} \ No newline at end of file diff --git a/Config/TMDBConfig.cs.uid b/Config/TMDBConfig.cs.uid new file mode 100644 index 0000000..905f30b --- /dev/null +++ b/Config/TMDBConfig.cs.uid @@ -0,0 +1 @@ +uid://v07s3j12o0lw diff --git a/Learn.csproj b/Learn.csproj index 05f4301..5cb36ba 100644 --- a/Learn.csproj +++ b/Learn.csproj @@ -6,5 +6,6 @@ + \ No newline at end of file diff --git a/Main.cs b/Main.cs index 25ca3c0..49c77e6 100644 --- a/Main.cs +++ b/Main.cs @@ -1,9 +1,13 @@ +using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using Godot; using System.Linq; +using System.Threading.Tasks; using Learn.Component; using Learn.Parsers; +using Learn.Utils; public partial class Main : Node { @@ -36,6 +40,8 @@ public partial class Main : Node [Export] private Button _foldAllButton; private bool _refreshPanels; + + private Configs _configs; public override void _Ready() { @@ -57,6 +63,7 @@ public partial class Main : Node _saveButton.Pressed += DoSave; _resetButton.Pressed += DoReset; _loadButton.Pressed += LoadData; + _doParseButton.Text = "开始解析"; LoadData(); @@ -64,6 +71,10 @@ public partial class Main : Node FoldAll(); _refreshPanels = true; + + _configs = new Configs(); + _configs.ConfigPath = "config.json"; + _configs.Load(true); } private void LoadData() @@ -129,29 +140,54 @@ public partial class Main : Node _refreshPanels = true; } - private void DoParse() + private async void DoParse() { var root = _mainTreePanel.GetRoot(); if (root == null) return; - ItemParser parser = new NormalParser(); + var originName = _doParseButton.Text; + _doParseButton.Disabled = true; - var queue = new Queue(); - queue.Enqueue(root); - while (queue.Count > 0) + ItemParser parser = new TMDBParser(_configs); + + var stopwatch = Stopwatch.StartNew(); + var stack = new Stack(); + stack.Push(root); + + while (stack.Count > 0) { - var treeItem = queue.Dequeue(); + _doParseButton.Text = "解析中"; + if (stopwatch.ElapsedMilliseconds > 2000) + { + _mainTreePanel.UpdateColumns(); + stopwatch.Restart(); + } + var treeItem = stack.Pop(); foreach (var child in treeItem.GetChildren()) { if(child == null) continue; if (_mainTreePanel.Query(child, out var node)) { - parser.DoParse(node); + try + { + if (!await parser.DoParse(node)) + { + GD.PrintErr($"解析{child.GetText(0)}失败"); + } + } + catch (Exception ex) + { + GD.PrintErr($"解析{child.GetText(0)}出现异常:" + ex); + } } - queue.Enqueue(child); + stack.Push(child); } } + + _doParseButton.Text = originName; + _doParseButton.Disabled = false; _mainTreePanel.UpdateColumns(); + GD.Print("Done"); } private const string DataPath = "data.json"; diff --git a/Parsers/ItemFieldExtension.cs b/Parsers/ItemFieldExtension.cs deleted file mode 100644 index da42ee3..0000000 --- a/Parsers/ItemFieldExtension.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using Learn.Models; - -namespace Learn.Parsers; - -public static class ItemFieldExtension -{ - #region 基础信息 - - public static string Name(this Item item) - { - var path = item.MainInfo["Path"]; - return Path.GetFileName(path); - } - - public static bool IsFolder(this Item item) - { - return item.MainInfo["IsDir"] == "True"; - } - - #endregion - - - public static void SetGroupIfNotExist(this Item item, string group) - { - item.Info.TryAdd("Group", group); - } - - public static void SetTitleIfNotExist(this Item item, string title) - { - item.Info.TryAdd("Title", title); - } - - public static void SetRawTitle(this Item item, string rawTitle) - { - item.Info.TryAdd("RawTitle", rawTitle); - } - - public static string RawTitle(this Item item) - { - return item.Info["RawTitle"]; - } - - public static void SetSeasonIfNotExist(this Item item, string season) - { - item.Info.TryAdd("Season", season); - } -} \ No newline at end of file diff --git a/Parsers/ItemFieldExtension.cs.uid b/Parsers/ItemFieldExtension.cs.uid deleted file mode 100644 index 0737d00..0000000 --- a/Parsers/ItemFieldExtension.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://fo70m114nwe7 diff --git a/Parsers/ItemFields.cs b/Parsers/ItemFields.cs new file mode 100644 index 0000000..68d5312 --- /dev/null +++ b/Parsers/ItemFields.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.IO; +using Learn.Models; + +namespace Learn.Parsers; + +public static class ItemFields +{ + #region 基础信息 + + public static string MainKey_Path => "Path"; + + public static string MainKey_IsDir => "IsDir"; + + public static string Name(this Item item) + { + var path = item.MainInfo[MainKey_Path]; + return Path.GetFileName(path); + } + + public static bool IsFolder(this Item item) + { + return item.MainInfo[MainKey_IsDir] == "True"; + } + + #endregion + + + public static string Key_Group => "Group"; + public static string Key_Title => "Title"; + public static string Key_RawTitle => "RawTitle"; + public static string Key_Season => "Season"; + public static string Key_Year => "Year"; + + + public static void SetGroupIfNotExist(this Item item, string group) + { + item.Info.TryAdd(Key_Group, group); + } + + public static void SetTitleIfNotExist(this Item item, string title) + { + item.Info.TryAdd(Key_Title, title); + } + + public static void SetRawTitleIfNotExist(this Item item, string rawTitle) + { + item.Info.TryAdd(Key_RawTitle, rawTitle); + } + + public static void SetSeasonIfNotExist(this Item item, string season) + { + item.Info.TryAdd(Key_Season, season); + } + + public static void SetYear(this Item item, int year) + { + item.Info[Key_Year] = year.ToString(); + } +} \ No newline at end of file diff --git a/Parsers/ItemFields.cs.uid b/Parsers/ItemFields.cs.uid new file mode 100644 index 0000000..18b944f --- /dev/null +++ b/Parsers/ItemFields.cs.uid @@ -0,0 +1 @@ +uid://cavjlh7k1lb6m diff --git a/Parsers/ItemParser.cs b/Parsers/ItemParser.cs index 3df1cc6..3dbd0cc 100644 --- a/Parsers/ItemParser.cs +++ b/Parsers/ItemParser.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; using Learn.Models; namespace Learn.Parsers; @@ -15,5 +16,5 @@ namespace Learn.Parsers; /// public interface ItemParser { - public bool DoParse(TreeNode node); + public Task DoParse(TreeNode node); } \ No newline at end of file diff --git a/Parsers/NormalParser.cs b/Parsers/NormalParser.cs deleted file mode 100644 index 12316c2..0000000 --- a/Parsers/NormalParser.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Linq; -using System.Text.RegularExpressions; -using Learn.Models; - -namespace Learn.Parsers; - -public class NormalParser : ItemParser -{ - private string ParseSeason(string season) - { - if (int.TryParse(season, out _)) return season; - - switch (season) - { - case "零": return "0"; - case "一": return "1"; - case "二": return "2"; - case "三": return "3"; - case "四": return "4"; - case "五": return "5"; - case "六": return "6"; - case "七": return "7"; - case "八": return "8"; - case "九": return "9"; - } - return season; - } - - public bool DoParse(TreeNode node) - { - var item = node.Info; - var name = item.Name(); - var matches = Regex.Matches(name, @"[^\[\]_]+").Select(match => match.Value).ToArray(); - - if (matches.Length == 1) - { - node.Info.SetRawTitle(matches[0]); - return true; - } - - if (matches.Length >= 2) - { - node.Info.SetGroupIfNotExist(matches[0]); - - var title = matches[1].Trim(); - var match = Regex.Match(title, "(.+)第(.+)季"); - if (match.Success) - { - node.Info.SetSeasonIfNotExist(ParseSeason(match.Groups[2].Value.Trim())); - node.Info.SetRawTitle(match.Groups[1].Value.Trim()); - } - else - { - node.Info.SetRawTitle(title); - } - } - - - return true; - } -} \ No newline at end of file diff --git a/Parsers/NormalParser.cs.uid b/Parsers/NormalParser.cs.uid deleted file mode 100644 index c482b65..0000000 --- a/Parsers/NormalParser.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bfixayy5gpwd6 diff --git a/Parsers/TMDBParser.cs b/Parsers/TMDBParser.cs new file mode 100644 index 0000000..6fd0b17 --- /dev/null +++ b/Parsers/TMDBParser.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Godot; +using Learn.Config; +using Learn.Models; +using Learn.Utils; +using TMDbLib.Client; +using TMDbLib.Objects.Search; + +namespace Learn.Parsers; + +public class TMDBParser(Configs configs) : ItemParser +{ + private readonly Dictionary _cache = new(); + + private TMDbClient _client; + + private async Task QueryTMDB(string title) + { + if (string.IsNullOrEmpty(title)) return null; + + if (_cache.TryGetValue(title, out var result)) + { + return result; + } + + var results = (await GetTMDbClient().SearchTvShowAsync(title, language: "zh-CN")).Results; + result = results.FirstOrDefault(); + _cache[title] = result; + return result; + } + + private TMDbClient GetTMDbClient() + { + if (_client != null) return _client; + + var apiKey = configs.Get().ApiKey; + var proxy = configs.Get().HttpProxy; + + if (string.IsNullOrEmpty(proxy)) + { + _client = new TMDbClient(apiKey); + } + else + { + _client = new TMDbClient(apiKey , proxy: new WebProxy(proxy)); + } + + return _client; + } + + private string ParseSeason(string season) + { + if (int.TryParse(season, out _)) return season; + + switch (season) + { + case "零": return "0"; + case "一": return "1"; + case "二": return "2"; + case "三": return "3"; + case "四": return "4"; + case "五": return "5"; + case "六": return "6"; + case "七": return "7"; + case "八": return "8"; + case "九": return "9"; + } + return season; + } + + private bool DoRawParse(TreeNode node) + { + var item = node.Info; + var name = item.Name(); + var matches = Regex.Matches(name, @"[^\[\]_]+").Select(match => match.Value).ToArray(); + + if (matches.Length == 0) return false; + + if (matches.Length == 1) + { + node.Info.SetRawTitleIfNotExist(matches[0]); + return true; + } + + if (matches.Length >= 2) + { + node.Info.SetGroupIfNotExist(matches[0]); + + var title = matches[1].Trim(); + var match = Regex.Match(title, "(.+)第(.+)季"); + if (match.Success) + { + node.Info.SetSeasonIfNotExist(ParseSeason(match.Groups[2].Value.Trim())); + node.Info.SetRawTitleIfNotExist(match.Groups[1].Value.Trim()); + } + else + { + node.Info.SetRawTitleIfNotExist(title); + } + } + + return true; + } + + public async Task DoParse(TreeNode node) + { + if (!node.TryGetValue(ItemFields.Key_RawTitle, out var rawTitle, out _)) + { + if (!DoRawParse(node)) + { + return false; + } + node.TryGetValue(ItemFields.Key_RawTitle, out rawTitle, out _); + } + + if (string.IsNullOrEmpty(rawTitle)) + { + return false; + } + + var result = await QueryTMDB(rawTitle); + if (result == null) + { + GD.PrintErr($"找不到对应的TV:{rawTitle}"); + return false; + } + + if (result.FirstAirDate != null) + { + node.Info.SetYear(result.FirstAirDate.Value.Year); + } + + node.Info.SetTitleIfNotExist(result.Name); + + return true; + } +} \ No newline at end of file diff --git a/Parsers/TMDBParser.cs.uid b/Parsers/TMDBParser.cs.uid new file mode 100644 index 0000000..550d763 --- /dev/null +++ b/Parsers/TMDBParser.cs.uid @@ -0,0 +1 @@ +uid://bw1sbx8ay4qsw diff --git a/Utils/Configs.cs b/Utils/Configs.cs new file mode 100644 index 0000000..13a1cb5 --- /dev/null +++ b/Utils/Configs.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Learn.Utils; + +public class ConfigItemAttribute(string name) : Attribute +{ + public readonly string Name = name; +} + +public interface IConfigItem +{ + public void OnBeforeSave() + { + } + + public void OnAfterLoad() + { + } +} + +public sealed class Configs +{ + private static readonly ConcurrentDictionary Cache = new(); + private readonly ConcurrentDictionary _configObjects = new(); + private readonly ConcurrentDictionary _configs = new(); + + public string ConfigPath { get; set; } = string.Empty; + + private static string GetConfigItemName(Type type) + { + if (Cache.TryGetValue(type, out var name)) return name; + name = type.Name; + if(type.GetCustomAttribute(typeof(ConfigItemAttribute)) is ConfigItemAttribute info) + { + name = string.IsNullOrEmpty(info.Name) ? type.Name : info.Name; + } + Cache[type] = name; + return name; + } + + /// + /// 获取配置项对象 + /// + /// 配置项类 + /// 获取的配置项对象是单例的 + /// 配置项对象 + public T Get() where T : class, IConfigItem, new() + { + var name = GetConfigItemName(typeof(T)); + if (_configObjects.TryGetValue(name, out var value)) + { + return (T) value; + } + T result; + if (_configs.TryGetValue(name, out var jObject)) + { + result = jObject.ToObject(); + result.OnAfterLoad(); + } + else + { + result = new T(); + } + _configObjects[name] = result; + return result; + } + + /// + /// 重置某个配置项。重置完后再取就是一个新的配置项 + /// + /// 配置项类 + public void Reset() where T : class, IConfigItem, new() + { + var name = GetConfigItemName(typeof(T)); + _configs.TryRemove(name, out _); + _configObjects.Remove(name, out _); + } + + /// + /// 清空当前配置并重新从文件中加载配置 + /// + /// 当不存在配置文件路径创建默认配置 + /// 加载是否成功。无论是否加载成功,当前配置都会被清空 + public bool Load(bool createDefaultIfNotExist) + { + _configs.Clear(); + _configObjects.Clear(); + if (string.IsNullOrEmpty(ConfigPath)) + { + return false; + } + + if (!File.Exists(ConfigPath)) + { + if (!createDefaultIfNotExist) return false; + if (!CreateEmptyConfig(ConfigPath)) return false; + } + + try + { + var configJson = File.ReadAllText(ConfigPath); + var config = JObject.Parse(configJson); + foreach (var kv in config) + { + _configs[kv.Key] = kv.Value.ToObject(); + } + } + catch (Exception) + { + return false; + } + return true; + } + + /// + /// 将当前配置保存到文件中 + /// + /// + public bool Save() + { + if (string.IsNullOrEmpty(ConfigPath)) return false; + try + { + foreach (var config in _configObjects) + { + config.Value.OnBeforeSave(); + _configs[config.Key] = JObject.FromObject(config.Value); + } + + File.WriteAllText(ConfigPath, JsonConvert.SerializeObject(_configs, Formatting.Indented)); + } + catch (Exception) + { + return false; + } + return true; + } + + private static bool CreateEmptyConfig(string filePath) + { + var configItemTypes = AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(assembly => + { + try + { + return assembly.GetTypes(); + } + catch (Exception) + { + return Array.Empty(); + } + }) + .Where(type => type.IsClass && + !type.IsAbstract && + typeof(IConfigItem).IsAssignableFrom(type)) + .ToList(); + + var configs = new Dictionary(); + foreach (var type in configItemTypes) + { + var name = GetConfigItemName(type); + var item = (IConfigItem) Activator.CreateInstance(type); + item.OnBeforeSave(); + configs[name] = item; + + } + try + { + File.WriteAllText(filePath, JsonConvert.SerializeObject(configs, Formatting.Indented)); + } + catch (Exception) + { + return false; + } + return true; + } +} \ No newline at end of file diff --git a/Utils/Configs.cs.uid b/Utils/Configs.cs.uid new file mode 100644 index 0000000..c754160 --- /dev/null +++ b/Utils/Configs.cs.uid @@ -0,0 +1 @@ +uid://b8ta3kj0ksopd