feat: Reinitialize the Repository.

重新初始化仓库。
This commit is contained in:
Zony 2021-05-07 10:26:26 +08:00
parent a754f419b2
commit 8e1e61764c
78 changed files with 3329 additions and 22 deletions

235
.gitignore vendored
View File

@ -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
*.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/

21
LICENSE Normal file
View File

@ -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.

16
README.md Normal file
View File

@ -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).

41
ZonyLrcTools.sln Normal file
View File

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

View File

@ -0,0 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=QQ/@EntryIndexedValue">QQ</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Gour/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Zony/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@ -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<DownloadCommand> _logger;
private readonly IFileScanner _fileScanner;
private readonly ITagLoader _tagLoader;
private readonly IEnumerable<ILyricDownloader> _lyricDownloaderList;
private readonly IEnumerable<IAlbumDownloader> _albumDownloaderList;
private readonly ToolOptions _options;
public DownloadCommand(ILogger<DownloadCommand> logger,
IFileScanner fileScanner,
IOptions<ToolOptions> options,
ITagLoader tagLoader,
IEnumerable<ILyricDownloader> lyricDownloaderList,
IEnumerable<IAlbumDownloader> 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<string> CreateArgs() => new();
protected override async Task<int> 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<List<string>> 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<ImmutableList<MusicInfo>> LoadMusicInfoAsync(IReadOnlyCollection<string> 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<MusicInfo> 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<MusicInfo> 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
}
}

View File

@ -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<ScanCommand> _logger;
public ScanCommand(IFileScanner fileScanner,
IOptions<ToolOptions> options,
ILogger<ScanCommand> logger)
{
_fileScanner = fileScanner;
_logger = logger;
_options = options.Value;
}
[Option("-d|--dir", CommandOptionType.SingleValue, Description = "指定需要扫描的目录。")]
[DirectoryExists]
public string DirectoryPath { get; set; }
protected override async Task<int> 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<string> CreateArgs()
{
return new();
}
}
}

View File

@ -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<string> CreateArgs()
{
return new();
}
public static async Task<int> 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<int> 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<ToolCommand>();
services.ConfigureConfiguration();
services.ConfigureToolService();
})
.RunCommandLineApplicationAsync<ToolCommand>(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
}
}

View File

@ -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<string> CreateArgs();
protected virtual Task<int> OnExecuteAsync(CommandLineApplication app)
{
return Task.FromResult(0);
}
}
}

View File

@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using McMaster.Extensions.CommandLineUtils;
namespace ZonyLrcTools.Cli.Commands
{
/// <summary>
/// 工具类相关命令。
/// </summary>
[Command("util", Description = "提供常用的工具类功能。")]
public class UtilityCommand : ToolCommandBase
{
public override List<string> CreateArgs() => new();
protected override Task<int> OnExecuteAsync(CommandLineApplication app)
{
return base.OnExecuteAsync(app);
}
}
}

View File

@ -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
{
/// <summary>
/// 支持的音乐文件后缀集合,以 ; 进行分隔。
/// </summary>
public string SupportFileExtensions { get; set; }
/// <summary>
/// 歌词下载相关的配置信息。
/// </summary>
public LyricItemCollectionOption LyricOption { get; set; }
/// <summary>
/// 标签加载器的加载配置项。
/// </summary>
public TagInfoProviderOptions TagInfoProviderOptions { get; set; }
/// <summary>
/// 网络代理相关的配置信息。
/// </summary>
public NetworkOptions NetworkOptions { get; set; }
}
}

View File

@ -0,0 +1,23 @@
using System.Threading.Tasks;
namespace ZonyLrcTools.Cli.Infrastructure.Album
{
/// <summary>
/// 专辑封面下载器,用于匹配并下载歌曲的专辑封面。
/// </summary>
public interface IAlbumDownloader
{
/// <summary>
/// 下载器的名称。
/// </summary>
string DownloaderName { get; }
/// <summary>
/// 下载专辑封面。
/// </summary>
/// <param name="songName">歌曲的名称。</param>
/// <param name="artist">歌曲的作者。</param>
/// <returns>专辑封面的图像数据。</returns>
ValueTask<byte[]> DownloadAsync(string songName, string artist);
}
}

View File

@ -0,0 +1,18 @@
namespace ZonyLrcTools.Cli.Infrastructure.Album
{
/// <summary>
/// 定义了程序默认提供的专辑图像下载器。
/// </summary>
public static class InternalAlbumDownloaderNames
{
/// <summary>
/// 网易云音乐专辑图像下载器。
/// </summary>
public const string NetEase = nameof(NetEase);
/// <summary>
/// QQ 音乐专辑图像下载器。
/// </summary>
public const string QQ = nameof(QQ);
}
}

View File

@ -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<HttpRequestMessage> _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<byte[]> DownloadAsync(string songName, string artist)
{
var requestParameter = new SongSearchRequest(songName, artist);
var searchResult = await _warpHttpClient.PostAsync<SongSearchResponse>(
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<string>();
if (string.IsNullOrEmpty(url))
{
throw new ErrorCodeException(ErrorCodes.TheReturnValueIsIllegal);
}
return await new HttpClient().GetByteArrayAsync(new Uri(url));
}
}
}

View File

@ -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<HttpRequestMessage> _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<byte[]> DownloadAsync(string songName, string artist)
{
var requestParameter = new SongSearchRequest(songName, artist);
var searchResult = await _warpHttpClient.GetAsync<SongSearchResponse>(
SearchApi,
requestParameter);
return null;
}
}
}

View File

@ -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
{
/// <summary>
/// 开始进行自动依赖注入。
/// </summary>
/// <remarks>
/// 会根据实现了 <see cref="ITransientDependency"/> 或 <see cref="ISingletonDependency"/> 的接口进行自动注入。
/// </remarks>
/// <param name="services">服务定义集合。</param>
/// <typeparam name="TAssemblyType">需要注入的任意类型。</typeparam>
public static IServiceCollection BeginAutoDependencyInject<TAssemblyType>(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<Type> GetDefaultExposedTypes(Type type)
{
var serviceTypes = new List<Type>();
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);
}
}
}

View File

@ -0,0 +1,9 @@
namespace ZonyLrcTools.Cli.Infrastructure.DependencyInject
{
/// <summary>
/// 继承了本接口的类都会以单例的形式注入到 IoC 容器当中。
/// </summary>
public interface ISingletonDependency
{
}
}

View File

@ -0,0 +1,9 @@
namespace ZonyLrcTools.Cli.Infrastructure.DependencyInject
{
/// <summary>
/// 继承了本接口的类都会以瞬时的形式注入到 IoC 容器当中。
/// </summary>
public interface ITransientDependency
{
}
}

View File

@ -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
{
/// <summary>
/// Service 注入的扩展方法。
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 配置工具会用到的服务。
/// </summary>
public static IServiceCollection ConfigureToolService(this IServiceCollection services)
{
if (services == null)
{
return null;
}
services.AddHttpClient(DefaultWarpHttpClient.HttpClientNameConstant)
.ConfigurePrimaryHttpMessageHandler(provider =>
{
var option = provider.GetRequiredService<IOptions<ToolOptions>>().Value;
return new HttpClientHandler
{
UseProxy = option.NetworkOptions.Enable,
Proxy = new WebProxy($"{option.NetworkOptions.ProxyIp}:{option.NetworkOptions.ProxyPort}")
};
});
return services;
}
/// <summary>
/// 配置工具关联的配置信息(<see cref="IConfiguration"/>)。
/// </summary>
public static IServiceCollection ConfigureConfiguration(this IServiceCollection services)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
services.Configure<ToolOptions>(configuration.GetSection("ToolOption"));
return services;
}
}
}

View File

@ -0,0 +1,26 @@
using System;
namespace ZonyLrcTools.Cli.Infrastructure.Exceptions
{
/// <summary>
/// 带错误码的异常实现。
/// </summary>
public class ErrorCodeException : Exception
{
public int ErrorCode { get; }
public object AttachObject { get; }
/// <summary>
/// 构建一个新的 <see cref="ErrorCodeException"/> 对象。
/// </summary>
/// <param name="errorCode">错误码,参考 <see cref="ErrorCodes"/> 类的定义。</param>
/// <param name="message">错误信息。</param>
/// <param name="attachObj">附加的对象数据。</param>
public ErrorCodeException(int errorCode, string message = null, object attachObj = null) : base(message)
{
ErrorCode = errorCode;
AttachObject = attachObj;
}
}
}

View File

@ -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
{
/// <summary>
/// 错误码相关的帮助类。
/// </summary>
public static class ErrorCodeHelper
{
public static Dictionary<int, string> ErrorMessages { get; }
static ErrorCodeHelper()
{
ErrorMessages = new Dictionary<int, string>();
}
/// <summary>
/// 从 err_msg.json 文件加载错误信息。
/// </summary>
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<JProperty>().ToList()
.ForEach(m => ErrorMessages.Add(int.Parse(m.Name), m.Value.Value<string>()));
}
public static string GetMessage(int errorCode) => ErrorMessages[errorCode];
}
}

View File

@ -0,0 +1,86 @@
namespace ZonyLrcTools.Cli.Infrastructure.Exceptions
{
/// <summary>
/// 错误码。
/// </summary>
public static class ErrorCodes
{
#region > <
/// <summary>
/// 文本: 待搜索的后缀不能为空。
/// </summary>
public const int FileSuffixIsEmpty = 10001;
/// <summary>
/// 文本: 需要扫描的目录不存在,请确认路径是否正确。。
/// </summary>
public const int DirectoryNotExist = 10002;
/// <summary>
/// 文本: 不能获取文件的后缀信息。
/// </summary>
public const int UnableToGetTheFileExtension = 10003;
/// <summary>
/// 文本: 没有扫描到任何音乐文件。
/// </summary>
public const int NoFilesWereScanned = 10004;
#endregion
#region > <
/// <summary>
/// 文本: 扫描文件时出现了错误。
/// </summary>
public const int ScanFileError = 50001;
/// <summary>
/// 文本: 歌曲名称或歌手名称均为空,无法进行搜索。
/// </summary>
public const int SongNameAndArtistIsNull = 50002;
/// <summary>
/// 文本: 歌曲名称不能为空,无法进行搜索。
/// </summary>
public const int SongNameIsNull = 50003;
/// <summary>
/// 文本: 下载器没有搜索到对应的歌曲信息。
/// </summary>
public const int NoMatchingSong = 50004;
/// <summary>
/// 文本: 下载请求的返回值不合法,可能是服务端故障。
/// </summary>
public const int TheReturnValueIsIllegal = 50005;
/// <summary>
/// 文本: 标签信息读取器为空,无法解析音乐 Tag 信息。
/// </summary>
public const int LoadTagInfoProviderError = 50006;
/// <summary>
/// 文本: TagLib 标签读取器出现了预期之外的异常。
/// </summary>
public const int TagInfoProviderLoadInfoFailed = 50007;
/// <summary>
/// 文本: 服务接口限制,无法进行请求,请尝试使用代理服务器。
/// </summary>
public const int ServiceUnavailable = 50008;
/// <summary>
/// 文本: 对目标服务器执行 Http 请求失败。
/// </summary>
public const int HttpRequestFailed = 50009;
/// <summary>
/// 文本: Http 请求的结果反序列化为 Json 失败。
/// </summary>
public const int HttpResponseConvertJsonFailed = 50010;
#endregion
}
}

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace ZonyLrcTools.Cli.Infrastructure.Extensions
{
/// <summary>
/// Linq 相关的扩展方法。
/// </summary>
public static class LinqHelper
{
/// <summary>
/// 使用 Lambda 的形式遍历指定的迭代器。
/// </summary>
/// <param name="items">等待遍历的迭代器实例。</param>
/// <param name="action">遍历时需要执行的操作。</param>
public static void Foreach<T>(this IEnumerable<T> items, Action<T> action)
{
foreach (var item in items)
{
action(item);
}
}
}
}

View File

@ -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
{
/// <summary>
/// 日志记录相关的扩展方法。
/// </summary>
public static class LoggerExtensions
{
/// <summary>
/// 使用 <see cref="LogLevel.Warning"/> 级别打印错误日志,并记录异常堆栈。
/// </summary>
/// <param name="logger">日志记录器实例。</param>
/// <param name="errorCode">错误码,具体请参考 <see cref="ErrorCodes"/> 类的定义。</param>
/// <param name="e">异常实例,可为空。</param>
public static void LogWarningWithErrorCode(this ILogger logger, int errorCode, Exception e = null)
{
logger.LogWarning($"错误代码: {errorCode}\n堆栈异常: {e?.StackTrace}");
}
/// <summary>
/// 使用 <see cref="LogLevel.Warning"/> 级别打印错误日志,并记录异常堆栈。
/// </summary>
/// <param name="logger">日志记录器的实例。</param>
/// <param name="exception">错误码异常实例。</param>
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());
}
}
}

View File

@ -0,0 +1,26 @@
using System;
namespace ZonyLrcTools.Cli.Infrastructure.Extensions
{
/// <summary>
/// 字符串处理相关的工具方法。
/// </summary>
public static class StringHelper
{
/// <summary>
/// 截断指定字符串末尾的匹配字串。
/// </summary>
/// <param name="string">待截断的字符串。</param>
/// <param name="trimEndStr">需要在末尾截断的字符串。</param>
/// <returns>截断成功的字符串实例。</returns>
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;
}
}
}

View File

@ -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<FileScanner> Logger { get; set; }
public FileScanner()
{
Logger = NullLogger<FileScanner>.Instance;
}
public Task<List<FileScannerResult>> ScanAsync(string path, IEnumerable<string> extensions)
{
if (extensions == null || !extensions.Any())
{
throw new ErrorCodeException(ErrorCodes.FileSuffixIsEmpty);
}
if (!Directory.Exists(path))
{
throw new ErrorCodeException(ErrorCodes.DirectoryNotExist);
}
var files = new List<FileScannerResult>();
foreach (var extension in extensions)
{
var tempResult = new ConcurrentBag<string>();
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<string> 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);
}
}
}
}

View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace ZonyLrcTools.Cli.Infrastructure.IO
{
/// <summary>
/// 文件扫描结果对象。
/// </summary>
public class FileScannerResult
{
/// <summary>
/// 当前路径对应的扩展名。
/// </summary>
public string ExtensionName { get; }
/// <summary>
/// 当前扩展名下面的所有文件路径集合。
/// </summary>
public List<string> FilePaths { get; }
/// <summary>
/// 构造一个新的 <see cref="FileScannerResult"/> 对象。
/// </summary>
/// <param name="extensionName">当前路径对应的扩展名。</param>
/// <param name="filePaths">当前扩展名下面的所有文件路径集合。</param>
public FileScannerResult(string extensionName, List<string> filePaths)
{
ExtensionName = extensionName;
FilePaths = filePaths;
}
}
}

View File

@ -0,0 +1,41 @@
using System.IO;
using System.Threading.Tasks;
namespace ZonyLrcTools.Cli.Infrastructure.IO
{
public static class FileStreamExtensions
{
/// <summary>
/// 将字节数据通过缓冲区的形式,写入到文件当中。
/// </summary>
/// <param name="fileStream">需要写入数据的文件流。</param>
/// <param name="data">等待写入的数据。</param>
/// <param name="bufferSize">缓冲区大小。</param>
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();
}
}
}
}

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace ZonyLrcTools.Cli.Infrastructure.IO
{
/// <summary>
/// 音乐文件扫描器,用于扫描音乐文件。
/// </summary>
public interface IFileScanner
{
/// <summary>
/// 扫描指定路径下面的歌曲文件。
/// </summary>
/// <param name="path">等待扫描的路径。</param>
/// <param name="extensions">需要搜索的歌曲后缀名。</param>
Task<List<FileScannerResult>> ScanAsync(string path, IEnumerable<string> extensions);
}
}

View File

@ -0,0 +1,23 @@
using System.Threading.Tasks;
namespace ZonyLrcTools.Cli.Infrastructure.Lyric
{
/// <summary>
/// 歌词数据下载器,用于匹配并下载歌曲的歌词。
/// </summary>
public interface ILyricDownloader
{
/// <summary>
/// 下载歌词数据。
/// </summary>
/// <param name="songName">歌曲的名称。</param>
/// <param name="artist">歌曲的作者。</param>
/// <returns>歌曲的歌词数据对象。</returns>
ValueTask<LyricItemCollection> DownloadAsync(string songName, string artist);
/// <summary>
/// 下载器的名称。
/// </summary>
string DownloaderName { get; }
}
}

View File

@ -0,0 +1,16 @@
namespace ZonyLrcTools.Cli.Infrastructure.Lyric
{
/// <summary>
/// 构建 <see cref="LyricItemCollection"/> 对象的工厂。
/// </summary>
public interface ILyricItemCollectionFactory
{
/// <summary>
/// 根据指定的歌曲数据构建新的 <see cref="LyricItemCollection"/> 实例。
/// </summary>
/// <param name="sourceLyric">原始歌词数据。</param>
/// <param name="translateLyric">翻译歌词数据。</param>
/// <returns>构建完成的 <see cref="LyricItemCollection"/> 对象。</returns>
LyricItemCollection Build(string sourceLyric, string translateLyric = null);
}
}

View File

@ -0,0 +1,7 @@
namespace ZonyLrcTools.Cli.Infrastructure.Lyric
{
public interface ILyricTextResolver
{
LyricItemCollection Resolve(string lyricText);
}
}

View File

@ -0,0 +1,23 @@
namespace ZonyLrcTools.Cli.Infrastructure.Lyric
{
/// <summary>
/// 定义了程序默认提供的歌词下载器。
/// </summary>
public static class InternalLyricDownloaderNames
{
/// <summary>
/// 网易云音乐歌词下载器。
/// </summary>
public const string NetEase = nameof(NetEase);
/// <summary>
/// QQ 音乐歌词下载器。
/// </summary>
public const string QQ = nameof(QQ);
/// <summary>
/// 酷狗音乐歌词下载器。
/// </summary>
public const string KuGou = nameof(KuGou);
}
}

View File

@ -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<byte[]> DownloadDataAsync(LyricDownloaderArgs args)
{
throw new System.NotImplementedException();
}
protected override ValueTask<LyricItemCollection> GenerateLyricAsync(byte[] data)
{
throw new System.NotImplementedException();
}
}
}

View File

@ -0,0 +1,23 @@
namespace ZonyLrcTools.Cli.Infrastructure.Lyric
{
/// <summary>
/// 换行符格式定义。
/// </summary>
public static class LineBreakType
{
/// <summary>
/// Windows 系统。
/// </summary>
public const string Windows = "\r\n";
/// <summary>
/// macOS 系统。
/// </summary>
public const string MacOs = "\r";
/// <summary>
/// UNIX 系统(Linux)。
/// </summary>
public const string Unix = "\n";
}
}

View File

@ -0,0 +1,41 @@
using System.Threading.Tasks;
using ZonyLrcTools.Cli.Infrastructure.DependencyInject;
using ZonyLrcTools.Cli.Infrastructure.Exceptions;
namespace ZonyLrcTools.Cli.Infrastructure.Lyric
{
/// <summary>
/// 歌词下载器的基类,定义了歌词下载器的常规逻辑。
/// </summary>
public abstract class LyricDownloader : ILyricDownloader, ITransientDependency
{
public abstract string DownloaderName { get; }
public virtual async ValueTask<LyricItemCollection> 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<byte[]> DownloadDataAsync(LyricDownloaderArgs args);
protected abstract ValueTask<LyricItemCollection> GenerateLyricAsync(byte[] data);
}
}

View File

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

View File

@ -0,0 +1,126 @@
using System;
using System.Text.RegularExpressions;
namespace ZonyLrcTools.Cli.Infrastructure.Lyric
{
/// <summary>
/// 每一行歌词的对象。
/// </summary>
public class LyricItem : IComparable<LyricItem>
{
/// <summary>
/// 原始时间轴,格式类似于 [01:55.12]。
/// </summary>
public string OriginalTimeline => $"[{Minute:00}:{Second:00.00}]";
/// <summary>
/// 歌词文本数据。
/// </summary>
public string LyricText { get; }
/// <summary>
/// 歌词所在的时间(分)。
/// </summary>
public int Minute { get; }
/// <summary>
/// 歌词所在的时间(秒)。
/// </summary>
public double Second { get; }
/// <summary>
/// 排序分数,用于一组歌词当中的排序权重。<br/>
/// </summary>
public double SortScore => Minute * 60 + Second;
/// <summary>
/// 构建新的 <see cref="LyricItem"/> 对象。
/// </summary>
/// <param name="lyricText">原始的 Lyric 歌词。</param>
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;
}
/// <summary>
/// 构造新的 <see cref="LyricItem"/> 对象。
/// </summary>
/// <param name="minute">歌词所在的时间(分)。</param>
/// <param name="second">歌词所在的时间(秒)。</param>
/// <param name="lyricText">歌词文本数据。</param>
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}";
}
}

View File

@ -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
{
/// <summary>
/// 歌词数据,包含多条歌词对象(<see cref="LyricItem"/>)。
/// </summary>
public class LyricItemCollection : List<LyricItem>
{
/// <summary>
/// 是否为纯音乐,当没有任何歌词数据的时候,属性值为 True。
/// </summary>
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;
}
/// <summary>
/// 根据歌词集合构建一个索引状态字典。
/// </summary>
/// <remarks>
/// 这个索引字典用于标识每个索引的歌词是否被处理,为 True 则为已处理,为 False 为未处理。
/// </remarks>
/// <param name="items">等待构建的歌词集合实例。</param>
private static Dictionary<int, bool> 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");
}
}
}

View File

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

View File

@ -0,0 +1,17 @@
namespace ZonyLrcTools.Cli.Infrastructure.Lyric
{
public class LyricItemCollectionOption
{
/// <summary>
/// 双语歌词是否合并为一行。
/// </summary>
public bool IsOneLine { get; set; } = false;
/// <summary>
/// 换行符格式,取值来自 <see cref="LineBreakType"/> 常量类。
/// </summary>
public string LineBreak { get; set; } = LineBreakType.Windows;
public static readonly LyricItemCollectionOption NullInstance = new();
}
}

View File

@ -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;
}
/// <summary>
/// 请求的操作系统。
/// </summary>
[JsonProperty("os")]
public string OS { get; }
/// <summary>
/// 歌曲的 SID 值。
/// </summary>
[JsonProperty("id")]
public long Id { get; }
[JsonProperty("lv")] public int Lv { get; }
[JsonProperty("kv")] public int Kv { get; }
[JsonProperty("tv")] public int Tv { get; }
}
}

View File

@ -0,0 +1,45 @@
using Newtonsoft.Json;
namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
{
public class GetLyricResponse
{
/// <summary>
/// 原始的歌词。
/// </summary>
[JsonProperty("lrc")]
public InnerLyric OriginalLyric { get; set; }
/// <summary>
/// 卡拉 OK 歌词。
/// </summary>
[JsonProperty("klyric")]
public InnerLyric KaraokeLyric { get; set; }
/// <summary>
/// 如果存在翻译歌词,则本项内容为翻译歌词。
/// </summary>
[JsonProperty("tlyric")]
public InnerLyric TranslationLyric { get; set; }
/// <summary>
/// 状态码。
/// </summary>
[JsonProperty("code")]
public string StatusCode { get; set; }
}
/// <summary>
/// 歌词 JSON 类型
/// </summary>
public class InnerLyric
{
[JsonProperty("version")] public string Version { get; set; }
/// <summary>
/// 具体的歌词数据。
/// </summary>
[JsonProperty("lyric")]
public string Text { get; set; }
}
}

View File

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

View File

@ -0,0 +1,59 @@
using System.Text;
using System.Web;
using Newtonsoft.Json;
namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
{
public class SongSearchRequest
{
/// <summary>
/// CSRF 标识,一般为空即可,接口不会进行校验。
/// </summary>
[JsonProperty("csrf_token")]
public string CsrfToken { get; set; }
/// <summary>
/// 需要搜索的内容,一般是歌曲名 + 歌手的格式。
/// </summary>
[JsonProperty("s")]
public string SearchKey { get; set; }
/// <summary>
/// 页偏移量。
/// </summary>
[JsonProperty("offset")]
public int Offset { get; set; }
/// <summary>
/// 搜索类型。
/// </summary>
[JsonProperty("type")]
public int Type { get; set; }
/// <summary>
/// 是否获取全部的搜索结果。
/// </summary>
[JsonProperty("total")]
public bool IsTotal { get; set; }
/// <summary>
/// 每页的最大结果容量。
/// </summary>
[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);
}
}
}

View File

@ -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<SongModel> SongItems { get; set; }
[JsonProperty("songCount")] public int SongCount { get; set; }
}
public class SongModel
{
/// <summary>
/// 歌曲的名称。
/// </summary>
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>
/// 歌曲的 Sid (Song Id)。
/// </summary>
[JsonProperty("id")]
public int Id { get; set; }
/// <summary>
/// 歌曲的演唱者。
/// </summary>
[JsonProperty("artists")]
public IList<SongArtistModel> Artists { get; set; }
/// <summary>
/// 歌曲的专辑信息。
/// </summary>
[JsonProperty("album")]
public SongAlbumModel Album { get; set; }
}
public class SongArtistModel
{
/// <summary>
/// 歌手/艺术家的名称。
/// </summary>
[JsonProperty("name")]
public string Name { get; set; }
}
public class SongAlbumModel
{
/// <summary>
/// 专辑的名称。
/// </summary>
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>
/// 专辑图像的 Url 地址。
/// </summary>
[JsonProperty("img1v1Url")]
public string PictureUrl { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace ZonyLrcTools.Cli.Infrastructure.Lyric.NetEase.JsonModel
{
public static class SongSearchResponseStatusCode
{
public const int Success = 200;
}
}

View File

@ -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<byte[]> DownloadDataAsync(LyricDownloaderArgs args)
{
var searchResult = await _warpHttpClient.PostAsync<SongSearchResponse>(
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<LyricItemCollection> GenerateLyricAsync(byte[] data)
{
await ValueTask.CompletedTask;
var json = JsonConvert.DeserializeObject<GetLyricResponse>(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);
}
}
}
}

View File

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

View File

@ -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<QQMusicInnerSongItem> SongItems { get; set; }
}
public class QQMusicInnerSongItem
{
[JsonProperty("mid")]
public string SongId { get; set; }
}
public class AlbumInfo
{
[JsonProperty("id")]
public long Id { get; set; }
}
}

View File

@ -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<byte[]> DownloadDataAsync(LyricDownloaderArgs args)
{
throw new System.NotImplementedException();
}
protected override ValueTask<LyricItemCollection> GenerateLyricAsync(byte[] data)
{
throw new System.NotImplementedException();
}
}
}

View File

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

View File

@ -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<DefaultWarpHttpClient> _logger;
public const string HttpClientNameConstant = "WarpClient";
public DefaultWarpHttpClient(IHttpClientFactory httpClientFactory,
ILogger<DefaultWarpHttpClient> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async ValueTask<string> PostAsync(string url,
object parameters = null,
bool isQueryStringParam = false,
Action<HttpRequestMessage> 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<TResponse> PostAsync<TResponse>(string url,
object parameters = null,
bool isQueryStringParam = false,
Action<HttpRequestMessage> 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<TResponse>(responseString);
if (responseObj != null) return responseObj;
throw throwException;
}
catch (JsonSerializationException)
{
throw throwException;
}
}
public async ValueTask<string> GetAsync(string url,
object parameters = null,
Action<HttpRequestMessage> 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<TResponse> GetAsync<TResponse>(string url,
object parameters = null,
Action<HttpRequestMessage> requestOption = null)
{
var responseStr = await GetAsync(url, parameters, requestOption);
var throwException = new ErrorCodeException(ErrorCodes.HttpResponseConvertJsonFailed, attachObj: new {parameters, responseStr});
try
{
var responseObj = JsonConvert.DeserializeObject<TResponse>(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<JsonPropertyAttribute>();
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);
}
}
}

View File

@ -0,0 +1,63 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
namespace ZonyLrcTools.Cli.Infrastructure.Network
{
/// <summary>
/// 基于 <see cref="IHttpClientFactory"/> 封装的 HTTP 请求客户端。
/// </summary>
public interface IWarpHttpClient
{
/// <summary>
/// 根据指定的配置执行 POST 请求,并以 <see cref="string"/> 作为返回值。
/// </summary>
/// <param name="url">请求的 URL 地址。</param>
/// <param name="parameters">请求的参数。</param>
/// <param name="isQueryStringParam">是否以 QueryString 形式携带参数。</param>
/// <param name="requestOption">请求时的配置动作。</param>
/// <returns>服务端的响应结果。</returns>
ValueTask<string> PostAsync(string url,
object parameters = null,
bool isQueryStringParam = false,
Action<HttpRequestMessage> requestOption = null);
/// <summary>
/// 根据指定的配置执行 POST 请求,并将结果反序列化为 <see cref="TResponse"/> 对象。
/// </summary>
/// <param name="url">请求的 URL 地址。</param>
/// <param name="parameters">请求的参数。</param>
/// <param name="isQueryStringParam">是否以 QueryString 形式携带参数。</param>
/// <param name="requestOption">请求时的配置动作。</param>
/// <typeparam name="TResponse">需要将响应结果反序列化的目标类型。</typeparam>
/// <returns>服务端的响应结果。</returns>
ValueTask<TResponse> PostAsync<TResponse>(string url,
object parameters = null,
bool isQueryStringParam = false,
Action<HttpRequestMessage> requestOption = null);
/// <summary>
/// 根据指定的配置执行 GET 请求,并以 <see cref="string"/> 作为返回值。
/// </summary>
/// <param name="url">请求的 URL 地址。</param>
/// <param name="parameters">请求的参数。</param>
/// <param name="requestOption">请求时的配置动作。</param>
/// <returns>服务端的响应结果。</returns>
ValueTask<string> GetAsync(string url,
object parameters = null,
Action<HttpRequestMessage> requestOption = null);
/// <summary>
/// 根据指定的配置执行 GET 请求,并将结果反序列化为 <see cref="TResponse"/> 对象。
/// </summary>
/// <param name="url">请求的 URL 地址。</param>
/// <param name="parameters">请求的参数。</param>
/// <param name="requestOption">请求时的配置动作。</param>
/// <typeparam name="TResponse">需要将响应结果反序列化的目标类型。</typeparam>
/// <returns>服务端的响应结果。</returns>
ValueTask<TResponse> GetAsync<TResponse>(
string url,
object parameters = null,
Action<HttpRequestMessage> requestOption = null);
}
}

View File

@ -0,0 +1,23 @@
namespace ZonyLrcTools.Cli.Infrastructure.Network
{
/// <summary>
/// 工具网络相关的设定。
/// </summary>
public class NetworkOptions
{
/// <summary>
/// 是否启用了网络代理功能。
/// </summary>
public bool Enable { get; set; }
/// <summary>
/// 代理服务器的 Ip。
/// </summary>
public string ProxyIp { get; set; }
/// <summary>
/// 代理服务器的 端口。
/// </summary>
public int ProxyPort { get; set; }
}
}

View File

@ -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<ITagInfoProvider> TagInfoProviders;
public DefaultTagLoader(IEnumerable<ITagInfoProvider> tagInfoProviders)
{
TagInfoProviders = tagInfoProviders;
}
public virtual async ValueTask<MusicInfo> 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;
}
}
}

View File

@ -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<ToolOptions> options)
{
Options = options.Value;
}
public async ValueTask<MusicInfo> 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);
}
}
}

View File

@ -0,0 +1,13 @@
using System.Threading.Tasks;
namespace ZonyLrcTools.Cli.Infrastructure.Tag
{
public interface ITagInfoProvider
{
int Priority { get; }
string Name { get; }
ValueTask<MusicInfo> LoadAsync(string filePath);
}
}

View File

@ -0,0 +1,14 @@
using System.Threading.Tasks;
namespace ZonyLrcTools.Cli.Infrastructure.Tag
{
public interface ITagLoader
{
/// <summary>
/// 加载歌曲的标签信息。
/// </summary>
/// <param name="filePath">歌曲文件的路径。</param>
/// <returns>加载完成的歌曲信息。</returns>
ValueTask<MusicInfo> LoadTagAsync(string filePath);
}
}

View File

@ -0,0 +1,10 @@
namespace ZonyLrcTools.Cli.Infrastructure.Tag
{
public class TagInfoProviderOptions
{
/// <summary>
/// 用于从文件名当中提取歌曲名、歌手名。
/// </summary>
public string FileNameRegularExpressions { get; set; }
}
}

View File

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

View File

@ -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<Task> 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<T> RunAsync<T>(Func<Task<T>> 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);
}
}
}

View File

@ -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 失败。"
}
}

View File

@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="3.1.0" />
<PackageReference Include="McMaster.Extensions.Hosting.CommandLine" Version="3.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Refit" Version="6.0.38" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.38" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.0.38" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
<PackageReference Include="TagLibSharp" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<None Remove="appsettings.json" />
<Content Include="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<None Remove="Resources\error_msg.json" />
<Content Include="Resources\error_msg.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Folder Include="Infrastructure\Network\Exceptions" />
</ItemGroup>
</Project>

View File

@ -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'.+)"
}
}
}

19
src/ZonyLrcTools.Cli/publish.sh Executable file
View File

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

View File

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

View File

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

View File

@ -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("需要扫描的目录不存在,请确认路径是否正确。");
}
}
}

View File

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

View File

@ -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<IWarpHttpClient>();
var response = await client.PostAsync(@"https://www.baidu.com");
response.ShouldNotBeNull();
response.ShouldContain("百度");
}
[Fact]
public async Task GetAsync_Test()
{
var client = ServiceProvider.GetRequiredService<IWarpHttpClient>();
var response = await client.GetAsync(@"https://www.baidu.com");
response.ShouldNotBeNull();
response.ShouldContain("百度");
}
[Fact]
public async Task GetAsyncWithProxy_Test()
{
var option = ServiceProvider.GetRequiredService<IOptions<ToolOptions>>();
option.Value.NetworkOptions.ProxyIp = "127.0.0.1";
option.Value.NetworkOptions.ProxyPort = 4780;
var client = ServiceProvider.GetRequiredService<IWarpHttpClient>();
var response = await client.GetAsync(@"https://www.baidu.com");
response.ShouldNotBeNull();
response.ShouldContain("百度");
}
}
}

View File

@ -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<IEnumerable<ITagInfoProvider>>()
.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("曾经艺也");
}
}
}

View File

@ -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<ITagLoader>();
tagLoader.ShouldNotBeNull();
var info = await tagLoader.LoadTagAsync(Path.Combine(Directory.GetCurrentDirectory(), "MusicFiles", "曾经艺也 - 荀彧(纯音乐版).mp3"));
info.ShouldNotBeNull();
info.Name.ShouldBe("荀彧(纯音乐版)");
info.Artist.ShouldBe("曾经艺也");
}
}
}

View File

@ -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<IEnumerable<ITagInfoProvider>>()
.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("曾经艺也");
}
}
}

View File

@ -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<ToolCommand>();
service.ConfigureToolService();
service.ConfigureConfiguration();
return service;
}
protected virtual void BuildServiceProvider() => ServiceProvider = ServiceCollection.BuildServiceProvider();
}
}

View File

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
<PackageReference Include="Shouldly" Version="4.0.3" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="1.3.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZonyLrcTools.Cli\ZonyLrcTools.Cli.csproj" />
</ItemGroup>
<ItemGroup>
<None Remove="MusicFiles\曾经艺也 - 荀彧(纯音乐版).mp3" />
<Content Include="MusicFiles\曾经艺也 - 荀彧(纯音乐版).mp3">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

50
zh_CN.md Normal file
View File

@ -0,0 +1,50 @@
## 简介
ZonyLrcToolX 2.0 是一个基于 CEF 的跨平台歌词下载工具。
🚧 当前版本正在开发当中。
🚧 如果你想查看可以工作的代码,请切换到 1.0 分支。
## 用法
### 命令
#### 文件扫描
子命令为 `scan`,可用于扫描指定文件夹下的音乐文件数量(好像没什么卵用),下面我以 Windows 的可执行程序为例。
```shell
./ZonyLrcTools.Cli.exe scan -d|dir <WAIT_SCAN_DIRECTORY>
./ZonyLrcTools.cli.exe -h|--help
```
#### 歌曲下载
子命令为 `download`,可用于下载歌词数据[^1]和专辑图像[^2],支持多个下载器[^3]进行下载。
```shell
./ZonyLrcTools.Cli.exe download -d|dir <WAIT_SCAN_DIRECTORY> [-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 ]: 哎是