This commit is contained in:
limil 2025-12-28 23:31:18 +08:00
parent 47df98a25e
commit cf128159d1
20 changed files with 479 additions and 133 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
/android/ /android/
.idea/ .idea/
data.json data.json
config.json

View File

@ -1,8 +1,11 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Godot; using Godot;
using Learn.Models; using Learn.Models;
using Learn.Parsers;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace Learn.Component; namespace Learn.Component;
@ -95,15 +98,22 @@ public partial class MainTreePanel : Tree
private void OnMultiSelected(TreeItem item, long column, bool selected) private void OnMultiSelected(TreeItem item, long column, bool selected)
{ {
if(selected) _selectingNodes.Add(_mapper[item]); for (int i = 0; i < Columns; i++)
else _selectingNodes.Remove(_mapper[item]); {
if (i == column ? selected : item.IsSelected(i))
{
_selectingNodes.Add(_mapper[item]);
return;
}
}
_selectingNodes.Remove(_mapper[item]);
} }
private TreeItem CreateNode(TreeItem father, Item item) private TreeItem CreateNode(TreeItem father, Item item)
{ {
var node = CreateItem(father); var node = CreateItem(father);
var name = Path.GetFileName(item.MainInfo["Path"]); var name = Path.GetFileName(item.MainInfo[ItemFields.MainKey_Path]);
node.SetText(0, name); node.SetText(0, name);
return node; return node;
} }
@ -118,8 +128,8 @@ public partial class MainTreePanel : Tree
{ {
MainInfo = MainInfo =
{ {
["IsDir"] = isDir ? "True" : "False", [ItemFields.MainKey_IsDir] = isDir ? "True" : "False",
["Path"] = path [ItemFields.MainKey_Path] = path
} }
}; };
_items.Add(path, item); _items.Add(path, item);

View File

@ -39,15 +39,16 @@ public partial class NodeInfoEditPanel : Tree
private void OnMultiSelected(TreeItem item, long column, bool selected) private void OnMultiSelected(TreeItem item, long column, bool selected)
{ {
if (selected) for (int i = 0; i < Columns; i++)
{
if (i == column ? selected : item.IsSelected(i))
{ {
_selectingNodes.Add(item); _selectingNodes.Add(item);
return;
}
} }
else
{
_selectingNodes.Remove(item); _selectingNodes.Remove(item);
} }
}
private void OnItemEdited() private void OnItemEdited()
{ {

9
Config/ProxyConfig.cs Normal file
View File

@ -0,0 +1,9 @@
using Learn.Utils;
namespace Learn.Config;
[ConfigItem("Proxy")]
public class ProxyConfig : IConfigItem
{
public string HttpProxy = "";
}

View File

@ -0,0 +1 @@
uid://k75xslv0u3pm

9
Config/TMDBConfig.cs Normal file
View File

@ -0,0 +1,9 @@
using Learn.Utils;
namespace Learn.Config;
[ConfigItem("TMDB")]
public class TMDBConfig : IConfigItem
{
public string ApiKey = "";
}

1
Config/TMDBConfig.cs.uid Normal file
View File

@ -0,0 +1 @@
uid://v07s3j12o0lw

View File

@ -6,5 +6,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="TMDbLib" Version="2.3.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

54
Main.cs
View File

@ -1,9 +1,13 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using Godot; using Godot;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Learn.Component; using Learn.Component;
using Learn.Parsers; using Learn.Parsers;
using Learn.Utils;
public partial class Main : Node public partial class Main : Node
{ {
@ -37,6 +41,8 @@ public partial class Main : Node
private bool _refreshPanels; private bool _refreshPanels;
private Configs _configs;
public override void _Ready() public override void _Ready()
{ {
_nodeInfoEditPanel.OnNodeInfoEdited += _mainTreePanel.UpdateColumns; _nodeInfoEditPanel.OnNodeInfoEdited += _mainTreePanel.UpdateColumns;
@ -57,6 +63,7 @@ public partial class Main : Node
_saveButton.Pressed += DoSave; _saveButton.Pressed += DoSave;
_resetButton.Pressed += DoReset; _resetButton.Pressed += DoReset;
_loadButton.Pressed += LoadData; _loadButton.Pressed += LoadData;
_doParseButton.Text = "开始解析";
LoadData(); LoadData();
@ -64,6 +71,10 @@ public partial class Main : Node
FoldAll(); FoldAll();
_refreshPanels = true; _refreshPanels = true;
_configs = new Configs();
_configs.ConfigPath = "config.json";
_configs.Load(true);
} }
private void LoadData() private void LoadData()
@ -129,29 +140,54 @@ public partial class Main : Node
_refreshPanels = true; _refreshPanels = true;
} }
private void DoParse() private async void DoParse()
{ {
var root = _mainTreePanel.GetRoot(); var root = _mainTreePanel.GetRoot();
if (root == null) return; if (root == null) return;
ItemParser parser = new NormalParser(); var originName = _doParseButton.Text;
_doParseButton.Disabled = true;
var queue = new Queue<TreeItem>(); ItemParser parser = new TMDBParser(_configs);
queue.Enqueue(root);
while (queue.Count > 0) var stopwatch = Stopwatch.StartNew();
var stack = new Stack<TreeItem>();
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()) foreach (var child in treeItem.GetChildren())
{ {
if(child == null) continue; if(child == null) continue;
if (_mainTreePanel.Query(child, out var node)) if (_mainTreePanel.Query(child, out var node))
{ {
parser.DoParse(node); try
} {
queue.Enqueue(child); if (!await parser.DoParse(node))
{
GD.PrintErr($"解析{child.GetText(0)}失败");
} }
} }
catch (Exception ex)
{
GD.PrintErr($"解析{child.GetText(0)}出现异常:" + ex);
}
}
stack.Push(child);
}
}
_doParseButton.Text = originName;
_doParseButton.Disabled = false;
_mainTreePanel.UpdateColumns(); _mainTreePanel.UpdateColumns();
GD.Print("Done");
} }
private const string DataPath = "data.json"; private const string DataPath = "data.json";

View File

@ -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);
}
}

View File

@ -1 +0,0 @@
uid://fo70m114nwe7

60
Parsers/ItemFields.cs Normal file
View File

@ -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();
}
}

View File

@ -0,0 +1 @@
uid://cavjlh7k1lb6m

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using Learn.Models; using Learn.Models;
namespace Learn.Parsers; namespace Learn.Parsers;
@ -15,5 +16,5 @@ namespace Learn.Parsers;
/// </summary> /// </summary>
public interface ItemParser public interface ItemParser
{ {
public bool DoParse(TreeNode node); public Task<bool> DoParse(TreeNode node);
} }

View File

@ -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;
}
}

View File

@ -1 +0,0 @@
uid://bfixayy5gpwd6

141
Parsers/TMDBParser.cs Normal file
View File

@ -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<string, SearchTv> _cache = new();
private TMDbClient _client;
private async Task<SearchTv> 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<TMDBConfig>().ApiKey;
var proxy = configs.Get<ProxyConfig>().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<bool> 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;
}
}

View File

@ -0,0 +1 @@
uid://bw1sbx8ay4qsw

184
Utils/Configs.cs Normal file
View File

@ -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<Type, string> Cache = new();
private readonly ConcurrentDictionary<string, IConfigItem> _configObjects = new();
private readonly ConcurrentDictionary<string, JObject> _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;
}
/// <summary>
/// 获取配置项对象
/// </summary>
/// <typeparam name="T">配置项类</typeparam>
/// <remarks>获取的配置项对象是单例的</remarks>
/// <returns>配置项对象</returns>
public T Get<T>() 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<T>();
result.OnAfterLoad();
}
else
{
result = new T();
}
_configObjects[name] = result;
return result;
}
/// <summary>
/// 重置某个配置项。重置完后再取就是一个新的配置项
/// </summary>
/// <typeparam name="T">配置项类</typeparam>
public void Reset<T>() where T : class, IConfigItem, new()
{
var name = GetConfigItemName(typeof(T));
_configs.TryRemove(name, out _);
_configObjects.Remove(name, out _);
}
/// <summary>
/// 清空当前配置并重新从文件中加载配置
/// </summary>
/// <param name="createDefaultIfNotExist">当不存在配置文件路径创建默认配置</param>
/// <returns>加载是否成功。无论是否加载成功,当前配置都会被清空</returns>
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<JObject>();
}
}
catch (Exception)
{
return false;
}
return true;
}
/// <summary>
/// 将当前配置保存到文件中
/// </summary>
/// <returns></returns>
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<Type>();
}
})
.Where(type => type.IsClass &&
!type.IsAbstract &&
typeof(IConfigItem).IsAssignableFrom(type))
.ToList();
var configs = new Dictionary<string, IConfigItem>();
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;
}
}

1
Utils/Configs.cs.uid Normal file
View File

@ -0,0 +1 @@
uid://b8ta3kj0ksopd