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