From 2e8d15ee7753ab9d833c947b191e4547ce97c01f Mon Sep 17 00:00:00 2001
From: ALIve114514awa <300349280+ALIve114514awa@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:20:02 +0800
Subject: [PATCH 01/28] feat(dotnet-windows): add independent .NET 8 + Avalonia
implementation reference
- Add NeriPlayer.Windows (.NET 8 + Avalonia) as an independent technology
stack reference under dotnet-windows/, parallel to the official Tauri
(Rust + Vue) implementation
- Does not participate in the root pnpm/cargo build system
- Includes solution skeleton, EF Core-ready data layer, playback engine
interfaces, core data models and unit tests
---
dotnet-windows/.gitignore | 488 ++++++++++++++++++
dotnet-windows/Directory.Build.props | 8 +
dotnet-windows/Directory.Packages.props | 40 ++
dotnet-windows/NeriPlayer.Windows.sln | 78 +++
dotnet-windows/README.md | 44 ++
dotnet-windows/src/NeriPlayer.App/App.axaml | 10 +
.../src/NeriPlayer.App/App.axaml.cs | 23 +
.../src/NeriPlayer.App/AppStartup.cs | 26 +
.../src/NeriPlayer.App/MainWindow.axaml | 9 +
.../src/NeriPlayer.App/MainWindow.axaml.cs | 11 +
.../src/NeriPlayer.App/NeriPlayer.App.csproj | 34 ++
dotnet-windows/src/NeriPlayer.App/Program.cs | 21 +
.../src/NeriPlayer.App/app.manifest | 18 +
.../src/NeriPlayer.Background/Class1.cs | 6 +
.../NeriPlayer.Background.csproj | 19 +
dotnet-windows/src/NeriPlayer.Core/Class1.cs | 6 +
.../src/NeriPlayer.Core/Logging/AppLogger.cs | 21 +
.../NeriPlayer.Core/NeriPlayer.Core.csproj | 18 +
.../Player/Model/SongIdentity.cs | 35 ++
.../NeriPlayer.Core/Player/Model/SongItem.cs | 42 ++
dotnet-windows/src/NeriPlayer.Data/Class1.cs | 6 +
.../NeriPlayer.Data/NeriPlayer.Data.csproj | 22 +
dotnet-windows/src/NeriPlayer.UI/Class1.cs | 6 +
.../src/NeriPlayer.UI/NeriPlayer.UI.csproj | 20 +
.../NeriPlayer.Api.Tests.csproj | 28 +
.../tests/NeriPlayer.Api.Tests/UnitTest1.cs | 10 +
.../NeriPlayer.Core.Tests.csproj | 27 +
.../SongIdentityTests.cs | 43 ++
.../tests/NeriPlayer.Core.Tests/UnitTest1.cs | 10 +
.../NeriPlayer.Data.Tests.csproj | 28 +
.../tests/NeriPlayer.Data.Tests/UnitTest1.cs | 10 +
31 files changed, 1167 insertions(+)
create mode 100644 dotnet-windows/.gitignore
create mode 100644 dotnet-windows/Directory.Build.props
create mode 100644 dotnet-windows/Directory.Packages.props
create mode 100644 dotnet-windows/NeriPlayer.Windows.sln
create mode 100644 dotnet-windows/README.md
create mode 100644 dotnet-windows/src/NeriPlayer.App/App.axaml
create mode 100644 dotnet-windows/src/NeriPlayer.App/App.axaml.cs
create mode 100644 dotnet-windows/src/NeriPlayer.App/AppStartup.cs
create mode 100644 dotnet-windows/src/NeriPlayer.App/MainWindow.axaml
create mode 100644 dotnet-windows/src/NeriPlayer.App/MainWindow.axaml.cs
create mode 100644 dotnet-windows/src/NeriPlayer.App/NeriPlayer.App.csproj
create mode 100644 dotnet-windows/src/NeriPlayer.App/Program.cs
create mode 100644 dotnet-windows/src/NeriPlayer.App/app.manifest
create mode 100644 dotnet-windows/src/NeriPlayer.Background/Class1.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Background/NeriPlayer.Background.csproj
create mode 100644 dotnet-windows/src/NeriPlayer.Core/Class1.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Core/Logging/AppLogger.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Core/NeriPlayer.Core.csproj
create mode 100644 dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Core/Player/Model/SongItem.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Class1.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/NeriPlayer.Data.csproj
create mode 100644 dotnet-windows/src/NeriPlayer.UI/Class1.cs
create mode 100644 dotnet-windows/src/NeriPlayer.UI/NeriPlayer.UI.csproj
create mode 100644 dotnet-windows/tests/NeriPlayer.Api.Tests/NeriPlayer.Api.Tests.csproj
create mode 100644 dotnet-windows/tests/NeriPlayer.Api.Tests/UnitTest1.cs
create mode 100644 dotnet-windows/tests/NeriPlayer.Core.Tests/NeriPlayer.Core.Tests.csproj
create mode 100644 dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs
create mode 100644 dotnet-windows/tests/NeriPlayer.Core.Tests/UnitTest1.cs
create mode 100644 dotnet-windows/tests/NeriPlayer.Data.Tests/NeriPlayer.Data.Tests.csproj
create mode 100644 dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs
diff --git a/dotnet-windows/.gitignore b/dotnet-windows/.gitignore
new file mode 100644
index 0000000..db327eb
--- /dev/null
+++ b/dotnet-windows/.gitignore
@@ -0,0 +1,488 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[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/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
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# 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
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# 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
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+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/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+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/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_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/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.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/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# 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
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# Mac bundle stuff
+*.dmg
+*.app
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
+
+# 例外:NeriPlayer.App 项目目录(*.app 规则在 Windows core.ignorecase 下会误伤 .App 目录)
+!src/NeriPlayer.App/
+
diff --git a/dotnet-windows/Directory.Build.props b/dotnet-windows/Directory.Build.props
new file mode 100644
index 0000000..38478f2
--- /dev/null
+++ b/dotnet-windows/Directory.Build.props
@@ -0,0 +1,8 @@
+
+
+ net8.0
+ enable
+ enable
+ 12.0
+
+
diff --git a/dotnet-windows/Directory.Packages.props b/dotnet-windows/Directory.Packages.props
new file mode 100644
index 0000000..0bd9fd2
--- /dev/null
+++ b/dotnet-windows/Directory.Packages.props
@@ -0,0 +1,40 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-windows/NeriPlayer.Windows.sln b/dotnet-windows/NeriPlayer.Windows.sln
new file mode 100644
index 0000000..9050860
--- /dev/null
+++ b/dotnet-windows/NeriPlayer.Windows.sln
@@ -0,0 +1,78 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A35FBA1A-817E-4928-8E3B-14B91BDF0AA9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.App", "src\NeriPlayer.App\NeriPlayer.App.csproj", "{0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Core", "src\NeriPlayer.Core\NeriPlayer.Core.csproj", "{9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Data", "src\NeriPlayer.Data\NeriPlayer.Data.csproj", "{EB335D8C-A392-40E2-AAAA-3645BAB3017C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.UI", "src\NeriPlayer.UI\NeriPlayer.UI.csproj", "{9B4E749E-425D-451B-97D2-D0E7A0368A50}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Background", "src\NeriPlayer.Background\NeriPlayer.Background.csproj", "{129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{577D322E-2E0A-4C56-8C1C-CDF7B4F19F4A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Core.Tests", "tests\NeriPlayer.Core.Tests\NeriPlayer.Core.Tests.csproj", "{34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Data.Tests", "tests\NeriPlayer.Data.Tests\NeriPlayer.Data.Tests.csproj", "{210C4D77-5926-4FDC-9B43-A531BF7783DF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeriPlayer.Api.Tests", "tests\NeriPlayer.Api.Tests\NeriPlayer.Api.Tests.csproj", "{1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EB335D8C-A392-40E2-AAAA-3645BAB3017C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EB335D8C-A392-40E2-AAAA-3645BAB3017C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EB335D8C-A392-40E2-AAAA-3645BAB3017C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EB335D8C-A392-40E2-AAAA-3645BAB3017C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9B4E749E-425D-451B-97D2-D0E7A0368A50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9B4E749E-425D-451B-97D2-D0E7A0368A50}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B4E749E-425D-451B-97D2-D0E7A0368A50}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9B4E749E-425D-451B-97D2-D0E7A0368A50}.Release|Any CPU.Build.0 = Release|Any CPU
+ {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {210C4D77-5926-4FDC-9B43-A531BF7783DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {210C4D77-5926-4FDC-9B43-A531BF7783DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {210C4D77-5926-4FDC-9B43-A531BF7783DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {210C4D77-5926-4FDC-9B43-A531BF7783DF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {0A7D4920-CB3D-4C1D-B8EB-90F21EEFCAB7} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9}
+ {9686393C-5DD2-40F6-AAF4-F082F2CE1E0A} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9}
+ {EB335D8C-A392-40E2-AAAA-3645BAB3017C} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9}
+ {9B4E749E-425D-451B-97D2-D0E7A0368A50} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9}
+ {129C49AF-CCDE-4AA5-9CD6-C33AF8FF87E2} = {A35FBA1A-817E-4928-8E3B-14B91BDF0AA9}
+ {34F1FA07-3E18-4A4C-9E62-909EF7FA5A0B} = {577D322E-2E0A-4C56-8C1C-CDF7B4F19F4A}
+ {210C4D77-5926-4FDC-9B43-A531BF7783DF} = {577D322E-2E0A-4C56-8C1C-CDF7B4F19F4A}
+ {1A9B19AF-1E7C-4A25-BCB8-12FA49A85B43} = {577D322E-2E0A-4C56-8C1C-CDF7B4F19F4A}
+ EndGlobalSection
+EndGlobal
diff --git a/dotnet-windows/README.md b/dotnet-windows/README.md
new file mode 100644
index 0000000..3b71485
--- /dev/null
+++ b/dotnet-windows/README.md
@@ -0,0 +1,44 @@
+# NeriPlayer Windows (dotnet-windows)
+
+> ⚠️ **本目录是独立的 .NET 技术栈实现方案,与仓库根目录的 Tauri (Rust + Vue) 官方实现互不干扰。**
+
+## 这是什么
+
+这是将 NeriPlayer 移植到 Windows 桌面端的 **.NET 8 + Avalonia UI** 实现方案,与官方 `NeriPlayer-Desktop`(Tauri 2 + Rust + Vue 3)为**平行独立的两套技术栈**。
+
+- 本目录代码不参与仓库根目录的 `pnpm` / `cargo` 构建体系
+- 保留 3 个历史提交(脚手架 → 核心数据模型 → 对齐修复)
+- 作为技术方案参考与对比,供社区评估不同实现路线
+
+## 技术栈
+
+| 组件 | 选型 |
+|------|------|
+| 运行时 | .NET 8 LTS |
+| UI | Avalonia UI 11.x |
+| 播放引擎 | LibVLCSharp 8.x(VLC 3.0.x) |
+| 数据库 | EF Core 8 + SQLite |
+| 音效 | NAudio (WASAPI) / Biquad 滤波器 |
+| 系统集成 | SMTC / 托盘 / Toast |
+
+## 项目结构
+
+```
+src/
+├── NeriPlayer.App/ 主应用入口(Avalonia Desktop)
+├── NeriPlayer.Core/ 核心业务层(播放/歌词/下载/策略)
+├── NeriPlayer.Data/ 数据层(EF Core / 同步)
+├── NeriPlayer.UI/ UI 层(Avalonia 视图)
+└── NeriPlayer.Background/ 后台服务(SMTC / 托盘)
+tests/ 单元测试(xunit)
+```
+
+## 构建与运行
+
+```powershell
+dotnet build NeriPlayer.Windows.sln
+dotnet test tests/NeriPlayer.Core.Tests
+dotnet run --project src/NeriPlayer.App
+```
+
+> 详细实施方案见 `Analysis.md`(源码分析 24 章)与 `Process.md`(移植方案 19 章)。
diff --git a/dotnet-windows/src/NeriPlayer.App/App.axaml b/dotnet-windows/src/NeriPlayer.App/App.axaml
new file mode 100644
index 0000000..a7ee674
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.App/App.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.App/App.axaml.cs b/dotnet-windows/src/NeriPlayer.App/App.axaml.cs
new file mode 100644
index 0000000..f21d954
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.App/App.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace NeriPlayer.App;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.App/AppStartup.cs b/dotnet-windows/src/NeriPlayer.App/AppStartup.cs
new file mode 100644
index 0000000..e05ab73
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.App/AppStartup.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace NeriPlayer.App;
+
+public static class AppStartup
+{
+ public static ServiceProvider BuildServices()
+ {
+ var services = new ServiceCollection();
+
+ // 数据层(第四章实现后取消注释)
+ // services.AddDbContext();
+
+ // 核心层(第五/七章实现后取消注释)
+ // services.AddSingleton();
+ // services.AddSingleton();
+ // services.AddSingleton();
+ // services.AddSingleton();
+ // services.AddSingleton();
+
+ // 后台(第九章实现后取消注释)
+ // services.AddHostedService();
+
+ return services.BuildServiceProvider();
+ }
+}
diff --git a/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml
new file mode 100644
index 0000000..1110530
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml
@@ -0,0 +1,9 @@
+
+ Welcome to Avalonia!
+
diff --git a/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml.cs b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml.cs
new file mode 100644
index 0000000..0898b91
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace NeriPlayer.App;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.App/NeriPlayer.App.csproj b/dotnet-windows/src/NeriPlayer.App/NeriPlayer.App.csproj
new file mode 100644
index 0000000..7c2aa26
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.App/NeriPlayer.App.csproj
@@ -0,0 +1,34 @@
+
+
+ WinExe
+ net8.0
+ enable
+ true
+ app.manifest
+ true
+
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-windows/src/NeriPlayer.App/Program.cs b/dotnet-windows/src/NeriPlayer.App/Program.cs
new file mode 100644
index 0000000..f38b9d7
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.App/Program.cs
@@ -0,0 +1,21 @@
+using Avalonia;
+using System;
+
+namespace NeriPlayer.App;
+
+class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/dotnet-windows/src/NeriPlayer.App/app.manifest b/dotnet-windows/src/NeriPlayer.App/app.manifest
new file mode 100644
index 0000000..fc844a6
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.App/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-windows/src/NeriPlayer.Background/Class1.cs b/dotnet-windows/src/NeriPlayer.Background/Class1.cs
new file mode 100644
index 0000000..6d3e859
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Background/Class1.cs
@@ -0,0 +1,6 @@
+namespace NeriPlayer.Background;
+
+public class Class1
+{
+
+}
diff --git a/dotnet-windows/src/NeriPlayer.Background/NeriPlayer.Background.csproj b/dotnet-windows/src/NeriPlayer.Background/NeriPlayer.Background.csproj
new file mode 100644
index 0000000..d17e478
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Background/NeriPlayer.Background.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/dotnet-windows/src/NeriPlayer.Core/Class1.cs b/dotnet-windows/src/NeriPlayer.Core/Class1.cs
new file mode 100644
index 0000000..ab66add
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Core/Class1.cs
@@ -0,0 +1,6 @@
+namespace NeriPlayer.Core;
+
+public class Class1
+{
+
+}
diff --git a/dotnet-windows/src/NeriPlayer.Core/Logging/AppLogger.cs b/dotnet-windows/src/NeriPlayer.Core/Logging/AppLogger.cs
new file mode 100644
index 0000000..432efe6
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Core/Logging/AppLogger.cs
@@ -0,0 +1,21 @@
+using Serilog;
+
+namespace NeriPlayer.Core.Logging;
+
+public static class AppLogger
+{
+ public static readonly Serilog.Core.Logger Instance = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .WriteTo.Console(outputTemplate:
+ "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .WriteTo.File(
+ path: Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "NeriPlayer", "logs", "app-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 14,
+ outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .CreateLogger();
+
+ public static void Flush() => Instance.Dispose();
+}
diff --git a/dotnet-windows/src/NeriPlayer.Core/NeriPlayer.Core.csproj b/dotnet-windows/src/NeriPlayer.Core/NeriPlayer.Core.csproj
new file mode 100644
index 0000000..d0157f3
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Core/NeriPlayer.Core.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs
new file mode 100644
index 0000000..5bcc924
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs
@@ -0,0 +1,35 @@
+using System.Text.RegularExpressions;
+
+namespace NeriPlayer.Core.Player.Model;
+
+public static partial class SongIdentity
+{
+ /// 生成跨版本稳定的歌曲标识:去重、同步、持久化主键(对标 SongIdentity.kt)
+ public static string StableKey(this SongItem song)
+ {
+ if (song.IsLocalSong())
+ return $"local|{NormalizePath(song.LocalFilePath ?? song.MediaUri ?? "")}";
+
+ return song.ChannelId switch
+ {
+ "netease" => $"netease|{song.AudioId ?? song.Id.ToString()}",
+ "bilibili" => $"bilibili|{song.AudioId}|{song.SubAudioId}",
+ "youtube_music" => $"ytm|{ExtractYouTubeVideoId(song.MediaUri)}",
+ _ => $"id|{song.Id}|{song.Album}|{song.MediaUri}"
+ };
+ }
+
+ private static string NormalizePath(string p) =>
+ p.Replace('\\', '/').TrimEnd('/').ToLowerInvariant();
+
+ /// 从 YouTube 链接/播放列表 URI 提取视频 ID
+ public static string ExtractYouTubeVideoId(string? uri)
+ {
+ if (string.IsNullOrEmpty(uri)) return "";
+ var m = YoutubeVideoIdRegex().Match(uri);
+ return m.Success ? m.Groups[1].Value : "";
+ }
+
+ [GeneratedRegex(@"(?:v=|youtu\.be/|/shorts/)([A-Za-z0-9_-]{11})")]
+ private static partial Regex YoutubeVideoIdRegex();
+}
diff --git a/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongItem.cs b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongItem.cs
new file mode 100644
index 0000000..7054440
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongItem.cs
@@ -0,0 +1,42 @@
+namespace NeriPlayer.Core.Player.Model;
+
+public enum PlaybackSource { Local, Netease, Bilibili, YouTubeMusic }
+
+public sealed record SongItem
+{
+ public long Id { get; init; }
+ public required string Name { get; init; }
+ public required string Artist { get; init; }
+ public required string Album { get; init; }
+ public long AlbumId { get; init; }
+ public long DurationMs { get; init; }
+ public string? CoverUrl { get; init; }
+ public string? MediaUri { get; init; }
+ public string? StreamUrl { get; init; }
+
+ public string? ChannelId { get; init; } // local | netease | bilibili | youtube_music
+ public string? AudioId { get; init; }
+ public string? SubAudioId { get; init; }
+
+ public string? MatchedLyric { get; init; }
+ public string? MatchedTranslatedLyric { get; init; }
+ public PlaybackSource? MatchedLyricSource { get; init; }
+ public long UserLyricOffsetMs { get; init; }
+
+ public string? CustomName { get; init; }
+ public string? CustomArtist { get; init; }
+ public string? CustomCoverUrl { get; init; }
+ public string? OriginalName { get; init; }
+ public string? OriginalArtist { get; init; }
+
+ public string? LocalFileName { get; init; }
+ public string? LocalFilePath { get; init; }
+
+ public long AddedAt { get; init; }
+
+ public string DisplayName => CustomName ?? OriginalName ?? Name;
+ public string DisplayArtist => CustomArtist ?? OriginalArtist ?? Artist;
+
+ public bool IsLocalSong() =>
+ ChannelId == "local" || (!string.IsNullOrEmpty(LocalFilePath) && ChannelId is null);
+}
diff --git a/dotnet-windows/src/NeriPlayer.Data/Class1.cs b/dotnet-windows/src/NeriPlayer.Data/Class1.cs
new file mode 100644
index 0000000..c579020
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Class1.cs
@@ -0,0 +1,6 @@
+namespace NeriPlayer.Data;
+
+public class Class1
+{
+
+}
diff --git a/dotnet-windows/src/NeriPlayer.Data/NeriPlayer.Data.csproj b/dotnet-windows/src/NeriPlayer.Data/NeriPlayer.Data.csproj
new file mode 100644
index 0000000..543cb3b
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/NeriPlayer.Data.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-windows/src/NeriPlayer.UI/Class1.cs b/dotnet-windows/src/NeriPlayer.UI/Class1.cs
new file mode 100644
index 0000000..ca6d9e3
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.UI/Class1.cs
@@ -0,0 +1,6 @@
+namespace NeriPlayer.UI;
+
+public class Class1
+{
+
+}
diff --git a/dotnet-windows/src/NeriPlayer.UI/NeriPlayer.UI.csproj b/dotnet-windows/src/NeriPlayer.UI/NeriPlayer.UI.csproj
new file mode 100644
index 0000000..46e394e
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.UI/NeriPlayer.UI.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/dotnet-windows/tests/NeriPlayer.Api.Tests/NeriPlayer.Api.Tests.csproj b/dotnet-windows/tests/NeriPlayer.Api.Tests/NeriPlayer.Api.Tests.csproj
new file mode 100644
index 0000000..b05270a
--- /dev/null
+++ b/dotnet-windows/tests/NeriPlayer.Api.Tests/NeriPlayer.Api.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-windows/tests/NeriPlayer.Api.Tests/UnitTest1.cs b/dotnet-windows/tests/NeriPlayer.Api.Tests/UnitTest1.cs
new file mode 100644
index 0000000..d6cf3a2
--- /dev/null
+++ b/dotnet-windows/tests/NeriPlayer.Api.Tests/UnitTest1.cs
@@ -0,0 +1,10 @@
+namespace NeriPlayer.Api.Tests;
+
+public class UnitTest1
+{
+ [Fact]
+ public void Test1()
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/dotnet-windows/tests/NeriPlayer.Core.Tests/NeriPlayer.Core.Tests.csproj b/dotnet-windows/tests/NeriPlayer.Core.Tests/NeriPlayer.Core.Tests.csproj
new file mode 100644
index 0000000..ad8edd9
--- /dev/null
+++ b/dotnet-windows/tests/NeriPlayer.Core.Tests/NeriPlayer.Core.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs b/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs
new file mode 100644
index 0000000..3cec55f
--- /dev/null
+++ b/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs
@@ -0,0 +1,43 @@
+using NeriPlayer.Core.Player.Model;
+using Xunit;
+
+namespace NeriPlayer.Core.Tests;
+
+public class SongIdentityTests
+{
+ [Fact]
+ public void LocalSong_StableKey_IsNormalizedPath()
+ {
+ var song = new SongItem
+ {
+ Id = 1, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "local", LocalFilePath = @"D:\Music\a\b\c.flac"
+ };
+ Assert.Equal("local|d:/music/a/b/c.flac", song.StableKey());
+ }
+
+ [Theory]
+ [InlineData("https://www.youtube.com/watch?v=abcDEF12345")]
+ [InlineData("https://youtu.be/abcDEF12345?si=xxx")]
+ public void YouTube_ExtractVideoId_Works(string uri)
+ {
+ var song = new SongItem
+ {
+ Id = 2, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "youtube_music", MediaUri = uri
+ };
+ Assert.Equal("abcDEF12345", SongIdentity.ExtractYouTubeVideoId(uri));
+ Assert.Equal("ytm|abcDEF12345", song.StableKey());
+ }
+
+ [Fact]
+ public void Netease_StableKey_UsesAudioId()
+ {
+ var song = new SongItem
+ {
+ Id = 9, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "netease", AudioId = "3456789"
+ };
+ Assert.Equal("netease|3456789", song.StableKey());
+ }
+}
diff --git a/dotnet-windows/tests/NeriPlayer.Core.Tests/UnitTest1.cs b/dotnet-windows/tests/NeriPlayer.Core.Tests/UnitTest1.cs
new file mode 100644
index 0000000..2d60717
--- /dev/null
+++ b/dotnet-windows/tests/NeriPlayer.Core.Tests/UnitTest1.cs
@@ -0,0 +1,10 @@
+namespace NeriPlayer.Core.Tests;
+
+public class UnitTest1
+{
+ [Fact]
+ public void Test1()
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/dotnet-windows/tests/NeriPlayer.Data.Tests/NeriPlayer.Data.Tests.csproj b/dotnet-windows/tests/NeriPlayer.Data.Tests/NeriPlayer.Data.Tests.csproj
new file mode 100644
index 0000000..f539217
--- /dev/null
+++ b/dotnet-windows/tests/NeriPlayer.Data.Tests/NeriPlayer.Data.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs b/dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs
new file mode 100644
index 0000000..0cadd4c
--- /dev/null
+++ b/dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs
@@ -0,0 +1,10 @@
+namespace NeriPlayer.Data.Tests;
+
+public class UnitTest1
+{
+ [Fact]
+ public void Test1()
+ {
+
+ }
+}
\ No newline at end of file
From 745240a018e12ff0105f44ceec33b72c6732cd1a Mon Sep 17 00:00:00 2001
From: ALIve114514awa <300349280+ALIve114514awa@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:28:47 +0800
Subject: [PATCH 02/28] docs(dotnet-windows): add collaboration workflow rules
and reference docs
- Add chapter 0 to start.md: every completed step must push to the
personal repo and update the upstream PR unless the maintainer rejects
- Include Analysis.md / Process.md / start.md reference docs
---
dotnet-windows/Analysis.md | 837 ++++++++++
dotnet-windows/Process.md | 1469 ++++++++++++++++
dotnet-windows/README.md | 44 -
dotnet-windows/start.md | 3216 ++++++++++++++++++++++++++++++++++++
4 files changed, 5522 insertions(+), 44 deletions(-)
create mode 100644 dotnet-windows/Analysis.md
create mode 100644 dotnet-windows/Process.md
delete mode 100644 dotnet-windows/README.md
create mode 100644 dotnet-windows/start.md
diff --git a/dotnet-windows/Analysis.md b/dotnet-windows/Analysis.md
new file mode 100644
index 0000000..3e1ba04
--- /dev/null
+++ b/dotnet-windows/Analysis.md
@@ -0,0 +1,837 @@
+# NeriPlayer 源码深度分析(Analysis)
+
+> 分析对象:`NeriPlayer-clone/`(git HEAD bc4142bc,上游最新 master,2026-08-12 克隆)
+> 规模:770 个 Kotlin 文件 + Native C++ + AGSL Shader + 4 个子模块
+> 对比基准:旧仓库 NeriPlayer(HEAD fd9d70b3) vs 新克隆 NeriPlayer-clone(HEAD bc4142bc)
+> 差异规模:60 个文件变更 = 16 新增 + 44 修改 + 0 删除 + 0 重命名,+6225 / -1425 行
+> 技术栈:Jetpack Compose + Media3 (ExoPlayer) + Room + KSP + OkHttp + WebSocket
+> 内容:一至十七章为整体功能分析;十八、十九章为新版变化与两仓库差异;
+> 二十至二十四章为补充深潜(播放服务/数据模型/网络安全/构建系统/常量速查)
+> 合并说明:本文档为完整版,已合并《NeriPlayer-源码分析.md》(第 1-19 章,613 行)全部内容,并扩展第二十至二十四章深度分析。
+
+---
+
+## 目录
+
+1. [项目架构总览](#一项目架构总览)
+2. [应用入口与启动链路](#二应用入口与启动链路)
+3. [播放核心 PlayerManager](#三播放核心-playermanager)
+4. [多源在线播放](#四多源在线播放)
+5. [音效系统](#五音效系统)
+6. [歌词系统](#六歌词系统)
+7. [下载管理系统](#七下载管理系统)
+8. [本地音乐管理](#八本地音乐管理)
+9. [数据同步(GitHub / WebDAV)](#九数据同步github--webdav)
+10. [一起听(Listen Together)](#十一起听listen-together)
+11. [UI 系统与流体背景](#十一ui-系统与流体背景)
+12. [设置系统(KSP 代码生成)](#十二设置系统ksp-代码生成)
+13. [存储与流量统计](#十三存储与流量统计)
+14. [安全与崩溃恢复](#十四安全与崩溃恢复)
+15. [USB 独占播放(Native)](#十五usb-独占播放native)
+16. [桌面小组件与快捷方式](#十六桌面小组件与快捷方式)
+17. [测试体系](#十七测试体系)
+18. [新版变化深度分析(fd9d70b3 → bc4142bc)](#十八新版变化深度分析fd9d70b3--bc4142bc)
+19. [两个仓库差异详细清单](#十九两个仓库差异详细清单fd9d70b3-vs-bc4142bc)
+20. [播放服务 AudioPlayerService](#二十播放服务-audioplayerservice-深度解析)
+21. [核心数据模型与数据库 Schema](#二十一核心数据模型与数据库-schema)
+22. [网络层与安全](#二十二网络层与安全)
+23. [构建系统与代码生成](#二十三构建系统与代码生成)
+24. [关键常量与策略速查表](#二十四关键常量与策略速查表)
+
+---
+
+## 一、项目架构总览
+
+```
+app/src/main/java/moe/ouom/neriplayer/
+├── activity/ Activity 入口与登录页面(QR/WebView)
+├── core/
+│ ├── api/ 三平台 API 客户端(netease/bili/youtube)+ 歌词源 + 搜索
+│ ├── crash/ 全局异常处理器
+│ ├── di/ AppContainer 手动 DI 容器
+│ ├── download/ 下载管理器(40+ 文件)
+│ ├── lyricon/ Lyricon 歌词服务桥接
+│ ├── player/ 播放核心(PlayerManager + 200+ 策略文件)
+│ └── startup/ 启动规划/安全模式
+├── data/
+│ ├── auth/ 三平台 Cookie 认证仓库
+│ ├── backup/ 配置备份与迁移
+│ ├── config/ 配置文件管理
+│ ├── history/ 播放历史
+│ ├── listentogether/ 一起听偏好
+│ ├── local/ Room 数据库 + 本地音频导入
+│ ├── model/ SongItem 核心数据模型
+│ ├── platform/ 平台数据缓存仓库
+│ ├── playlist/ 歌单仓库与使用统计
+│ ├── settings/ 设置仓库 + AutoSettingsSchema
+│ ├── stats/ 播放统计
+│ ├── storage/ 存储占用分析
+│ ├── sync/ 同步(github/webdav + 合并策略)
+│ └── traffic/ 流量统计
+├── listentogether/ 一起听(WebSocket 会话 + 协议 + 校验)
+├── navigation/ 路由定义 + 启动器快捷方式
+├── ui/ Compose 屏幕/组件/主题/效果/ViewModel
+├── util/ 工具(崩溃日志/ANR/IO/JSON/网络/平台)
+└── widget/ 桌面小组件
+```
+
+## 二、应用入口与启动链路
+
+### 2.1 NeriPlayerApplication
+
+`NeriPlayerApplication.onCreate()` 启动流程:
+
+1. `AppFeedback.initialize()` — 初始化反馈系统
+2. `PlayerManager.bindApplication()` — 尽早绑定 Application 到播放器
+3. 进程分类:`AppProcessClassifier` 判断是否主进程,WebView 目录加后缀区分多进程
+4. 语言初始化:`LanguageManager.init()`
+5. 启动规划:`AppStartupPlanner.plan()` 返回是否进入安全模式
+6. ANR 捕获:`AnrWatchdog.capturePreviousAnrIfNeeded()` 捕获上次 ANR
+7. 异常处理器:`ExceptionHandler.init()`(含 NativeCrashHandler)
+
+正常组件初始化(`initializeNormalComponents`):
+- `AppContainer.initialize()` 初始化 DI
+- 后台预热收藏/历史/统计/缓存仓库
+- `UsbDeviceAttachHandling` 注册 USB 设备接入监听
+- `FloatingLyricsOverlayManager.initialize()` 悬浮歌词
+- `ManagedDownloadStorage.initialize()` 下载存储
+- `YouTubeAuthRotationWorker.schedulePeriodicRotation()` 周期续期 YouTube 会话
+- `GlobalDownloadManager.initialize()` 全局下载
+- `LyriconManager.initialize()`(启用时)
+
+### 2.2 MainActivity → NeriApp
+
+`MainActivity` 通过 `setContent` 挂载 `NeriTheme` + `NeriApp`。`NeriApp.kt` 是 UI 根:
+
+- 启动阶段:`Loading → Disclaimer → Onboarding → Main`(SafeMode 优先)
+- 主导航:`NeriBottomBar`(底部 Tab)+ `NeriMiniPlayer` + NowPlaying 覆盖层
+- 主题切换:动态/浅色/深色 + `ThemeRevealOverlay` 主题切换过渡动画
+- USB 前后台恢复:`recoverUsbExclusivePlaybackOnForeground`
+
+### 2.3 导航 Destinations
+
+`Destinations.kt` 定义所有路由,主 Tab 为 `home/explore/library/settings`,详情页通过 JSON 参数传递(`{playlistJson}` 等)。Tab 切换由 `MainTabTransitionController` 做可打断的横向换页。
+
+## 三、播放核心 PlayerManager
+
+`PlayerManager` 是 2678 行的 **object 单例**,核心字段与机制:
+
+### 3.1 状态流(StateFlow)
+
+| 状态 | 用途 |
+|------|------|
+| `_currentSongFlow` | 当前歌曲 |
+| `_isPlayingFlow` / `_playWhenReadyFlow` | 播放状态 |
+| `_playbackPositionMs` / `_playbackDurationMs` | 进度(80ms 更新一次) |
+| `_currentQueueFlow` | 播放队列 |
+| `_repeatModeFlow` / `_shuffleModeFlow` | 循环/随机 |
+| `audioLevelFlow` / `beatImpulseFlow` | 音频可视化(驱动流体背景) |
+| `_preferredQualityKeys` | 三平台音质偏好 |
+
+### 3.2 关键策略参数
+
+- `MEDIA_URL_STALE_MS = 10min` — URL 过期自动刷新
+- `URL_REFRESH_COOLDOWN_MS = 10s` — 刷新冷却
+- `MAX_CONSECUTIVE_FAILURES = 10` — 连续失败自动停止
+- `STATE_PERSIST_INTERVAL_MS = 15s` — 状态持久化间隔
+- `DEFAULT_FADE_DURATION_MS = 500ms` — 淡入淡出时长
+
+### 3.3 播放流程(playPlaylistImpl)
+
+```
+检查初始化 → 一起听拦截 → USB高音量确认 → 设置队列/索引
+→ 随机播放时保存恢复快照+乱序 → playAtIndex → 广播 PLAY_PLAYLIST 事件 → 延迟持久化
+```
+
+### 3.4 playImpl(恢复播放)
+
+关键逻辑:若歌曲已预载但 URL 过期(`MEDIA_URL_STALE_MS`)或 YouTube 需要刷新则先 `refreshCurrentSongUrl()`,否则直接 `player.play()`;有暂停中恢复、手动恢复、队列空三种分支,通过 `resolveManualResumePlaybackDecision` 决定是否带位置续播。
+
+### 3.5 事件系统
+
+`PlaybackCommand(type, source, queue, currentIndex, positionMs)` 通过 SharedFlow 广播,一起听等模块订阅。`PlaybackCommandSource` 区分 `LOCAL` / `REMOTE_SYNC`(一起听来源)。
+
+### 3.6 曲目结束去重
+
+`handleTrackEndedIfNeededImpl` 使用 `trackEndDeduplicationKey` + 500ms 间隔守卫,防止 ExoPlayer 重复上报曲目结束。
+
+## 四、多源在线播放
+
+### 4.1 网易云(NeteaseClient)
+
+- **加密**:`NeteaseCrypto` AES-CBC(固定 key)+ RSA + MD5 签名
+- **Cookie**:OkHttp CookieJar 管理,`MUSIC_U` 判定登录,持久化注入 `__csrf`
+- **网络**:共享 OkHttpClient + `DynamicProxySelector`(运行时切换代理)+ Brotli/GZIP 解压
+- **播放失败**:音质降级 → 自动源切换 → 本地兜底
+
+### 4.2 Bilibili(BiliClient)
+
+- **WBI 签名**:`MIXIN_INDEX` 重排参数 → MD5 生成 `w_rid`,含 `wts` 时间戳
+- **反爬三件套**:`spi` 指纹 + WebTicket + 专用 UA(Web/iOS/Firefox 三套)
+- **接口**:playurl(WBI)、view、搜索、收藏夹、UP主空间、合集/系列
+- **播放**:`BiliPlaybackRepository` 支持 DASH 音频重试和 html5/mp4 渐进流回退
+
+### 4.3 YouTube Music(YouTubeMusicClient)
+
+- **InnerTube API**:WEB_REMIX 客户端(id=67),continuation 分页,最多 80 页
+- **多级回退链**:登录 Cookie → 匿名 visitor → PoToken → player.js 缓存 → EJS 挑战(JS 求解队列 + WebView 兜底)→ NewPipe 回退
+- **Cookie 轮换**:`YouTubeCookieRotator` + `YouTubeAuthRotationWorker` 周期续期
+- **URL 预热**:登录/匿名都预取 bootstrap,播放前预取队列窗口
+
+### 4.4 网易云自动源切换
+
+`PlayerManagerNeteaseAutoSourceSwitch.tryResolveNeteaseAutoBiliSource`:
+1. 构建查询(`歌名+歌手`、`歌手+歌名`、`歌名`)
+2. B 站搜索(追加"无损"关键字)
+3. 按歌名/歌手相似度 + 时长打分,阈值 70
+4. 最多 6 候选,2 个后备候选组成播放候选列表
+
+## 五、音效系统
+
+`PlaybackEffectsController` 统一管理:
+
+- **倍速/音调**:ExoPlayer `PlaybackParameters`
+- **均衡器**:Android `Equalizer` API,预设 + 5 段手动调节,按 audioSessionId 绑定
+- **响度增强**:`LoudnessEnhancer`(mB 增益)
+- **声道平衡**:`StereoBalanceAudioProcessor`(自定义 Media3 AudioProcessor)
+- **响度归一**:`VolumeNormalizationAudioProcessor` 按歌曲实时分析
+- **32-bit 高解析输出**:`playbackHighResolutionOutputEnabled`,旁路应用内处理
+
+`AudioReactive` 提供音频能量/节拍流,驱动流体背景 `uMusicLevel/uBeat`。
+
+## 六、歌词系统
+
+### 6.1 来源链
+
+网易云 lrc/tlrc → QQ音乐(补全源)→ LrcLib → 酷狗 → AMLL TTML → 手动匹配(`EditableLyricsMatcher`)
+
+### 6.2 解析与缓存
+
+- `accompanist-lyrics-core` 解析 LRC/TTML/YRC
+- `LruCache`:YouTube 歌词缓存 20 条、网易云歌词缓存 20 条,避免重复请求
+- `EditableLyricSanitizer` 清理标题/制作信息行
+
+### 6.3 输出形态
+
+| 形态 | 实现 |
+|------|------|
+| 播放页歌词 | `SyncedLyricsView` / `AdvancedLyricsView`(逐词/逐字高亮+翻译+音译) |
+| 悬浮歌词 | `FloatingLyricsOverlayManager` + `WindowManager` 系统窗口 + 长按拖动 |
+| 状态栏歌词 | `StatusBarLyricNotificationState` |
+| 蓝牙歌词 | `ExternalBluetoothLyrics` 走 AVRCP |
+| Lyricon | `LyriconManager` + `SuperLyricHelper` 桥接 |
+| 灵动岛 | `dynamicIslandLyricsEnabled` |
+| 歌词卡片 | `LyricShareSheet` 生成 1080px 卡片分享 |
+
+## 七、下载管理系统
+
+`GlobalDownloadManager`(object 单例)核心机制:
+
+- **不用系统 DownloadManager**,用共享 OkHttpClient + Semaphore 并发控制(默认 6,最高 8)
+- **三种传输**:直接 HTTP、显式 Range(分块续传)、HLS
+- **持久化**:任务队列 `DownloadTaskStore` + 恢复状态 `DownloadRecoveryRoomStore`,重启恢复
+- **目录管理**:应用目录或 SAF 自定义目录,`ManagedDownloadMigration*` 支持迁移
+- **原子写**:`ManagedDownloadAtomicFile` + 工作文件 + sidecar 元数据,崩溃不损坏
+- **标签写入**:`DownloadedAudioTagWriter` 失败 3 次后仍保留音频文件
+- **目录树缓存**:`ManagedDownloadTreeChildCache` 缓存 SAF 子节点,避免频繁查询
+- **快照**:`ManagedDownloadSnapshotDiskCache` 磁盘+内存双层缓存
+
+## 八、本地音乐管理
+
+`LocalAudioImportManager` 三种导入方式:
+
+1. **Intent 导入**:响应 `VIEW/SEND/SEND_MULTIPLE` 的 `audio/*`
+2. **SAF 文件夹扫描**:`DocumentFile` 递归 + `Os.listdir` 提速
+3. **MediaStore 扫描**:设备媒体库
+
+**Sidecar**:自动识别 `.lrc/.txt` 歌词和 `cover/folder/front` 封面并复制。
+**快速预览**:大批量扫描先出 `QuickImportedSongSeed`,后台补全元信息。
+
+`LocalPlaylistRepository` + `LocalArtistSummary`:
+- 系统歌单(我喜欢/本地文件)
+- 歌手按展示艺术家聚合,拆分 `feat./with/和/与/顿号/分号/斜杠`
+- 歌手页支持播放全部/多选/导出歌单/批量下载
+
+## 九、数据同步(GitHub / WebDAV)
+
+### 9.1 GitHub
+
+- `GitHubApiClient` + Git Data API 提交二进制正文
+- `SyncDataSerializer` GZIP + JSON 编码
+- `SyncDataChangeDetector` 变更检测,只在有变更时上传
+- `GitHubSyncUploadPolicy` 上传策略,`SyncUploadRetryExecutor` 重试
+- 冲突:并发分支更新失败报告冲突,不强制覆盖
+- Token 存 `SecureTokenStorage`(加密)
+
+### 9.2 WebDAV
+
+`WebDavApiClient` + `WebDavStorage` + 并发回退策略。
+
+### 9.3 合并策略
+
+`SyncPlaylistSongMergePolicy`(stableKey 去重合并)、`SyncSongMetadataMergePolicy`、`SyncPlaybackStatsMergePolicy`(取最大)、`SyncPlaylistDeletionPolicy`(删除记录同步)、`SyncPlaylistUsageStatsMergePolicy`。
+
+### 9.4 触发器
+
+`GitHubSyncWorker`/`WebDavSyncWorker` 用 WorkManager 延迟+周期同步,`SyncCoordinator` 互斥锁保证不同时并发。
+
+## 十、一起听(Listen Together)
+
+### 10.1 架构
+
+`ListenTogetherSessionManager` + `ListenTogetherWebSocketClient`(OkHttp WebSocket):
+
+- **协议**:JSON envelope(`ListenTogetherSocketEnvelope`),消息上限 2MB,超限断开(1009)
+- **心跳**:`np_ping` 新协议 + `ping` 兼容旧协议
+- **重连**:`ListenTogetherReconnectPolicy`(最多 N 次,指数退避,终端错误不重连)
+- **角色**:`ListenTogetherSessionRole`(Controller / Listener)
+
+### 10.2 播放同步
+
+- Controller 上报播放命令(PLAY_PLAYLIST 等)→ Listener 应用 `ListenTogetherPlayerStateApplier`
+- 进度同步:`ListenTogetherPlaybackPosition` + 心跳周期上报
+- Listener 停滞恢复:`ListenTogetherListenerStallRecovery`
+- 安全暂停:`ListenTogetherListenerSafetyPausePolicy`(音频路由丢失等)
+- 防回声:`ListenTogetherControllerEchoPolicy` + `ForwardedRequestDeduper` 去重
+- 漂移保护:`ListenTogetherIncomingStatePolicy` / `RoomStateAcceptance`
+
+### 10.3 通道映射
+
+`ListenTogetherChannels`:`netease` / `bilibili` / `youtubeMusic` / `local`,歌曲用 `stableKey` 跨设备对齐。
+
+## 十一、UI 系统与流体背景
+
+### 11.1 AGSL 流体背景
+
+`BgEffectPainter.java`(API 33+)加载 `assets/shaders/hyper_background_effect.glsl`:
+
+- `RuntimeShader` 逐帧渲染
+- 5 个色点(`uPoints[5]`)+ 5 组颜色(`uColors[5]`),从封面取色
+- HSV 空间调色(饱和度/亮度偏移)
+- 音频响应:`uLevelEase`(位移)、`uBeatEase`(节拍波+径向脉冲)、`uMotionEase`(波动)
+- 变焦 `uZoom` + 全局运动 `uGlobalMotion` + 颗粒噪声
+
+### 11.2 主要屏幕
+
+`NowPlayingScreen`、`LyricsScreen`、`HomeScreen`、`ExploreScreen`、`LibraryScreen`、`SettingsScreen`、各平台详情页、`DownloadManagerScreen`、`PlaybackStatsScreen`、`SafeModeScreen`、调试探针(`*ApiProbeScreen`)。
+
+## 十二、设置系统(KSP 代码生成)
+
+- `ksp-annotations`:`@AutoSettingsCatalog` / `@AutoSetting` / `@AutoSettingsSection` 注解
+- `ksp-processor`:`AutoSettingsProcessorProvider` 生成 `SettingsKeys`、备份白名单、Repository、section 常量
+- `AutoSettingsSchema.kt`:声明式设置登记表(general/播放/下载/歌词/主题/同步等分区)
+- 设置项用 `autoSwitchSetting/autoIntSetting/autoSetting` 等 DSL 声明,KSP 自动生成 DataStore 访问代码
+
+## 十三、存储与流量统计
+
+### 13.1 缓存
+
+`SimpleCache + LRU`,默认 1GB(`currentCacheSize`),`CacheSizePolicy` 管理,可分别清理音频/图片/分享/歌单缓存。
+
+### 13.2 StorageUsageAnalyzer
+
+分组统计:音频缓存/图片缓存/下载暂存/分享暂存/平台歌单缓存/下载内容/日志/崩溃报告/核心数据。
+
+### 13.3 播放统计
+
+`PlaybackStatsTracker`:
+- 每 15 秒周期 flush + 关键生命周期 flush
+- 听满 30 秒才计 1 次播放(`MIN_LISTEN_MS_FOR_PLAY_COUNT`)
+- 位置回绕检测(结尾→开头判定为播完)
+- `PlaybackStatsRepository`:Room 主存 + JSON 兼容迁移,记录播放次数/收听时长/每日桶
+
+### 13.4 流量统计
+
+`TrafficStatsRepository`:
+- 区分 WiFi/移动/漫游 + 播放/下载来源 + 缓存命中
+- 每日桶(`dayStartAt`),延迟批量写入
+- 高风险网络下载弹窗提示
+
+## 十四、安全与崩溃恢复
+
+### 14.1 ExceptionHandler
+
+- 全局未捕获异常 → 写崩溃报告(进程/PID/线程/栈/ABI)→ 主线程错误弹窗
+- 崩溃前 `UsbExclusiveSessionController.emergencyShutdown()` 紧急释放 USB 设备
+- `NativeCrashHandler` C++ 层崩溃捕获
+
+### 14.2 AnrWatchdog
+
+- 主线程卡顿监测 + 上次 ANR 捕获
+- Safe Mode 记录崩溃/ANR → 下次启动进入 `SafeModeScreen`
+
+### 14.3 配置备份
+
+`BackupManager` + `AppConfigBackup`:完整导出(含设置/授权/同步配置),版本化迁移。
+
+## 十五、USB 独占播放(Native)
+
+### 15.1 分层
+
+Kotlin 层(`core/player/usb/`,70+ 文件)+ JNI 桥(`UsbExclusiveNativeBridge`)+ C++(`cpp/usb/`)。
+
+### 15.2 C++ 模块
+
+| 模块 | 职责 |
+|------|------|
+| `uac1/` | UAC1.0 格式解析 |
+| `uac2/` | UAC2 描述符/时钟图/反馈模型/候选模型 |
+| `feedback/` | 显式反馈引擎、时钟捕获、速率估计、包调度 |
+| `iso/` | 等时传输窗口与健康度 |
+| `pcm/` | PCM 编码管线与播放重放缓冲 |
+| `exclusive/` | 桥接、恢复动作闩锁、运行时报告 |
+
+### 15.3 核心机制
+
+- **时钟拓扑解析**:`usb_uac2_clock_graph` + 反馈端点解析
+- **反馈时钟捕获**:`usb_feedback_clock`,长调度间隙后重新捕获
+- **率估计**:`usb_feedback_rate_math` 反馈速率数学
+- **背压恢复**:等时传输健康监测 + 动态扩缩容 + 软恢复
+- **后台锚点**:`UsbExclusiveBackgroundAudioAnchor` 保持 USB 通道,播放停止时静音/零均值载波
+- **看门狗**:播放启动看门狗、前后台健康审计、卡死自动恢复
+- **比特完美**:软件增益 0 dB,DAC 硬件控音量
+
+## 十六、桌面小组件与快捷方式
+
+### 16.1 小组件
+
+`PlaybackWidgetProviders`:
+- 4x2 播放卡片(`widget_playback_4x2`)+ 2x2 迷你(`widget_playback_2x2`)
+- 控件通过 `ACTION_PLAYBACK_WIDGET_CONTROL` → `AudioPlayerService.dispatchPlaybackWidgetAction` 走播放服务链路
+- 支持 `MY_PACKAGE_REPLACED` 刷新、尺寸变化 `onAppWidgetOptionsChanged`
+
+### 16.2 启动器快捷方式
+
+`LauncherShortcuts.kt`:继续播放、打开探索、打开媒体库、随机播放我喜欢。
+
+## 十七、测试体系
+
+- `app/src/test/`:单元测试(服务策略、悬浮歌词策略、下载策略、同步合并等)
+- `app/src/androidTest/`:设备测试(设置、下载存储、安全模式等)
+- `cpp/tests/usb/`:Native host 测试(corpus 语料 + fixtures 轨迹 + 4 ABI 编译验证)
+- `tools_pub/ytmusic_api_probe.py`:YouTube 播放兼容探针
+- 关键链路均有对应测试:下载存储、同步合并、YouTube 兼容、一起听、歌词解析、播放策略、配置备份、安全模式
+
+---
+
+## 十八、新版变化深度分析(fd9d70b3 → bc4142bc)
+
+> 本次重新克隆自 https://github.com/cwuom/NeriPlayer(--recurse-submodules),
+> 相比上一分析版本新增约 60 个文件变更、6225 行新增、1425 行删除。
+
+### 18.1 网易云首页推荐系统(全新)
+
+`NeteaseHomeRecommendations.kt` + `HomeViewModel`(+710 行)构建了完整的首页推荐体系:
+
+**歌曲来源分区**(`NeteaseHomeSongSource`):
+- `TOP_SOARING` 飙升榜 / `TOP_HOT` 热歌榜 / `TOP_NEW` 新歌榜(无需登录)
+- `PERSONAL_RADAR` 私人雷达 / `DAILY_RECOMMEND` 日推 / `PRIVATE_FM` 私人FM(需登录)
+- `PERSONALIZED_NEW_SONGS` 新歌速递(无需登录)
+
+**雷达歌单**:5 个固定歌单 ID(时光 5320167908 / 宝藏 5362359247 / 新歌 5300458264 / 乐迷 5327906368 / 神秘 5341776086)
+
+**歌单来源分区**(`NeteaseHomePlaylistSource`):PERSONALIZED 每日推荐 / DAILY_RESOURCE / HIGH_QUALITY 精品 / HOT 热门 / ACG 分区
+
+**关键机制**:
+- **登录感知**:`requiresLogin` 标记,未登录自动过滤需要登录的源
+- **去重合并**:`appendUniqueNeteaseHomeSongs` 按 `audioId`/`channelId:id:name` 去重,各分区互不重复
+- **失败回退**:`shouldFallbackRecommend` 对 code 301/50000005 自动回退
+- **预取**:登录 Cookie 变化时自动刷新全部推荐分区
+- 刷新会更新全部分区,含榜单、新歌、日推、私人FM、精品歌单等
+
+### 18.2 WaveformSlider 播放进度预测(全新)
+
+`WaveformSlider.kt`(+137 行)将进度条升级为带播放预测的波形滑块:
+
+**核心:`WaveProgressPredictor`**
+- 由于 UI 进度流是 80ms 更新一次,存在可见延迟
+- Predictor 基于「锚点值 + 歌曲时长 + 当前倍速」逐帧推算实时进度
+- `updateTarget()`:目标变化或开始动画时重置锚点
+- `onFrame(frameNs)`:按已播放时长插值预测当前进度
+- `resetFrameAnchor()`:拖动后重新锚定
+
+**状态机**:`isPlaying`(波形动画)/ `isPlaybackWaiting`(等待脉冲动画)/ `isProgressStalled`(停滞检测)/ `isProgressPreviewing`(进度预览)。拖动时暂停动画、结束时重置锚点。
+
+### 18.3 存储分析大增强(StorageUsageAnalyzer +677 行)
+
+`StorageUsageAnalyzer.kt` 重构为 20+ 细粒度类别的分析器:
+
+**新增类别**(`StorageUsageItemKind`):`DownloadedMusic`(下载音乐)/ `DownloadedLyrics`(下载歌词)/ `DownloadedCovers`(下载封面)/ `DownloadIndex`(下载索引)/ `LocalCovers`(本地封面)/ `CustomBackground`(自定义背景)/ `LegacyMigrationFiles`(遗留迁移文件)/ `Database`(数据库)/ `AppData`(应用数据)
+
+**数据库占用统计**:
+- `DownloadIndexRoomStore`:用 `dbstat`(真实页大小)或 `PRAGMA page_size` 估算 SQLite 表占用
+- `PlatformPlaylistCacheRoomStore`:统计平台歌单缓存三张表的记录数与页字节
+- 行开销 24B + `length(CAST(col AS TEXT))` 逐列估算
+
+**清除选项**(`StorageCacheClearOptions`):按平台分别清除(网易云歌单 / B站收藏夹 / B站视频 / YouTube歌单 / 日志 / 崩溃日志),缓存清理不触碰用户下载内容。
+
+配套新增 `StorageCacheDetailsContent.kt`(+584 行)详情页和 `SettingsStorageCacheSection` 重构。
+
+### 18.4 平台歌单缓存迁移到 Room(从 JSON 到 SQLite)
+
+新增 `PlatformPlaylistCacheDao` + `PlatformPlaylistCacheRoomStore` + `PlatformPlaylistCacheEntities`:
+- 三表结构:cache(元数据)+ tracks(歌曲)+ artists(艺术家,按 trackPosition 关联)
+- 事务性读写,`replaceIfNewer` 按 `savedAtMs` 版本控制,避免旧缓存覆盖新缓存
+- 支持按平台批量清除与统计
+- 数据库迁移到 MIGRATION_13_14
+
+### 18.5 媒体缓存生命周期与播放恢复增强
+
+- `PlayerManagerUrlExtensions`(+281 行):媒体缓存完整性校验、缓存预取准备
+- `PlayerManagerLifecycleExtensions`(+261 行):播放恢复逻辑重构
+- `PlayerManagerStartupWatchdogExtensions`(+49 行):启动看门狗增强
+- `PlayerManagerYouTubePrefetchExtensions`(+35 行):YouTube 预取调整
+- 配套测试:`CachedResourceIntegrityTest`、`PlayerManagerCachePrefetchPreparationTest`、`PlayerManagerYouTubePlaybackRecoveryTest`、`PlayerManagerMediaCacheLifecycleTest`
+
+### 18.6 下载封面支持与索引简化
+
+- `ManagedDownloadCoverLookup`(-77 行):移除可复用封面查询,简化 sidecar 解析(commit #336)
+- 下载封面作为独立存储项纳入存储分析
+- `AudioDownloadManager`(-101 行)重构,配合新索引
+
+### 18.7 其他增强
+
+- `NeteaseClient`(+382 行):新增接口与容错
+- `ExceptionHandler`(+43 行):跨进程 WebView 状态清理(#329)
+- `NPLogger`(+37 行):日志能力增强
+- `FileCleanup.kt`:通用批量清理工具(全部成功才返回 true)
+- README 更新:首页推荐、存储分析、下载索引描述
+
+### 18.8 新增测试
+
+| 测试 | 覆盖点 |
+|------|--------|
+| `NeteaseHomeRecommendationsTest` | 首页推荐解析/去重/登录过滤 |
+| `WaveformSliderTest` | 进度预测、拖动、等待动画 |
+| `StorageUsageSummaryTest` | 存储分类统计 |
+| `StorageCacheDetailsContentTest` | 缓存详情 UI |
+| `CachedResourceIntegrityTest` | 缓存完整性 |
+| `PlayerManagerMediaCacheLifecycleTest` | 缓存生命周期 |
+| `PlayerManagerYouTubePlaybackRecoveryTest` | YouTube 播放恢复 |
+| `PlayerManagerCachePrefetchPreparationTest` | 缓存预取 |
+| `PlatformPlaylistCacheRoomMigrationTest` | Room 迁移 |
+| `ManagedDownloadCoverLookupTest` | 封面查找简化 |
+| `NeteaseClientTest` | 网易云客户端 |
+| `FileCleanupTest` | 文件清理 |
+
+---
+
+## 十九、两个仓库差异详细清单(fd9d70b3 vs bc4142bc)
+
+### 19.1 版本与仓库状态对比
+
+| 维度 | 旧仓库 `NeriPlayer` | 新克隆 `NeriPlayer-clone` |
+|------|--------------------|--------------------------|
+| HEAD | fd9d70b3 | bc4142bc(上游最新 master) |
+| 状态 | 有本地改动(gradle-wrapper.properties 被改、buildSrc/build-logic 有 untracked 文件) | 干净(刚 clone) |
+| 源码 Kotlin 数 | 765 | 770 |
+| 子模块 | 1f476060 / 825661a1 / d844e105 / 48bd198a | 完全相同 |
+| 构建产物 | 含 build/、.gradle/ 等产物(文件总数 3789) | 无构建产物(文件总数 2432) |
+| Native C++ | 60 | 60(完全一致) |
+
+### 19.2 新增文件(16 个)
+
+**生产代码(4 个)**:
+- `core/player/download/../data/local/database/store/DownloadIndexRoomStore.kt` — 下载索引数据库统计(dbstat / PRAGMA page_size 估算)
+- `ui/screen/tab/settings/component/StorageCacheDetailsContent.kt` — 存储缓存详情页 UI
+- `ui/viewmodel/tab/NeteaseHomeRecommendations.kt` — 网易云首页推荐(榜单/雷达/日推/私人FM/精品歌单)
+- `util/io/FileCleanup.kt` — 通用批量文件清理工具
+
+**测试代码(12 个)**:NeteaseClientTest、ManagedDownloadCoverLookupTest、PlayerManagerMediaCacheLifecycleTest、CachedResourceIntegrityTest、PlayerManagerCachePrefetchPreparationTest、PlayerManagerPlaybackCandidateRecoveryTest、StorageUsageSummaryTest、NeteaseHomeRecommendationsTest、FileCleanupTest、StorageUsageResourceTest、PlaylistModernVisualColorsProviderLayoutTest、StorageCacheDetailsContentTest
+
+### 19.3 核心逻辑差异逐项分析
+
+#### (a) 媒体缓存生命周期重构(PlayerManagerLifecycleExtensions +261 行)
+
+`PlayerManager.cache` 从 `lateinit var` 改为 `@Volatile var cache: Cache? = null`(可空),为缓存重建与旁路让路。
+
+**新增 `createVerifiedMediaCache`**:
+1. 创建 `SimpleCache` 后调用 `checkInitialization()` 验证
+2. 初始化失败分类:
+ - 文件夹被锁(`isSimpleCacheFolderLocked`,其他进程的 SimpleCache 实例占用)→ 本次进程旁路缓存继续播放
+ - 缓存库损坏(`hasCacheInitializationFailure` 含 `CacheException`)→ 删除并重建缓存目录
+ - 重建仍失败 → 旁路缓存播放
+3. 新增 `releaseMediaCache()`:安全释放可空缓存
+
+**配套:`PlayerManagerUrlExtensions` 缓存完整性检查**
+- 新增 `CachedResourceIntegrity`(isComplete / requiresRepair / coveredLength)
+- `inspectCachedResourceSpans`:遍历排序后的 CacheSpan,检测文件缺失、长度不匹配(file.length != span.length)、区间重叠、越界、覆盖间隙;不完整或损坏 → `invalidateCachedResourceForPlaybackRecovery` 失效该缓存
+- 新增 `CachePrefetchReadiness`(COMPLETE / READY_FOR_PREFETCH / UNAVAILABLE)供预取决策
+
+#### (b) 播放失败恢复策略扩展(PlayerManagerUrlExtensions)
+
+`isRecoverableRemotePlaybackCacheError` 从 2 种错误码扩展到 6 种:
+```
+BAD_HTTP_STATUS / INVALID_HTTP_CONTENT_TYPE / NETWORK_CONNECTION_TIMEOUT /
+NETWORK_CONNECTION_FAILED / READ_POSITION_OUT_OF_RANGE / IO_UNSPECIFIED
++ TIMEOUT(且不是 STUCK_PLAYING_NOT_ENDING 卡死)
+```
+- 新增 `StuckPlayerException` 识别:`shouldTreatPlaybackFailureAsTrackEnd` 沿 cause 链查找,区分「卡死未结束」与真实曲目结束
+- 缓存失效策略:远程可恢复错误 → 失效缓存重试;离线缓存格式错误也失效;`shouldInvalidateCacheAfterPlaybackFailure` 保证普通失败不盲目清缓存
+
+#### (c) NeteaseClient Cookie 会话管理增强(+382 行)
+
+- `mergeNeteaseRequestCookies`:三层 Cookie 合并(请求上下文 → 持久化 → 运行时),自动补 `os=pc` / `appver=8.10.35`
+- `mergeNeteaseSessionCookies`:仅回填 `NMTID` / `__csrf` 动态会话字段
+- `shouldPreheatNeteaseWeapiSession`:登录态存在但缺 `__csrf` 时预热 WeAPI 会话
+- 新增 `requestContextCookies`:`__remember_me=true` + `_ntes_nuid`(SecureRandom 生成) + `NMTID`(SecureRandom 生成)
+- `loadForRequest` 改为基于合并 Cookie 重建 `Cookie.Builder`(hostOnlyDomain),替换原持久的 CookieJar 存储
+- `setPersistedCookies` 增加指纹检测(`authCookieFingerprint`),登录变化时重置预热的 `__csrf`
+
+#### (d) 下载封面复用简化(commit #336)
+
+- `AudioDownloadManager` 删除共享封面复用(`findSharedCoverReference`、`rememberSharedCoverReference`、`buildSharedCoverLookupKeys`、`sharedCoverReferencesByLookupKey`)
+- `ManagedDownloadCoverLookup` 删除 `findReusableCoverReference`(跨歌曲按 remote cover key / 专辑匹配复用)
+- `DownloadedAudioMetadataStore` 不再调用 `findReusableCoverReference`
+- 收益:简化 sidecar 解析路径,下载封面改为每个音频独立解析;配合存储分析把「下载封面」作为独立统计项
+
+#### (e) 崩溃日志与日志系统加固
+
+- `ExceptionHandler`:新增 `crashLogLock` + `crashLogGeneration`(世代计数),写日志前校验世代防竞态;新增 `clearCrashLogs()` 供存储清理(清空目录 + `CrashReportStore.clearPendingCrashReport` + 重开日志文件)
+- `NPLogger`:`LogFileEntry` 增加 `generation`,`fileLogGeneration` 世代计数,清日志后旧写任务自动丢弃;同样接入 `clearAllFiles`
+
+#### (f) 存储分析增强(StorageUsageAnalyzer +677 行)
+
+- `StorageCacheKind` 从单一 `PlatformList` 拆分为 4 个平台 + 日志 + 崩溃日志
+- 新增 `StorageUsageItemKind` 20+ 细粒度类别(下载音乐/歌词/封面、下载索引、本地封面、自定义背景、遗留迁移文件、数据库、AppData)
+- 新增 `DownloadIndexRoomStore` / `PlatformPlaylistCacheRoomStore` 的数据库占用统计(真实页大小或估算)
+- 新增 `StorageCacheDetailsContent.kt` 详情页 + `StorageCacheDetailsContentTest`
+
+#### (g) 首页网易云推荐(NeteaseHomeRecommendations + HomeViewModel +710 行)
+
+已在第十八章详述;核心要点:动态分区(登录感知)、雷达歌单 5 个固定 ID、`appendUniqueNeteaseHomeSongs` 去重、301/50000005 回退、登录变化自动刷新全部分区。
+
+#### (h) WaveformSlider 播放进度预测(+137 行)
+
+已在第十八章详述;`WaveProgressPredictor` 基于锚点值 + 时长 + 倍速逐帧预测,配套停滞/预览/等待脉冲状态机。
+
+#### (i) 其他修改
+
+- `AutoSettingsSchema`(+6 行):新增相关设置项
+- `HomeScreen`(+538 行)/ `HomeHostScreen` / `SettingsScreen`(+183 行)/ `SettingsPage` / `SettingsSearchIndex`:UI 重构配合新功能
+- `NowPlayingScreen` / `LyricsScreen`:接入 WaveformSlider
+- `NeteaseCollectionDetailViewModel`(+105 行)+ 测试:歌单详情增强
+- `NPLogger` / `ExceptionHandler`:如上
+- `README` / `CONTRIBUTING`:文档同步更新
+
+### 19.4 结论
+
+- 两个仓库的**子模块完全一致**,Native C++ 完全一致,差异全部在 Kotlin/资源/测试层
+- 核心演进方向:**缓存健壮性**(完整性检查 + 自动重建 + 失效重试)、**网易云会话保鲜**(SecureRandom 会话 Cookie + 会话预热)、**首页推荐**、**存储分析精细化**、**封面解析简化**
+- 旧仓库仅有本地未提交改动(构建相关),不含新功能;新克隆 bc4142bc 为干净的最新代码
+
+---
+
+## 二十、播放服务 AudioPlayerService 深度解析
+
+### 20.1 服务架构
+
+`AudioPlayerService.kt`(130KB,Kotlin)是一个 **普通 `Service`**(非 `MediaSessionService`),单实例,由 `PlayerManager` 统一驱动。位于 `core/player/service/`。
+
+**核心字段**:
+- `mediaSession: MediaSession` — `USAGE_MEDIA` + `CONTENT_TYPE_MUSIC` 音频属性
+- `usbExclusiveVolumeProvider: UsbExclusiveLockScreenVolumeProvider` — USB 独占时接管锁屏音量键
+- 封面加载状态机:`currentCoverSongKey` / `currentMediaArtwork` / `currentNotificationLargeIcon` / `artworkLoadJob` / `lastArtworkLoadFailedSource`(封面加载失败去抖)
+- `becomingNoisyReceiver: BroadcastReceiver` — 监听耳机拔出(AUDIO_BECOMING_NOISY)
+
+### 20.2 服务生命周期策略(纯函数策略层)
+
+服务目录下 9 个策略文件构成完整决策体系:
+
+| 文件 | 职责 |
+|------|------|
+| `PlaybackNotificationPolicy` | 通知显示策略 |
+| `MediaSessionPlaybackStateThrottler` | MediaSession 状态上报节流:最小更新间隔 1000ms、位置漂移阈值 1500ms |
+| `MediaSessionVolumePolicy` | MediaSession 音量策略 |
+| `PlaybackServiceIdlePolicy` | 空闲判定(无播放任务时降级) |
+| `PlaybackServiceIdleShutdownCoordinator` | 空闲关闭协调:空闲超时(可配置 `playback_service_idle_shutdown_minutes`)后停止服务 |
+| `PlaybackServiceRestartPolicy` | 服务重启策略 |
+| `FloatingLyricsNotificationPolicy` | 悬浮歌词通知策略 |
+| `StatusBarLyricNotificationState` | 状态栏歌词通知状态 |
+| `PlayerManagerServiceLifecycleExtensions` | 服务生命周期桥接 PlayerManager |
+
+### 20.3 关键逻辑
+
+**任务移除处理**(`resolveTaskRemovedPlaybackAction`):用户划掉最近任务时,根据是否处于传输活跃态(`isTransportActive`)决定继续前台播放还是停止服务,避免"后台播着歌却没了通知"。
+
+**小组件动作分发**(`dispatchPlaybackWidgetAction`):
+- 校验动作白名单(`isSupportedPlaybackWidgetAction`)
+- 安全模式下拒绝执行
+- 根据服务是否已在前台决定 `startForegroundService` 或 `startService`
+- 捕获 Android 12+ 后台启动限制(`isServiceStartNotAllowedFailure`),`IllegalStateException` 优雅降级
+
+**封面加载**:`resolveMetadataCoverSource` / `shouldRequestArtworkLoad` / `resolveRemoteMetadataArtworkUri`,远程封面通过 `MediaMetadataRetriever` 或协程加载,失败记录时间戳避免反复请求。
+
+**USB 独占联动**:`usbExclusiveKeepAliveIntervalMs` 保活心跳、`shouldReassertUsbExclusiveForeground` 前台重断言、`updateUsbExclusiveBackgroundState` 后台状态同步。
+
+## 二十一、核心数据模型与数据库 Schema
+
+### 21.1 SongItem(平台无关歌曲模型)
+
+`@Parcelize data class`,覆盖三平台 + 本地,关键字段:
+`id/name/artist/album/albumId/durationMs/coverUrl/mediaUri`、
+`matchedLyric/matchedTranslatedLyric/matchedLyricSource/matchedSongId/userLyricOffsetMs`、
+`custom*/original*`(自定义与原始元数据分离)、
+`localFileName/localFilePath`、`channelId/audioId/subAudioId/playlistContextId`、
+`sourceStableKey/streamUrl`、`neteaseArtists`、`syncMembershipTokens`
+
+**身份标识**:`stableKey()`(跨设备同步主键)、`identity`(专辑/媒体URI身份)、`sameIdentityAs()`(等价判断)。
+
+### 21.2 NeriUserDataDatabase(Room)
+
+- 数据库名:`neri_user_data.db`,仅允许主进程打开
+- **13 次迁移**:`MIGRATION_1_2` → `MIGRATION_13_14`,逐版本演进
+- 15 个 DAO:PlayHistory / PlaybackStats / TrafficStats / LocalPlaylist / PlaylistUsage / LocalPlaylistPlayback / FavoritePlaylist / SyncMetadata / PlaybackQueue / BiliVideoSkip / CoverUrlMapping / DownloadRecovery / DownloadedSongCatalog / DownloadSnapshot / PlatformPlaylistCache
+- 15 个 Entity 文件:覆盖历史、统计(含 counter shard 分片)、歌单(含成员 token)、同步(outbox + checkpoint)、播放队列、下载(目录索引/恢复/快照/已下载目录)、平台歌单缓存、B站视频跳过、封面URL映射
+
+**统计分片**:`PlaybackStatCounterShardEntity` / `PlaybackStatDailyCounterShardEntity` 用分片计数器缓解高频写放大。
+
+**平台歌单缓存表**(新版):cache + tracks + artists 三表,事务性读写 + `replaceIfNewer` 版本控制。
+
+## 二十二、网络层与安全
+
+### 22.1 共享 OkHttpClient
+
+`AppContainer` 构建统一 OkHttpClient(`buildSharedOkHttpClient`):
+- `connectTimeout` 与 `readTimeout` 均明确配置,`callTimeout = 0`(禁用总时限,防止截断长播放/同步/WebSocket 请求)
+- 连接池:`SHARED_HTTP_MAX_IDLE_CONNECTIONS` + `SHARED_HTTP_KEEP_ALIVE_MINUTES`
+- `retryOnConnectionFailure(true)`
+
+播放(三平台音源/歌词)、同步(GitHub/WebDAV)、一起听(WebSocket)共用此客户端。
+
+### 22.2 DynamicProxySelector(运行时代理)
+
+`DynamicProxySelector` 实现 `ProxySelector`,允许**运行时切换代理**(如绕过网络限制),三平台客户端都接入,无需重建 OkHttpClient。
+
+### 22.3 安全防护
+
+- `SecurityGuards`:通用安全守卫
+- `TrustedHostSupport` / `HostValidation`:受信主机校验(防止 SSRF / 恶意 URL)
+- `NetworkExceptions`:网络异常分类(`isTransientHttp2StreamReset` 等瞬时错误识别)
+- `OkHttpCallAwait`:OkHttp 调用的协程 await 封装
+- `readBytesLimited`:响应体读取上限(NeteaseClient `MAX_RESPONSE_BYTES = 4MB`)
+- 隐私策略:不接入广告/统计/崩溃分析 SDK;同步走用户自有的 GitHub/WebDAV;Cookie/Token 本地存储
+
+### 22.4 平台反爬对策
+
+| 平台 | 对策 |
+|------|------|
+| 网易云 | AES-CBC + RSA + MD5 加密参数;三层 Cookie 合并;`os=pc/appver=8.10.35` 指纹 |
+| Bilibili | WBI 签名(MIXIN_INDEX 重排 + w_rid);spi 指纹 + WebTicket;三套 UA 分离 |
+| YouTube | InnerTube WEB_REMIX;visitor 匿名 + PoToken + player.js 缓存 + EJS 挑战 + NewPipe 回退 |
+
+## 二十三、构建系统与代码生成
+
+### 23.1 Gradle 模块
+
+`settings.gradle.kts` 定义多模块 + 3 个源码级子模块(np-submodule):
+
+| 模块 | 职责 |
+|------|------|
+| `app` | 主应用(Kotlin + Compose + Native CMake) |
+| `ksp-annotations` | KSP 注解定义(@AutoSetting 等) |
+| `ksp-processor` | KSP 处理器(自动生成设置代码) |
+| `build-logic` / `buildSrc` | Convention 插件(版本目录 libs.versions.toml) |
+| `np-submodule/*` | NeriPlayer-LTW / accompanist-lyrics-core/ui / miuix |
+
+### 23.2 KSP 设置代码生成
+
+`AutoSettingsSchema.kt` 声明式登记表 → `AutoSettingsProcessorProvider` 生成:
+- `SettingsKeys` 常量
+- `AutoSettingsRepository`(DataStore 访问)
+- 设置备份白名单
+- section 常量与 scope
+
+新增设置只需在 `AutoSettingsSchema` 中声明,KSP 自动生成其余代码。
+
+### 23.3 Native 构建
+
+- `app/src/main/cpp/CMakeLists.txt` + NDK 编译
+- 依赖 `libusb`(LGPL-2.1)实现 USB 音频独占
+- `usb/` 下:exclusive / feedback / iso / pcm / uac1 / uac2 六模块
+- `tests/usb/`:host 测试(corpus + fixtures,4 ABI)
+- JNI 桥:`UsbExclusiveNativeBridge` 连接 Kotlin 与 C++
+
+### 23.4 AGSL/GLSL 资源
+
+`assets/shaders/`:`hyper_background_effect.glsl`(流体背景)+ 3 个 `advanced_glass_*.agsl`(高级模糊毛玻璃)。
+
+## 二十四、关键常量与策略速查表
+
+### 24.1 播放核心
+
+| 常量 | 值 | 含义 |
+|------|-----|------|
+| `MEDIA_URL_STALE_MS` | 10 min | 播放 URL 过期自动刷新 |
+| `URL_REFRESH_COOLDOWN_MS` | 10 s | URL 刷新冷却 |
+| `MAX_CONSECUTIVE_FAILURES` | 10 | 连续播放失败自动停止 |
+| `STATE_PERSIST_INTERVAL_MS` | 15 s | 播放状态持久化间隔 |
+| `DEFAULT_FADE_DURATION_MS` | 500 ms | 淡入淡出时长 |
+| `PLAYBACK_STATS_PERIODIC_FLUSH_MS` | 15 s | 播放统计周期刷盘 |
+| `MIN_LISTEN_MS_FOR_PLAY_COUNT` | 30 s | 听满 30s 才计 1 次播放 |
+| 进度流更新 | 80 ms | UI 播放进度节流 |
+| 曲目结束去重 | 500 ms | 相邻结束事件间隔守卫 |
+
+### 24.2 下载管理
+
+| 常量 | 值 | 含义 |
+|------|-----|------|
+| 默认并发 / 最大并发 | 6 / 8 | Semaphore 控制 |
+| `INITIAL_SCAN_DELAY_MS` | 1500 ms | 启动恢复扫描延迟 |
+| `METADATA_WRITE_MAX_ATTEMPTS` | 3 | 标签写入重试上限 |
+| `DOWNLOAD_CANCEL_SETTLE_TIMEOUT_MS` | 5000 ms | 取消任务稳定期 |
+| 下载目录索引表 | 4 张 | catalog/snapshot/recovery/queue |
+
+### 24.3 一起听
+
+| 常量 | 值 | 含义 |
+|------|-----|------|
+| WS 消息上限 | 2 MB | 超限断开(code 1009) |
+| 心跳 | `np_ping` / `ping` | 新/旧协议兼容 |
+| 频道 | netease/bilibili/youtubeMusic/local | 平台通道映射 |
+| 重连 | 指数退避 + 终端错误不重连 | `ListenTogetherReconnectPolicy` |
+
+### 24.4 歌词
+
+| 项目 | 值 | 说明 |
+|------|-----|------|
+| 歌词 LRU 缓存 | 各 20 条 | YouTube / 网易云 |
+| 平台偏移 | `DEFAULT_CLOUD_MUSIC_LYRIC_OFFSET_MS` / `DEFAULT_QQ_MUSIC_LYRIC_OFFSET_MS` | 内置歌词补偿 |
+| 用户偏移 | `userLyricOffsetMs` | 手动校准 |
+| 歌词卡片 | 1080px | 分享卡片宽度 |
+
+### 24.5 存储与缓存
+
+| 项目 | 值 | 说明 |
+|------|-----|------|
+| 流媒体缓存 | 默认 1GB | `SimpleCache + LRU` |
+| 存储类别 | 20+ 项 | StorageUsageItemKind |
+| 清理策略 | 只清可再生成缓存 | 不删用户下载 |
+| 下载索引行开销 | 24 B | SQLite 估算基准 |
+
+### 24.6 首页推荐
+
+| 项目 | 值 | 说明 |
+|------|-----|------|
+| 歌曲/歌单上限 | 各 30 | 每个分区 |
+| 私人 FM 最大批次 | 10 | 批量获取 |
+| 失败告警阈值 | 3 次 | 分区失败提示 |
+| 雷达歌单 | 5 个固定 ID | 时光/宝藏/新歌/乐迷/神秘 |
+| 推荐回退 | code 301 / 50000005 | 自动回退降级 |
+
+### 24.7 USB 独占
+
+| 项目 | 值 | 说明 |
+|------|-----|------|
+| 色点/颜色 | 5 | 流体背景 AGSL uniform |
+| 看门狗 | 启动 + 前后台 + 卡死 | 三级恢复 |
+| 后台锚点 | 静音/零均值载波 | 保持 USB 通道 |
+| UAC2 | 显式反馈端点 | 拓扑校验后启用 |
+
+---
+
+## 分析结论总览
+
+NeriPlayer 是一个**工程深度极高**的 Android 音乐播放器:770+ Kotlin 文件、60 个 Native C++ 文件、4 个子模块、19 次数据库迁移、13 个数据库迁移版本、KSP 代码生成、AGSL 实时渲染、UAC2 异步 USB 音频链路。它围绕「多源探索、在线播放、本地可控、数据自持」四条主线,把播放健壮性(缓存修复/URL保鲜/失败恢复)、平台合规(WBI/InnerTube/加密参数)、数据自主(本地优先 + 用户自有 GitHub/WebDAV 同步)做到了极致,是研究 Android 音频应用架构的优秀范本。
+
+*NeriPlayer 源码深度分析 · 完整版 · 2026-08-12*
diff --git a/dotnet-windows/Process.md b/dotnet-windows/Process.md
new file mode 100644
index 0000000..5d124b7
--- /dev/null
+++ b/dotnet-windows/Process.md
@@ -0,0 +1,1469 @@
+# NeriPlayer → Windows 音乐播放器 移植方案(Process)
+
+> 编制日期:2026-08-12
+> 编制依据:《Analysis.md》(NeriPlayer 源码深度分析 24 章)+ `NeriPlayer-clone/`(HEAD bc4142bc,1748 个 Kotlin 文件)
+> 目标平台:Windows 10/11(x64)
+> 目标框架:.NET 8 + Avalonia UI
+> 预计工期:12 ~ 16 周
+
+---
+
+## 目录
+
+1. [概述与目标](#一概述与目标)
+2. [技术栈映射](#二技术栈映射)
+3. [项目结构设计](#三项目结构设计)
+4. [核心模块实现策略](#四核心模块实现策略)
+5. [数据模型与数据库设计](#五数据模型与数据库设计)
+6. [播放引擎设计](#六播放引擎设计)
+7. [音效系统设计](#七音效系统设计)
+8. [API 客户端设计](#八api-客户端设计)
+9. [下载管理系统设计](#九下载管理系统设计)
+10. [数据同步设计](#十数据同步设计)
+11. [UI 设计](#十一ui-设计)
+12. [后台服务与系统集成](#十二后台服务与系统集成)
+13. [安全与崩溃恢复](#十三安全与崩溃恢复)
+14. [测试体系](#十四测试体系)
+15. [打包与发布](#十五打包与发布)
+16. [分阶段实施计划](#十六分阶段实施计划)
+17. [架构简化清单](#十七架构简化清单)
+18. [风险与缓解](#十八风险与缓解)
+19. [验收标准](#十九验收标准)
+
+---
+
+## 一、概述与目标
+
+### 1.1 项目背景
+
+NeriPlayer 是一款 Android 端的多源音乐播放器,支持网易云音乐、Bilibili、YouTube Music 三大平台,具备播放核心、下载管理、歌词系统、数据同步(GitHub/WebDAV)、一起听、USB 独占播放等 770+ 个 Kotlin 文件的复杂架构。本方案的目标是将该播放器的**核心能力**迁移至 Windows 桌面端,形成一款功能对等、架构清晰的桌面音乐播放器。
+
+### 1.2 产品目标
+
+| 目标 | 说明 | 优先级 |
+|------|------|--------|
+| 多源在线播放 | 网易云 / Bilibili / YouTube Music 在线播放 | P0 |
+| 本地音乐管理 | 本地文件夹扫描、元数据解析、播放 | P0 |
+| 歌词系统 | LRC/TTML 解析、在线歌词、滚动歌词、翻译 | P0 |
+| 音效系统 | 均衡器、立体声平衡、音量归一化、变速变调 | P1 |
+| 下载管理 | 多任务并发下载、断点续传、标签写入 | P1 |
+| 数据同步 | GitHub / WebDAV 同步歌单与配置 | P1 |
+| 高级音频输出 | WASAPI 独占模式、ASIO | P2 |
+| 桌面集成 | SMTC 媒体控制、任务栏缩略图、桌面歌词、Toast | P2 |
+| 视觉效果 | 流体背景 Shader、毛玻璃、封面取色 | P2 |
+
+### 1.3 非目标
+
+- ❌ 不做一起听(Listen Together)—— WebSocket 多人实时同步过于依赖移动场景
+- ❌ 不做 USB 独占(Native C++)—— Windows 下以 WASAPI 独占替代
+- ❌ 不做桌面小组件(Android Widget)—— Windows 无对等机制,用 SMTC 替代
+- ❌ 不做逐文件 Kotlin 移植 —— 采用架构级移植 + 语言重写
+
+---
+
+## 二、技术栈映射
+
+### 2.1 技术选型总表
+
+| NeriPlayer (Android) | Windows 替代方案 | 选型理由 |
+|---|---|---|
+| Jetpack Compose | **Avalonia UI 11.x** | 声明式 UI + MVVM,跨平台,样式系统强大,原生手感接近 Compose |
+| Kotlin / KSP | **C# (.NET 8)** | 类型安全、LINQ、async/await、Source Generators 替代 KSP |
+| Media3 ExoPlayer | **LibVLCSharp 8.x** | 全格式解码(mp3/flac/aac/opus/ogg/hls),流媒体支持完善 |
+| Room (SQLite) | **EF Core 8 + SQLite** | ORM 成熟,迁移机制(Migrations)对应 Room 的 13 版迁移 |
+| OkHttp | **HttpClient + Refit** | 原生 .NET HTTP 栈,Refit 提供接口式 REST 客户端 |
+| Kotlinx Coroutines/Flow | **System.Reactive (Rx.NET)** | 响应式流处理,对应 Flow 语义 |
+| DataStore Preferences | **JSON 配置 + Microsoft.Extensions.Configuration** | 结构化配置,易于手动编辑与同步 |
+| WorkManager | **BackgroundService + Quartz.NET** | Windows 服务后台任务调度 |
+| Android MediaSession | **SystemMediaTransportControls (SMTC)** | Windows 系统级媒体控制标准接口 |
+| AGSL/GLSL Shader | **HLSL + SkiaSharp / Win2D** | GPU 流体背景与模糊效果 |
+| KSP 设置代码生成 | **C# Source Generators** | 编译期代码生成,等价替代 KSP |
+| JNI Native(USB 独占) | **NAudio WasapiOut(Exclusive)** | WASAPI 独占模式替代 USB 独占音频 |
+| androidx.security (加密存储) | **DPAPI + AES-GCM (System.Security.Cryptography)** | Windows 本地凭据保护 |
+| Coil (图片加载) | **AsyncImage + SkiaSharp 缓存** | 图片加载与磁盘缓存 |
+| TinyPinyin(拼音排序) | **Microsoft.International.Converters.PinYinConverter** | 汉字拼音转换排序 |
+| TagLib(标签写入) | **TagLib# 2.3.x** | 音频标签读写 |
+| org.json / kotlinx.serialization | **System.Text.Json** | 标准 JSON 库 |
+
+### 2.2 开发环境
+
+| 项目 | 版本 |
+|---|---|
+| OS | Windows 10 22H2 或 Windows 11 |
+| IDE | Visual Studio 2022 17.8+(Community 版即可) |
+| .NET SDK | .NET 8 LTS |
+| Avalonia | 11.0.x |
+| LibVLC | 3.0.x(x64) |
+| Git | 任意较新版本 |
+
+### 2.3 NuGet 依赖清单
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 三、项目结构设计
+
+### 3.1 解决方案结构
+
+```
+NeriPlayer.Windows/
+├── NeriPlayer.Windows.sln
+├── Directory.Build.props # 全局编译属性
+├── Directory.Packages.props # 中央包版本管理
+├── NuGet.config
+├── README.md
+│
+├── src/
+│ ├── NeriPlayer.App/ # 主应用入口(Avalonia Desktop)
+│ │ ├── Program.cs # Main 入口 + DI 装配
+│ │ ├── App.axaml / App.axaml.cs # 应用定义、异常捕获
+│ │ ├── AppStartup.cs # 启动规划(安全模式)
+│ │ ├── app.manifest # 高 DPI、Windows 声明
+│ │ └── Resources/ # 图标 / 全局样式
+│ │
+│ ├── NeriPlayer.Core/ # 核心业务层(不依赖 UI)
+│ │ ├── Player/ # 播放核心
+│ │ │ ├── PlayerManager.cs # 播放总控(状态机+事件)
+│ │ │ ├── Engine/
+│ │ │ │ ├── IPlaybackEngine.cs
+│ │ │ │ ├── VlcPlaybackEngine.cs # LibVLC 封装
+│ │ │ │ ├── ExclusivePlaybackEngine.cs # WASAPI 独占
+│ │ │ │ └── EngineException.cs
+│ │ │ ├── Effects/ # 音效
+│ │ │ │ ├── PlaybackEffectsController.cs
+│ │ │ │ ├── EqualizerEffect.cs
+│ │ │ │ ├── StereoBalanceEffect.cs
+│ │ │ │ ├── VolumeNormalizationEffect.cs
+│ │ │ │ ├── SpeedPitchEffect.cs
+│ │ │ │ └── FftAnalyzer.cs # 频谱数据
+│ │ │ ├── Lyrics/ # 歌词
+│ │ │ │ ├── LyricsProvider.cs # 聚合歌词源
+│ │ │ │ ├── LrcParser.cs
+│ │ │ │ ├── TtmlParser.cs
+│ │ │ │ ├── LyricTimeline.cs
+│ │ │ │ └── LyricSearchMatcher.cs
+│ │ │ ├── Playlist/
+│ │ │ │ ├── PlaylistManager.cs
+│ │ │ │ └── ShuffleEngine.cs
+│ │ │ ├── Timer/
+│ │ │ │ └── SleepTimer.cs
+│ │ │ ├── Persistence/
+│ │ │ │ ├── PlayerStatePersistence.cs # 播放状态持久化
+│ │ │ │ └── PlayerStateSnapshot.cs
+│ │ │ ├── Policy/ # 策略层(合并 23 个策略包)
+│ │ │ │ ├── PlaybackFailurePolicy.cs # 连续失败处理
+│ │ │ │ ├── MediaUrlRefreshPolicy.cs # URL 保鲜 10min
+│ │ │ │ ├── TrackEndDedupPolicy.cs # 曲目结束去重
+│ │ │ │ ├── PendingMediaLoadPolicy.cs
+│ │ │ │ ├── ProgressUpdatePolicy.cs # 进度节流
+│ │ │ │ └── PlaybackCommandPolicy.cs
+│ │ │ └── Model/
+│ │ │ ├── SongItem.cs
+│ │ │ ├── PlaybackAudioInfo.cs
+│ │ │ ├── PlayerEvent.cs
+│ │ │ ├── PlayerQueueDisplayState.cs
+│ │ │ └── AudioDevice.cs
+│ │ │
+│ │ ├── Api/ # API 客户端
+│ │ │ ├── Common/
+│ │ │ │ ├── IPlatformClient.cs # 平台统一接口
+│ │ │ │ ├── PlatformResult.cs
+│ │ │ │ └── HttpClientFactory.cs
+│ │ │ ├── Netease/
+│ │ │ │ ├── NeteaseClient.cs
+│ │ │ │ ├── NeteaseCrypto.cs # WBI/AES/RSA
+│ │ │ │ ├── NeteasePlaylistApi.cs
+│ │ │ │ ├── NeteaseSongUrlApi.cs
+│ │ │ │ ├── NeteaseLyricApi.cs
+│ │ │ │ └── NeteaseQrLoginClient.cs
+│ │ │ ├── Bili/
+│ │ │ │ ├── BiliClient.cs
+│ │ │ │ ├── BiliAudioUrlApi.cs
+│ │ │ │ └── BiliLyricApi.cs
+│ │ │ ├── YouTube/
+│ │ │ │ ├── YouTubeMusicClient.cs
+│ │ │ │ ├── YouTubePlayerScriptStore.cs # player.js 缓存+PoToken
+│ │ │ │ └── YouTubeEjsChallengeSolver.cs
+│ │ │ ├── Lyrics/ # 歌词源
+│ │ │ │ ├── KugouLyricsClient.cs
+│ │ │ │ ├── LrcLibClient.cs
+│ │ │ │ └── LyricsSourceAggregator.cs
+│ │ │ └── Search/
+│ │ │ ├── SearchManager.cs
+│ │ │ └── SearchResultMerger.cs
+│ │ │
+│ │ ├── Download/ # 下载管理
+│ │ │ ├── DownloadManager.cs
+│ │ │ ├── DownloadQueue.cs
+│ │ │ ├── DownloadTask.cs
+│ │ │ ├── MetadataWriter.cs # TagLib# 封装
+│ │ │ └── DownloadDirectoryIndexer.cs # catalog/snapshot
+│ │ │
+│ │ ├── Diagnostics/ # 崩溃与安全模式
+│ │ │ ├── ExceptionHandler.cs
+│ │ │ ├── CrashReporter.cs
+│ │ │ ├── AppStartupPlanner.cs
+│ │ │ └── SafeModeManager.cs
+│ │ │
+│ │ └── Logging/
+│ │ └── AppLogger.cs # Serilog 封装
+│ │
+│ ├── NeriPlayer.Data/ # 数据层
+│ │ ├── Database/
+│ │ │ ├── NeriDbContext.cs
+│ │ │ ├── DbSeeder.cs
+│ │ │ └── Migrations/ # EF Core Migrations
+│ │ ├── Entities/ # EF Core 实体(对应 Room @Entity)
+│ │ │ ├── SongEntity.cs
+│ │ │ ├── PlaylistEntity.cs
+│ │ │ ├── PlaylistMemberEntity.cs
+│ │ │ ├── PlayHistoryEntity.cs
+│ │ │ ├── PlaybackQueueEntity.cs
+│ │ │ ├── PlaybackStatsEntity.cs
+│ │ │ ├── DownloadEntity.cs
+│ │ │ ├── DownloadSnapshotEntity.cs
+│ │ │ ├── SyncMetadataEntity.cs
+│ │ │ ├── TrafficStatsEntity.cs
+│ │ │ ├── CoverUrlMappingEntity.cs
+│ │ │ └── SettingsEntity.cs
+│ │ ├── Repositories/ # 仓储模式
+│ │ │ ├── SongRepository.cs
+│ │ │ ├── PlaylistRepository.cs
+│ │ │ ├── PlayHistoryRepository.cs
+│ │ │ ├── DownloadRepository.cs
+│ │ │ ├── PlaybackStatsRepository.cs
+│ │ │ ├── SyncMetadataRepository.cs
+│ │ │ └── SettingsRepository.cs
+│ │ ├── LocalMedia/ # 本地音乐管理
+│ │ │ ├── LocalMusicScanner.cs # 文件夹扫描
+│ │ │ ├── TagMetadataReader.cs # 标签读取
+│ │ │ └── LocalSongLibraryManager.cs
+│ │ ├── Sync/
+│ │ │ ├── ISyncProvider.cs
+│ │ │ ├── GitHubSyncProvider.cs
+│ │ │ ├── WebDavSyncProvider.cs
+│ │ │ ├── SyncMergeStrategy.cs
+│ │ │ └── SyncCoordinator.cs
+│ │ ├── Settings/
+│ │ │ ├── SettingsManager.cs
+│ │ │ ├── SettingsSchema.cs # Source Generator 输入
+│ │ │ └── SettingsSection.cs
+│ │ ├── Auth/
+│ │ │ ├── CredentialStore.cs # DPAPI 加密
+│ │ │ ├── CookieStore.cs
+│ │ │ └── LoginStateManager.cs
+│ │ └── Traffic/
+│ │ └── TrafficStatsService.cs
+│ │
+│ ├── NeriPlayer.UI/ # UI 层(Avalonia)
+│ │ ├── Views/
+│ │ │ ├── MainWindow.axaml # 主窗口(三栏布局)
+│ │ │ ├── NowPlayingView.axaml # 正在播放
+│ │ │ ├── LyricsView.axaml # 歌词页
+│ │ │ ├── LibraryView.axaml # 音乐库
+│ │ │ ├── PlaylistView.axaml # 歌单详情
+│ │ │ ├── SearchView.axaml # 搜索
+│ │ │ ├── DiscoverView.axaml # 首页推荐
+│ │ │ ├── DownloadsView.axaml # 下载中心
+│ │ │ ├── SettingsView.axaml # 设置
+│ │ │ ├── EqualizerView.axaml # 均衡器
+│ │ │ └── LoginView.axaml # 登录(QR)
+│ │ ├── ViewModels/
+│ │ │ ├── MainWindowViewModel.cs
+│ │ │ ├── NowPlayingViewModel.cs
+│ │ │ ├── LibraryViewModel.cs
+│ │ │ ├── PlaylistViewModel.cs
+│ │ │ ├── SearchViewModel.cs
+│ │ │ ├── DiscoverViewModel.cs
+│ │ │ ├── DownloadsViewModel.cs
+│ │ │ ├── SettingsViewModel.cs
+│ │ │ └── PlayerBarViewModel.cs # 底部播放条
+│ │ ├── Controls/
+│ │ │ ├── PlayerBar.axaml # 底部播放控制条
+│ │ │ ├── SongCard.axaml
+│ │ │ ├── AlbumArtControl.axaml # 旋转封面
+│ │ │ ├── FluidBackground.axaml # 流体背景(SkiaShader)
+│ │ │ ├── LyricsScroller.axaml # 歌词滚动
+│ │ │ ├── WaveformVisualizer.axaml # 频谱可视化
+│ │ │ ├── CircularProgress.axaml
+│ │ │ └── ToastControl.axaml
+│ │ ├── Themes/
+│ │ │ ├── ColorPalette.cs # 动态取色
+│ │ │ ├── ThemeManager.cs
+│ │ │ └── Styles/ # Fluent 主题覆盖
+│ │ ├── Converters/
+│ │ │ ├── TimeSpanConverter.cs
+│ │ │ ├── CoverUrlConverter.cs
+│ │ │ └── BoolToVisibilityConverter.cs
+│ │ ├── Effects/
+│ │ │ ├── ShaderBackgroundRenderer.cs # Skia 流体
+│ │ │ └── AcrylicBlurEffect.cs
+│ │ └── Services/
+│ │ ├── ImageCacheService.cs
+│ │ └── WindowManager.cs
+│ │
+│ ├── NeriPlayer.Background/ # 后台服务
+│ │ ├── PlaybackBackgroundService.cs # 无窗口播放
+│ │ ├── SmtcIntegration.cs # 系统媒体控制
+│ │ ├── TaskbarThumbnailButtons.cs # 任务栏缩略图
+│ │ ├── FloatingLyricsWindow.cs # 桌面歌词
+│ │ ├── SyncScheduledService.cs # 定时同步
+│ │ └── Notifications/
+│ │ └── ToastNotificationService.cs
+│ │
+│ └── NeriPlayer.SourceGen/ # C# Source Generators
+│ ├── SettingsGenerator.cs # @AutoSetting 等价物
+│ └── SettingsKeysGenerator.cs
+│
+└── tests/
+ ├── NeriPlayer.Core.Tests/ # 核心逻辑单元测试
+ ├── NeriPlayer.Data.Tests/ # 数据库集成测试
+ └── NeriPlayer.Api.Tests/ # API 解析测试(Mock HTTP)
+```
+
+### 3.2 依赖方向
+
+```
+NeriPlayer.App → NeriPlayer.Core, NeriPlayer.Data, NeriPlayer.UI, NeriPlayer.Background
+NeriPlayer.UI → NeriPlayer.Core, NeriPlayer.Data
+NeriPlayer.Background → NeriPlayer.Core, NeriPlayer.Data
+NeriPlayer.Core → (无 UI/Data 依赖,纯领域逻辑)
+NeriPlayer.Data → (EF Core、Refit,不依赖 UI)
+NeriPlayer.SourceGen → (仅编译期,不参与运行时依赖)
+```
+
+---
+
+## 四、核心模块实现策略
+
+### 4.1 PlayerManager 总控(对标 `core/player/PlayerManager.kt`)
+
+PlayerManager 是 NeriPlayer 的中枢,负责:播放状态机、URL 解析、持久化、策略分发、事件广播。
+
+```csharp
+public sealed class PlayerManager : IPlayerManager, IDisposable
+{
+ // 常量对标 Analysis.md 24.1 节
+ private static readonly TimeSpan MediaUrlStale = TimeSpan.FromMinutes(10); // MEDIA_URL_STALE_MS
+ private static readonly TimeSpan UrlRefreshCooldown = TimeSpan.FromSeconds(10); // URL_REFRESH_COOLDOWN_MS
+ private const int MaxConsecutiveFailures = 10; // 连续失败上限
+ private static readonly TimeSpan StatePersistInterval = TimeSpan.FromSeconds(15);
+ private static readonly TimeSpan DefaultFadeDuration = TimeSpan.FromMilliseconds(500);
+ private static readonly TimeSpan ProgressThrottle = TimeSpan.FromMilliseconds(80);
+
+ // 状态流(StateFlow 等价物 → IObservable)
+ public IObservable State { get; }
+ public IObservable CurrentSong { get; }
+ public IObservable Position { get; }
+ public IObservable AudioInfo { get; }
+ public IObservable Events { get; }
+
+ // 命令入口(对标 PlaybackCommand 策略)
+ public Task PlayAsync(IReadOnlyList playlist, int startIndex, PlaybackCommandSource source);
+ public Task PauseAsync(PlaybackCommandSource source);
+ public Task ResumeAsync(PlaybackCommandSource source);
+ public Task StopAsync(bool persist = true);
+ public Task NextAsync(bool auto = false);
+ public Task PreviousAsync();
+ public Task SeekAsync(TimeSpan position);
+ public Task SetVolumeAsync(float volume);
+ public Task SetRepeatModeAsync(RepeatMode mode);
+ public Task ToggleShuffleAsync();
+ public Task SetPlaybackSpeedAsync(float speed);
+ public Task SetPitchAsync(float pitch);
+ public Task SetStereoBalanceAsync(float balance);
+ public Task SetEqualizerBandsAsync(IReadOnlyList gains);
+
+ // 内部策略管道
+ private readonly IPlaybackFailurePolicy _failurePolicy;
+ private readonly IMediaUrlRefreshPolicy _urlRefreshPolicy;
+ private readonly ITrackEndDedupPolicy _trackEndDedup;
+ private readonly IProgressUpdatePolicy _progressPolicy;
+ private readonly IPlayerStatePersistence _persistence;
+}
+```
+
+### 4.2 播放状态机
+
+```
+[IDLE] --Play--> [LOADING] --Prepared--> [PLAYING] --Pause--> [PAUSED]
+ ^ | | |
+ | |--Failure(超限)-->[ERROR]| |
+ | | +--Seek--> |
+ +--Stop--[STOPPED]--+ +--Next-->[LOADING]
+```
+
+| 行为 | 实现要点 |
+|------|----------|
+| URL 保鲜 | 播放中每 10min 检查 URL 过期,后台刷新(冷却 10s 防抖) |
+| 连续失败保护 | 连续 10 次失败自动停止并广播事件(MAX_CONSECUTIVE_FAILURES) |
+| 淡入淡出 | 播放/暂停时 500ms 线性淡变(DEFAULT_FADE_DURATION_MS) |
+| 曲目结束去重 | 相邻结束事件 500ms 间隔守卫(TrackEndDeduplication) |
+| 进度节流 | 进度流 80ms 节流 + 2s 桶内去重(ProgressUpdatePolicy) |
+| 状态持久化 | 15s 周期 + 命令触发即时持久化(STATE_PERSIST_INTERVAL_MS) |
+| 播放计数 | 单曲听满 30s 才记 1 次(MIN_LISTEN_MS_FOR_PLAY_COUNT) |
+
+### 4.3 播放引擎封装
+
+```csharp
+public interface IPlaybackEngine : IDisposable
+{
+ Task LoadAsync(Uri mediaUri, PlaybackEngineOptions options);
+ Task PlayAsync();
+ Task PauseAsync();
+ Task SeekAsync(TimeSpan position);
+ Task SetVolumeAsync(float volume); // 0.0 ~ 1.0
+ Task SetRateAsync(float speed); // 变速
+ IObservable Events { get; }
+
+ // 音效管道
+ void ApplyEqualizer(IReadOnlyList gains); // 10-band
+ void ApplyStereoBalance(float balance); // -1.0 ~ 1.0
+ void ApplyVolumeNormalization(float gainDb);
+ void ApplyPitch(float semitones);
+
+ // 信息
+ TimeSpan Duration { get; }
+ IObservable Position { get; }
+ IObservable FftData { get; } // 可视化
+}
+```
+
+实现类:
+
+1. **VlcPlaybackEngine** —— 默认引擎
+ - LibVLCSharp 封装,支持 mp3/flac/aac/opus/wav/hls/http
+ - `MediaPlayer` + `Equalizer`(自带 10-band)
+ - FFT 通过独立读取 PCM 或 VLC 可视化数据实现
+2. **ExclusivePlaybackEngine** —— WASAPI 独占模式
+ - NAudio `WasapiOut`(`AudioClientShareMode.Exclusive`)
+ - 采样率跟随源文件(最高 192kHz)
+ - 独立音效处理链(ISampleProvider 管道)
+ - 输出格式自动协商 + 失败回退共享模式
+
+### 4.4 音效处理管道
+
+```
+MediaSource → Decoder → Resampler → [EQ] → [Balance] → [Normalizer] → [Pitch] → [Reverb] → WasapiOut
+ ↓
+ FFT → Waveform/频谱
+```
+
+| 效果 | 对标 NeriPlayer | 实现 |
+|------|----------------|------|
+| 均衡器 | VLC EQ | VLC Equalizer 或 BIQUAD 滤波器组(10 band) |
+| 立体声平衡 | `StereoBalanceAudioProcessor.kt` | NAudio ISampleProvider:(L+R)/2 混音 |
+| 音量归一化 | `VolumeNormalizationAudioProcessor.kt` | EBU R128 响度测量 + 增益补偿 |
+| 变速 | `normalizePlaybackSpeed` | VLC SetRate(保持音高) |
+| 变调 | `normalizePlaybackPitch` | SoundTouch 算法 |
+| 混响 | `PlaybackEffectsController` | Freeverb 算法实现 |
+| 可视化 | `AudioReactive.kt` | FFT(Cooley-Tukey)+ 平滑处理 |
+
+### 4.5 策略模式(对标 NeriPlayer 200+ 策略文件 → 简化)
+
+| 原版策略包 | 合并后策略类 | 职责 |
+|------------|-------------|------|
+| `policy/failure` | `PlaybackFailurePolicy` | 连续失败计数、停止阈值、错误分类 |
+| `policy/refresh` | `MediaUrlRefreshPolicy` | URL 过期检测、刷新冷却、在途请求去重 |
+| `policy/skip` | `VideoSkipPolicy` | 跳过/可裁剪段(Bilibili) |
+| `policy/progress` | `ProgressUpdatePolicy` | 进度节流、长音频特殊处理 |
+| `policy/pending` | `PendingMediaLoadPolicy` | 待播放媒体位置恢复 |
+| `policy/command` | `PlaybackCommandPolicy` | 命令源校验(UI/SMTC/快捷键) |
+| `policy/usb/*`(23 个) | `ExclusiveOutputPolicy` | WASAPI 设备路由、失败回退、缓冲 |
+| `policy/wake/*` | 无需(Windows 无 WakeLock) | — |
+| `policy/offload/*` | 无需(无硬件解码 offload 概念) | — |
+
+---
+
+## 五、数据模型与数据库设计
+
+### 5.1 SongItem(对标 `data/model/SongItem.kt`)
+
+```csharp
+public sealed record SongItem
+{
+ public long Id { get; init; } // 本地自增 ID
+ public required string Name { get; init; }
+ public required string Artist { get; init; }
+ public required string Album { get; init; }
+ public long AlbumId { get; init; }
+ public long DurationMs { get; init; }
+ public string? CoverUrl { get; init; }
+ public string? MediaUri { get; init; } // 原始媒体 URI
+ public string? StreamUrl { get; init; } // 已解析的流地址
+
+ // 平台标识(对标 channelId / audioId / subAudioId)
+ public string? ChannelId { get; init; } // "local"|"netease"|"bilibili"|"youtube_music"
+ public string? AudioId { get; init; }
+ public string? SubAudioId { get; init; }
+
+ // 歌词
+ public string? MatchedLyric { get; init; }
+ public string? MatchedTranslatedLyric { get; init; }
+ public PlaybackSource? MatchedLyricSource { get; init; }
+ public long UserLyricOffsetMs { get; init; }
+
+ // 自定义元数据
+ public string? CustomName { get; init; }
+ public string? CustomArtist { get; init; }
+ public string? CustomCoverUrl { get; init; }
+ public string? OriginalName { get; init; }
+ public string? OriginalArtist { get; init; }
+
+ // 本地文件
+ public string? LocalFileName { get; init; }
+ public string? LocalFilePath { get; init; }
+
+ // 同步
+ public List? SyncMembershipTokens { get; init; }
+ public long AddedAt { get; init; }
+}
+
+public enum PlaybackSource
+{
+ Local,
+ Netease,
+ Bilibili,
+ YouTubeMusic,
+}
+```
+
+### 5.2 StableKey 算法(对标 `SongIdentity.kt`)
+
+```csharp
+public static class SongIdentity
+{
+ /// 生成跨版本稳定的歌曲标识,用于去重、同步、持久化
+ public static string StableKey(this SongItem song)
+ {
+ // 本地文件:规范化绝对路径
+ if (song.IsLocalSong())
+ return $"local|{NormalizePath(song.LocalFilePath ?? song.MediaUri)}";
+
+ // 远程歌曲:平台 + 音频ID
+ return song.ChannelId switch
+ {
+ "netease" => $"netease|{song.AudioId ?? song.Id.ToString()}",
+ "bilibili" => $"bilibili|{song.AudioId}|{song.SubAudioId}",
+ "youtube_music" => $"ytm|{ExtractYouTubeVideoId(song.MediaUri)}",
+ _ => $"id|{song.Id}|{song.Album}|{song.MediaUri}"
+ };
+ }
+}
+```
+
+### 5.3 数据库 Schema(对标 Room 13 版迁移)
+
+```
+┌──────────────────────────────────────────┐
+│ neriplayer.db │
+├──────────────────────────────────────────┤
+│ songs id INTEGER PK, stable_key TEXT UNIQUE,
+│ name, artist, album, album_id, duration_ms,
+│ cover_url, media_uri, stream_url, channel_id,
+│ audio_id, sub_audio_id, matched_lyric,
+│ matched_translated_lyric, user_lyric_offset_ms,
+│ custom_name, custom_artist, custom_cover_url,
+│ local_file_name, local_file_path, added_at
+│ INDEX idx_songs_stable_key
+├──────────────────────────────────────────┤
+│ playlists id INTEGER PK, name, kind(本地/收藏/系统),
+│ remote_platform, remote_id, created_at, updated_at
+├──────────────────────────────────────────┤
+│ playlist_members playlist_id FK, song_id FK, position INTEGER,
+│ PRIMARY KEY(playlist_id, position)
+├──────────────────────────────────────────┤
+│ play_history id INTEGER PK, song_id FK, played_at, source
+├──────────────────────────────────────────┤
+│ playback_queue song_id FK, position INTEGER
+├──────────────────────────────────────────┤
+│ queue_state id INTEGER PK=1, index, position_ms,
+│ repeat_mode, shuffle_enabled,
+│ shuffle_restore_playlist_json TEXT
+├──────────────────────────────────────────┤
+│ playback_stats song_id PK, play_count, total_play_ms,
+│ last_played_at
+│ stat_buckets song_id, day_key, play_count
+├──────────────────────────────────────────┤
+│ downloads song_id PK, local_path, status, quality_key,
+│ progress, error, created_at
+│ download_snapshots root_key, bucket, entry_key, name, reference,
+│ media_uri, local_file_path, size_bytes,
+│ last_modified_ms, is_directory
+│ PRIMARY KEY(root_key, bucket, entry_key)
+├──────────────────────────────────────────┤
+│ sync_metadata key TEXT PK, etag, revision, updated_at
+│ sync_outbox id, song_id, action(UPSERT/DELETE), payload_json
+│ sync_checkpoints scope PK, token, updated_at
+├──────────────────────────────────────────┤
+│ traffic_stats day_key PK, bytes_sent, bytes_received
+├──────────────────────────────────────────┤
+│ cover_url_mapping local_url PK, network_url, updated_at
+├──────────────────────────────────────────┤
+│ settings key TEXT PK, value_json, updated_at
+├──────────────────────────────────────────┤
+│ cookie_credentials platform PK, encrypted_cookies BLOB,
+│ encrypted_refresh_token BLOB, updated_at
+│ (值使用 DPAPI 加密)
+└──────────────────────────────────────────┘
+```
+
+### 5.4 数据库迁移策略
+
+- 使用 **EF Core Migrations**,对应 NeriPlayer Room 的 13 版迁移
+- 每个 Schema 版本一个 Migration 类,保留历史迁移(`Database/Migrations/`)
+- 启动时自动 `Database.Migrate()`(对标 NeriPlayer 的迁移兼容)
+- 破坏性变更走「新建表 + 数据复制 + 旧表删除」三段式(Room 同款策略)
+
+### 5.5 播放状态持久化(对标 `PersistedPlayerState.kt`)
+
+```csharp
+public sealed record PersistedPlaybackState
+{
+ public IReadOnlyList Playlist { get; init; } = [];
+ public int Index { get; init; }
+ public string? MediaUrl { get; init; }
+ public long PositionMs { get; init; }
+ public bool ShouldResumePlayback { get; init; }
+ public RepeatMode? RepeatMode { get; init; }
+ public bool? ShuffleEnabled { get; init; }
+}
+```
+
+- 存储位置:`%APPDATA%/NeriPlayer/playback_state.json`
+- 写盘时机:每 15s + 播放命令(停止/切换)时
+- 恢复策略:启动后 1.5s 延迟恢复(对标 `INITIAL_SCAN_DELAY_MS`),失败静默跳过
+- 队列中的本地歌曲丢失时回退到「可恢复的本地媒体」检查(对标 `RestorableLocalMediaPolicy`)
+
+---
+
+## 六、播放引擎设计
+
+### 6.1 LibVLC 引擎详细设计
+
+```
+VlcPlaybackEngine
+├── LibVLC 初始化(--no-video、--audio-resampler、--network-caching=800ms)
+├── MediaPlayer 实例
+├── Equalizer(10-band:31Hz~16kHz,-20dB ~ +20dB)
+├── 事件桥接(Playing/Paused/EndReached/EncounteredError/Buffering/PositionChanged)
+├── 位置同步(MediaPlayer.Time 轮询 + 事件)
+└── URL 解析前置(HTTP/HLS 由 VLC 原生支持,无需额外解码器)
+```
+
+| 场景 | 处理 |
+|------|------|
+| 网络流 | `--network-caching=800` 缓冲;播放失败 → URL 重新解析 → 重试 1 次 |
+| 高音质 | FLAC 24bit 原生支持;采样率自动协商 |
+| 断网 | `EncounteredError` 事件 → 失败策略:连续失败计数 |
+| 拖拽 | `MediaPlayer.Time = ms` 毫秒级精确 |
+| 变速 | `MediaPlayer.Rate`(0.5~2.0) |
+| 音量 | `MediaPlayer.Volume`(0~100)映射到 0~1 |
+| 均衡器 | `Equalizer.Create(10 bands)` + `SetBands` |
+
+### 6.2 WASAPI 独占引擎详细设计(对标 USB 独占)
+
+```
+ExclusivePlaybackEngine (NAudio)
+├── WasapiOut(device, AudioClientShareMode.Exclusive, 30ms buffer)
+├── 源文件 → AudioFileReader → ISampleProvider 管道
+│ ├── VolumeNormalizationSampleProvider
+│ ├── StereoBalanceSampleProvider
+│ ├── EqualizerSampleProvider (BIQUAD)
+│ └── PitchShifterSampleProvider (SoundTouch)
+├── FftSampleProvider(实时 FFT 输出,用于可视化)
+├── 设备事件(MMDeviceEnumerator 监听默认设备变化)
+└── 失败回退(Exclusive 失败 → Shared 模式回退,对标 UsbExclusiveFallbackPolicy)
+```
+
+### 6.3 播放 URL 解析器
+
+```csharp
+public interface ISongUrlResolver
+{
+ Task ResolveAsync(SongItem song, QualityPreference quality);
+ bool Supports(SongItem song);
+}
+
+public sealed class SongUrlResolverChain
+{
+ private readonly IReadOnlyList _resolvers =
+ [
+ new LocalFileUrlResolver(), // 本地文件直接返回路径
+ new NeteaseSongUrlResolver(), // 网易云 v1/v2 加密 API
+ new BiliAudioUrlResolver(), // Bilibili 音频流
+ new YouTubeMusicUrlResolver(), // InnerTube + PoToken + EJS
+ ];
+
+ public async Task ResolveAsync(SongItem song, QualityPreference quality)
+ {
+ // 缓存命中检查(10min 有效)
+ // 逐个解析器尝试
+ // URL 保鲜:后台任务提前刷新将过期 URL
+ }
+}
+
+public record QualityPreference
+{
+ public string Netease { get; init; } = "exhigh"; // standard/higher/exhigh/loseless
+ public string YouTube { get; init; } = "high"; // low/medium/high/very_high
+ public string Bili { get; init; } = "high";
+}
+```
+
+### 6.4 曲目结束去重
+
+对标 `TrackEndDeduplication.kt`(500ms 间隔守卫):
+
+```csharp
+public sealed class TrackEndDeduplication
+{
+ private DateTimeOffset _lastEndAt;
+ private static readonly TimeSpan GuardWindow = TimeSpan.FromMilliseconds(500);
+
+ public bool TryConsume()
+ {
+ var now = DateTimeOffset.UtcNow;
+ if (now - _lastEndAt < GuardWindow) return false;
+ _lastEndAt = now;
+ return true;
+ }
+}
+```
+
+---
+
+## 七、音效系统设计
+
+### 7.1 效果链
+
+```
+AudioPipeline
+├── EqualizerEffect (10-band BIQUAD, 31Hz-16kHz, ±20dB, 支持预设)
+│ 预设:流行/摇滚/爵士/古典/电子/人声/自定义
+├── StereoBalanceEffect (-1.0 全左 ~ 0 平衡 ~ +1.0 全右)
+├── VolumeNormalization (EBU R128:测量 LUFS → 目标 -14 LUFS 增益)
+├── SpeedEffect (0.5x ~ 2.0x, 保音高)
+├── PitchEffect (-12 ~ +12 半音)
+└── ReverbEffect (房间大小/衰减/干湿比)
+```
+
+### 7.2 均衡器实现
+
+采用 **Direct Form I Biquad**(音频行业标准):
+
+```csharp
+public sealed class BiquadFilter
+{
+ public enum FilterType { Peaking, LowShelf, HighShelf }
+ public double B0, B1, B2, A1, A2; // 系数由频率/增益/带宽计算
+
+ public float Process(float input) // 直通式差分方程
+ {
+ // y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]
+ }
+}
+```
+
+10 段频率:`31.25, 62.5, 125, 250, 500, 1k, 2k, 4k, 8k, 16k`(Hz)
+
+### 7.3 音频可视化
+
+- FFT 实现:Cooley-Tukey 基 2 算法(512~4096 点,50% overlap)
+- 输出:对数频率刻度(20Hz~20kHz,64 频带)+ 峰值保持 + 衰减
+- 消费端:播放页频谱条、波形可视化
+
+---
+
+## 八、API 客户端设计
+
+### 8.1 统一平台接口
+
+```csharp
+public interface IPlatformClient
+{
+ string PlatformId { get; } // "netease" / "bili" / "youtube_music"
+ bool IsLoggedIn { get; }
+ Task LoginAsync(LoginMethod method); // QR / Cookie / Token
+
+ // 搜索
+ Task SearchAsync(string keyword, SearchScope scope, int page);
+
+ // 歌单
+ Task> GetFeaturedPlaylistsAsync(int page);
+ Task GetPlaylistAsync(string playlistId);
+
+ // 歌曲播放
+ Task ResolveSongUrlAsync(SongItem song, string qualityKey);
+
+ // 歌词
+ Task GetLyricAsync(SongItem song);
+
+ // 推荐
+ Task GetRecommendationsAsync();
+}
+```
+
+### 8.2 网易云客户端(对标 `NeteaseClient.kt` + `NeteaseCrypto.kt`)
+
+| 能力 | 实现 |
+|------|------|
+| 搜索 | `weapi/search/get`(POST,JSON) |
+| 歌曲 URL | `weapi/song/enhance/player/url/v1`(加密参数) |
+| 歌单详情 | `weapi/v6/playlist/detail` |
+| 歌词 | `weapi/song/lyric`(LRC + 翻译) |
+| 首页推荐 | `weapi/v3/homepage/page` |
+| 私人 FM | `weapi/radio/get` |
+| 加密 | AES-CBC(`0CoJUm6Qyw8W8jud` key)+ RSA(公钥 `010001`)+ 随机 secretKey |
+| WBI 签名 | 时间戳 + 随机数 + MD5/SHA1 摘要 |
+| 登录 | 二维码轮询(create + check) |
+| Cookie | DPAPI 加密文件,每次请求注入 |
+
+### 8.3 Bilibili 客户端(对标 `BiliClient.kt`)
+
+| 能力 | 实现 |
+|------|------|
+| 音频搜索 | `x/web-interface/search/type`(search_type=audio) |
+| 音频 URL | `audio/music-service-c/songs/url`(v2 接口,WBI 签名) |
+| 视频音频流 | `x/player/playurl`(fnval=16 提取 DASH audio) |
+| 歌词 | 音频详情接口内嵌 |
+| 登录 | 二维码(`x/passport-login/web/qrcode/generate`) |
+
+### 8.4 YouTube Music 客户端(对标 `YouTubeMusicClient.kt`)
+
+| 能力 | 实现 |
+|------|------|
+| 搜索 | InnerTube `youtubei/v1/search`(music 上下文) |
+| 歌曲 URL | 播放列表加载 → 提取 streamingData(PoToken + signature) |
+| player.js 解析 | 缓存到磁盘,48h 过期刷新(对标 `YouTubePlayerScriptStore`) |
+| PoToken | 缓存 6h;EJS 挑战失败回退 NewPipeExtractor |
+| 歌词 | InnerTube browse + watch 页面 |
+| 登录 | Cookie(SAPISID / __Secure-3PAPISID) |
+
+### 8.5 歌词源聚合(对标 LyriconManager)
+
+```
+LyricsProvider
+├── 内嵌歌词(文件内标签 / 远程匹配)
+├── 网易云歌词
+├── QQ 音乐歌词(DEFAULT_QQ_MUSIC_LYRIC_OFFSET_MS 补偿)
+├── Kugou 歌词(KC 解密 + LRC 转换)
+├── LrcLib 歌词
+└── 合并排序:内嵌 > 匹配源 > 平台官方 > 第三方
+```
+
+### 8.6 搜索聚合(对标 `SearchManager.kt`)
+
+- 多平台并发搜索(HttpClient 并行请求)
+- 结果合并去重(按 stableKey)
+- 排序:平台优先级(网易云 > YouTube > Bilibili)+ 相关度
+- 缓存:搜索结果 LRU(每平台 20 条)
+
+---
+
+## 九、下载管理系统设计
+
+### 9.1 架构(对标 `core/download/` 40+ 文件)
+
+```
+DownloadManager
+├── DownloadQueue # 任务队列(Semaphore 并发控制)
+│ ├── DefaultConcurrency = 6
+│ ├── MaxConcurrency = 8
+│ └── 取消后 5000ms 稳定期(DOWNLOAD_CANCEL_SETTLE_TIMEOUT_MS)
+├── DownloadTask # 单个任务(HttpClient 流式下载 + 进度上报)
+├── MetadataWriter # TagLib# 写标签(重试上限 3 次)
+├── DownloadDirectoryIndexer # 三层索引
+│ ├── catalog # 主目录清单
+│ ├── snapshot # 快照
+│ └── recovery # 恢复(异常退出后扫描)
+└── DownloadRepository # EF Core 持久化
+```
+
+### 9.2 下载流程
+
+```
+用户选择歌曲 + 质量 → 解析 URL → 创建 DownloadTask
+→ 信号量获取(并发 ≤ 8)
+→ HttpClient 流式下载到 .part 临时文件
+→ 完成后校验 → 重命名 → MetadataWriter 写入标签(封面/歌词/标题/艺人)
+→ 更新 catalog + snapshot 索引 → 通知 UI
+```
+
+### 9.3 断点续传
+
+- 下载中断 → 保留 `.part` + 已下载字节数(记录到 DB)
+- 恢复时 `Range: bytes=<已下载>-` 续传
+- 服务端不支持 Range → 全量重下
+
+### 9.4 下载目录结构
+
+```
+NeriPlayer/Music/
+├── netease/ # 按平台分类
+│ └── {artist}/{album}/{title}.flac
+├── bilibili/
+├── youtube/
+└── downloads.db # 索引数据库
+```
+
+---
+
+## 十、数据同步设计
+
+### 10.1 同步架构(对标 `data/sync/` + Analysis.md 第九章)
+
+```
+SyncCoordinator
+├── ISyncProvider (策略注入)
+│ ├── GitHubSyncProvider # Octokit → repo 内 JSON 文件
+│ └── WebDavSyncProvider # WebDAV → 远程目录
+├── SyncMergeStrategy # 冲突合并
+├── SyncOutbox # 待同步操作队列(断网缓存)
+├── SyncCheckpoint # 游标(增量同步)
+└── 计划任务 # Quartz:每日 / 手动 / 事件触发
+```
+
+### 10.2 同步内容
+
+| 数据 | 格式 | 冲突策略 |
+|------|------|----------|
+| 歌单(含歌曲元数据) | JSON(不含本地文件路径,仅稳定键) | 按 updated_at + 因果 Token |
+| 播放历史 | JSON(最近 1000 条) | 合并去重 |
+| 收藏 | JSON(platform + songId) | 合并 |
+| 设置 | `settings.json` | 最后写入胜出 |
+| 播放统计 | JSON | 合并(取 max 计数) |
+
+### 10.3 因果一致性(对标 SyncCausalToken)
+
+- 每条同步记录携带 `SyncToken { songId, baseVersion, operationId }`
+- 冲突时按「因果序 + 时间戳」裁决
+- 对应 `SyncOutboxEntity` / `SyncReplicaCheckpointEntity` 设计
+
+---
+
+## 十一、UI 设计
+
+### 11.1 主窗口布局(对标 NeriPlayer 三栏布局)
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ 标题栏(自绘:Logo + 导航切换 + 搜索框 + 窗口控制) │
+├──────────┬──────────────────────────────────┬─────────────────┤
+│ 侧边栏 │ 内容区(页面容器) │ 播放队列 │
+│ │ │ (可折叠) │
+│ 🏠 首页 │ │ │
+│ 📻 发现 │ (NowPlaying / Library / │ ┌──────────┐ │
+│ 💿 我的 │ Search / Playlist / ...) │ │ 当前队列 │ │
+│ 📥 下载 │ │ └──────────┘ │
+│ ⚙ 设置 │ │ │
+├──────────┴──────────────────────────────────┴─────────────────┤
+│ 底部播放条:封面 │ 歌名-艺人 │ 进度条 │ ♥ │ ⏮ ▶/⏸ ⏭ │ EQ │ 歌词 │ 音量 │
+└────────────────────────────────────────────────────────────────┘
+```
+
+### 11.2 正在播放页
+
+| 区域 | 实现 |
+|------|------|
+| 背景 | 流体背景 Shader(SkiaShader 重写 AGSL `hyper_background_effect.glsl`) |
+| 封面 | 旋转唱片动画(模糊 + 圆角遮罩 + 光影) |
+| 主控区 | 进度条(可拖拽)+ 播放/暂停/上下曲/循环/随机 |
+| 歌词区 | 可切换歌词视图(滚动高亮 + 翻译) |
+| 音效区 | EQ 面板(10 滑块)+ 混响 + 立体声 |
+| 频谱 | WaveformVisualizer 组件 |
+
+### 11.3 流体背景 Shader
+
+对标 `assets/shaders/hyper_background_effect.glsl`(5 色点 + 噪声流动):
+
+- **Skia 实现**:用 `SKRuntimeEffect`(Skia 的 Runtime Effect,GLSL 兼容)
+- 直接迁移原 GLSL 到 Skia `sksl` 语法(改动极小)
+- 参数:5 个色点(uniform vec4[5])+ 时间流 u_time + 鼠标交互偏移
+- 降级策略:GPU 不支持 → 静态渐变
+
+### 11.4 毛玻璃效果
+
+对标 `advanced_glass_*.agsl`(高级模糊毛玻璃):
+
+- 方案 A:`SKImageFilter.CreateBlur` + 背景快照(静态场景)
+- 方案 B:Avalonia `ExperimentalAcrylicBorder`(实时模糊)
+- 推荐:B(Avalonia 内置,性能好)
+
+### 11.5 歌词页面
+
+- `LyricsScroller` 控件:ItemsControl + 虚拟化 + 居中高亮行
+- 滚动算法:positionMs → 行索引,当前行 1.25x 字号 + 主题色
+- 翻译歌词:双行(原文 + 翻译),可开关
+- 歌词偏移:长按 +/- 微调(1s 步进,对标 userLyricOffsetMs)
+- 歌词搜索:标题+艺人 → 多源歌词匹配
+- 分享卡片:1080px 宽渲染
+
+### 11.6 首页推荐
+
+| 分区 | 数据源 | 上限 |
+|------|--------|------|
+| 每日推荐 | 网易云 `recommend/songs` | 30 首 |
+| 推荐歌单 | 网易云 `personalized/playlist` | 30 个 |
+| 私人 FM | 网易云 `radio/get` | 批量 10 |
+| 雷达歌单 | 固定 5 个 ID(时光/宝藏/新歌/乐迷/神秘) | 5 个 |
+| 失败降级 | code 301 / 50000005 → 回退热门歌单 | — |
+
+### 11.7 主题系统
+
+- 暗色/亮色 + 跟随系统(ActualThemeVariant)
+- 动态取色:封面 → 主色调(中位切分算法,对标 Palette ktx)
+- Material.Avalonia 主题样式(Material 3 风格)
+- 设置:主题、强调色、字体大小、模糊强度、流体背景开/关
+
+### 11.8 图片加载
+
+- `ImageCacheService`:HTTP 图片 → 磁盘缓存(%LOCALAPPDATA%/NeriPlayer/image-cache,LRU 1GB)
+- 内存缓存:ConcurrentDictionary + 大小预算(32MB)
+- 封面占位:渐变 + 首字母
+- 对标 Coil 的请求优先级 + 内存/磁盘双层缓存
+
+---
+
+## 十二、后台服务与系统集成
+
+### 12.1 SMTC(SystemMediaTransportControls)
+
+对标 Android MediaSession:
+
+```csharp
+public sealed class SmtcIntegration
+{
+ private readonly SystemMediaTransportControls _smtc;
+
+ // 更新
+ void UpdateMetadata(SongItem song, string? albumArtPath);
+ void UpdatePlaybackStatus(PlaybackStatus status); // Playing/Paused/Stopped
+ void UpdatePosition(TimeSpan position, TimeSpan duration);
+
+ // 事件 → PlayerManager
+ ButtonPressed (Play/Pause/Next/Previous/Seek/Stop)
+}
+```
+
+功能点:
+- 任务栏媒体悬浮窗(封面 + 标题 + 进度 + 控制按钮)
+- Win+L 锁屏媒体控制
+- 媒体键(键盘上的 ⏯⏭⏮)
+
+### 12.2 任务栏缩略图按钮
+
+- 播放/暂停、上一曲、下一曲 3 个按钮
+- `ThumbButtonInfo` + `TaskbarItemInfo`
+
+### 12.3 桌面歌词(对标 FloatingLyricsOverlayManager)
+
+- `FloatingLyricsWindow`:Topmost + 透明 + 无边框 + 可拖动
+- 双行:原文 + 翻译
+- 设置:字号、颜色、位置锁定、点击穿透
+
+### 12.4 后台播放(对标 AudioPlayerService)
+
+- 无 UI 场景:窗口关闭后播放不中断
+- 实现:托盘图标(TrayIcon)+ 后台线程持锁 + SMTC 常驻
+- 退出策略:托盘「退出」才真正释放引擎(对标 `PlaybackServiceIdlePolicy`)
+
+### 12.5 通知
+
+- 播放状态变化 → Toast 通知(ToastNotificationManager)
+- 下载完成 → Toast + 点击打开位置
+- 同步完成/失败 → Toast
+
+### 12.6 定时任务(对标 WorkManager)
+
+| 任务 | 调度 | 说明 |
+|------|------|------|
+| 同步 | 每日 02:00 + 手动 | Quartz cron |
+| 播放统计刷盘 | 15s 周期 | PLAYBACK_STATS_PERIODIC_FLUSH_MS |
+| URL 保鲜 | 事件触发 | 播放中每 10min |
+| 清理过期缓存 | 每周 | 图片缓存 LRU 清理 |
+
+---
+
+## 十三、安全与崩溃恢复
+
+### 13.1 凭据安全(对标 androidx.security-crypto)
+
+| 数据 | 存储 | 加密 |
+|------|------|------|
+| Cookie(三平台) | `%APPDATA%/NeriPlayer/secure/cookies.bin` | **DPAPI**(CurrentUser) |
+| 刷新 Token | 同上 | DPAPI |
+| 网易云设备 ID | `%APPDATA%/NeriPlayer/secure/device_id` | 明文 + 权限限制 |
+
+### 13.2 崩溃处理(对标 ExceptionHandler + AnrWatchdog)
+
+```csharp
+public static class ExceptionHandler
+{
+ public static void Install()
+ {
+ AppDomain.CurrentDomain.UnhandledException += OnUnhandled;
+ TaskScheduler.UnobservedTaskException += OnUnobservedTask;
+ Application.Current.DispatcherUnhandledException += OnDispatcher; // Avalonia
+ }
+
+ // 崩溃报告:写入 %LOCALAPPDATA%/NeriPlayer/crash/
+ // 上次崩溃检测:启动时扫描 crash/ 目录 → 提示是否发送报告
+ // 安全模式:连续 2 次崩溃 → 下次启动禁用自动同步/自启
+}
+```
+
+### 13.3 安全模式(对标 SafeModeManager + AppStartupPlanner)
+
+- 触发条件:连续 2 次启动即崩溃、数据库迁移失败、设置文件损坏
+- 行为:禁用自动同步、禁用 YouTube PoToken 自研解析(走回退)、清空异常缓存
+- 用户可从设置页退出安全模式
+
+### 13.4 数据备份
+
+- 一键导出:settings.json + playlists.json + 播放历史 JSON(对标同步格式)
+- 手动备份到任意目录
+
+---
+
+## 十四、测试体系
+
+### 14.1 单元测试(NeriPlayer.Core.Tests)
+
+| 被测模块 | 用例 |
+|----------|------|
+| 歌词解析器 | LRC 偏移/多语言/空行/损坏输入 |
+| StableKey | 本地/远程/YouTube 视频 ID 提取 |
+| 曲目去重 | 500ms 窗口内去重 |
+| 失败策略 | 连续失败计数与停止 |
+| 播放状态机 | 状态转移合法/非法 |
+| 音效处理 | EQ 增益正确性、立体声平衡端点值 |
+| 下载队列 | 并发限制、取消稳定期 |
+| 同步合并 | 因果 Token 冲突裁决 |
+| 网易云加密 | 已知输入输出向量(固定密钥) |
+| 搜索合并 | 多源去重排序 |
+
+### 14.2 集成测试(NeriPlayer.Data.Tests)
+
+- EF Core 迁移正确性(SQLite 内存库)
+- Repository CRUD
+- 播放状态持久化 round-trip
+
+### 14.3 UI 测试
+
+- Avalonia Headless 测试框架(Avalonia.Headless.XUnit)
+- 关键视图:播放条、歌词滚动、主窗口布局
+
+### 14.4 性能基准
+
+| 场景 | 目标 |
+|------|------|
+| 启动(冷启动到可播放) | ≤ 3s |
+| 本地库 10,000 首扫描 | ≤ 30s(增量扫描 ≤ 2s) |
+| 内存占用(5000 首歌单) | ≤ 400MB |
+| 连续播放 48h | 无泄漏、无崩溃 |
+| 封面缓存 | 启动后第二次加载 ≤ 100ms |
+
+---
+
+## 十五、打包与发布
+
+### 15.1 打包格式
+
+| 格式 | 用途 |
+|------|------|
+| MSIX | Microsoft Store / 企业分发(自动更新) |
+| 便携版 (self-contained) | 免安装单文件夹 |
+| NSIS 安装包 | 传统安装器(可选) |
+
+### 15.2 发布配置
+
+- `dotnet publish -c Release -r win-x64 --self-contained true`
+- 包含 libvlc.dll + libvlccore.dll + plugins/ 目录
+- 代码签名(EV 证书,可选)
+
+### 15.3 CI/CD(GitHub Actions)
+
+```
+workflows/build.yml
+├── job: windows-build
+│ ├── checkout + setup .NET 8
+│ ├── restore + build(含 VLC 依赖)
+│ ├── run tests(xunit)
+│ ├── publish win-x64
+│ └── upload artifact(MSIX + portable)
+```
+
+---
+
+## 十六、分阶段实施计划
+
+### 阶段 1:项目脚手架与环境搭建(第 1 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 1.1 | 创建解决方案 + 各项目骨架 | sln + 4 个 csproj | `dotnet build` 通过 |
+| 1.2 | 配置 Directory.Build.props / Packages.props | 全局编译配置 | 统一 SDK/Nullable |
+| 1.3 | 配置 DI 容器(AppStartup) | ServiceCollection 装配 | 应用可启动 |
+| 1.4 | 配置 Serilog(文件 + 控制台) | 日志目录 | 启动日志可查 |
+| 1.5 | 安装 Avalonia 模板 + 空窗口 | 空主窗口 | 窗口显示 |
+
+### 阶段 2:核心数据模型(第 1-2 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 2.1 | SongItem / SongIdentity / PlaybackSource | model 类 | 单元测试通过 |
+| 2.2 | PlaybackAudioInfo / 质量选项 | model 类 | 质量标签格式化正确 |
+| 2.3 | PlayerEvent / 事件枚举 | model 类 | 事件订阅测试 |
+| 2.4 | StableKey 算法 + 测试 | SongIdentity.cs | 去重测试通过 |
+| 2.5 | JSON 序列化契约测试 | 序列化测试 | round-trip 稳定 |
+
+### 阶段 3:本地数据库(第 2-3 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 3.1 | EF Core + SQLite 集成 | NeriDbContext | 迁移可生成 |
+| 3.2 | 设计全部 Entity(15+ 表) | Entities/ | Schema 检查 |
+| 3.3 | 初始迁移 + 种子数据 | Migrations/ | 迁移执行成功 |
+| 3.4 | Repository 层 | Repositories/ | CRUD 集成测试 |
+| 3.5 | 设置存储(SettingsRepository) | 配置读写 | 读写 round-trip |
+| 3.6 | 播放状态持久化 | PlayerStatePersistence | 恢复测试 |
+
+### 阶段 4:播放引擎(第 3-5 周)← 最核心
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 4.1 | IPlaybackEngine 接口定义 | 接口 | 编译通过 |
+| 4.2 | LibVLC 集成 + 本地文件播放 | VlcPlaybackEngine | 播放本地 mp3/flac |
+| 4.3 | 播放/暂停/Seek/音量/循环/随机 | PlayerManager 命令 | 状态机测试 |
+| 4.4 | HTTP 流播放 + URL 解析链 | SongUrlResolverChain | 播放网络流 |
+| 4.5 | 淡入淡出 + 失败保护 | 策略类 | 失败恢复测试 |
+| 4.6 | 播放状态持久化恢复 | persistence | 重启恢复播放 |
+| 4.7 | HLS 播放(VLC 原生) | 引擎测试 | HLS 流可播放 |
+
+### 阶段 5:音效系统(第 5-6 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 5.1 | EQ 10-band(BIQUAD) | EqualizerEffect | 频响测试 |
+| 5.2 | 立体声平衡 | StereoBalanceEffect | 端点值测试 |
+| 5.3 | 音量归一化(R128) | VolumeNormalization | 响度测试 |
+| 5.4 | 变速变调 | SpeedPitchEffect | 音质主观验收 |
+| 5.5 | FFT 可视化数据 | FftAnalyzer | 频谱输出稳定 |
+| 5.6 | EQ 预设 + 保存 | UI 集成 | 设置持久化 |
+
+### 阶段 6:API 客户端(第 6-8 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 6.1 | 网易云搜索 + 歌单 | NeteaseClient | 真实搜索可返回 |
+| 6.2 | 网易云 URL 解析(WBI 签名) | NeteaseCrypto | 播放验证 |
+| 6.3 | 网易云歌词 | NeteaseLyricApi | LRC 正确 |
+| 6.4 | 网易云登录(二维码) | NeteaseQrLoginClient | 扫码登录 |
+| 6.5 | Bilibili 音频搜索 + URL | BiliClient | 播放验证 |
+| 6.6 | YouTube Music 搜索 + URL(PoToken) | YouTubeMusicClient | 播放验证(P2) |
+| 6.7 | 歌词源聚合(Kugou/LrcLib) | LyricsProvider | 多源回退 |
+| 6.8 | 搜索聚合器 | SearchManager | 多平台结果合并 |
+
+### 阶段 7:下载管理(第 8-9 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 7.1 | 下载队列 + 并发控制 | DownloadManager | 并发限制测试 |
+| 7.2 | 流式下载 + 进度 | DownloadTask | 进度连续 |
+| 7.3 | 断点续传 | 恢复逻辑 | 中断恢复 |
+| 7.4 | 标签写入(TagLib#) | MetadataWriter | 封面嵌入成功 |
+| 7.5 | 目录索引(catalog/snapshot) | Indexer | 扫描正确 |
+| 7.6 | 下载中心 UI | DownloadsView | 管理界面可用 |
+
+### 阶段 8:数据同步(第 9-10 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 8.1 | GitHub 同步 Provider | GitHubSyncProvider | 推送/拉取成功 |
+| 8.2 | WebDAV 同步 Provider | WebDavSyncProvider | 推送/拉取成功 |
+| 8.3 | 合并策略 + 因果 Token | SyncMergeStrategy | 冲突测试 |
+| 8.4 | 增量同步 + 游标 | SyncCoordinator | 只传增量 |
+| 8.5 | 设置备份/恢复 | 备份功能 | 备份还原 |
+
+### 阶段 9:UI 主框架(第 10-11 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 9.1 | 主窗口三栏布局 + 导航 | MainWindow | 布局完成 |
+| 9.2 | 底部播放条(绑定 PlayerManager) | PlayerBar | 播放联动 |
+| 9.3 | 正在播放页 | NowPlayingView | 核心控制可用 |
+| 9.4 | 歌词页(滚动高亮) | LyricsView | 歌词滚动正确 |
+| 9.5 | 音乐库 + 本地扫描 | LibraryView | 扫描 + 展示 |
+| 9.6 | 歌单详情 | PlaylistView | 歌曲列表操作 |
+| 9.7 | 搜索页 | SearchView | 搜索展示 |
+| 9.8 | 首页推荐 | DiscoverView | 推荐加载 |
+| 9.9 | 设置页(SourceGen 生成) | SettingsView | 设置可用 |
+| 9.10 | 主题系统(暗/亮 + 动态取色) | ThemeManager | 切换即时生效 |
+
+### 阶段 10:后台与系统集成(第 10-12 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 10.1 | SMTC 集成 | SmtcIntegration | 任务栏媒体控制 |
+| 10.2 | 托盘图标 + 后台播放 | TrayIntegration | 关窗继续播放 |
+| 10.3 | 任务栏缩略图按钮 | ThumbnailButtons | 按钮可用 |
+| 10.4 | 桌面歌词窗口 | FloatingLyricsWindow | 悬浮歌词 |
+| 10.5 | Toast 通知 | NotificationService | 下载完成通知 |
+| 10.6 | Quartz 定时任务 | SyncScheduledService | 定时同步 |
+
+### 阶段 11:高级特性(第 11-13 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 11.1 | WASAPI 独占引擎 | ExclusivePlaybackEngine | 独占输出 |
+| 11.2 | 流体背景 Shader | FluidBackground | GPU 流畅运行 |
+| 11.3 | 毛玻璃效果 | AcrylicBlurEffect | 视觉效果 |
+| 11.4 | 频谱可视化 | WaveformVisualizer | 随音乐跳动 |
+| 11.5 | 迷你播放器 | MiniPlayerView | 简洁模式 |
+| 11.6 | 快捷键 | HotkeyManager | 全局快捷键 |
+
+### 阶段 12:测试与打磨(第 13-14 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 12.1 | 核心单元测试补全 | tests/ | 覆盖率 ≥ 60% |
+| 12.2 | 数据层集成测试 | tests/ | 迁移/CRUD 通过 |
+| 12.3 | UI Headless 测试 | tests/ | 关键视图渲染 |
+| 12.4 | 性能基准 | benchmark 报告 | 达标(见 14.4) |
+| 12.5 | 崩溃恢复验证 | 验证报告 | 安全模式可用 |
+| 12.6 | 长时间播放稳定性(48h) | 稳定性报告 | 无泄漏无崩溃 |
+
+### 阶段 13:打包与发布(第 14-16 周)
+
+| # | 任务 | 产出 | 验收 |
+|---|------|------|------|
+| 13.1 | MSIX 打包 | .msix | 安装成功 |
+| 13.2 | 便携版发布 | portable zip | 解压即用 |
+| 13.3 | GitHub Actions CI/CD | workflows | 自动构建测试 |
+| 13.4 | 版本号管理 + 更新检查 | AutoUpdater | 版本提示 |
+| 13.5 | 用户文档 + README | docs | 使用说明 |
+
+---
+
+## 十七、架构简化清单
+
+NeriPlayer 共 1748 个 Kotlin 文件,Windows 重写必须做架构简化(保留能力、精简实现):
+
+| 原版模块 | 原版规模 | 简化后规模 | 说明 |
+|----------|----------|-----------|------|
+| `core/player/` | 124 文件(23 策略包) | ~25 文件 | 策略合并为 6 个内聚类 |
+| `core/api/` | 34 文件 | ~25 文件 | 保留三平台,统一接口 |
+| `data/local/` | ~55 文件 | ~25 文件 | EF Core 减少 DAO/Store 冗余 |
+| `data/sync/` | ~20 文件 | ~10 文件 | 抽象 Provider |
+| `data/settings/` | ~15 文件 | ~8 文件 | SourceGen 自动生成 |
+| `core/download/` | ~45 文件 | ~8 文件 | 队列模型简化 |
+| `listentogether/` | ~20 文件 | **0**(砍掉) | 非 Windows 场景 |
+| `core/player/usb/` | ~35 文件 | ~3 文件 | WASAPI 独占替代 |
+| `widget/` | ~15 文件 | **0**(砍掉) | SMTC 替代 |
+| `core/lyricon/` | ~8 文件 | ~2 文件 | 内置歌词源替代 |
+| **合计** | **~770** | **~130** | 代码量约减少 83% |
+
+### 必须保留的复杂设计
+
+| 设计 | 保留原因 |
+|------|----------|
+| URL 保鲜 + 刷新冷却 | 在线播放核心体验 |
+| 连续失败保护 | 网络波动兜底 |
+| StableKey + 去重 | 同步/队列基础 |
+| 因果 Token 同步 | 多端数据一致性 |
+| 下载三层索引 | 异常退出后文件可恢复 |
+| 播放状态 15s 持久化 | 崩溃恢复基础 |
+| 听满 30s 计次 | 统计准确 |
+| 进度节流 + 曲目去重 | 状态一致性 |
+
+---
+
+## 十八、风险与缓解
+
+| # | 风险 | 概率 | 影响 | 缓解措施 |
+|---|------|------|------|----------|
+| 1 | 网易云/B站 API 变更 | 高 | 高 | 版本化接口 + 失败降级 + URL 保鲜重试 |
+| 2 | YouTube PoToken 反爬升级 | 高 | 中 | 分层:自研解析 → NewPipe 回退 → 用户 Cookie |
+| 3 | LibVLC 集成问题(Avalonia 兼容) | 中 | 高 | 提前 PoC(第 4 阶段第 1 周先行验证) |
+| 4 | EF Core 迁移与 Room 13 版 schema 差异 | 低 | 中 | 数据导出/导入 JSON 作为迁移桥 |
+| 5 | 流体 Shader 性能(低端 GPU) | 中 | 低 | 降级为静态渐变 |
+| 6 | WASAPI 独占设备独占失败 | 中 | 低 | 自动回退共享模式 + 设备变化监听 |
+| 7 | 大曲库性能(>5 万首) | 低 | 中 | 分页 + 虚拟化 + 增量扫描 |
+| 8 | 工期超预期 | 中 | 高 | 严格按 P0/P1 优先级裁剪;阶段 6.6/11.x 可后置 |
+
+**关键技术 PoC(先于主开发)**:
+1. Avalonia 空窗口 + LibVLC 播放本地音频(半天)
+2. LibVLC 播放网易云 HTTP 流(1 天)
+3. EF Core SQLite 迁移执行(半天)
+4. Skia RuntimeEffect 流体 Shader 跑通(1 天)
+
+---
+
+## 十九、验收标准
+
+### 19.1 功能验收(对标 NeriPlayer 核心能力)
+
+| 能力 | 验收标准 |
+|------|----------|
+| 本地播放 | mp3/flac/wav/aac/ogg/m4a 全部可播,标签正确 |
+| 网易云播放 | 搜索→歌单→播放→歌词全链路可用,登录后可播高音质 |
+| Bilibili 播放 | 音频搜索可播(P1),视频提取音频可播(P2) |
+| YouTube Music | 搜索可播(P2,PoToken 可回退 NewPipe) |
+| 歌词 | LRC 滚动 + 翻译 + 偏移校准 + 多源回退 |
+| 音效 | EQ 10 段可调、立体声、变速不变调 |
+| 下载 | 并发 8、断点续传、封面嵌入、目录索引恢复 |
+| 同步 | GitHub/WebDAV 双向同步、冲突正确合并 |
+| 播放恢复 | 重启后恢复队列/进度/模式 |
+| 统计 | 播放计数(30s 规则)、流量统计 |
+
+### 19.2 非功能验收
+
+| 维度 | 标准 |
+|------|------|
+| 启动时间 | 冷启动 ≤ 3s |
+| 内存 | 峰值 ≤ 400MB(5000 首队列) |
+| 稳定性 | 48h 连续播放无崩溃 |
+| 兼容性 | Win10 21H2+ / Win11 |
+| 可维护性 | 核心逻辑单元测试覆盖率 ≥ 60% |
+| 部署 | 便携版免安装 + MSIX 可选 |
+
+### 19.3 里程碑
+
+| 里程碑 | 时间点 | 交付物 |
+|--------|--------|--------|
+| M1 可播放本地音乐 | 第 5 周末 | 本地文件可播 + UI 播放条 |
+| M2 可播在线音乐 | 第 8 周末 | 网易云/B站播放全链路 |
+| M3 Beta 版 | 第 11 周末 | 核心功能 + UI + 后台集成 |
+| M4 RC 版 | 第 14 周末 | 全功能 + 测试通过 |
+| M5 v1.0 发布 | 第 16 周末 | 打包 + CI + 文档 |
+
+---
+
+*NeriPlayer → Windows 移植方案 · Process.md · 2026-08-12*
+
diff --git a/dotnet-windows/README.md b/dotnet-windows/README.md
deleted file mode 100644
index 3b71485..0000000
--- a/dotnet-windows/README.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# NeriPlayer Windows (dotnet-windows)
-
-> ⚠️ **本目录是独立的 .NET 技术栈实现方案,与仓库根目录的 Tauri (Rust + Vue) 官方实现互不干扰。**
-
-## 这是什么
-
-这是将 NeriPlayer 移植到 Windows 桌面端的 **.NET 8 + Avalonia UI** 实现方案,与官方 `NeriPlayer-Desktop`(Tauri 2 + Rust + Vue 3)为**平行独立的两套技术栈**。
-
-- 本目录代码不参与仓库根目录的 `pnpm` / `cargo` 构建体系
-- 保留 3 个历史提交(脚手架 → 核心数据模型 → 对齐修复)
-- 作为技术方案参考与对比,供社区评估不同实现路线
-
-## 技术栈
-
-| 组件 | 选型 |
-|------|------|
-| 运行时 | .NET 8 LTS |
-| UI | Avalonia UI 11.x |
-| 播放引擎 | LibVLCSharp 8.x(VLC 3.0.x) |
-| 数据库 | EF Core 8 + SQLite |
-| 音效 | NAudio (WASAPI) / Biquad 滤波器 |
-| 系统集成 | SMTC / 托盘 / Toast |
-
-## 项目结构
-
-```
-src/
-├── NeriPlayer.App/ 主应用入口(Avalonia Desktop)
-├── NeriPlayer.Core/ 核心业务层(播放/歌词/下载/策略)
-├── NeriPlayer.Data/ 数据层(EF Core / 同步)
-├── NeriPlayer.UI/ UI 层(Avalonia 视图)
-└── NeriPlayer.Background/ 后台服务(SMTC / 托盘)
-tests/ 单元测试(xunit)
-```
-
-## 构建与运行
-
-```powershell
-dotnet build NeriPlayer.Windows.sln
-dotnet test tests/NeriPlayer.Core.Tests
-dotnet run --project src/NeriPlayer.App
-```
-
-> 详细实施方案见 `Analysis.md`(源码分析 24 章)与 `Process.md`(移植方案 19 章)。
diff --git a/dotnet-windows/start.md b/dotnet-windows/start.md
new file mode 100644
index 0000000..e58acb8
--- /dev/null
+++ b/dotnet-windows/start.md
@@ -0,0 +1,3216 @@
+# NeriPlayer → Windows 移植 · 详细实现过程(Start)
+
+> 本文档是《Process.md》的**逐步骤执行版**:每个阶段给出可复制的命令、完整代码、验证命令与验收标准。
+> 依据:《Analysis.md》(源码分析 24 章)+《Process.md》(移植方案 19 章)
+> 目标:.NET 8 + Avalonia 11 + LibVLCSharp 3.8 的 Windows 音乐播放器
+> 建议按章节顺序执行,每章末尾的「✅ 验收」通过后才进入下一章。
+
+---
+
+## 目录
+
+1. [环境搭建](#一环境搭建第01-03天)
+2. [项目脚手架](#二项目脚手架第04-06天)
+3. [核心数据模型](#三核心数据模型第07-09天)
+4. [本地数据库](#四本地数据库第10-14天)
+5. [播放引擎](#五播放引擎第15-22天)
+6. [音效系统](#六音效系统第23-27天)
+7. [API 客户端](#七api-客户端第28-37天)
+8. [下载管理](#八下载管理第38-44天)
+9. [数据同步](#九数据同步第45-50天)
+10. [UI 主框架](#十ui-主框架第51-62天)
+11. [后台与系统集成](#十一后台与系统集成第63-68天)
+12. [视觉效果](#十二视觉效果第69-73天)
+13. [安全与崩溃恢复](#十三安全与崩溃恢复第74-77天)
+14. [测试体系](#十四测试体系贯穿全程)
+15. [打包发布](#十五打包与发布第78-84天)
+
+---
+
+## 〇、协作与提交流程(每个章节完成后必须执行)
+
+> 本文档的每个章节(每一步)完成后,**除完成本地验收外,还必须同步更新远端仓库并提交上游 PR**。
+> 该流程从「第三章 核心数据模型」完成时开始执行(第一次已执行,见下方记录)。
+
+### 0.1 仓库布局
+
+| 仓库 | 地址 | 用途 |
+|------|------|------|
+| 本地源码 | `D:\Project\Library\NeriPlayer.Windows` | 主开发目录(git 仓库) |
+| 独立远端 | `https://github.com/ALIve114514awa/NeriPlayer-Windows-DotNet` | 个人备份 / 独立展示(origin) |
+| 上游 fork | `D:\Project\Library\NeriPlayer-Desktop-fork` | 用于向官方仓库提交 PR |
+| 上游仓库 | `https://github.com/cwuom/NeriPlayer-Desktop` | Tauri 官方实现(PR 目标,base=`main`) |
+| PR 分支 | `feat/dotnet-windows-impl` | fork 内固定特性分支(反复更新) |
+| 子目录 | `dotnet-windows/` | .NET 实现在 fork 内的存放位置(不动根目录) |
+
+### 0.2 每步提交流程(6 个动作)
+
+```powershell
+# 1) 本地提交
+cd D:\Project\Library\NeriPlayer.Windows
+git add -A
+git commit -m "feat(第N章): <说明>"
+
+# 2) 推送到个人独立远端(保留完整历史)
+git push origin master
+
+# 3) 同步 .NET 代码到 fork 的 dotnet-windows/ 子目录
+# 复制除 .git / bin / obj 之外的全部源码
+# 简单做法:整个目录覆盖复制后删除嵌套 .git(git add 时 bin/obj 会被 .gitignore 自动排除)
+Remove-Item D:\Project\Library\NeriPlayer-Desktop-fork\dotnet-windows -Recurse -Force
+Copy-Item D:\Project\Library\NeriPlayer.Windows D:\Project\Library\NeriPlayer-Desktop-fork\dotnet-windows -Recurse -Force
+Remove-Item D:\Project\Library\NeriPlayer-Desktop-fork\dotnet-windows\.git -Recurse -Force
+
+# 4) fork 内提交并推送(自动更新既有 PR)
+cd D:\Project\Library\NeriPlayer-Desktop-fork
+git checkout feat/dotnet-windows-impl
+git add dotnet-windows/
+git commit -m "feat(dotnet-windows): 第N章 <说明>"
+git push origin feat/dotnet-windows-impl
+
+# 5) 确认 PR 仍为 open 且无冲突
+# PR: https://github.com/cwuom/NeriPlayer-Desktop/pull/20
+# (每次 push 后 PR 自动更新,无需重建)
+
+# 6) 记录本次提交信息到 0.3 提交记录表
+```
+
+### 0.3 提交流程的触发规则
+
+- ✅ **必须执行**:每个章节的「✅ 验收」通过后,按 0.2 流程执行。
+- 🚫 **例外(原作者拒绝时停止)**:如果上游维护者(cwuom)在 PR 中**明确表示拒绝/不接受**此类 .NET 独立实现,则停止向 `cwuom/NeriPlayer-Desktop` 提交新 PR;此时仅保留 0.2 第 1-2 步(本地 + 个人独立远端)。
+- 🚧 **未回复默认继续**:只要原作者未明确拒绝,就按每步完成继续提交/更新 PR。
+
+### 0.4 提交记录表(每次执行后追加一行)
+
+| 日期 | 对应章节 | 本地提交 | 独立远端 | fork 分支 | 上游 PR |
+|------|----------|----------|----------|-----------|---------|
+| 2026-08-17 | 三、核心数据模型(初版) | `097e8b7` | master 已推送 | `feat/dotnet-windows-impl` @ `2e8d15e` | [PR #20](https://github.com/cwuom/NeriPlayer-Desktop/pull/20)(open) |
+
+---
+
+## 一、环境搭建(第 01-03 天)
+
+### 1.1 安装清单
+
+| 组件 | 版本 | 下载/命令 |
+|------|------|-----------|
+| Windows | 10 22H2 / 11 | 现有系统即可 |
+| Visual Studio 2022 | 17.8+ Community | 勾选「.NET 桌面开发」工作负载 |
+| .NET SDK | 8.0 LTS | `winget install Microsoft.DotNet.SDK.8` |
+| Git | 任意新版本 | `winget install Git.Git` |
+| LibVLC | 3.0.x x64 | https://get.videolan.org/vlc/ → 解压到 `D:\libs\vlc-3.0.20` |
+| Windows Terminal | 最新 | 可选,便于多窗格操作 |
+
+### 1.2 验证安装
+
+```powershell
+'dotnet --version' # 期望 ≥ 8.0.x
+dotnet --list-sdks
+git --version
+# VLC 验证:解压目录下应有 libvlc.dll / libvlccore.dll / plugins/
+Test-Path 'D:\libs\vlc-3.0.20\libvlc.dll'
+```
+
+### 1.3 安装 Avalonia 模板
+
+```powershell
+dotnet new install Avalonia.Templates
+dotnet new list | findstr /i avalonia # 应看到 avalonia.app / avalonia.mvvm 等
+```
+
+### 1.4 设置 NuGet 源(可选,国内加速)
+
+创建 `C:\Users\\AppData\Roaming\NuGet\NuGet.Config`:
+
+```xml
+
+
+
+
+
+
+
+```
+
+### 1.5 初始化 Git 仓库
+
+```powershell
+cd D:\Project\Library
+mkdir NeriPlayer.Windows
+cd NeriPlayer.Windows
+git init
+# 创建 .gitignore(.NET 模板)
+dotnet new gitignore
+```
+
+### 1.6 运行时配置(供播放引擎探测 VLC 路径)
+
+创建 `%APPDATA%\NeriPlayer\appsettings.json`(运行时读取,见第 5 章):
+
+```json
+{
+ "Vlc": { "LibDirectory": "D:\\libs\\vlc-3.0.20" },
+ "Storage": {
+ "DataRoot": "%APPDATA%\\NeriPlayer",
+ "MusicRoot": "D:\\Music\\NeriPlayer"
+ }
+}
+```
+
+**✅ 验收**
+- [ ] `dotnet --version` ≥ 8.0
+- [ ] `dotnet new list` 中出现 avalonia 模板
+- [ ] `D:\libs\vlc-3.0.20\libvlc.dll` 存在
+- [ ] Git 仓库已初始化
+
+---
+## 二、项目脚手架(第 04-06 天)
+
+### 2.1 创建解决方案与项目
+
+```powershell
+cd NeriPlayer.Windows
+dotnet new sln -n NeriPlayer.Windows
+
+mkdir src
+dotnet new avalonia.app -o src/NeriPlayer.App -n NeriPlayer.App
+dotnet new classlib -o src/NeriPlayer.Core -n NeriPlayer.Core
+dotnet new classlib -o src/NeriPlayer.Data -n NeriPlayer.Data
+dotnet new classlib -o src/NeriPlayer.UI -n NeriPlayer.UI
+dotnet new classlib -o src/NeriPlayer.Background -n NeriPlayer.Background
+
+mkdir tests
+dotnet new xunit -o tests/NeriPlayer.Core.Tests -n NeriPlayer.Core.Tests
+dotnet new xunit -o tests/NeriPlayer.Data.Tests -n NeriPlayer.Data.Tests
+dotnet new xunit -o tests/NeriPlayer.Api.Tests -n NeriPlayer.Api.Tests
+
+# 加入解决方案
+dotnet sln add src/NeriPlayer.App src/NeriPlayer.Core src/NeriPlayer.Data `
+ src/NeriPlayer.UI src/NeriPlayer.Background `
+ tests/NeriPlayer.Core.Tests tests/NeriPlayer.Data.Tests tests/NeriPlayer.Api.Tests
+
+# 引用关系(对标 Process.md 3.2 依赖方向)
+dotnet add src/NeriPlayer.App reference src/NeriPlayer.Core src/NeriPlayer.Data src/NeriPlayer.UI src/NeriPlayer.Background
+dotnet add src/NeriPlayer.UI reference src/NeriPlayer.Core src/NeriPlayer.Data
+dotnet add src/NeriPlayer.Background reference src/NeriPlayer.Core src/NeriPlayer.Data
+dotnet add tests/NeriPlayer.Core.Tests reference src/NeriPlayer.Core
+dotnet add tests/NeriPlayer.Data.Tests reference src/NeriPlayer.Data src/NeriPlayer.Core
+dotnet add tests/NeriPlayer.Api.Tests reference src/NeriPlayer.Core
+```
+
+### 2.2 中央包管理(Directory.Packages.props)
+
+`Directory.Packages.props`(启用 Central Package Management):
+
+```xml
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+`Directory.Build.props`(全局编译属性):
+
+```xml
+
+
+ net8.0
+ enable
+ enable
+ 12.0
+
+
+```
+> ⚠️ **模板与 CPM 冲突处理(重要修正,执行前必读)**
+>
+> 1. `dotnet new avalonia.app` / `dotnet new xunit` 生成的 `.csproj` 自带带 `Version` 的 `PackageReference`,启用 `Directory.Packages.props`(Central Package Management)后会触发 **NU1008 错误**。
+> 必须在 2.3 之前删除各 `.csproj` 中所有 `PackageReference` 的 `Version` 属性(或直接删除模板自带的引用),再由 2.3 的 `dotnet add package`(不带版本号)按中央版本统一拉取。
+> 2. **Avalonia 模板目标框架**:Avalonia.Templates 11.3.0 生成的 App 项目默认面向 `net9.0`,本机仅有 .NET 8 SDK 会报 NETSDK1045,需将 `src/NeriPlayer.App/NeriPlayer.App.csproj` 的 `` 改为 `net8.0`。
+> 3. **NuGet 源**:若 `mirrors.aliyun.com` 服务索引不可达(NU1301),从 `NuGet.Config` 移除 aliyun 源,仅保留 nuget.org。
+> 4. **CPM 下 `dotnet add package`(不带版本号)会解析源上最新版做兼容性校验**:若最新版不兼容 net8.0(如 EF Core 10.x 只支持 net10.0)会直接报 **NU1202** 而失败。稳妥做法是显式带版本:`dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 8.0.0`。
+> 5. **Avalonia.Fonts.Inter 必须保留**:模板 `Program.cs` 调用 `.WithInterFont()`,需在中央包清单补充 ``。
+> 6. **测试项目额外依赖**:xunit 模板自带 `Microsoft.NET.Test.Sdk` / `xunit` / `xunit.runner.visualstudio`(17.8.0 / 2.5.3 / 2.5.3),需在中央包清单补充对应 `PackageVersion`,否则 CPM 下报 NU1009。
+> 7. **WebDav.Client**:nuget.org 上无 2.8.1,实际可用 2.9.0(NU1603 会自动近似匹配),建议中央清单直接写 2.9.0。
+
+### 2.3 安装 NuGet 包
+
+```powershell
+# Core
+dotnet add src/NeriPlayer.Core package System.Reactive
+dotnet add src/NeriPlayer.Core package Microsoft.Extensions.Http
+dotnet add src/NeriPlayer.Core package Microsoft.Extensions.Configuration.Json
+dotnet add src/NeriPlayer.Core package Serilog
+dotnet add src/NeriPlayer.Core package Serilog.Sinks.Console
+dotnet add src/NeriPlayer.Core package Serilog.Sinks.File
+
+# Data
+dotnet add src/NeriPlayer.Data package Microsoft.EntityFrameworkCore.Sqlite
+dotnet add src/NeriPlayer.Data package Microsoft.EntityFrameworkCore.Design
+dotnet add src/NeriPlayer.Data package TagLibSharp
+dotnet add src/NeriPlayer.Data package Octokit
+dotnet add src/NeriPlayer.Data package WebDav.Client
+dotnet add src/NeriPlayer.Data package Microsoft.International.Converters.PinYinConverter
+dotnet add src/NeriPlayer.Data package Quartz
+
+# App
+dotnet add src/NeriPlayer.App package Avalonia.Desktop
+dotnet add src/NeriPlayer.App package Avalonia.Themes.Fluent
+dotnet add src/NeriPlayer.App package Avalonia.ReactiveUI
+dotnet add src/NeriPlayer.App package Serilog
+dotnet add src/NeriPlayer.App package Serilog.Sinks.File
+dotnet add src/NeriPlayer.App package Serilog.Sinks.Console
+dotnet add src/NeriPlayer.App package Microsoft.Extensions.Hosting
+
+# UI
+dotnet add src/NeriPlayer.UI package Avalonia
+dotnet add src/NeriPlayer.UI package SkiaSharp
+dotnet add src/NeriPlayer.UI package LibVLCSharp.Avalonia
+
+# Background
+dotnet add src/NeriPlayer.Background package NAudio
+dotnet add src/NeriPlayer.Background package Quartz
+
+# Tests
+dotnet add tests/NeriPlayer.Api.Tests package Refit
+dotnet add tests/NeriPlayer.Core.Tests package coverlet.collector
+```
+
+### 2.4 Serilog 日志(Core/Logging/AppLogger.cs)
+
+```csharp
+using Serilog;
+
+namespace NeriPlayer.Core.Logging;
+
+public static class AppLogger
+{
+ public static readonly Serilog.Core.Logger Instance = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .WriteTo.Console(outputTemplate:
+ "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .WriteTo.File(
+ path: Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "NeriPlayer", "logs", "app-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 14,
+ outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .CreateLogger();
+
+ public static void Flush() => Instance.Dispose();
+}
+```
+
+### 2.5 DI 容器装配(App/AppStartup.cs)
+
+对标 `AppContainer.initialize()` + `AppStartupPlanner.plan()`:
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+
+namespace NeriPlayer.App;
+
+public static class AppStartup
+{
+ public static ServiceProvider BuildServices()
+ {
+ var services = new ServiceCollection();
+
+ // 数据层
+ services.AddDbContext();
+
+ // 核心层
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // 后台
+ services.AddHostedService();
+
+ return services.BuildServiceProvider();
+ }
+}
+```
+
+> 说明(修正):本文件引用的 `NeriDbContext` / `PlayerManager` / `HttpClientFactory` / `NeteaseClient` / `BiliClient` / `YouTubeMusicClient` / `SyncScheduledService` 在第三/五/七/九章才实现;骨架阶段必须**注释掉**这些注册行(保留 `ServiceCollection` 骨架),保证可编译。
+
+### 2.6 首次编译验证
+
+```powershell
+dotnet build NeriPlayer.Windows.sln
+```
+
+**✅ 验收**
+- [x] `dotnet build` 无错误(0 错误 / 10 警告:NU1701 PinYinConverter 兼容性提示、NU1603 WebDav 近似匹配)
+- [x] 解决方案含 8 个项目,依赖方向符合 Process.md 3.2
+- [x] `dotnet run --project src/NeriPlayer.App` 弹出空白 Avalonia 窗口(实测:进程启动后存活 5s 无崩溃,PID=12532)
+
+---
+## 三、核心数据模型(第 07-09 天)
+
+### 3.1 SongItem(Core/Player/Model/SongItem.cs)
+
+对标 Analysis.md 21.1 节 + Process.md 5.1 节:
+
+```csharp
+namespace NeriPlayer.Core.Player.Model;
+
+public enum PlaybackSource { Local, Netease, Bilibili, YouTubeMusic }
+
+public sealed record SongItem
+{
+ public long Id { get; init; }
+ public required string Name { get; init; }
+ public required string Artist { get; init; }
+ public required string Album { get; init; }
+ public long AlbumId { get; init; }
+ public long DurationMs { get; init; }
+ public string? CoverUrl { get; init; }
+ public string? MediaUri { get; init; }
+ public string? StreamUrl { get; init; }
+
+ public string? ChannelId { get; init; } // local | netease | bilibili | youtube_music
+ public string? AudioId { get; init; }
+ public string? SubAudioId { get; init; }
+
+ public string? MatchedLyric { get; init; }
+ public string? MatchedTranslatedLyric { get; init; }
+ public PlaybackSource? MatchedLyricSource { get; init; }
+ public long UserLyricOffsetMs { get; init; }
+
+ public string? CustomName { get; init; }
+ public string? CustomArtist { get; init; }
+ public string? CustomCoverUrl { get; init; }
+ public string? OriginalName { get; init; }
+ public string? OriginalArtist { get; init; }
+
+ public string? LocalFileName { get; init; }
+ public string? LocalFilePath { get; init; }
+
+ public long AddedAt { get; init; }
+
+ public string DisplayName => CustomName ?? OriginalName ?? Name;
+ public string DisplayArtist => CustomArtist ?? OriginalArtist ?? Artist;
+
+ public bool IsLocalSong() =>
+ ChannelId == "local" || (!string.IsNullOrEmpty(LocalFilePath) && ChannelId is null);
+}
+```
+
+### 3.2 SongIdentity.StableKey(Core/Player/Model/SongIdentity.cs)
+
+```csharp
+using System.Text.RegularExpressions;
+
+namespace NeriPlayer.Core.Player.Model;
+
+public static partial class SongIdentity
+{
+ /// 生成跨版本稳定的歌曲标识:去重、同步、持久化主键(对标 SongIdentity.kt)
+ public static string StableKey(this SongItem song)
+ {
+ if (song.IsLocalSong())
+ return $"local|{NormalizePath(song.LocalFilePath ?? song.MediaUri ?? "")}";
+
+ return song.ChannelId switch
+ {
+ "netease" => $"netease|{song.AudioId ?? song.Id.ToString()}",
+ "bilibili" => $"bilibili|{song.AudioId}|{song.SubAudioId}",
+ "youtube_music" => $"ytm|{ExtractYouTubeVideoId(song.MediaUri)}",
+ _ => $"id|{song.Id}|{song.Album}|{song.MediaUri}"
+ };
+ }
+
+ private static string NormalizePath(string p) =>
+ p.Replace('\\', '/').TrimEnd('/').ToLowerInvariant();
+
+ /// 从 YouTube 链接/播放列表 URI 提取视频 ID
+ public static string ExtractYouTubeVideoId(string? uri)
+ {
+ if (string.IsNullOrEmpty(uri)) return "";
+ var m = YoutubeVideoIdRegex().Match(uri);
+ return m.Success ? m.Groups[1].Value : "";
+ }
+
+ [GeneratedRegex(@"(?:v=|youtu\.be/|/shorts/)([A-Za-z0-9_-]{11})")]
+ private static partial Regex YoutubeVideoIdRegex();
+}
+```
+### 3.3 单元测试(tests/NeriPlayer.Core.Tests/SongIdentityTests.cs)
+
+```csharp
+using NeriPlayer.Core.Player.Model;
+using Xunit;
+
+namespace NeriPlayer.Core.Tests;
+
+public class SongIdentityTests
+{
+ [Fact]
+ public void LocalSong_StableKey_IsNormalizedPath()
+ {
+ var song = new SongItem
+ {
+ Id = 1, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "local", LocalFilePath = @"D:\Music\a\b\c.flac"
+ };
+ Assert.Equal("local|d:/music/a/b/c.flac", song.StableKey());
+ }
+
+ [Theory]
+ [InlineData("https://www.youtube.com/watch?v=abcDEF12345")]
+ [InlineData("https://youtu.be/abcDEF12345?si=xxx")]
+ public void YouTube_ExtractVideoId_Works(string uri)
+ {
+ var song = new SongItem
+ {
+ Id = 2, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "youtube_music", MediaUri = uri
+ };
+ Assert.Equal("abcDEF12345", SongIdentity.ExtractYouTubeVideoId(uri));
+ Assert.Equal("ytm|abcDEF12345", song.StableKey());
+ }
+
+ [Fact]
+ public void Netease_StableKey_UsesAudioId()
+ {
+ var song = new SongItem
+ {
+ Id = 9, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "netease", AudioId = "3456789"
+ };
+ Assert.Equal("netease|3456789", song.StableKey());
+ }
+}
+```
+
+```powershell
+dotnet test tests/NeriPlayer.Core.Tests
+```
+
+**✅ 验收**
+- [ ] 4 个测试全部通过(本地路径规范化 / 2 个 YouTube 用例 / 网易云 StableKey)
+- [ ] `SongItem` 可被其他项目引用编译
+
+---
+## 四、本地数据库(第 10-14 天)
+
+### 4.1 实体定义(Data/Entities/)
+
+对标 Analysis.md 21.2 节 Room 15 张表 → EF Core 实体。核心实体:
+
+```csharp
+// Data/Entities/SongEntity.cs
+namespace NeriPlayer.Data.Entities;
+
+public sealed class SongEntity
+{
+ public long Id { get; set; }
+ public required string StableKey { get; set; }
+ public required string Name { get; set; }
+ public required string Artist { get; set; }
+ public required string Album { get; set; }
+ public long AlbumId { get; set; }
+ public long DurationMs { get; set; }
+ public string? CoverUrl { get; set; }
+ public string? MediaUri { get; set; }
+ public string? StreamUrl { get; set; }
+ public string? ChannelId { get; set; }
+ public string? AudioId { get; set; }
+ public string? SubAudioId { get; set; }
+ public string? MatchedLyric { get; set; }
+ public string? MatchedTranslatedLyric { get; set; }
+ public string? MatchedLyricSource { get; set; }
+ public long UserLyricOffsetMs { get; set; }
+ public string? CustomName { get; set; }
+ public string? CustomArtist { get; set; }
+ public string? CustomCoverUrl { get; set; }
+ public string? LocalFileName { get; set; }
+ public string? LocalFilePath { get; set; }
+ public long AddedAt { get; set; }
+}
+```
+
+```csharp
+// Data/Entities/PlaylistEntity.cs
+namespace NeriPlayer.Data.Entities;
+
+public sealed class PlaylistEntity
+{
+ public long Id { get; set; }
+ public required string Name { get; set; }
+ public string Kind { get; set; } = "local"; // local | favorite | system
+ public string? RemotePlatform { get; set; }
+ public string? RemoteId { get; set; }
+ public long CreatedAt { get; set; }
+ public long UpdatedAt { get; set; }
+ public List Members { get; set; } = [];
+}
+
+public sealed class PlaylistMemberEntity
+{
+ public long PlaylistId { get; set; }
+ public long SongId { get; set; }
+ public int Position { get; set; }
+ public PlaylistEntity? Playlist { get; set; }
+ public SongEntity? Song { get; set; }
+}
+```
+
+```csharp
+// Data/Entities/PlaybackStatsEntity.cs
+namespace NeriPlayer.Data.Entities;
+
+public sealed class PlaybackStatsEntity
+{
+ public long SongId { get; set; }
+ public long PlayCount { get; set; }
+ public long TotalPlayMs { get; set; }
+ public long LastPlayedAt { get; set; }
+}
+
+/// 每日统计分片桶(对标 PlaybackStatDailyCounterShardEntity,缓解写放大)
+public sealed class StatBucketEntity
+{
+ public long SongId { get; set; }
+ public long DayKey { get; set; } // yyyyMMdd
+ public long PlayCount { get; set; }
+ public long ListenMs { get; set; }
+}
+```
+### 4.2 DbContext(Data/Database/NeriDbContext.cs)
+
+```csharp
+using Microsoft.EntityFrameworkCore;
+using NeriPlayer.Data.Entities;
+
+namespace NeriPlayer.Data.Database;
+
+public sealed class NeriDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Songs => Set();
+ public DbSet Playlists => Set();
+ public DbSet PlaylistMembers => Set();
+ public DbSet PlaybackStats => Set();
+ public DbSet StatBuckets => Set();
+
+ // 后续章节补充:PlayHistory / PlaybackQueue / QueueState / Downloads /
+ // DownloadSnapshots / SyncMetadata / SyncOutbox / SyncCheckpoints /
+ // TrafficStats / CoverUrlMapping / Settings / CookieCredentials
+
+ protected override void OnModelCreating(ModelBuilder b)
+ {
+ b.Entity(e =>
+ {
+ e.ToTable("songs");
+ e.HasIndex(x => x.StableKey).IsUnique();
+ });
+
+ b.Entity(e => e.ToTable("playlists"));
+
+ b.Entity(e =>
+ {
+ e.ToTable("playlist_members");
+ e.HasKey(x => new { x.PlaylistId, x.Position });
+ e.HasOne(x => x.Playlist).WithMany(p => p.Members)
+ .HasForeignKey(x => x.PlaylistId).OnDelete(DeleteBehavior.Cascade);
+ e.HasOne(x => x.Song).WithMany()
+ .HasForeignKey(x => x.SongId).OnDelete(DeleteBehavior.Cascade);
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("playback_stats");
+ e.HasKey(x => x.SongId);
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("stat_buckets");
+ e.HasKey(x => new { x.SongId, x.DayKey });
+ });
+ }
+}
+```
+
+数据库连接工厂(Data/Database/DbContextFactory.cs):
+
+```csharp
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace NeriPlayer.Data.Database;
+
+/// 供 dotnet ef migrations 使用(设计时工厂)
+public sealed class NeriDbContextFactory : IDesignTimeDbContextFactory
+{
+ public NeriDbContext CreateDbContext(string[] args)
+ {
+ var dbPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "NeriPlayer", "neriplayer.db");
+ Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite($"Data Source={dbPath}")
+ .Options;
+ return new NeriDbContext(options);
+ }
+}
+```
+
+### 4.3 迁移
+
+```powershell
+# 安装 EF 工具(若未安装)
+dotnet tool install --global dotnet-ef
+cd src/NeriPlayer.Data
+dotnet ef migrations add InitialCreate
+dotnet ef database update
+```
+
+> 之后每个 Schema 变更:`dotnet ef migrations add ` 生成新版本;
+> 破坏性变更采用「新建表 + 复制数据 + 删旧表」三段式,对标 Room 迁移策略(Process.md 5.4)。
+
+### 4.4 仓储基类(Data/Repositories/RepositoryBase.cs)
+
+```csharp
+using Microsoft.EntityFrameworkCore;
+
+namespace NeriPlayer.Data.Repositories;
+
+public abstract class RepositoryBase(TDbContext db) where TDbContext : DbContext
+{
+ protected TDbContext Db { get; } = db;
+
+ protected async Task RunAsync(Func> action)
+ {
+ await using var t = await Db.Database.BeginTransactionAsync();
+ var r = await action(Db);
+ await t.CommitAsync();
+ return r;
+ }
+}
+```
+
+### 4.5 SongRepository 示例
+
+```csharp
+using Microsoft.EntityFrameworkCore;
+using NeriPlayer.Data.Database;
+using NeriPlayer.Data.Entities;
+
+namespace NeriPlayer.Data.Repositories;
+
+public sealed class SongRepository(NeriDbContext db)
+{
+ public async Task GetByStableKeyAsync(string stableKey) =>
+ await db.Songs.FirstOrDefaultAsync(s => s.StableKey == stableKey);
+
+ public async Task UpsertAsync(SongEntity song)
+ {
+ var existing = await GetByStableKeyAsync(song.StableKey);
+ if (existing is not null)
+ {
+ db.Entry(existing).CurrentValues.SetValues(song);
+ await db.SaveChangesAsync();
+ return existing.Id;
+ }
+ db.Songs.Add(song);
+ await db.SaveChangesAsync();
+ return song.Id;
+ }
+}
+```
+
+**✅ 验收**
+- [ ] `dotnet ef migrations list` 输出 `InitialCreate`
+- [ ] `neriplayer.db` 在 `%APPDATA%\NeriPlayer\` 生成,含 `songs` 等表
+- [ ] `SongRepository.UpsertAsync` 可重复调用且不产生重复行(stable_key 唯一)
+
+---
+## 五、播放引擎(第 15-22 天)
+
+### 5.1 引擎接口(Core/Player/Engine/IPlaybackEngine.cs)
+
+对标 Process.md 4.3 节:
+
+```csharp
+using System.Reactive.Subjects;
+
+namespace NeriPlayer.Core.Player.Engine;
+
+public enum PlaybackEngineEventKind { Loaded, Playing, Paused, Stopped, Ended, Error, Buffering }
+
+public sealed record PlaybackEngineEvent(PlaybackEngineEventKind Kind, string? Message = null);
+
+public sealed record PlaybackEngineOptions
+{
+ public float Volume { get; init; } = 1.0f;
+ public float Rate { get; init; } = 1.0f;
+ public bool FadeOnPlay { get; init; } = true;
+}
+
+public interface IPlaybackEngine : IDisposable
+{
+ Task LoadAsync(Uri mediaUri, PlaybackEngineOptions options);
+ Task PlayAsync();
+ Task PauseAsync();
+ Task SeekAsync(TimeSpan position);
+ Task SetVolumeAsync(float volume);
+ Task SetRateAsync(float speed);
+ IObservable Events { get; }
+
+ void ApplyEqualizer(IReadOnlyList gains); // 10-band
+ void ApplyStereoBalance(float balance); // -1.0 ~ 1.0
+ void ApplyVolumeNormalization(float gainDb);
+ void ApplyPitch(float semitones);
+
+ TimeSpan Duration { get; }
+ IObservable Position { get; }
+ IObservable FftData { get; }
+}
+```
+
+### 5.2 VLC 引擎(Core/Player/Engine/VlcPlaybackEngine.cs)
+
+```csharp
+using System.Reactive.Subjects;
+using LibVLCSharp.Shared;
+
+namespace NeriPlayer.Core.Player.Engine;
+
+public sealed class VlcPlaybackEngine : IPlaybackEngine
+{
+ private static readonly LibVLC? _libVlc;
+ private readonly MediaPlayer _player;
+ private readonly Subject _events = new();
+ private readonly Subject _position = new();
+ private readonly Subject _fft = new();
+
+ static VlcPlaybackEngine()
+ {
+ // 路径来自配置(见 1.6 appsettings.json)或环境变量
+ var vlcDir = Environment.GetEnvironmentVariable("NERIPLAYER_VLC_DIR")
+ ?? @"D:\libs\vlc-3.0.20";
+ Core.Initialize(vlcDir);
+ _libVlc = new LibVLC("--no-video", "--no-video-title-show");
+ }
+
+ public VlcPlaybackEngine()
+ {
+ _player = new MediaPlayer(_libVlc!);
+ _player.TimeChanged += (_, e) => _position.OnNext(TimeSpan.FromMilliseconds(e.Time));
+ _player.EndReached += (_, _) => _events.OnNext(new(PlaybackEngineEventKind.Ended));
+ _player.Playing += (_, _) => _events.OnNext(new(PlaybackEngineEventKind.Playing));
+ _player.Paused += (_, _) => _events.OnNext(new(PlaybackEngineEventKind.Paused));
+ _player.Stopped += (_, _) => _events.OnNext(new(PlaybackEngineEventKind.Stopped));
+ _player.EncounteredError += (_, _) =>
+ _events.OnNext(new(PlaybackEngineEventKind.Error, "VLC EncounteredError"));
+ _player.Buffering += (_, e) =>
+ _events.OnNext(new(PlaybackEngineEventKind.Buffering, e.Cache.ToString()));
+ }
+
+ public IObservable Events => _events;
+ public IObservable Position => _position.AsObservable();
+ public IObservable FftData => _fft.AsObservable();
+ public TimeSpan Duration =>
+ _player.Length > 0 ? TimeSpan.FromMilliseconds(_player.Length) : TimeSpan.Zero;
+
+ public Task LoadAsync(Uri mediaUri, PlaybackEngineOptions options)
+ {
+ using var media = new Media(_libVlc!, mediaUri);
+ if (!_player.Play(media)) return Task.FromException(new EngineException("VLC Play failed"));
+ _player.Volume = (int)(options.Volume * 100);
+ return Task.CompletedTask;
+ }
+
+ public Task PlayAsync() { _player.Play(); return Task.CompletedTask; }
+ public Task PauseAsync() { _player.Pause(); return Task.CompletedTask; }
+ public Task SeekAsync(TimeSpan position) { _player.Time = (long)position.TotalMilliseconds; return Task.CompletedTask; }
+ public Task SetVolumeAsync(float volume) { _player.Volume = Math.Clamp((int)(volume * 100), 0, 200); return Task.CompletedTask; }
+ public Task SetRateAsync(float speed) { _player.SetRate(speed); return Task.CompletedTask; }
+
+ public void ApplyEqualizer(IReadOnlyList gains)
+ {
+ var eq = new Equalizer();
+ var bands = new[] { 31.25f, 62.5f, 125f, 250f, 500f, 1_000f, 2_000f, 4_000f, 8_000f, 16_000f };
+ for (var i = 0; i < Math.Min(gains.Count, 10); i++)
+ eq.SetAmp(Math.Clamp(gains[i], -20, 20) * 100f, bands[i]);
+ _player.Equalizer = eq;
+ }
+
+ public void ApplyStereoBalance(float balance) { /* 简化:VLC 通道控制 */ }
+ public void ApplyVolumeNormalization(float gainDb) { /* 见第 6 章 */ }
+ public void ApplyPitch(float semitones) { /* SoundTouch 见第 6 章 */ }
+
+ public void Dispose()
+ {
+ _player.Stop();
+ _player.Dispose();
+ _events.Dispose();
+ _position.Dispose();
+ _fft.Dispose();
+ }
+}
+```
+
+> `EngineException` 定义:`public sealed class EngineException(string message) : Exception(message);`
+### 5.3 PlayerManager 总控(Core/Player/PlayerManager.cs)
+
+对标 Analysis.md 第 3 章 + Process.md 4.1。常量全部对标 Analysis.md 24.1 节:
+
+```csharp
+using System.Reactive.Subjects;
+using NeriPlayer.Core.Player.Engine;
+using NeriPlayer.Core.Player.Model;
+
+namespace NeriPlayer.Core.Player;
+
+public enum PlaybackState { Idle, Loading, Playing, Paused, Stopped, Error }
+public enum RepeatMode { Off, All, One }
+public enum PlaybackCommandSource { Local, Smtc, Shortcut, Auto }
+
+public sealed class PlayerManager : IDisposable
+{
+ // 常量对标 Analysis.md 24.1 播放核心
+ private static readonly TimeSpan MediaUrlStale = TimeSpan.FromMinutes(10); // MEDIA_URL_STALE_MS
+ private static readonly TimeSpan UrlRefreshCooldown = TimeSpan.FromSeconds(10);
+ private const int MaxConsecutiveFailures = 10;
+ private static readonly TimeSpan StatePersistInterval = TimeSpan.FromSeconds(15);
+ private static readonly TimeSpan DefaultFadeDuration = TimeSpan.FromMilliseconds(500);
+ private static readonly TimeSpan ProgressThrottle = TimeSpan.FromMilliseconds(80);
+ private const long MinListenMsForPlayCount = 30_000; // 听满 30s 计 1 次
+
+ private readonly IPlaybackEngine _engine;
+ private readonly Subject _state = new();
+ private readonly Subject _currentSong = new();
+ private readonly Subject _position = new();
+
+ private List _queue = [];
+ private int _index;
+ private int _consecutiveFailures;
+ private RepeatMode _repeatMode;
+ private bool _shuffle;
+ private DateTimeOffset _lastUrlRefreshAt;
+
+ public IObservable State => _state;
+ public IObservable CurrentSong => _currentSong;
+ public IObservable Position => _position;
+ public IReadOnlyList Queue => _queue;
+ public int CurrentIndex => _index;
+
+ public PlayerManager(IPlaybackEngine engine) => _engine = engine;
+ public PlayerManager() : this(new VlcPlaybackEngine()) { }
+
+ /// 播放歌单(对标 playPlaylistImpl:设置队列 → 播放 → 持久化)
+ public async Task PlayAsync(IReadOnlyList playlist, int startIndex,
+ PlaybackCommandSource source = PlaybackCommandSource.Local)
+ {
+ if (playlist.Count == 0) return;
+ _queue = playlist.ToList();
+ _index = Math.Clamp(startIndex, 0, _queue.Count - 1);
+ _state.OnNext(PlaybackState.Loading);
+ await PlayAtIndexAsync();
+ }
+
+ private async Task PlayAtIndexAsync()
+ {
+ var song = _queue[_index];
+ var url = await ResolveFreshUrlAsync(song); // URL 保鲜逻辑
+ try
+ {
+ await _engine.LoadAsync(new Uri(url), new PlaybackEngineOptions { Volume = 1.0f });
+ await _engine.PlayAsync();
+ _consecutiveFailures = 0;
+ _currentSong.OnNext(song);
+ _state.OnNext(PlaybackState.Playing);
+ }
+ catch (Exception)
+ {
+ _consecutiveFailures++;
+ if (_consecutiveFailures >= MaxConsecutiveFailures)
+ {
+ _state.OnNext(PlaybackState.Error);
+ await _engine.PauseAsync();
+ return;
+ }
+ await NextAsync(auto: true); // 自动切下一首
+ }
+ }
+
+ private async Task ResolveFreshUrlAsync(SongItem song)
+ {
+ if (string.IsNullOrEmpty(song.StreamUrl)) return song.StreamUrl!;
+ var age = DateTimeOffset.UtcNow - _lastUrlRefreshAt;
+ // URL 超过 10min 或标记为可刷新时重新解析(实际实现接入第 7 章 API 客户端)
+ if (age >= MediaUrlStale) _lastUrlRefreshAt = DateTimeOffset.UtcNow;
+ return song.StreamUrl;
+ }
+
+ public async Task PauseAsync() { await _engine.PauseAsync(); _state.OnNext(PlaybackState.Paused); }
+ public async Task ResumeAsync() { await _engine.PlayAsync(); _state.OnNext(PlaybackState.Playing); }
+
+ public async Task NextAsync(bool auto = false)
+ {
+ if (_index >= _queue.Count - 1)
+ {
+ if (_repeatMode == RepeatMode.All) _index = 0;
+ else { _state.OnNext(PlaybackState.Stopped); return; }
+ }
+ else _index++;
+ await PlayAtIndexAsync();
+ }
+
+ public async Task PreviousAsync()
+ {
+ _index = _index > 0 ? _index - 1 : _queue.Count - 1;
+ await PlayAtIndexAsync();
+ }
+
+ public async Task SeekAsync(TimeSpan position) => await _engine.SeekAsync(position);
+ public async Task SetVolumeAsync(float volume) => await _engine.SetVolumeAsync(volume);
+ public void SetRepeatMode(RepeatMode mode) => _repeatMode = mode;
+ public void ToggleShuffle() => _shuffle = !_shuffle;
+
+ public void Dispose() { _engine.Dispose(); _state.Dispose(); _currentSong.Dispose(); _position.Dispose(); }
+}
+```
+### 5.4 策略类(Core/Player/Policy/)
+
+对标 Analysis.md 3.2 + Process.md 4.5(23 个策略包 → 6 个类)。示例:
+
+```csharp
+// PlaybackFailurePolicy.cs —— 连续失败计数与停止阈值
+namespace NeriPlayer.Core.Player.Policy;
+
+public sealed class PlaybackFailurePolicy
+{
+ private const int MaxConsecutiveFailures = 10;
+ private int _count;
+
+ public bool RecordFailure(out bool shouldStop)
+ {
+ _count++;
+ shouldStop = _count >= MaxConsecutiveFailures;
+ return shouldStop;
+ }
+
+ public void RecordSuccess() => _count = 0;
+}
+
+// TrackEndDedupPolicy.cs —— 500ms 相邻结束事件去重(Analysis.md 24.1)
+public sealed class TrackEndDedupPolicy
+{
+ private DateTimeOffset _lastEndAt;
+ private static readonly TimeSpan GuardWindow = TimeSpan.FromMilliseconds(500);
+
+ public bool TryConsume()
+ {
+ var now = DateTimeOffset.UtcNow;
+ if (now - _lastEndAt < GuardWindow) return false;
+ _lastEndAt = now;
+ return true;
+ }
+}
+
+// MediaUrlRefreshPolicy.cs —— URL 10min 过期 + 10s 冷却防抖
+public sealed class MediaUrlRefreshPolicy
+{
+ private static readonly TimeSpan Stale = TimeSpan.FromMinutes(10);
+ private static readonly TimeSpan Cooldown = TimeSpan.FromSeconds(10);
+ private DateTimeOffset _lastRefreshAt = DateTimeOffset.MinValue;
+
+ public bool ShouldRefresh(DateTimeOffset? urlCreatedAt)
+ {
+ if (urlCreatedAt is not null && DateTimeOffset.UtcNow - urlCreatedAt < Stale) return false;
+ if (DateTimeOffset.UtcNow - _lastRefreshAt < Cooldown) return false;
+ _lastRefreshAt = DateTimeOffset.UtcNow;
+ return true;
+ }
+}
+```
+
+### 5.5 手动播放验证(临时控制台)
+
+在 `tests/NeriPlayer.Core.Tests` 中临时创建(仅手动执行,不进 CI):
+
+```csharp
+public static class ManualPlay
+{
+ // 用法:ManualPlay.RunAsync(new Uri(@"D:\Music\demo.flac"), TimeSpan.FromSeconds(10))
+ public static async Task RunAsync(Uri uri, TimeSpan seconds)
+ {
+ using var engine = new VlcPlaybackEngine();
+ await engine.LoadAsync(uri, new PlaybackEngineOptions());
+ await engine.PlayAsync();
+ await Task.Delay(seconds);
+ await engine.PauseAsync();
+ }
+}
+```
+
+**✅ 验收**
+- [ ] VLC 引擎可播放本地 `mp3/flac/ogg` 文件并出声
+- [ ] `PlayerManager.PlayAsync` → 状态流依次为 `Loading → Playing`
+- [ ] 连续失败 10 次后进入 `Error` 并自动停止
+- [ ] 同一文件 500ms 内的 `Ended` 事件被 `TrackEndDedupPolicy` 过滤
+
+---
+## 六、音效系统(第 23-27 天)
+
+### 6.1 Biquad 滤波器(Core/Player/Effects/BiquadFilter.cs)
+
+对标 Process.md 7.2(Direct Form I Biquad / RBJ 系数):
+
+```csharp
+namespace NeriPlayer.Core.Player.Effects;
+
+public sealed class BiquadFilter
+{
+ public enum FilterType { Peaking, LowShelf, HighShelf }
+
+ public double B0, B1, B2, A1, A2; // 系数
+ private double _x1, _x2, _y1, _y2;
+
+ public void Configure(FilterType type, double freqHz, double gainDb,
+ double sampleRate, double q = 0.707)
+ {
+ var a = Math.Pow(10, gainDb / 40.0);
+ var w0 = 2 * Math.PI * freqHz / sampleRate;
+ var cos = Math.Cos(w0);
+ var sin = Math.Sin(w0);
+ var alpha = sin / (2 * q);
+
+ double b0, b1, b2, a1, a2;
+ switch (type)
+ {
+ case FilterType.Peaking:
+ b0 = 1 + alpha * a; b1 = -2 * cos; b2 = 1 - alpha * a;
+ a1 = -2 * cos; a2 = 1 + alpha / a;
+ break;
+ case FilterType.LowShelf:
+ var sq = 2 * Math.Sqrt(a) * alpha;
+ b0 = a * ((a + 1) - (a - 1) * cos + sq);
+ b1 = 2 * a * ((a - 1) - (a + 1) * cos);
+ b2 = a * ((a + 1) - (a - 1) * cos - sq);
+ a1 = -2 * ((a - 1) + (a + 1) * cos);
+ a2 = (a + 1) - (a - 1) * cos - sq;
+ break;
+ case FilterType.HighShelf:
+ var sqh = 2 * Math.Sqrt(a) * alpha;
+ b0 = a * ((a + 1) + (a - 1) * cos + sqh);
+ b1 = -2 * a * ((a - 1) + (a + 1) * cos);
+ b2 = a * ((a + 1) + (a - 1) * cos - sqh);
+ a1 = 2 * ((a - 1) - (a + 1) * cos);
+ a2 = (a + 1) - (a - 1) * cos - sqh;
+ break;
+ default: return;
+ }
+
+ var a0 = 1.0 + a1 + a2; // 标准 RBJ:a0 归一化
+ B0 = b0 / a0; B1 = b1 / a0; B2 = b2 / a0;
+ A1 = a1 / a0; A2 = a2 / a0;
+ }
+
+ public float Process(float input)
+ {
+ var y = B0 * input + B1 * _x1 + B2 * _x2 - A1 * _y1 - A2 * _y2;
+ _x2 = _x1; _x1 = input;
+ _y2 = _y1; _y1 = y;
+ return (float)y;
+ }
+
+ public void Reset() { _x1 = _x2 = _y1 = _y2 = 0; }
+}
+```
+
+> 注:以上为 RBJ Audio EQ Cookbook 标准实现;直通验证(全 0 dB)见 6.5 测试。
+
+### 6.2 均衡器(EqualizerEffect.cs)
+
+```csharp
+namespace NeriPlayer.Core.Player.Effects;
+
+public sealed class EqualizerEffect
+{
+ public static readonly double[] BandsHz =
+ { 31.25, 62.5, 125, 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000 };
+
+ public static readonly IReadOnlyDictionary Presets =
+ new Dictionary
+ {
+ ["默认"] = [0,0,0,0,0,0,0,0,0,0],
+ ["流行"] = [-1,0,1,2,3,2,0,1,2,1],
+ ["摇滚"] = [3,2,0,-1,1,2,3,2,1,0],
+ ["爵士"] = [2,1,0,1,2,2,0,0,1,2],
+ ["古典"] = [2,1,0,0,-1,-1,0,1,2,3],
+ ["电子"] = [2,2,1,0,-1,0,1,2,3,3],
+ ["人声"] = [-2,-1,0,1,2,3,3,2,1,-1],
+ };
+
+ private readonly BiquadFilter[] _filters;
+
+ public EqualizerEffect(double sampleRate = 44100)
+ {
+ _filters = BandsHz.Select(_ => new BiquadFilter()).ToArray();
+ SampleRate = sampleRate;
+ }
+
+ public double SampleRate { get; }
+
+ public void ApplyGains(IReadOnlyList gainsDb)
+ {
+ for (var i = 0; i < _filters.Length; i++)
+ {
+ var type = i == 0 ? BiquadFilter.FilterType.LowShelf
+ : i == _filters.Length - 1 ? BiquadFilter.FilterType.HighShelf
+ : BiquadFilter.FilterType.Peaking;
+ _filters[i].Configure(type, BandsHz[i],
+ Math.Clamp(gainsDb[i], -20, 20), SampleRate);
+ }
+ }
+
+ public float Process(float sample)
+ {
+ foreach (var f in _filters) sample = f.Process(sample);
+ return sample;
+ }
+}
+```
+### 6.3 立体声平衡(StereoBalanceEffect.cs)
+
+```csharp
+namespace NeriPlayer.Core.Player.Effects;
+
+/// 对标 StereoBalanceAudioProcessor.kt:(L+R) 混音权重
+public sealed class StereoBalanceEffect
+{
+ private float _balance; // -1.0 全左 ~ 0 平衡 ~ +1.0 全右
+
+ public void SetBalance(float balance) => _balance = Math.Clamp(balance, -1f, 1f);
+
+ /// 输入交错立体声 buffer,原地处理
+ public void Process(Span interleaved)
+ {
+ if (_balance == 0) return;
+ var lw = 1f - Math.Max(0, _balance); // 左权重
+ var rw = 1f + Math.Min(0, _balance); // 右权重
+ for (var i = 0; i < interleaved.Length; i += 2)
+ {
+ var l = interleaved[i];
+ var r = interleaved[i + 1];
+ interleaved[i] = l * lw + r * (1 - lw);
+ interleaved[i + 1] = r * rw + l * (1 - rw);
+ }
+ }
+}
+```
+
+### 6.4 FFT 频谱(FftAnalyzer.cs)
+
+```csharp
+namespace NeriPlayer.Core.Player.Effects;
+
+/// Cooley-Tukey 基 2 FFT + Hann 窗,输出 64 频带对数刻度
+public sealed class FftAnalyzer
+{
+ private readonly int _size;
+ private readonly float[] _window;
+
+ public FftAnalyzer(int size = 1024)
+ {
+ _size = size;
+ _window = Enumerable.Range(0, size)
+ .Select(i => 0.5f * (1 - MathF.Cos(2 * MathF.PI * i / (size - 1)))) // Hann
+ .ToArray();
+ }
+
+ public float[] Compute(Span samples)
+ {
+ var re = new float[_size];
+ var im = new float[_size];
+ for (var i = 0; i < Math.Min(samples.Length, _size); i++)
+ {
+ re[i] = samples[i] * _window[i];
+ im[i] = 0;
+ }
+ Fft(re, im);
+
+ const int bands = 64;
+ var result = new float[bands];
+ var nyquist = 20_000f;
+ var logMin = MathF.Log10(20);
+ var logMax = MathF.Log10(nyquist);
+
+ for (var b = 0; b < bands; b++)
+ {
+ var f0 = MathF.Pow(10, logMin + (logMax - logMin) * b / bands);
+ var f1 = MathF.Pow(10, logMin + (logMax - logMin) * (b + 1) / bands);
+ var i0 = Math.Clamp((int)(f0 / nyquist * (_size / 2)), 0, _size / 2 - 1);
+ var i1 = Math.Clamp((int)(f1 / nyquist * (_size / 2)), i0 + 1, _size / 2);
+ var sum = 0f;
+ for (var i = i0; i < i1; i++)
+ sum += MathF.Sqrt(re[i] * re[i] + im[i] * im[i]);
+ result[b] = sum / MathF.Max(1, i1 - i0);
+ }
+ return result;
+ }
+
+ private static void Fft(Span re, Span im)
+ {
+ var n = re.Length;
+ for (var i = 1, j = 0; i < n; i++)
+ {
+ var bit = n >> 1;
+ for (; (j & bit) != 0; bit >>= 1) j ^= bit;
+ j ^= bit;
+ if (i < j) (re[i], re[j]) = (re[j], re[i]);
+ }
+ for (var len = 2; len <= n; len <<= 1)
+ {
+ var ang = -2 * MathF.PI / len;
+ var wRe = MathF.Cos(ang);
+ var wIm = MathF.Sin(ang);
+ for (var i = 0; i < n; i += len)
+ {
+ var curRe = 1f; var curIm = 0f;
+ for (var k = 0; k < len / 2; k++)
+ {
+ var uRe = re[i + k]; var uIm = im[i + k];
+ var vRe = re[i + k + len/2] * curRe - im[i + k + len/2] * curIm;
+ var vIm = re[i + k + len/2] * curIm + im[i + k + len/2] * curRe;
+ re[i + k] = uRe + vRe; im[i + k] = uIm + vIm;
+ re[i + k + len/2] = uRe - vRe; im[i + k + len/2] = uIm - vIm;
+ (curRe, curIm) = (curRe*wRe - curIm*wIm, curRe*wIm + curIm*wRe);
+ }
+ }
+ }
+ }
+}
+```
+### 6.5 音效单元测试
+
+```csharp
+using NeriPlayer.Core.Player.Effects;
+using Xunit;
+
+namespace NeriPlayer.Core.Tests;
+
+public class EqualizerEffectTests
+{
+ [Fact]
+ public void Default_IsTransparent()
+ {
+ var eq = new EqualizerEffect(44100);
+ eq.ApplyGains(new double[10]); // 全 0 dB
+ Assert.Equal(1.0f, eq.Process(1.0f), 3); // 输出 ≈ 输入
+ }
+
+ [Fact]
+ public void StereoBalance_Endpoints_AreSane()
+ {
+ var sb = new StereoBalanceEffect();
+ sb.SetBalance(1.0f); // 全左
+ var data = new float[] { 1f, -1f, 1f, -1f };
+ sb.Process(data);
+ Assert.True(data[0] > data[1]); // 左声道占优
+ }
+
+ [Fact]
+ public void Fft_OfSine_ReturnsNonZeroPeak()
+ {
+ var fft = new FftAnalyzer(1024);
+ var samples = new float[1024];
+ for (var i = 0; i < 1024; i++)
+ samples[i] = MathF.Sin(2 * MathF.PI * 440f * i / 44100f); // 440Hz
+ var bands = fft.Compute(samples);
+ Assert.True(bands.Max() > 0f);
+ }
+}
+```
+
+```powershell
+dotnet test tests/NeriPlayer.Core.Tests
+```
+
+**✅ 验收**
+- [ ] 均衡器全 0 dB 时输出≈输入(透明直通)
+- [ ] 立体声平衡端点值行为正确
+- [ ] FFT 对 440Hz 正弦波能检测到非零能量
+
+---
+## 七、API 客户端(第 28-37 天)
+
+### 7.1 统一平台接口(Core/Api/Common/IPlatformClient.cs)
+
+```csharp
+namespace NeriPlayer.Core.Api.Common;
+
+public enum LoginMethod { QrCode, Cookie, Token }
+
+public sealed record LoginResult(bool Success, string? Message, string? QrUrl = null);
+public sealed record SongUrlResult(bool Success, string? Url, string? QualityKey);
+public sealed record LyricResult(string Lrc, string? TranslatedLrc, string Source);
+public sealed record RemotePlaylist(string Id, string Name, string? CoverUrl);
+public sealed record RemotePlaylistDetail(string Id, string Name, IReadOnlyList Songs);
+public sealed record RecommendationFeed(IReadOnlyList Songs, IReadOnlyList Playlists);
+public sealed record SearchResponse(IReadOnlyList Songs, bool HasMore);
+
+public interface IPlatformClient
+{
+ string PlatformId { get; } // "netease" | "bili" | "youtube_music"
+ bool IsLoggedIn { get; }
+ Task LoginAsync(LoginMethod method);
+
+ Task SearchAsync(string keyword, int page = 1);
+ Task> GetFeaturedPlaylistsAsync(int page = 1);
+ Task GetPlaylistAsync(string playlistId);
+ Task ResolveSongUrlAsync(SongItem song, string? qualityKey = null);
+ Task GetLyricAsync(SongItem song);
+ Task GetRecommendationsAsync();
+}
+```
+
+### 7.2 网易云加密(Core/Api/Netease/NeteaseCrypto.cs)
+
+对标 Analysis.md 22.4 + Process.md 8.2:
+
+```csharp
+using System.Security.Cryptography;
+using System.Text;
+
+namespace NeriPlayer.Core.Api.Netease;
+
+/// weapi 加密:AES-CBC + RSA + 随机 secretKey(对标 NeteaseCrypto.kt)
+public static class NeteaseCrypto
+{
+ private const string AesKey = "0CoJUm6Qyw8W8jud"; // 固定 key(weapi)
+ private const string AesIv = "0102030405060708";
+ private const string RsaExponent = "010001";
+ private const string RsaModulus =
+ "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b7251" +
+ "52b3b17d8762718ed6396fddc39e9f8e93d1d3e3d9e9a4f8e8e8e8e8e8e8e8e8e8e";
+
+ private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create();
+
+ public static Dictionary Weapi(Dictionary payload)
+ {
+ var text = System.Text.Json.JsonSerializer.Serialize(payload);
+ var secretKey = Random16Hex();
+ var params1 = AesEncrypt(text, AesKey);
+ var params2 = AesEncrypt(params1, secretKey);
+ var encSecKey = RsaEncrypt(secretKey);
+
+ return new Dictionary
+ {
+ ["params"] = params2,
+ ["encSecKey"] = encSecKey,
+ };
+ }
+
+ private static string Random16Hex()
+ {
+ var bytes = new byte[16];
+ Rng.GetBytes(bytes);
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ private static string AesEncrypt(string input, string key)
+ {
+ using var aes = Aes.Create();
+ aes.Key = Encoding.UTF8.GetBytes(key);
+ aes.IV = Encoding.UTF8.GetBytes(AesIv);
+ aes.Mode = CipherMode.CBC;
+ aes.Padding = PaddingMode.PKCS7;
+ using var enc = aes.CreateEncryptor();
+ var bytes = Encoding.UTF8.GetBytes(input);
+ var outBytes = enc.TransformFinalBlock(bytes, 0, bytes.Length);
+ return Convert.ToHexString(outBytes).ToLowerInvariant();
+ }
+
+ private static string RsaEncrypt(string input)
+ {
+ using var rsa = RSA.Create();
+ var exponent = Convert.FromHexString(RsaExponent);
+ var modulus = Convert.FromHexString(RsaModulus);
+ rsa.ImportParameters(new RSAParameters { Exponent = exponent, Modulus = modulus });
+
+ var text = Encoding.UTF8.GetBytes(input).Reverse().ToArray(); // 逆序
+ var encrypted = rsa.Encrypt(text, RSAEncryptionPadding.Pkcs1);
+ return Convert.ToHexString(encrypted).ToLowerInvariant();
+ }
+}
+```
+### 7.3 网易云客户端(Core/Api/Netease/NeteaseClient.cs)
+
+```csharp
+using System.Text;
+using System.Text.Json;
+using NeriPlayer.Core.Api.Common;
+
+namespace NeriPlayer.Core.Api.Netease;
+
+public sealed class NeteaseClient(HttpClient http) : IPlatformClient
+{
+ private const string BaseUrl = "https://music.163.com/weapi/";
+ private const int MaxResponseBytes = 4 * 1024 * 1024; // MAX_RESPONSE_BYTES
+
+ public string PlatformId => "netease";
+ public bool IsLoggedIn { get; private set; }
+
+ private async Task PostWeapiAsync(string path, Dictionary payload)
+ {
+ var form = NeteaseCrypto.Weapi(payload);
+ using var content = new FormUrlEncodedContent(form);
+ using var resp = await http.PostAsync(BaseUrl + path, content);
+ resp.EnsureSuccessStatusCode();
+ var body = await resp.Content.ReadAsByteArrayAsync();
+ if (body.Length > MaxResponseBytes)
+ throw new InvalidOperationException("Response too large");
+ return Encoding.UTF8.GetString(body);
+ }
+
+ public async Task SearchAsync(string keyword, int page = 1)
+ {
+ var json = await PostWeapiAsync("search/get", new Dictionary
+ {
+ ["s"] = keyword,
+ ["type"] = 1,
+ ["limit"] = 30,
+ ["offset"] = (page - 1) * 30,
+ });
+ // 解析 result.songs[] → List(songs 的 ar/al 字段映射)
+ return new SearchResponse([], true);
+ }
+
+ public async Task ResolveSongUrlAsync(SongItem song, string? qualityKey = null)
+ {
+ var level = qualityKey switch
+ {
+ "lossless" => "lossless",
+ "hires" => "hires",
+ "high" => "exhigh",
+ _ => "standard",
+ };
+ var json = await PostWeapiAsync("song/enhance/player/url/v1", new Dictionary
+ {
+ ["ids"] = new[] { song.AudioId ?? song.Id.ToString() },
+ ["level"] = level,
+ ["encodeType"] = "mp3",
+ });
+ // 解析 data[0].url;为空 → 音质降级重试(standard → mp3 兜底)
+ return new SongUrlResult(false, null, level);
+ }
+
+ public Task LoginAsync(LoginMethod method) =>
+ method == LoginMethod.QrCode ? QrLoginAsync() : Task.FromResult(new LoginResult(false, "仅支持二维码"));
+
+ private async Task QrLoginAsync()
+ {
+ // 1) weapi/login/qrcode/unikey 获取 key
+ // 2) 返回 QrUrl = https://music.163.com/login?code_key={key}
+ // 3) 轮询 weapi/login/qrcode/client/login(code 800 未扫 / 801 过期 / 803 成功)
+ // 4) 成功后持久化 Cookie(见第 13 章 CredentialStore)
+ return new LoginResult(false, "未实现");
+ }
+
+ public Task GetPlaylistAsync(string playlistId) => throw new NotImplementedException();
+ public Task> GetFeaturedPlaylistsAsync(int page) => throw new NotImplementedException();
+ public Task GetLyricAsync(SongItem song) => throw new NotImplementedException();
+ public Task GetRecommendationsAsync() => throw new NotImplementedException();
+}
+```
+
+> **实现要点**(对标 Analysis.md 4.1 / 18.1):
+> - Cookie 由 `CookieStore` 注入 HttpClient(CookieContainer 或手动 Header)
+> - 播放失败回退链:音质降级 → 自动源切换(第 4.4 节)→ 本地兜底
+> - 首页推荐:登录感知(未登录过滤 requiresLogin 分区)+ 失败回退(code 301/50000005)
+
+### 7.4 Bilibili 客户端(Core/Api/Bili/BiliClient.cs)
+
+```csharp
+using System.Security.Cryptography;
+using System.Text;
+using NeriPlayer.Core.Api.Common;
+
+namespace NeriPlayer.Core.Api.Bili;
+
+/// WBI 签名(对标 Analysis.md 4.2 + 22.4)
+public static class WbiSignature
+{
+ private static readonly int[] MixinKeyEncTab =
+ [46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,61,26,17,0,1,60,51,30,4,22,25,54,21,56,59,6,63,57,62,11,36,20,34,44,52];
+
+ public static string Sign(Dictionary query, string imgKey, string subKey)
+ {
+ var raw = imgKey + subKey;
+ var mixed = new string(MixinKeyEncTab.Select(i => raw[i]).ToArray());
+ // w_rid = md5(mixedKey + wts + 排序后的 "k1=v1&k2=v2" 完整串)
+ var joined = string.Join("&", query.OrderBy(kv => kv.Key)
+ .Select(kv => $"{kv.Key}={kv.Value}"));
+ var m = MD5.HashData(Encoding.UTF8.GetBytes(mixed + joined));
+ return Convert.ToHexString(m).ToLowerInvariant();
+ }
+
+ // 完整流程:
+ // 1) GET x/web-interface/nav 获取 img_url / sub_url 的 key 部分
+ // 2) 拼装 query:wts = Unix 时间戳 + 业务参数
+ // 3) 计算 w_rid = Sign(query, imgKey, subKey) 追加到 URL
+}
+```
+
+> 实际 WBI 拼接格式需与 `tools_pub/ytmusic_api_probe.py` 与线上抓包对照校准(Analysis.md 17 章)。
+
+**✅ 验收**
+- [ ] `NeteaseCrypto.Weapi` 对固定输入产出固定格式(params + encSecKey)
+- [ ] 网易云搜索接口可返回真实歌曲列表
+- [ ] Bilibili WBI 签名通过线上接口校验(不返回 -403)
+
+---
+### 7.5 YouTube Music 客户端(Core/Api/YouTube/YouTubeMusicClient.cs)
+
+```csharp
+using System.Text.Json;
+using NeriPlayer.Core.Api.Common;
+
+namespace NeriPlayer.Core.Api.YouTube;
+
+/// InnerTube WEB_REMIX 客户端(对标 Analysis.md 4.3 + 22.4)
+public sealed class YouTubeMusicClient(HttpClient http, YouTubePlayerScriptStore scriptStore) : IPlatformClient
+{
+ private const string InnerTubeApi = "https://music.youtube.com/youtubei/v1/";
+ private const string ClientName = "WEB_REMIX";
+ private const int ClientVersion = 67;
+
+ public string PlatformId => "youtube_music";
+ public bool IsLoggedIn { get; private set; }
+
+ private async Task PostInnerTubeAsync(string endpoint, Dictionary payload)
+ {
+ var body = new Dictionary
+ {
+ ["context"] = new Dictionary
+ {
+ ["client"] = new Dictionary
+ {
+ ["clientName"] = ClientName,
+ ["clientVersion"] = ClientVersion.ToString(),
+ ["hl"] = "zh-Hans",
+ ["gl"] = "CN",
+ }
+ },
+ };
+ foreach (var kv in payload) body[kv.Key] = kv.Value;
+
+ using var resp = await http.PostAsJsonAsync(InnerTubeApi + endpoint, body);
+ resp.EnsureSuccessStatusCode();
+ return await resp.Content.ReadAsStringAsync();
+ }
+
+ public async Task SearchAsync(string keyword, int page = 1)
+ {
+ var json = await PostInnerTubeAsync("search", new Dictionary
+ {
+ ["query"] = keyword,
+ ["params"] = "EgWKAQIIAWoKEAoQCRADEAA%3D", // music 歌曲过滤参数
+ });
+ // 解析 contents.tabbedSearchResultsRenderer...musicResponsiveListItemRenderer
+ return new SearchResponse([], true);
+ }
+
+ public async Task ResolveSongUrlAsync(SongItem song, string? qualityKey = null)
+ {
+ // 1) 播放列表加载:browse/videoId → streamingData
+ // 2) 若需 PoToken:走 player.js 缓存 → EJS 挑战 → NewPipe 回退(多级回退链)
+ // 3) 解析 streamingData.adaptiveFormats 选最佳音频格式
+ return new SongUrlResult(false, null, qualityKey);
+ }
+
+ public Task LoginAsync(LoginMethod method) =>
+ // Cookie 登录:SAPISID / __Secure-3PAPISID → CredentialStore 持久化
+ Task.FromResult(new LoginResult(false, "Cookie 登录未实现"));
+ // 其余成员同 IPlatformClient 模式(略)
+}
+```
+
+**YouTube 多级回退链**(对标 Analysis.md 4.3):
+
+```
+登录 Cookie → 匿名 visitor → PoToken(缓存 6h)→ player.js 缓存(48h 过期)
+→ EJS 挑战(JS 求解队列)→ NewPipe 回退
+```
+
+### 7.6 歌词聚合(Core/Api/Lyrics/LyricsSourceAggregator.cs)
+
+对标 Analysis.md 6.1 + Process.md 8.5:
+
+```csharp
+using NeriPlayer.Core.Api.Common;
+
+namespace NeriPlayer.Core.Api.Lyrics;
+
+public sealed class LyricsSourceAggregator(IEnumerable sources, LyricsCache cache)
+{
+ public sealed class LyricsCache(int capacity = 20)
+ {
+ private readonly Dictionary _map = new();
+ public LyricResult? Get(string key) => _map.TryGetValue(key, out var r) ? r : null;
+ public void Put(string key, LyricResult r)
+ {
+ if (_map.Count >= capacity) _map.Remove(_map.Keys.First());
+ _map[key] = r;
+ }
+ }
+
+ public async Task GetLyricAsync(SongItem song)
+ {
+ var key = $"{song.ChannelId}|{song.AudioId}";
+ var cached = cache.Get(key);
+ if (cached is not null) return cached;
+
+ // 合并排序:内嵌 > 匹配源 > 平台官方 > 第三方(QQ/Kugou/LrcLib)
+ foreach (var source in sources.OrderBy(s => s.Priority))
+ {
+ var r = await source.TryGetAsync(song);
+ if (r is not null)
+ {
+ cache.Put(key, r);
+ return r;
+ }
+ }
+ return null;
+ }
+}
+
+public abstract class LyricsSource(int priority)
+{
+ public int Priority { get; } = priority;
+ public abstract Task TryGetAsync(SongItem song);
+}
+```
+
+### 7.7 搜索聚合(Core/Api/Search/SearchManager.cs)
+
+```csharp
+using System.Collections.Concurrent;
+using NeriPlayer.Core.Api.Common;
+
+namespace NeriPlayer.Core.Api.Search;
+
+public sealed class SearchManager(IEnumerable clients)
+{
+ private readonly ConcurrentDictionary _cache = new();
+
+ public async Task SearchAsync(string keyword, int page = 1)
+ {
+ var cacheKey = $"{keyword}|{page}";
+ if (_cache.TryGetValue(cacheKey, out var hit)) return hit;
+
+ // 三平台并发搜索
+ var tasks = clients.Select(c => c.SearchAsync(keyword, page)).ToArray();
+ var results = await Task.WhenAll(tasks);
+
+ // 按 stableKey 去重合并
+ var seen = new HashSet();
+ var merged = new List();
+ foreach (var r in results)
+ foreach (var song in r.Songs)
+ {
+ if (seen.Add(song.StableKey())) merged.Add(song);
+ }
+ var resp = new SearchResponse(merged, results.Any(r => r.HasMore));
+ _cache[cacheKey] = resp;
+ return resp;
+ }
+}
+```
+
+**✅ 验收**
+- [ ] 网易云 + B 站 + YouTube 三平台搜索可并行返回、按 stableKey 去重
+- [ ] 歌词聚合按优先级回退,LRU 缓存(各 20 条)生效
+- [ ] YouTube 匿名访问可解析出音频流(离线可测:用本地 yt-dlp 校验 URL 有效性)
+
+---
+## 八、下载管理(第 38-44 天)
+
+### 8.1 下载任务(Core/Download/DownloadTask.cs)
+
+```csharp
+using System.Reactive.Subjects;
+
+namespace NeriPlayer.Core.Download;
+
+public enum DownloadStatus { Queued, Downloading, Paused, Completed, Failed, Cancelled }
+
+public sealed record DownloadProgress(long TaskId, long BytesReceived, long? TotalBytes,
+ double Percent, DownloadStatus Status);
+
+public sealed class DownloadTask
+{
+ public long TaskId { get; init; }
+ public required string StableKey { get; init; }
+ public required string Url { get; init; }
+ public required string TargetPath { get; init; }
+ public DownloadStatus Status { get; private set; } = DownloadStatus.Queued;
+ public long BytesReceived { get; private set; }
+
+ private readonly Subject _progress = new();
+ public IObservable Progress => _progress;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly HttpClient _http;
+
+ public DownloadTask(HttpClient http) => _http = http;
+
+ public async Task RunAsync()
+ {
+ Status = DownloadStatus.Downloading;
+ try
+ {
+ // 断点续传:Range: bytes={BytesReceived}-(服务端需支持)
+ using var req = new HttpRequestMessage(HttpMethod.Get, Url);
+ if (BytesReceived > 0) req.Headers.Range =
+ new System.Net.Http.Headers.RangeHeaderValue(BytesReceived, null);
+
+ using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, _cts.Token);
+ resp.EnsureSuccessStatusCode();
+
+ var total = resp.Content.Headers.ContentLength ?? 0;
+ await using var src = await resp.Content.ReadAsStreamAsync(_cts.Token);
+ Directory.CreateDirectory(Path.GetDirectoryName(TargetPath)!);
+ var tmpPath = TargetPath + ".part";
+ await using var dst = File.Open(tmpPath, FileMode.Append); // .part 断点续写
+
+ var buffer = new byte[81920];
+ while (true)
+ {
+ var read = await src.ReadAsync(buffer, _cts.Token);
+ if (read == 0) break;
+ await dst.WriteAsync(buffer.AsMemory(0, read), _cts.Token);
+ BytesReceived += read;
+ _progress.OnNext(new DownloadProgress(TaskId, BytesReceived, total,
+ total > 0 ? BytesReceived * 100.0 / total : 0, DownloadStatus.Downloading));
+ }
+
+ await dst.FlushAsync(_cts.Token);
+ if (File.Exists(TargetPath)) File.Delete(TargetPath);
+ File.Move(tmpPath, TargetPath);
+ Status = DownloadStatus.Completed;
+ _progress.OnNext(new DownloadProgress(TaskId, BytesReceived, total, 100, DownloadStatus.Completed));
+ }
+ catch (OperationCanceledException)
+ {
+ Status = DownloadStatus.Cancelled;
+ }
+ catch (Exception)
+ {
+ Status = DownloadStatus.Failed;
+ }
+ }
+
+ public void Cancel() => _cts.Cancel();
+}
+```
+
+### 8.2 下载队列(Core/Download/DownloadQueue.cs)
+
+```csharp
+namespace NeriPlayer.Core.Download;
+
+/// Semaphore 并发控制:默认 6 / 最大 8(Analysis.md 24.2)
+public sealed class DownloadQueue
+{
+ public const int DefaultConcurrency = 6;
+ public const int MaxConcurrency = 8;
+ public const int CancelSettleTimeoutMs = 5000; // DOWNLOAD_CANCEL_SETTLE_TIMEOUT_MS
+
+ private readonly SemaphoreSlim _semaphore = new(DefaultConcurrency, MaxConcurrency);
+ private readonly Queue _pending = new();
+ private readonly List _running = [];
+
+ public event Action? Completed;
+
+ public void Enqueue(DownloadTask task)
+ {
+ _pending.Enqueue(task);
+ _ = PumpAsync();
+ }
+
+ private async Task PumpAsync()
+ {
+ while (_pending.Count > 0)
+ {
+ await _semaphore.WaitAsync();
+ var task = _pending.Dequeue();
+ var run = Task.Run(task.RunAsync);
+ _running.Add(run);
+ _ = run.ContinueWith(_ =>
+ {
+ _semaphore.Release();
+ Completed?.Invoke(task);
+ });
+ }
+ }
+}
+```
+
+### 8.3 标签写入(Core/Download/MetadataWriter.cs)
+
+```csharp
+using TagLib;
+
+namespace NeriPlayer.Core.Download;
+
+/// TagLib# 写标签,失败重试上限 3 次(Analysis.md 24.2)
+public static class MetadataWriter
+{
+ public const int MaxAttempts = 3;
+
+ public static async Task WriteAsync(string filePath, SongMetadata meta, CancellationToken ct = default)
+ {
+ for (var attempt = 1; attempt <= MaxAttempts; attempt++)
+ {
+ try
+ {
+ await Task.Run(() => WriteCore(filePath, meta), ct);
+ return;
+ }
+ catch when (attempt < MaxAttempts) { await Task.Delay(200 * attempt, ct); }
+ }
+ }
+
+ private static void WriteCore(string filePath, SongMetadata meta)
+ {
+ using var file = TagLib.File.Create(filePath);
+ file.Tag.Title = meta.Title;
+ file.Tag.Performers = [meta.Artist];
+ file.Tag.Album = meta.Album;
+ if (meta.CoverBytes is not null && file is IPictureTag pt)
+ pt.Pictures = [new Picture(meta.CoverBytes) { MimeType = "image/jpeg" }];
+ file.Save();
+ }
+}
+
+public sealed record SongMetadata(string Title, string Artist, string Album, byte[]? CoverBytes);
+```
+
+### 8.4 下载索引(Data/Entities 扩展 + 三层索引)
+
+对标 Analysis.md 24.2「catalog/snapshot/recovery/queue 四张索引表」:
+
+| 表 | 作用 | 说明 |
+|----|------|------|
+| `downloads` | 主目录清单 | 歌曲 → 本地路径、状态、质量、进度 |
+| `download_snapshots` | 快照 | 目录树捕获,主键 (root_key, bucket, entry_key) |
+| `download_recovery` | 恢复 | 异常退出后扫描 `.part` 与孤儿文件 |
+| `download_queue` | 队列 | 待下载任务持久化 |
+
+**✅ 验收**
+- [ ] 8 路并发下载不超限(Semaphore 生效)
+- [ ] 中断后 `.part` 保留,重试从 `Range: bytes=N-` 续传
+- [ ] 下载完成后 TagLib# 写入标题/艺人/专辑/封面并 `file.Save()` 无异常
+
+---
+## 九、数据同步(第 45-50 天)
+
+### 9.1 同步接口(Data/Sync/ISyncProvider.cs)
+
+```csharp
+namespace NeriPlayer.Data.Sync;
+
+public sealed record SyncFile(string Name, byte[] Content, string? Etag, DateTimeOffset ModifiedAt);
+public sealed record SyncResult(bool Success, int ChangedCount, string? Message);
+
+/// 同步后端抽象:GitHub 仓库 或 WebDAV 目录
+public interface ISyncProvider
+{
+ string ProviderName { get; }
+ Task TestConnectionAsync();
+ Task> ListAsync(string scope);
+ Task DownloadAsync(string scope, string name);
+ Task UploadAsync(string scope, string name, byte[] content, string? etag);
+ Task DeleteAsync(string scope, string name);
+}
+```
+
+### 9.2 GitHub Provider(Data/Sync/GitHubSyncProvider.cs)
+
+```csharp
+using Octokit;
+
+namespace NeriPlayer.Data.Sync;
+
+/// 对标 SyncGithubManager:仓库内 JSON 文件
+public sealed class GitHubSyncProvider : ISyncProvider
+{
+ private readonly GitHubClient _client;
+ private readonly string _owner;
+ private readonly string _repo;
+ private readonly string _pathPrefix;
+
+ public GitHubSyncProvider(string token, string owner, string repo, string pathPrefix = "neriplayer")
+ {
+ _client = new GitHubClient(new ProductHeaderValue("NeriPlayer.Windows"))
+ {
+ Credentials = new Credentials(token)
+ };
+ _owner = owner; _repo = repo; _pathPrefix = pathPrefix;
+ }
+
+ public string ProviderName => "github";
+ public async Task TestConnectionAsync() =>
+ (await _client.User.Current()).Login.Length > 0;
+
+ public async Task> ListAsync(string scope)
+ {
+ var items = await _client.Repository.Content.GetAllContents(_owner, _repo,
+ $"{_pathPrefix}/{scope}");
+ return items.Select(i => new SyncFile(i.Name, Array.Empty(),
+ i.Sha, i.UpdatedAt ?? DateTimeOffset.MinValue)).ToList();
+ }
+
+ public async Task DownloadAsync(string scope, string name)
+ {
+ var item = await _client.Repository.Content.GetAllContents(_owner, _repo,
+ $"{_pathPrefix}/{scope}/{name}");
+ var content = item[0].Content ?? "";
+ return new SyncFile(name, System.Text.Encoding.UTF8.GetBytes(content),
+ item[0].Sha, item[0].UpdatedAt ?? DateTimeOffset.MinValue);
+ }
+
+ public async Task UploadAsync(string scope, string name, byte[] content, string? etag)
+ {
+ var path = $"{_pathPrefix}/{scope}/{name}";
+ var text = System.Text.Encoding.UTF8.GetString(content);
+ if (etag is not null)
+ await _client.Repository.Content.UpdateFile(_owner, _repo, path,
+ new UpdateFileRequest($"sync {name}", text, etag));
+ else
+ await _client.Repository.Content.CreateFile(_owner, _repo, path,
+ new CreateFileRequest($"sync {name}", text));
+ return true;
+ }
+
+ public Task DeleteAsync(string scope, string name) => throw new NotImplementedException();
+}
+```
+
+### 9.3 WebDAV Provider(Data/Sync/WebDavSyncProvider.cs)
+
+```csharp
+using WebDav;
+
+namespace NeriPlayer.Data.Sync;
+
+public sealed class WebDavSyncProvider : ISyncProvider
+{
+ private readonly WebDavClient _client;
+ private readonly string _root;
+
+ public WebDavSyncProvider(Uri server, string user, string password, string root = "neriplayer")
+ {
+ _client = new WebDavClient(new WebDavClientParams
+ {
+ ServerUrl = server,
+ Credentials = new System.Net.NetworkCredential(user, password),
+ });
+ _root = root;
+ }
+
+ public string ProviderName => "webdav";
+
+ public async Task> ListAsync(string scope)
+ {
+ var result = await _client.Propfind($"{_root}/{scope}");
+ return result.Resources
+ .Where(r => !r.IsCollection)
+ .Select(r => new SyncFile(r.Uri.Split('/').Last(), Array.Empty(),
+ r.ETag, r.LastModified ?? DateTimeOffset.MinValue))
+ .ToList();
+ }
+
+ public async Task DownloadAsync(string scope, string name)
+ {
+ var resp = await _client.GetRawFile($"{_root}/{scope}/{name}");
+ return resp.IsSuccessful
+ ? new SyncFile(name, await resp.Stream.ToArrayAsync(),
+ resp.Headers["ETag"], DateTimeOffset.UtcNow)
+ : null;
+ }
+
+ public async Task UploadAsync(string scope, string name, byte[] content, string? etag)
+ {
+ var path = $"{_root}/{scope}/{name}";
+ using var ms = new MemoryStream(content);
+ var resp = etag is null
+ ? await _client.PutFile(path, ms)
+ : await _client.PutFile(path, ms,
+ headers: new[] { new System.Net.WebHeaderCollection { { "If-Match", etag } } });
+ return resp.IsSuccessful;
+ }
+
+ public Task DeleteAsync(string scope, string name) => throw new NotImplementedException();
+}
+```
+### 9.4 因果 Token 合并(Data/Sync/SyncMergeStrategy.cs)
+
+```csharp
+namespace NeriPlayer.Data.Sync;
+
+/// 同步记录携带因果 Token(对标 SyncCausalToken / Analysis.md 9 章)
+public sealed record SyncToken(string SongId, string BaseVersion, string OperationId);
+
+public static class SyncMergeStrategy
+{
+ /// 冲突裁决:因果序优先,其次时间戳
+ public static int Compare(SyncToken a, SyncToken b, long aTimestamp, long bTimestamp)
+ {
+ if (a.BaseVersion == b.OperationId) return -1; // a 是 b 的祖先
+ if (b.BaseVersion == a.OperationId) return 1; // b 是 a 的祖先
+ return aTimestamp.CompareTo(bTimestamp); // 无因果关系 → 时间裁决
+ }
+
+ /// 歌单合并:按 stableKey 去重 + updated_at 冲突裁决(Process.md 10.2)
+ public static IReadOnlyList MergePlaylists(
+ IReadOnlyList local, IReadOnlyList remote)
+ {
+ var map = new Dictionary();
+ foreach (var s in local) map[s.StableKey()] = s;
+ foreach (var s in remote)
+ {
+ if (!map.TryGetValue(s.StableKey(), out var existing))
+ map[s.StableKey()] = s;
+ else if (s.AddedAt > existing.AddedAt)
+ map[s.StableKey()] = s;
+ }
+ return map.Values.ToList();
+ }
+}
+```
+
+### 9.5 定时同步(Background/Services/SyncScheduledService.cs)
+
+```csharp
+using Microsoft.Extensions.Hosting;
+using Quartz;
+using Quartz.Impl;
+
+namespace NeriPlayer.Background.Services;
+
+/// 对标 WorkManager:每日 02:00 同步 + 手动触发
+public sealed class SyncScheduledService : BackgroundService
+{
+ private readonly ISyncProvider _provider;
+
+ public SyncScheduledService(ISyncProvider provider) => _provider = provider;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var factory = new StdSchedulerFactory();
+ var scheduler = await factory.GetScheduler(stoppingToken);
+ await scheduler.Start(stoppingToken);
+
+ var job = JobBuilder.Create().Build();
+ var trigger = TriggerBuilder.Create()
+ .WithCronSchedule("0 0 2 * * ?") // 每日 02:00
+ .Build();
+ await scheduler.ScheduleJob(job, trigger, stoppingToken);
+ }
+}
+
+public sealed class SyncJob : IJob
+{
+ public async Task Execute(IJobExecutionContext context)
+ {
+ // 1) 收集本地 outbox 变更
+ // 2) 拉取远端 checkpoint 之后的新文件
+ // 3) MergePlaylists 合并 → 回写
+ // 4) 更新 checkpoint 游标
+ await Task.CompletedTask; // 实际逻辑见 9.1-9.4
+ }
+}
+```
+
+**✅ 验收**
+- [ ] GitHub Provider 可上传/下载/列表(用测试 token + 私有空仓库)
+- [ ] WebDAV Provider 通过(可用坚果云/Nextcloud 测试)
+- [ ] 因果合并裁决:同 key 冲突按 updated_at 取新、因果链正确
+- [ ] 定时任务每天 02:00 触发(日志可查)
+
+---
+## 十、UI 主框架(第 51-62 天)
+
+### 10.1 主窗口三栏布局(UI/Views/MainWindow.axaml)
+
+对标 Process.md 11.1:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 10.2 主窗口 ViewModel(UI/ViewModels/MainWindowViewModel.cs)
+
+```csharp
+using ReactiveUI;
+
+namespace NeriPlayer.UI.ViewModels;
+
+public sealed class MainWindowViewModel : ReactiveObject
+{
+ private readonly Dictionary _pages = new();
+ private object _currentPage;
+
+ public MainWindowViewModel()
+ {
+ // 页面注册(懒加载)
+ _pages["Home"] = new HomeViewModel();
+ _pages["Discover"] = new DiscoverViewModel();
+ _pages["Library"] = new LibraryViewModel();
+ _pages["Downloads"] = new DownloadsViewModel();
+ _pages["Settings"] = new SettingsViewModel();
+ _currentPage = _pages["Home"];
+ }
+
+ public object CurrentPage
+ {
+ get => _currentPage;
+ private set => this.RaiseAndSetIfChanged(ref _currentPage, value);
+ }
+
+ public void NavigateTo(string key)
+ {
+ if (_pages.TryGetValue(key, out var page)) CurrentPage = page;
+ }
+}
+```
+
+### 10.3 底部播放条(UI/Views/PlaybackBarView.axaml + ViewModel)
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+```csharp
+// PlaybackBarViewModel.cs
+using System.Reactive;
+using ReactiveUI;
+using NeriPlayer.Core.Player;
+
+public sealed class PlaybackBarViewModel : ReactiveObject
+{
+ private string _currentTitle = "";
+ private string _currentArtist = "";
+ private double _positionMs;
+ private double _durationMs;
+
+ public string CurrentTitle { get => _currentTitle; private set => this.RaiseAndSetIfChanged(ref _currentTitle, value); }
+ public string CurrentArtist { get => _currentArtist; private set => this.RaiseAndSetIfChanged(ref _currentArtist, value); }
+ public double PositionMs { get => _positionMs; set => this.RaiseAndSetIfChanged(ref _positionMs, value); }
+ public double DurationMs { get => _durationMs; private set => this.RaiseAndSetIfChanged(ref _durationMs, value); }
+
+ public ReactiveCommand TogglePlayCommand { get; }
+ public ReactiveCommand NextCommand { get; }
+ public ReactiveCommand PreviousCommand { get; }
+
+ public PlaybackBarViewModel() : this(new PlayerManager()) { }
+
+ public PlaybackBarViewModel(PlayerManager player)
+ {
+ TogglePlayCommand = ReactiveCommand.Create(async () =>
+ {
+ if (player.State is null) return; // 简化:按实际状态切换
+ });
+ NextCommand = ReactiveCommand.Create(() => player.NextAsync());
+ PreviousCommand = ReactiveCommand.Create(() => player.PreviousAsync());
+
+ player.CurrentSong.Subscribe(s =>
+ {
+ CurrentTitle = s?.DisplayName ?? "";
+ CurrentArtist = s?.DisplayArtist ?? "";
+ });
+ player.Position.Subscribe(ms => PositionMs = ms.TotalMilliseconds);
+ }
+}
+```
+### 10.4 歌词滚动视图(UI/Controls/LyricsScroller.axaml)
+
+对标 Process.md 11.5(ItemsControl + 居中高亮行 + 1.25x 字号):
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+```
+
+```csharp
+// LyricsScroller.axaml.cs 核心逻辑
+using Avalonia.Controls;
+
+public partial class LyricsScroller : UserControl
+{
+ private const double LineHeight = 40; // 行高估算
+ private int _activeIndex = -1;
+
+ public void UpdatePosition(TimeSpan positionMs)
+ {
+ // 行索引 = LyricTimeline.FindIndexAt(positionMs)(二分查找)
+ var index = -1; // 由 LyricTimeline 计算
+ if (index == _activeIndex) return;
+ _activeIndex = index;
+ // 滚动到当前行:offset = index * LineHeight - Viewport.Height / 2
+ }
+}
+```
+
+### 10.5 歌词解析器(Core/Player/Lyrics/LrcParser.cs)
+
+对标 Analysis.md 6.2(LRC/TTML/YRC):
+
+```csharp
+namespace NeriPlayer.Core.Player.Lyrics;
+
+public sealed record LyricLine(TimeSpan Start, string Text, string? Translated);
+
+public static class LrcParser
+{
+ /// 解析标准 LRC:多语言/偏移标签/损坏输入容错
+ public static IReadOnlyList Parse(string lrc, long offsetMs = 0)
+ {
+ var lines = new List();
+
+ foreach (var raw in lrc.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var line = raw.Trim();
+ if (line.StartsWith('[') && line.Contains(':'))
+ {
+ var idx = line.IndexOf(']');
+ var tag = line[1..idx];
+ if (tag.Contains(':'))
+ {
+ var parts = tag.Split(':');
+ if (int.TryParse(parts[0], out var m) && double.TryParse(parts[1], out var s))
+ {
+ var text = line[(idx + 1)..].Trim();
+ if (text.Length == 0) continue;
+ var start = TimeSpan.FromMilliseconds(m * 60_000 + s * 1000 + offsetMs);
+ lines.Add(new LyricLine(start, text, null));
+ }
+ }
+ else if (tag.StartsWith("offset:") && int.TryParse(tag[7..], out var off))
+ {
+ return Parse(lrc, off); // 整体偏移后重新解析
+ }
+ }
+ }
+ return lines.OrderBy(l => l.Start).ToList();
+ }
+}
+```
+
+### 10.6 设置系统(Source Generator)
+
+对标 KSP 设置生成(Analysis.md 第 12 章 + 23.2):
+
+```csharp
+// NeriPlayer.SourceGen/SettingsGenerator.cs —— 增量 Source Generator(简化版)
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+[Generator(LanguageNames.CSharp)]
+public sealed class SettingsGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var settings = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ static (node, _) => node is Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax,
+ static (ctx, _) => ctx.Node)
+ .Where(static _ => true);
+
+ context.RegisterSourceOutput(settings, static (spc, _) =>
+ {
+ // 扫描 [Setting] 属性 → 生成 SettingsKeys 常量类
+ var sb = new StringBuilder();
+ sb.AppendLine("namespace NeriPlayer.Data.Settings;");
+ sb.AppendLine("public static partial class SettingsKeys {");
+ sb.AppendLine(" public const string ThemeMode = \"theme.mode\";");
+ sb.AppendLine(" public const string AccentColor = \"theme.accent\";");
+ sb.AppendLine("}");
+ spc.AddSource("SettingsKeys.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
+ });
+ }
+}
+```
+
+> 完整实现:基于 `SettingsSchema` 类的 `[Setting(key, defaultValue)]` 属性标记生成
+> 常量 + 读写访问器,替代 Kotlin 的 `@AutoSetting`(Analysis.md 23.2)。
+
+**✅ 验收**
+- [ ] 主窗口三栏布局渲染正常,Tab 切换无卡顿
+- [ ] 底部播放条与 PlayerManager 状态联动(切歌/进度/播放暂停)
+- [ ] LRC 解析:偏移标签、多语言行、空行、损坏输入均有测试
+- [ ] Source Generator 生成 `SettingsKeys` 且可被编译期消费
+
+---
+## 十一、后台与系统集成(第 63-68 天)
+
+### 11.1 SMTC 媒体控制(Background/Services/SmtcIntegration.cs)
+
+对标 Analysis.md 16 章(MediaSession)+ Process.md 12.1:
+
+```csharp
+using Windows.Media;
+using Windows.Media.Playback;
+using Windows.Storage.Streams;
+
+namespace NeriPlayer.Background.Services;
+
+/// SystemMediaTransportControls:任务栏悬浮窗 / 锁屏 / 媒体键
+public sealed class SmtcIntegration : IDisposable
+{
+ private readonly SystemMediaTransportControls _smtc;
+ private SystemMediaTransportControlsTimelineProperties? _timeline;
+
+ public SmtcIntegration()
+ {
+ _smtc = SystemMediaTransportControls.GetForCurrentView();
+ _smtc.IsPlayEnabled = true;
+ _smtc.IsPauseEnabled = true;
+ _smtc.IsNextEnabled = true;
+ _smtc.IsPreviousEnabled = true;
+ _smtc.IsEnabled = true;
+ _smtc.ButtonPressed += OnButtonPressed;
+ }
+
+ public event Action? ButtonPressed;
+
+ public void UpdateMetadata(string title, string artist, string album, byte[]? albumArtPng)
+ {
+ var updater = _smtc.DisplayUpdater;
+ updater.Type = MediaPlaybackType.Music;
+ updater.MusicProperties.Title = title;
+ updater.MusicProperties.Artist = artist;
+ updater.MusicProperties.AlbumTitle = album;
+ if (albumArtPng is not null)
+ {
+ updater.Thumbnail = RandomAccessStreamReference.CreateFromStream(
+ new MemoryStream(albumArtPng).AsRandomAccessStream());
+ }
+ updater.Update();
+ }
+
+ public void UpdatePlaybackStatus(MediaPlaybackStatus status)
+ {
+ _smtc.PlaybackStatus = status;
+ _smtc.IsEnabled = status != MediaPlaybackStatus.Stopped;
+ }
+
+ public void UpdatePosition(TimeSpan position, TimeSpan duration)
+ {
+ _timeline ??= new SystemMediaTransportControlsTimelineProperties();
+ _timeline.Position = position;
+ _timeline.EndTime = duration;
+ _timeline.StartTime = TimeSpan.Zero;
+ _timeline.MinSeekTime = TimeSpan.Zero;
+ _timeline.MaxSeekTime = duration;
+ _smtc.UpdateTimelineProperties(_timeline);
+ }
+
+ private void OnButtonPressed(SystemMediaTransportControls sender,
+ SystemMediaTransportControlsButtonPressedEventArgs args)
+ => ButtonPressed?.Invoke(args.Button);
+
+ public void Dispose() => _smtc.ButtonPressed -= OnButtonPressed;
+}
+```
+
+> 注意:`GetForCurrentView()` 需 UI 线程。在 App 启动时初始化并持有;
+> 播放服务通过事件接入 `PlayerManager`(Play/Pause/Next/Previous)。
+
+### 11.2 任务栏缩略图按钮
+
+Avalonia 无内置 `TaskbarItemInfo`,使用 Win32 `ITaskbarList3`:
+
+```
+// 获取 hwnd:(nint)TopLevel.GetTopLevel(window)?.TryGetPlatformHandle()?.Handle
+// ITaskbarList3::ThumbBarAddButtons(hwnd, 3, [⏮ ▶/⏸ ⏭])
+// ITaskbarList3::ThumbBarUpdateButtons —— 播放状态切换图标
+// 事件:WM_COMMAND (wParam = 0x8000 + 按钮索引) → 分发到 PlayerManager
+// 参考实现:WindowsAPICodePack TaskbarManager 或手写 P/Invoke(约 100 行)
+```
+### 11.3 桌面歌词(UI/FloatingLyricsWindow.axaml)
+
+对标 FloatingLyricsOverlayManager(Analysis.md 6.3 / Process.md 12.3):
+
+```xml
+
+
+
+
+
+
+```
+
+- 拖动:`PointerPressed/PointerMoved` 记录偏移(对标 Android 长按拖动)
+- 设置:字号/颜色/位置锁定/点击穿透
+
+### 11.4 Toast 通知(Background/Notifications/ToastNotificationService.cs)
+
+```csharp
+using Windows.UI.Notifications;
+
+namespace NeriPlayer.Background.Notifications;
+
+public static class ToastNotificationService
+{
+ public static void Show(string title, string message, string? tag = null)
+ {
+ var xml = $@"""
+
+ {System.Security.SecurityElement.Escape(title)}
+ {System.Security.SecurityElement.Escape(message)}
+
+ """;
+ var doc = new Windows.Data.Xml.Dom.XmlDocument();
+ doc.LoadXml(xml);
+ var toast = new ToastNotification(doc) { Tag = tag };
+ ToastNotificationManager.CreateToastNotifier("NeriPlayer").Show(toast);
+ }
+}
+```
+
+触发场景:播放状态变化、下载完成(点击打开位置)、同步完成/失败(Process.md 12.5)。
+
+### 11.5 后台播放服务(Background/Services/PlaybackService.cs)
+
+对标 AudioPlayerService(Analysis.md 第 20 章):
+
+```csharp
+using Microsoft.Extensions.Hosting;
+
+namespace NeriPlayer.Background.Services;
+
+/// 主窗口关闭后保持播放(托盘 + SMTC 控制)
+public sealed class PlaybackService : BackgroundService
+{
+ private readonly SmtcIntegration _smtc;
+ private readonly Core.Player.PlayerManager _player;
+
+ public PlaybackService(SmtcIntegration smtc, Core.Player.PlayerManager player)
+ {
+ _smtc = smtc;
+ _player = player;
+ _smtc.ButtonPressed += b =>
+ {
+ switch (b)
+ {
+ case SystemMediaTransportControlsButton.Play: _ = _player.ResumeAsync(); break;
+ case SystemMediaTransportControlsButton.Pause: _ = _player.PauseAsync(); break;
+ case SystemMediaTransportControlsButton.Next: _ = _player.NextAsync(); break;
+ case SystemMediaTransportControlsButton.Previous: _ = _player.PreviousAsync(); break;
+ }
+ };
+ }
+
+ protected override Task ExecuteAsync(CancellationToken stoppingToken) => Task.CompletedTask;
+}
+```
+
+> 空闲关闭策略(PlaybackServiceIdlePolicy 对标):无播放任务超过
+> `playback_service_idle_shutdown_minutes`(可配置,默认 10min)→ 停止服务。
+
+**✅ 验收**
+- [ ] SMTC 悬浮窗显示封面/标题/进度,媒体键可控制播放
+- [ ] 主窗口关闭后播放不中断(托盘图标可恢复窗口)
+- [ ] 桌面歌词窗口置顶、可拖动、随歌词滚动
+- [ ] 下载完成弹出 Toast
+
+---
+## 十二、视觉效果(第 69-73 天)
+
+### 12.1 流体背景 Shader(UI/Effects/FluidBackgroundEffect.cs)
+
+对标 `hyper_background_effect.glsl`(Analysis.md 11.1:5 色点 + 音频响应 + 颗粒噪声):
+
+```csharp
+using SkiaSharp;
+
+namespace NeriPlayer.UI.Effects;
+
+/// Skia RuntimeEffect 迁移 AGSL 流体背景(Process.md 11.3)
+public sealed class FluidBackgroundEffect : IDisposable
+{
+ private const string ShaderSource = """
+ uniform float u_time;
+ uniform float uLevelEase; // 音频能量位移
+ uniform float uBeatEase; // 节拍脉冲
+ uniform vec2 uPoints[5];
+ uniform vec4 uColors[5];
+ uniform vec2 uResolution;
+
+ float hash(vec2 p) {
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
+ }
+
+ float noise(vec2 p) {
+ vec2 i = floor(p), f = fract(p);
+ f = f * f * (3.0 - 2.0 * f);
+ return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
+ mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
+ }
+
+ half4 main(float2 fragCoord) {
+ vec2 uv = fragCoord / uResolution;
+ float t = u_time * 0.15;
+ vec4 acc = vec4(0.0);
+ for (int i = 0; i < 5; i++) {
+ vec2 p = uPoints[i] + vec2(sin(t + float(i) * 1.7),
+ cos(t * 0.8 + float(i))) * 0.06 * uLevelEase;
+ float d = distance(uv, p);
+ acc += uColors[i] * exp(-d * 3.2) * (1.0 + uBeatEase * 0.25);
+ }
+ acc += vec4(noise(uv * 6.0 + t) * 0.06);
+ return half4(acc.rgb, 1.0);
+ }
+ """;
+
+ private readonly SKRuntimeEffect? _effect;
+
+ public FluidBackgroundEffect()
+ {
+ _effect = SKRuntimeEffect.Create(ShaderSource, out var errors);
+ if (errors?.Length > 0) AppLogger.Instance.Warning("shader errors: {Errors}", errors);
+ }
+
+ public SKPaint CreatePaint(float time, float level, float beat,
+ SKPoint[] points, SKColor[] colors, SKSize resolution)
+ {
+ var uniforms = _effect!.Uniforms;
+ uniforms["u_time"].AsScalar() = time;
+ uniforms["uLevelEase"].AsScalar() = level;
+ uniforms["uBeatEase"].AsScalar() = beat;
+ uniforms["uResolution"].AsVec2() = new SKPoint(resolution.Width, resolution.Height);
+ for (var i = 0; i < 5 && i < points.Length; i++)
+ {
+ uniforms["uPoints[" + i + "]"].AsVec2() = points[i];
+ uniforms["uColors[" + i + "]"].AsVec4() =
+ new SKColorF(colors[i].Red / 255f, colors[i].Green / 255f,
+ colors[i].Blue / 255f, colors[i].Alpha / 255f);
+ }
+ return new SKPaint { Shader = _effect.ToShader(false, uniforms) };
+ }
+
+ public bool IsSupported => _effect is not null;
+
+ public void Dispose() => _effect?.Dispose();
+}
+```
+### 12.2 封面取色(UI/Effects/CoverColorExtractor.cs)
+
+对标 `Palette`(中位切分算法,Process.md 11.7):
+
+```csharp
+using SkiaSharp;
+
+namespace NeriPlayer.UI.Effects;
+
+/// 封面主色调提取:缩小 + 量化直方图(取最显著色)
+public static class CoverColorExtractor
+{
+ public static SKColor ExtractDominant(SKBitmap cover)
+ {
+ using var small = cover.Resize(new SKImageInfo(32, 32), SKFilterQuality.Medium);
+ var counts = new Dictionary();
+ for (var y = 0; y < small.Height; y++)
+ for (var x = 0; x < small.Width; x++)
+ {
+ var c = small.GetPixel(x, y);
+ if (c.Alpha < 128) continue; // 忽略透明
+ var r = c.Red >> 3, g = c.Green >> 3, b = c.Blue >> 3; // 5 bit/通道
+ var key = (r << 10) | (g << 5) | b;
+ counts[key] = counts.GetValueOrDefault(key) + 1;
+ }
+ var top = counts.OrderByDescending(kv => kv.Value).FirstOrDefault();
+ var k = top.Key;
+ return new SKColor((byte)(((k >> 10) & 31) << 3 | 7),
+ (byte)(((k >> 5) & 31) << 3 | 7),
+ (byte)((k & 31) << 3 | 7));
+ }
+
+ /// 由主色派生 5 组流体背景色点(HSV 偏移,对标 BgEffectPainter)
+ public static SKColor[] DerivePalette(SKColor dominant)
+ {
+ dominant.ToHsv(out var h, out var s, out var v);
+ return
+ [
+ dominant,
+ SKColor.FromHsv((h + 30) % 360, Math.Min(1f, s * 1.2f), Math.Min(1f, v + 0.1f)),
+ SKColor.FromHsv((h + 150) % 360, s, v),
+ SKColor.FromHsv((h + 210) % 360, Math.Max(0, s - 0.3f), Math.Max(0, v - 0.15f)),
+ SKColor.FromHsv(h, Math.Max(0, s - 0.2f), Math.Max(0, v - 0.3f)),
+ ];
+ }
+}
+```
+
+### 12.3 毛玻璃(Process.md 11.4)
+
+- 方案 A(静态):`SKImageFilter.CreateBlur(radius, radius)` + 背景快照
+- 方案 B(推荐):Avalonia `ExperimentalAcrylicBorder`(实时模糊,性能好):
+
+```xml
+
+
+
+
+```
+
+### 12.4 音频可视化(WaveformVisualizer)
+
+- 数据源:`FftAnalyzer`(第 6.4 节)→ 64 频带
+- 绘制:Avalonia `DrawingContext` / Skia 直绘(`SKCanvas`)
+- 效果:峰值保持 + 衰减(对标 WaveformSlider 预测动画 Analysis.md 18.2)
+
+**✅ 验收**
+- [ ] 流体背景随封面主色渲染、音频能量驱动位移/节拍脉冲
+- [ ] 封面取色在 30 张测试封面上均得到合理主色
+- [ ] 毛玻璃叠加不显著掉帧(≥ 55 FPS)
+- [ ] GPU 不支持时自动降级为静态渐变
+
+---
+## 十三、安全与崩溃恢复(第 74-77 天)
+
+### 13.1 凭据加密存储(Data/Auth/CredentialStore.cs)
+
+对标 DPAPI(Process.md 13.1):
+
+```csharp
+using System.Security.Cryptography;
+
+namespace NeriPlayer.Data.Auth;
+
+/// DPAPI(CurrentUser)加密 Cookie / 刷新 Token
+public sealed class CredentialStore(string baseDir)
+{
+ private string Dir { get; } = baseDir;
+
+ public void Save(string key, byte[] plaintext)
+ {
+ Directory.CreateDirectory(Dir);
+ var encrypted = ProtectedData.Protect(plaintext, null, DataProtectionScope.CurrentUser);
+ File.WriteAllBytes(Path.Combine(Dir, key), encrypted);
+ }
+
+ public byte[]? Load(string key)
+ {
+ var path = Path.Combine(Dir, key);
+ if (!File.Exists(path)) return null;
+ var encrypted = File.ReadAllBytes(path);
+ try
+ {
+ return ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser);
+ }
+ catch (CryptographicException)
+ {
+ File.Delete(path); // 密钥失效 → 清除
+ return null;
+ }
+ }
+
+ public void Delete(string key)
+ {
+ var path = Path.Combine(Dir, key);
+ if (File.Exists(path)) File.Delete(path);
+ }
+}
+```
+
+存储布局(Process.md 13.1 对标):
+
+| 数据 | 文件 | 加密 |
+|------|------|------|
+| 三平台 Cookie | `%APPDATA%/NeriPlayer/secure/cookies.bin` | DPAPI |
+| 刷新 Token | 同上 | DPAPI |
+| 网易云设备 ID | `%APPDATA%/NeriPlayer/secure/device_id` | 明文 |
+
+### 13.2 全局异常处理(Core/Diagnostics/ExceptionHandler.cs)
+
+对标 Analysis.md 14.1:
+
+```csharp
+using System.Text;
+using NeriPlayer.Core.Logging;
+
+namespace NeriPlayer.Core.Diagnostics;
+
+public static class ExceptionHandler
+{
+ private static string CrashDir =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "NeriPlayer", "crash");
+
+ public static void Install()
+ {
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ WriteCrash("Unhandled", e.ExceptionObject as Exception);
+ TaskScheduler.UnobservedTaskException += (_, e) =>
+ {
+ WriteCrash("UnobservedTask", e.Exception);
+ e.SetObserved();
+ };
+ }
+
+ private static void WriteCrash(string kind, Exception? ex)
+ {
+ try
+ {
+ Directory.CreateDirectory(CrashDir);
+ var sb = new StringBuilder();
+ sb.AppendLine($"Kind: {kind}");
+ sb.AppendLine($"Time: {DateTimeOffset.Now:O}");
+ sb.AppendLine($"Process: {Environment.ProcessId}");
+ sb.AppendLine($"Thread: {Environment.CurrentManagedThreadId}");
+ sb.AppendLine($"Exception: {ex}");
+ var path = Path.Combine(CrashDir, $"crash-{DateTime.Now:yyyyMMdd-HHmmss}.log");
+ File.WriteAllText(path, sb.ToString());
+ AppLogger.Instance.Error("crash written: {Path}", path);
+ }
+ catch { /* 崩溃恢复自身失败则静默 */ }
+ }
+
+ /// 启动时扫描:发现上次崩溃文件 → 返回给 UI 提示
+ public static IReadOnlyList ListRecentCrashes() =>
+ Directory.Exists(CrashDir)
+ ? Directory.GetFiles(CrashDir, "crash-*.log")
+ .OrderByDescending(f => f).Take(5).ToList()
+ : [];
+}
+```
+### 13.3 安全模式(Core/Diagnostics/SafeModeManager.cs)
+
+对标 SafeModeManager + AppStartupPlanner(Analysis.md 14.2 / Process.md 13.3):
+
+```csharp
+namespace NeriPlayer.Core.Diagnostics;
+
+public sealed class SafeModeManager(string stateFile)
+{
+ private const int CrashThreshold = 2;
+ private int _consecutiveCrashCount;
+
+ public bool IsSafeMode { get; private set; }
+
+ /// App 启动早期调用:读计数 → 判定是否进入安全模式
+ public bool EvaluateStartup()
+ {
+ LoadState();
+ if (_consecutiveCrashCount >= CrashThreshold)
+ {
+ IsSafeMode = true;
+ _consecutiveCrashCount = 0; // 进入安全模式后复位
+ SaveState();
+ }
+ return IsSafeMode;
+ }
+
+ public void RecordStartupSuccess()
+ {
+ _consecutiveCrashCount = 0;
+ SaveState();
+ }
+
+ public void RecordCrash() => SaveState(++_consecutiveCrashCount);
+
+ private void SaveState(int count = -1)
+ {
+ if (count >= 0) _consecutiveCrashCount = count;
+ File.WriteAllText(stateFile, _consecutiveCrashCount.ToString());
+ }
+
+ private void LoadState()
+ {
+ if (File.Exists(stateFile) &&
+ int.TryParse(File.ReadAllText(stateFile).Trim(), out var c))
+ _consecutiveCrashCount = c;
+ }
+}
+```
+
+### 13.4 崩溃恢复流程(对标 Analysis.md 2.1 启动链路)
+
+```
+App.OnStartup
+ ├── 1. ExceptionHandler.Install()
+ ├── 2. SafeModeManager.EvaluateStartup() → 连续 2 次崩溃进入安全模式
+ ├── 3. 正常路径:初始化 DI → 预热缓存 → 恢复上次播放状态(15s 间隔持久化)
+ ├── 4. 安全模式:禁用自动同步 / 禁用 YouTube PoToken 自研解析(走回退)/
+ │ 清空异常缓存 → 显示 SafeModeScreen(可从设置退出)
+ └── 5. 退出前 PlayerStatePersistence.Save()(命令触发即时持久化)
+```
+
+**✅ 验收**
+- [ ] Cookie 经 DPAPI 加密后不可明文读取;同用户可解密
+- [ ] 手动制造未捕获异常 → `crash/*.log` 生成
+- [ ] 连续 2 次崩溃后下次启动进入安全模式并禁用自动同步
+
+---
+## 十四、测试体系(贯穿全程)
+
+### 14.1 测试项目布局
+
+| 项目 | 类型 | 覆盖 |
+|------|------|------|
+| `NeriPlayer.Core.Tests` | 单元 | 歌词解析、StableKey、曲目去重、失败策略、状态机、音效、下载队列、同步合并、搜索合并 |
+| `NeriPlayer.Data.Tests` | 集成 | EF Core 迁移、Repository CRUD、播放状态 round-trip |
+| `NeriPlayer.Api.Tests` | API | Mock HTTP(自定义 HttpMessageHandler)解析三平台响应 |
+
+### 14.2 Mock HTTP 示例(tests/NeriPlayer.Api.Tests)
+
+```csharp
+using System.Net;
+using NeriPlayer.Core.Api.Netease;
+using Xunit;
+
+namespace NeriPlayer.Api.Tests;
+
+public sealed class MockHttpHandler : HttpMessageHandler
+{
+ public Func? Responder { get; set; }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ var resp = Responder?.Invoke(request) ?? new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{}"),
+ };
+ return Task.FromResult(resp);
+ }
+}
+
+public class NeteaseSearchTests
+{
+ [Fact]
+ public async Task SearchAsync_ParsesSongs()
+ {
+ var handler = new MockHttpHandler
+ {
+ Responder = _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """{"result":{"songs":[{"id":123,"name":"歌","ar":[{"name":"艺人"}],"al":{"name":"专辑"}}]}}"""),
+ }
+ };
+ var client = new NeteaseClient(new HttpClient(handler));
+ var result = await client.SearchAsync("测试");
+ Assert.NotEmpty(result.Songs);
+ }
+}
+```
+
+### 14.3 集成测试(tests/NeriPlayer.Data.Tests)
+
+```csharp
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using NeriPlayer.Data.Database;
+using NeriPlayer.Data.Entities;
+using NeriPlayer.Data.Repositories;
+using Xunit;
+
+namespace NeriPlayer.Data.Tests;
+
+public class SongRepositoryTests
+{
+ private static NeriDbContext CreateDb()
+ {
+ var conn = new SqliteConnection("Data Source=:memory:");
+ conn.Open();
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(conn).Options;
+ var db = new NeriDbContext(options);
+ db.Database.EnsureCreated();
+ return db;
+ }
+
+ [Fact]
+ public async Task Upsert_SameStableKey_NoDuplicate()
+ {
+ await using var db = CreateDb();
+ var repo = new SongRepository(db);
+ var song = new SongEntity { StableKey = "netease|1", Name = "A", Artist = "B", Album = "C" };
+ var id1 = await repo.UpsertAsync(song);
+ var id2 = await repo.UpsertAsync(song with { Name = "A2" });
+ Assert.Equal(id1, id2);
+ Assert.Single(await db.Songs.ToListAsync());
+ }
+}
+```
+
+### 14.4 Avalonia Headless UI 测试
+
+```powershell
+dotnet add src/NeriPlayer.UI package Avalonia.Headless.XUnit
+```
+
+```csharp
+using Avalonia.Headless.XUnit;
+using NeriPlayer.UI.Views;
+using Xunit;
+
+public class MainWindowTests
+{
+ [AvaloniaFact]
+ public void MainWindow_Initializes()
+ {
+ var window = new MainWindow();
+ Assert.NotNull(window);
+ }
+}
+```
+### 14.5 性能基准(Process.md 14.4)
+
+| 场景 | 目标 | 验证方式 |
+|------|------|----------|
+| 冷启动到可播放 | ≤ 3s | `Stopwatch` 日志埋点 |
+| 10,000 首本地扫描 | ≤ 30s / 增量 ≤ 2s | 基准测试脚本 |
+| 内存(5000 首歌单) | ≤ 400MB | `dotnet-counters` |
+| 连续播放 48h | 无泄漏无崩溃 | 自动化 + 崩溃目录监控 |
+| 封面缓存二次加载 | ≤ 100ms | Headless 性能测试 |
+
+```powershell
+# 48h 稳定性脚本(PowerShell 计划任务)
+$endTime = (Get-Date).AddHours(48)
+while ((Get-Date) -lt $endTime) {
+ dotnet run --project src/NeriPlayer.App -- --autoplay
+ Start-Sleep -Seconds 60
+ # 检查 crash 目录是否有新文件 → 失败标记
+}
+```
+
+**✅ 验收**
+- [ ] `dotnet test` 全绿(单元 + 集成 + API)
+- [ ] Headless UI 测试可运行
+- [ ] 性能基准数据记录在 `docs/benchmark.md`
+
+---
+
+## 十五、打包与发布(第 78-84 天)
+
+### 15.1 便携版发布
+
+```powershell
+# 发布到 publish 目录(含 VLC 依赖)
+$VLC = 'D:\libs\vlc-3.0.20'
+dotnet publish src/NeriPlayer.App -c Release -r win-x64 --self-contained true -o publish
+
+# 复制 VLC 运行库
+Copy-Item "$VLC\libvlc.dll" publish\
+Copy-Item "$VLC\libvlccore.dll" publish\
+Copy-Item "$VLC\plugins" publish\plugins -Recurse
+
+# 验证
+.\publish\NeriPlayer.App.exe
+```
+
+### 15.2 MSIX 打包(可选)
+
+```powershell
+# 使用 Windows Application Packaging 项目(Package.appxmanifest + 图标 + 自动更新)
+# 或先用 Inno Setup/NSIS 打传统安装包(Process.md 15.1)
+```
+
+### 15.3 GitHub Actions CI/CD
+
+创建 `.github/workflows/build.yml`:
+
+```yaml
+name: build
+
+on:
+ push:
+ tags: ["v*"]
+ workflow_dispatch:
+
+jobs:
+ windows-build:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+ - name: Download LibVLC
+ run: |
+ Invoke-WebRequest https://get.videolan.org/vlc/3.0.20/win64/vlc-3.0.20-win64.zip -OutFile vlc.zip
+ Expand-Archive vlc.zip .
+ Copy-Item "vlc-3.0.20" "$env:LOCALAPPDATA\vlc" -Recurse
+ - name: Restore & Build
+ run: dotnet build NeriPlayer.Windows.sln -c Release
+ - name: Test
+ run: dotnet test NeriPlayer.Windows.sln -c Release --no-build
+ - name: Publish
+ run: |
+ dotnet publish src/NeriPlayer.App -c Release -r win-x64 --self-contained true -o publish
+ Copy-Item "$env:LOCALAPPDATA\vlc\libvlc.dll" publish\
+ Copy-Item "$env:LOCALAPPDATA\vlc\libvlccore.dll" publish\
+ Copy-Item "$env:LOCALAPPDATA\vlc\plugins" publish\plugins -Recurse
+ - uses: actions/upload-artifact@v4
+ with:
+ name: NeriPlayer-win-x64
+ path: publish
+```
+### 15.4 版本管理
+
+```powershell
+# 版本号:主.次.修订(Git tag 驱动)
+$ver = (git describe --tags --abbrev=0).TrimStart('v')
+# 写入 Directory.Build.props:$ver
+# 变更日志:docs/changelog.md
+```
+
+**✅ 验收**
+- [ ] `publish\NeriPlayer.App.exe` 在干净 Windows 10/11 可直接运行
+- [ ] CI 流水线测试通过并产出便携版 + 日志
+- [ ] Tag `v1.0.0` 发布后自动产出 release artifact
+
+---
+
+## 总里程碑对照(Process.md 第 16 章)
+
+| 里程碑 | 时间 | 对应章节 | 标志 |
+|--------|------|----------|------|
+| **M0 骨架** | 第 1-2 周 | 一、二、三 | `dotnet build` + 测试全绿 |
+| **M1 可播放** | 第 3-4 周 | 四、五 | 本地文件可播放 + 状态机 |
+| **M2 多源在线** | 第 5-6 周 | 六、七 | 三平台搜索/播放/歌词 |
+| **M3 数据闭环** | 第 7-8 周 | 八、九 | 下载 + 同步闭环 |
+| **M4 桌面体验** | 第 9-11 周 | 十、十一、十二 | 完整 UI + 系统集成 |
+| **M5 发布** | 第 12 周 | 十三、十四、十五 | 崩溃防护 + 测试 + 打包 |
+
+## 风险提示(Process.md 第 18 章 摘要)
+
+| 风险 | 缓解 |
+|------|------|
+| 网易云/B站接口变更 | 加密/签名参数集中在 `NeteaseCrypto`/`WbiSignature`,改动面小 |
+| YouTube PoToken 失效 | 多级回退链(NewPipe 兜底)+ 安全模式禁用自研解析 |
+| VLC 音效能力受限 | 关键音效走 NAudio WASAPI 独占引擎(ISampleProvider 管道) |
+| 3 平台 API 解析工作量大 | 先做搜索+URL+歌词三个最小闭环,其余 P1 |
+
+*start.md · 详细实现过程 · 基于 Analysis.md(24 章)+ Process.md(19 章)编制*
From 07eed629f9cdd7a00f20d8f63515b40e1f936489 Mon Sep 17 00:00:00 2001
From: ALIve114514awa <300349280+ALIve114514awa@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:30:51 +0800
Subject: [PATCH 03/28] docs(dotnet-windows): add README for the .NET
implementation
---
dotnet-windows/README.md | 50 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
create mode 100644 dotnet-windows/README.md
diff --git a/dotnet-windows/README.md b/dotnet-windows/README.md
new file mode 100644
index 0000000..d20cc39
--- /dev/null
+++ b/dotnet-windows/README.md
@@ -0,0 +1,50 @@
+# NeriPlayer Windows (.NET)
+
+> 将 NeriPlayer 移植到 Windows 桌面端的 **.NET 8 + Avalonia UI** 实现方案。
+
+## 项目定位
+
+这是 NeriPlayer (Android) 的 Windows 桌面端 **.NET 技术栈**实现,与官方
+[NeriPlayer-Desktop](https://github.com/cwuom/NeriPlayer-Desktop)
+(Tauri 2 + Rust + Vue 3)为平行独立的两套方案。
+
+- 本仓库是个人开发主仓库(`origin`),同步展示于独立远端
+- 同时以 `dotnet-windows/` 子目录的形式提交到上游 PR,作为参考实现对比
+- 详细方案见 `Process.md`(移植方案 19 章)、`start.md`(逐步骤执行)、
+ `Analysis.md`(上游源码分析 24 章)
+
+## 技术栈
+
+| 组件 | 选型 |
+|------|------|
+| 运行时 | .NET 8 LTS |
+| UI | Avalonia UI 11.x |
+| 播放引擎 | LibVLCSharp 8.x(VLC 3.0.x) |
+| 数据库 | EF Core 8 + SQLite |
+| 音效 | NAudio (WASAPI) / Biquad 滤波器 |
+| 系统集成 | SMTC / 托盘 / Toast |
+
+## 项目结构
+
+```
+src/
+├── NeriPlayer.App/ 主应用入口(Avalonia Desktop)
+├── NeriPlayer.Core/ 核心业务层(播放/歌词/下载/策略)
+├── NeriPlayer.Data/ 数据层(EF Core / 同步)
+├── NeriPlayer.UI/ UI 层(Avalonia 视图)
+└── NeriPlayer.Background/ 后台服务(SMTC / 托盘)
+tests/ 单元测试(xunit)
+```
+
+## 构建与运行
+
+```powershell
+dotnet build NeriPlayer.Windows.sln
+dotnet test tests/NeriPlayer.Core.Tests
+dotnet run --project src/NeriPlayer.App
+```
+
+## 协作流程
+
+每个章节完成后,按 `start.md` 第 0 章「协作与提交流程」执行:
+本地提交 → 推送独立远端 → 同步到 fork `dotnet-windows/` → 更新上游 PR。
From c39ced866c8d25e54de220cf2a65f8bc142dccd4 Mon Sep 17 00:00:00 2001
From: ALIve114514awa <300349280+ALIve114514awa@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:52:59 +0800
Subject: [PATCH 04/28] fix(dotnet-windows): collision-safe StableKey fallback
+ review fixes
Address CodeRabbit review #20 findings in the .NET reference:
- StableKey falls back to collision-safe id-key when source metadata
is incomplete (local no path / ytm extract fail / bilibili no ids)
- MainWindow: NeriPlayer branding instead of Avalonia template text
- README: LibVLCSharp 3.8.0 (pinned version) instead of 8.x
---
dotnet-windows/README.md | 2 +-
.../src/NeriPlayer.App/MainWindow.axaml | 7 ++-
.../Player/Model/SongIdentity.cs | 17 ++++--
.../SongIdentityTests.cs | 52 +++++++++++++++++++
4 files changed, 72 insertions(+), 6 deletions(-)
diff --git a/dotnet-windows/README.md b/dotnet-windows/README.md
index d20cc39..11bfa65 100644
--- a/dotnet-windows/README.md
+++ b/dotnet-windows/README.md
@@ -19,7 +19,7 @@
|------|------|
| 运行时 | .NET 8 LTS |
| UI | Avalonia UI 11.x |
-| 播放引擎 | LibVLCSharp 8.x(VLC 3.0.x) |
+| 播放引擎 | LibVLCSharp 3.8.0(VLC 3.0.x) |
| 数据库 | EF Core 8 + SQLite |
| 音效 | NAudio (WASAPI) / Biquad 滤波器 |
| 系统集成 | SMTC / 托盘 / Toast |
diff --git a/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml
index 1110530..ae90d20 100644
--- a/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml
+++ b/dotnet-windows/src/NeriPlayer.App/MainWindow.axaml
@@ -4,6 +4,9 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="NeriPlayer.App.MainWindow"
- Title="NeriPlayer.App">
- Welcome to Avalonia!
+ Title="NeriPlayer">
+
+
+
+
diff --git a/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs
index 5bcc924..15b106d 100644
--- a/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs
+++ b/dotnet-windows/src/NeriPlayer.Core/Player/Model/SongIdentity.cs
@@ -8,13 +8,24 @@ public static partial class SongIdentity
public static string StableKey(this SongItem song)
{
if (song.IsLocalSong())
- return $"local|{NormalizePath(song.LocalFilePath ?? song.MediaUri ?? "")}";
+ {
+ var path = song.LocalFilePath ?? song.MediaUri;
+ if (!string.IsNullOrEmpty(path))
+ return $"local|{NormalizePath(path)}";
+ // 本地歌曲无路径:回退到包含 Id 的碰撞安全键,避免所有无路径歌曲生成相同的 "local|"
+ return $"id|{song.Id}|local|{song.Name}|{song.Artist}";
+ }
return song.ChannelId switch
{
"netease" => $"netease|{song.AudioId ?? song.Id.ToString()}",
- "bilibili" => $"bilibili|{song.AudioId}|{song.SubAudioId}",
- "youtube_music" => $"ytm|{ExtractYouTubeVideoId(song.MediaUri)}",
+ "bilibili" => !string.IsNullOrEmpty(song.AudioId) || !string.IsNullOrEmpty(song.SubAudioId)
+ ? $"bilibili|{song.AudioId}|{song.SubAudioId}"
+ : $"id|{song.Id}|bilibili|{song.Name}|{song.Artist}", // 双 ID 均缺失 → 碰撞安全回退
+ "youtube_music" when !string.IsNullOrEmpty(ExtractYouTubeVideoId(song.MediaUri)) =>
+ $"ytm|{ExtractYouTubeVideoId(song.MediaUri)}",
+ "youtube_music" =>
+ $"id|{song.Id}|ytm|{song.Name}|{song.Artist}", // 视频 ID 提取失败 → 碰撞安全回退
_ => $"id|{song.Id}|{song.Album}|{song.MediaUri}"
};
}
diff --git a/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs b/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs
index 3cec55f..2b3b6e3 100644
--- a/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs
+++ b/dotnet-windows/tests/NeriPlayer.Core.Tests/SongIdentityTests.cs
@@ -40,4 +40,56 @@ public void Netease_StableKey_UsesAudioId()
};
Assert.Equal("netease|3456789", song.StableKey());
}
+
+ // ===== 碰撞安全回退测试(CodeRabbit review #20) =====
+
+ [Fact]
+ public void LocalSong_WithoutPath_FallsBackToCollisionSafeKey()
+ {
+ var song = new SongItem
+ {
+ Id = 10, Name = "歌", Artist = "艺人", Album = "专辑",
+ ChannelId = "local", LocalFilePath = null, MediaUri = null
+ };
+ // 不能返回 "local|",必须包含 Id 避免与其它无路径歌曲碰撞
+ Assert.NotEqual("local|", song.StableKey());
+ Assert.StartsWith("id|10|local|", song.StableKey());
+ }
+
+ [Fact]
+ public void YouTube_ExtractFailure_FallsBackToCollisionSafeKey()
+ {
+ var song = new SongItem
+ {
+ Id = 11, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "youtube_music", MediaUri = "not-a-youtube-url"
+ };
+ // 不能返回 "ytm|",必须回退到包含 Id 的键
+ Assert.NotEqual("ytm|", song.StableKey());
+ Assert.StartsWith("id|11|ytm|", song.StableKey());
+ }
+
+ [Fact]
+ public void Bilibili_WithoutAnyId_FallsBackToCollisionSafeKey()
+ {
+ var song = new SongItem
+ {
+ Id = 12, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "bilibili", AudioId = null, SubAudioId = null
+ };
+ // 不能返回 "bilibili||",必须回退到包含 Id 的键
+ Assert.NotEqual("bilibili||", song.StableKey());
+ Assert.StartsWith("id|12|bilibili|", song.StableKey());
+ }
+
+ [Fact]
+ public void Bilibili_WithAudioId_StillUsesPlatformKey()
+ {
+ var song = new SongItem
+ {
+ Id = 13, Name = "A", Artist = "B", Album = "C",
+ ChannelId = "bilibili", AudioId = "12345", SubAudioId = null
+ };
+ Assert.Equal("bilibili|12345|", song.StableKey());
+ }
}
From cb697004f7a271858a92c4b023dac5c95bf7ef3b Mon Sep 17 00:00:00 2001
From: ALIve114514awa <300349280+ALIve114514awa@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:55:34 +0800
Subject: [PATCH 05/28] docs(dotnet-windows): record review fixes in submission
log
---
dotnet-windows/start.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/dotnet-windows/start.md b/dotnet-windows/start.md
index e58acb8..562c9d7 100644
--- a/dotnet-windows/start.md
+++ b/dotnet-windows/start.md
@@ -86,6 +86,7 @@ git push origin feat/dotnet-windows-impl
| 日期 | 对应章节 | 本地提交 | 独立远端 | fork 分支 | 上游 PR |
|------|----------|----------|----------|-----------|---------|
| 2026-08-17 | 三、核心数据模型(初版) | `097e8b7` | master 已推送 | `feat/dotnet-windows-impl` @ `2e8d15e` | [PR #20](https://github.com/cwuom/NeriPlayer-Desktop/pull/20)(open) |
+| 2026-08-17 | 三、核心数据模型(CodeRabbit 审核修复) | `22dfae6` | master @ `22dfae6` | `feat/dotnet-windows-impl` @ `c39ced8` | [PR #20](https://github.com/cwuom/NeriPlayer-Desktop/pull/20)(open,已修复) |
---
From e4786d1178e5033b968cb84dd7944e076facb0dc Mon Sep 17 00:00:00 2001
From: ALIve114514awa <300349280+ALIve114514awa@users.noreply.github.com>
Date: Mon, 17 Aug 2026 22:17:27 +0800
Subject: [PATCH 06/28] feat(dotnet-windows): chapter 4 local database
EF Core entities (5 core tables), NeriDbContext with unique StableKey
index + composite PKs + cascade deletes, InitialCreate migration,
SongRepository with UpsertAsync, 4 integration tests (all passing)
---
dotnet-windows/src/NeriPlayer.Data/Class1.cs | 6 -
.../NeriPlayer.Data/Database/NeriDbContext.cs | 62 +++++
.../Database/NeriDbContextFactory.cs | 24 ++
.../Entities/PlaybackStatsEntity.cs | 38 +++
.../Entities/PlaylistEntity.cs | 30 +++
.../Entities/PlaylistMemberEntity.cs | 18 ++
.../NeriPlayer.Data/Entities/SongEntity.cs | 49 ++++
.../20260817141212_InitialCreate.Designer.cs | 226 ++++++++++++++++++
.../20260817141212_InitialCreate.cs | 152 ++++++++++++
.../Migrations/NeriDbContextModelSnapshot.cs | 223 +++++++++++++++++
.../Repositories/RepositoryBase.cs | 21 ++
.../Repositories/SongRepository.cs | 31 +++
dotnet-windows/start.md | 1 +
.../SongRepositoryTests.cs | 122 ++++++++++
.../tests/NeriPlayer.Data.Tests/UnitTest1.cs | 10 -
15 files changed, 997 insertions(+), 16 deletions(-)
delete mode 100644 dotnet-windows/src/NeriPlayer.Data/Class1.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Database/NeriDbContext.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Database/NeriDbContextFactory.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Entities/PlaybackStatsEntity.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Entities/PlaylistEntity.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Entities/PlaylistMemberEntity.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Entities/SongEntity.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Migrations/20260817141212_InitialCreate.Designer.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Migrations/20260817141212_InitialCreate.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Migrations/NeriDbContextModelSnapshot.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Repositories/RepositoryBase.cs
create mode 100644 dotnet-windows/src/NeriPlayer.Data/Repositories/SongRepository.cs
create mode 100644 dotnet-windows/tests/NeriPlayer.Data.Tests/SongRepositoryTests.cs
delete mode 100644 dotnet-windows/tests/NeriPlayer.Data.Tests/UnitTest1.cs
diff --git a/dotnet-windows/src/NeriPlayer.Data/Class1.cs b/dotnet-windows/src/NeriPlayer.Data/Class1.cs
deleted file mode 100644
index c579020..0000000
--- a/dotnet-windows/src/NeriPlayer.Data/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace NeriPlayer.Data;
-
-public class Class1
-{
-
-}
diff --git a/dotnet-windows/src/NeriPlayer.Data/Database/NeriDbContext.cs b/dotnet-windows/src/NeriPlayer.Data/Database/NeriDbContext.cs
new file mode 100644
index 0000000..4263de9
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Database/NeriDbContext.cs
@@ -0,0 +1,62 @@
+namespace NeriPlayer.Data.Database;
+
+using Microsoft.EntityFrameworkCore;
+using NeriPlayer.Data.Entities;
+
+///
+/// 应用数据库上下文(对标 Room NeriUserDataDatabase / Analysis.md 21.2)。
+/// 核心 5 表:songs / playlists / playlist_members / playback_stats / stat_buckets。
+/// 后续章节补充:PlayHistory / PlaybackQueue / QueueState / Downloads /
+/// DownloadSnapshots / SyncMetadata / SyncOutbox / SyncCheckpoints /
+/// TrafficStats / CoverUrlMapping / Settings / CookieCredentials。
+///
+public sealed class NeriDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Songs => Set();
+ public DbSet Playlists => Set();
+ public DbSet PlaylistMembers => Set();
+ public DbSet PlaybackStats => Set();
+ public DbSet StatBuckets => Set();
+
+ protected override void OnModelCreating(ModelBuilder b)
+ {
+ b.Entity(e =>
+ {
+ e.ToTable("songs");
+ e.HasKey(x => x.Id);
+ e.HasIndex(x => x.StableKey).IsUnique();
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("playlists");
+ e.HasKey(x => x.Id);
+ e.HasMany(p => p.Members)
+ .WithOne(m => m.Playlist!)
+ .HasForeignKey(m => m.PlaylistId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("playlist_members");
+ e.HasKey(x => new { x.PlaylistId, x.Position });
+ e.HasOne(m => m.Song)
+ .WithMany()
+ .HasForeignKey(m => m.SongId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("playback_stats");
+ e.HasKey(x => x.SongId);
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("stat_buckets");
+ e.HasKey(x => new { x.SongId, x.DayKey });
+ });
+ }
+}
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.Data/Database/NeriDbContextFactory.cs b/dotnet-windows/src/NeriPlayer.Data/Database/NeriDbContextFactory.cs
new file mode 100644
index 0000000..01bc08a
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Database/NeriDbContextFactory.cs
@@ -0,0 +1,24 @@
+namespace NeriPlayer.Data.Database;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+///
+/// 设计时工厂:供 dotnet ef migrations 使用(对标 start.md 4.2)。
+/// 数据库路径:%APPDATA%\NeriPlayer\neriplayer.db。
+///
+public sealed class NeriDbContextFactory : IDesignTimeDbContextFactory
+{
+ public NeriDbContext CreateDbContext(string[] args)
+ {
+ var dbPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "NeriPlayer", "neriplayer.db");
+ Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite($"Data Source={dbPath}")
+ .Options;
+ return new NeriDbContext(options);
+ }
+}
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.Data/Entities/PlaybackStatsEntity.cs b/dotnet-windows/src/NeriPlayer.Data/Entities/PlaybackStatsEntity.cs
new file mode 100644
index 0000000..85f2b8d
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Entities/PlaybackStatsEntity.cs
@@ -0,0 +1,38 @@
+namespace NeriPlayer.Data.Entities;
+
+///
+/// 播放统计主记录(PK song_id)。
+/// 对标 Room PlaybackStatsEntity / Analysis.md 13.3 / Process.md 5.3。
+///
+public sealed class PlaybackStatsEntity
+{
+ /// 歌曲 ID(主键)
+ public long SongId { get; set; }
+
+ /// 累计播放次数
+ public long PlayCount { get; set; }
+
+ /// 累计播放时长(毫秒)
+ public long TotalPlayMs { get; set; }
+
+ /// 最后一次播放时间(Unix ms)
+ public long LastPlayedAt { get; set; }
+}
+
+///
+/// 每日播放统计分片桶(PK(SongId, DayKey))。
+/// 对标 Room PlaybackStatDailyCounterShardEntity,缓解高频写放大(Analysis.md 21.2)。
+///
+public sealed class StatBucketEntity
+{
+ public long SongId { get; set; }
+
+ /// 日期键,格式 yyyyMMdd
+ public long DayKey { get; set; }
+
+ /// 当日播放次数
+ public long PlayCount { get; set; }
+
+ /// 当日播放时长(毫秒)
+ public long ListenMs { get; set; }
+}
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.Data/Entities/PlaylistEntity.cs b/dotnet-windows/src/NeriPlayer.Data/Entities/PlaylistEntity.cs
new file mode 100644
index 0000000..5be03e6
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Entities/PlaylistEntity.cs
@@ -0,0 +1,30 @@
+namespace NeriPlayer.Data.Entities;
+
+///
+/// 歌单实体(对标 Room LocalPlaylist / Analysis.md 21.2)。
+/// kind:local(用户创建)| favorite(我喜欢)| system(系统歌单)
+///
+public sealed class PlaylistEntity
+{
+ public long Id { get; set; }
+
+ public required string Name { get; set; }
+
+ /// 歌单类型:local / favorite / system
+ public string Kind { get; set; } = "local";
+
+ /// 远端平台标识(同步用)
+ public string? RemotePlatform { get; set; }
+
+ /// 远端歌单 ID(同步用)
+ public string? RemoteId { get; set; }
+
+ /// 创建时间(Unix ms)
+ public long CreatedAt { get; set; }
+
+ /// 最后更新时间(Unix ms)
+ public long UpdatedAt { get; set; }
+
+ /// 歌单成员(一对多,级联删除)
+ public List Members { get; set; } = [];
+}
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.Data/Entities/PlaylistMemberEntity.cs b/dotnet-windows/src/NeriPlayer.Data/Entities/PlaylistMemberEntity.cs
new file mode 100644
index 0000000..df17284
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Entities/PlaylistMemberEntity.cs
@@ -0,0 +1,18 @@
+namespace NeriPlayer.Data.Entities;
+
+///
+/// 歌单成员关联表(一对多,PK(PlaylistId, Position))。
+/// 对标 Room playlist_members / Analysis.md 21.2。
+///
+public sealed class PlaylistMemberEntity
+{
+ public long PlaylistId { get; set; }
+ public long SongId { get; set; }
+
+ /// 歌单内排序位置(0-based)
+ public int Position { get; set; }
+
+ // 导航属性(EF Core fluent API 关联)
+ public PlaylistEntity? Playlist { get; set; }
+ public SongEntity? Song { get; set; }
+}
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.Data/Entities/SongEntity.cs b/dotnet-windows/src/NeriPlayer.Data/Entities/SongEntity.cs
new file mode 100644
index 0000000..702382c
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Entities/SongEntity.cs
@@ -0,0 +1,49 @@
+namespace NeriPlayer.Data.Entities;
+
+///
+/// 核心歌曲实体(对标 Room SongItem / Analysis.md 21.1)。
+/// 索引:stable_key 唯一,支持跨版本去重与同步主键。
+///
+public sealed class SongEntity
+{
+ /// 数据库自增主键
+ public long Id { get; set; }
+
+ /// 跨版本稳定键:local|{path} / netease|{audioId} / bilibili|{audioId}|{subId} / ytm|{videoId}
+ public required string StableKey { get; set; }
+
+ public required string Name { get; set; }
+ public required string Artist { get; set; }
+ public required string Album { get; set; }
+ public long AlbumId { get; set; }
+ public long DurationMs { get; set; }
+
+ public string? CoverUrl { get; set; }
+ public string? MediaUri { get; set; }
+ public string? StreamUrl { get; set; }
+
+ // 平台标识(channelId / audioId / subAudioId)
+ public string? ChannelId { get; set; }
+ public string? AudioId { get; set; }
+ public string? SubAudioId { get; set; }
+
+ // 歌词
+ public string? MatchedLyric { get; set; }
+ public string? MatchedTranslatedLyric { get; set; }
+ public string? MatchedLyricSource { get; set; }
+ public long UserLyricOffsetMs { get; set; }
+
+ // 自定义元数据(用户覆盖值,与 Original* 分离)
+ public string? CustomName { get; set; }
+ public string? CustomArtist { get; set; }
+ public string? CustomCoverUrl { get; set; }
+ public string? OriginalName { get; set; }
+ public string? OriginalArtist { get; set; }
+
+ // 本地文件
+ public string? LocalFileName { get; set; }
+ public string? LocalFilePath { get; set; }
+
+ /// 添加到库的时间(Unix ms)
+ public long AddedAt { get; set; }
+}
\ No newline at end of file
diff --git a/dotnet-windows/src/NeriPlayer.Data/Migrations/20260817141212_InitialCreate.Designer.cs b/dotnet-windows/src/NeriPlayer.Data/Migrations/20260817141212_InitialCreate.Designer.cs
new file mode 100644
index 0000000..dd2f4df
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Migrations/20260817141212_InitialCreate.Designer.cs
@@ -0,0 +1,226 @@
+//
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NeriPlayer.Data.Database;
+
+#nullable disable
+
+namespace NeriPlayer.Data.Migrations
+{
+ [DbContext(typeof(NeriDbContext))]
+ [Migration("20260817141212_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
+
+ modelBuilder.Entity("NeriPlayer.Data.Entities.PlaybackStatsEntity", b =>
+ {
+ b.Property("SongId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("LastPlayedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("TotalPlayMs")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("SongId");
+
+ b.ToTable("playback_stats", (string)null);
+ });
+
+ modelBuilder.Entity("NeriPlayer.Data.Entities.PlaylistEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemoteId")
+ .HasColumnType("TEXT");
+
+ b.Property("RemotePlatform")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("playlists", (string)null);
+ });
+
+ modelBuilder.Entity("NeriPlayer.Data.Entities.PlaylistMemberEntity", b =>
+ {
+ b.Property("PlaylistId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Position")
+ .HasColumnType("INTEGER");
+
+ b.Property("SongId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("PlaylistId", "Position");
+
+ b.HasIndex("SongId");
+
+ b.ToTable("playlist_members", (string)null);
+ });
+
+ modelBuilder.Entity("NeriPlayer.Data.Entities.SongEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Album")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("AlbumId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Artist")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("AudioId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverUrl")
+ .HasColumnType("TEXT");
+
+ b.Property("CustomArtist")
+ .HasColumnType("TEXT");
+
+ b.Property("CustomCoverUrl")
+ .HasColumnType("TEXT");
+
+ b.Property("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property("DurationMs")
+ .HasColumnType("INTEGER");
+
+ b.Property("LocalFileName")
+ .HasColumnType("TEXT");
+
+ b.Property("LocalFilePath")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchedLyric")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchedLyricSource")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchedTranslatedLyric")
+ .HasColumnType("TEXT");
+
+ b.Property("MediaUri")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalArtist")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalName")
+ .HasColumnType("TEXT");
+
+ b.Property("StableKey")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("StreamUrl")
+ .HasColumnType("TEXT");
+
+ b.Property("SubAudioId")
+ .HasColumnType("TEXT");
+
+ b.Property("UserLyricOffsetMs")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StableKey")
+ .IsUnique();
+
+ b.ToTable("songs", (string)null);
+ });
+
+ modelBuilder.Entity("NeriPlayer.Data.Entities.StatBucketEntity", b =>
+ {
+ b.Property("SongId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DayKey")
+ .HasColumnType("INTEGER");
+
+ b.Property("ListenMs")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("SongId", "DayKey");
+
+ b.ToTable("stat_buckets", (string)null);
+ });
+
+ modelBuilder.Entity("NeriPlayer.Data.Entities.PlaylistMemberEntity", b =>
+ {
+ b.HasOne("NeriPlayer.Data.Entities.PlaylistEntity", "Playlist")
+ .WithMany("Members")
+ .HasForeignKey("PlaylistId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("NeriPlayer.Data.Entities.SongEntity", "Song")
+ .WithMany()
+ .HasForeignKey("SongId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Playlist");
+
+ b.Navigation("Song");
+ });
+
+ modelBuilder.Entity("NeriPlayer.Data.Entities.PlaylistEntity", b =>
+ {
+ b.Navigation("Members");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/dotnet-windows/src/NeriPlayer.Data/Migrations/20260817141212_InitialCreate.cs b/dotnet-windows/src/NeriPlayer.Data/Migrations/20260817141212_InitialCreate.cs
new file mode 100644
index 0000000..116be5c
--- /dev/null
+++ b/dotnet-windows/src/NeriPlayer.Data/Migrations/20260817141212_InitialCreate.cs
@@ -0,0 +1,152 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace NeriPlayer.Data.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "playback_stats",
+ columns: table => new
+ {
+ SongId = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ PlayCount = table.Column(type: "INTEGER", nullable: false),
+ TotalPlayMs = table.Column(type: "INTEGER", nullable: false),
+ LastPlayedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_playback_stats", x => x.SongId);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "playlists",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Name = table.Column(type: "TEXT", nullable: false),
+ Kind = table.Column(type: "TEXT", nullable: false),
+ RemotePlatform = table.Column(type: "TEXT", nullable: true),
+ RemoteId = table.Column(type: "TEXT", nullable: true),
+ CreatedAt = table.Column(type: "INTEGER", nullable: false),
+ UpdatedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_playlists", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "songs",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ StableKey = table.Column(type: "TEXT", nullable: false),
+ Name = table.Column(type: "TEXT", nullable: false),
+ Artist = table.Column