diff --git a/.gitignore b/.gitignore
index 3c4efe2..4cd989e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,23 @@
+# Created by .ignore support plugin (hsz.mobi)
+### VisualStudioCode template
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+### VisualStudio template
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
+*.rsuser
*.suo
*.user
*.userosscache
@@ -10,6 +26,9 @@
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
+# Mono auto generated files
+mono_crash.*
+
# Build results
[Dd]ebug/
[Dd]ebugPublic/
@@ -17,42 +36,62 @@
[Rr]eleases/
x64/
x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
+[Ll]ogs/
-# Visual Studio 2015 cache/options directory
+# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
-# NUNIT
+# NUnit
*.VisualState.xml
TestResult.xml
+nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
-# DNX
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
*_i.c
*_p.c
-*_i.h
+*_h.h
*.ilk
*.meta
*.obj
+*.iobj
*.pch
*.pdb
+*.ipdb
*.pgc
*.pgd
*.rsp
@@ -62,6 +101,7 @@ artifacts/
*.tlh
*.tmp
*.tmp_proj
+*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
@@ -90,6 +130,9 @@ ipch/
*.vspx
*.sap
+# Visual Studio Trace Files
+*.e2e
+
# TFS 2012 Local Workspace
$tf/
@@ -101,15 +144,25 @@ _ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
-# JustCode is a .NET coding add-in
-.JustCode
-
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
# NCrunch
_NCrunch_*
.*crunch*.local.xml
@@ -141,9 +194,9 @@ publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
-# TODO: Comment the next line if you want to checkin your web deploy settings
+# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
-#*.pubxml
+*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
@@ -153,13 +206,15 @@ PublishScripts/
# NuGet Packages
*.nupkg
+# NuGet Symbol Packages
+*.snupkg
# The packages folder can be ignored because of Package Restore
-**/packages/*
+**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
-!**/packages/build/
+!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
-#!**/packages/repositories.config
-# NuGet v3's project.json files produces more ignoreable files
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
@@ -176,12 +231,15 @@ AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
-!*.[Cc]ache/
+!?*.[Cc]ache/
# Others
ClientBin/
@@ -192,9 +250,12 @@ ClientBin/
*.jfm
*.pfx
*.publishsettings
-node_modules/
orleans.codegen.cs
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
@@ -209,15 +270,22 @@ _UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
+*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
@@ -227,6 +295,7 @@ FakesAssemblies/
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
+node_modules/
# Visual Studio 6 build log
*.plg
@@ -234,6 +303,9 @@ FakesAssemblies/
# Visual Studio 6 workspace options file
*.opt
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
@@ -249,13 +321,132 @@ paket-files/
# FAKE - F# Make
.fake/
-# JetBrains Rider
-.idea/
-*.sln.iml
-
-# CodeRush
-.cr/
+# CodeRush personal settings
+.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
-*.pyc
\ No newline at end of file
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+### JetBrains template
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+.idea/
+/.idea
+/src/ZonyLrcTools.Cli/TempFiles/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4aaab1c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 Zony
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4cd129f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,16 @@
+English | [简体中文](./zh_CN.md)
+
+## Overview
+ZonyLrcToolX 2.0 is a cross-platform lyric downlaod tool based on CEF.
+
+🚧 The current version is under development.
+🚧 If you want to see the working code, please switch to the 1.0 branch.
+## Usage
+
+## Donation
+
+## Roadmap
+
+- [ ] Supports cross-platform CLI tools.
+- [ ] Web GUI based site (local).
+- [ ] Support plug-in system (Lua Engine).
\ No newline at end of file
diff --git a/ZonyLrcTools.sln b/ZonyLrcTools.sln
new file mode 100644
index 0000000..41e38d4
--- /dev/null
+++ b/ZonyLrcTools.sln
@@ -0,0 +1,41 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.29009.5
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C29FB05C-54B1-4020-94D2-87E192EB2F98}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{AF8ADB2F-E46C-4DEE-8316-652D9FE1A69B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ui", "ui", "{D6E0DAF5-8171-44C0-817E-2FF9CF574E4F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ZonyLrcTools.Cli", "src\ZonyLrcTools.Cli\ZonyLrcTools.Cli.csproj", "{55D74323-ABFA-4A73-A3BF-F3E8D5DE6DE8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ZonyLrcTools.Tests", "tests\ZonyLrcTools.Tests\ZonyLrcTools.Tests.csproj", "{FFBD3200-568F-455B-8390-5E76C51D522C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {55D74323-ABFA-4A73-A3BF-F3E8D5DE6DE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {55D74323-ABFA-4A73-A3BF-F3E8D5DE6DE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {55D74323-ABFA-4A73-A3BF-F3E8D5DE6DE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {55D74323-ABFA-4A73-A3BF-F3E8D5DE6DE8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FFBD3200-568F-455B-8390-5E76C51D522C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FFBD3200-568F-455B-8390-5E76C51D522C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FFBD3200-568F-455B-8390-5E76C51D522C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FFBD3200-568F-455B-8390-5E76C51D522C}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {7A6191C3-CC25-4732-885C-F4DD32F9E412}
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {55D74323-ABFA-4A73-A3BF-F3E8D5DE6DE8} = {C29FB05C-54B1-4020-94D2-87E192EB2F98}
+ {FFBD3200-568F-455B-8390-5E76C51D522C} = {AF8ADB2F-E46C-4DEE-8316-652D9FE1A69B}
+ EndGlobalSection
+EndGlobal
diff --git a/ZonyLrcTools.sln.DotSettings b/ZonyLrcTools.sln.DotSettings
new file mode 100644
index 0000000..738f543
--- /dev/null
+++ b/ZonyLrcTools.sln.DotSettings
@@ -0,0 +1,4 @@
+
+ QQ
+ True
+ True
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Commands/DownloadCommand.cs b/src/ZonyLrcTools.Cli/Commands/DownloadCommand.cs
new file mode 100644
index 0000000..4565392
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Commands/DownloadCommand.cs
@@ -0,0 +1,196 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using McMaster.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ZonyLrcTools.Cli.Config;
+using ZonyLrcTools.Cli.Infrastructure;
+using ZonyLrcTools.Cli.Infrastructure.Album;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+using ZonyLrcTools.Cli.Infrastructure.Extensions;
+using ZonyLrcTools.Cli.Infrastructure.IO;
+using ZonyLrcTools.Cli.Infrastructure.Lyric;
+using ZonyLrcTools.Cli.Infrastructure.Tag;
+using ZonyLrcTools.Cli.Infrastructure.Threading;
+
+namespace ZonyLrcTools.Cli.Commands
+{
+ [Command("download", Description = "下载歌词文件或专辑图像。")]
+ public class DownloadCommand : ToolCommandBase
+ {
+ private readonly ILogger _logger;
+ private readonly IFileScanner _fileScanner;
+ private readonly ITagLoader _tagLoader;
+ private readonly IEnumerable _lyricDownloaderList;
+ private readonly IEnumerable _albumDownloaderList;
+
+ private readonly ToolOptions _options;
+
+ public DownloadCommand(ILogger logger,
+ IFileScanner fileScanner,
+ IOptions options,
+ ITagLoader tagLoader,
+ IEnumerable lyricDownloaderList,
+ IEnumerable albumDownloaderList)
+ {
+ _logger = logger;
+ _fileScanner = fileScanner;
+ _tagLoader = tagLoader;
+ _lyricDownloaderList = lyricDownloaderList;
+ _albumDownloaderList = albumDownloaderList;
+ _options = options.Value;
+ }
+
+ #region > Options <
+
+ [Option("-d|--dir", CommandOptionType.SingleValue, Description = "指定需要扫描的目录。")]
+ [DirectoryExists]
+ public string Directory { get; set; }
+
+ [Option("-l|--lyric", CommandOptionType.NoValue, Description = "指定程序需要下载歌词文件。")]
+ public bool DownloadLyric { get; set; }
+
+ [Option("-a|--album", CommandOptionType.NoValue, Description = "指定程序需要下载专辑图像。")]
+ public bool DownloadAlbum { get; set; }
+
+ [Option("-n|--number", CommandOptionType.SingleValue, Description = "指定下载时候的线程数量。(默认值 2)")]
+ public int ParallelNumber { get; set; } = 2;
+
+ #endregion
+
+ public override List CreateArgs() => new();
+
+ protected override async Task OnExecuteAsync(CommandLineApplication app)
+ {
+ var files = await ScanMusicFilesAsync();
+ var musicInfos = await LoadMusicInfoAsync(files);
+
+ if (DownloadLyric)
+ {
+ await DownloadLyricFilesAsync(musicInfos);
+ }
+
+ if (DownloadAlbum)
+ {
+ await DownloadAlbumAsync(musicInfos);
+ }
+
+ return 0;
+ }
+
+ private async Task> ScanMusicFilesAsync()
+ {
+ var files = (await _fileScanner.ScanAsync(Directory, _options.SupportFileExtensions.Split(';')))
+ .SelectMany(t => t.FilePaths)
+ .ToList();
+
+ if (files.Count == 0)
+ {
+ _logger.LogError("没有找到任何音乐文件。");
+ throw new ErrorCodeException(ErrorCodes.NoFilesWereScanned);
+ }
+
+ _logger.LogInformation($"已经扫描到了 {files.Count} 个音乐文件。");
+
+ return files;
+ }
+
+ private async Task> LoadMusicInfoAsync(IReadOnlyCollection files)
+ {
+ _logger.LogInformation("开始加载音乐文件的标签信息...");
+
+ var warpTask = new WarpTask(ParallelNumber);
+ var warpTaskList = files.Select(file => warpTask.RunAsync(() => Task.Run(async () => await _tagLoader.LoadTagAsync(file))));
+ var result = await Task.WhenAll(warpTaskList);
+
+ _logger.LogInformation($"已成功加载 {files.Count} 个音乐文件的标签信息。");
+
+ return result.ToImmutableList();
+ }
+
+ #region > 歌词下载逻辑 <
+
+ private async ValueTask DownloadLyricFilesAsync(ImmutableList musicInfos)
+ {
+ _logger.LogInformation("开始下载歌词文件数据...");
+
+ var downloader = _lyricDownloaderList.FirstOrDefault(d => d.DownloaderName == InternalLyricDownloaderNames.NetEase);
+ var warpTask = new WarpTask(ParallelNumber);
+ var warpTaskList = musicInfos.Select(info =>
+ warpTask.RunAsync(() => Task.Run(async () => await DownloadLyricTaskLogicAsync(downloader, info))));
+
+ await Task.WhenAll(warpTaskList);
+
+ _logger.LogInformation($"歌词数据下载完成,成功: {musicInfos.Count} 失败{0}。");
+ }
+
+ private async Task DownloadLyricTaskLogicAsync(ILyricDownloader downloader, MusicInfo info)
+ {
+ try
+ {
+ var lyric = await downloader.DownloadAsync(info.Name, info.Artist);
+ var filePath = Path.Combine(Path.GetDirectoryName(info.FilePath)!, $"{Path.GetFileNameWithoutExtension(info.FilePath)}.lrc");
+ if (File.Exists(filePath))
+ {
+ return;
+ }
+
+ if (lyric.IsPruneMusic)
+ {
+ return;
+ }
+
+ await using var stream = new FileStream(filePath, FileMode.Create);
+ await using var sw = new StreamWriter(stream);
+ await sw.WriteAsync(lyric.ToString());
+ await sw.FlushAsync();
+ }
+ catch (ErrorCodeException ex)
+ {
+ _logger.LogWarningInfo(ex);
+ }
+ }
+
+ #endregion
+
+ #region > 专辑图像下载逻辑 <
+
+ private async ValueTask DownloadAlbumAsync(ImmutableList musicInfos)
+ {
+ _logger.LogInformation("开始下载专辑图像数据...");
+
+ var downloader = _albumDownloaderList.FirstOrDefault(d => d.DownloaderName == InternalAlbumDownloaderNames.NetEase);
+ var warpTask = new WarpTask(ParallelNumber);
+ var warpTaskList = musicInfos.Select(info =>
+ warpTask.RunAsync(() => Task.Run(async () => await DownloadAlbumTaskLogicAsync(downloader, info))));
+
+ await Task.WhenAll(warpTaskList);
+
+ _logger.LogInformation($"专辑数据下载完成,成功: {musicInfos.Count} 失败{0}。");
+ }
+
+ private async Task DownloadAlbumTaskLogicAsync(IAlbumDownloader downloader, MusicInfo info)
+ {
+ try
+ {
+ var album = await downloader.DownloadAsync(info.Name, info.Artist);
+ var filePath = Path.Combine(Path.GetDirectoryName(info.FilePath)!, $"{Path.GetFileNameWithoutExtension(info.FilePath)}.png");
+ if (File.Exists(filePath) || album.Length <= 0)
+ {
+ return;
+ }
+
+ await new FileStream(filePath, FileMode.Create).WriteBytesToFileAsync(album, 1024);
+ }
+ catch (ErrorCodeException ex)
+ {
+ _logger.LogWarningInfo(ex);
+ }
+ }
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Commands/ScanCommand.cs b/src/ZonyLrcTools.Cli/Commands/ScanCommand.cs
new file mode 100644
index 0000000..cb5efb2
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Commands/ScanCommand.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using McMaster.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ZonyLrcTools.Cli.Config;
+using ZonyLrcTools.Cli.Infrastructure.IO;
+
+namespace ZonyLrcTools.Cli.Commands
+{
+ [Command("scan", Description = "扫描指定目录下符合条件的音乐文件数量。")]
+ public class ScanCommand : ToolCommandBase
+ {
+ private readonly IFileScanner _fileScanner;
+ private readonly ToolOptions _options;
+ private readonly ILogger _logger;
+
+ public ScanCommand(IFileScanner fileScanner,
+ IOptions options,
+ ILogger logger)
+ {
+ _fileScanner = fileScanner;
+ _logger = logger;
+ _options = options.Value;
+ }
+
+ [Option("-d|--dir", CommandOptionType.SingleValue, Description = "指定需要扫描的目录。")]
+ [DirectoryExists]
+ public string DirectoryPath { get; set; }
+
+ protected override async Task OnExecuteAsync(CommandLineApplication app)
+ {
+ var result = await _fileScanner.ScanAsync(
+ DirectoryPath,
+ _options.SupportFileExtensions.Split(';'));
+
+ _logger.LogInformation($"目录扫描完成,共扫描到 {result.Sum(f => f.FilePaths.Count)} 个音乐文件。");
+ return 0;
+ }
+
+ public override List CreateArgs()
+ {
+ return new();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Commands/ToolCommand.cs b/src/ZonyLrcTools.Cli/Commands/ToolCommand.cs
new file mode 100644
index 0000000..4f0c9ef
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Commands/ToolCommand.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using McMaster.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Serilog;
+using Serilog.Events;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+using ZonyLrcTools.Cli.Infrastructure.Extensions;
+
+namespace ZonyLrcTools.Cli.Commands
+{
+ [Command("lyric-tool")]
+ [Subcommand(typeof(ScanCommand), typeof(DownloadCommand))]
+ public class ToolCommand : ToolCommandBase
+ {
+ public override List CreateArgs()
+ {
+ return new();
+ }
+
+ public static async Task Main(string[] args)
+ {
+ ConfigureLogger();
+ ConfigureErrorMessage();
+
+ try
+ {
+ return await BuildHostedService(args);
+ }
+ catch (Exception ex)
+ {
+ return HandleException(ex);
+ }
+ finally
+ {
+ Log.CloseAndFlush();
+ }
+ }
+
+ #region > 程序初始化配置 <
+
+ private static void ConfigureErrorMessage() => ErrorCodeHelper.LoadErrorMessage();
+
+ private static void ConfigureLogger()
+ {
+ Log.Logger = new LoggerConfiguration()
+#if DEBUG
+ .MinimumLevel.Debug()
+#else
+ .MinimumLevel.Information()
+#endif
+ .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
+ .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
+ .Enrich.FromLogContext()
+ .WriteTo.Async(c => c.Console())
+ .WriteTo.Logger(lc =>
+ {
+ lc.Filter.ByIncludingOnly(lc => lc.Level == LogEventLevel.Warning)
+ .WriteTo.Async(c => c.File("Logs/warnings.txt"));
+ })
+ .WriteTo.Logger(lc =>
+ {
+ lc.Filter.ByIncludingOnly(lc => lc.Level == LogEventLevel.Error)
+ .WriteTo.Async(c => c.File("Logs/errors.txt"));
+ })
+ .CreateLogger();
+ }
+
+ private static Task BuildHostedService(string[] args)
+ {
+ return new HostBuilder()
+ .ConfigureLogging(builder => builder.AddSerilog())
+ .ConfigureHostConfiguration(builder =>
+ {
+ builder
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json");
+ })
+ .ConfigureServices((_, services) =>
+ {
+ services.AddSingleton(PhysicalConsole.Singleton);
+ services.BeginAutoDependencyInject();
+ services.ConfigureConfiguration();
+ services.ConfigureToolService();
+ })
+ .RunCommandLineApplicationAsync(args);
+ }
+
+ private static int HandleException(Exception ex)
+ {
+ switch (ex)
+ {
+ case ErrorCodeException exception:
+ Log.Logger.Error($"出现了未处理的异常,错误代码: {exception.ErrorCode},错误信息: {ErrorCodeHelper.GetMessage(exception.ErrorCode)}\n调用栈:\n{exception.StackTrace}");
+ Environment.Exit(exception.ErrorCode);
+ return exception.ErrorCode;
+ case Exception unknownException:
+ Log.Logger.Error($"出现了未处理的异常: {unknownException.Message}\n调用栈:\n{unknownException.StackTrace}");
+ Environment.Exit(-1);
+ return 1;
+ default:
+ return 1;
+ }
+ }
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Commands/ToolCommandBase.cs b/src/ZonyLrcTools.Cli/Commands/ToolCommandBase.cs
new file mode 100644
index 0000000..4ea4b81
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Commands/ToolCommandBase.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using McMaster.Extensions.CommandLineUtils;
+
+namespace ZonyLrcTools.Cli.Commands
+{
+ [HelpOption("--help|-h", Description = "欢迎使用 ZonyLrcToolsX Command Line Interface。")]
+ public abstract class ToolCommandBase
+ {
+ public abstract List CreateArgs();
+
+ protected virtual Task OnExecuteAsync(CommandLineApplication app)
+ {
+ return Task.FromResult(0);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Commands/UtilityCommand.cs b/src/ZonyLrcTools.Cli/Commands/UtilityCommand.cs
new file mode 100644
index 0000000..50eaefb
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Commands/UtilityCommand.cs
@@ -0,0 +1,20 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using McMaster.Extensions.CommandLineUtils;
+
+namespace ZonyLrcTools.Cli.Commands
+{
+ ///
+ /// 工具类相关命令。
+ ///
+ [Command("util", Description = "提供常用的工具类功能。")]
+ public class UtilityCommand : ToolCommandBase
+ {
+ public override List CreateArgs() => new();
+
+ protected override Task OnExecuteAsync(CommandLineApplication app)
+ {
+ return base.OnExecuteAsync(app);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Config/ToolOptions.cs b/src/ZonyLrcTools.Cli/Config/ToolOptions.cs
new file mode 100644
index 0000000..df605b6
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Config/ToolOptions.cs
@@ -0,0 +1,29 @@
+using ZonyLrcTools.Cli.Infrastructure.Lyric;
+using ZonyLrcTools.Cli.Infrastructure.Network;
+using ZonyLrcTools.Cli.Infrastructure.Tag;
+
+namespace ZonyLrcTools.Cli.Config
+{
+ public class ToolOptions
+ {
+ ///
+ /// 支持的音乐文件后缀集合,以 ; 进行分隔。
+ ///
+ public string SupportFileExtensions { get; set; }
+
+ ///
+ /// 歌词下载相关的配置信息。
+ ///
+ public LyricItemCollectionOption LyricOption { get; set; }
+
+ ///
+ /// 标签加载器的加载配置项。
+ ///
+ public TagInfoProviderOptions TagInfoProviderOptions { get; set; }
+
+ ///
+ /// 网络代理相关的配置信息。
+ ///
+ public NetworkOptions NetworkOptions { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Album/IAlbumDownloader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Album/IAlbumDownloader.cs
new file mode 100644
index 0000000..3ce69ee
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Album/IAlbumDownloader.cs
@@ -0,0 +1,23 @@
+using System.Threading.Tasks;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Album
+{
+ ///
+ /// 专辑封面下载器,用于匹配并下载歌曲的专辑封面。
+ ///
+ public interface IAlbumDownloader
+ {
+ ///
+ /// 下载器的名称。
+ ///
+ string DownloaderName { get; }
+
+ ///
+ /// 下载专辑封面。
+ ///
+ /// 歌曲的名称。
+ /// 歌曲的作者。
+ /// 专辑封面的图像数据。
+ ValueTask DownloadAsync(string songName, string artist);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Album/InternalAlbumDownloaderNames.cs b/src/ZonyLrcTools.Cli/Infrastructure/Album/InternalAlbumDownloaderNames.cs
new file mode 100644
index 0000000..d2f921d
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Album/InternalAlbumDownloaderNames.cs
@@ -0,0 +1,18 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Album
+{
+ ///
+ /// 定义了程序默认提供的专辑图像下载器。
+ ///
+ public static class InternalAlbumDownloaderNames
+ {
+ ///
+ /// 网易云音乐专辑图像下载器。
+ ///
+ public const string NetEase = nameof(NetEase);
+
+ ///
+ /// QQ 音乐专辑图像下载器。
+ ///
+ public const string QQ = nameof(QQ);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Album/NetEase/NetEaseAlbumDownloader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Album/NetEase/NetEaseAlbumDownloader.cs
new file mode 100644
index 0000000..efa1c60
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Album/NetEase/NetEaseAlbumDownloader.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+using ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel;
+using ZonyLrcTools.Cli.Infrastructure.Network;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Album.NetEase
+{
+ public class NetEaseAlbumDownloader : IAlbumDownloader, ITransientDependency
+ {
+ public string DownloaderName => InternalAlbumDownloaderNames.NetEase;
+
+ private readonly IWarpHttpClient _warpHttpClient;
+ private readonly Action _defaultOption;
+
+ private const string SearchMusicApi = @"https://music.163.com/api/search/get/web";
+ private const string GetMusicInfoApi = @"https://music.163.com/api/song/detail";
+ private const string DefaultReferer = @"https://music.163.com";
+
+ public NetEaseAlbumDownloader(IWarpHttpClient warpHttpClient)
+ {
+ _warpHttpClient = warpHttpClient;
+ _defaultOption = message =>
+ {
+ message.Headers.Referrer = new Uri(DefaultReferer);
+
+ if (message.Content != null)
+ {
+ message.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded");
+ }
+ };
+ }
+
+ public async ValueTask DownloadAsync(string songName, string artist)
+ {
+ var requestParameter = new SongSearchRequest(songName, artist);
+ var searchResult = await _warpHttpClient.PostAsync(
+ SearchMusicApi,
+ requestParameter,
+ true,
+ _defaultOption);
+
+ if (searchResult is not {StatusCode: 200} || searchResult.Items?.SongCount <= 0)
+ {
+ throw new ErrorCodeException(ErrorCodes.NoMatchingSong);
+ }
+
+ var songDetailJsonStr = await _warpHttpClient.GetAsync(
+ GetMusicInfoApi,
+ new GetSongDetailsRequest(searchResult.GetFirstSongId()),
+ _defaultOption);
+
+ var url = JObject.Parse(songDetailJsonStr).SelectToken("$.songs[0].album.picUrl")?.Value();
+ if (string.IsNullOrEmpty(url))
+ {
+ throw new ErrorCodeException(ErrorCodes.TheReturnValueIsIllegal);
+ }
+
+ return await new HttpClient().GetByteArrayAsync(new Uri(url));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Album/QQMusic/QQMusicAlbumDownloader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Album/QQMusic/QQMusicAlbumDownloader.cs
new file mode 100644
index 0000000..4c6c51f
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Album/QQMusic/QQMusicAlbumDownloader.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Lyric.QQMusic.JsonModel;
+using ZonyLrcTools.Cli.Infrastructure.Network;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Album.QQMusic
+{
+ public class QQMusicAlbumDownloader : IAlbumDownloader, ITransientDependency
+ {
+ public string DownloaderName => InternalAlbumDownloaderNames.QQ;
+
+ private readonly IWarpHttpClient _warpHttpClient;
+ private readonly Action _defaultOption;
+
+ private const string SearchApi = "https://c.y.qq.com/soso/fcgi-bin/client_search_cp";
+ private const string DefaultReferer = "https://y.qq.com";
+
+ public QQMusicAlbumDownloader(IWarpHttpClient warpHttpClient)
+ {
+ _warpHttpClient = warpHttpClient;
+ _defaultOption = message =>
+ {
+ message.Headers.Referrer = new Uri(DefaultReferer);
+
+ if (message.Content != null)
+ {
+ message.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded");
+ }
+ };
+ }
+
+ public async ValueTask DownloadAsync(string songName, string artist)
+ {
+ var requestParameter = new SongSearchRequest(songName, artist);
+ var searchResult = await _warpHttpClient.GetAsync(
+ SearchApi,
+ requestParameter);
+
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/AutoDependencyInjectExtensions.cs b/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/AutoDependencyInjectExtensions.cs
new file mode 100644
index 0000000..c6a2000
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/AutoDependencyInjectExtensions.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+using ZonyLrcTools.Cli.Infrastructure.Extensions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.DependencyInject
+{
+ public static class AutoDependencyInjectExtensions
+ {
+ ///
+ /// 开始进行自动依赖注入。
+ ///
+ ///
+ /// 会根据实现了 或 的接口进行自动注入。
+ ///
+ /// 服务定义集合。
+ /// 需要注入的任意类型。
+ public static IServiceCollection BeginAutoDependencyInject(this IServiceCollection services)
+ {
+ var allTypes = typeof(TAssemblyType).Assembly
+ .GetTypes()
+ .Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType)
+ .ToArray();
+
+ var transientTypes = allTypes.Where(t => typeof(ITransientDependency).IsAssignableFrom(t));
+ var singletonTypes = allTypes.Where(t => typeof(ISingletonDependency).IsAssignableFrom(t));
+
+ transientTypes.Foreach(t =>
+ {
+ foreach (var exposedService in GetDefaultExposedTypes(t))
+ {
+ services.Add(CreateServiceDescriptor(t, exposedService, ServiceLifetime.Transient));
+ }
+ });
+
+ singletonTypes.Foreach(t =>
+ {
+ foreach (var exposedService in GetDefaultExposedTypes(t))
+ {
+ services.Add(CreateServiceDescriptor(t, exposedService, ServiceLifetime.Singleton));
+ }
+ });
+
+ return services;
+ }
+
+ public static List GetDefaultExposedTypes(Type type)
+ {
+ var serviceTypes = new List();
+
+ foreach (var interfaceType in type.GetTypeInfo().GetInterfaces())
+ {
+ var interfaceName = interfaceType.Name;
+
+ if (interfaceName.StartsWith("I"))
+ {
+ interfaceName = interfaceName.Substring(1, interfaceName.Length - 1);
+ }
+
+ if (type.Name.EndsWith(interfaceName))
+ {
+ serviceTypes.Add(interfaceType);
+ serviceTypes.Add(type);
+ }
+ }
+
+ return serviceTypes;
+ }
+
+ public static ServiceDescriptor CreateServiceDescriptor(Type implementationType,
+ Type exposingServiceType,
+ ServiceLifetime lifetime)
+ {
+ return new ServiceDescriptor(exposingServiceType, implementationType, lifetime);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ISingletonDependency.cs b/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ISingletonDependency.cs
new file mode 100644
index 0000000..b31eeb0
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ISingletonDependency.cs
@@ -0,0 +1,9 @@
+namespace ZonyLrcTools.Cli.Infrastructure.DependencyInject
+{
+ ///
+ /// 继承了本接口的类都会以单例的形式注入到 IoC 容器当中。
+ ///
+ public interface ISingletonDependency
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ITransientDependency.cs b/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ITransientDependency.cs
new file mode 100644
index 0000000..f2920e2
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ITransientDependency.cs
@@ -0,0 +1,9 @@
+namespace ZonyLrcTools.Cli.Infrastructure.DependencyInject
+{
+ ///
+ /// 继承了本接口的类都会以瞬时的形式注入到 IoC 容器当中。
+ ///
+ public interface ITransientDependency
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ServiceCollectionExtensions.cs b/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..03e7bcb
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/DependencyInject/ServiceCollectionExtensions.cs
@@ -0,0 +1,57 @@
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ZonyLrcTools.Cli.Config;
+using ZonyLrcTools.Cli.Infrastructure.Network;
+
+namespace ZonyLrcTools.Cli.Infrastructure.DependencyInject
+{
+ ///
+ /// Service 注入的扩展方法。
+ ///
+ public static class ServiceCollectionExtensions
+ {
+ ///
+ /// 配置工具会用到的服务。
+ ///
+ public static IServiceCollection ConfigureToolService(this IServiceCollection services)
+ {
+ if (services == null)
+ {
+ return null;
+ }
+
+ services.AddHttpClient(DefaultWarpHttpClient.HttpClientNameConstant)
+ .ConfigurePrimaryHttpMessageHandler(provider =>
+ {
+ var option = provider.GetRequiredService>().Value;
+
+ return new HttpClientHandler
+ {
+ UseProxy = option.NetworkOptions.Enable,
+ Proxy = new WebProxy($"{option.NetworkOptions.ProxyIp}:{option.NetworkOptions.ProxyPort}")
+ };
+ });
+
+ return services;
+ }
+
+ ///
+ /// 配置工具关联的配置信息()。
+ ///
+ public static IServiceCollection ConfigureConfiguration(this IServiceCollection services)
+ {
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile("appsettings.json")
+ .Build();
+
+ services.Configure(configuration.GetSection("ToolOption"));
+
+ return services;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodeException.cs b/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodeException.cs
new file mode 100644
index 0000000..b446f00
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodeException.cs
@@ -0,0 +1,26 @@
+using System;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Exceptions
+{
+ ///
+ /// 带错误码的异常实现。
+ ///
+ public class ErrorCodeException : Exception
+ {
+ public int ErrorCode { get; }
+
+ public object AttachObject { get; }
+
+ ///
+ /// 构建一个新的 对象。
+ ///
+ /// 错误码,参考 类的定义。
+ /// 错误信息。
+ /// 附加的对象数据。
+ public ErrorCodeException(int errorCode, string message = null, object attachObj = null) : base(message)
+ {
+ ErrorCode = errorCode;
+ AttachObject = attachObj;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodeHelper.cs b/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodeHelper.cs
new file mode 100644
index 0000000..43dac58
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodeHelper.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Exceptions
+{
+ ///
+ /// 错误码相关的帮助类。
+ ///
+ public static class ErrorCodeHelper
+ {
+ public static Dictionary ErrorMessages { get; }
+
+ static ErrorCodeHelper()
+ {
+ ErrorMessages = new Dictionary();
+ }
+
+ ///
+ /// 从 err_msg.json 文件加载错误信息。
+ ///
+ public static void LoadErrorMessage()
+ {
+ // 防止重复加载。
+ if (ErrorMessages.Count != 0)
+ {
+ return;
+ }
+
+ var jsonPath = Path.Combine(Directory.GetCurrentDirectory(), "Resources", "error_msg.json");
+ using var jsonReader = new JsonTextReader(File.OpenText(jsonPath));
+ var jsonObj = JObject.Load(jsonReader);
+
+ var errors = jsonObj.SelectTokens("$.Error.*");
+ var warnings = jsonObj.SelectTokens("$.Warning.*");
+ errors.Union(warnings).Select(m => m.Parent).OfType().ToList()
+ .ForEach(m => ErrorMessages.Add(int.Parse(m.Name), m.Value.Value()));
+ }
+
+ public static string GetMessage(int errorCode) => ErrorMessages[errorCode];
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodes.cs b/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodes.cs
new file mode 100644
index 0000000..7e81281
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Exceptions/ErrorCodes.cs
@@ -0,0 +1,86 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Exceptions
+{
+ ///
+ /// 错误码。
+ ///
+ public static class ErrorCodes
+ {
+ #region > 错误信息 <
+
+ ///
+ /// 文本: 待搜索的后缀不能为空。
+ ///
+ public const int FileSuffixIsEmpty = 10001;
+
+ ///
+ /// 文本: 需要扫描的目录不存在,请确认路径是否正确。。
+ ///
+ public const int DirectoryNotExist = 10002;
+
+ ///
+ /// 文本: 不能获取文件的后缀信息。
+ ///
+ public const int UnableToGetTheFileExtension = 10003;
+
+ ///
+ /// 文本: 没有扫描到任何音乐文件。
+ ///
+ public const int NoFilesWereScanned = 10004;
+
+ #endregion
+
+ #region > 警告信息 <
+
+ ///
+ /// 文本: 扫描文件时出现了错误。
+ ///
+ public const int ScanFileError = 50001;
+
+ ///
+ /// 文本: 歌曲名称或歌手名称均为空,无法进行搜索。
+ ///
+ public const int SongNameAndArtistIsNull = 50002;
+
+ ///
+ /// 文本: 歌曲名称不能为空,无法进行搜索。
+ ///
+ public const int SongNameIsNull = 50003;
+
+ ///
+ /// 文本: 下载器没有搜索到对应的歌曲信息。
+ ///
+ public const int NoMatchingSong = 50004;
+
+ ///
+ /// 文本: 下载请求的返回值不合法,可能是服务端故障。
+ ///
+ public const int TheReturnValueIsIllegal = 50005;
+
+ ///
+ /// 文本: 标签信息读取器为空,无法解析音乐 Tag 信息。
+ ///
+ public const int LoadTagInfoProviderError = 50006;
+
+ ///
+ /// 文本: TagLib 标签读取器出现了预期之外的异常。
+ ///
+ public const int TagInfoProviderLoadInfoFailed = 50007;
+
+ ///
+ /// 文本: 服务接口限制,无法进行请求,请尝试使用代理服务器。
+ ///
+ public const int ServiceUnavailable = 50008;
+
+ ///
+ /// 文本: 对目标服务器执行 Http 请求失败。
+ ///
+ public const int HttpRequestFailed = 50009;
+
+ ///
+ /// 文本: Http 请求的结果反序列化为 Json 失败。
+ ///
+ public const int HttpResponseConvertJsonFailed = 50010;
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Extensions/LinqHelper.cs b/src/ZonyLrcTools.Cli/Infrastructure/Extensions/LinqHelper.cs
new file mode 100644
index 0000000..941dd78
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Extensions/LinqHelper.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Extensions
+{
+ ///
+ /// Linq 相关的扩展方法。
+ ///
+ public static class LinqHelper
+ {
+ ///
+ /// 使用 Lambda 的形式遍历指定的迭代器。
+ ///
+ /// 等待遍历的迭代器实例。
+ /// 遍历时需要执行的操作。
+ public static void Foreach(this IEnumerable items, Action action)
+ {
+ foreach (var item in items)
+ {
+ action(item);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Extensions/LoggerExtensions.cs b/src/ZonyLrcTools.Cli/Infrastructure/Extensions/LoggerExtensions.cs
new file mode 100644
index 0000000..cd5f5b2
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Extensions/LoggerExtensions.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Extensions
+{
+ ///
+ /// 日志记录相关的扩展方法。
+ ///
+ public static class LoggerExtensions
+ {
+ ///
+ /// 使用 级别打印错误日志,并记录异常堆栈。
+ ///
+ /// 日志记录器实例。
+ /// 错误码,具体请参考 类的定义。
+ /// 异常实例,可为空。
+ public static void LogWarningWithErrorCode(this ILogger logger, int errorCode, Exception e = null)
+ {
+ logger.LogWarning($"错误代码: {errorCode}\n堆栈异常: {e?.StackTrace}");
+ }
+
+ ///
+ /// 使用 级别打印错误日志,并记录异常堆栈。
+ ///
+ /// 日志记录器的实例。
+ /// 错误码异常实例。
+ public static void LogWarningInfo(this ILogger logger, ErrorCodeException exception)
+ {
+ if (exception.ErrorCode < 50000)
+ {
+ throw exception;
+ }
+
+ var sb = new StringBuilder();
+ sb.Append($"错误代码: {exception.ErrorCode},信息: {ErrorCodeHelper.GetMessage(exception.ErrorCode)}");
+ sb.Append($"\n附加信息:\n {JsonConvert.SerializeObject(exception.AttachObject)}");
+ logger.LogWarning(sb.ToString());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Extensions/StringHelper.cs b/src/ZonyLrcTools.Cli/Infrastructure/Extensions/StringHelper.cs
new file mode 100644
index 0000000..b1f2b4a
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Extensions/StringHelper.cs
@@ -0,0 +1,26 @@
+using System;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Extensions
+{
+ ///
+ /// 字符串处理相关的工具方法。
+ ///
+ public static class StringHelper
+ {
+ ///
+ /// 截断指定字符串末尾的匹配字串。
+ ///
+ /// 待截断的字符串。
+ /// 需要在末尾截断的字符串。
+ /// 截断成功的字符串实例。
+ public static string TrimEnd(this string @string, string trimEndStr)
+ {
+ if (@string.EndsWith(trimEndStr, StringComparison.Ordinal))
+ {
+ return @string.Substring(0, @string.Length - trimEndStr.Length);
+ }
+
+ return @string;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/IO/FileScanner.cs b/src/ZonyLrcTools.Cli/Infrastructure/IO/FileScanner.cs
new file mode 100644
index 0000000..7c84787
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/IO/FileScanner.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+using ZonyLrcTools.Cli.Infrastructure.Extensions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.IO
+{
+ public class FileScanner : IFileScanner, ITransientDependency
+ {
+ public ILogger Logger { get; set; }
+
+ public FileScanner()
+ {
+ Logger = NullLogger.Instance;
+ }
+
+ public Task> ScanAsync(string path, IEnumerable extensions)
+ {
+ if (extensions == null || !extensions.Any())
+ {
+ throw new ErrorCodeException(ErrorCodes.FileSuffixIsEmpty);
+ }
+
+ if (!Directory.Exists(path))
+ {
+ throw new ErrorCodeException(ErrorCodes.DirectoryNotExist);
+ }
+
+ var files = new List();
+ foreach (var extension in extensions)
+ {
+ var tempResult = new ConcurrentBag();
+ SearchFile(tempResult, path, extension);
+
+ files.Add(new FileScannerResult(
+ Path.GetExtension(extension) ?? throw new ErrorCodeException(ErrorCodes.UnableToGetTheFileExtension),
+ tempResult.ToList()));
+ }
+
+ return Task.FromResult(files);
+ }
+
+ private void SearchFile(ConcurrentBag files, string folder, string extension)
+ {
+ try
+ {
+ foreach (var file in Directory.GetFiles(folder, extension))
+ {
+ files.Add(file);
+ }
+
+ foreach (var directory in Directory.GetDirectories(folder))
+ {
+ SearchFile(files, directory, extension);
+ }
+ }
+ catch (Exception e)
+ {
+ Logger.LogWarningWithErrorCode(ErrorCodes.ScanFileError, e);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/IO/FileScannerResult.cs b/src/ZonyLrcTools.Cli/Infrastructure/IO/FileScannerResult.cs
new file mode 100644
index 0000000..985bdc6
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/IO/FileScannerResult.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+
+namespace ZonyLrcTools.Cli.Infrastructure.IO
+{
+ ///
+ /// 文件扫描结果对象。
+ ///
+ public class FileScannerResult
+ {
+ ///
+ /// 当前路径对应的扩展名。
+ ///
+ public string ExtensionName { get; }
+
+ ///
+ /// 当前扩展名下面的所有文件路径集合。
+ ///
+ public List FilePaths { get; }
+
+ ///
+ /// 构造一个新的 对象。
+ ///
+ /// 当前路径对应的扩展名。
+ /// 当前扩展名下面的所有文件路径集合。
+ public FileScannerResult(string extensionName, List filePaths)
+ {
+ ExtensionName = extensionName;
+ FilePaths = filePaths;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/IO/FileStreamExtensions.cs b/src/ZonyLrcTools.Cli/Infrastructure/IO/FileStreamExtensions.cs
new file mode 100644
index 0000000..28dc6a5
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/IO/FileStreamExtensions.cs
@@ -0,0 +1,41 @@
+using System.IO;
+using System.Threading.Tasks;
+
+namespace ZonyLrcTools.Cli.Infrastructure.IO
+{
+ public static class FileStreamExtensions
+ {
+ ///
+ /// 将字节数据通过缓冲区的形式,写入到文件当中。
+ ///
+ /// 需要写入数据的文件流。
+ /// 等待写入的数据。
+ /// 缓冲区大小。
+ public static async Task WriteBytesToFileAsync(this FileStream fileStream, byte[] data, int bufferSize = 1024)
+ {
+ await using (fileStream)
+ {
+ var count = data.Length / 1024;
+ var modCount = data.Length % 1024;
+ if (count <= 0)
+ {
+ await fileStream.WriteAsync(data, 0, modCount);
+ }
+ else
+ {
+ for (var i = 0; i < count; i++)
+ {
+ await fileStream.WriteAsync(data, i * 1024, 1024);
+ }
+
+ if (modCount != 0)
+ {
+ await fileStream.WriteAsync(data, count * 1024, modCount);
+ }
+ }
+
+ await fileStream.FlushAsync();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/IO/IFileScanner.cs b/src/ZonyLrcTools.Cli/Infrastructure/IO/IFileScanner.cs
new file mode 100644
index 0000000..0de05a3
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/IO/IFileScanner.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace ZonyLrcTools.Cli.Infrastructure.IO
+{
+ ///
+ /// 音乐文件扫描器,用于扫描音乐文件。
+ ///
+ public interface IFileScanner
+ {
+ ///
+ /// 扫描指定路径下面的歌曲文件。
+ ///
+ /// 等待扫描的路径。
+ /// 需要搜索的歌曲后缀名。
+ Task> ScanAsync(string path, IEnumerable extensions);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricDownloader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricDownloader.cs
new file mode 100644
index 0000000..a17a634
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricDownloader.cs
@@ -0,0 +1,23 @@
+using System.Threading.Tasks;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ ///
+ /// 歌词数据下载器,用于匹配并下载歌曲的歌词。
+ ///
+ public interface ILyricDownloader
+ {
+ ///
+ /// 下载歌词数据。
+ ///
+ /// 歌曲的名称。
+ /// 歌曲的作者。
+ /// 歌曲的歌词数据对象。
+ ValueTask DownloadAsync(string songName, string artist);
+
+ ///
+ /// 下载器的名称。
+ ///
+ string DownloaderName { get; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricItemCollectionFactory.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricItemCollectionFactory.cs
new file mode 100644
index 0000000..91a7c0c
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricItemCollectionFactory.cs
@@ -0,0 +1,16 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ ///
+ /// 构建 对象的工厂。
+ ///
+ public interface ILyricItemCollectionFactory
+ {
+ ///
+ /// 根据指定的歌曲数据构建新的 实例。
+ ///
+ /// 原始歌词数据。
+ /// 翻译歌词数据。
+ /// 构建完成的 对象。
+ LyricItemCollection Build(string sourceLyric, string translateLyric = null);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricTextResolver.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricTextResolver.cs
new file mode 100644
index 0000000..360640d
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/ILyricTextResolver.cs
@@ -0,0 +1,7 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ public interface ILyricTextResolver
+ {
+ LyricItemCollection Resolve(string lyricText);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/InternalLyricDownloaderNames.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/InternalLyricDownloaderNames.cs
new file mode 100644
index 0000000..8a26016
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/InternalLyricDownloaderNames.cs
@@ -0,0 +1,23 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ ///
+ /// 定义了程序默认提供的歌词下载器。
+ ///
+ public static class InternalLyricDownloaderNames
+ {
+ ///
+ /// 网易云音乐歌词下载器。
+ ///
+ public const string NetEase = nameof(NetEase);
+
+ ///
+ /// QQ 音乐歌词下载器。
+ ///
+ public const string QQ = nameof(QQ);
+
+ ///
+ /// 酷狗音乐歌词下载器。
+ ///
+ public const string KuGou = nameof(KuGou);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/KuGou/KuGourLyricDownloader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/KuGou/KuGourLyricDownloader.cs
new file mode 100644
index 0000000..5f46a19
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/KuGou/KuGourLyricDownloader.cs
@@ -0,0 +1,30 @@
+using System.Threading.Tasks;
+using ZonyLrcTools.Cli.Infrastructure.Network;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.KuGou
+{
+ public class KuGourLyricDownloader : LyricDownloader
+ {
+ public override string DownloaderName => InternalLyricDownloaderNames.KuGou;
+
+ private readonly IWarpHttpClient _warpHttpClient;
+ private readonly ILyricItemCollectionFactory _lyricItemCollectionFactory;
+
+ public KuGourLyricDownloader(IWarpHttpClient warpHttpClient,
+ ILyricItemCollectionFactory lyricItemCollectionFactory)
+ {
+ _warpHttpClient = warpHttpClient;
+ _lyricItemCollectionFactory = lyricItemCollectionFactory;
+ }
+
+ protected override ValueTask DownloadDataAsync(LyricDownloaderArgs args)
+ {
+ throw new System.NotImplementedException();
+ }
+
+ protected override ValueTask GenerateLyricAsync(byte[] data)
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LineBreakType.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LineBreakType.cs
new file mode 100644
index 0000000..b0c9be5
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LineBreakType.cs
@@ -0,0 +1,23 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ ///
+ /// 换行符格式定义。
+ ///
+ public static class LineBreakType
+ {
+ ///
+ /// Windows 系统。
+ ///
+ public const string Windows = "\r\n";
+
+ ///
+ /// macOS 系统。
+ ///
+ public const string MacOs = "\r";
+
+ ///
+ /// UNIX 系统(Linux)。
+ ///
+ public const string Unix = "\n";
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricDownloader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricDownloader.cs
new file mode 100644
index 0000000..65465b3
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricDownloader.cs
@@ -0,0 +1,41 @@
+using System.Threading.Tasks;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ ///
+ /// 歌词下载器的基类,定义了歌词下载器的常规逻辑。
+ ///
+ public abstract class LyricDownloader : ILyricDownloader, ITransientDependency
+ {
+ public abstract string DownloaderName { get; }
+
+ public virtual async ValueTask DownloadAsync(string songName, string artist)
+ {
+ var args = new LyricDownloaderArgs(songName, artist);
+ await ValidateAsync(args);
+ var downloadDataBytes = await DownloadDataAsync(args);
+ return await GenerateLyricAsync(downloadDataBytes);
+ }
+
+ protected virtual ValueTask ValidateAsync(LyricDownloaderArgs args)
+ {
+ if (string.IsNullOrEmpty(args.SongName))
+ {
+ throw new ErrorCodeException(ErrorCodes.SongNameIsNull, attachObj: args);
+ }
+
+ if (string.IsNullOrEmpty(args.SongName) && string.IsNullOrEmpty(args.Artist))
+ {
+ throw new ErrorCodeException(ErrorCodes.SongNameAndArtistIsNull, attachObj: args);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ protected abstract ValueTask DownloadDataAsync(LyricDownloaderArgs args);
+
+ protected abstract ValueTask GenerateLyricAsync(byte[] data);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricDownloaderArgs.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricDownloaderArgs.cs
new file mode 100644
index 0000000..26adb7a
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricDownloaderArgs.cs
@@ -0,0 +1,15 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ public class LyricDownloaderArgs
+ {
+ public string SongName { get; set; }
+
+ public string Artist { get; set; }
+
+ public LyricDownloaderArgs(string songName, string artist)
+ {
+ SongName = songName;
+ Artist = artist;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItem.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItem.cs
new file mode 100644
index 0000000..5028589
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItem.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Text.RegularExpressions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ ///
+ /// 每一行歌词的对象。
+ ///
+ public class LyricItem : IComparable
+ {
+ ///
+ /// 原始时间轴,格式类似于 [01:55.12]。
+ ///
+ public string OriginalTimeline => $"[{Minute:00}:{Second:00.00}]";
+
+ ///
+ /// 歌词文本数据。
+ ///
+ public string LyricText { get; }
+
+ ///
+ /// 歌词所在的时间(分)。
+ ///
+ public int Minute { get; }
+
+ ///
+ /// 歌词所在的时间(秒)。
+ ///
+ public double Second { get; }
+
+ ///
+ /// 排序分数,用于一组歌词当中的排序权重。
+ ///
+ public double SortScore => Minute * 60 + Second;
+
+ ///
+ /// 构建新的 对象。
+ ///
+ /// 原始的 Lyric 歌词。
+ public LyricItem(string lyricText)
+ {
+ var timeline = new Regex(@"\[\d+:\d+.\d+\]").Match(lyricText)
+ .Value.Replace("]", string.Empty)
+ .Replace("[", string.Empty)
+ .Split(':');
+
+ if (int.TryParse(timeline[0], out var minute)) Minute = minute;
+ if (double.TryParse(timeline[1], out var second)) Second = second;
+
+ LyricText = new Regex(@"(?<=\[\d+:\d+.\d+\]).+").Match(lyricText).Value;
+ }
+
+ ///
+ /// 构造新的 对象。
+ ///
+ /// 歌词所在的时间(分)。
+ /// 歌词所在的时间(秒)。
+ /// 歌词文本数据。
+ public LyricItem(int minute, double second, string lyricText)
+ {
+ Minute = minute;
+ Second = second;
+ LyricText = lyricText;
+ }
+
+ public int CompareTo(LyricItem other)
+ {
+ if (SortScore > other.SortScore)
+ {
+ return 1;
+ }
+
+ if (SortScore < other.SortScore)
+ {
+ return -1;
+ }
+
+ return 0;
+ }
+
+ public static bool operator >(LyricItem left, LyricItem right)
+ {
+ return left.SortScore > right.SortScore;
+ }
+
+ public static bool operator <(LyricItem left, LyricItem right)
+ {
+ return left.SortScore < right.SortScore;
+ }
+
+ public static bool operator ==(LyricItem left, LyricItem right)
+ {
+ return (int?) left?.SortScore == (int?) right?.SortScore;
+ }
+
+ public static bool operator !=(LyricItem item1, LyricItem item2)
+ {
+ return !(item1 == item2);
+ }
+
+ public static LyricItem operator +(LyricItem src, LyricItem dist)
+ {
+ return new LyricItem(src.Minute, src.Second, $"{src.LyricText} {dist.LyricText}");
+ }
+
+ protected bool Equals(LyricItem other)
+ {
+ return LyricText == other.LyricText && Minute == other.Minute && Second.Equals(other.Second);
+ }
+
+ public override bool Equals(object obj)
+ {
+ if (ReferenceEquals(null, obj)) return false;
+ if (ReferenceEquals(this, obj)) return true;
+ if (obj.GetType() != this.GetType()) return false;
+ return Equals((LyricItem) obj);
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode.Combine(LyricText, Minute, Second);
+ }
+
+ public override string ToString() => $"[{Minute:00}:{Second:00.00}]{LyricText}";
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollection.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollection.cs
new file mode 100644
index 0000000..0e1dffc
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollection.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using ZonyLrcTools.Cli.Infrastructure.Extensions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ ///
+ /// 歌词数据,包含多条歌词对象()。
+ ///
+ public class LyricItemCollection : List
+ {
+ ///
+ /// 是否为纯音乐,当没有任何歌词数据的时候,属性值为 True。
+ ///
+ public bool IsPruneMusic => Count == 0;
+
+ public LyricItemCollectionOption Option { get; private set; }
+
+ public LyricItemCollection(LyricItemCollectionOption option)
+ {
+ Option = option;
+ }
+
+ public static LyricItemCollection operator +(LyricItemCollection left, LyricItemCollection right)
+ {
+ if (right.IsPruneMusic)
+ {
+ return left;
+ }
+
+ var option = left.Option;
+ var newCollection = new LyricItemCollection(option);
+ var indexDiff = left.Count - right.Count;
+
+ if (!option.IsOneLine)
+ {
+ left.ForEach(item => newCollection.Add(item));
+ right.ForEach(item => newCollection.Add(item));
+
+ newCollection.Sort();
+ return newCollection;
+ }
+
+ // 如果索引相等,直接根据索引快速匹配构建。
+ if (indexDiff == 0)
+ {
+ newCollection.AddRange(left.Select((t, index) => t + right[index]));
+
+ return newCollection;
+ }
+
+ // 首先按照时间轴进行合并。
+ var leftMarkDict = BuildMarkDictionary(left);
+ var rightMarkDict = BuildMarkDictionary(right);
+
+ for (var leftIndex = 0; leftIndex < left.Count; leftIndex++)
+ {
+ var rightItem = right.Find(lyric => Math.Abs(lyric.SortScore - left[leftIndex].SortScore) < 0.001);
+ if (rightItem != null)
+ {
+ newCollection.Add(left[leftIndex] + rightItem);
+ var rightIndex = right.FindIndex(item => item == rightItem);
+ rightMarkDict[rightIndex] = true;
+ }
+ else
+ {
+ newCollection.Add(left[leftIndex]);
+ }
+
+ leftMarkDict[leftIndex] = true;
+ }
+
+ // 遍历未处理的歌词项,将其添加到返回集合当中。
+ var leftWaitProcessIndex = leftMarkDict
+ .Where(item => item.Value)
+ .Select(pair => pair.Key);
+ var rightWaitProcessIndex = rightMarkDict
+ .Where(item => item.Value)
+ .Select(pair => pair.Key);
+
+ leftWaitProcessIndex.Foreach(index => newCollection.Add(left[index]));
+ rightWaitProcessIndex.Foreach(index => newCollection.Add(right[index]));
+
+ newCollection.Sort();
+ return newCollection;
+ }
+
+ ///
+ /// 根据歌词集合构建一个索引状态字典。
+ ///
+ ///
+ /// 这个索引字典用于标识每个索引的歌词是否被处理,为 True 则为已处理,为 False 为未处理。
+ ///
+ /// 等待构建的歌词集合实例。
+ private static Dictionary BuildMarkDictionary(LyricItemCollection items)
+ {
+ return items
+ .Select((item, index) => new {index, item})
+ .ToDictionary(item => item.index, item => false);
+ }
+
+ public override string ToString()
+ {
+ var lyricBuilder = new StringBuilder();
+ ForEach(lyric => lyricBuilder.Append(lyric).Append("\r\n"));
+ return lyricBuilder.ToString().TrimEnd("\r\n");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollectionFactory.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollectionFactory.cs
new file mode 100644
index 0000000..6f4f652
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollectionFactory.cs
@@ -0,0 +1,34 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Options;
+using ZonyLrcTools.Cli.Config;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ public class LyricItemCollectionFactory : ILyricItemCollectionFactory, ITransientDependency
+ {
+ private readonly ToolOptions _options;
+
+ public LyricItemCollectionFactory(IOptions options)
+ {
+ _options = options.Value;
+ }
+
+ public LyricItemCollection Build(string sourceLyric, string translateLyric = null)
+ {
+ var items = new LyricItemCollection(_options.LyricOption);
+ if (string.IsNullOrEmpty(sourceLyric))
+ {
+ return items;
+ }
+
+ var regex = new Regex(@"\[\d+:\d+.\d+\].+\n?");
+ foreach (Match match in regex.Matches(sourceLyric))
+ {
+ items.Add(new LyricItem(match.Value));
+ }
+
+ return items;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollectionOption.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollectionOption.cs
new file mode 100644
index 0000000..8953543
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/LyricItemCollectionOption.cs
@@ -0,0 +1,17 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric
+{
+ public class LyricItemCollectionOption
+ {
+ ///
+ /// 双语歌词是否合并为一行。
+ ///
+ public bool IsOneLine { get; set; } = false;
+
+ ///
+ /// 换行符格式,取值来自 常量类。
+ ///
+ public string LineBreak { get; set; } = LineBreakType.Windows;
+
+ public static readonly LyricItemCollectionOption NullInstance = new();
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetLyricRequest.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetLyricRequest.cs
new file mode 100644
index 0000000..6d2cf0b
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetLyricRequest.cs
@@ -0,0 +1,34 @@
+using Newtonsoft.Json;
+
+// ReSharper disable InconsistentNaming
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
+{
+ public class GetLyricRequest
+ {
+ public GetLyricRequest(long songId)
+ {
+ OS = "osx";
+ Id = songId;
+ Lv = Kv = Tv = -1;
+ }
+
+ ///
+ /// 请求的操作系统。
+ ///
+ [JsonProperty("os")]
+ public string OS { get; }
+
+ ///
+ /// 歌曲的 SID 值。
+ ///
+ [JsonProperty("id")]
+ public long Id { get; }
+
+ [JsonProperty("lv")] public int Lv { get; }
+
+ [JsonProperty("kv")] public int Kv { get; }
+
+ [JsonProperty("tv")] public int Tv { get; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetLyricResponse.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetLyricResponse.cs
new file mode 100644
index 0000000..48648ba
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetLyricResponse.cs
@@ -0,0 +1,45 @@
+using Newtonsoft.Json;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
+{
+ public class GetLyricResponse
+ {
+ ///
+ /// 原始的歌词。
+ ///
+ [JsonProperty("lrc")]
+ public InnerLyric OriginalLyric { get; set; }
+
+ ///
+ /// 卡拉 OK 歌词。
+ ///
+ [JsonProperty("klyric")]
+ public InnerLyric KaraokeLyric { get; set; }
+
+ ///
+ /// 如果存在翻译歌词,则本项内容为翻译歌词。
+ ///
+ [JsonProperty("tlyric")]
+ public InnerLyric TranslationLyric { get; set; }
+
+ ///
+ /// 状态码。
+ ///
+ [JsonProperty("code")]
+ public string StatusCode { get; set; }
+ }
+
+ ///
+ /// 歌词 JSON 类型
+ ///
+ public class InnerLyric
+ {
+ [JsonProperty("version")] public string Version { get; set; }
+
+ ///
+ /// 具体的歌词数据。
+ ///
+ [JsonProperty("lyric")]
+ public string Text { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetSongDetailsRequest.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetSongDetailsRequest.cs
new file mode 100644
index 0000000..5aeafa0
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/GetSongDetailsRequest.cs
@@ -0,0 +1,19 @@
+using Newtonsoft.Json;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
+{
+ public class GetSongDetailsRequest
+ {
+ public GetSongDetailsRequest(int songId)
+ {
+ SongId = songId;
+ SongIds = $"%5B{songId}%5D";
+ }
+
+ [JsonProperty("id")]
+ public int SongId { get; }
+
+ [JsonProperty("ids")]
+ public string SongIds { get; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchRequest.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchRequest.cs
new file mode 100644
index 0000000..1b2b423
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchRequest.cs
@@ -0,0 +1,59 @@
+using System.Text;
+using System.Web;
+using Newtonsoft.Json;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
+{
+ public class SongSearchRequest
+ {
+ ///
+ /// CSRF 标识,一般为空即可,接口不会进行校验。
+ ///
+ [JsonProperty("csrf_token")]
+ public string CsrfToken { get; set; }
+
+ ///
+ /// 需要搜索的内容,一般是歌曲名 + 歌手的格式。
+ ///
+ [JsonProperty("s")]
+ public string SearchKey { get; set; }
+
+ ///
+ /// 页偏移量。
+ ///
+ [JsonProperty("offset")]
+ public int Offset { get; set; }
+
+ ///
+ /// 搜索类型。
+ ///
+ [JsonProperty("type")]
+ public int Type { get; set; }
+
+ ///
+ /// 是否获取全部的搜索结果。
+ ///
+ [JsonProperty("total")]
+ public bool IsTotal { get; set; }
+
+ ///
+ /// 每页的最大结果容量。
+ ///
+ [JsonProperty("limit")]
+ public int Limit { get; set; }
+
+ public SongSearchRequest()
+ {
+ CsrfToken = string.Empty;
+ Type = 1;
+ Offset = 0;
+ IsTotal = true;
+ Limit = 5;
+ }
+
+ public SongSearchRequest(string musicName, string artistName) : this()
+ {
+ SearchKey = HttpUtility.UrlEncode($"{musicName}+{artistName}", Encoding.UTF8);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchResponse.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchResponse.cs
new file mode 100644
index 0000000..7217cc9
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchResponse.cs
@@ -0,0 +1,75 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
+{
+ public class SongSearchResponse
+ {
+ [JsonProperty("result")] public InnerListItemModel Items { get; set; }
+
+ [JsonProperty("code")] public int StatusCode { get; set; }
+
+ public int GetFirstSongId()
+ {
+ return Items.SongItems[0].Id;
+ }
+ }
+
+ public class InnerListItemModel
+ {
+ [JsonProperty("songs")] public IList SongItems { get; set; }
+
+ [JsonProperty("songCount")] public int SongCount { get; set; }
+ }
+
+ public class SongModel
+ {
+ ///
+ /// 歌曲的名称。
+ ///
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ ///
+ /// 歌曲的 Sid (Song Id)。
+ ///
+ [JsonProperty("id")]
+ public int Id { get; set; }
+
+ ///
+ /// 歌曲的演唱者。
+ ///
+ [JsonProperty("artists")]
+ public IList Artists { get; set; }
+
+ ///
+ /// 歌曲的专辑信息。
+ ///
+ [JsonProperty("album")]
+ public SongAlbumModel Album { get; set; }
+ }
+
+ public class SongArtistModel
+ {
+ ///
+ /// 歌手/艺术家的名称。
+ ///
+ [JsonProperty("name")]
+ public string Name { get; set; }
+ }
+
+ public class SongAlbumModel
+ {
+ ///
+ /// 专辑的名称。
+ ///
+ [JsonProperty("name")]
+ public string Name { get; set; }
+
+ ///
+ /// 专辑图像的 Url 地址。
+ ///
+ [JsonProperty("img1v1Url")]
+ public string PictureUrl { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchResponseStatusCode.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchResponseStatusCode.cs
new file mode 100644
index 0000000..87a03e7
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/JsonModel/SongSearchResponseStatusCode.cs
@@ -0,0 +1,7 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
+{
+ public static class SongSearchResponseStatusCode
+ {
+ public const int Success = 200;
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/NetEaseLyricDownloader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/NetEaseLyricDownloader.cs
new file mode 100644
index 0000000..0c881d4
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/NetEase/NetEaseLyricDownloader.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+using ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel;
+using ZonyLrcTools.Cli.Infrastructure.Network;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase
+{
+ public class NetEaseLyricDownloader : LyricDownloader
+ {
+ public override string DownloaderName => InternalLyricDownloaderNames.NetEase;
+
+ private readonly IWarpHttpClient _warpHttpClient;
+ private readonly ILyricItemCollectionFactory _lyricItemCollectionFactory;
+
+ private const string NetEaseSearchMusicUrl = @"https://music.163.com/api/search/get/web";
+ private const string NetEaseGetLyricUrl = @"https://music.163.com/api/song/lyric";
+ private const string NetEaseRequestReferer = @"https://music.163.com";
+ private const string NetEaseRequestContentType = @"application/x-www-form-urlencoded";
+
+ public NetEaseLyricDownloader(IWarpHttpClient warpHttpClient,
+ ILyricItemCollectionFactory lyricItemCollectionFactory)
+ {
+ _warpHttpClient = warpHttpClient;
+ _lyricItemCollectionFactory = lyricItemCollectionFactory;
+ }
+
+ protected override async ValueTask DownloadDataAsync(LyricDownloaderArgs args)
+ {
+ var searchResult = await _warpHttpClient.PostAsync(
+ NetEaseSearchMusicUrl,
+ new SongSearchRequest(args.SongName, args.Artist),
+ true,
+ msg =>
+ {
+ msg.Headers.Referrer = new Uri(NetEaseRequestReferer);
+ if (msg.Content != null)
+ {
+ msg.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(NetEaseRequestContentType);
+ }
+ });
+
+ ValidateSongSearchResponse(searchResult, args);
+
+ var lyricResponse = await _warpHttpClient.GetAsync(
+ NetEaseGetLyricUrl,
+ new GetLyricRequest(searchResult.GetFirstSongId()),
+ msg => msg.Headers.Referrer = new Uri(NetEaseRequestReferer));
+
+ return Encoding.UTF8.GetBytes(lyricResponse);
+ }
+
+ protected override async ValueTask GenerateLyricAsync(byte[] data)
+ {
+ await ValueTask.CompletedTask;
+
+ var json = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(data));
+ if (json?.OriginalLyric == null)
+ {
+ return new LyricItemCollection(LyricItemCollectionOption.NullInstance);
+ }
+
+ return _lyricItemCollectionFactory.Build(
+ json.OriginalLyric.Text,
+ json.TranslationLyric.Text);
+ }
+
+ protected virtual void ValidateSongSearchResponse(SongSearchResponse response, LyricDownloaderArgs args)
+ {
+ if (response?.StatusCode != SongSearchResponseStatusCode.Success)
+ {
+ throw new ErrorCodeException(ErrorCodes.TheReturnValueIsIllegal, attachObj: args);
+ }
+
+ if (response.Items?.SongCount <= 0)
+ {
+ throw new ErrorCodeException(ErrorCodes.NoMatchingSong, attachObj: args);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/JsonModel/SongSearchRequest.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/JsonModel/SongSearchRequest.cs
new file mode 100644
index 0000000..028a532
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/JsonModel/SongSearchRequest.cs
@@ -0,0 +1,80 @@
+using System.Text;
+using System.Web;
+using Newtonsoft.Json;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.QQMusic.JsonModel
+{
+ public class SongSearchRequest
+ {
+ [JsonProperty("ct")] public int UnknownParameter1 { get; set; }
+
+ [JsonProperty("qqmusic_ver")] public int ClientVersion { get; set; }
+
+ [JsonProperty("new_json")] public int UnknownParameter2 { get; set; }
+
+ [JsonProperty("remoteplace")] public string RemotePlace { get; set; }
+
+ [JsonProperty("t")] public int UnknownParameter3 { get; set; }
+
+ [JsonProperty("aggr")] public int UnknownParameter4 { get; set; }
+
+ [JsonProperty("cr")] public int UnknownParameter5 { get; set; }
+
+ [JsonProperty("catZhida")] public int UnknownParameter6 { get; set; }
+
+ [JsonProperty("lossless")] public int LossLess { get; set; }
+
+ [JsonProperty("flag_qc")] public int UnknownParameter7 { get; set; }
+
+ [JsonProperty("p")] public int Page { get; set; }
+
+ [JsonProperty("n")] public int Limit { get; set; }
+
+ [JsonProperty("w")] public string Keyword { get; set; }
+
+ [JsonProperty("g_tk")] public int UnknownParameter8 { get; set; }
+
+ [JsonProperty("hostUin")] public int UnknownParameter9 { get; set; }
+
+ [JsonProperty("format")] public string ResultFormat { get; set; }
+
+ [JsonProperty("inCharset")] public string InCharset { get; set; }
+
+ [JsonProperty("outCharset")] public string OutCharset { get; set; }
+
+ [JsonProperty("notice")] public int UnknownParameter10 { get; set; }
+
+ [JsonProperty("platform")] public string Platform { get; set; }
+
+ [JsonProperty("needNewCode")] public int UnknownParameter11 { get; set; }
+
+ public SongSearchRequest()
+ {
+ UnknownParameter1 = 24;
+ ClientVersion = 1298;
+ UnknownParameter2 = 1;
+ RemotePlace = "txt.yqq.song";
+ UnknownParameter3 = 0;
+ UnknownParameter4 = 1;
+ UnknownParameter5 = 1;
+ UnknownParameter6 = 1;
+ LossLess = 0;
+ UnknownParameter7 = 0;
+ Page = 1;
+ Limit = 5;
+ UnknownParameter8 = 5381;
+ UnknownParameter9 = 0;
+ ResultFormat = "json";
+ InCharset = "utf8";
+ OutCharset = "utf8";
+ UnknownParameter10 = 0;
+ Platform = "yqq";
+ UnknownParameter11 = 0;
+ }
+
+ public SongSearchRequest(string musicName, string artistName) : this()
+ {
+ Keyword = HttpUtility.UrlEncode($"{musicName}+{artistName}", Encoding.UTF8);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/JsonModel/SongSearchResponse.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/JsonModel/SongSearchResponse.cs
new file mode 100644
index 0000000..f31323a
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/JsonModel/SongSearchResponse.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.QQMusic.JsonModel
+{
+ public class SongSearchResponse
+ {
+ [JsonProperty("code")]
+ public int StatusCode { get; set; }
+
+ [JsonProperty("data")]
+ public QQMusicInnerDataModel Data { get; set; }
+ }
+
+ public class QQMusicInnerDataModel
+ {
+ [JsonProperty("song")]
+ public QQMusicInnerSongModel Song { get; set; }
+ }
+
+ public class QQMusicInnerSongModel
+ {
+ [JsonProperty("list")]
+ public List SongItems { get; set; }
+ }
+
+ public class QQMusicInnerSongItem
+ {
+ [JsonProperty("mid")]
+ public string SongId { get; set; }
+ }
+
+ public class AlbumInfo
+ {
+ [JsonProperty("id")]
+ public long Id { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/QQLyricDownloader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/QQLyricDownloader.cs
new file mode 100644
index 0000000..930682e
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Lyric/QQMusic/QQLyricDownloader.cs
@@ -0,0 +1,30 @@
+using System.Threading.Tasks;
+using ZonyLrcTools.Cli.Infrastructure.Network;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Lyric.QQMusic
+{
+ public class QQLyricDownloader : LyricDownloader
+ {
+ public override string DownloaderName => InternalLyricDownloaderNames.QQ;
+
+ private readonly IWarpHttpClient _warpHttpClient;
+ private readonly ILyricItemCollectionFactory _lyricItemCollectionFactory;
+
+ public QQLyricDownloader(IWarpHttpClient warpHttpClient,
+ ILyricItemCollectionFactory lyricItemCollectionFactory)
+ {
+ _warpHttpClient = warpHttpClient;
+ _lyricItemCollectionFactory = lyricItemCollectionFactory;
+ }
+
+ protected override ValueTask DownloadDataAsync(LyricDownloaderArgs args)
+ {
+ throw new System.NotImplementedException();
+ }
+
+ protected override ValueTask GenerateLyricAsync(byte[] data)
+ {
+ throw new System.NotImplementedException();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/MusicInfo.cs b/src/ZonyLrcTools.Cli/Infrastructure/MusicInfo.cs
new file mode 100644
index 0000000..18c3bc3
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/MusicInfo.cs
@@ -0,0 +1,18 @@
+namespace ZonyLrcTools.Cli.Infrastructure
+{
+ public class MusicInfo
+ {
+ public string FilePath { get; }
+
+ public string Name { get; }
+
+ public string Artist { get; }
+
+ public MusicInfo(string filePath, string name, string artist)
+ {
+ FilePath = filePath;
+ Name = name;
+ Artist = artist;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Network/DefaultWarpHttpClient.cs b/src/ZonyLrcTools.Cli/Infrastructure/Network/DefaultWarpHttpClient.cs
new file mode 100644
index 0000000..c7db29a
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Network/DefaultWarpHttpClient.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Network
+{
+ public class DefaultWarpHttpClient : IWarpHttpClient, ITransientDependency
+ {
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+
+ public const string HttpClientNameConstant = "WarpClient";
+
+ public DefaultWarpHttpClient(IHttpClientFactory httpClientFactory,
+ ILogger logger)
+ {
+ _httpClientFactory = httpClientFactory;
+ _logger = logger;
+ }
+
+ public async ValueTask PostAsync(string url,
+ object parameters = null,
+ bool isQueryStringParam = false,
+ Action requestOption = null)
+ {
+ var parametersStr = isQueryStringParam ? BuildQueryString(parameters) : BuildJsonBodyString(parameters);
+ var requestMessage = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
+ requestMessage.Content = new StringContent(parametersStr);
+
+ requestOption?.Invoke(requestMessage);
+
+ using var responseMessage = await BuildHttpClient().SendAsync(requestMessage);
+ var responseContentString = await responseMessage.Content.ReadAsStringAsync();
+
+ return responseMessage.StatusCode switch
+ {
+ HttpStatusCode.OK => responseContentString,
+ HttpStatusCode.ServiceUnavailable => throw new ErrorCodeException(ErrorCodes.ServiceUnavailable),
+ _ => throw new ErrorCodeException(ErrorCodes.ServiceUnavailable, attachObj: new {parametersStr, responseContentString})
+ };
+ }
+
+ public async ValueTask PostAsync(string url,
+ object parameters = null,
+ bool isQueryStringParam = false,
+ Action requestOption = null)
+ {
+ var responseString = await PostAsync(url, parameters, isQueryStringParam, requestOption);
+ var throwException = new ErrorCodeException(ErrorCodes.HttpResponseConvertJsonFailed, attachObj: new {parameters, responseString});
+
+ try
+ {
+ var responseObj = JsonConvert.DeserializeObject(responseString);
+ if (responseObj != null) return responseObj;
+
+ throw throwException;
+ }
+ catch (JsonSerializationException)
+ {
+ throw throwException;
+ }
+ }
+
+ public async ValueTask GetAsync(string url,
+ object parameters = null,
+ Action requestOption = null)
+ {
+ var requestParamsStr = BuildQueryString(parameters);
+ var requestMsg = new HttpRequestMessage(HttpMethod.Get, new Uri($"{url}?{requestParamsStr}"));
+ requestOption?.Invoke(requestMsg);
+
+ using (var responseMsg = await BuildHttpClient().SendAsync(requestMsg))
+ {
+ var responseContent = await responseMsg.Content.ReadAsStringAsync();
+
+ return responseMsg.StatusCode switch
+ {
+ HttpStatusCode.OK => responseContent,
+ HttpStatusCode.ServiceUnavailable => throw new ErrorCodeException(ErrorCodes.ServiceUnavailable),
+ _ => throw new ErrorCodeException(ErrorCodes.ServiceUnavailable, attachObj: new {requestParamsStr, responseContent})
+ };
+ }
+ }
+
+ public async ValueTask GetAsync(string url,
+ object parameters = null,
+ Action requestOption = null)
+ {
+ var responseStr = await GetAsync(url, parameters, requestOption);
+ var throwException = new ErrorCodeException(ErrorCodes.HttpResponseConvertJsonFailed, attachObj: new {parameters, responseStr});
+ try
+ {
+ var responseObj = JsonConvert.DeserializeObject(responseStr);
+ if (responseObj != null) return responseObj;
+
+ throw throwException;
+ }
+ catch (JsonSerializationException)
+ {
+ throw throwException;
+ }
+ }
+
+ protected virtual HttpClient BuildHttpClient()
+ {
+ return _httpClientFactory.CreateClient(HttpClientNameConstant);
+ }
+
+ private string BuildQueryString(object parameters)
+ {
+ if (parameters == null)
+ {
+ return string.Empty;
+ }
+
+ var type = parameters.GetType();
+ if (type == typeof(string))
+ {
+ return parameters as string;
+ }
+
+ var properties = type.GetProperties();
+ var paramBuilder = new StringBuilder();
+
+ foreach (var propertyInfo in properties)
+ {
+ var jsonProperty = propertyInfo.GetCustomAttribute();
+ var propertyName = jsonProperty != null ? jsonProperty.PropertyName : propertyInfo.Name;
+
+ paramBuilder.Append($"{propertyName}={propertyInfo.GetValue(parameters)}&");
+ }
+
+ return paramBuilder.ToString().TrimEnd('&');
+ }
+
+ private string BuildJsonBodyString(object parameters)
+ {
+ if (parameters == null) return string.Empty;
+ if (parameters is string result) return result;
+
+ return JsonConvert.SerializeObject(parameters);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Network/IWarpHttpClient.cs b/src/ZonyLrcTools.Cli/Infrastructure/Network/IWarpHttpClient.cs
new file mode 100644
index 0000000..fac759f
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Network/IWarpHttpClient.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Network
+{
+ ///
+ /// 基于 封装的 HTTP 请求客户端。
+ ///
+ public interface IWarpHttpClient
+ {
+ ///
+ /// 根据指定的配置执行 POST 请求,并以 作为返回值。
+ ///
+ /// 请求的 URL 地址。
+ /// 请求的参数。
+ /// 是否以 QueryString 形式携带参数。
+ /// 请求时的配置动作。
+ /// 服务端的响应结果。
+ ValueTask PostAsync(string url,
+ object parameters = null,
+ bool isQueryStringParam = false,
+ Action requestOption = null);
+
+ ///
+ /// 根据指定的配置执行 POST 请求,并将结果反序列化为 对象。
+ ///
+ /// 请求的 URL 地址。
+ /// 请求的参数。
+ /// 是否以 QueryString 形式携带参数。
+ /// 请求时的配置动作。
+ /// 需要将响应结果反序列化的目标类型。
+ /// 服务端的响应结果。
+ ValueTask PostAsync(string url,
+ object parameters = null,
+ bool isQueryStringParam = false,
+ Action requestOption = null);
+
+ ///
+ /// 根据指定的配置执行 GET 请求,并以 作为返回值。
+ ///
+ /// 请求的 URL 地址。
+ /// 请求的参数。
+ /// 请求时的配置动作。
+ /// 服务端的响应结果。
+ ValueTask GetAsync(string url,
+ object parameters = null,
+ Action requestOption = null);
+
+ ///
+ /// 根据指定的配置执行 GET 请求,并将结果反序列化为 对象。
+ ///
+ /// 请求的 URL 地址。
+ /// 请求的参数。
+ /// 请求时的配置动作。
+ /// 需要将响应结果反序列化的目标类型。
+ /// 服务端的响应结果。
+ ValueTask GetAsync(
+ string url,
+ object parameters = null,
+ Action requestOption = null);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Network/NetworkOptions.cs b/src/ZonyLrcTools.Cli/Infrastructure/Network/NetworkOptions.cs
new file mode 100644
index 0000000..3620fe3
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Network/NetworkOptions.cs
@@ -0,0 +1,23 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Network
+{
+ ///
+ /// 工具网络相关的设定。
+ ///
+ public class NetworkOptions
+ {
+ ///
+ /// 是否启用了网络代理功能。
+ ///
+ public bool Enable { get; set; }
+
+ ///
+ /// 代理服务器的 Ip。
+ ///
+ public string ProxyIp { get; set; }
+
+ ///
+ /// 代理服务器的 端口。
+ ///
+ public int ProxyPort { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Tag/DefaultTagLoader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Tag/DefaultTagLoader.cs
new file mode 100644
index 0000000..8584b62
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Tag/DefaultTagLoader.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Tag
+{
+ public class DefaultTagLoader : ITagLoader, ITransientDependency
+ {
+ protected readonly IEnumerable TagInfoProviders;
+
+ public DefaultTagLoader(IEnumerable tagInfoProviders)
+ {
+ TagInfoProviders = tagInfoProviders;
+ }
+
+ public virtual async ValueTask LoadTagAsync(string filePath)
+ {
+ if (!TagInfoProviders.Any())
+ {
+ throw new ErrorCodeException(ErrorCodes.LoadTagInfoProviderError);
+ }
+
+ foreach (var provider in TagInfoProviders.OrderBy(p => p.Priority))
+ {
+ var info = await provider.LoadAsync(filePath);
+ if (info != null)
+ {
+ return info;
+ }
+ }
+
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Tag/FileNameTagInfoProvider.cs b/src/ZonyLrcTools.Cli/Infrastructure/Tag/FileNameTagInfoProvider.cs
new file mode 100644
index 0000000..7bf6c04
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Tag/FileNameTagInfoProvider.cs
@@ -0,0 +1,38 @@
+using System.IO;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Options;
+using ZonyLrcTools.Cli.Config;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Tag
+{
+ public class FileNameTagInfoProvider : ITagInfoProvider, ITransientDependency
+ {
+ public int Priority => 2;
+
+ public string Name => ConstantName;
+ public const string ConstantName = "FileName";
+
+ private readonly ToolOptions Options;
+
+ public FileNameTagInfoProvider(IOptions options)
+ {
+ Options = options.Value;
+ }
+
+ public async ValueTask LoadAsync(string filePath)
+ {
+ await ValueTask.CompletedTask;
+
+ var match = Regex.Match(Path.GetFileNameWithoutExtension(filePath), Options.TagInfoProviderOptions.FileNameRegularExpressions);
+
+ if (match.Groups.Count != 3)
+ {
+ return null;
+ }
+
+ return new MusicInfo(filePath, match.Groups["name"].Value, match.Groups["artist"].Value);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Tag/ITagInfoProvider.cs b/src/ZonyLrcTools.Cli/Infrastructure/Tag/ITagInfoProvider.cs
new file mode 100644
index 0000000..f67ceb5
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Tag/ITagInfoProvider.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Tag
+{
+ public interface ITagInfoProvider
+ {
+ int Priority { get; }
+
+ string Name { get; }
+
+ ValueTask LoadAsync(string filePath);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Tag/ITagLoader.cs b/src/ZonyLrcTools.Cli/Infrastructure/Tag/ITagLoader.cs
new file mode 100644
index 0000000..fa2ca2c
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Tag/ITagLoader.cs
@@ -0,0 +1,14 @@
+using System.Threading.Tasks;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Tag
+{
+ public interface ITagLoader
+ {
+ ///
+ /// 加载歌曲的标签信息。
+ ///
+ /// 歌曲文件的路径。
+ /// 加载完成的歌曲信息。
+ ValueTask LoadTagAsync(string filePath);
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Tag/TagInfoProviderOptions.cs b/src/ZonyLrcTools.Cli/Infrastructure/Tag/TagInfoProviderOptions.cs
new file mode 100644
index 0000000..4876b1a
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Tag/TagInfoProviderOptions.cs
@@ -0,0 +1,10 @@
+namespace ZonyLrcTools.Cli.Infrastructure.Tag
+{
+ public class TagInfoProviderOptions
+ {
+ ///
+ /// 用于从文件名当中提取歌曲名、歌手名。
+ ///
+ public string FileNameRegularExpressions { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Tag/TaglibTagInfoProvider.cs b/src/ZonyLrcTools.Cli/Infrastructure/Tag/TaglibTagInfoProvider.cs
new file mode 100644
index 0000000..9d87330
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Tag/TaglibTagInfoProvider.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Threading.Tasks;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Tag
+{
+ public class TaglibTagInfoProvider : ITagInfoProvider, ITransientDependency
+ {
+ public int Priority => 1;
+
+ public string Name => ConstantName;
+ public const string ConstantName = "Taglib";
+
+ public async ValueTask LoadAsync(string filePath)
+ {
+ try
+ {
+ var file = TagLib.File.Create(filePath);
+
+ var songName = file.Tag.Title;
+ var songArtist = file.Tag.FirstPerformer;
+
+ if (!string.IsNullOrEmpty(file.Tag.FirstAlbumArtist))
+ {
+ songArtist = file.Tag.FirstAlbumArtist;
+ }
+
+ await ValueTask.CompletedTask;
+
+ if (songName == null && songArtist == null)
+ {
+ return null;
+ }
+
+ return new MusicInfo(filePath, songName, songArtist);
+ }
+ catch (Exception ex)
+ {
+ throw new ErrorCodeException(ErrorCodes.TagInfoProviderLoadInfoFailed, ex.Message, filePath);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Threading/WarpTask.cs b/src/ZonyLrcTools.Cli/Infrastructure/Threading/WarpTask.cs
new file mode 100644
index 0000000..d6afa95
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Infrastructure/Threading/WarpTask.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ZonyLrcTools.Cli.Infrastructure.Threading
+{
+ public class WarpTask : IDisposable
+ {
+ private readonly CancellationTokenSource _cts = new();
+ private readonly SemaphoreSlim _semaphore;
+ private readonly int _maxDegreeOfParallelism;
+
+ public WarpTask(int maxDegreeOfParallelism)
+ {
+ if (maxDegreeOfParallelism <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxDegreeOfParallelism));
+ }
+
+ _maxDegreeOfParallelism = maxDegreeOfParallelism;
+ _semaphore = new SemaphoreSlim(maxDegreeOfParallelism);
+ }
+
+ public async Task RunAsync(Func taskFactory, CancellationToken cancellationToken = default)
+ {
+ using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token))
+ {
+ await _semaphore.WaitAsync(cts.Token);
+ try
+ {
+ await taskFactory().ConfigureAwait(false);
+ }
+ finally
+ {
+ _semaphore.Release(1);
+ }
+ }
+ }
+
+ public async Task RunAsync(Func> taskFactory, CancellationToken cancellationToken = default)
+ {
+ using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token))
+ {
+ await _semaphore.WaitAsync(cts.Token);
+ try
+ {
+ return await taskFactory().ConfigureAwait(false);
+ }
+ finally
+ {
+ _semaphore.Release(1);
+ }
+ }
+ }
+
+ private bool _disposedValue = false;
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposedValue)
+ {
+ if (disposing)
+ {
+ _cts.Cancel();
+ for (int i = 0; i < _maxDegreeOfParallelism; i++)
+ {
+ _semaphore.WaitAsync().GetAwaiter().GetResult();
+ }
+
+ _semaphore.Dispose();
+ _cts.Dispose();
+ }
+
+ _disposedValue = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ~WarpTask()
+ {
+ Dispose(false);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/Resources/error_msg.json b/src/ZonyLrcTools.Cli/Resources/error_msg.json
new file mode 100644
index 0000000..ccc0264
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/Resources/error_msg.json
@@ -0,0 +1,20 @@
+{
+ "Error": {
+ "10001": "待搜索的后缀不能为空。",
+ "10002": "需要扫描的目录不存在,请确认路径是否正确。",
+ "10003": "不能获取文件的后缀信息。",
+ "10004": "没有扫描到任何音乐文件。"
+ },
+ "Warning": {
+ "50001": "扫描文件时出现了错误。",
+ "50002": "歌曲名称或歌手名称均为空,无法进行搜索。",
+ "50003": "歌曲名称不能为空,无法进行搜索。",
+ "50004": "下载器没有搜索到对应的歌曲信息。",
+ "50005": "下载请求的返回值不合法,可能是服务端故障。",
+ "50006": "标签信息读取器为空,无法解析音乐 Tag 信息。",
+ "50007": "TagLib 标签读取器出现了预期之外的异常。",
+ "50008": "服务接口限制,无法进行请求,请尝试使用代理服务器。",
+ "50009": "对目标服务器执行 Http 请求失败。",
+ "50010": "Http 请求的结果反序列化为 Json 失败。"
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/ZonyLrcTools.Cli.csproj b/src/ZonyLrcTools.Cli/ZonyLrcTools.Cli.csproj
new file mode 100644
index 0000000..b55563c
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/ZonyLrcTools.Cli.csproj
@@ -0,0 +1,39 @@
+
+
+
+ net5.0
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+ Always
+
+
+
+
+
+
+
+
diff --git a/src/ZonyLrcTools.Cli/appsettings.json b/src/ZonyLrcTools.Cli/appsettings.json
new file mode 100644
index 0000000..890c7a6
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/appsettings.json
@@ -0,0 +1,20 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Microsoft": "Information"
+ }
+ },
+ "ToolOption": {
+ "SupportFileExtensions": "*.mp3;*.flac",
+ "NetworkOptions": {
+ "Enable": true,
+ "ProxyIp": "127.0.0.1",
+ "ProxyPort": 4780
+ },
+ "TagInfoProviderOptions": {
+ "FileNameRegularExpressions": "(?'artist'.+)\\s-\\s(?'name'.+)"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ZonyLrcTools.Cli/publish.sh b/src/ZonyLrcTools.Cli/publish.sh
new file mode 100755
index 0000000..a1af555
--- /dev/null
+++ b/src/ZonyLrcTools.Cli/publish.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+read -r -p "请输入版本号:" Version
+Platforms=('win-x64' 'linux-x64' 'osx-x64')
+
+if ! [ -d './TempFiles' ];
+then
+ mkdir ./TempFiles
+fi
+
+rm -rf ./TempFiles/*
+
+for platform in "${Platforms[@]}"
+do
+ dotnet publish -r "$platform" -c Release -p:PublishSingleFile=true -p:PublishTrimmed=true --self-contained true
+
+ zip -r -j ./bin/Release/net5.0/"$platform"/publish/ZonyLrcTools_"$platform"_"$Version".zip ./bin/Release/net5.0/"$platform"/publish/
+
+ mv ./bin/Release/net5.0/"$platform"/publish/ZonyLrcTools_"$platform"_"$Version".zip ./TempFiles
+done
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/FileScannerTests.cs b/tests/ZonyLrcTools.Tests/FileScannerTests.cs
new file mode 100644
index 0000000..3a71de4
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/FileScannerTests.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZonyLrcTools.Cli.Infrastructure.IO;
+
+namespace ZonyLrcTools.Tests
+{
+ public class FileScannerTests : TestBase
+ {
+ [Fact]
+ public async Task ScanAsync_Test()
+ {
+ var tempMusicFilePath = Path.Combine(Directory.GetCurrentDirectory(), "Temp.mp3");
+ File.Create(tempMusicFilePath);
+
+ var fileScanner = ServiceProvider.GetRequiredService();
+ var result = await fileScanner.ScanAsync(
+ Path.GetDirectoryName(tempMusicFilePath),
+ new[] {"*.mp3", "*.flac"});
+
+ result.Count.ShouldBe(2);
+ result.First(e => e.ExtensionName == ".mp3").FilePaths.Count.ShouldNotBe(0);
+
+ File.Delete(tempMusicFilePath);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/Infrastructure/Album/NetEaseAlbumDownloader_Tests.cs b/tests/ZonyLrcTools.Tests/Infrastructure/Album/NetEaseAlbumDownloader_Tests.cs
new file mode 100644
index 0000000..db9821a
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/Infrastructure/Album/NetEaseAlbumDownloader_Tests.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZonyLrcTools.Cli.Infrastructure.Album;
+
+namespace ZonyLrcTools.Tests.Infrastructure.Album
+{
+ public class NetEaseAlbumDownloaderTests : TestBase
+ {
+ [Fact]
+ public async Task DownloadDataAsync_Test()
+ {
+ var downloader = ServiceProvider.GetRequiredService>()
+ .FirstOrDefault(x => x.DownloaderName == InternalAlbumDownloaderNames.NetEase);
+
+ downloader.ShouldNotBeNull();
+ var albumBytes = await downloader.DownloadAsync("东方红", null);
+ albumBytes.Length.ShouldBeGreaterThan(0);
+
+ // 显示具体的图像。
+ var tempAlbumPath = Path.Combine(Directory.GetCurrentDirectory(), "tempAlbum.png");
+ File.Delete(tempAlbumPath);
+
+ await using var file = File.Create(tempAlbumPath);
+ await using var ws = new BinaryWriter(file);
+ ws.Write(albumBytes);
+ ws.Flush();
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/Infrastructure/Exceptions/ErrorCodeHelperTests.cs b/tests/ZonyLrcTools.Tests/Infrastructure/Exceptions/ErrorCodeHelperTests.cs
new file mode 100644
index 0000000..620eaf8
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/Infrastructure/Exceptions/ErrorCodeHelperTests.cs
@@ -0,0 +1,26 @@
+using Shouldly;
+using Xunit;
+using ZonyLrcTools.Cli.Infrastructure.Exceptions;
+
+namespace ZonyLrcTools.Tests.Infrastructure.Exceptions
+{
+ public class ErrorCodeHelperTests : TestBase
+ {
+ [Fact]
+ public void LoadMessage_Test()
+ {
+ ErrorCodeHelper.LoadErrorMessage();
+
+ ErrorCodeHelper.ErrorMessages.ShouldNotBeNull();
+ ErrorCodeHelper.ErrorMessages.Count.ShouldBe(11);
+ }
+
+ [Fact]
+ public void GetMessage_Test()
+ {
+ ErrorCodeHelper.LoadErrorMessage();
+
+ ErrorCodeHelper.GetMessage(ErrorCodes.DirectoryNotExist).ShouldBe("需要扫描的目录不存在,请确认路径是否正确。");
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/Infrastructure/Lyric/NetEaseLyricDownloaderTests.cs b/tests/ZonyLrcTools.Tests/Infrastructure/Lyric/NetEaseLyricDownloaderTests.cs
new file mode 100644
index 0000000..c8bb603
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/Infrastructure/Lyric/NetEaseLyricDownloaderTests.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZonyLrcTools.Cli.Infrastructure.Lyric;
+
+namespace ZonyLrcTools.Tests.Infrastructure.Lyric
+{
+ public class NetEaseLyricDownloaderTests : TestBase
+ {
+ [Fact]
+ public async Task DownloadAsync_Test()
+ {
+ var downloaderList = ServiceProvider.GetRequiredService>();
+ var netEaseDownloader = downloaderList.FirstOrDefault(t => t.DownloaderName == InternalLyricDownloaderNames.NetEase);
+
+ netEaseDownloader.ShouldNotBeNull();
+ var lyric = await netEaseDownloader.DownloadAsync("Hollow", "Janet Leon");
+ lyric.ShouldNotBeNull();
+ lyric.IsPruneMusic.ShouldBe(false);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/Infrastructure/Network/WarpClientTests.cs b/tests/ZonyLrcTools.Tests/Infrastructure/Network/WarpClientTests.cs
new file mode 100644
index 0000000..cf646f0
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/Infrastructure/Network/WarpClientTests.cs
@@ -0,0 +1,48 @@
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+using ZonyLrcTools.Cli.Config;
+using ZonyLrcTools.Cli.Infrastructure.Network;
+
+namespace ZonyLrcTools.Tests.Infrastructure.Network
+{
+ public class WarpClientTests : TestBase
+ {
+ [Fact]
+ public async Task PostAsync_Test()
+ {
+ var client = ServiceProvider.GetRequiredService();
+
+ var response = await client.PostAsync(@"https://www.baidu.com");
+ response.ShouldNotBeNull();
+ response.ShouldContain("百度");
+ }
+
+ [Fact]
+ public async Task GetAsync_Test()
+ {
+ var client = ServiceProvider.GetRequiredService();
+
+ var response = await client.GetAsync(@"https://www.baidu.com");
+ response.ShouldNotBeNull();
+ response.ShouldContain("百度");
+ }
+
+ [Fact]
+ public async Task GetAsyncWithProxy_Test()
+ {
+ var option = ServiceProvider.GetRequiredService>();
+ option.Value.NetworkOptions.ProxyIp = "127.0.0.1";
+ option.Value.NetworkOptions.ProxyPort = 4780;
+
+ var client = ServiceProvider.GetRequiredService();
+
+ var response = await client.GetAsync(@"https://www.baidu.com");
+
+ response.ShouldNotBeNull();
+ response.ShouldContain("百度");
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/Infrastructure/Tag/FileNameTagInfoProviderTests.cs b/tests/ZonyLrcTools.Tests/Infrastructure/Tag/FileNameTagInfoProviderTests.cs
new file mode 100644
index 0000000..23c7eb2
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/Infrastructure/Tag/FileNameTagInfoProviderTests.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZonyLrcTools.Cli.Infrastructure.Tag;
+
+namespace ZonyLrcTools.Tests.Infrastructure.Tag
+{
+ public class FileNameTagInfoProviderTests : TestBase
+ {
+ [Fact]
+ public async Task LoadAsync_Test()
+ {
+ var provider = ServiceProvider.GetRequiredService>()
+ .FirstOrDefault(p => p.Name == FileNameTagInfoProvider.ConstantName);
+
+ provider.ShouldNotBeNull();
+
+ var info = await provider.LoadAsync(Path.Combine(Directory.GetCurrentDirectory(), "MusicFiles", "曾经艺也 - 荀彧(纯音乐版).mp3"));
+ info.ShouldNotBeNull();
+ info.Name.ShouldBe("荀彧(纯音乐版)");
+ info.Artist.ShouldBe("曾经艺也");
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/Infrastructure/Tag/TagLoaderTests.cs b/tests/ZonyLrcTools.Tests/Infrastructure/Tag/TagLoaderTests.cs
new file mode 100644
index 0000000..c42ea55
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/Infrastructure/Tag/TagLoaderTests.cs
@@ -0,0 +1,24 @@
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZonyLrcTools.Cli.Infrastructure.Tag;
+
+namespace ZonyLrcTools.Tests.Infrastructure.Tag
+{
+ public class TagLoaderTests : TestBase
+ {
+ [Fact]
+ public async Task LoadTagAsync_Test()
+ {
+ var tagLoader = ServiceProvider.GetRequiredService();
+
+ tagLoader.ShouldNotBeNull();
+ var info = await tagLoader.LoadTagAsync(Path.Combine(Directory.GetCurrentDirectory(), "MusicFiles", "曾经艺也 - 荀彧(纯音乐版).mp3"));
+ info.ShouldNotBeNull();
+ info.Name.ShouldBe("荀彧(纯音乐版)");
+ info.Artist.ShouldBe("曾经艺也");
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/Infrastructure/Tag/TaglibTagInfoProviderTests.cs b/tests/ZonyLrcTools.Tests/Infrastructure/Tag/TaglibTagInfoProviderTests.cs
new file mode 100644
index 0000000..f03c63c
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/Infrastructure/Tag/TaglibTagInfoProviderTests.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZonyLrcTools.Cli.Infrastructure.Tag;
+
+namespace ZonyLrcTools.Tests.Infrastructure.Tag
+{
+ public class TaglibTagInfoProviderTests : TestBase
+ {
+ [Fact]
+ public async Task LoadAsync_Test()
+ {
+ var provider = ServiceProvider.GetRequiredService>()
+ .FirstOrDefault(p => p.Name == TaglibTagInfoProvider.ConstantName);
+
+ provider.ShouldNotBeNull();
+
+ var info = await provider.LoadAsync(Path.Combine(Directory.GetCurrentDirectory(), "MusicFiles", "曾经艺也 - 荀彧(纯音乐版).mp3"));
+ info.ShouldNotBeNull();
+ info.Name.ShouldBe("荀彧(纯音乐版)");
+ info.Artist.ShouldBe("曾经艺也");
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/MusicFiles/曾经艺也 - 荀彧(纯音乐版).mp3 b/tests/ZonyLrcTools.Tests/MusicFiles/曾经艺也 - 荀彧(纯音乐版).mp3
new file mode 100644
index 0000000..397b40e
Binary files /dev/null and b/tests/ZonyLrcTools.Tests/MusicFiles/曾经艺也 - 荀彧(纯音乐版).mp3 differ
diff --git a/tests/ZonyLrcTools.Tests/TestBase.cs b/tests/ZonyLrcTools.Tests/TestBase.cs
new file mode 100644
index 0000000..a8c00cd
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/TestBase.cs
@@ -0,0 +1,33 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using ZonyLrcTools.Cli.Commands;
+using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
+using ZonyLrcTools.Cli.Infrastructure.Extensions;
+
+namespace ZonyLrcTools.Tests
+{
+ public class TestBase
+ {
+ protected IServiceProvider ServiceProvider { get; private set; }
+ protected IServiceCollection ServiceCollection { get; private set; }
+
+ public TestBase()
+ {
+ ServiceCollection = BuildService();
+ BuildServiceProvider();
+ }
+
+ protected virtual IServiceCollection BuildService()
+ {
+ var service = new ServiceCollection();
+
+ service.BeginAutoDependencyInject();
+ service.ConfigureToolService();
+ service.ConfigureConfiguration();
+
+ return service;
+ }
+
+ protected virtual void BuildServiceProvider() => ServiceProvider = ServiceCollection.BuildServiceProvider();
+ }
+}
\ No newline at end of file
diff --git a/tests/ZonyLrcTools.Tests/ZonyLrcTools.Tests.csproj b/tests/ZonyLrcTools.Tests/ZonyLrcTools.Tests.csproj
new file mode 100644
index 0000000..6725306
--- /dev/null
+++ b/tests/ZonyLrcTools.Tests/ZonyLrcTools.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net5.0
+
+ false
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/zh_CN.md b/zh_CN.md
new file mode 100644
index 0000000..52c8698
--- /dev/null
+++ b/zh_CN.md
@@ -0,0 +1,50 @@
+## 简介
+ZonyLrcToolX 2.0 是一个基于 CEF 的跨平台歌词下载工具。
+
+🚧 当前版本正在开发当中。
+🚧 如果你想查看可以工作的代码,请切换到 1.0 分支。
+## 用法
+
+### 命令
+
+#### 文件扫描
+
+子命令为 `scan`,可用于扫描指定文件夹下的音乐文件数量(好像没什么卵用),下面我以 Windows 的可执行程序为例。
+
+```shell
+./ZonyLrcTools.Cli.exe scan -d|dir
+
+./ZonyLrcTools.cli.exe -h|--help
+```
+
+#### 歌曲下载
+
+子命令为 `download`,可用于下载歌词数据[^1]和专辑图像[^2],支持多个下载器[^3]进行下载。
+
+```shell
+./ZonyLrcTools.Cli.exe download -d|dir [-l|--lyric] [-a|--album] [-n|--number]
+
+./ZonyLrcTools.Cli.exe download -h|--help
+```
+
+### 配置文件
+
+程序的部分配置信息需要在 `appsettings.json` 进行更改,下面标注了各个配置的说明。
+
+| 属性 | 说明 | 示例值 |
+| ------------------------------------------------- | ------------------------------------------------------------ | ------------------------------- |
+| ToolOption.SupportFileExtensions | 允许扫描的歌曲文件后缀名,以 `;` 号隔开多个后缀。 | `*.mp3;*.flac` |
+| ToolOption.NetworkOptions.Enable | 是否启用 HTTP 网络代理服务,true 表示启用,false 表示禁用。 | false |
+| ToolOption.NetworkOptions.ProxyIp | HTTP 网络代理服务的 IP,在 `Enable` 为 false 时会忽略该属性值。 | 127.0.0.1 |
+| ToolOption.NetworkOptions.ProxyPort | HTTP 网络代理服务的 端口,在 `Enable` 为 false 时会忽略该属性值。 | 8080 |
+| TagInfoProviderOptions.FileNameRegularExpressions | 文件名 Tag 标签信息读取器使用,使用正则表达式匹配歌曲名和歌手,请使用命名分组编写正则表达式。 | (?'artist'.+)\\s-\\s(?'name'.+) |
+
+## 捐赠
+
+## 路线图
+
+- [ ] 支持跨平台的 CLI 工具。
+- [ ] 基于 Web GUI 的操作站点。
+- [ ] 支持插件系统(Lua 引擎)。
+
+[^1 ]: 哎是
\ No newline at end of file