[
  {
    "path": ".dockerignore",
    "content": "## Ignore Visual Studio temporary files, build results, and\n## files generated by popular Visual Studio add-ons.\n##\n## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore\n\n# User-specific files\n*.rsuser\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n\n# User-specific files (MonoDevelop/Xamarin Studio)\n*.userprefs\n\n# Mono auto generated files\nmono_crash.*\n\n# Rider\n.idea\n\n# macOS shit\n.DS_Store\n\n# Build results\n[Dd]ebug/\n[Dd]ebugPublic/\n[Rr]elease/\n[Rr]eleases/\nx64/\nx86/\n[Ww][Ii][Nn]32/\n[Aa][Rr][Mm]/\n[Aa][Rr][Mm]64/\nbld/\n[Bb]in/\n[Oo]bj/\n[Ll]og/\n[Ll]ogs/\n\n# Visual Studio 2015/2017 cache/options directory\n.vs/\n# Uncomment if you have tasks that create the project's static files in wwwroot\n#wwwroot/\n\n# Visual Studio 2017 auto generated files\nGenerated\\ Files/\n\n# MSTest test Results\n[Tt]est[Rr]esult*/\n[Bb]uild[Ll]og.*\n\n# NUnit\n*.VisualState.xml\nTestResult.xml\nnunit-*.xml\n\n# Build Results of an ATL Project\n[Dd]ebugPS/\n[Rr]eleasePS/\ndlldata.c\n\n# Benchmark Results\nBenchmarkDotNet.Artifacts/\n\n# .NET Core\nproject.lock.json\nproject.fragment.lock.json\nartifacts/\n\n# ASP.NET Scaffolding\nScaffoldingReadMe.txt\n\n# StyleCop\nStyleCopReport.xml\n\n# Files built by Visual Studio\n*_i.c\n*_p.c\n*_h.h\n*.ilk\n*.meta\n*.obj\n*.iobj\n*.pch\n*.pdb\n*.ipdb\n*.pgc\n*.pgd\n*.rsp\n*.sbr\n*.tlb\n*.tli\n*.tlh\n*.tmp\n*.tmp_proj\n*_wpftmp.csproj\n*.log\n*.vspscc\n*.vssscc\n.builds\n*.pidb\n*.svclog\n*.scc\n\n# Chutzpah Test files\n_Chutzpah*\n\n# Visual C++ cache files\nipch/\n*.aps\n*.ncb\n*.opendb\n*.opensdf\n*.sdf\n*.cachefile\n*.VC.db\n*.VC.VC.opendb\n\n# Visual Studio profiler\n*.psess\n*.vsp\n*.vspx\n*.sap\n\n# Visual Studio Trace Files\n*.e2e\n\n# TFS 2012 Local Workspace\n$tf/\n\n# Guidance Automation Toolkit\n*.gpState\n\n# ReSharper is a .NET coding add-in\n_ReSharper*/\n*.[Rr]e[Ss]harper\n*.DotSettings.user\n\n# TeamCity is a build add-in\n_TeamCity*\n\n# DotCover is a Code Coverage Tool\n*.dotCover\n\n# AxoCover is a Code Coverage Tool\n.axoCover/*\n!.axoCover/settings.json\n\n# Coverlet is a free, cross platform Code Coverage Tool\ncoverage*.json\ncoverage*.xml\ncoverage*.info\n\n# Visual Studio code coverage results\n*.coverage\n*.coveragexml\n\n# NCrunch\n_NCrunch_*\n.*crunch*.local.xml\nnCrunchTemp_*\n\n# MightyMoose\n*.mm.*\nAutoTest.Net/\n\n# Web workbench (sass)\n.sass-cache/\n\n# Installshield output folder\n[Ee]xpress/\n\n# DocProject is a documentation generator add-in\nDocProject/buildhelp/\nDocProject/Help/*.HxT\nDocProject/Help/*.HxC\nDocProject/Help/*.hhc\nDocProject/Help/*.hhk\nDocProject/Help/*.hhp\nDocProject/Help/Html2\nDocProject/Help/html\n\n# Click-Once directory\npublish/\n\n# Publish Web Output\n*.[Pp]ublish.xml\n*.azurePubxml\n# Note: Comment the next line if you want to checkin your web deploy settings,\n# but database connection strings (with potential passwords) will be unencrypted\n*.pubxml\n*.publishproj\n\n# Microsoft Azure Web App publish settings. Comment the next line if you want to\n# checkin your Azure Web App publish settings, but sensitive information contained\n# in these scripts will be unencrypted\nPublishScripts/\n\n# NuGet Packages\n*.nupkg\n# NuGet Symbol Packages\n*.snupkg\n# The packages folder can be ignored because of Package Restore\n**/[Pp]ackages/*\n# except build/, which is used as an MSBuild target.\n!**/[Pp]ackages/build/\n# Uncomment if necessary however generally it will be regenerated when needed\n#!**/[Pp]ackages/repositories.config\n# NuGet v3's project.json files produces more ignorable files\n*.nuget.props\n*.nuget.targets\n\n# Microsoft Azure Build Output\ncsx/\n*.build.csdef\n\n# Microsoft Azure Emulator\necf/\nrcf/\n\n# Windows Store app package directories and files\nAppPackages/\nBundleArtifacts/\nPackage.StoreAssociation.xml\n_pkginfo.txt\n*.appx\n*.appxbundle\n*.appxupload\n\n# Visual Studio cache files\n# files ending in .cache can be ignored\n*.[Cc]ache\n# but keep track of directories ending in .cache\n!?*.[Cc]ache/\n\n# Others\nClientBin/\n~$*\n*~\n*.dbmdl\n*.dbproj.schemaview\n*.jfm\n*.pfx\n*.publishsettings\norleans.codegen.cs\n\n# Including strong name files can present a security risk\n# (https://github.com/github/gitignore/pull/2483#issue-259490424)\n#*.snk\n\n# Since there are multiple workflows, uncomment next line to ignore bower_components\n# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)\n#bower_components/\n\n# RIA/Silverlight projects\nGenerated_Code/\n\n# Backup & report files from converting an old project file\n# to a newer Visual Studio version. Backup files are not needed,\n# because we have git ;-)\n_UpgradeReport_Files/\nBackup*/\nUpgradeLog*.XML\nUpgradeLog*.htm\nServiceFabricBackup/\n*.rptproj.bak\n\n# SQL Server files\n*.mdf\n*.ldf\n*.ndf\n\n# Business Intelligence projects\n*.rdl.data\n*.bim.layout\n*.bim_*.settings\n*.rptproj.rsuser\n*- [Bb]ackup.rdl\n*- [Bb]ackup ([0-9]).rdl\n*- [Bb]ackup ([0-9][0-9]).rdl\n\n# Microsoft Fakes\nFakesAssemblies/\n\n# GhostDoc plugin setting file\n*.GhostDoc.xml\n\n# Node.js Tools for Visual Studio\n.ntvs_analysis.dat\nnode_modules/\n\n# Visual Studio 6 build log\n*.plg\n\n# Visual Studio 6 workspace options file\n*.opt\n\n# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)\n*.vbw\n\n# Visual Studio LightSwitch build output\n**/*.HTMLClient/GeneratedArtifacts\n**/*.DesktopClient/GeneratedArtifacts\n**/*.DesktopClient/ModelManifest.xml\n**/*.Server/GeneratedArtifacts\n**/*.Server/ModelManifest.xml\n_Pvt_Extensions\n\n# Paket dependency manager\n.paket/paket.exe\npaket-files/\n\n# FAKE - F# Make\n.fake/\n\n# CodeRush personal settings\n.cr/personal\n\n# Python Tools for Visual Studio (PTVS)\n__pycache__/\n*.pyc\n\n# Cake - Uncomment if you are using it\n# tools/**\n# !tools/packages.config\n\n# Tabs Studio\n*.tss\n\n# Telerik's JustMock configuration file\n*.jmconfig\n\n# BizTalk build output\n*.btp.cs\n*.btm.cs\n*.odx.cs\n*.xsd.cs\n\n# OpenCover UI analysis results\nOpenCover/\n\n# Azure Stream Analytics local run output\nASALocalRun/\n\n# MSBuild Binary and Structured Log\n*.binlog\n\n# NVidia Nsight GPU debugger configuration file\n*.nvuser\n\n# MFractors (Xamarin productivity tool) working folder\n.mfractor/\n\n# Local History for Visual Studio\n.localhistory/\n\n# BeatPulse healthcheck temp database\nhealthchecksdb\n\n# Backup folder for Package Reference Convert tool in Visual Studio 2017\nMigrationBackup/\n\n# Ionide (cross platform F# VS Code tools) working folder\n.ionide/\n\n# Fody - auto-generated XML schema\nFodyWeavers.xsd\n\n# debug log\ndebug_*.json\n\n# dotnet run in `BBDown/` sub folder\n/BBDown/*.mp4\n/BBDown/*.xml\n/BBDown/*.ass\n"
  },
  {
    "path": ".editorconfig",
    "content": "# EditorConfig is awesome: https://EditorConfig.org\r\n\r\n# top-most EditorConfig file\r\nroot = true\r\n\r\n[*]\r\nindent_style = space\r\nindent_size = 4\r\ncharset = utf-8\r\n# end_of_line = crlf\r\n# trim_trailing_whitespace = false\r\n# insert_final_newline = false"
  },
  {
    "path": ".github/issue_template.md",
    "content": "<!-- 提问前请确认你的版本是最新版 不要拿一个过时的版本来咨询为什么某某功能失效 -->\n#### 1. 你使用的BBDown版本是什么？（指明 Release / Actions / DotnetTool）\n。。。\n\n#### 2. 你在什么系统使用本软件？（Win/Linux/Mac）\n。。。\n\n#### 3. 你使用的完整命令是什么？\n```\nBBDown ...\n```\n#### 4. 遇到了什么问题？\nxxx\n\n#### 5. 运行截图（最好开启`--debug`；注意自行将Cookie/Token等敏感信息隐藏）\n。。。\n"
  },
  {
    "path": ".github/workflows/build_latest.yml",
    "content": "name: Build Latest\r\n\r\non: [push,workflow_dispatch]\r\n\r\nenv:\r\n  DOTNET_SDK_VERSION: \"9.0.306\"\r\n  ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true\r\n\r\njobs:\r\n  set-date:\r\n    runs-on: ubuntu-latest\r\n    outputs:\r\n      date: ${{ steps.get_date.outputs.date }}\r\n    steps:\r\n      - name: Get Date in UTC+8\r\n        id: get_date\r\n        run: echo \"date=$(date -u -d '8 hours' +'%Y%m%d')\" >> \"$GITHUB_OUTPUT\"\r\n\r\n  build-win-x64-arm64:\r\n    runs-on: windows-latest\r\n    needs: set-date\r\n\r\n    steps:\r\n      - uses: actions/checkout@v1\r\n\r\n      - name: Set up dotnet\r\n        uses: actions/setup-dotnet@v3\r\n        with:\r\n          dotnet-version: ${{ env.DOTNET_SDK_VERSION }}\r\n\r\n      - name: Install zip\r\n        run: choco install zip --no-progress --yes\r\n\r\n      - name: Publish [win]\r\n        run: |\r\n          dotnet publish BBDown -r win-x64 -c Release -o artifact\r\n          dotnet publish BBDown -r win-arm64 -c Release -o artifact-arm64\r\n\r\n      - name: Package [win]\r\n        run: |\r\n          cd artifact\r\n          zip ../BBDown_${{ needs.set-date.outputs.date }}_win-x64.zip BBDown.exe\r\n          cd ../artifact-arm64\r\n          zip ../BBDown_${{ needs.set-date.outputs.date }}_win-arm64.zip BBDown.exe\r\n\r\n      - name: Upload Artifact [win-x64]\r\n        uses: actions/upload-artifact@v4\r\n        with:\r\n          name: BBDown_win-x64\r\n          path: BBDown_${{ needs.set-date.outputs.date }}_win-x64.zip\r\n\r\n      - name: Upload Artifact [win-arm64]\r\n        uses: actions/upload-artifact@v4\r\n        with:\r\n          name: BBDown_win-arm64\r\n          path: BBDown_${{ needs.set-date.outputs.date }}_win-arm64.zip\r\n\r\n  build-linux-x64-arm64:\r\n    runs-on: ubuntu-latest\r\n    needs: set-date\r\n\r\n    steps:\r\n      - uses: actions/checkout@v1\r\n      \r\n      - name: Build x64 in Ubuntu 18.04 container (for glibc 2.27 compatibility)\r\n        run: |\r\n          # 在 Ubuntu 18.04 容器中执行完整构建流程\r\n          docker run --rm \\\r\n            -v \"$PWD:/workspace\" \\\r\n            -w /workspace \\\r\n            ubuntu:18.04 \\\r\n            bash -c \"\r\n              set -e\r\n  \r\n              # 安装编译和运行依赖\r\n              apt-get update\r\n              DEBIAN_FRONTEND=noninteractive apt-get install -y wget build-essential clang libicu-dev zlib1g libcurl4-openssl-dev libkrb5-dev\r\n  \r\n              # 下载并安装 .NET SDK\r\n              DOTNET_SDK_VERSION='${{ env.DOTNET_SDK_VERSION }}'\r\n              DOTNET_SDK_URL=\\\"https://builds.dotnet.microsoft.com/dotnet/Sdk/\\${DOTNET_SDK_VERSION}/dotnet-sdk-\\${DOTNET_SDK_VERSION}-linux-x64.tar.gz\\\"\r\n              wget -nv \\\"\\$DOTNET_SDK_URL\\\" -O dotnet-sdk.tar.gz\r\n              mkdir -p /opt/dotnet\r\n              tar -xzf dotnet-sdk.tar.gz -C /opt/dotnet\r\n              export PATH=\\\"/opt/dotnet:\\$PATH\\\"\r\n  \r\n              # 编译 Native AOT 输出到挂载的 artifact 目录\r\n              dotnet publish BBDown -r linux-x64 -c Release -o /workspace/artifact\r\n            \"\r\n      \r\n      - name: Build arm64 in Ubuntu 18.04 container (for glibc 2.27 compatibility)\r\n        run: |\r\n          # 在 Ubuntu 18.04 容器中执行完整构建流程\r\n          docker run --rm \\\r\n            -v \"$PWD:/workspace\" \\\r\n            -w /workspace \\\r\n            mcr.microsoft.com/dotnet-buildtools/prereqs:ubuntu-18.04-cross-arm64-20220312201346-b2c2436 \\\r\n            bash -c \"\r\n              set -e\r\n  \r\n              # 下载并安装 .NET SDK\r\n              DOTNET_SDK_VERSION='${{ env.DOTNET_SDK_VERSION }}'\r\n              DOTNET_SDK_URL=\\\"https://builds.dotnet.microsoft.com/dotnet/Sdk/\\${DOTNET_SDK_VERSION}/dotnet-sdk-\\${DOTNET_SDK_VERSION}-linux-x64.tar.gz\\\"\r\n              wget -nv \\\"\\$DOTNET_SDK_URL\\\" -O dotnet-sdk.tar.gz\r\n              mkdir -p /opt/dotnet\r\n              tar -xzf dotnet-sdk.tar.gz -C /opt/dotnet\r\n              export PATH=\\\"/opt/dotnet:\\$PATH\\\"\r\n  \r\n              # 编译 Native AOT 输出到挂载的 artifact 目录\r\n              dotnet publish BBDown -r linux-arm64 -c Release -p:StripSymbols=true -p:CppCompilerAndLinker=clang-9 -p:SysRoot=/crossrootfs/arm64 -o /workspace/artifact-arm64\r\n            \"\r\n\r\n      - name: Package [linux]\r\n        run: |\r\n          cd artifact\r\n          zip ../BBDown_${{ needs.set-date.outputs.date }}_linux-x64.zip BBDown\r\n          cd ../artifact-arm64\r\n          zip ../BBDown_${{ needs.set-date.outputs.date }}_linux-arm64.zip BBDown\r\n\r\n      - name: Upload Artifact [linux-x64]\r\n        uses: actions/upload-artifact@v4\r\n        with:\r\n          name: BBDown_linux-x64\r\n          path: BBDown_${{ needs.set-date.outputs.date }}_linux-x64.zip\r\n\r\n      - name: Upload Artifact[linux-arm64]\r\n        uses: actions/upload-artifact@v4\r\n        with:\r\n          name: BBDown_linux-arm64\r\n          path: BBDown_${{ needs.set-date.outputs.date }}_linux-arm64.zip\r\n\r\n  build-mac-x64-arm64:\r\n    runs-on: macos-latest\r\n    needs: set-date\r\n\r\n    steps:\r\n      - uses: actions/checkout@v1\r\n\r\n      - name: Set up dotnet\r\n        uses: actions/setup-dotnet@v3\r\n        with:\r\n          dotnet-version: ${{ env.DOTNET_SDK_VERSION }}\r\n\r\n      - name: Publish [osx]\r\n        run: |\r\n          dotnet publish BBDown -r osx-x64 -c Release -o artifact\r\n          dotnet publish BBDown -r osx-arm64 -c Release -o artifact-arm64\r\n\r\n      - name: Package [osx]\r\n        run: |\r\n          cd artifact\r\n          zip ../BBDown_${{ needs.set-date.outputs.date }}_osx-x64.zip BBDown\r\n          cd ../artifact-arm64\r\n          zip ../BBDown_${{ needs.set-date.outputs.date }}_osx-arm64.zip BBDown\r\n\r\n      - name: Upload Artifact [osx-x64]\r\n        uses: actions/upload-artifact@v4\r\n        with:\r\n          name: BBDown_osx-x64\r\n          path: BBDown_${{ needs.set-date.outputs.date }}_osx-x64.zip\r\n\r\n      - name: Upload Artifact [osx-arm64]\r\n        uses: actions/upload-artifact@v4\r\n        with:\r\n          name: BBDown_osx-arm64\r\n          path: BBDown_${{ needs.set-date.outputs.date }}_osx-arm64.zip"
  },
  {
    "path": ".gitignore",
    "content": "## Ignore Visual Studio temporary files, build results, and\n## files generated by popular Visual Studio add-ons.\n##\n## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore\n\n# User-specific files\n*.rsuser\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n\n# User-specific files (MonoDevelop/Xamarin Studio)\n*.userprefs\n\n# Mono auto generated files\nmono_crash.*\n\n# Rider\n.idea\n\n# macOS shit\n.DS_Store\n\n# Build results\n[Dd]ebug/\n[Dd]ebugPublic/\n[Rr]elease/\n[Rr]eleases/\nx64/\nx86/\n[Ww][Ii][Nn]32/\n[Aa][Rr][Mm]/\n[Aa][Rr][Mm]64/\nbld/\n[Bb]in/\n[Oo]bj/\n[Ll]og/\n[Ll]ogs/\n\n# Visual Studio 2015/2017 cache/options directory\n.vs/\n# Uncomment if you have tasks that create the project's static files in wwwroot\n#wwwroot/\n\n# Visual Studio 2017 auto generated files\nGenerated\\ Files/\n\n# MSTest test Results\n[Tt]est[Rr]esult*/\n[Bb]uild[Ll]og.*\n\n# NUnit\n*.VisualState.xml\nTestResult.xml\nnunit-*.xml\n\n# Build Results of an ATL Project\n[Dd]ebugPS/\n[Rr]eleasePS/\ndlldata.c\n\n# Benchmark Results\nBenchmarkDotNet.Artifacts/\n\n# .NET Core\nproject.lock.json\nproject.fragment.lock.json\nartifacts/\n\n# ASP.NET Scaffolding\nScaffoldingReadMe.txt\n\n# StyleCop\nStyleCopReport.xml\n\n# Files built by Visual Studio\n*_i.c\n*_p.c\n*_h.h\n*.ilk\n*.meta\n*.obj\n*.iobj\n*.pch\n*.pdb\n*.ipdb\n*.pgc\n*.pgd\n*.rsp\n*.sbr\n*.tlb\n*.tli\n*.tlh\n*.tmp\n*.tmp_proj\n*_wpftmp.csproj\n*.log\n*.vspscc\n*.vssscc\n.builds\n*.pidb\n*.svclog\n*.scc\n\n# Chutzpah Test files\n_Chutzpah*\n\n# Visual C++ cache files\nipch/\n*.aps\n*.ncb\n*.opendb\n*.opensdf\n*.sdf\n*.cachefile\n*.VC.db\n*.VC.VC.opendb\n\n# Visual Studio profiler\n*.psess\n*.vsp\n*.vspx\n*.sap\n\n# Visual Studio Trace Files\n*.e2e\n\n# TFS 2012 Local Workspace\n$tf/\n\n# Guidance Automation Toolkit\n*.gpState\n\n# ReSharper is a .NET coding add-in\n_ReSharper*/\n*.[Rr]e[Ss]harper\n*.DotSettings.user\n\n# TeamCity is a build add-in\n_TeamCity*\n\n# DotCover is a Code Coverage Tool\n*.dotCover\n\n# AxoCover is a Code Coverage Tool\n.axoCover/*\n!.axoCover/settings.json\n\n# Coverlet is a free, cross platform Code Coverage Tool\ncoverage*.json\ncoverage*.xml\ncoverage*.info\n\n# Visual Studio code coverage results\n*.coverage\n*.coveragexml\n\n# NCrunch\n_NCrunch_*\n.*crunch*.local.xml\nnCrunchTemp_*\n\n# MightyMoose\n*.mm.*\nAutoTest.Net/\n\n# Web workbench (sass)\n.sass-cache/\n\n# Installshield output folder\n[Ee]xpress/\n\n# DocProject is a documentation generator add-in\nDocProject/buildhelp/\nDocProject/Help/*.HxT\nDocProject/Help/*.HxC\nDocProject/Help/*.hhc\nDocProject/Help/*.hhk\nDocProject/Help/*.hhp\nDocProject/Help/Html2\nDocProject/Help/html\n\n# Click-Once directory\npublish/\n\n# Publish Web Output\n*.[Pp]ublish.xml\n*.azurePubxml\n# Note: Comment the next line if you want to checkin your web deploy settings,\n# but database connection strings (with potential passwords) will be unencrypted\n*.pubxml\n*.publishproj\n\n# Microsoft Azure Web App publish settings. Comment the next line if you want to\n# checkin your Azure Web App publish settings, but sensitive information contained\n# in these scripts will be unencrypted\nPublishScripts/\n\n# NuGet Packages\n*.nupkg\n# NuGet Symbol Packages\n*.snupkg\n# The packages folder can be ignored because of Package Restore\n**/[Pp]ackages/*\n# except build/, which is used as an MSBuild target.\n!**/[Pp]ackages/build/\n# Uncomment if necessary however generally it will be regenerated when needed\n#!**/[Pp]ackages/repositories.config\n# NuGet v3's project.json files produces more ignorable files\n*.nuget.props\n*.nuget.targets\n\n# Microsoft Azure Build Output\ncsx/\n*.build.csdef\n\n# Microsoft Azure Emulator\necf/\nrcf/\n\n# Windows Store app package directories and files\nAppPackages/\nBundleArtifacts/\nPackage.StoreAssociation.xml\n_pkginfo.txt\n*.appx\n*.appxbundle\n*.appxupload\n\n# Visual Studio cache files\n# files ending in .cache can be ignored\n*.[Cc]ache\n# but keep track of directories ending in .cache\n!?*.[Cc]ache/\n\n# Others\nClientBin/\n~$*\n*~\n*.dbmdl\n*.dbproj.schemaview\n*.jfm\n*.pfx\n*.publishsettings\norleans.codegen.cs\n\n# Including strong name files can present a security risk\n# (https://github.com/github/gitignore/pull/2483#issue-259490424)\n#*.snk\n\n# Since there are multiple workflows, uncomment next line to ignore bower_components\n# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)\n#bower_components/\n\n# RIA/Silverlight projects\nGenerated_Code/\n\n# Backup & report files from converting an old project file\n# to a newer Visual Studio version. Backup files are not needed,\n# because we have git ;-)\n_UpgradeReport_Files/\nBackup*/\nUpgradeLog*.XML\nUpgradeLog*.htm\nServiceFabricBackup/\n*.rptproj.bak\n\n# SQL Server files\n*.mdf\n*.ldf\n*.ndf\n\n# Business Intelligence projects\n*.rdl.data\n*.bim.layout\n*.bim_*.settings\n*.rptproj.rsuser\n*- [Bb]ackup.rdl\n*- [Bb]ackup ([0-9]).rdl\n*- [Bb]ackup ([0-9][0-9]).rdl\n\n# Microsoft Fakes\nFakesAssemblies/\n\n# GhostDoc plugin setting file\n*.GhostDoc.xml\n\n# Node.js Tools for Visual Studio\n.ntvs_analysis.dat\nnode_modules/\n\n# Visual Studio 6 build log\n*.plg\n\n# Visual Studio 6 workspace options file\n*.opt\n\n# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)\n*.vbw\n\n# Visual Studio LightSwitch build output\n**/*.HTMLClient/GeneratedArtifacts\n**/*.DesktopClient/GeneratedArtifacts\n**/*.DesktopClient/ModelManifest.xml\n**/*.Server/GeneratedArtifacts\n**/*.Server/ModelManifest.xml\n_Pvt_Extensions\n\n# Paket dependency manager\n.paket/paket.exe\npaket-files/\n\n# FAKE - F# Make\n.fake/\n\n# CodeRush personal settings\n.cr/personal\n\n# Python Tools for Visual Studio (PTVS)\n__pycache__/\n*.pyc\n\n# Cake - Uncomment if you are using it\n# tools/**\n# !tools/packages.config\n\n# Tabs Studio\n*.tss\n\n# Telerik's JustMock configuration file\n*.jmconfig\n\n# BizTalk build output\n*.btp.cs\n*.btm.cs\n*.odx.cs\n*.xsd.cs\n\n# OpenCover UI analysis results\nOpenCover/\n\n# Azure Stream Analytics local run output\nASALocalRun/\n\n# MSBuild Binary and Structured Log\n*.binlog\n\n# NVidia Nsight GPU debugger configuration file\n*.nvuser\n\n# MFractors (Xamarin productivity tool) working folder\n.mfractor/\n\n# Local History for Visual Studio\n.localhistory/\n\n# BeatPulse healthcheck temp database\nhealthchecksdb\n\n# Backup folder for Package Reference Convert tool in Visual Studio 2017\nMigrationBackup/\n\n# Ionide (cross platform F# VS Code tools) working folder\n.ionide/\n\n# Fody - auto-generated XML schema\nFodyWeavers.xsd\n\n# debug log\ndebug_*.json\n\n# dotnet run in `BBDown/` sub folder\n/BBDown/*.mp4\n/BBDown/*.xml\n/BBDown/*.ass\n"
  },
  {
    "path": "BBDown/BBDown.csproj",
    "content": "<Project Sdk=\"Microsoft.NET.Sdk.Web\">\n\n  <PropertyGroup>\n    <OutputType>Exe</OutputType>\n    <TargetFramework>net9.0</TargetFramework>\n    <PackageLicenseExpression>MIT</PackageLicenseExpression>\n    <Version>1.6.3</Version>\n    <Description>BBDown是一个免费且便捷高效的哔哩哔哩下载/解析软件.</Description>\n    <PackageProjectUrl>https://github.com/nilaoda/BBDown</PackageProjectUrl>\n    <StartupObject></StartupObject>\n    <PackAsTool>true</PackAsTool>\n    <ToolCommandName>BBDown</ToolCommandName>\n    <PackageOutputPath>./nupkg</PackageOutputPath>\n    <Nullable>enable</Nullable>\n  </PropertyGroup>\n\n  <ItemGroup>\n    <PackageReference Include=\"QRCoder\" Version=\"1.6.0\" />\n    <PackageReference Include=\"SharpZipLib\" Version=\"1.4.2\" />\n    <PackageReference Include=\"System.CommandLine\" Version=\"2.0.0-beta4.22272.1\" />\n  </ItemGroup>\n\n  <ItemGroup>\n    <ProjectReference Include=\"..\\BBDown.Core\\BBDown.Core.csproj\" />\n  </ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "BBDown/BBDownApiServer.cs",
    "content": "using System;\r\nusing System.Collections.Generic;\r\nusing System.Data;\r\nusing System.Linq;\r\nusing System.Net.Http;\r\nusing System.Net.Http.Json;\r\nusing System.Text.Json;\r\nusing System.Text.Json.Serialization;\r\nusing System.Text.Json.Serialization.Metadata;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\nusing BBDown.Core;\r\nusing Microsoft.AspNetCore.Builder;\r\nusing Microsoft.AspNetCore.Http;\r\nusing Microsoft.AspNetCore.Mvc;\r\nusing Microsoft.Extensions.DependencyInjection;\r\nnamespace BBDown;\r\n\r\npublic class BBDownApiServer\r\n{\r\n    private WebApplication? app;\r\n    private readonly List<DownloadTask> runningTasks = [];\r\n    private readonly List<DownloadTask> finishedTasks = [];\r\n\r\n    public void SetUpServer()\r\n    {\r\n        if (app is not null) return;\r\n        var builder = WebApplication.CreateSlimBuilder();\r\n        builder.Services.ConfigureHttpJsonOptions((options) =>\r\n        {\r\n            options.SerializerOptions.TypeInfoResolver = JsonTypeInfoResolver.Combine(options.SerializerOptions.TypeInfoResolver, AppJsonSerializerContext.Default);\r\n        });\r\n        builder.Services.AddCors((options) =>\r\n        {\r\n            options.AddPolicy(\"AllowAnyOrigin\",\r\n                policy =>\r\n                {\r\n                    policy.AllowAnyOrigin()\r\n                          .AllowAnyMethod()\r\n                          .AllowAnyHeader();\r\n                });\r\n        });\r\n        app = builder.Build();\r\n        app.UseCors(\"AllowAnyOrigin\");\r\n        var taskStatusApi = app.MapGroup(\"/get-tasks\");\r\n        taskStatusApi.MapGet(\"/\", handler: () => Results.Json(new DownloadTaskCollection(runningTasks, finishedTasks), AppJsonSerializerContext.Default.DownloadTaskCollection));\r\n        taskStatusApi.MapGet(\"/running\", handler: () => Results.Json(runningTasks, AppJsonSerializerContext.Default.ListDownloadTask));\r\n        taskStatusApi.MapGet(\"/finished\", handler: () => Results.Json(finishedTasks, AppJsonSerializerContext.Default.ListDownloadTask));\r\n        taskStatusApi.MapGet(\"/{id}\", (string id) =>\r\n        {\r\n            var task = finishedTasks.FirstOrDefault(a => a.Aid == id);\r\n            var rtask = runningTasks.FirstOrDefault(a => a.Aid == id);\r\n            if (rtask is not null) task = rtask;\r\n            if (task is null)\r\n            {\r\n                return Results.NotFound();\r\n            }\r\n            return Results.Json(task, AppJsonSerializerContext.Default.DownloadTask);\r\n        });\r\n        app.MapPost(\"/add-task\", (MyOptionBindingResult<ServeRequestOptions> bindingResult) =>\r\n        {\r\n            if (!bindingResult.IsValid)\r\n            {\r\n                //var exception = bindingResult.Exception;\r\n                return Results.BadRequest(\"输入有误\");\r\n            }\r\n            var req = bindingResult.Result;\r\n            _ = AddDownloadTaskAsync(req)\r\n                .ContinueWith(async task => {\r\n                    // send request to callback webhook\r\n                    if (string.IsNullOrEmpty(req.CallBackWebHook))\r\n                    {\r\n                        return;\r\n                    }\r\n                    string callback = req.CallBackWebHook;\r\n                    var client = new HttpClient();\r\n                    var downloadTask = await task;\r\n                    string? jsonContent = JsonSerializer.Serialize(downloadTask, AppJsonSerializerContext.Default.DownloadTask);\r\n                    try\r\n                    {\r\n                        await client.PostAsync(callback, new StringContent(jsonContent, System.Text.Encoding.UTF8, \"application/json\"));\r\n                    }\r\n                    catch (System.Exception e)\r\n                    {\r\n                        Logger.LogDebug(\"回调失败\", e.Message);\r\n                    }\r\n                 });\r\n            return Results.Ok();\r\n        });\r\n        var finishedRemovalApi = app.MapGroup(\"remove-finished\");\r\n        finishedRemovalApi.MapGet(\"/\", () => { finishedTasks.RemoveAll(t => true); return Results.Ok(); });\r\n        finishedRemovalApi.MapGet(\"/failed\", () => { finishedTasks.RemoveAll(t => !t.IsSuccessful); return Results.Ok(); });\r\n        finishedRemovalApi.MapGet(\"/{id}\", (string id) => { finishedTasks.RemoveAll(t => t.Aid == id); return Results.Ok(); });\r\n    }\r\n\r\n    public void Run(string url)\r\n    {\r\n        if (app is null) return;\r\n        bool result = Uri.TryCreate(url, UriKind.Absolute, out Uri? uriResult)\r\n            && uriResult.Scheme == Uri.UriSchemeHttp;\r\n        if (!result)\r\n        {\r\n            Console.BackgroundColor = ConsoleColor.Red;\r\n            Console.ForegroundColor = ConsoleColor.White;\r\n            Console.WriteLine($\"{url}不是合法的http URL，url示例：http://0.0.0.0:5000\");\r\n            Console.WriteLine(\"如果您需要https，请额外配置反向代理\");\r\n            Console.ResetColor();\r\n            Console.WriteLine();\r\n            Thread.Sleep(1);\r\n            Environment.Exit(1);\r\n        }\r\n        app.Run(url);\r\n    }\r\n\r\n    private async Task<DownloadTask> AddDownloadTaskAsync(MyOption option)\r\n    {\r\n        var aid = await BBDownUtil.GetAvIdAsync(option.Url);\r\n        DownloadTask? runningTask = runningTasks.FirstOrDefault(task => task.Aid == aid);\r\n        if (runningTask is not null)\r\n        {\r\n            return runningTask;\r\n        };\r\n        var task = new DownloadTask(aid, option.Url, DateTimeOffset.Now.ToUnixTimeSeconds());\r\n        runningTasks.Add(task);\r\n        try\r\n        {\r\n            var (encodingPriority, dfnPriority, firstEncoding, downloadDanmaku, downloadDanmakuFormats, input, savePathFormat, lang, aidOri, delay) = Program.SetUpWork(option);\r\n            var (fetchedAid, vInfo, apiType) = await Program.GetVideoInfoAsync(option, aidOri, input);\r\n            task.Title = vInfo.Title;\r\n            task.Pic = vInfo.Pic;\r\n            task.VideoPubTime = vInfo.PubTime;\r\n            await Program.DownloadPagesAsync(option, vInfo, encodingPriority, dfnPriority, firstEncoding, downloadDanmaku, downloadDanmakuFormats,\r\n                        input, savePathFormat, lang, fetchedAid, delay, apiType, task);\r\n            task.IsSuccessful = true;\r\n        }\r\n        catch (Exception e)\r\n        {\r\n            Console.BackgroundColor = ConsoleColor.Red;\r\n            Console.ForegroundColor = ConsoleColor.White;\r\n            Console.WriteLine($\"{aid}下载失败\");\r\n            var msg = Config.DEBUG_LOG ? e.ToString() : e.Message;\r\n            Console.Write($\"{msg}{Environment.NewLine}请尝试升级到最新版本后重试!\");\r\n            Console.ResetColor();\r\n            Console.WriteLine();\r\n        }\r\n        task.TaskFinishTime = DateTimeOffset.Now.ToUnixTimeSeconds();\r\n        if (task.IsSuccessful)\r\n        {\r\n            task.Progress = 1f;\r\n            task.DownloadSpeed = (double)(task.TotalDownloadedBytes / (task.TaskFinishTime - task.TaskCreateTime));\r\n        }\r\n        runningTasks.Remove(task);\r\n        finishedTasks.Add(task);\r\n        return task;\r\n    }\r\n}\r\n\r\npublic record DownloadTask(string Aid, string Url, long TaskCreateTime)\r\n{\r\n    [JsonInclude]\r\n    public string? Title = null;\r\n    [JsonInclude]\r\n    public string? Pic = null;\r\n    [JsonInclude]\r\n    public long? VideoPubTime = null;\r\n    [JsonInclude]\r\n    public long? TaskFinishTime = null;\r\n    [JsonInclude]\r\n    public double Progress = 0f;\r\n    [JsonInclude]\r\n    public double DownloadSpeed = 0f;\r\n    [JsonInclude]\r\n    public double TotalDownloadedBytes = 0f;\r\n    [JsonInclude]\r\n    public bool IsSuccessful = false;\r\n\r\n    [JsonInclude]\r\n    public List<string> SavePaths = new();\r\n};\r\npublic record DownloadTaskCollection(List<DownloadTask> Running, List<DownloadTask> Finished);\r\n\r\nrecord struct MyOptionBindingResult<T>(T? Result, Exception? Exception)\r\n{\r\n    public bool IsValid => Exception is null;\r\n\r\n    public static async ValueTask<MyOptionBindingResult<T>> BindAsync(HttpContext httpContext)\r\n    {\r\n        try\r\n        {\r\n            JsonTypeInfo? jsonTypeInfo = SourceGenerationContext.Default.GetTypeInfo(typeof(T));\r\n            if (jsonTypeInfo is null)\r\n            {\r\n                return new(default, new InvalidOperationException($\"Cannot find TypeInfo for type {typeof(T)}\"));\r\n            }\r\n            var item = await httpContext.Request.ReadFromJsonAsync(jsonTypeInfo);\r\n\r\n            if (item is null) return new(default, new NoNullAllowedException());\r\n\r\n            return new((T)item, null);\r\n        }\r\n        catch (Exception ex)\r\n        {\r\n            return new(default, ex);\r\n        }\r\n    }\r\n}\r\n\r\n[JsonSerializable(typeof(ProblemDetails))]\r\n[JsonSerializable(typeof(ValidationProblemDetails))]\r\n[JsonSerializable(typeof(HttpValidationProblemDetails))]\r\n[JsonSerializable(typeof(DownloadTask))]\r\n[JsonSerializable(typeof(List<DownloadTask>))]\r\n[JsonSerializable(typeof(DownloadTaskCollection))]\r\npublic partial class AppJsonSerializerContext : JsonSerializerContext\r\n{\r\n\r\n}\r\n\r\n[JsonSerializable(typeof(MyOption))]\r\n[JsonSerializable(typeof(ServeRequestOptions))]\r\ninternal partial class SourceGenerationContext : JsonSerializerContext\r\n{\r\n\r\n}\r\n"
  },
  {
    "path": "BBDown/BBDownAria2c.cs",
    "content": "﻿using System.Diagnostics;\nusing System.IO;\nusing System.Threading.Tasks;\n\nnamespace BBDown;\n\nstatic class BBDownAria2c\n{\n    public static string ARIA2C = \"aria2c\";\n\n    public static async Task<int> RunCommandCodeAsync(string command, string args)\n    {\n        using Process p = new();\n        p.StartInfo.UseShellExecute = false;\n        p.StartInfo.RedirectStandardOutput = false;\n        p.StartInfo.FileName = command;\n        p.StartInfo.Arguments = args;\n        p.Start();\n        await p.WaitForExitAsync();\n        return p.ExitCode;\n    }\n\n    public static async Task DownloadFileByAria2cAsync(string url, string path, string extraArgs)\n    {\n        var headerArgs = \"\";\n        if (!url.Contains(\"platform=android_tv_yst\") && !url.Contains(\"platform=android\"))\n            headerArgs += \" --header=\\\"Referer: https://www.bilibili.com\\\"\";\n        headerArgs += \" --header=\\\"User-Agent: Mozilla/5.0\\\"\";\n        headerArgs += $\" --header=\\\"Cookie: {Core.Config.COOKIE}\\\"\";\n        await RunCommandCodeAsync(ARIA2C, $\" --auto-file-renaming=false --download-result=hide --allow-overwrite=true --console-log-level=warn -x16 -s16 -j16 -k5M {headerArgs} {extraArgs} \\\"{url}\\\" -d \\\"{Path.GetDirectoryName(path)}\\\" -o \\\"{Path.GetFileName(path)}\\\"\");\n    }\n}"
  },
  {
    "path": "BBDown/BBDownConfigParser.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.CommandLine.Parsing;\nusing System.CommandLine;\nusing System.IO;\nusing System.Linq;\nusing static BBDown.Core.Logger;\n\nnamespace BBDown;\n\ninternal static class BBDownConfigParser\n{\n    public static void HandleConfig(List<string> newArgsList, RootCommand rootCommand)\n    {\n        try\n        {\n            var configPath = newArgsList.Contains(\"--config-file\")\n                ? newArgsList.ElementAt(newArgsList.IndexOf(\"--config-file\") + 1)\n                : Path.Combine(Program.APP_DIR, \"BBDown.config\");\n            if (File.Exists(configPath))\n            {\n                Log($\"加载配置文件: {configPath}\");\n                var configArgs = File\n                    .ReadAllLines(configPath)\n                    .Where(s => !string.IsNullOrEmpty(s) && !s.StartsWith('#'))\n                    .SelectMany(s =>\n                        {\n                            var trimLine = s.Trim();\n                            if (trimLine.StartsWith('-') && trimLine.Contains(' '))\n                            {\n                                var spaceIndex = trimLine.IndexOf(' ');\n                                var paramsGroup = new[] { trimLine[..spaceIndex], trimLine[spaceIndex..] };\n                                return paramsGroup.Where(s => !string.IsNullOrEmpty(s)).Select(s => s.Trim(' ').Trim('\\\"'));\n                            }\n                            return [trimLine.Trim('\\\"')];\n                        }\n                    );\n                var configArgsResult = rootCommand.Parse(configArgs.ToArray());\n                foreach (var item in configArgsResult.CommandResult.Children)\n                {\n                    if (item is OptionResult o)\n                    {\n                        if (!newArgsList.Contains(\"--\" + o.Option.Name))\n                        {\n                            newArgsList.Add(\"--\" + o.Option.Name);\n                            newArgsList.AddRange(o.Tokens.Select(t => t.Value));\n                        }\n                    }\n                }\n\n                //命令行的优先级>配置文件优先级\n                LogDebug(\"新的命令行参数: \" + string.Join(\" \", newArgsList));\n            }\n        }\n        catch (Exception)\n        {\n            LogError(\"配置文件读取异常，忽略\");\n        }\n    }\n}"
  },
  {
    "path": "BBDown/BBDownDownloadUtil.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Net.Http;\nusing System.Net;\nusing System.Threading.Tasks;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Logger;\nusing static BBDown.Core.Util.HTTPUtil;\nusing System.Collections.Concurrent;\n\nnamespace BBDown;\n\ninternal static class BBDownDownloadUtil\n{\n    public class DownloadConfig\n    {\n        public bool UseAria2c { get; set; } = false;\n        public string Aria2cArgs { get; set; } = string.Empty;\n        public bool ForceHttp { get; set; } = false;\n        public bool MultiThread { get; set; } = false;\n        public DownloadTask? RelatedTask { get; set; } = null;\n    }\n\n    private static async Task RangeDownloadToTmpAsync(int id, string url, string tmpName, long fromPosition, long? toPosition, Action<int, long, long> onProgress, bool failOnRangeNotSupported = false)\n    {\n        DateTimeOffset? lastTime = File.Exists(tmpName) ? new FileInfo(tmpName).LastWriteTimeUtc : null;\n        using var fileStream = new FileStream(tmpName, FileMode.OpenOrCreate);\n        fileStream.Seek(0, SeekOrigin.End);\n        if (toPosition > 0 && fileStream.Position == toPosition - fromPosition + 1)\n        {\n            // 已下载完成 直接汇报进度并跳过下载\n            onProgress(id, fileStream.Position, fileStream.Position);\n            return;\n        }\n        var downloadedBytes = fromPosition + fileStream.Position;\n\n        using var httpRequestMessage = new HttpRequestMessage();\n        if (!url.Contains(\"platform=android_tv_yst\") && !url.Contains(\"platform=android\"))\n            httpRequestMessage.Headers.TryAddWithoutValidation(\"Referer\", \"https://www.bilibili.com\");\n        httpRequestMessage.Headers.TryAddWithoutValidation(\"User-Agent\", \"Mozilla/5.0\");\n        httpRequestMessage.Headers.TryAddWithoutValidation(\"Cookie\", Core.Config.COOKIE);\n        httpRequestMessage.Headers.Range = new(downloadedBytes, toPosition);\n        httpRequestMessage.Headers.IfRange = lastTime != null ? new(lastTime.Value) : null;\n        httpRequestMessage.RequestUri = new(url);\n\n        using var response = (await AppHttpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead)).EnsureSuccessStatusCode();\n\n        if (response.StatusCode == HttpStatusCode.OK) // server doesn't response a partial content\n        {\n            if (failOnRangeNotSupported && (downloadedBytes > 0 || toPosition != null)) throw new NotSupportedException(\"Range request is not supported.\");\n            downloadedBytes = 0;\n            fileStream.Seek(0, SeekOrigin.Begin);\n        }\n\n        using var stream = await response.Content.ReadAsStreamAsync();\n        var totalBytes = downloadedBytes + (response.Content.Headers.ContentLength ?? long.MaxValue - downloadedBytes);\n\n        const int blockSize = 1048576 / 4;\n        var buffer = new byte[blockSize];\n\n        while (downloadedBytes < totalBytes)\n        {\n            var recevied = await stream.ReadAsync(buffer);\n            if (recevied == 0) break;\n            await fileStream.WriteAsync(buffer.AsMemory(0, recevied));\n            await fileStream.FlushAsync();\n            downloadedBytes += recevied;\n            onProgress(id, downloadedBytes - fromPosition, totalBytes);\n        }\n\n        if (response.Content.Headers.ContentLength != null && (response.Content.Headers.ContentLength != new FileInfo(tmpName).Length))\n            throw new Exception(\"Retry...\");\n    }\n\n    public static async Task DownloadFileAsync(string url, string path, DownloadConfig config)\n    {\n        if (string.IsNullOrEmpty(url)) return;\n        if (config.ForceHttp) url = ReplaceUrl(url);\n        LogDebug(\"Start downloading: {0}\", url);\n        string desDir = Path.GetDirectoryName(path)!;\n        if (!string.IsNullOrEmpty(desDir) && !Directory.Exists(desDir)) Directory.CreateDirectory(desDir);\n        if (config.UseAria2c)\n        {\n            await BBDownAria2c.DownloadFileByAria2cAsync(url, path, config.Aria2cArgs);\n            if (File.Exists(path + \".aria2\") || !File.Exists(path))\n                throw new Exception(\"aria2下载可能存在错误\");\n            Console.WriteLine();\n            return;\n        }\n        int retry = 0;\n        string tmpName = Path.Combine(desDir, Path.GetFileNameWithoutExtension(path) + \".tmp\");\n        reDown:\n        try\n        {\n            using var progress = new ProgressBar(config.RelatedTask);\n            await RangeDownloadToTmpAsync(0, url, tmpName, 0, null, (_, downloaded, total) => progress.Report((double)downloaded / total, downloaded));\n            File.Move(tmpName, path, true);\n        }\n        catch (Exception)\n        {\n            if (++retry == 3) throw;\n            goto reDown;\n        }\n    }\n\n    public static async Task MultiThreadDownloadFileAsync(string url, string path, DownloadConfig config)\n    {\n        if (config.ForceHttp) url = ReplaceUrl(url);\n        LogDebug(\"Start downloading: {0}\", url);\n        if (config.UseAria2c)\n        {\n            await BBDownAria2c.DownloadFileByAria2cAsync(url, path, config.Aria2cArgs);\n            if (File.Exists(path + \".aria2\") || !File.Exists(path))\n                throw new Exception(\"aria2下载可能存在错误\");\n            Console.WriteLine();\n            return;\n        }\n        long fileSize = await GetFileSizeAsync(url);\n        LogDebug(\"文件大小：{0} bytes\", fileSize);\n        //已下载过, 跳过下载\n        if (File.Exists(path) && new FileInfo(path).Length == fileSize)\n        {\n            LogDebug(\"文件已下载过, 跳过下载\");\n            return;\n        }\n        List<Clip> allClips = GetAllClips(url, fileSize);\n        int total = allClips.Count;\n        LogDebug(\"分段数量：{0}\", total);\n        ConcurrentDictionary<int, long> clipProgress = new();\n        foreach (var i in allClips) clipProgress[i.index] = 0;\n\n        using var progress = new ProgressBar(config.RelatedTask);\n        progress.Report(0);\n        await Parallel.ForEachAsync(allClips, async (clip, _) =>\n        {\n            int retry = 0;\n            string tmp = Path.Combine(Path.GetDirectoryName(path)!, clip.index.ToString(\"00000\") + \"_\" + Path.GetFileNameWithoutExtension(path) + (Path.GetExtension(path).EndsWith(\".mp4\") ? \".vclip\" : \".aclip\"));\n            reDown:\n            try\n            {\n                await RangeDownloadToTmpAsync(clip.index, url, tmp, clip.from, clip.to == -1 ? null : clip.to, (index, downloaded, _) =>\n                {\n                    clipProgress[index] = downloaded;\n                    progress.Report((double)clipProgress.Values.Sum() / fileSize, clipProgress.Values.Sum());\n                }, true);\n            }\n            catch (NotSupportedException)\n            {\n                if (++retry == 3) throw new Exception($\"服务器可能并不支持多线程下载, 请使用 --multi-thread false 关闭多线程\");\n                goto reDown;\n            }\n            catch (Exception)\n            {\n                if (++retry == 3) throw new Exception($\"Failed to download clip {clip.index}\");\n                goto reDown;\n            }\n        });\n    }\n\n    //此函数主要是切片下载逻辑\n    private static List<Clip> GetAllClips(string url, long fileSize)\n    {\n        List<Clip> clips = [];\n        int index = 0;\n        long counter = 0;\n        int perSize = 20 * 1024 * 1024;\n        while (fileSize > 0)\n        {\n            Clip c = new()\n            {\n                index = index,\n                from = counter,\n                to = counter + perSize\n            };\n            //没到最后\n            if (fileSize - perSize > 0)\n            {\n                fileSize -= perSize;\n                counter += perSize + 1;\n                index++;\n                clips.Add(c);\n            }\n            //已到最后\n            else\n            {\n                c.to = -1;\n                clips.Add(c);\n                break;\n            }\n        }\n        return clips;\n    }\n\n    private static async Task<long> GetFileSizeAsync(string url)\n    {\n        using var httpRequestMessage = new HttpRequestMessage();\n        if (!url.Contains(\"platform=android_tv_yst\") && !url.Contains(\"platform=android\"))\n            httpRequestMessage.Headers.TryAddWithoutValidation(\"Referer\", \"https://www.bilibili.com\");\n        httpRequestMessage.Headers.TryAddWithoutValidation(\"User-Agent\", \"Mozilla/5.0\");\n        httpRequestMessage.Headers.TryAddWithoutValidation(\"Cookie\", Core.Config.COOKIE);\n        httpRequestMessage.RequestUri = new(url);\n        var response = (await AppHttpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead)).EnsureSuccessStatusCode();\n        long totalSizeBytes = response.Content.Headers.ContentLength ?? 0;\n\n        return totalSizeBytes;\n    }\n\n    /// <summary>\n    /// 将下载地址强制转换为HTTP\n    /// </summary>\n    /// <param name=\"url\"></param>\n    /// <returns></returns>\n    private static string ReplaceUrl(string url)\n    {\n        if (url.Contains(\".mcdn.bilivideo.cn:\"))\n        {\n            LogDebug(\"对[*.mcdn.bilivideo.cn:xxx]域名不做处理\");\n            return url;\n        }\n\n        LogDebug(\"将https更改为http\");\n        return url.Replace(\"https:\", \"http:\");\n    }\n}"
  },
  {
    "path": "BBDown/BBDownEnums.cs",
    "content": "using System;\r\nusing System.Linq;\r\n\r\nnamespace BBDown;\r\n\r\npublic enum BBDownDanmakuFormat\r\n{\r\n    Xml,\r\n    Ass,\r\n}\r\n\r\npublic static class BBDownDanmakuFormatInfo\r\n{\r\n    // 默认\r\n    public static BBDownDanmakuFormat[] DefaultFormats = [BBDownDanmakuFormat.Xml, BBDownDanmakuFormat.Ass];\r\n    public static string[] DefaultFormatsNames = DefaultFormats.Select(f => f.ToString().ToLower()).ToArray();\r\n    // 可选项\r\n    public static string[] AllFormatNames = Enum.GetNames(typeof(BBDownDanmakuFormat)).Select(f => f.ToLower()).ToArray();\r\n\r\n    public static BBDownDanmakuFormat FromFormatName(string formatName)\r\n    {\r\n        return formatName switch\r\n        {\r\n            \"xml\" => BBDownDanmakuFormat.Xml,\r\n            \"ass\" => BBDownDanmakuFormat.Ass,\r\n            _ => BBDownDanmakuFormat.Xml,\r\n        };\r\n    }\r\n}\r\n"
  },
  {
    "path": "BBDown/BBDownLoginUtil.cs",
    "content": "﻿using QRCoder;\nusing System;\nusing System.IO;\nusing System.Threading.Tasks;\nusing static BBDown.BBDownUtil;\nusing static BBDown.Core.Logger;\nusing System.Text;\nusing System.Text.Json;\nusing System.Net.Http;\nusing BBDown.Core.Util;\n\nnamespace BBDown;\n\ninternal static class BBDownLoginUtil\n{\n    public static async Task<string> GetLoginStatusAsync(string qrcodeKey)\n    {\n        string queryUrl = $\"https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key={qrcodeKey}&source=main-fe-header\";\n        return await HTTPUtil.GetWebSourceAsync(queryUrl);\n    }\n\n    public static async Task LoginWEB()\n    {\n        try\n        {\n            Log(\"获取登录地址...\");\n            string loginUrl = \"https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header\";\n            string url = JsonDocument.Parse(await HTTPUtil.GetWebSourceAsync(loginUrl)).RootElement.GetProperty(\"data\").GetProperty(\"url\").ToString();\n            string qrcodeKey = GetQueryString(\"qrcode_key\", url);\n            //Log(oauthKey);\n            //Log(url);\n            bool flag = false;\n            Log(\"生成二维码...\");\n            QRCodeGenerator qrGenerator = new();\n            QRCodeData qrCodeData = qrGenerator.CreateQrCode(url, QRCodeGenerator.ECCLevel.Q);\n            PngByteQRCode pngByteCode = new(qrCodeData);\n            await File.WriteAllBytesAsync(\"qrcode.png\", pngByteCode.GetGraphic(7));\n            Log(\"生成二维码成功: qrcode.png, 请打开并扫描, 或扫描打印的二维码\");\n            var consoleQRCode = new ConsoleQRCode(qrCodeData);\n            consoleQRCode.GetGraphic();\n\n            while (true)\n            {\n                await Task.Delay(1000);\n                string w = await GetLoginStatusAsync(qrcodeKey);\n                int code = JsonDocument.Parse(w).RootElement.GetProperty(\"data\").GetProperty(\"code\").GetInt32();\n                if (code == 86038)\n                {\n                    LogColor(\"二维码已过期, 请重新执行登录指令.\");\n                    break;\n                }\n                else if (code == 86101) //等待扫码\n                {\n                    continue;\n                }\n                else if (code == 86090) //等待确认\n                {\n                    if (!flag)\n                    {\n                        Log(\"扫码成功, 请确认...\");\n                        flag = !flag;\n                    }\n                }\n                else\n                {\n                    string cc = JsonDocument.Parse(w).RootElement.GetProperty(\"data\").GetProperty(\"url\").ToString();\n                    Log(\"登录成功: SESSDATA=\" + GetQueryString(\"SESSDATA\", cc));\n                    //导出cookie, 转义英文逗号 否则部分场景会出问题\n                    await File.WriteAllTextAsync(Path.Combine(Program.APP_DIR, \"BBDown.data\"), cc[(cc.IndexOf('?') + 1)..].Replace(\"&\", \";\").Replace(\",\", \"%2C\"));\n                    File.Delete(\"qrcode.png\");\n                    break;\n                }\n            }\n        }\n        catch (Exception e) { LogError(e.Message); }\n    }\n\n    public static async Task LoginTV()\n    {\n        try\n        {\n            string loginUrl = \"https://passport.snm0516.aisee.tv/x/passport-tv-login/qrcode/auth_code\";\n            string pollUrl = \"https://passport.bilibili.com/x/passport-tv-login/qrcode/poll\";\n            var parms = GetTVLoginParms();\n            Log(\"获取登录地址...\");\n            byte[] responseArray = await (await HTTPUtil.AppHttpClient.PostAsync(loginUrl, new FormUrlEncodedContent(parms.ToDictionary()))).Content.ReadAsByteArrayAsync();\n            string web = Encoding.UTF8.GetString(responseArray);\n            string url = JsonDocument.Parse(web).RootElement.GetProperty(\"data\").GetProperty(\"url\").ToString();\n            string authCode = JsonDocument.Parse(web).RootElement.GetProperty(\"data\").GetProperty(\"auth_code\").ToString();\n            Log(\"生成二维码...\");\n            QRCodeGenerator qrGenerator = new();\n            QRCodeData qrCodeData = qrGenerator.CreateQrCode(url, QRCodeGenerator.ECCLevel.Q);\n            PngByteQRCode pngByteCode = new(qrCodeData);\n            await File.WriteAllBytesAsync(\"qrcode.png\", pngByteCode.GetGraphic(7));\n            Log(\"生成二维码成功: qrcode.png, 请打开并扫描, 或扫描打印的二维码\");\n            var consoleQRCode = new ConsoleQRCode(qrCodeData);\n            consoleQRCode.GetGraphic();\n            parms.Set(\"auth_code\", authCode);\n            parms.Set(\"ts\", GetTimeStamp(true));\n            parms.Remove(\"sign\");\n            parms.Add(\"sign\", GetSign(ToQueryString(parms)));\n            while (true)\n            {\n                await Task.Delay(1000);\n                responseArray = await (await HTTPUtil.AppHttpClient.PostAsync(pollUrl, new FormUrlEncodedContent(parms.ToDictionary()))).Content.ReadAsByteArrayAsync();\n                web = Encoding.UTF8.GetString(responseArray);\n                string code = JsonDocument.Parse(web).RootElement.GetProperty(\"code\").ToString();\n                if (code == \"86038\")\n                {\n                    LogColor(\"二维码已过期, 请重新执行登录指令.\");\n                    break;\n                }\n                else if (code == \"86039\") //等待扫码\n                {\n                    continue;\n                }\n                else\n                {\n                    string cc = JsonDocument.Parse(web).RootElement.GetProperty(\"data\").GetProperty(\"access_token\").ToString();\n                    Log(\"登录成功: AccessToken=\" + cc);\n                    //导出cookie\n                    await File.WriteAllTextAsync(Path.Combine(Program.APP_DIR, \"BBDownTV.data\"), \"access_token=\" + cc);\n                    File.Delete(\"qrcode.png\");\n                    break;\n                }\n            }\n        }\n        catch (Exception e) { LogError(e.Message); }\n    }\n}"
  },
  {
    "path": "BBDown/BBDownMuxer.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.Diagnostics;\nusing System.Linq;\nusing System.Text;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.BBDownUtil;\nusing static BBDown.Core.Util.SubUtil;\nusing static BBDown.Core.Logger;\nusing System.IO;\nusing BBDown.Core;\nusing System.Runtime.InteropServices;\n\nnamespace BBDown;\n\nstatic partial class BBDownMuxer\n{\n    public static string FFMPEG = \"ffmpeg\";\n    public static string MP4BOX = \"mp4box\";\n\n    private static int RunExe(string app, string parms, bool customBin = false)\n    {\n        int code = 0;\n        Process p = new();\n        p.StartInfo.FileName = app;\n        p.StartInfo.Arguments = parms;\n        p.StartInfo.UseShellExecute = false;\n        p.StartInfo.RedirectStandardError = true;\n        p.StartInfo.CreateNoWindow = false;\n        p.ErrorDataReceived += delegate (object sendProcess, DataReceivedEventArgs output) {\n            if (!string.IsNullOrWhiteSpace(output.Data))\n                Log(output.Data);\n        };\n        p.StartInfo.StandardErrorEncoding = Encoding.UTF8;\n        p.Start();\n        p.BeginErrorReadLine();\n        p.WaitForExit();\n        p.Close();\n        p.Dispose();\n        return code;\n    }\n\n    private static string EscapeString(string str)\n    {\n        return string.IsNullOrEmpty(str) ? str : str.Replace(\"\\\"\", \"'\").Replace(\"\\\\\", \"\\\\\\\\\");\n    }\n\n    private static int MuxByMp4box(string url, string videoPath, string audioPath, string outPath, string desc, string title, string author, string episodeId, string pic, string lang, List<Subtitle>? subs, bool audioOnly, bool videoOnly, List<ViewPoint>? points)\n    {\n        StringBuilder inputArg = new();\n        StringBuilder metaArg = new();\n        int nowId = 0;\n        inputArg.Append(\" -inter 500 -noprog \");\n        if (!string.IsNullOrEmpty(videoPath))\n        {\n            inputArg.Append($\" -add \\\"{videoPath}#trackID={(audioOnly && audioPath == \"\" ? \"2\" : \"1\")}:name=\\\" \");\n            nowId++;\n        }\n        if (!string.IsNullOrEmpty(audioPath))\n        {\n            inputArg.Append($\" -add \\\"{audioPath}:lang={(lang == \"\" ? \"und\" : lang)}\\\" \");\n            nowId++;\n        }\n        if (points != null && points.Any())\n        {\n            var meta = GetMp4boxMetaString(points);\n            var metaFile = Path.Combine(Path.GetDirectoryName(string.IsNullOrEmpty(videoPath) ? audioPath : videoPath)!, \"chapters\");\n            File.WriteAllText(metaFile, meta);\n            inputArg.Append($\" -chap  \\\"{metaFile}\\\"  \");\n        }\n        if (!string.IsNullOrEmpty(pic))\n            metaArg.Append($\":cover=\\\"{pic}\\\"\");\n        if (!string.IsNullOrEmpty(episodeId))\n            metaArg.Append($\":album=\\\"{title}\\\":title=\\\"{episodeId}\\\"\");\n        else\n            metaArg.Append($\":title=\\\"{title}\\\"\");\n        metaArg.Append($\":sdesc=\\\"{desc}\\\"\");\n        metaArg.Append($\":comment=\\\"{url}\\\"\");\n        metaArg.Append($\":artist=\\\"{author}\\\"\");\n\n        if (subs != null)\n        {\n            for (int i = 0; i < subs.Count; i++)\n            {\n                if (File.Exists(subs[i].path) && File.ReadAllText(subs[i].path!) != \"\")\n                {\n                    nowId++;\n                    inputArg.Append($\" -add \\\"{subs[i].path}#trackID=1:name=:hdlr=sbtl:lang={GetSubtitleCode(subs[i].lan).Item1}\\\" \");\n                    inputArg.Append($\" -udta {nowId}:type=name:str=\\\"{GetSubtitleCode(subs[i].lan).Item2}\\\" \");\n                }\n            }\n        }\n\n        //----分析完毕\n        var arguments = (Config.DEBUG_LOG ? \" -v \" : \"\") + inputArg + (metaArg.ToString() == \"\" ? \"\" : \" -itags tool=\" + metaArg) + $\" -new -- \\\"{outPath}\\\"\";\n        LogDebug(\"mp4box命令: {0}\", arguments);\n        return RunExe(MP4BOX, arguments, MP4BOX != \"mp4box\");\n    }\n\n    public static int MuxAV(bool useMp4box, string bvid, string videoPath, string audioPath, List<AudioMaterial> audioMaterial, string outPath, string desc = \"\", string title = \"\", string author = \"\", string episodeId = \"\", string pic = \"\", string lang = \"\", List<Subtitle>? subs = null, bool audioOnly = false, bool videoOnly = false, List<ViewPoint>? points = null, long pubTime = 0, bool simplyMux = false, bool isHevc = false)\n    {\n        if (audioOnly && audioPath != \"\")\n            videoPath = \"\";\n        if (videoOnly)\n            audioPath = \"\";\n        desc = EscapeString(desc);\n        title = EscapeString(title);\n        episodeId = EscapeString(episodeId);\n        var url = $\"https://www.bilibili.com/video/{bvid}/\";\n\n        if (useMp4box)\n        {\n            return MuxByMp4box(url, videoPath, audioPath, outPath, desc, title, author, episodeId, pic, lang, subs, audioOnly, videoOnly, points);\n        }\n\n        if (outPath.Contains('/') && ! Directory.Exists(Path.GetDirectoryName(outPath)))\n            Directory.CreateDirectory(Path.GetDirectoryName(outPath)!);\n        //----分析并生成-i参数\n        StringBuilder inputArg = new();\n        StringBuilder metaArg = new();\n        byte inputCount = 0;\n        foreach (string path in new[] { videoPath, audioPath })\n        {\n            if (!string.IsNullOrEmpty(path))\n            {\n                inputCount++;\n                inputArg.Append($\"-i \\\"{path}\\\" \");\n            }\n        }\n\n        if (audioMaterial.Any())\n        {\n            byte audioCount = 0;\n            metaArg.Append(\"-metadata:s:a:0 title=\\\"原音频\\\" \");\n            foreach (var audio in audioMaterial)\n            {\n                inputCount++;\n                audioCount++;\n                inputArg.Append($\"-i \\\"{audio.path}\\\" \");\n                if (!string.IsNullOrWhiteSpace(audio.title)) metaArg.Append($\"-metadata:s:a:{audioCount} title=\\\"{audio.title}\\\" \");\n                if (!string.IsNullOrWhiteSpace(audio.personName)) metaArg.Append($\"-metadata:s:a:{audioCount} artist=\\\"{audio.personName}\\\" \");\n            }\n        }\n\n        if (!string.IsNullOrEmpty(pic))\n        {\n            inputCount++;\n            inputArg.Append($\"-i \\\"{pic}\\\" \");\n        }\n\n        if (subs != null)\n        {\n            for (int i = 0; i < subs.Count; i++)\n            {\n                if(File.Exists(subs[i].path) && File.ReadAllText(subs[i].path!) != \"\")\n                {\n                    inputCount++;\n                    inputArg.Append($\"-i \\\"{subs[i].path}\\\" \");\n                    metaArg.Append($\"-metadata:s:s:{i} title=\\\"{GetSubtitleCode(subs[i].lan).Item2}\\\" -metadata:s:s:{i} language={GetSubtitleCode(subs[i].lan).Item1} \");\n                }\n            }\n        }\n\n        if (!string.IsNullOrEmpty(pic))\n            metaArg.Append($\"-disposition:v:{(audioOnly ? \"0\" : \"1\")} attached_pic \");\n        // var inputCount = InputRegex().Matches(inputArg.ToString()).Count;\n\n        if (points != null && points.Any())\n        {\n            var meta = GetFFmpegMetaString(points);\n            var metaFile = Path.Combine(Path.GetDirectoryName(string.IsNullOrEmpty(videoPath) ? audioPath : videoPath)!, \"chapters\");\n            File.WriteAllText(metaFile, meta);\n            inputArg.Append($\"-i \\\"{metaFile}\\\" -map_chapters {inputCount} \");\n        }\n\n        inputArg.Append(string.Concat(Enumerable.Range(0, inputCount).Select(i => $\"-map {i} \")));\n\n        //----分析完毕\n        StringBuilder argsBuilder = new StringBuilder();\n        argsBuilder.Append($\"-loglevel {(Config.DEBUG_LOG ? \"verbose\" : \"warning\")} -y \");\n        argsBuilder.Append(inputArg);\n        argsBuilder.Append(metaArg);\n        if (!simplyMux) {\n            argsBuilder.Append($\"-metadata title=\\\"{(episodeId == \"\" ? title : episodeId)}\\\" \");\n            argsBuilder.Append($\"-metadata comment=\\\"{url}\\\" \");\n            if (lang != \"\") argsBuilder.Append($\"-metadata:s:a:0 language={lang} \");\n            if (!string.IsNullOrWhiteSpace(desc)) argsBuilder.Append($\"-metadata description=\\\"{desc}\\\" \");\n            if (!string.IsNullOrEmpty(author)) argsBuilder.Append($\"-metadata artist=\\\"{author}\\\" \");\n            if (episodeId != \"\") argsBuilder.Append($\"-metadata album=\\\"{title}\\\" \");\n            if (pubTime != 0) argsBuilder.Append($\"-metadata creation_time=\\\"{(DateTimeOffset.FromUnixTimeSeconds(pubTime).ToString(\"yyyy-MM-ddTHH:mm:ss.ffffffZ\"))}\\\" \");\n        }\n        argsBuilder.Append(\"-c:v copy -c:a copy \");\n        if (audioOnly && audioPath == \"\") argsBuilder.Append(\"-vn \");\n        if (subs != null) argsBuilder.Append(\"-c:s mov_text \");\n        // fix macOS hev1, see https://discussions.apple.com/thread/253081863?sortBy=rank\n        if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && isHevc) argsBuilder.Append(\"-tag:v:0 hvc1 \");\n        argsBuilder.Append($\"-movflags faststart -strict unofficial -strict -2 -f mp4 -- \\\"{outPath}\\\"\");\n\n        string arguments = argsBuilder.ToString();\n\n        LogDebug(\"ffmpeg命令: {0}\", arguments);\n        return RunExe(FFMPEG, arguments, FFMPEG != \"ffmpeg\");\n    }\n\n    public static void MergeFLV(string[] files, string outPath)\n    {\n        if (files.Length == 1)\n        {\n            File.Move(files[0], outPath);\n        }\n        else\n        {\n            foreach (var file in files)\n            {\n                var tmpFile = Path.Combine(Path.GetDirectoryName(file)!, Path.GetFileNameWithoutExtension(file) + \".ts\");\n                var arguments = $\"-loglevel warning -y -i \\\"{file}\\\" -map 0 -c copy -f mpegts -bsf:v h264_mp4toannexb \\\"{tmpFile}\\\"\";\n                LogDebug(\"ffmpeg命令: {0}\", arguments);\n                RunExe(\"ffmpeg\", arguments);\n                File.Delete(file);\n            }\n            var f = GetFiles(Path.GetDirectoryName(files[0])!, \".ts\");\n            CombineMultipleFilesIntoSingleFile(f, outPath);\n            foreach (var s in f) File.Delete(s);\n        }\n    }\n}"
  },
  {
    "path": "BBDown/BBDownUtil.cs",
    "content": "using System;\nusing System.Collections.Generic;\nusing System.Collections.Specialized;\nusing System.Diagnostics;\nusing System.IO;\nusing System.Linq;\nusing System.Security.Cryptography;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing System.Threading.Tasks;\nusing System.Web;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Logger;\nusing static BBDown.Core.Util.HTTPUtil;\n\nnamespace BBDown;\n\nstatic partial class BBDownUtil\n{\n    public static async Task CheckUpdateAsync()\n    {\n        try\n        {\n            var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!;\n            string nowVer = $\"{ver.Major}.{ver.Minor}.{ver.Build}\";\n            string redirectUrl = await GetWebLocationAsync(\"https://github.com/nilaoda/BBDown/releases/latest\");\n            string latestVer = redirectUrl.Replace(\"https://github.com/nilaoda/BBDown/releases/tag/\", \"\");\n            if (nowVer != latestVer && !latestVer.StartsWith(\"https\"))\n            {\n                Console.Title = $\"发现新版本：{latestVer}\";\n                LogColor($\"发现新版本：{latestVer}\");\n            }\n        }\n        catch (Exception)\n        {\n            ;\n        }\n    }\n\n    public static async Task<string> GetAvIdAsync(string input)\n    {\n        var avid = input;\n        if (input.StartsWith(\"http\"))\n        {\n            if (input.Contains(\"b23.tv\"))\n            {\n                string tmp = await GetWebLocationAsync(input);\n                if (tmp == input) throw new Exception(\"无限重定向\");\n                input = tmp;\n            }\n            if (input.Contains(\"video/av\"))\n            {\n                avid = AvRegex().Match(input).Groups[1].Value;\n            }\n            else if (input.ToLower().Contains(\"video/bv\"))\n            {\n                avid = GetAidByBV(BVRegex().Match(input).Groups[1].Value);\n            }\n            else if (input.Contains(\"/cheese/\"))\n            {\n                string epId = \"\";\n                if (input.Contains(\"/ep\"))\n                {\n                    epId = EpRegex().Match(input).Groups[1].Value;\n                }\n                else if (input.Contains(\"/ss\"))\n                {\n                    epId = await GetEpidBySSIdAsync(SsRegex().Match(input).Groups[1].Value);\n                }\n                avid = $\"cheese:{epId}\";\n            }\n            else if (input.Contains(\"/ep\"))\n            {\n                string epId = EpRegex().Match(input).Groups[1].Value;\n                avid = $\"ep:{epId}\";\n            }\n            else if (input.Contains(\"/ss\"))\n            {\n                string epId = await GetEpIdByBangumiSSIdAsync(SsRegex().Match(input).Groups[1].Value);\n                avid = $\"ep:{epId}\";\n            }\n            else if (input.Contains(\"/medialist/\") && input.Contains(\"business_id=\") && input.Contains(\"business=space_collection\")) // 列表类型是合集\n            {\n                string bizId = GetQueryString(\"business_id\", input);\n                avid = $\"listBizId:{bizId}\";\n            }\n            else if (input.Contains(\"/medialist/\") && input.Contains(\"business_id=\") && input.Contains(\"business=space_series\")) // 列表类型是系列\n            {\n                string bizId = GetQueryString(\"business_id\", input);\n                avid = $\"seriesBizId:{bizId}\";\n            }\n            else if (input.Contains(\"/channel/collectiondetail?sid=\"))\n            {\n                string bizId = GetQueryString(\"sid\", input);\n                avid = $\"listBizId:{bizId}\";\n            }\n            else if (input.Contains(\"/channel/seriesdetail?sid=\"))\n            {\n                string bizId = GetQueryString(\"sid\", input);\n                avid = $\"seriesBizId:{bizId}\";\n            }\n            // 新版个人空间合集/系列链接兼容：\n            // 例如：\n            //   合集: https://space.bilibili.com/392959666/lists/1560264?type=season\n            //   系列: https://space.bilibili.com/392959666/lists/1560264?type=series\n            else if (input.Contains(\"/space.bilibili.com/\") && input.Contains(\"/lists/\"))\n            {\n                var type = GetQueryString(\"type\", input).ToLower();\n                // path 最后一个 / 后到 ? 前即为 sid\n                var path = input.Split('?', '#')[0];\n                var sidPart = path[(path.LastIndexOf('/') + 1)..];\n\n                if (type == \"season\")\n                {\n                    avid = $\"listBizId:{sidPart}\";\n                }\n                else if (type == \"series\")\n                {\n                    avid = $\"seriesBizId:{sidPart}\";\n                }\n                else\n                {\n                    // 未知类型按合集处理，至少不会识别失败\n                    avid = $\"listBizId:{sidPart}\";\n                }\n            }\n            else if (input.Contains(\"/space.bilibili.com/\") && input.Contains(\"/favlist\"))\n            {\n                string mid = UidRegex().Match(input).Groups[1].Value;\n                string fid = GetQueryString(\"fid\", input);\n                avid = $\"favId:{fid}:{mid}\";\n            }\n            else if (input.Contains(\"/space.bilibili.com/\"))\n            {\n                string mid = UidRegex().Match(input).Groups[1].Value;\n                avid = $\"mid:{mid}\";\n            }\n            else if (input.Contains(\"ep_id=\"))\n            {\n                string epId = GetQueryString(\"ep_id\", input);\n                avid = $\"ep:{epId}\";\n            }\n            else if (GlobalEpRegex().Match(input).Success)\n            {\n                string epId = GlobalEpRegex().Match(input).Groups[1].Value;\n                avid = $\"ep:{epId}\";\n            }\n            else if (BangumiMdRegex().Match(input).Success)\n            {\n                string mdId = BangumiMdRegex().Match(input).Groups[1].Value;\n                string epId = await GetEpIdByMDAsync(mdId);\n                avid = $\"ep:{epId}\";\n            }\n            else\n            {\n                string web = await GetWebSourceAsync(input);\n                Regex regex = StateRegex();\n                string json = regex.Match(web).Groups[1].Value;\n                using var jDoc = JsonDocument.Parse(json);\n                string epId = jDoc.RootElement.GetProperty(\"epList\").EnumerateArray().First().GetProperty(\"id\").ToString();\n                avid = $\"ep:{epId}\";\n            }\n        }\n        else if (input.ToLower().StartsWith(\"bv\"))\n        {\n            avid = GetAidByBV(input[3..]);\n        }\n        else if (input.ToLower().StartsWith(\"av\")) // av\n        {\n            avid = input.ToLower()[2..];\n        }\n        else if (input.StartsWith(\"cheese/\")) // ^cheese/(ep|ss)\\d+ 格式\n        {\n            string epId = \"\";\n            if (input.Contains(\"/ep\"))\n            {\n                epId = EpRegex().Match(input).Groups[1].Value;\n            }\n            else if (input.Contains(\"/ss\"))\n            {\n                epId = await GetEpidBySSIdAsync(SsRegex().Match(input).Groups[1].Value);\n            }\n            avid = $\"cheese:{epId}\";\n        }\n        else if (input.StartsWith(\"ep\"))\n        {\n            string epId = input[2..];\n            avid = $\"ep:{epId}\";\n        }\n        else if (input.StartsWith(\"ss\"))\n        {\n            string epId = await GetEpIdByBangumiSSIdAsync(input[2..]);\n            avid = $\"ep:{epId}\";\n        }\n        else if (input.StartsWith(\"md\"))\n        {\n            string mdId = MdRegex().Match(input).Groups[1].Value;\n            string epId = await GetEpIdByMDAsync(mdId);\n            avid = $\"ep:{epId}\";\n        }\n        else\n        {\n            throw new Exception(\"输入有误\");\n        }\n        return await FixAvidAsync(avid);\n    }\n\n    public static string FormatFileSize(double fileSize)\n    {\n        return fileSize switch\n        {\n            < 0 => throw new ArgumentOutOfRangeException(nameof(fileSize)),\n            >= 1024 * 1024 * 1024 => $\"{fileSize / (1024 * 1024 * 1024):########0.00} GB\",\n            >= 1024 * 1024 => $\"{fileSize / (1024 * 1024):####0.00} MB\",\n            >= 1024 => $\"{fileSize / 1024:####0.00} KB\",\n            _ => $\"{fileSize} bytes\"\n        };\n    }\n\n    public static string FormatTime(int time, bool absolute = false)\n    {\n        var ts = TimeSpan.FromSeconds(time);\n        var totalHours = (int)ts.TotalHours;\n        var minutes = ts.Minutes;\n        var seconds = ts.Seconds;\n\n        if (absolute)\n        {\n            return $\"{totalHours:D2}:{minutes:D2}:{seconds:D2}\";\n        }\n\n        return totalHours == 0 ? $\"{minutes:D2}m{seconds:D2}s\" : $\"{totalHours}h{minutes:D2}m{seconds:D2}s\";\n    }\n\n    /// <summary>\n    /// 通过avid检测是否为版权内容, 如果是的话返回ep:xx格式\n    /// </summary>\n    /// <param name=\"avid\"></param>\n    /// <returns></returns>\n    private static async Task<string> FixAvidAsync(string avid)\n    {\n        if (!avid.All(char.IsDigit))\n            return avid;\n        string api = $\"https://www.bilibili.com/video/av{avid}/\";\n        string location = await GetWebLocationAsync(api);\n        return location.Contains(\"/ep\") ? $\"ep:{EpRegex().Match(location).Groups[1].Value}\" : avid;\n    }\n\n    private static string GetAidByBV(string bv)\n    {\n        // 能在本地就在本地\n        return Core.Util.BilibiliBvConverter.Decode(bv).ToString();\n    }\n\n    private static async Task<string> GetEpidBySSIdAsync(string ssid)\n    {\n        string api = $\"https://api.bilibili.com/pugv/view/web/season?season_id={ssid}\";\n        string json = await GetWebSourceAsync(api);\n        using var jDoc = JsonDocument.Parse(json);\n        string epId = jDoc.RootElement.GetProperty(\"data\").GetProperty(\"episodes\").EnumerateArray().First().GetProperty(\"id\").ToString();\n        return epId;\n    }\n\n    private static async Task<string> GetEpIdByBangumiSSIdAsync(string ssId)\n    {\n        string api = $\"https://{Core.Config.EPHOST}/pgc/view/web/season?season_id={ssId}\";\n        string json = await GetWebSourceAsync(api);\n        using var jDoc = JsonDocument.Parse(json);\n        string epId = jDoc.RootElement.GetProperty(\"result\").GetProperty(\"episodes\").EnumerateArray().First().GetProperty(\"id\").ToString();\n        return epId;\n    }\n\n    private static async Task<string> GetEpIdByMDAsync(string mdId)\n    {\n        string api = $\"https://api.bilibili.com/pgc/review/user?media_id={mdId}\";\n        string json = await GetWebSourceAsync(api);\n        using var jDoc = JsonDocument.Parse(json);\n        string epId = jDoc.RootElement.GetProperty(\"result\").GetProperty(\"media\").GetProperty(\"new_ep\").GetProperty(\"id\").ToString();\n        return epId;\n    }\n\n    /// <summary>\n    /// 输入一堆已存在的文件, 合并到新文件\n    /// </summary>\n    /// <param name=\"files\"></param>\n    /// <param name=\"outputFilePath\"></param>\n    public static void CombineMultipleFilesIntoSingleFile(string[] files, string outputFilePath)\n    {\n        if (!files.Any()) return;\n        if (files.Length == 1)\n        {\n            FileInfo fi = new(files[0]);\n            fi.MoveTo(outputFilePath, true);\n            return;\n        }\n\n        if (!Directory.Exists(Path.GetDirectoryName(outputFilePath)))\n            Directory.CreateDirectory(Path.GetDirectoryName(outputFilePath)!);\n\n        string[] inputFilePaths = files;\n        using var outputStream = File.Create(outputFilePath);\n        foreach (var inputFilePath in inputFilePaths)\n        {\n            if (inputFilePath == \"\")\n                continue;\n            using var inputStream = File.OpenRead(inputFilePath);\n            // Buffer size can be passed as the second argument.\n            inputStream.CopyTo(outputStream);\n            //Console.WriteLine(\"The file {0} has been processed.\", inputFilePath);\n        }\n        //Global.ExplorerFile(outputFilePath);\n    }\n\n    /// <summary>\n    /// 寻找指定目录下指定后缀的文件的详细路径 如\".txt\"\n    /// </summary>\n    /// <param name=\"dir\"></param>\n    /// <param name=\"ext\"></param>\n    /// <returns></returns>\n    public static string[] GetFiles(string dir, string ext)\n    {\n        List<string> al = [];\n        StringBuilder sb = new();\n        DirectoryInfo d = new(dir);\n        foreach (FileInfo fi in d.GetFiles())\n        {\n            if (fi.Extension.ToUpper() == ext.ToUpper())\n            {\n                al.Add(fi.FullName);\n            }\n        }\n        string[] res = al.ToArray();\n        Array.Sort(res); //排序\n        return res;\n    }\n\n    private static readonly char[] InvalidChars = \"34,60,62,124,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,58,42,63,92,47\"\n        .Split(',').Select(s => (char)byte.Parse(s)).ToArray();\n\n    public static string GetValidFileName(string input, string re = \"_\", bool filterSlash = false)\n    {\n        string title = input;\n\n        foreach (char invalidChar in InvalidChars)\n        {\n            title = title.Replace(invalidChar.ToString(), re);\n        }\n        if (filterSlash)\n        {\n            title = title.Replace(\"/\", re);\n            title = title.Replace(\"\\\\\", re);\n        }\n        return title;\n    }\n\n\n    /// <summary>\n    /// 获取url字符串参数, 返回参数值字符串\n    /// </summary>\n    /// <param name=\"name\">参数名称</param>\n    /// <param name=\"url\">url字符串</param>\n    /// <returns></returns>\n    public static string GetQueryString(string name, string url)\n    {\n        Regex re = QueryRegex();\n        MatchCollection mc = re.Matches(url);\n        foreach (Match m in mc.Cast<Match>())\n        {\n            if (m.Result(\"$2\").Equals(name))\n            {\n                return m.Result(\"$3\");\n            }\n        }\n        return \"\";\n    }\n\n    //https://s1.hdslb.com/bfs/static/player/main/video.9efc0c61.js\n    public static string GetSession(string buvid3)\n    {\n        //这个参数可以没有 所以此处就不写具体实现了\n        throw new NotImplementedException();\n    }\n\n    public static string GetSign(string parms)\n    {\n        string toEncode = parms + \"59b43e04ad6965f34319062b478f83dd\";\n        return string.Concat(MD5.HashData(Encoding.UTF8.GetBytes(toEncode)).Select(i => i.ToString(\"x2\")));\n    }\n\n    public static string GetTimeStamp(bool bflag)\n    {\n        DateTimeOffset ts = DateTimeOffset.Now;\n        return (bflag ? ts.ToUnixTimeSeconds() : ts.ToUnixTimeMilliseconds()).ToString();\n    }\n\n    //https://stackoverflow.com/questions/1344221/how-can-i-generate-random-alphanumeric-strings\n    private static readonly Random random = new();\n    public static string GetRandomString(int length)\n    {\n        const string chars = \"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_0123456789\";\n        return new string(Enumerable.Repeat(chars, length)\n            .Select(s => s[random.Next(s.Length)]).ToArray());\n    }\n\n    //https://stackoverflow.com/a/45088333\n    public static string ToQueryString(NameValueCollection nameValueCollection)\n    {\n        NameValueCollection httpValueCollection = HttpUtility.ParseQueryString(string.Empty);\n        httpValueCollection.Add(nameValueCollection);\n        return httpValueCollection.ToString()!;\n    }\n\n    public static Dictionary<string, string> ToDictionary(this NameValueCollection nameValueCollection)\n    {\n        var dict = new Dictionary<string, string>();\n        foreach (var key in nameValueCollection.AllKeys)\n        {\n            dict[key!] = nameValueCollection[key]!;\n        }\n        return dict;\n    }\n\n    public static NameValueCollection GetTVLoginParms()\n    {\n        NameValueCollection sb = new();\n        DateTime now = DateTime.Now;\n        string deviceId = GetRandomString(20);\n        string buvid = GetRandomString(37);\n        string fingerprint = $\"{now:yyyyMMddHHmmssfff}{GetRandomString(45)}\";\n        sb.Add(\"appkey\", \"4409e2ce8ffd12b8\");\n        sb.Add(\"auth_code\", \"\");\n        sb.Add(\"bili_local_id\", deviceId);\n        sb.Add(\"build\", \"102801\");\n        sb.Add(\"buvid\", buvid);\n        sb.Add(\"channel\", \"master\");\n        sb.Add(\"device\", \"OnePlus\");\n        sb.Add($\"device_id\", deviceId);\n        sb.Add(\"device_name\", \"OnePlus7TPro\");\n        sb.Add(\"device_platform\", \"Android10OnePlusHD1910\");\n        sb.Add($\"fingerprint\", fingerprint);\n        sb.Add($\"guid\", buvid);\n        sb.Add($\"local_fingerprint\", fingerprint);\n        sb.Add($\"local_id\", buvid);\n        sb.Add(\"mobi_app\", \"android_tv_yst\");\n        sb.Add(\"networkstate\", \"wifi\");\n        sb.Add(\"platform\", \"android\");\n        sb.Add(\"sys_ver\", \"29\");\n        sb.Add($\"ts\", GetTimeStamp(true));\n        sb.Add($\"sign\", GetSign(ToQueryString(sb)));\n\n        return sb;\n    }\n\n    /// <summary>\n    /// 检测ffmpeg是否识别杜比视界\n    /// </summary>\n    /// <returns></returns>\n    public static bool CheckFFmpegDOVI()\n    {\n        try\n        {\n            var process = new Process\n            {\n                StartInfo = new ProcessStartInfo\n                {\n                    FileName = BBDownMuxer.FFMPEG,\n                    Arguments = \"-version\",\n                    UseShellExecute = false,\n                    RedirectStandardError = true,\n                    RedirectStandardOutput = true,\n                    CreateNoWindow = true\n                }\n            };\n            process.Start();\n            string info = process.StandardOutput.ReadToEnd() + Environment.NewLine + process.StandardError.ReadToEnd();\n            process.WaitForExit();\n            var match = LibavutilRegex().Match(info);\n            if (!match.Success) return false;\n            if((Convert.ToInt32(match.Groups[1].Value)==57 && Convert.ToInt32(match.Groups[1].Value) >= 17)\n               || Convert.ToInt32(match.Groups[1].Value) > 57)\n            {\n                return true;\n            }\n        }\n        catch (Exception)\n        {\n        }\n        return false;\n    }\n\n    /// <summary>\n    /// 获取章节信息\n    /// </summary>\n    /// <param name=\"cid\"></param>\n    /// <param name=\"aid\"></param>\n    /// <returns></returns>\n    public static async Task<List<ViewPoint>> FetchPointsAsync(string cid, string aid)\n    {\n        var ponints = new List<ViewPoint>();\n        try\n        {\n            string api = $\"https://api.bilibili.com/x/player/wbi/v2?cid={cid}&aid={aid}\";\n            string json = await GetWebSourceAsync(api);\n            using var infoJson = JsonDocument.Parse(json);\n            if (infoJson.RootElement.GetProperty(\"data\").TryGetProperty(\"view_points\", out JsonElement vPoint))\n            {\n                foreach (var point in vPoint.EnumerateArray())\n                {\n                    ponints.Add(new ViewPoint()\n                    {\n                        title = point.GetProperty(\"content\").GetString()!,\n                        start = int.Parse(point.GetProperty(\"from\").ToString()),\n                        end = int.Parse(point.GetProperty(\"to\").ToString())\n                    });\n                }\n            }\n        }\n        catch (Exception) { }\n        return ponints;\n    }\n\n    /// <summary>\n    /// 生成metadata文件, 用于ffmpeg混流章节信息\n    /// </summary>\n    /// <param name=\"points\"></param>\n    /// <returns></returns>\n    public static string GetFFmpegMetaString(List<ViewPoint> points)\n    {\n        StringBuilder sb = new();\n        sb.AppendLine(\";FFMETADATA\");\n        foreach (var p in points)\n        {\n            var time = 1000; //固定 1000\n            sb.AppendLine(\"[CHAPTER]\");\n            sb.AppendLine($\"TIMEBASE=1/{time}\");\n            sb.AppendLine($\"START={p.start * time}\");\n            sb.AppendLine($\"END={p.end * time}\");\n            sb.AppendLine($\"title={p.title}\");\n            sb.AppendLine();\n        }\n        return sb.ToString();\n    }\n\n    /// <summary>\n    /// 生成metadata文件, 用于mp4box混流章节信息\n    /// </summary>\n    /// <param name=\"points\"></param>\n    /// <returns></returns>\n    public static string GetMp4boxMetaString(List<ViewPoint> points)\n    {\n        StringBuilder sb = new();\n        foreach (var p in points)\n        {\n            sb.AppendLine($\"{FormatTime(p.start, true)} {p.title}\");\n        }\n        return sb.ToString();\n    }\n\n    public static string? FindExecutable(string name)\n    {\n        var fileExt = OperatingSystem.IsWindows() ? \".exe\" : \"\";\n        var searchPath = new [] { Environment.CurrentDirectory, Program.APP_DIR };\n        var envPath = Environment.GetEnvironmentVariable(\"PATH\")?.Split(Path.PathSeparator) ?? [];\n        return searchPath.Concat(envPath).Select(p => Path.Combine(p, name + fileExt)).FirstOrDefault(File.Exists);\n    }\n\n    public static string RSubString(string sub)\n    {\n        sub = sub[(sub.LastIndexOf('/') + 1)..];\n        return sub[..sub.LastIndexOf('.')];\n    }\n\n    private static string GetMixinKey(string orig)\n    {\n        byte[] mixinKeyEncTab = \n        [\n            46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,\n            27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13\n        ];\n\n        var tmp = new StringBuilder(32);\n        foreach (var index in mixinKeyEncTab)\n        {\n            tmp.Append(orig[index]);\n        }\n        return tmp.ToString();\n    }\n\n    public static async Task<bool> CheckLogin(string cookie)\n    {\n        try\n        {\n            var api = \"https://api.bilibili.com/x/web-interface/nav\";\n            var source = await GetWebSourceAsync(api);\n            var json = JsonDocument.Parse(source).RootElement;\n            var is_login = json.GetProperty(\"data\").GetProperty(\"isLogin\").GetBoolean();\n            var wbi_img = json.GetProperty(\"data\").GetProperty(\"wbi_img\");\n            Core.Config.WBI = GetMixinKey(RSubString(wbi_img.GetProperty(\"img_url\").GetString()) + RSubString(wbi_img.GetProperty(\"sub_url\").GetString()));\n            LogDebug(\"wbi: {0}\", Core.Config.WBI);\n            return is_login;\n        }\n        catch (Exception)\n        {\n            return false;\n        }\n    }\n\n    [GeneratedRegex(\"av(\\\\d+)\")]\n    private static partial Regex AvRegex();\n    [GeneratedRegex(\"[Bb][Vv]1(\\\\w+)\")]\n    private static partial Regex BVRegex();\n    [GeneratedRegex(\"/ep(\\\\d+)\")]\n    private static partial Regex EpRegex();\n    [GeneratedRegex(\"/ss(\\\\d+)\")]\n    private static partial Regex SsRegex();\n    [GeneratedRegex(@\"space\\.bilibili\\.com/(\\d+)\")]\n    private static partial Regex UidRegex();\n    [GeneratedRegex(@\"\\.bilibili\\.tv\\/\\w+\\/play\\/\\d+\\/(\\d+)\")]\n    private static partial Regex GlobalEpRegex();\n    [GeneratedRegex(\"bangumi/media/(md\\\\d+)\")]\n    private static partial Regex BangumiMdRegex();\n    [GeneratedRegex(@\"window.__INITIAL_STATE__=([\\s\\S].*?);\\(function\\(\\)\")]\n    private static partial Regex StateRegex();\n    [GeneratedRegex(\"md(\\\\d+)\")]\n    private static partial Regex MdRegex();\n    [GeneratedRegex(\"(^|&)?(\\\\w+)=([^&]+)(&|$)?\", RegexOptions.Compiled)]\n    private static partial Regex QueryRegex();\n    [GeneratedRegex(\"libavutil\\\\s+(\\\\d+)\\\\. +(\\\\d+)\\\\.\")]\n    private static partial Regex LibavutilRegex();\n}"
  },
  {
    "path": "BBDown/CommandLineInvoker.cs",
    "content": "﻿using System;\nusing System.CommandLine;\nusing System.CommandLine.Binding;\nusing System.CommandLine.Parsing;\nusing System.Threading.Tasks;\n\nnamespace BBDown;\n\ninternal static class CommandLineInvoker\n{\n    private static readonly Argument<string> Url = new(\"url\", description: \"视频地址 或 av|bv|BV|ep|ss\");\n    private static readonly Option<bool> UseTvApi = new([\"--use-tv-api\", \"-tv\"], \"使用TV端解析模式\");\n    private static readonly Option<bool> UseAppApi = new([\"--use-app-api\", \"-app\"], \"使用APP端解析模式\");\n    private static readonly Option<bool> UseIntlApi = new([\"--use-intl-api\", \"-intl\"], \"使用国际版(东南亚视频)解析模式\");\n    private static readonly Option<bool> UseMP4box = new([\"--use-mp4box\"], \"使用MP4Box来混流\");\n    private static readonly Option<string> EncodingPriority = new([\"--encoding-priority\", \"-e\"], \"视频及音频编码的选择优先级, 用逗号分割 例: \\\"hevc,av1,avc,flac,eac3,m4a\\\"\");\n    private static readonly Option<string> DfnPriority = new([\"--dfn-priority\", \"-q\"], \"画质优先级,用逗号分隔 例: \\\"8K 超高清, 1080P 高码率, HDR 真彩, 杜比视界\\\"\");\n    private static readonly Option<bool> OnlyShowInfo = new([\"--only-show-info\", \"-info\"], \"仅解析而不进行下载\");\n    private static readonly Option<bool> HideStreams = new([\"--hide-streams\", \"-hs\"], \"不要显示所有可用音视频流\");\n    private static readonly Option<bool> Interactive = new([\"--interactive\", \"-ia\"], \"交互式选择清晰度\");\n    private static readonly Option<bool> ShowAll = new([\"--show-all\"], \"展示所有分P标题\");\n    private static readonly Option<bool> UseAria2c = new([\"--use-aria2c\", \"-aria2\"], \"调用aria2c进行下载(你需要自行准备好二进制可执行文件)\");\n    private static readonly Option<string> Aria2cArgs = new([\"--aria2c-args\"], \"调用aria2c的附加参数(默认参数包含\\\"-x16 -s16 -j16 -k 5M\\\", 使用时注意字符串转义)\");\n    private static readonly Option<bool> MultiThread = new([\"--multi-thread\", \"-mt\"], \"使用多线程下载(默认开启)\");\n    private static readonly Option<string> SelectPage = new([\"--select-page\", \"-p\"], \"选择指定分p或分p范围: (-p 8 或 -p 1,2 或 -p 3-5 或 -p ALL 或 -p LAST 或 -p 3,5,LATEST)\");\n    private static readonly Option<bool> SimplyMux = new([\"--simply-mux\"], \"精简混流，不增加描述、作者等信息\");\n    private static readonly Option<bool> AudioOnly = new([\"--audio-only\"], \"仅下载音频\");\n    private static readonly Option<bool> VideoOnly = new([\"--video-only\"], \"仅下载视频\");\n    private static readonly Option<bool> DanmakuOnly = new([\"--danmaku-only\"], \"仅下载弹幕\");\n    private static readonly Option<bool> CoverOnly = new([\"--cover-only\"], \"仅下载封面\");\n    private static readonly Option<bool> SubOnly = new([\"--sub-only\"], \"仅下载字幕\");\n    private static readonly Option<bool> Debug = new([\"--debug\"], \"输出调试日志\");\n    private static readonly Option<bool> SkipMux = new([\"--skip-mux\"], \"跳过混流步骤\");\n    private static readonly Option<bool> SkipSubtitle = new([\"--skip-subtitle\"], \"跳过字幕下载\");\n    private static readonly Option<bool> SkipCover = new([\"--skip-cover\"], \"跳过封面下载\");\n    private static readonly Option<bool> ForceHttp = new([\"--force-http\"], \"下载音视频时强制使用HTTP协议替换HTTPS(默认开启)\");\n    private static readonly Option<bool> DownloadDanmaku = new([\"--download-danmaku\", \"-dd\"], \"下载弹幕\");\n    private static readonly Option<string> DownloadDanmakuFormats = new([\"--download-danmaku-formats\", \"-ddf\"], $\"指定需下载的弹幕格式, 用逗号分隔, 可选 {string.Join('/', BBDownDanmakuFormatInfo.AllFormatNames)}, 默认: \\\"{string.Join(',', BBDownDanmakuFormatInfo.AllFormatNames)}\\\"\");\n    private static readonly Option<bool> SkipAi = new([\"--skip-ai\"], description: \"跳过AI字幕下载(默认开启)\");\n    private static readonly Option<bool> VideoAscending = new([\"--video-ascending\"], \"视频升序(最小体积优先)\");\n    private static readonly Option<bool> AudioAscending = new([\"--audio-ascending\"], \"音频升序(最小体积优先)\");\n    private static readonly Option<bool> AllowPcdn = new([\"--allow-pcdn\"], \"不替换PCDN域名, 仅在正常情况与--upos-host均无法下载时使用\");\n    private static readonly Option<string> Language = new([\"--language\"], \"设置混流的音频语言(代码), 如chi, jpn等\");\n    private static readonly Option<string> UserAgent = new([\"--user-agent\", \"-ua\"], \"指定user-agent, 否则使用随机user-agent\");\n    private static readonly Option<string> Cookie = new([\"--cookie\", \"-c\"], \"设置字符串cookie用以下载网页接口的会员内容\");\n    private static readonly Option<string> AccessToken = new([\"--access-token\", \"-token\"], \"设置access_token用以下载TV/APP接口的会员内容\");\n    private static readonly Option<string> WorkDir = new([\"--work-dir\"], \"设置程序的工作目录\");\n    private static readonly Option<string> FFmpegPath = new([\"--ffmpeg-path\"], \"设置ffmpeg的路径\");\n    private static readonly Option<string> Mp4boxPath = new([\"--mp4box-path\"], \"设置mp4box的路径\");\n    private static readonly Option<string> Aria2cPath = new([\"--aria2c-path\"], \"设置aria2c的路径\");\n    private static readonly Option<string> UposHost = new([\"--upos-host\"], \"自定义upos服务器\");\n    private static readonly Option<bool> ForceReplaceHost = new([\"--force-replace-host\"], \"强制替换下载服务器host(默认开启)\");\n    private static readonly Option<bool> SaveArchivesToFile = new([\"--save-archives-to-file\"], \"将下载过的视频记录到本地文件中, 用于后续跳过下载同个视频\");\n    private static readonly Option<string> DelayPerPage = new([\"--delay-per-page\"], \"设置下载合集分P之间的下载间隔时间(单位: 秒, 默认无间隔)\");\n    private static readonly Option<string> FilePattern = new([\"--file-pattern\", \"-F\"], \n        $\"使用内置变量自定义单P存储文件名:\\r\\n\\r\\n\" + \n        $\"<videoTitle>: 视频主标题\\r\\n\" + \n        $\"<pageNumber>: 视频分P序号\\r\\n\" + \n        $\"<pageNumberWithZero>: 视频分P序号(前缀补零)\\r\\n\" + \n        $\"<pageTitle>: 视频分P标题\\r\\n\" + \n        $\"<bvid>: 视频BV号\\r\\n\" + \n        $\"<aid>: 视频aid\\r\\n\" + \n        $\"<cid>: 视频cid\\r\\n\" + \n        $\"<dfn>: 视频清晰度\\r\\n\" + \n        $\"<res>: 视频分辨率\\r\\n\" + \n        $\"<fps>: 视频帧率\\r\\n\" + \n        $\"<videoCodecs>: 视频编码\\r\\n\" + \n        $\"<videoBandwidth>: 视频码率\\r\\n\" + \n        $\"<audioCodecs>: 音频编码\\r\\n\" + \n        $\"<audioBandwidth>: 音频码率\\r\\n\" + \n        $\"<ownerName>: 上传者名称\\r\\n\" + \n        $\"<ownerMid>: 上传者mid\\r\\n\" + \n        $\"<publishDate>: 收藏夹/番剧/合集发布时间\\r\\n\" + \n        $\"<videoDate>: 视频发布时间(分p视频发布时间与<publishDate>相同)\\r\\n\" + \n        $\"<apiType>: API类型(TV/APP/INTL/WEB)\\r\\n\\r\\n\" + \n        $\"默认为: {Program.SinglePageDefaultSavePath}\\r\\n\");\n    private static readonly Option<string> MultiFilePattern = new([\"--multi-file-pattern\", \"-M\"], $\"使用内置变量自定义多P存储文件名:\\r\\n\\r\\n默认为: {Program.MultiPageDefaultSavePath}\\r\\n\");\n    private static readonly Option<string> Host = new([\"--host\"], \"指定BiliPlus host(使用BiliPlus需要access_token, 不需要cookie, 解析服务器能够获取你账号的大部分权限!)\");\n    private static readonly Option<string> EpHost = new([\"--ep-host\"], \"指定BiliPlus EP host(用于代理api.bilibili.com/pgc/view/web/season, 大部分解析服务器不支持代理该接口)\");\n    private static readonly Option<string> TvHost = new([\"--tv-host\"], \"自定义tv端接口请求Host(用于代理api.snm0516.aisee.tv)\");\n    private static readonly Option<string> Area = new([\"--area\"], \"(hk|tw|th) 使用BiliPlus时必选, 指定BiliPlus area\");\n    private static readonly Option<string> ConfigFile = new([\"--config-file\"], \"读取指定的BBDown本地配置文件(默认为: BBDown.config)\");//以下仅为兼容旧版本命令行, 不建议使用\n    private static readonly Option<string> Aria2cProxy = new([\"--aria2c-proxy\"], \"调用aria2c进行下载时的代理地址配置\") { IsHidden = true };\n    private static readonly Option<bool> OnlyHevc = new([\"--only-hevc\", \"-hevc\"], \"只下载hevc编码\") { IsHidden = true };\n    private static readonly Option<bool> OnlyAvc = new([\"--only-avc\", \"-avc\"], \"只下载avc编码\") { IsHidden = true };\n    private static readonly Option<bool> OnlyAv1 = new([\"--only-av1\", \"-av1\"], \"只下载av1编码\") { IsHidden = true };\n    private static readonly Option<bool> AddDfnSubfix = new([\"--add-dfn-subfix\"], \"为文件加入清晰度后缀, 如XXX[1080P 高码率]\") { IsHidden = true };\n    private static readonly Option<bool> NoPaddingPageNum = new([\"--no-padding-page-num\"], \"不给分P序号补零\") { IsHidden = true };\n    private static readonly Option<bool> BandwithAscending = new([\"--bandwith-ascending\"], \"比特率升序(最小体积优先)\") { IsHidden = true };\n\n\n    class MyOptionBinder : BinderBase<MyOption>\n    {\n        protected override MyOption GetBoundValue(BindingContext bindingContext)\n        {\n            var option = new MyOption\n            {\n                Url = bindingContext.ParseResult.GetValueForArgument(Url)\n            };\n\n            if (bindingContext.ParseResult.HasOption(UseTvApi)) option.UseTvApi = bindingContext.ParseResult.GetValueForOption(UseTvApi)!;\n            if (bindingContext.ParseResult.HasOption(UseAppApi)) option.UseAppApi = bindingContext.ParseResult.GetValueForOption(UseAppApi)!;\n            if (bindingContext.ParseResult.HasOption(UseIntlApi)) option.UseIntlApi = bindingContext.ParseResult.GetValueForOption(UseIntlApi)!;\n            if (bindingContext.ParseResult.HasOption(UseMP4box)) option.UseMP4box = bindingContext.ParseResult.GetValueForOption(UseMP4box)!;\n            if (bindingContext.ParseResult.HasOption(EncodingPriority)) option.EncodingPriority = bindingContext.ParseResult.GetValueForOption(EncodingPriority)!;\n            if (bindingContext.ParseResult.HasOption(DfnPriority)) option.DfnPriority = bindingContext.ParseResult.GetValueForOption(DfnPriority)!;\n            if (bindingContext.ParseResult.HasOption(OnlyShowInfo)) option.OnlyShowInfo = bindingContext.ParseResult.GetValueForOption(OnlyShowInfo)!;\n            if (bindingContext.ParseResult.HasOption(ShowAll)) option.ShowAll = bindingContext.ParseResult.GetValueForOption(ShowAll)!;\n            if (bindingContext.ParseResult.HasOption(UseAria2c)) option.UseAria2c = bindingContext.ParseResult.GetValueForOption(UseAria2c)!;\n            if (bindingContext.ParseResult.HasOption(Interactive)) option.Interactive = bindingContext.ParseResult.GetValueForOption(Interactive)!;\n            if (bindingContext.ParseResult.HasOption(HideStreams)) option.HideStreams = bindingContext.ParseResult.GetValueForOption(HideStreams)!;\n            if (bindingContext.ParseResult.HasOption(MultiThread)) option.MultiThread = bindingContext.ParseResult.GetValueForOption(MultiThread)!;\n            if (bindingContext.ParseResult.HasOption(SimplyMux)) option.SimplyMux = bindingContext.ParseResult.GetValueForOption(SimplyMux)!;\n            if (bindingContext.ParseResult.HasOption(VideoOnly)) option.VideoOnly = bindingContext.ParseResult.GetValueForOption(VideoOnly)!;\n            if (bindingContext.ParseResult.HasOption(AudioOnly)) option.AudioOnly = bindingContext.ParseResult.GetValueForOption(AudioOnly)!;\n            if (bindingContext.ParseResult.HasOption(DanmakuOnly)) option.DanmakuOnly = bindingContext.ParseResult.GetValueForOption(DanmakuOnly)!;\n            if (bindingContext.ParseResult.HasOption(CoverOnly)) option.CoverOnly = bindingContext.ParseResult.GetValueForOption(CoverOnly)!;\n            if (bindingContext.ParseResult.HasOption(SubOnly)) option.SubOnly = bindingContext.ParseResult.GetValueForOption(SubOnly)!;\n            if (bindingContext.ParseResult.HasOption(Debug)) option.Debug = bindingContext.ParseResult.GetValueForOption(Debug)!;\n            if (bindingContext.ParseResult.HasOption(SkipMux)) option.SkipMux = bindingContext.ParseResult.GetValueForOption(SkipMux)!;\n            if (bindingContext.ParseResult.HasOption(SkipSubtitle)) option.SkipSubtitle = bindingContext.ParseResult.GetValueForOption(SkipSubtitle)!;\n            if (bindingContext.ParseResult.HasOption(SkipCover)) option.SkipCover = bindingContext.ParseResult.GetValueForOption(SkipCover)!;\n            if (bindingContext.ParseResult.HasOption(ForceHttp)) option.ForceHttp = bindingContext.ParseResult.GetValueForOption(ForceHttp)!;\n            if (bindingContext.ParseResult.HasOption(DownloadDanmaku)) option.DownloadDanmaku = bindingContext.ParseResult.GetValueForOption(DownloadDanmaku)!;\n            if (bindingContext.ParseResult.HasOption(DownloadDanmakuFormats)) option.DownloadDanmakuFormats = bindingContext.ParseResult.GetValueForOption(DownloadDanmakuFormats)!;\n            if (bindingContext.ParseResult.HasOption(SkipAi)) option.SkipAi = bindingContext.ParseResult.GetValueForOption(SkipAi)!;\n            if (bindingContext.ParseResult.HasOption(VideoAscending)) option.VideoAscending = bindingContext.ParseResult.GetValueForOption(VideoAscending)!;\n            if (bindingContext.ParseResult.HasOption(AudioAscending)) option.AudioAscending = bindingContext.ParseResult.GetValueForOption(AudioAscending)!;\n            if (bindingContext.ParseResult.HasOption(AllowPcdn)) option.AllowPcdn = bindingContext.ParseResult.GetValueForOption(AllowPcdn)!;\n            if (bindingContext.ParseResult.HasOption(FilePattern)) option.FilePattern = bindingContext.ParseResult.GetValueForOption(FilePattern)!;\n            if (bindingContext.ParseResult.HasOption(MultiFilePattern)) option.MultiFilePattern = bindingContext.ParseResult.GetValueForOption(MultiFilePattern)!;\n            if (bindingContext.ParseResult.HasOption(SelectPage)) option.SelectPage = bindingContext.ParseResult.GetValueForOption(SelectPage)!;\n            if (bindingContext.ParseResult.HasOption(Language)) option.Language = bindingContext.ParseResult.GetValueForOption(Language)!;\n            if (bindingContext.ParseResult.HasOption(UserAgent)) option.UserAgent = bindingContext.ParseResult.GetValueForOption(UserAgent)!;\n            if (bindingContext.ParseResult.HasOption(Cookie)) option.Cookie = bindingContext.ParseResult.GetValueForOption(Cookie)!;\n            if (bindingContext.ParseResult.HasOption(AccessToken)) option.AccessToken = bindingContext.ParseResult.GetValueForOption(AccessToken)!;\n            if (bindingContext.ParseResult.HasOption(Aria2cArgs)) option.Aria2cArgs = bindingContext.ParseResult.GetValueForOption(Aria2cArgs)!;\n            if (bindingContext.ParseResult.HasOption(WorkDir)) option.WorkDir = bindingContext.ParseResult.GetValueForOption(WorkDir)!;\n            if (bindingContext.ParseResult.HasOption(FFmpegPath)) option.FFmpegPath = bindingContext.ParseResult.GetValueForOption(FFmpegPath)!;\n            if (bindingContext.ParseResult.HasOption(Mp4boxPath)) option.Mp4boxPath = bindingContext.ParseResult.GetValueForOption(Mp4boxPath)!;\n            if (bindingContext.ParseResult.HasOption(Aria2cPath)) option.Aria2cPath = bindingContext.ParseResult.GetValueForOption(Aria2cPath)!;\n            if (bindingContext.ParseResult.HasOption(UposHost)) option.UposHost = bindingContext.ParseResult.GetValueForOption(UposHost)!;\n            if (bindingContext.ParseResult.HasOption(ForceReplaceHost)) option.ForceReplaceHost = bindingContext.ParseResult.GetValueForOption(ForceReplaceHost)!;\n            if (bindingContext.ParseResult.HasOption(SaveArchivesToFile)) option.SaveArchivesToFile = bindingContext.ParseResult.GetValueForOption(SaveArchivesToFile)!;\n            if (bindingContext.ParseResult.HasOption(DelayPerPage)) option.DelayPerPage = bindingContext.ParseResult.GetValueForOption(DelayPerPage)!;\n            if (bindingContext.ParseResult.HasOption(Host)) option.Host = bindingContext.ParseResult.GetValueForOption(Host)!;\n            if (bindingContext.ParseResult.HasOption(EpHost)) option.EpHost = bindingContext.ParseResult.GetValueForOption(EpHost)!;\n            if (bindingContext.ParseResult.HasOption(TvHost)) option.TvHost = bindingContext.ParseResult.GetValueForOption(TvHost)!;\n            if (bindingContext.ParseResult.HasOption(Area)) option.Area = bindingContext.ParseResult.GetValueForOption(Area)!;\n            if (bindingContext.ParseResult.HasOption(ConfigFile)) option.ConfigFile = bindingContext.ParseResult.GetValueForOption(ConfigFile)!;\n            if (bindingContext.ParseResult.HasOption(Aria2cProxy)) option.Aria2cProxy = bindingContext.ParseResult.GetValueForOption(Aria2cProxy)!;\n            if (bindingContext.ParseResult.HasOption(OnlyHevc)) option.OnlyHevc = bindingContext.ParseResult.GetValueForOption(OnlyHevc)!;\n            if (bindingContext.ParseResult.HasOption(OnlyAvc)) option.OnlyAvc = bindingContext.ParseResult.GetValueForOption(OnlyAvc)!;\n            if (bindingContext.ParseResult.HasOption(OnlyAv1)) option.OnlyAv1 = bindingContext.ParseResult.GetValueForOption(OnlyAv1)!;\n            if (bindingContext.ParseResult.HasOption(AddDfnSubfix)) option.AddDfnSubfix = bindingContext.ParseResult.GetValueForOption(AddDfnSubfix)!;\n            if (bindingContext.ParseResult.HasOption(NoPaddingPageNum)) option.NoPaddingPageNum = bindingContext.ParseResult.GetValueForOption(NoPaddingPageNum)!;\n            if (bindingContext.ParseResult.HasOption(BandwithAscending)) option.BandwithAscending = bindingContext.ParseResult.GetValueForOption(BandwithAscending)!;\n            return option;\n        }\n    }\n\n    public static RootCommand GetRootCommand(Func<MyOption, Task> action)\n    {\n        var rootCommand = new RootCommand\n        {\n            Url,\n            UseTvApi,\n            UseAppApi,\n            UseIntlApi,\n            UseMP4box,\n            EncodingPriority,\n            DfnPriority,\n            OnlyShowInfo,\n            ShowAll,\n            UseAria2c,\n            Interactive,\n            HideStreams,\n            MultiThread,\n            VideoOnly,\n            AudioOnly,\n            DanmakuOnly,\n            SubOnly,\n            CoverOnly,\n            Debug,\n            SkipMux,\n            SkipSubtitle,\n            SkipCover,\n            ForceHttp,\n            DownloadDanmaku,\n            DownloadDanmakuFormats,\n            SkipAi,\n            VideoAscending,\n            AudioAscending,\n            AllowPcdn,\n            FilePattern,\n            MultiFilePattern,\n            SelectPage,\n            Language,\n            UserAgent,\n            Cookie,\n            AccessToken,\n            Aria2cArgs,\n            WorkDir,\n            FFmpegPath,\n            Mp4boxPath,\n            Aria2cPath,\n            UposHost,\n            ForceReplaceHost,\n            SaveArchivesToFile,\n            DelayPerPage,\n            Host,\n            EpHost,\n            TvHost,\n            Area,\n            ConfigFile,\n            Aria2cProxy,\n            OnlyHevc,\n            OnlyAvc,\n            OnlyAv1,\n            AddDfnSubfix,\n            NoPaddingPageNum,\n            BandwithAscending\n        };\n\n        rootCommand.SetHandler(async (myOption) => await action(myOption), new MyOptionBinder());\n\n        return rootCommand;\n    }\n}"
  },
  {
    "path": "BBDown/ConsoleQRCode.cs",
    "content": "﻿using QRCoder;\nusing System;\n\nnamespace BBDown;\n\npublic class ConsoleQRCode : AbstractQRCode\n{\n    public ConsoleQRCode() { }\n\n    public ConsoleQRCode(QRCodeData data) : base(data) { }\n\n    public void GetGraphic() => GetGraphic(ConsoleColor.Black, ConsoleColor.White);\n\n    public void GetGraphic(ConsoleColor darkColor, ConsoleColor lightColor)\n    {\n        var previousBackColor = Console.BackgroundColor;\n        var previousForeColor = Console.ForegroundColor;\n        Console.ForegroundColor = ConsoleColor.White;\n        for (int y = 0; y < QrCodeData.ModuleMatrix.Count; y++)\n        {\n            for (int x = 0; x < QrCodeData.ModuleMatrix[y].Count; x++)\n            {\n                Console.ForegroundColor = QrCodeData.ModuleMatrix[y][x] ? darkColor : lightColor;\n                Console.Write(\"██\");\n            }\n            Console.BackgroundColor = darkColor;\n            Console.WriteLine(\"\");\n        }\n        Console.BackgroundColor = previousBackColor;\n        Console.ForegroundColor = previousForeColor;\n    }\n}"
  },
  {
    "path": "BBDown/Directory.Build.props",
    "content": "<Project>\n\n  <PropertyGroup>\n    <IlcOptimizationPreference>Speed</IlcOptimizationPreference>\n    <IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>\n    <PublishAot>true</PublishAot>\n    <EventSourceSupport>false</EventSourceSupport>\n    <UseSystemResourceKeys>true</UseSystemResourceKeys>\n    <InvariantGlobalization>true</InvariantGlobalization>\n    <StripSymbols>true</StripSymbols>\n    <ObjCopyName Condition=\"'$(RuntimeIdentifier)' == 'linux-arm64'\">aarch64-linux-gnu-objcopy</ObjCopyName>\n  </PropertyGroup>\n  \n</Project>\n"
  },
  {
    "path": "BBDown/Model/ServeRequestOptions.cs",
    "content": "using BBDown;\n\ninternal class ServeRequestOptions : MyOption\n{\n\n    /// <summary>\n    /// 任务完成回调Http请求地址\n    /// </summary>\n    public string? CallBackWebHook { get; set; }\n\n}"
  },
  {
    "path": "BBDown/MyOption.cs",
    "content": "﻿namespace BBDown;\n\ninternal class MyOption\n{\n    public string Url { get; set; } = default!;\n    public bool UseTvApi { get; set; }\n    public bool UseAppApi { get; set; }\n    public bool UseIntlApi { get; set; }\n    public bool UseMP4box { get; set; }\n    public string? EncodingPriority { get; set; }\n    public string? DfnPriority { get; set; }\n    public bool OnlyShowInfo { get; set; }\n    public bool ShowAll { get; set; }\n    public bool UseAria2c { get; set; }\n    public bool Interactive { get; set; }\n    public bool HideStreams { get; set; }\n    public bool MultiThread { get; set; } = true;\n    public bool SimplyMux {  get; set; } = false;\n    public bool VideoOnly { get; set; }\n    public bool AudioOnly { get; set; }\n    public bool DanmakuOnly { get; set; }\n    public bool CoverOnly { get; set; }\n    public bool SubOnly { get; set; }\n    public bool Debug { get; set; }\n    public bool SkipMux { get; set; }\n    public bool SkipSubtitle { get; set; }\n    public bool SkipCover { get; set; }\n    public bool ForceHttp { get; set; } = true;\n    public bool DownloadDanmaku { get; set; } = false;\n    public string? DownloadDanmakuFormats { get; set; }\n    public bool SkipAi { get; set; } = true;\n    public bool VideoAscending { get; set; } = false;\n    public bool AudioAscending { get; set; } = false;\n    public bool AllowPcdn { get; set; } = false;\n    public bool ForceReplaceHost { get; set; } = true;\n    public bool SaveArchivesToFile { get; set; } = false;\n    public string FilePattern { get; set; } = \"\";\n    public string MultiFilePattern { get; set; } = \"\";\n    public string SelectPage { get; set; } = \"\";\n    public string Language { get; set; } = \"\";\n    public string UserAgent { get; set; } = \"\";\n    public string Cookie { get; set; } = \"\";\n    public string AccessToken { get; set; } = \"\";\n    public string Aria2cArgs { get; set; } = \"\";\n    public string WorkDir { get; set; } = \"\";\n    public string FFmpegPath { get; set; } = \"\";\n    public string Mp4boxPath { get; set; } = \"\";\n    public string Aria2cPath { get; set; } = \"\";\n    public string UposHost { get; set; } = \"\";\n    public string DelayPerPage { get; set; } = \"0\";\n    public string Host { get; set; } = \"api.bilibili.com\";\n    public string EpHost { get; set; } = \"api.bilibili.com\";\n    public string TvHost { get; set; } = \"api.snm0516.aisee.tv\";\n    public string Area { get; set; } = \"\";\n    public string? ConfigFile { get; set; }\n    //以下仅为兼容旧版本命令行，不建议使用\n    public string Aria2cProxy { get; set; } = \"\";\n    public bool OnlyHevc { get; set; }\n    public bool OnlyAvc { get; set; }\n    public bool OnlyAv1 { get; set; }\n    public bool AddDfnSubfix { get; set; }\n    public bool NoPaddingPageNum { get; set; }\n    public bool BandwithAscending { get; set; }\n}"
  },
  {
    "path": "BBDown/Program.Methods.cs",
    "content": "﻿using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Threading.Tasks;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.BBDownUtil;\nusing static BBDown.Core.Logger;\nusing System.Linq;\nusing System.Text.RegularExpressions;\nusing BBDown.Core;\nusing BBDown.Core.Entity;\nusing static BBDown.BBDownDownloadUtil;\n\nnamespace BBDown;\n\ninternal partial class Program\n{\n\n    /// <summary>\n    /// 兼容旧版本命令行参数并给出警告\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    private static void HandleDeprecatedOptions(MyOption myOption)\n    {\n        if (myOption.AddDfnSubfix)\n        {\n            LogWarn(\"--add-dfn-subfix 已被弃用, 建议使用 --file-pattern/-F 或 --multi-file-pattern/-M 来自定义输出文件名格式\");\n            if (string.IsNullOrEmpty(myOption.FilePattern) && string.IsNullOrEmpty(myOption.MultiFilePattern))\n            {\n                SinglePageDefaultSavePath += \"[<dfn>]\";\n                MultiPageDefaultSavePath += \"[<dfn>]\";\n                LogWarn($\"已切换至 -F \\\"{SinglePageDefaultSavePath}\\\" -M \\\"{MultiPageDefaultSavePath}\\\"\");\n            }\n        }\n        if (myOption.Aria2cProxy != \"\")\n        {\n            LogWarn(\"--aria2c-proxy 已被弃用, 请使用 --aria2c-args 来设置aria2c代理, 本次执行已添加该代理\");\n            myOption.Aria2cArgs += $\" --all-proxy=\\\"{myOption.Aria2cProxy}\\\"\";\n        }\n        if (myOption.OnlyHevc)\n        {\n            LogWarn(\"--only-hevc/-hevc 已被弃用, 请使用 --encoding-priority 来设置编码优先级, 本次执行已将hevc设置为最高优先级\");\n            myOption.EncodingPriority = \"hevc\";\n        }\n        if (myOption.OnlyAvc)\n        {\n            LogWarn(\"--only-avc/-avc 已被弃用, 请使用 --encoding-priority 来设置编码优先级, 本次执行已将avc设置为最高优先级\");\n            myOption.EncodingPriority = \"avc\";\n        }\n        if (myOption.OnlyAv1)\n        {\n            LogWarn(\"--only-av1/-av1 已被弃用, 请使用 --encoding-priority 来设置编码优先级, 本次执行已将av1设置为最高优先级\");\n            myOption.EncodingPriority = \"av1\";\n        }\n        if (myOption.NoPaddingPageNum)\n        {\n            LogWarn(\"--no-padding-page-num 已被弃用, 建议使用 --file-pattern/-F 或 --multi-file-pattern/-M 来自定义输出文件名格式\");\n            if (string.IsNullOrEmpty(myOption.FilePattern) && string.IsNullOrEmpty(myOption.MultiFilePattern))\n            {\n                MultiPageDefaultSavePath = MultiPageDefaultSavePath.Replace(\"<pageNumberWithZero>\", \"<pageNumber>\");\n                LogWarn($\"已切换至 -M \\\"{MultiPageDefaultSavePath}\\\"\");\n            }\n        }\n        if (myOption.BandwithAscending)\n        {\n            LogWarn(\"--bandwith-ascending 已被弃用, 建议使用 --video-ascending 与 --audio-ascending 来指定视频或音频是否升序, 本次执行已将视频与音频均设为升序\");\n            myOption.VideoAscending = true;\n            myOption.AudioAscending = true;\n        }\n    }\n\n    /// <summary>\n    /// 解析用户指定的编码优先级\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    /// <returns></returns>\n    private static Dictionary<string, byte> ParseEncodingPriority(MyOption myOption, out string firstEncoding)\n    {\n        var encodingPriority = new Dictionary<string, byte>();\n        firstEncoding = \"\";\n        if (myOption.EncodingPriority != null)\n        {\n            var encodingPriorityTemp = myOption.EncodingPriority\n                .ToUpper()\n                .Replace('，', ',')\n                .Replace(\"-\", string.Empty)\n                .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)\n                .Where(s => !string.IsNullOrEmpty(s)).ToList();\n            byte index = 0;\n            firstEncoding = encodingPriorityTemp.First();\n            foreach (string encoding in encodingPriorityTemp)\n            {\n                if (encodingPriority.ContainsKey(encoding))\n                    continue;\n                encodingPriority[encoding] = index;\n                index++;\n            }\n        }\n        return encodingPriority;\n    }\n\n    private static BBDownDanmakuFormat[] ParseDownloadDanmakuFormats(MyOption myOption)\n    {\n        if (string.IsNullOrEmpty(myOption.DownloadDanmakuFormats)) return BBDownDanmakuFormatInfo.DefaultFormats;\n\n        var formats = myOption.DownloadDanmakuFormats.Replace(\"，\", \",\").ToLower().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);\n        if (formats.Any(format => !BBDownDanmakuFormatInfo.AllFormatNames.Contains(format)))\n        {\n            LogError($\"包含不支持的下载弹幕格式：{myOption.DownloadDanmakuFormats}\");\n            return BBDownDanmakuFormatInfo.DefaultFormats;\n        }\n        \n        return formats.Select(BBDownDanmakuFormatInfo.FromFormatName).ToArray();\n    }\n\n    /// <summary>\n    /// 解析用户输入的清晰度规格优先级\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    /// <returns></returns>\n    private static Dictionary<string, int> ParseDfnPriority(MyOption myOption)\n    {\n        var dfnPriority = new Dictionary<string, int>();\n        if (myOption.DfnPriority != null)\n        {\n            var dfnPriorityTemp = myOption.DfnPriority.Replace(\"，\", \",\").Split(',').Select(s => s.ToUpper().Trim()).Where(s => !string.IsNullOrEmpty(s));\n            int index = 0;\n            foreach (string dfn in dfnPriorityTemp)\n            {\n                if (dfnPriority.ContainsKey(dfn)) { continue; }\n                dfnPriority[dfn] = index;\n                index++;\n            }\n        }\n        return dfnPriority;\n    }\n\n    /// <summary>\n    /// 寻找并设置所需的二进制文件\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    /// <exception cref=\"Exception\"></exception>\n    private static void FindBinaries(MyOption myOption)\n    {\n        if (!string.IsNullOrEmpty(myOption.FFmpegPath) && File.Exists(myOption.FFmpegPath))\n        {\n            BBDownMuxer.FFMPEG = myOption.FFmpegPath;\n        }\n\n        if (!string.IsNullOrEmpty(myOption.Mp4boxPath) && File.Exists(myOption.Mp4boxPath))\n        {\n            BBDownMuxer.MP4BOX = myOption.Mp4boxPath;\n        }\n\n        if (!string.IsNullOrEmpty(myOption.Aria2cPath) && File.Exists(myOption.Aria2cPath))\n        {\n            BBDownAria2c.ARIA2C = myOption.Aria2cPath;\n        }\n        //寻找ffmpeg或mp4box\n        if (!myOption.SkipMux)\n        {\n            if (myOption.UseMP4box)\n            {\n                if (string.IsNullOrEmpty(BBDownMuxer.MP4BOX) || !File.Exists(BBDownMuxer.MP4BOX))\n                {\n                    var binPath = FindExecutable(\"mp4box\") ?? FindExecutable(\"MP4box\");\n                    if (string.IsNullOrEmpty(binPath))\n                        throw new Exception(\"找不到可执行的mp4box文件\");\n                    BBDownMuxer.MP4BOX = binPath;\n                }\n            }\n            else if (string.IsNullOrEmpty(BBDownMuxer.FFMPEG) || !File.Exists(BBDownMuxer.FFMPEG))\n            {\n                var binPath = FindExecutable(\"ffmpeg\");\n                if (string.IsNullOrEmpty(binPath))\n                    throw new Exception(\"找不到可执行的ffmpeg文件\");\n                BBDownMuxer.FFMPEG = binPath;\n            }\n        }\n\n        //寻找aria2c\n        if (myOption.UseAria2c)\n        {\n            if (string.IsNullOrEmpty(BBDownAria2c.ARIA2C) || !File.Exists(BBDownAria2c.ARIA2C))\n            {\n                var binPath = FindExecutable(\"aria2c\");\n                if (string.IsNullOrEmpty(binPath))\n                    throw new Exception(\"找不到可执行的aria2c文件\");\n                BBDownAria2c.ARIA2C = binPath;\n            }\n\n        }\n    }\n\n    /// <summary>\n    /// 处理有冲突的选项\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    private static void HandleConflictingOptions(MyOption myOption)\n    {\n        //手动选择时不能隐藏流\n        if (myOption.Interactive)\n        {\n            myOption.HideStreams = false;\n        }\n        //audioOnly和videoOnly同时开启则全部忽视\n        if (myOption.AudioOnly && myOption.VideoOnly)\n        {\n            myOption.AudioOnly = false;\n            myOption.VideoOnly = false;\n        }\n        if (myOption.SkipSubtitle)\n        {\n            myOption.SubOnly = false;\n        }\n    }\n\n    /// <summary>\n    /// 设置用户输入的自定义工作目录\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    private static void ChangeWorkingDir(MyOption myOption)\n    {\n        if (!string.IsNullOrEmpty(myOption.WorkDir))\n        {\n            //解释环境变量\n            myOption.WorkDir = Environment.ExpandEnvironmentVariables(myOption.WorkDir);\n            var dir = Path.GetFullPath(myOption.WorkDir);\n            if (!Directory.Exists(dir))\n            {\n                Directory.CreateDirectory(dir);\n            }\n            //设置工作目录\n            Environment.CurrentDirectory = dir;\n            LogDebug(\"切换工作目录至：{0}\", dir);\n        }\n    }\n\n    /// <summary>\n    /// 加载用户的认证信息（cookie或token）\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    private static void LoadCredentials(MyOption myOption)\n    {\n        if (string.IsNullOrEmpty(Config.COOKIE) && File.Exists(Path.Combine(APP_DIR, \"BBDown.data\")))\n        {\n            Log(\"加载本地cookie...\");\n            LogDebug(\"文件路径：{0}\", Path.Combine(APP_DIR, \"BBDown.data\"));\n            Config.COOKIE = File.ReadAllText(Path.Combine(APP_DIR, \"BBDown.data\"));\n        }\n        if (string.IsNullOrEmpty(Config.TOKEN) && File.Exists(Path.Combine(APP_DIR, \"BBDownTV.data\")) && myOption.UseTvApi)\n        {\n            Log(\"加载本地token...\");\n            LogDebug(\"文件路径：{0}\", Path.Combine(APP_DIR, \"BBDownTV.data\"));\n            Config.TOKEN = File.ReadAllText(Path.Combine(APP_DIR, \"BBDownTV.data\"));\n            Config.TOKEN = Config.TOKEN.Replace(\"access_token=\", \"\");\n        }\n        if (string.IsNullOrEmpty(Config.TOKEN) && File.Exists(Path.Combine(APP_DIR, \"BBDownApp.data\")) && myOption.UseAppApi)\n        {\n            Log(\"加载本地token...\");\n            LogDebug(\"文件路径：{0}\", Path.Combine(APP_DIR, \"BBDownApp.data\"));\n            Config.TOKEN = File.ReadAllText(Path.Combine(APP_DIR, \"BBDownApp.data\"));\n            Config.TOKEN = Config.TOKEN.Replace(\"access_token=\", \"\");\n        }\n    }\n\n    private static object fileLock = new object();\n    public static void SaveAidToFile(string aid)\n    {\n        lock (fileLock)\n        {\n            string filePath = Path.Combine(APP_DIR, \"BBDown.archives\");\n            LogDebug(\"文件路径：{0}\", filePath);\n            File.AppendAllText(filePath, $\"{aid}|\");\n        }\n    }\n\n    public static bool CheckAidFromFile(string aid)\n    {\n        lock (fileLock)\n        {\n            string filePath = Path.Combine(APP_DIR, \"BBDown.archives\");\n            if (!File.Exists(filePath)) return false;\n            LogDebug(\"文件路径：{0}\", filePath);\n            var text = File.ReadAllText(filePath);\n            return text.Split('|').Any(item => item == aid);\n        }\n    }\n\n    /// <summary>\n    /// 获取选中的分P列表\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    /// <param name=\"vInfo\"></param>\n    /// <param name=\"input\"></param>\n    /// <returns></returns>\n    private static List<string>? GetSelectedPages(MyOption myOption, VInfo vInfo, string input)\n    {\n        List<string>? selectedPages = null;\n        List<Page> pagesInfo = vInfo.PagesInfo;\n        string selectPage = myOption.SelectPage.ToUpper().Trim().Trim(',');\n\n        if (string.IsNullOrEmpty(selectPage))\n        {\n            //如果用户没有选择分P, 根据epid或query param来确定某一集\n            if (!string.IsNullOrEmpty(vInfo.Index))\n            {\n                selectedPages = [vInfo.Index];\n                Log(\"程序已自动选择你输入的集数, 如果要下载其他集数请自行指定分P(如可使用-p ALL代表全部)\");\n            }\n            else if (!string.IsNullOrEmpty(GetQueryString(\"p\", input)))\n            {\n                selectedPages = [GetQueryString(\"p\", input)];\n                Log(\"程序已自动选择你输入的集数, 如果要下载其他集数请自行指定分P(如可使用-p ALL代表全部)\");\n            }\n        }\n        else if (selectPage != \"ALL\")\n        {\n            selectedPages = new List<string>();\n\n            //选择最新分P\n            string lastPage = pagesInfo.Count.ToString();\n            foreach (string key in new[] { \"LAST\", \"NEW\", \"LATEST\" })\n            {\n                selectPage = selectPage.Replace(key, lastPage);\n            }\n\n            try\n            {\n                if (selectPage.Contains('-'))\n                {\n                    string[] tmp = selectPage.Split('-');\n                    int start = int.Parse(tmp[0]);\n                    int end = int.Parse(tmp[1]);\n                    for (int i = start; i <= end; i++)\n                    {\n                        selectedPages.Add(i.ToString());\n                    }\n                }\n                else\n                {\n                    foreach (var s in selectPage.Split(','))\n                    {\n                        selectedPages.Add(s);\n                    }\n                }\n            }\n            catch { LogError(\"解析分P参数时失败了~\"); selectedPages = null; };\n        }\n\n        return selectedPages;\n    }\n\n    /// <summary>\n    /// 处理CDN域名\n    /// </summary>\n    /// <param name=\"myOption\"></param>\n    /// <param name=\"video\"></param>\n    /// <param name=\"audio\"></param>\n    private static void HandlePcdn(MyOption myOption, Video? selectedVideo, Audio? selectedAudio)\n    {\n        if (myOption.UposHost == \"\")\n        {\n            //处理PCDN\n            if (!myOption.AllowPcdn)\n            {\n                var pcdnReg = PcdnRegex();\n                if (selectedVideo != null && pcdnReg.IsMatch(selectedVideo.baseUrl))\n                {\n                    LogWarn($\"检测到视频流为PCDN, 尝试强制替换为{BACKUP_HOST}……\");\n                    selectedVideo.baseUrl = pcdnReg.Replace(selectedVideo.baseUrl, $\"://{BACKUP_HOST}/\");\n                }\n                if (selectedAudio != null && pcdnReg.IsMatch(selectedAudio.baseUrl))\n                {\n                    LogWarn($\"检测到音频流为PCDN, 尝试强制替换为{BACKUP_HOST}……\");\n                    selectedAudio.baseUrl = pcdnReg.Replace(selectedAudio.baseUrl, $\"://{BACKUP_HOST}/\");\n                }\n            }\n\n            var akamReg = AkamRegex();\n            if (selectedVideo != null && Config.AREA != \"\" && selectedVideo.baseUrl.Contains(\"akamaized.net\"))\n            {\n                LogWarn($\"检测到视频流为外国源, 尝试强制替换为{BACKUP_HOST}……\");\n                selectedVideo.baseUrl = akamReg.Replace(selectedVideo.baseUrl, $\"://{BACKUP_HOST}/\");\n            }\n            if (selectedAudio != null && Config.AREA != \"\" && selectedAudio.baseUrl.Contains(\"akamaized.net\"))\n            {\n                LogWarn($\"检测到音频流为外国源, 尝试强制替换为{BACKUP_HOST}……\");\n                selectedAudio.baseUrl = akamReg.Replace(selectedAudio.baseUrl, $\"://{BACKUP_HOST}/\");\n            }\n        }\n        else\n        {\n            if (selectedVideo != null)\n            {\n                LogWarn($\"尝试将视频流强制替换为{myOption.UposHost}……\");\n                selectedVideo.baseUrl = UposRegex().Replace(selectedVideo.baseUrl, $\"://{myOption.UposHost}/\");\n            }\n            if (selectedAudio != null)\n            {\n                LogWarn($\"尝试将音频流强制替换为{myOption.UposHost}……\");\n                selectedAudio.baseUrl = UposRegex().Replace(selectedAudio.baseUrl, $\"://{myOption.UposHost}/\");\n            }\n        }\n    }\n\n    /// <summary>\n    /// 打印解析到的各个轨道信息\n    /// </summary>\n    /// <param name=\"parsedResult\"></param>\n    /// <param name=\"pageDur\"></param>\n    private static void PrintAllTracksInfo(ParsedResult parsedResult, int pageDur, bool onlyShowInfo)\n    {\n        if (parsedResult.BackgroundAudioTracks.Any() && parsedResult.RoleAudioList.Any())\n        {\n            Log($\"共计{parsedResult.BackgroundAudioTracks.Count}条背景音频流.\");\n            int index = 0;\n            foreach (var a in parsedResult.BackgroundAudioTracks)\n            {\n                int pDur = pageDur == 0 ? a.dur : pageDur;\n                LogColor($\"{index++}. [{a.codecs}] [{a.bandwith} kbps] [~{FormatFileSize(pDur * a.bandwith * 1024 / 8)}]\", false);\n            }\n            Log($\"共计{parsedResult.RoleAudioList.Count}条配音, 每条包含{parsedResult.RoleAudioList[0].audio.Count}条配音流.\");\n            index = 0;\n            foreach (var a in parsedResult.RoleAudioList[0].audio)\n            {\n                int pDur = pageDur == 0 ? a.dur : pageDur;\n                LogColor($\"{index++}. [{a.codecs}] [{a.bandwith} kbps] [~{FormatFileSize(pDur * a.bandwith * 1024 / 8)}]\", false);\n            }\n        }\n        //展示所有的音视频流信息\n        if (parsedResult.VideoTracks.Any())\n        {\n            Log($\"共计{parsedResult.VideoTracks.Count}条视频流.\");\n            int index = 0;\n            foreach (var v in parsedResult.VideoTracks)\n            {\n                int pDur = pageDur == 0 ? v.dur : pageDur;\n                var size = v.size > 0 ? v.size : pDur * v.bandwith * 1024 / 8;\n                LogColor($\"{index++}. [{v.dfn}] [{v.res}] [{v.codecs}] [{v.fps}] [{v.bandwith} kbps] [~{FormatFileSize(size)}]\".Replace(\"[] \", \"\"), false);\n                if (onlyShowInfo) Console.WriteLine(v.baseUrl);\n            }\n        }\n        if (parsedResult.AudioTracks.Any())\n        {\n            Log($\"共计{parsedResult.AudioTracks.Count}条音频流.\");\n            int index = 0;\n            foreach (var a in parsedResult.AudioTracks)\n            {\n                int pDur = pageDur == 0 ? a.dur : pageDur;\n                LogColor($\"{index++}. [{a.codecs}] [{a.bandwith} kbps] [~{FormatFileSize(pDur * a.bandwith * 1024 / 8)}]\", false);\n                if (onlyShowInfo) Console.WriteLine(a.baseUrl);\n            }\n        }\n    }\n\n    private static void PrintSelectedTrackInfo(Video? selectedVideo, Audio? selectedAudio, int pageDur)\n    {\n        if (selectedVideo != null)\n        {\n            int pDur = pageDur == 0 ? selectedVideo.dur : pageDur;\n            var size = selectedVideo.size > 0 ? selectedVideo.size : pDur * selectedVideo.bandwith * 1024 / 8;\n            LogColor($\"[视频] [{selectedVideo.dfn}] [{selectedVideo.res}] [{selectedVideo.codecs}] [{selectedVideo.fps}] [{selectedVideo.bandwith} kbps] [~{FormatFileSize(size)}]\".Replace(\"[] \", \"\"), false);\n        }\n        if (selectedAudio != null)\n        {\n            int pDur = pageDur == 0 ? selectedAudio.dur : pageDur;\n            LogColor($\"[音频] [{selectedAudio.codecs}] [{selectedAudio.bandwith} kbps] [~{FormatFileSize(pDur * selectedAudio.bandwith * 1024 / 8)}]\", false);\n        }\n    }\n\n    /// <summary>\n    /// 引导用户进行手动选择轨道\n    /// </summary>\n    /// <param name=\"parsedResult\"></param>\n    /// <param name=\"vIndex\"></param>\n    /// <param name=\"aIndex\"></param>\n    private static void SelectTrackManually(ParsedResult parsedResult, ref int vIndex, ref int aIndex)\n    {\n        if (parsedResult.VideoTracks.Any())\n        {\n            Log(\"请选择一条视频流(输入序号): \", false);\n            Console.ForegroundColor = ConsoleColor.Cyan;\n            vIndex = Convert.ToInt32(Console.ReadLine());\n            if (vIndex > parsedResult.VideoTracks.Count || vIndex < 0) vIndex = 0;\n            Console.ResetColor();\n        }\n        if (parsedResult.AudioTracks.Any())\n        {\n            Log(\"请选择一条音频流(输入序号): \", false);\n            Console.ForegroundColor = ConsoleColor.Cyan;\n            aIndex = Convert.ToInt32(Console.ReadLine());\n            if (aIndex > parsedResult.AudioTracks.Count || aIndex < 0) aIndex = 0;\n            Console.ResetColor();\n        }\n    }\n\n    /// <summary>\n    /// 下载轨道\n    /// </summary>\n    /// <returns></returns>\n    private static async Task DownloadTrackAsync(string url, string destPath, DownloadConfig downloadConfig, bool video)\n    {\n        if (downloadConfig.MultiThread && !url.Contains(\"-cmcc-\"))\n        {\n            await MultiThreadDownloadFileAsync(url, destPath, downloadConfig);\n            Log($\"合并{(video ? \"视频\" : \"音频\")}分片...\");\n            CombineMultipleFilesIntoSingleFile(GetFiles(Path.GetDirectoryName(destPath)!, $\".{(video ? \"v\" : \"a\")}clip\"), destPath);\n            Log(\"清理分片...\");\n            foreach (var file in new DirectoryInfo(Path.GetDirectoryName(destPath)!).EnumerateFiles(\"*.?clip\")) file.Delete();\n        }\n        else\n        {\n            if (downloadConfig.MultiThread && url.Contains(\"-cmcc-\"))\n            {\n                LogWarn(\"检测到cmcc域名cdn, 已经禁用多线程\");\n                downloadConfig.ForceHttp = false;\n            }\n            await DownloadFileAsync(url, destPath, downloadConfig);\n        }\n    }\n\n    [GeneratedRegex(\"://.*:\\\\d+/\")]\n    private static partial Regex PcdnRegex();\n    [GeneratedRegex(\"://.*akamaized\\\\.net/\")]\n    private static partial Regex AkamRegex();\n    [GeneratedRegex(\"://[^/]+/\")]\n    private static partial Regex UposRegex();\n}"
  },
  {
    "path": "BBDown/Program.cs",
    "content": "using System;\r\nusing System.Collections.Generic;\r\nusing System.CommandLine;\r\nusing System.CommandLine.Parsing;\r\nusing System.IO;\r\nusing System.Net;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\nusing static BBDown.Core.Entity.Entity;\r\nusing static BBDown.BBDownUtil;\r\nusing static BBDown.BBDownDownloadUtil;\r\nusing static BBDown.Core.Parser;\r\nusing static BBDown.Core.Logger;\r\nusing System.Linq;\r\nusing System.Text.Json;\r\nusing System.Text.RegularExpressions;\r\nusing BBDown.Core;\r\nusing BBDown.Core.Util;\r\nusing System.Text.Json.Serialization;\r\nusing System.CommandLine.Builder;\r\nusing BBDown.Core.Entity;\r\n\r\nnamespace BBDown;\r\n\r\npartial class Program\r\n{\r\n    private static readonly string BACKUP_HOST = \"upos-sz-mirrorcoso1.bilivideo.com\";\r\n    public static string SinglePageDefaultSavePath { get; set; } = \"<videoTitle>\";\r\n    public static string MultiPageDefaultSavePath { get; set; } = \"<videoTitle>/[P<pageNumberWithZero>]<pageTitle>\";\r\n\r\n    public static readonly string APP_DIR = Path.GetDirectoryName(Environment.ProcessPath)!;\r\n\r\n    private static string FormatTimeStamp(long ts, string format)\r\n    {\r\n        try\r\n        {\r\n            return ts == 0 ? \"null\" : DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime().ToString(format);\r\n        }\r\n        catch (Exception ex)\r\n        {\r\n            LogError($\"格式化日期出错: {ex.Message}\");\r\n            return ts.ToString();\r\n        }\r\n    }\r\n\r\n    [JsonSerializable(typeof(MyOption))]\r\n    [JsonSerializable(typeof(ServeRequestOptions))]\r\n    partial class MyOptionJsonContext : JsonSerializerContext { }\r\n\r\n    private static void Console_CancelKeyPress(object? sender, ConsoleCancelEventArgs e)\r\n    {\r\n        LogWarn(\"Force Exit...\");\r\n        try\r\n        {\r\n            Console.ResetColor();\r\n            Console.CursorVisible = true;\r\n            if (!OperatingSystem.IsWindows())\r\n                System.Diagnostics.Process.Start(\"stty\", \"echo\");\r\n        }\r\n        catch { }\r\n        Environment.Exit(0);\r\n    }\r\n\r\n    public static async Task<int> Main(params string[] args)\r\n    {\r\n        Console.CancelKeyPress += Console_CancelKeyPress;\r\n        ServicePointManager.DefaultConnectionLimit = 2048;\r\n\r\n        var rootCommand = CommandLineInvoker.GetRootCommand(RunApp);\r\n        Command loginCommand = new(\r\n            \"login\",\r\n            \"通过APP扫描二维码以登录您的WEB账号\");\r\n        rootCommand.AddCommand(loginCommand);\r\n        Command loginTVCommand = new(\r\n            \"logintv\",\r\n            \"通过APP扫描二维码以登录您的TV账号\");\r\n        rootCommand.AddCommand(loginTVCommand);\r\n        var serverUrlOpt = new Option<string>(\r\n            [\"--listen\", \"-l\"],\r\n            description: \"服务器监听url\");\r\n        Command runAsServerCommand = new(\r\n                \"serve\",\r\n                \"以服务器模式运行\")\r\n            { serverUrlOpt };\r\n        runAsServerCommand.SetHandler(StartServer, serverUrlOpt);\r\n        rootCommand.AddCommand(runAsServerCommand);\r\n        rootCommand.Description = \"BBDown是一个免费且便捷高效的哔哩哔哩下载/解析软件.\";\r\n        rootCommand.TreatUnmatchedTokensAsErrors = true;\r\n\r\n        //WEB登录\r\n        loginCommand.SetHandler(BBDownLoginUtil.LoginWEB);\r\n\r\n        //TV登录\r\n        loginTVCommand.SetHandler(BBDownLoginUtil.LoginTV);\r\n\r\n        var parser = new CommandLineBuilder(rootCommand)\r\n            .UseDefaults()\r\n            .EnablePosixBundling(false)\r\n            .UseExceptionHandler((ex, context) =>\r\n            {\r\n                LogError(ex.Message);\r\n                try { Console.CursorVisible = true; } catch { }\r\n                Thread.Sleep(3000);\r\n                Environment.Exit(1);\r\n            }, 1)\r\n            .Build();\r\n\r\n        var newArgsList = new List<string>();\r\n        var commandLineResult = rootCommand.Parse(args);\r\n\r\n        //显式抛出异常\r\n        if (commandLineResult.Errors.Any())\r\n        {\r\n            Console.ForegroundColor = ConsoleColor.Red;\r\n            Console.Error.WriteLine(commandLineResult.Errors.First().Message);\r\n            Console.ResetColor();\r\n            Console.Error.WriteLine($\"请使用 BBDown --help 查看帮助\");\r\n            return 1;\r\n        }\r\n\r\n        if (commandLineResult.CommandResult.Command.Name.ToLower() != Path.GetFileNameWithoutExtension(Environment.ProcessPath)!.ToLower() && Path.GetFileNameWithoutExtension(Environment.ProcessPath)!.ToLower() != \"dotnet\")\r\n        {\r\n            // 服务器模式需要完整的arg列表\r\n            if (commandLineResult.CommandResult.Command.Name.ToLower() == \"serve\")\r\n            {\r\n                return await parser.InvokeAsync(args.ToArray());\r\n            }\r\n            newArgsList.Add(commandLineResult.CommandResult.Command.Name);\r\n            return await parser.InvokeAsync(newArgsList.ToArray());\r\n        }\r\n\r\n        foreach (var item in commandLineResult.CommandResult.Children)\r\n        {\r\n            if (item is ArgumentResult a)\r\n            {\r\n                newArgsList.Add(a.Tokens[0].Value);\r\n            }\r\n            else if (item is OptionResult o)\r\n            {\r\n                newArgsList.Add(\"--\" + o.Option.Name);\r\n                newArgsList.AddRange(o.Tokens.Select(t => t.Value));\r\n            }\r\n        }\r\n\r\n        if (newArgsList.Contains(\"--debug\"))\r\n        {\r\n            Config.DEBUG_LOG = true;\r\n        }\r\n\r\n        Console.BackgroundColor = ConsoleColor.DarkBlue;\r\n        Console.ForegroundColor = ConsoleColor.White;\r\n        var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!;\r\n        Console.Write($\"BBDown version {ver.Major}.{ver.Minor}.{ver.Build}, Bilibili Downloader.\\r\\n\");\r\n        Console.ResetColor();\r\n        Console.Write(\"遇到问题请首先到以下地址查阅有无相关信息：\\r\\nhttps://github.com/nilaoda/BBDown/issues\\r\\n\");\r\n        Console.WriteLine();\r\n\r\n        //处理配置文件\r\n        BBDownConfigParser.HandleConfig(newArgsList, rootCommand);\r\n\r\n        return await parser.InvokeAsync(newArgsList.ToArray());\r\n    }\r\n\r\n    private static Task RunApp(MyOption myOption)\r\n    {\r\n        //检测更新\r\n        _ = CheckUpdateAsync();\r\n        return DoWorkAsync(myOption);\r\n    }\r\n\r\n    private static void StartServer(string? listenUrl)\r\n    {\r\n        var defaultListenUrl = \"http://0.0.0.0:23333\";\r\n        //检测更新\r\n        _ = CheckUpdateAsync();\r\n        var server = new BBDownApiServer();\r\n        server.SetUpServer();\r\n        server.Run(string.IsNullOrEmpty(listenUrl) ? defaultListenUrl : listenUrl);\r\n    }\r\n\r\n    public static (Dictionary<string, byte> encodingPriority, Dictionary<string, int> dfnPriority, string? firstEncoding,\r\n        bool downloadDanmaku, BBDownDanmakuFormat[] downloadDanmakuFormats, string input, string savePathFormat, string lang, string aidOri, int delay)\r\n        SetUpWork(MyOption myOption)\r\n    {\r\n        //处理废弃选项\r\n        HandleDeprecatedOptions(myOption);\r\n\r\n        //处理冲突选项\r\n        HandleConflictingOptions(myOption);\r\n\r\n        //寻找并设置所需的二进制文件路径\r\n        FindBinaries(myOption);\r\n\r\n        //切换工作目录\r\n        ChangeWorkingDir(myOption);\r\n\r\n        //解析优先级\r\n        var encodingPriority = ParseEncodingPriority(myOption, out var firstEncoding);\r\n        var dfnPriority = ParseDfnPriority(myOption);\r\n\r\n        //优先使用用户设置的UA\r\n        HTTPUtil.UserAgent = string.IsNullOrEmpty(myOption.UserAgent) ? HTTPUtil.UserAgent : myOption.UserAgent;\r\n\r\n        bool downloadDanmaku = myOption.DownloadDanmaku || myOption.DanmakuOnly;\r\n        BBDownDanmakuFormat[] downloadDanmakuFormats = ParseDownloadDanmakuFormats(myOption);\r\n\r\n        string input = myOption.Url;\r\n        string savePathFormat = myOption.FilePattern;\r\n        string lang = myOption.Language;\r\n        string aidOri = \"\"; //原始aid\r\n        int delay = Convert.ToInt32(myOption.DelayPerPage);\r\n        Config.DEBUG_LOG = myOption.Debug;\r\n        Config.HOST = myOption.Host;\r\n        Config.EPHOST = myOption.EpHost;\r\n        Config.TVHOST = myOption.TvHost;\r\n        Config.AREA = myOption.Area;\r\n        Config.COOKIE = myOption.Cookie;\r\n        Config.TOKEN = myOption.AccessToken.Replace(\"access_token=\", \"\");\r\n\r\n        LogDebug(\"AppDirectory: {0}\", APP_DIR);\r\n        LogDebug(\"运行参数：{0}\", JsonSerializer.Serialize(myOption, MyOptionJsonContext.Default.MyOption));\r\n        return (encodingPriority, dfnPriority, firstEncoding, downloadDanmaku, downloadDanmakuFormats, input, savePathFormat, lang, aidOri, delay);\r\n    }\r\n\r\n    public static async Task<(string fetchedAid, VInfo vInfo, string apiType)> GetVideoInfoAsync(MyOption myOption, string aidOri, string input)\r\n    {\r\n        // 加载认证信息\r\n        LoadCredentials(myOption);\r\n\r\n        // 检测是否登录了账号\r\n        if (myOption is { UseIntlApi: false, UseTvApi: false } && Config.AREA == \"\")\r\n        {\r\n            Log(\"检测账号登录...\");\r\n            if (!await CheckLogin(Config.COOKIE))\r\n            {\r\n                LogWarn(\"你尚未登录B站账号, 解析可能受到限制\");\r\n            }\r\n        }\r\n\r\n        Log(\"获取aid...\");\r\n        aidOri = await GetAvIdAsync(input);\r\n        Log($\"获取aid结束: {aidOri}\");\r\n\r\n        if (string.IsNullOrEmpty(aidOri))\r\n        {\r\n            throw new Exception(\"输入有误\");\r\n        }\r\n\r\n        Log(\"获取视频信息...\");\r\n        IFetcher fetcher = FetcherFactory.CreateFetcher(aidOri, myOption.UseIntlApi);\r\n        VInfo? vInfo = null;\r\n\r\n        // 只输入 EP/SS 时优先按番剧查找，如果找不到则尝试按课程查找\r\n        try\r\n        {\r\n            vInfo = await fetcher.FetchAsync(aidOri);\r\n        }\r\n        catch (KeyNotFoundException e)\r\n        {\r\n            if (e.Message != \"Arg_KeyNotFound\") throw; // 错误消息不符合预期，抛出异常\r\n            if (aidOri.StartsWith(\"cheese:\")) throw; // 已经按课程查找过，不再重复尝试\r\n\r\n            LogWarn(\"未找到此 EP/SS 对应番剧信息, 正在尝试按课程查找。\");\r\n\r\n            aidOri = aidOri.Replace(\"ep\", \"cheese\");\r\n            Log(\"新的 aid: \" + aidOri);\r\n\r\n            if (string.IsNullOrEmpty(aidOri))\r\n            {\r\n                throw new Exception(\"输入有误\");\r\n            }\r\n\r\n            Log(\"获取视频信息...\");\r\n            fetcher = FetcherFactory.CreateFetcher(aidOri, myOption.UseIntlApi);\r\n            vInfo = await fetcher.FetchAsync(aidOri);\r\n        }\r\n\r\n        string title = vInfo.Title;\r\n        long pubTime = vInfo.PubTime;\r\n        LogColor(\"视频标题: \" + title);\r\n        if (pubTime != 0)\r\n        {\r\n            Log(\"发布时间: \" + FormatTimeStamp(pubTime, \"yyyy-MM-dd HH:mm:ss zzz\"));\r\n        }\r\n        var bvid = vInfo.PagesInfo.FirstOrDefault()?.bvid;\r\n        if (!string.IsNullOrEmpty(bvid) && !myOption.UseIntlApi)\r\n        {\r\n            Log($\"视频URL: https://www.bilibili.com/video/{bvid}/\");\r\n        }\r\n        var mid = vInfo.PagesInfo.FirstOrDefault(p => !string.IsNullOrEmpty(p.ownerMid))?.ownerMid;\r\n        if (!string.IsNullOrEmpty(mid))\r\n        {\r\n            Log($\"UP主页: https://space.bilibili.com/{mid}\");\r\n        }\r\n\r\n        if (vInfo.IsSteinGate && myOption.UseTvApi)\r\n        {\r\n            Log(\"视频为互动视频，暂时不支持tv下载，修改为默认下载\");\r\n            myOption.UseTvApi = false;\r\n        }\r\n        string apiType = myOption.UseTvApi ? \"TV\" : (myOption.UseAppApi ? \"APP\" : (myOption.UseIntlApi ? \"INTL\" : \"WEB\"));\r\n\r\n        //打印分P信息\r\n        List<Page> pagesInfo = vInfo.PagesInfo;\r\n        bool more = false;\r\n        foreach (Page p in pagesInfo)\r\n        {\r\n            if (!myOption.ShowAll)\r\n            {\r\n                if (more && p.index != pagesInfo.Count) continue;\r\n                if (!more && p.index > 5)\r\n                {\r\n                    Log(\"......\");\r\n                    more = true;\r\n                    continue;\r\n                }\r\n            }\r\n\r\n            Log($\"P{p.index}: [{p.cid}] [{p.title}] [{FormatTime(p.dur)}]\");\r\n        }\r\n        return (aidOri, vInfo, apiType);\r\n    }\r\n\r\n    public static async Task DownloadPagesAsync(MyOption myOption, VInfo vInfo, Dictionary<string, byte> encodingPriority, Dictionary<string, int> dfnPriority,\r\n        string? firstEncoding, bool downloadDanmaku, BBDownDanmakuFormat[] downloadDanmakuFormats, string input, string savePathFormat, string lang, string aidOri, int delay, string apiType, DownloadTask? relatedTask = null)\r\n    {\r\n        List<Page> pagesInfo = vInfo.PagesInfo;\r\n        bool bangumi = vInfo.IsBangumi;\r\n        bool cheese = vInfo.IsCheese;\r\n        //获取已选择的分P列表\r\n        List<string>? selectedPages = GetSelectedPages(myOption, vInfo, input);\r\n\r\n        Log($\"共计 {pagesInfo.Count} 个分P, 已选择：\" + (selectedPages == null ? \"ALL\" : string.Join(\",\", selectedPages)));\r\n        var pagesCount = pagesInfo.Count;\r\n\r\n        //过滤不需要的分P\r\n        if (selectedPages != null)\r\n        {\r\n            pagesInfo = pagesInfo.Where(p => selectedPages.Contains(p.index.ToString())).ToList();\r\n        }\r\n\r\n        // 根据p数选择存储路径\r\n        savePathFormat = string.IsNullOrEmpty(myOption.FilePattern) ? SinglePageDefaultSavePath : myOption.FilePattern;\r\n        // 1. 多P; 2. 只有1P, 但是是番剧, 尚未完结时 按照多P处理\r\n        if (pagesCount > 1 || (bangumi && !vInfo.IsBangumiEnd))\r\n        {\r\n            savePathFormat = string.IsNullOrEmpty(myOption.MultiFilePattern) ? MultiPageDefaultSavePath : myOption.MultiFilePattern;\r\n        }\r\n\r\n        foreach (Page p in pagesInfo)\r\n        {\r\n            if (pagesInfo.Count > 1 && delay > 0)\r\n            {\r\n                Log($\"停顿{delay}秒...\");\r\n                await Task.Delay(delay * 1000);\r\n            }\r\n            Log($\"开始解析P{p.index}: {p.aid}... ({pagesInfo.IndexOf(p) + 1} of {pagesInfo.Count})\");\r\n\r\n            if (myOption.SaveArchivesToFile)\r\n            {\r\n                if (CheckAidFromFile(p.aid))\r\n                {\r\n\r\n                    Log($\"aid: {p.aid}已下载过, 跳过下载...\");\r\n                    continue;\r\n                }\r\n            }\r\n\r\n            await DownloadPageAsync(p, myOption, vInfo, pagesInfo, encodingPriority, dfnPriority, firstEncoding,\r\n                downloadDanmaku, downloadDanmakuFormats, input, savePathFormat, lang, aidOri, apiType, relatedTask);\r\n\r\n            if (myOption.SaveArchivesToFile)\r\n            {\r\n                SaveAidToFile(p.aid);\r\n            }\r\n        }\r\n\r\n        Log(\"任务完成\");\r\n    }\r\n\r\n    private static async Task DownloadPageAsync(Page p, MyOption myOption, VInfo vInfo, List<Page> selectedPagesInfo, Dictionary<string, byte> encodingPriority, Dictionary<string, int> dfnPriority,\r\n        string? firstEncoding, bool downloadDanmaku, BBDownDanmakuFormat[] downloadDanmakuFormats, string input, string savePathFormat, string lang, string aidOri, string apiType, DownloadTask? relatedTask = null)\r\n    {\r\n        string desc = string.IsNullOrEmpty(p.desc) ? vInfo.Desc : p.desc;\r\n        bool bangumi = vInfo.IsBangumi;\r\n        var pagesCount = selectedPagesInfo.Count;\r\n        List<Subtitle> subtitleInfo = [];\r\n        string title = vInfo.Title;\r\n        string pic = vInfo.Pic;\r\n        long pubTime = vInfo.PubTime;\r\n        bool selected = false; //用户是否已经手动选择过了轨道\r\n        int retryCount = 0;\r\n        downloadPage:\r\n        try\r\n        {\r\n            LogDebug(\"尝试获取章节信息...\");\r\n            p.points = await FetchPointsAsync(p.cid, p.aid);\r\n\r\n            string videoPath = $\"{p.aid}/{p.aid}.P{p.index}.{p.cid}.mp4\";\r\n            string audioPath = $\"{p.aid}/{p.aid}.P{p.index}.{p.cid}.m4a\";\r\n            var coverPath = $\"{p.aid}/{p.aid}.jpg\";\r\n\r\n            //处理文件夹以.结尾导致的异常情况\r\n            if (title.EndsWith('.')) title += \"_fix\";\r\n            //处理文件夹以.开头导致的异常情况\r\n            if (title.StartsWith('.')) title = \"_\" + title;\r\n\r\n            //处理封面&&字幕\r\n            if (!myOption.OnlyShowInfo)\r\n            {\r\n                if (!Directory.Exists(p.aid))\r\n                {\r\n                    Directory.CreateDirectory(p.aid);\r\n                }\r\n                if (!myOption.SkipCover && !myOption.SubOnly && !File.Exists(coverPath) && !myOption.DanmakuOnly && !myOption.CoverOnly)\r\n                {\r\n                    await DownloadFileAsync(pic == \"\" ? p.cover! : pic, coverPath, new DownloadConfig());\r\n                }\r\n\r\n                if (!myOption.SkipSubtitle && !myOption.DanmakuOnly && !myOption.CoverOnly)\r\n                {\r\n                    LogDebug(\"获取字幕...\");\r\n                    subtitleInfo = await SubUtil.GetSubtitlesAsync(p.aid, p.cid, p.epid, p.index, myOption.UseIntlApi);\r\n                    if (myOption.SkipAi && subtitleInfo.Any())\r\n                    {\r\n                        Log($\"跳过下载AI字幕\");\r\n                        subtitleInfo = subtitleInfo.Where(s => !s.lan.StartsWith(\"ai-\")).ToList();\r\n                    }\r\n                    foreach (Subtitle s in subtitleInfo)\r\n                    {\r\n                        Log($\"下载字幕 {s.lan} => {SubUtil.GetSubtitleCode(s.lan).Item2}...\");\r\n                        LogDebug(\"下载：{0}\", s.url);\r\n                        await SubUtil.SaveSubtitleAsync(s.url, s.path);\r\n                        if (myOption.SubOnly && File.Exists(s.path) && File.ReadAllText(s.path) != \"\")\r\n                        {\r\n                            var _outSubPath = FormatSavePath(savePathFormat, title, null, null, p, pagesCount, apiType, pubTime);\r\n                            if (_outSubPath.Contains('/'))\r\n                            {\r\n                                if (!Directory.Exists(_outSubPath.Split('/').First()))\r\n                                    Directory.CreateDirectory(_outSubPath.Split('/').First());\r\n                            }\r\n                            _outSubPath = Path.ChangeExtension(_outSubPath, $\".{s.lan}.srt\");\r\n                            File.Move(s.path, _outSubPath, true);\r\n                        }\r\n                    }\r\n                }\r\n\r\n                if (myOption.SubOnly)\r\n                {\r\n                    if (Directory.Exists(p.aid) && Directory.GetFiles(p.aid).Length == 0) Directory.Delete(p.aid, true);\r\n                    return;\r\n                }\r\n            }\r\n\r\n            //调用解析\r\n            ParsedResult parsedResult = await ExtractTracksAsync(aidOri, p.aid, p.cid, p.epid, myOption.UseTvApi, myOption.UseIntlApi, myOption.UseAppApi, firstEncoding);\r\n            List<AudioMaterial> audioMaterial = [];\r\n            if (!p.points.Any())\r\n            {\r\n                p.points = parsedResult.ExtraPoints;\r\n            }\r\n\r\n            if (Config.DEBUG_LOG)\r\n            {\r\n                File.WriteAllText($\"debug_{DateTime.Now:yyyyMMddHHmmssfff}.json\", parsedResult.WebJsonString);\r\n            }\r\n\r\n            var savePath = \"\";\r\n\r\n            var downloadConfig = new DownloadConfig()\r\n            {\r\n                UseAria2c = myOption.UseAria2c,\r\n                Aria2cArgs = myOption.Aria2cArgs,\r\n                ForceHttp = myOption.ForceHttp,\r\n                MultiThread = myOption.MultiThread,\r\n                RelatedTask = relatedTask,\r\n            };\r\n\r\n            //此处代码简直灾难, 后续优化吧\r\n            if ((parsedResult.VideoTracks.Any() || parsedResult.AudioTracks.Any()) && !parsedResult.Clips.Any())   //dash\r\n            {\r\n                if (parsedResult.VideoTracks.Count == 0)\r\n                {\r\n                    LogWarn(\"没有找到符合要求的视频流\");\r\n                    if (myOption.VideoOnly) return;\r\n                }\r\n                if (parsedResult.AudioTracks.Count == 0)\r\n                {\r\n                    LogWarn(\"没有找到符合要求的音频流\");\r\n                    if (myOption.AudioOnly) return;\r\n                }\r\n\r\n                if (myOption.AudioOnly)\r\n                {\r\n                    parsedResult.VideoTracks.Clear();\r\n                }\r\n                if (myOption.VideoOnly)\r\n                {\r\n                    parsedResult.AudioTracks.Clear();\r\n                    parsedResult.BackgroundAudioTracks.Clear();\r\n                    parsedResult.RoleAudioList.Clear();\r\n                }\r\n\r\n                //排序\r\n                parsedResult.VideoTracks = SortTracks(parsedResult.VideoTracks, dfnPriority, encodingPriority, myOption.VideoAscending);\r\n                parsedResult.AudioTracks = SortTracks(parsedResult.AudioTracks, encodingPriority, myOption.AudioAscending);\r\n                parsedResult.BackgroundAudioTracks = SortTracks(parsedResult.BackgroundAudioTracks, encodingPriority, myOption.AudioAscending);\r\n                foreach (var role in parsedResult.RoleAudioList)\r\n                {\r\n                    role.audio = SortTracks(role.audio, encodingPriority, myOption.AudioAscending);\r\n                }\r\n\r\n                //打印轨道信息\r\n                if (!myOption.HideStreams)\r\n                {\r\n                    PrintAllTracksInfo(parsedResult, p.dur, myOption.OnlyShowInfo);\r\n                }\r\n\r\n                //仅展示 跳过下载\r\n                if (myOption.OnlyShowInfo)\r\n                {\r\n                    return;\r\n                }\r\n\r\n                int vIndex = 0; //用户手动选择的视频序号\r\n                int aIndex = 0; //用户手动选择的音频序号\r\n\r\n                //选择轨道\r\n                if (myOption.Interactive && !selected)\r\n                {\r\n                    SelectTrackManually(parsedResult, ref vIndex, ref aIndex);\r\n                    selected = true;\r\n                }\r\n\r\n                Video? selectedVideo = parsedResult.VideoTracks.ElementAtOrDefault(vIndex);\r\n                Audio? selectedAudio = parsedResult.AudioTracks.ElementAtOrDefault(aIndex);\r\n                Audio? selectedBackgroundAudio = parsedResult.BackgroundAudioTracks.ElementAtOrDefault(aIndex);\r\n\r\n                LogDebug(\"Format Before: \" + savePathFormat);\r\n                savePath = FormatSavePath(savePathFormat, title, selectedVideo, selectedAudio, p, pagesCount, apiType, pubTime);\r\n                LogDebug(\"Format After: \" + savePath);\r\n\r\n                if (downloadDanmaku)\r\n                {\r\n                    var danmakuXmlPath = Path.ChangeExtension(savePath, \".xml\");\r\n                    var danmakuAssPath = Path.ChangeExtension(savePath, \".ass\");\r\n                    Log(\"正在下载弹幕Xml文件\");\r\n                    var danmakuUrl = $\"https://comment.bilibili.com/{p.cid}.xml\";\r\n                    await DownloadFileAsync(danmakuUrl, danmakuXmlPath, downloadConfig);\r\n                    var danmakus = DanmakuUtil.ParseXml(danmakuXmlPath);\r\n                    if (danmakus == null)\r\n                    {\r\n                        Log(\"弹幕Xml解析失败, 删除Xml...\");\r\n                        File.Delete(danmakuXmlPath);\r\n                    }\r\n                    else if (danmakus.Length == 0)\r\n                    {\r\n                        Log(\"当前视频没有弹幕, 删除Xml...\");\r\n                        File.Delete(danmakuXmlPath);\r\n                    }\r\n                    else if (downloadDanmakuFormats.Contains(BBDownDanmakuFormat.Ass))\r\n                    {\r\n                        Log(\"正在保存弹幕Ass文件...\");\r\n                        await DanmakuUtil.SaveAsAssAsync(danmakus, danmakuAssPath);\r\n                    }\r\n\r\n                    // delete xml if possible\r\n                    if (!downloadDanmakuFormats.Contains(BBDownDanmakuFormat.Xml) && File.Exists(danmakuXmlPath)) \r\n                    {\r\n                        File.Delete(danmakuXmlPath);\r\n                    }\r\n\r\n                    if (myOption.DanmakuOnly)\r\n                    {\r\n                        if (Directory.Exists(p.aid))\r\n                        {\r\n                            Directory.Delete(p.aid);\r\n                        }\r\n                        return;\r\n                    }\r\n                }\r\n\r\n                if (myOption.CoverOnly)\r\n                {\r\n                    var coverUrl = pic == \"\" ? p.cover! : pic;\r\n                    var newCoverPath = Path.ChangeExtension(savePath, Path.GetExtension(coverUrl));\r\n                    await DownloadFileAsync(coverUrl, newCoverPath, downloadConfig);\r\n                    if (Directory.Exists(p.aid) && Directory.GetFiles(p.aid).Length == 0) Directory.Delete(p.aid, true);\r\n                    relatedTask?.SavePaths.Add(newCoverPath);\r\n                }\r\n\r\n                Log($\"已选择的流:\");\r\n                PrintSelectedTrackInfo(selectedVideo, selectedAudio, p.dur);\r\n\r\n                //用户开启了强制替换\r\n                if (myOption.ForceReplaceHost && string.IsNullOrEmpty(myOption.UposHost))\r\n                {\r\n                    myOption.UposHost = BACKUP_HOST;\r\n                }\r\n\r\n                //处理PCDN\r\n                HandlePcdn(myOption, selectedVideo, selectedAudio);\r\n\r\n                if (!myOption.OnlyShowInfo && File.Exists(savePath) && new FileInfo(savePath).Length != 0)\r\n                {\r\n                    Log($\"{savePath}已存在, 跳过下载...\");\r\n                    relatedTask?.SavePaths.Add(savePath);\r\n                    File.Delete(coverPath);\r\n                    if (Directory.Exists(p.aid) && Directory.GetFiles(p.aid).Length == 0)\r\n                    {\r\n                        Directory.Delete(p.aid, true);\r\n                    }\r\n                    return;\r\n                }\r\n\r\n                if (selectedVideo != null)\r\n                {\r\n                    //杜比视界, 若ffmpeg版本小于5.0, 使用mp4box封装\r\n                    if (selectedVideo.dfn == Config.qualitys[\"126\"] && !myOption.UseMP4box && !CheckFFmpegDOVI())\r\n                    {\r\n                        LogWarn($\"检测到杜比视界清晰度且您的ffmpeg版本小于5.0,将使用mp4box混流...\");\r\n                        myOption.UseMP4box = true;\r\n                    }\r\n                    Log($\"开始下载P{p.index}视频...\");\r\n                    await DownloadTrackAsync(selectedVideo.baseUrl, videoPath, downloadConfig, video: true);\r\n                }\r\n\r\n                if (selectedAudio != null)\r\n                {\r\n                    Log($\"开始下载P{p.index}音频...\");\r\n                    await DownloadTrackAsync(selectedAudio.baseUrl, audioPath, downloadConfig, video: false);\r\n                }\r\n\r\n                if (selectedBackgroundAudio != null)\r\n                {\r\n                    var backgroundPath = $\"{p.aid}/{p.aid}.{p.cid}.P{p.index}.back_ground.m4a\";\r\n                    Log($\"开始下载P{p.index}背景配音...\");\r\n                    await DownloadTrackAsync(selectedBackgroundAudio.baseUrl, backgroundPath, downloadConfig, video: false);\r\n                    audioMaterial.Add(new AudioMaterial(\"背景音频\", \"\", backgroundPath));\r\n                }\r\n\r\n                if (parsedResult.RoleAudioList.Any())\r\n                {\r\n                    foreach (var role in parsedResult.RoleAudioList)\r\n                    {\r\n                        Log($\"开始下载P{p.index}配音[{role.title}]...\");\r\n                        await DownloadTrackAsync(role.audio[aIndex].baseUrl, role.path, downloadConfig, video: false);\r\n                        audioMaterial.Add(new AudioMaterial(role));\r\n                    }\r\n                }\r\n\r\n                Log($\"下载P{p.index}完毕\");\r\n                if (!parsedResult.VideoTracks.Any()) videoPath = \"\";\r\n                if (!parsedResult.AudioTracks.Any()) audioPath = \"\";\r\n                if (myOption.SkipMux) return;\r\n                Log($\"开始合并音视频{(subtitleInfo.Any() ? \"和字幕\" : \"\")}...\");\r\n                if (myOption.AudioOnly)\r\n                    savePath = savePath[..^4] + \".m4a\";\r\n\r\n                var isHevc = selectedVideo?.codecs == \"HEVC\";\r\n                int code = BBDownMuxer.MuxAV(myOption.UseMP4box, p.bvid, videoPath, audioPath, audioMaterial, savePath,\r\n                    desc,\r\n                    title,\r\n                    p.ownerName ?? \"\",\r\n                    (pagesCount > 1 || (bangumi && !vInfo.IsBangumiEnd)) ? p.title : \"\",\r\n                    File.Exists(coverPath) ? coverPath : \"\",\r\n                    lang,\r\n                    subtitleInfo, myOption.AudioOnly, myOption.VideoOnly, p.points, p.pubTime, myOption.SimplyMux, isHevc);\r\n                if (code != 0 || !File.Exists(savePath) || new FileInfo(savePath).Length == 0)\r\n                {\r\n                    LogError(\"合并失败\"); return;\r\n                }\r\n                Log(\"清理临时文件...\");\r\n                Thread.Sleep(200);\r\n                if (parsedResult.VideoTracks.Any()) File.Delete(videoPath);\r\n                if (parsedResult.AudioTracks.Any()) File.Delete(audioPath);\r\n                if (p.points.Any()) File.Delete(Path.Combine(Path.GetDirectoryName(string.IsNullOrEmpty(videoPath) ? audioPath : videoPath)!, \"chapters\"));\r\n                foreach (var s in subtitleInfo) File.Delete(s.path);\r\n                foreach (var a in audioMaterial) File.Delete(a.path);\r\n                if (selectedPagesInfo.Count == 1 || p.index == selectedPagesInfo.Last().index || p.aid != selectedPagesInfo.Last().aid)\r\n                    File.Delete(coverPath);\r\n                if (Directory.Exists(p.aid) && Directory.GetFiles(p.aid).Length == 0) Directory.Delete(p.aid, true);\r\n            }\r\n            else if (parsedResult.Clips.Any() && parsedResult.Dfns.Any())   //flv\r\n            {\r\n                bool flag = false;\r\n                var clips = parsedResult.Clips;\r\n                var dfns = parsedResult.Dfns;\r\n                reParse:\r\n                //排序\r\n                parsedResult.VideoTracks = SortTracks(parsedResult.VideoTracks, dfnPriority, encodingPriority, myOption.VideoAscending);\r\n\r\n                int vIndex = 0;\r\n                if (myOption.Interactive && !flag && !selected)\r\n                {\r\n                    int i = 0;\r\n                    dfns.ForEach(key => LogColor($\"{i++}.{Config.qualitys[key]}\"));\r\n                    Log(\"请选择最想要的清晰度(输入序号): \", false);\r\n                    Console.ForegroundColor = ConsoleColor.Cyan;\r\n                    vIndex = Convert.ToInt32(Console.ReadLine());\r\n                    if (vIndex > dfns.Count || vIndex < 0) vIndex = 0;\r\n                    Console.ResetColor();\r\n                    //重新解析\r\n                    parsedResult.VideoTracks.Clear();\r\n                    parsedResult = await ExtractTracksAsync(aidOri, p.aid, p.cid, p.epid, myOption.UseTvApi, myOption.UseIntlApi, myOption.UseAppApi, firstEncoding, dfns[vIndex]);\r\n                    if (!p.points.Any()) p.points = parsedResult.ExtraPoints;\r\n                    flag = true;\r\n                    selected = true;\r\n                    goto reParse;\r\n                }\r\n\r\n                Log($\"共计{parsedResult.VideoTracks.Count}条流(共有{clips.Count}个分段).\");\r\n                int index = 0;\r\n                foreach (var v in parsedResult.VideoTracks)\r\n                {\r\n                    LogColor($\"{index++}. [{v.dfn}] [{v.res}] [{v.codecs}] [{v.fps}] [~{v.size / 1024 / v.dur * 8:00} kbps] [{FormatFileSize(v.size)}]\".Replace(\"[] \", \"\"), false);\r\n                    if (myOption.OnlyShowInfo)\r\n                    {\r\n                        clips.ForEach(Console.WriteLine);\r\n                    }\r\n                }\r\n                if (myOption.OnlyShowInfo) return;\r\n                savePath = FormatSavePath(savePathFormat, title, parsedResult.VideoTracks.ElementAtOrDefault(vIndex), null, p, pagesCount, apiType, pubTime);\r\n                if (File.Exists(savePath) && new FileInfo(savePath).Length != 0)\r\n                {\r\n                    Log($\"{savePath}已存在, 跳过下载...\");\r\n                    relatedTask?.SavePaths.Add(savePath);\r\n                    if (selectedPagesInfo.Count == 1 && Directory.Exists(p.aid))\r\n                    {\r\n                        Directory.Delete(p.aid, true);\r\n                    }\r\n                    return;\r\n                }\r\n                var pad = string.Empty.PadRight(clips.Count.ToString().Length, '0');\r\n                for (int i = 0; i < clips.Count; i++)\r\n                {\r\n                    var link = clips[i];\r\n                    videoPath = $\"{p.aid}/{p.aid}.P{p.index}.{p.cid}.{i.ToString(pad)}.mp4\";\r\n                    Log($\"开始下载P{p.index}视频, 片段({(i + 1).ToString(pad)}/{clips.Count})...\");\r\n                    await DownloadTrackAsync(link, videoPath, downloadConfig, video: true);\r\n                }\r\n                Log($\"下载P{p.index}完毕\");\r\n                Log(\"开始合并分段...\");\r\n                var files = GetFiles(Path.GetDirectoryName(videoPath)!, \".mp4\");\r\n                videoPath = $\"{p.aid}/{p.aid}.P{p.index}.{p.cid}.mp4\";\r\n                BBDownMuxer.MergeFLV(files, videoPath);\r\n                if (myOption.SkipMux) return;\r\n                Log($\"开始混流视频{(subtitleInfo.Any() ? \"和字幕\" : \"\")}...\");\r\n                if (myOption.AudioOnly)\r\n                    savePath = savePath[..^4] + \".m4a\";\r\n                int code = BBDownMuxer.MuxAV(false, p.bvid, videoPath, \"\", audioMaterial, savePath,\r\n                    desc,\r\n                    title,\r\n                    p.ownerName ?? \"\",\r\n                    (pagesCount > 1 || (bangumi && !vInfo.IsBangumiEnd)) ? p.title : \"\",\r\n                    File.Exists(coverPath) ? coverPath : \"\",\r\n                    lang,\r\n                    subtitleInfo, myOption.AudioOnly, myOption.VideoOnly, p.points, p.pubTime, myOption.SimplyMux);\r\n                if (code != 0 || !File.Exists(savePath) || new FileInfo(savePath).Length == 0)\r\n                {\r\n                    LogError(\"合并失败\"); return;\r\n                }\r\n                Log(\"清理临时文件...\");\r\n                Thread.Sleep(200);\r\n                if (parsedResult.VideoTracks.Count != 0) File.Delete(videoPath);\r\n                foreach (var s in subtitleInfo) File.Delete(s.path);\r\n                foreach (var a in audioMaterial) File.Delete(a.path);\r\n                if (p.points.Any()) File.Delete(Path.Combine(Path.GetDirectoryName(string.IsNullOrEmpty(videoPath) ? audioPath : videoPath)!, \"chapters\"));\r\n                if (selectedPagesInfo.Count == 1 || p.index == selectedPagesInfo.Last().index || p.aid != selectedPagesInfo.Last().aid)\r\n                    File.Delete(coverPath);\r\n                if (Directory.Exists(p.aid) && Directory.GetFiles(p.aid).Length == 0) Directory.Delete(p.aid, true);\r\n            }\r\n            else\r\n            {\r\n                LogError(\"解析此分P失败(建议--debug查看详细信息)\");\r\n                if (parsedResult.WebJsonString.Length < 100)\r\n                {\r\n                    LogError(parsedResult.WebJsonString);\r\n                }\r\n                LogDebug(\"{0}\", parsedResult.WebJsonString);\r\n            }\r\n\r\n            if (!string.IsNullOrWhiteSpace(savePath)) {\r\n                relatedTask?.SavePaths.Add(savePath);\r\n            }\r\n        }\r\n        catch (Exception ex)\r\n        {\r\n            if (++retryCount > 2) throw;\r\n            LogError(ex.Message);\r\n            LogWarn(\"下载出现异常, 3秒后将进行自动重试...\");\r\n            await Task.Delay(3000);\r\n            goto downloadPage;\r\n        }\r\n    }\r\n\r\n    private static async Task DoWorkAsync(MyOption myOption)\r\n    {\r\n        try\r\n        {\r\n            var (encodingPriority, dfnPriority, firstEncoding, downloadDanmaku, downloadDanmakuFormats,\r\n                input, savePathFormat, lang, aidOri, delay) = SetUpWork(myOption);\r\n            var (fetchedAid, vInfo, apiType) = await GetVideoInfoAsync(myOption, aidOri, input);\r\n            await DownloadPagesAsync(myOption, vInfo, encodingPriority, dfnPriority, firstEncoding, downloadDanmaku, downloadDanmakuFormats,\r\n                input, savePathFormat, lang, fetchedAid, delay, apiType);\r\n        }\r\n        catch (Exception e)\r\n        {\r\n            Console.BackgroundColor = ConsoleColor.Red;\r\n            Console.ForegroundColor = ConsoleColor.White;\r\n            var msg = Config.DEBUG_LOG ? e.ToString() : e.Message;\r\n            Console.Write($\"{msg}{Environment.NewLine}请尝试升级到最新版本后重试!\");\r\n            Console.ResetColor();\r\n            Console.WriteLine();\r\n            Thread.Sleep(1);\r\n            Environment.Exit(1);\r\n        }\r\n    }\r\n\r\n    private static List<Video> SortTracks(List<Video> videoTracks, Dictionary<string, int> dfnPriority, Dictionary<string, byte> encodingPriority, bool videoAscending)\r\n    {\r\n        //用户同时输入了自定义分辨率优先级和自定义编码优先级, 则根据输入顺序依次进行排序\r\n        return dfnPriority.Any() && encodingPriority.Any() && Environment.CommandLine.IndexOf(\"--encoding-priority\", StringComparison.Ordinal) < Environment.CommandLine.IndexOf(\"--dfn-priority\")\r\n            ? videoTracks\r\n                .OrderBy(v => encodingPriority.GetValueOrDefault(v.codecs, (byte)100))\r\n                .ThenBy(v => dfnPriority.GetValueOrDefault(v.dfn, 100))\r\n                .ThenByDescending(v => Convert.ToInt32(v.id))\r\n                .ThenBy(v => videoAscending ? v.bandwith : -v.bandwith)\r\n                .ToList()\r\n            : videoTracks\r\n                .OrderBy(v => dfnPriority.GetValueOrDefault(v.dfn, 100))\r\n                .ThenBy(v => encodingPriority.GetValueOrDefault(v.codecs, (byte)100))\r\n                .ThenByDescending(v => Convert.ToInt32(v.id))\r\n                .ThenBy(v => videoAscending ? v.bandwith : -v.bandwith)\r\n                .ToList();\r\n    }\r\n    \r\n    private static List<Audio> SortTracks(List<Audio> audioTracks, Dictionary<string, byte> encodingPriority, bool audioAscending)\r\n    {\r\n        return audioTracks\r\n            .OrderBy(a => encodingPriority.GetValueOrDefault(a.shortCodecs, (byte)100))\r\n            .ThenBy(a => audioAscending ? a.bandwith : -a.bandwith)\r\n            .ToList();\r\n    }\r\n\r\n    private static string FormatSavePath(string savePathFormat, string title, Video? videoTrack, Audio? audioTrack, Page p, int pagesCount, string apiType, long pubTime)\r\n    {\r\n        var result = savePathFormat.Replace('\\\\', '/');\r\n        var regex = InfoRegex();\r\n        foreach (Match m in regex.Matches(result).Cast<Match>())\r\n        {\r\n            var key = m.Groups[1].Value;\r\n\r\n            //解析自定义日期格式\r\n            var defaultDateFormat = \"yyyy-MM-dd_HH-mm-ss\";\r\n            string[] prefixes = [\"publishDate:\", \"videoDate:\"];\r\n            foreach (var prefix in prefixes)\r\n            {\r\n                if (key.StartsWith(prefix))\r\n                {\r\n                    defaultDateFormat = key[(key.IndexOf(':') + 1)..];\r\n                    key = prefix.Replace(\":\", \"\");\r\n                    break;\r\n                }\r\n            }\r\n\r\n            var v = key switch\r\n            {\r\n                \"videoTitle\" => GetValidFileName(title, filterSlash: true).Trim().TrimEnd('.').Trim(),\r\n                \"pageNumber\" => p.index.ToString(),\r\n                \"pageNumberWithZero\" => p.index.ToString().PadLeft(pagesCount.ToString().Length, '0'),\r\n                \"pageTitle\" => GetValidFileName(p.title, filterSlash: true).Trim().TrimEnd('.').Trim(),\r\n                \"bvid\" => p.bvid,\r\n                \"aid\" => p.aid,\r\n                \"cid\" => p.cid,\r\n                \"ownerName\" => p.ownerName == null ? \"\" : GetValidFileName(p.ownerName, filterSlash: true).Trim().TrimEnd('.').Trim(),\r\n                \"ownerMid\" => p.ownerMid ?? \"\",\r\n                \"dfn\" => videoTrack == null ? \"\" : videoTrack.dfn,\r\n                \"res\" => videoTrack == null ? \"\" : videoTrack.res,\r\n                \"fps\" => videoTrack == null ? \"\" : videoTrack.fps,\r\n                \"videoCodecs\" => videoTrack == null ? \"\" : videoTrack.codecs,\r\n                \"videoBandwidth\" => videoTrack == null ? \"\" : videoTrack.bandwith.ToString(),\r\n                \"audioCodecs\" => audioTrack == null ? \"\" : audioTrack.codecs,\r\n                \"audioBandwidth\" => audioTrack == null ? \"\" : audioTrack.bandwith.ToString(),\r\n                \"publishDate\" => FormatTimeStamp(pubTime, defaultDateFormat),\r\n                \"videoDate\" => FormatTimeStamp(p.pubTime, defaultDateFormat),\r\n                \"apiType\" => apiType,\r\n                _ => $\"<{key}>\"\r\n            };\r\n            result = result.Replace(m.Value, v);\r\n        }\r\n        if (!result.EndsWith(\".mp4\")) { result += \".mp4\"; }\r\n        return result;\r\n    }\r\n\r\n    [GeneratedRegex(\"<([\\\\w:\\\\-.]+?)>\")]\r\n    private static partial Regex InfoRegex();\r\n}"
  },
  {
    "path": "BBDown/ProgressBar.cs",
    "content": "﻿using System;\nusing System.Text;\nusing System.Threading;\n\n/**\n * From https://gist.github.com/DanielSWolf/0ab6a96899cc5377bf54\n */\nnamespace BBDown;\n\nclass ProgressBar : IDisposable, IProgress<double>\n{\n\tprivate const int blockCount = 40;\n\tprivate readonly TimeSpan animationInterval = TimeSpan.FromSeconds(1.0 / 8);\n\tprivate const string animation = @\"|/-\\\";\n\n\tprivate readonly Timer timer;\n\n\tprivate double currentProgress = 0;\n\tprivate string currentText = string.Empty;\n\tprivate bool disposed = false;\n\tprivate int animationIndex = 0;\n\n\t//速度计算\n\tprivate readonly TimeSpan speedCalcInterval = TimeSpan.FromSeconds(1);\n\tprivate long lastDownloadedBytes = 0;\n\tprivate long downloadedBytes = 0;\n\tprivate string speedString = \"\";\n\tprivate readonly Timer speedTimer;\n\n\t//服务器模式使用，更新下载任务的进度\n\tprivate DownloadTask? RelatedTask = null;\n\n\tpublic ProgressBar(DownloadTask? task = null)\n\t{\n\t\ttimer = new Timer(TimerHandler);\n\t\tspeedTimer = new Timer(SpeedTimerHandler);\n\t\tif (task is not null) RelatedTask = task;\n\t\t// A progress bar is only for temporary display in a console window.\n\t\t// If the console output is redirected to a file, draw nothing.\n\t\t// Otherwise, we'll end up with a lot of garbage in the target file.\n\t\t// However, if this progressbar is for a server download task,\n\t\t// we still need it to report progress no matter where stdout is redirected.\n\t\t// The prevention of writing garbage should be controlled on the methods do the actual writing.\n\t\tif (!Console.IsOutputRedirected || RelatedTask is not null)\n\t\t{\n\t\t\tResetTimer();\n\t\t\tResetSpeedTimer();\n\n\t\t}\n\t}\n\n\tpublic void Report(double value)\n\t{\n\t\t// Make sure value is in [0..1] range\n\t\tvalue = Math.Max(0, Math.Min(1, value));\n\t\tInterlocked.Exchange(ref currentProgress, value);\n\t}\n\n\tpublic void Report(double value, long bytesCount)\n\t{\n\t\t// Make sure value is in [0..1] range\n\t\tvalue = Math.Max(0, Math.Min(1, value));\n\t\tInterlocked.Exchange(ref currentProgress, value);\n\t\tInterlocked.Exchange(ref downloadedBytes, bytesCount);\n\t}\n\n\tprivate void SpeedTimerHandler(object? state)\n\t{\n\t\tlock (speedTimer)\n\t\t{\n\t\t\tif (disposed) return;\n\n\t\t\tif (downloadedBytes > 0 && downloadedBytes - lastDownloadedBytes > 0)\n\t\t\t{\n\t\t\t\tvar delta = downloadedBytes - lastDownloadedBytes;\n\t\t\t\tspeedString = \" - \" + BBDownUtil.FormatFileSize(delta) + \"/s\";\n\t\t\t\tlastDownloadedBytes = downloadedBytes;\n\t\t\t\tif (RelatedTask is not null) \n\t\t\t\t{\n\t\t\t\t\tRelatedTask.DownloadSpeed = delta;\n\t\t\t\t\tRelatedTask.TotalDownloadedBytes += delta;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tResetSpeedTimer();\n\t\t}\n\t}\n\n\tprivate void TimerHandler(object? state)\n\t{\n\t\tlock (timer)\n\t\t{\n\t\t\tif (disposed) return;\n\n\t\t\tint progressBlockCount = (int)(currentProgress * blockCount);\n\t\t\tdouble percent = currentProgress * 100;\n\t\t\tstring text = string.Format(\"                            [{0}{1}] {2,3:0.00}% {3}{4}\",\n\t\t\t\tnew string('#', progressBlockCount), new string('-', blockCount - progressBlockCount), percent,\n\t\t\t\tanimation[animationIndex++ % animation.Length],\n\t\t\t\tspeedString);\n\t\t\tUpdateText(text);\n\t\t\tif (RelatedTask is not null) \n\t\t\t{\n\t\t\t\tRelatedTask.Progress = currentProgress;\n\t\t\t}\n\n\t\t\tResetTimer();\n\t\t}\n\t}\n\n\tprivate void UpdateText(string text)\n\t{\n\t\t// Write nothing when output is redirected\n\t\tif (Console.IsOutputRedirected) return;\n\t\t// Get length of common portion\n\t\tint commonPrefixLength = 0;\n\t\tint commonLength = Math.Min(currentText.Length, text.Length);\n\t\twhile (commonPrefixLength < commonLength && text[commonPrefixLength] == currentText[commonPrefixLength])\n\t\t{\n\t\t\tcommonPrefixLength++;\n\t\t}\n\n\t\t// Backtrack to the first differing character\n\t\tStringBuilder outputBuilder = new();\n\t\toutputBuilder.Append('\\b', currentText.Length - commonPrefixLength);\n\n\t\t// Output new suffix\n\t\toutputBuilder.Append(text[commonPrefixLength..]);\n\n\t\t// If the new text is shorter than the old one: delete overlapping characters\n\t\tint overlapCount = currentText.Length - text.Length;\n\t\tif (overlapCount > 0)\n\t\t{\n\t\t\toutputBuilder.Append(' ', overlapCount);\n\t\t\toutputBuilder.Append('\\b', overlapCount);\n\t\t}\n\n\t\tConsole.Write(outputBuilder);\n\t\tcurrentText = text;\n\t}\n\n\tprivate void ResetTimer()\n\t{\n\t\ttimer.Change(animationInterval, TimeSpan.FromMilliseconds(-1));\n\t}\n\n\tprivate void ResetSpeedTimer()\n\t{\n\t\tspeedTimer.Change(speedCalcInterval, TimeSpan.FromMilliseconds(-1));\n\t}\n\n\tpublic void Dispose()\n\t{\n\t\tlock (timer)\n\t\t{\n\t\t\tdisposed = true;\n\t\t\tUpdateText(string.Empty);\n\t\t}\n\t}\n}"
  },
  {
    "path": "BBDown/Properties/launchSettings.json",
    "content": "{\r\n  \"profiles\": {\r\n    \"BBDown\": {\r\n      \"commandName\": \"Project\",\r\n      \"environmentVariables\": {\r\n        \"ASPNETCORE_ENVIRONMENT\": \"Development\"\r\n      },\r\n      \"applicationUrl\": \"http://localhost:58682\"\r\n    },\r\n    \"BBDown.Server\": {\r\n      \"commandName\": \"Project\",\r\n      \"commandLineArgs\": \"serve\",\r\n      \"environmentVariables\": {\r\n        \"ASPNETCORE_ENVIRONMENT\": \"Development\"\r\n      },\r\n      \"applicationUrl\": \"http://localhost:58682\"\r\n    }\r\n  }\r\n}"
  },
  {
    "path": "BBDown.Core/APP/Header/device.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage Device {\n    optional int32 appId = 1;\n    optional int32 build = 2;\n    optional string buvid = 3;\n    optional string mobiApp = 4;\n    optional string platform = 5;\n    optional string device = 6;\n    optional string channel = 7;\n    optional string brand = 8;\n    optional string model = 9;\n    optional string osver = 10;\n    optional string fpLocal  = 11;\n    optional string fpRemote  = 12;\n    optional string versionName  = 13;\n    optional string fp = 14;\n}"
  },
  {
    "path": "BBDown.Core/APP/Header/fawkesreq.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage FawkesReq {\n    optional string appkey = 1;\n    optional string env = 2;\n    optional string sessionId = 3;\n}"
  },
  {
    "path": "BBDown.Core/APP/Header/locale.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage Locale {\n    message LocaleIds {\n        optional string language = 1;\n        optional string script = 2;\n        optional string region = 3;\n    }\n    optional LocaleIds cLocale = 1;\n    optional LocaleIds sLocale = 2;\n}"
  },
  {
    "path": "BBDown.Core/APP/Header/metadata.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage Metadata {\n    optional string accessKey = 1;\n    optional string mobiApp = 2;\n    optional string device = 3;\n    optional int32 build = 4;\n    optional string channel = 5;\n    optional string buvid = 6;\n    optional string platform = 7;\n}"
  },
  {
    "path": "BBDown.Core/APP/Header/network.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage Network {\n    enum TYPE {\n        NT_UNKNOWN = 0;\n        WIFI = 1;\n        CELLULAR = 2;\n        OFFLINE = 3;\n        OTHERNET = 4;\n        ETHERNET = 5;\n    }\n    optional TYPE type = 1;\n    enum TF {\n        TF_UNKNOWN = 0;\n        U_CARD = 1;\n        U_PKG = 2;\n        C_CARD = 3;\n        C_PKG = 4;\n        T_CARD = 5;\n        T_PKG = 6;\n    }\n    optional TF tf = 2;\n    optional string oid = 3;\n}"
  },
  {
    "path": "BBDown.Core/APP/Header/restriction.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage Restriction {\n    optional bool teenagersMode = 1;\n    optional bool lessonsMode = 2;\n    enum Mode {\n        NORMAL = 0;\n        TEENAGERS = 1;\n        LESSONS = 2;\n    }\n    optional Mode mode = 3;\n    optional bool review = 4;\n}"
  },
  {
    "path": "BBDown.Core/APP/Payload/dmviewreq.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage DmViewReq {\n    optional int64 pid = 1;\n    optional int64 oid = 2;\n    optional int32 type = 3;\n    optional string spmid = 4;\n    optional int32 isHardBoot = 5;\n}"
  },
  {
    "path": "BBDown.Core/APP/Payload/playviewreq.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage PlayViewReq {\n    optional int64 epId = 1;\n    optional int64 cid = 2;\n    optional int64 qn = 3;\n    optional int32 fnver = 4;\n    optional int32 fnval = 5;\n    optional uint32 download = 6;\n    optional int32 forceHost = 7;\n    optional bool fourk = 8;\n    optional string spmid = 9;\n    optional string fromSpmid = 10;\n    optional int32 teenagersMode = 11;\n    enum CodeType {\n        NOCODE = 0;\n        CODE264 = 1;\n        CODE265 = 2;\n        CODEAV1 = 3;\n    }\n    optional CodeType preferCodecType = 12;\n    optional bool isPreview = 13;\n    optional int64 roomId = 14;\n}"
  },
  {
    "path": "BBDown.Core/APP/Response/dmviewreply.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage DmViewReply {\n    optional bool closed = 1;\n    optional VideoMask mask = 2;\n    optional VideoSubtitle subtitle = 3;\n    repeated string specialDms = 4;\n    optional DanmakuFlagConfig aiFlag = 5;\n    optional DanmuPlayerViewConfig playerConfig = 6;\n    optional int32 sendBoxStyle = 7;\n    optional bool allow = 8;\n    optional string checkBoxShowMsg = 9;\n    optional bool checkBox = 10;\n    optional string textPlaceholder = 11;\n}\n\nmessage VideoMask {\n    optional int64 cid = 1;\n    optional int32 plat = 2;\n    optional int32 fps = 3;\n    optional int64 time = 4;\n    optional string maskUrl = 5;\n}\n\nmessage VideoSubtitle {\n    optional string lan = 1;\n    optional string lanDoc = 2;\n    repeated SubtitleItem subtitles = 3;\n\n}\n\nmessage DanmakuFlagConfig {\n    optional int32 recFlag = 1;\n    optional string recText = 2;\n    optional int32 recSwitch = 3;\n}\n\nmessage DanmuPlayerViewConfig {\n    optional DanmuDefaultPlayerConfig danmukuDefaultPlayerConfig = 1;\n    optional DanmuPlayerConfig danmukuPlayerConfig = 2;\n    optional DanmuPlayerDynamicConfig danmukuPlayerDynamicConfig = 3;\n}\n\nmessage SubtitleItem {\n    optional UserInfo author = 6;\n    optional string idStr = 1;\n    optional int64 id = 2;\n    optional string lanDoc = 4;\n    optional string lan = 3;\n    optional string subtitleUrl = 5;\n}\n\nmessage UserInfo {\n    optional string face = 4;\n    optional int64 mid = 1;\n    optional string name = 2;\n    optional int32 rank = 6;\n    optional string sex = 3;\n    optional string sign = 5;\n}\n\nmessage DanmuDefaultPlayerConfig {\n    optional bool playerDanmakuUseDefaultConfig = 1;\n    optional bool playerDanmakuAiRecommendedSwitch = 4;\n    optional int32 playerDanmakuAiRecommendedLevel = 5;\n    optional bool playerDanmakuBlocktop = 6;\n    optional bool playerDanmakuBlockscroll = 7;\n    optional bool playerDanmakuBlockbottom = 8;\n    optional bool playerDanmakuBlockcolorful = 9;\n    optional bool playerDanmakuBlockrepeat = 10;\n    optional bool playerDanmakuBlockspecial = 11;\n    optional float playerDanmakuOpacity = 12;\n    optional float playerDanmakuScalingfactor = 13;\n    optional float playerDanmakuDomain = 14;\n    optional int32 playerDanmakuSpeed = 15;\n    optional bool inlinePlayerDanmakuSwitch = 16;\n}\n\nmessage DanmuPlayerConfig {\n    optional bool playerDanmakuSwitchSave = 1;\n    optional bool playerDanmakuSwitch = 2;\n    optional bool playerDanmakuUseDefaultConfig = 3;\n    optional bool playerDanmakuAiRecommendedSwitch = 4;\n    optional int32 playerDanmakuAiRecommendedLevel = 5;\n    optional bool playerDanmakuBlocktop = 6;\n    optional bool playerDanmakuBlockscroll = 7;\n    optional bool playerDanmakuBlockbottom = 8;\n    optional bool playerDanmakuBlockcolorful = 9;\n    optional bool playerDanmakuBlockrepeat = 10;\n    optional bool playerDanmakuBlockspecial = 11;\n    optional float playerDanmakuOpacity = 12;\n    optional float playerDanmakuScalingfactor = 13;\n    optional float playerDanmakuDomain = 14;\n    optional int32 playerDanmakuSpeed = 15;\n    optional bool playerDanmakuEnableblocklist = 16;\n    optional bool inlinePlayerDanmakuSwitch = 17;\n    optional int32 inlinePlayerDanmakuConfig = 18;\n}\n\nmessage DanmuPlayerDynamicConfig {\n    optional int32 progress = 1;\n    optional float playerDanmakuDomain = 14;\n}"
  },
  {
    "path": "BBDown.Core/APP/Response/playviewreply.proto",
    "content": "syntax = \"proto2\";\noption csharp_namespace = \"BBDown.Core.Protobuf\";\n\nmessage VideoInfo {\n    optional uint32 quality = 1;\n    optional string format = 2;\n    optional uint64 timelength = 3;\n    optional uint32 videoCodecid = 4;\n    repeated StreamItem streamList = 5;\n    repeated DashItem dashAudio = 6;\n    //杜比伴音流\n    optional DolbyItem dolby = 7;\n    optional DolbyItem flac = 9;\n}\n\n//杜比音频信息\nmessage DolbyItem {\n\toptional int32 type = 1;\n\t//音频流\n\toptional DashItem audio = 2;\n}\n\nmessage PlayAbilityConf {\n    optional bool backgroundPlayDisable = 1;\n    optional bool flipDisable = 2;\n    optional bool castDisable = 3;\n    optional bool feedbackDisable = 4;\n    optional bool subtitleDisable = 5;\n    optional bool playbackRateDisable = 6;\n    optional bool timeUpDisable = 7;\n    optional bool playbackModeDisable = 8;\n    optional bool scaleModeDisable = 9;\n    optional bool likeDisable = 10;\n    optional bool dislikeDisable = 11;\n    optional bool coinDisable = 12;\n    optional bool elecDisable = 13;\n    optional bool shareDisable = 14;\n    optional bool screenShotDisable = 15;\n    optional bool lockScreenDisable = 16;\n    optional bool recommendDisable = 17;\n    optional bool playbackSpeedDisable = 18;\n    optional bool definitionDisable = 19;\n    optional bool selectionsDisable = 20;\n    optional bool nextDisable = 21;\n    optional bool editDmDisable = 22;\n    optional bool smallWindowDisable = 23;\n    optional bool shakeDisable = 24;\n}\n\nmessage ClipInfo {\n    optional int32 start = 2;\n    optional int32 end = 3;\n    optional string toastText = 5;\n}\n\nmessage BusinessInfo {\n    optional bool isPreview = 1;\n    optional bool bp = 2;\n    optional string marlinToken = 3;\n    repeated ClipInfo clipInfo = 6;\n}\n\nmessage Event {\n    optional Shake shake = 1;\n}\n\nmessage Shake {\n    optional string file = 1;\n}\n\nmessage DashItem {\n    optional uint32 id = 1;\n    optional string baseUrl = 2;\n    repeated string backupUrl = 3;\n    optional uint32 bandwidth = 4;\n    optional uint32 codecid = 5;\n    optional string md5 = 6;\n    optional uint64 size = 7;\n}\n\nmessage StreamItem {\n    optional StreamInfo streamInfo = 1;\n    optional DashVideo  dashVideo = 2;\n    optional SegmentVideo segmentVideo = 3;\n}\n\nmessage StreamInfo {\n    optional uint32 quality = 1;\n    optional string format = 2;\n    optional string description = 3;\n    optional uint32 errCode = 4;\n    optional StreamLimit limit = 5;\n    optional bool needVip = 6;\n    optional bool needLogin = 7;\n    optional bool intact = 8;\n    optional bool noRexcode = 9;\n    optional uint64 attribute = 10;\n\n}\n\nmessage DashVideo {\n    optional string baseUrl = 1;\n    repeated string backupUrl = 2;\n    optional uint32 bandwidth = 3;\n    optional uint32 codecid = 4;\n    optional string md5 = 5;\n    optional uint64 size = 6;\n    optional uint32 audioId = 7;\n    optional bool noRexcode = 8;\n}\n\nmessage SegmentVideo {\n    repeated ResponseUrl segment = 1;\n}\n\nmessage StreamLimit {\n    optional string title = 1;\n    optional string uri = 2;\n    optional string msg = 3;\n\n}\n\nmessage ResponseUrl {\n    optional uint32 order = 1;\n    optional uint64 length = 2;\n    optional uint64 size = 3;\n    optional string url = 4;\n    repeated string backupUrl = 5;\n    optional string md5 = 6;\n}\n\nmessage RoleAudioProto {\n    // 配音列表\n    repeated AudioMaterialProto audioMaterialList = 4;\n}\n\nmessage AudioMaterialProto {\n    optional string audioId = 1;\n    optional string title = 2;\n    optional string edition = 3;\n    optional string personName = 5;\n    repeated DashItem audio = 7;\n}\n\nmessage PlayDubbingInfo {\n    // 背景音频\n    optional AudioMaterialProto backgroundAudio = 1;\n    // 角色音频列表\n    repeated RoleAudioProto roleAudioList = 2;\n}\n\nmessage PlayExtInfo {\n    // 播放配音信息\n    optional PlayDubbingInfo playDubbingInfo = 1;\n}\n\nmessage PlayViewReply {\n    optional VideoInfo videoInfo = 1;\n    optional PlayAbilityConf playConf = 2;\n    optional BusinessInfo business = 3;\n    optional Event event = 4;\n    optional PlayExtInfo playExtInfo = 7;\n}"
  },
  {
    "path": "BBDown.Core/AppHelper.cs",
    "content": "﻿using BBDown.Core.Protobuf;\nusing Google.Protobuf;\nusing System.Buffers.Binary;\nusing System.IO.Compression;\nusing System.Text.Json;\nusing System.Text.Json.Serialization;\nusing static BBDown.Core.Util.HTTPUtil;\nusing static BBDown.Core.Logger;\n\nnamespace BBDown.Core;\n\nstatic class AppHelper\n{\n    private static readonly string API = \"https://grpc.biliapi.net/bilibili.app.playurl.v1.PlayURL/PlayView\";\n    private static readonly string API2 = \"https://app.bilibili.com/bilibili.pgc.gateway.player.v2.PlayURL/PlayView\";\n    private static readonly string dalvikVer = \"2.1.0\";\n    private static readonly string osVer = \"11\";\n    private static readonly string brand = \"M2012K11AC\";\n    private static readonly string model = \"Build/RKQ1.200826.002\";\n    private static readonly string appVer = \"7.32.0\";\n    private static readonly int build = 7320200; // 新版才能抓到配音\n    private static readonly string channel = \"xiaomi_cn_tv.danmaku.bili_zm20200902\";\n    private static readonly Network.Types.TYPE networkType = Network.Types.TYPE.Wifi;\n    private static readonly string networkOid = \"46007\";\n    private static readonly string cronet = \"1.36.1\";\n    private static readonly string buvid = \"\";\n    private static readonly string mobiApp = \"android\";\n    private static readonly string appKey = \"android64\";\n    private static readonly string sessionId = \"dedf8669\";\n    private static readonly string platform = \"android\";\n    private static readonly string env = \"prod\";\n    private static readonly int appId = 1;\n    private static readonly string region = \"CN\";\n    private static readonly string language = \"zh\";\n\n    private static PlayViewReq.Types.CodeType GetVideoCodeType(string code)\n    {\n        return code switch\n        {\n            \"AVC\" => PlayViewReq.Types.CodeType.Code264,\n            \"HEVC\" => PlayViewReq.Types.CodeType.Code265,\n            \"AV1\" => PlayViewReq.Types.CodeType.Codeav1,\n            _ => PlayViewReq.Types.CodeType.Code265\n        };\n    }\n\n    /// <summary>\n    /// 发起请求并返回响应报文(protobuf -> json)\n    /// </summary>\n    /// <param name=\"epId\"></param>\n    /// <param name=\"cid\"></param>\n    /// <param name=\"qn\"></param>\n    /// <param name=\"appkey\"></param>\n    /// <returns></returns>\n    public static async Task<string> DoReqAsync(string aid, string cid, string epId, string qn, bool bangumi, string encoding, string appkey = \"\")\n    {\n\n        var headers = GetHeader(appkey);\n        LogDebug(\"App-Req-Headers: {0}\", JsonSerializer.Serialize(headers, JsonContext.Default.DictionaryStringString));\n        byte[] data;\n        // 只有pgc接口才有配音和片头尾信息\n        if (bangumi)\n        {\n            if (!(string.IsNullOrEmpty(encoding) || encoding == \"HEVC\"))\n                LogWarn(\"APP的番剧不支持 HEVC 以外的编码\");\n            var body = GetPayload(Convert.ToInt64(epId), Convert.ToInt64(cid), Convert.ToInt64(qn), PlayViewReq.Types.CodeType.Code265);\n            data = await GetPostResponseAsync(API2, body, headers);\n        }\n        else\n        {\n            var body = GetPayload(Convert.ToInt64(aid), Convert.ToInt64(cid), Convert.ToInt64(qn), GetVideoCodeType(encoding));\n            data = await GetPostResponseAsync(API, body, headers);\n        }\n        var resp = new MessageParser<PlayViewReply>(() => new PlayViewReply()).ParseFrom(ReadMessage(data));\n\n        LogDebug(\"PlayViewReplyPlain: {0}\", JsonSerializer.Serialize(resp, JsonContext.Default.PlayViewReply));\n        return ConvertToDashJson(resp);\n    }\n\n    /// <summary>\n    /// 将protobuf转换成网页那种json 这样就不用修改之前的解析逻辑了\n    /// </summary>\n    /// <param name=\"data\"></param>\n    /// <returns></returns>\n    private static string ConvertToDashJson(object data)\n    {\n        var resp = (PlayViewReply)data;\n        var videos = new List<object>();\n        var audios = new List<object>();\n        var clips = new List<object>();\n\n        if (resp.VideoInfo.StreamList != null)\n        {\n            foreach (var item in resp.VideoInfo.StreamList)\n            {\n                if (item.DashVideo != null)\n                {\n                    videos.Add(new AudioInfoWitCodecId(\n                        item.StreamInfo.Quality,\n                        item.DashVideo.BaseUrl,\n                        item.DashVideo.BackupUrl.ToList(),\n                        (uint)(item.DashVideo.Size * 8 / (resp.VideoInfo.Timelength / 1000)),\n                        item.DashVideo.Codecid\n                    ));\n                }\n            }\n        }\n\n        if (resp.VideoInfo.DashAudio != null)\n        {\n            audios.AddRange(resp.VideoInfo.DashAudio.Select(item => new AudioInfoWithCodecName(\n                item.Id,\n                item.BaseUrl,\n                item.BackupUrl.ToList(),\n                item.Bandwidth,\n                \"M4A\"\n            )));\n        }\n\n        if (resp.VideoInfo.Flac != null && resp.VideoInfo.Flac.Audio != null)\n        {\n            audios.Add(new AudioInfoWithCodecName(\n                resp.VideoInfo.Flac.Audio.Id,\n                resp.VideoInfo.Flac.Audio.BaseUrl,\n                resp.VideoInfo.Flac.Audio.BackupUrl.ToList(),\n                resp.VideoInfo.Flac.Audio.Bandwidth,\n                \"FLAC\"\n            ));\n        }\n\n        if (resp.VideoInfo.Dolby != null && resp.VideoInfo.Dolby.Audio != null)\n        {\n            audios.Add(new AudioInfoWithCodecName(\n                resp.VideoInfo.Dolby.Audio.Id,\n                resp.VideoInfo.Dolby.Audio.BaseUrl,\n                resp.VideoInfo.Dolby.Audio.BackupUrl.ToList(),\n                resp.VideoInfo.Dolby.Audio.Bandwidth,\n                \"E-AC-3\"\n            ));\n        }\n\n        if (resp.Business != null && resp.Business.ClipInfo != null)\n        {\n            clips.AddRange(resp.Business.ClipInfo.Select(clip => new DashClip(\n                clip.Start,\n                clip.End,\n                clip.ToastText\n            )));\n        }\n\n        var backgroundAudios = new List<object>();\n        var roles = new List<object>();\n        if (resp.PlayExtInfo != null && resp.PlayExtInfo.PlayDubbingInfo != null && resp.PlayExtInfo.PlayDubbingInfo.BackgroundAudio != null)\n        {\n            var dubInfo = resp.PlayExtInfo.PlayDubbingInfo;\n\n            backgroundAudios.AddRange(dubInfo.BackgroundAudio.Audio.Select(item => new AudioInfoWithCodecName(\n                item.Id,\n                item.BaseUrl,\n                item.BackupUrl.ToList(),\n                item.Bandwidth,\n                \"M4A\"\n            )));\n\n            foreach (var item in dubInfo.RoleAudioList)\n            {\n                foreach (var role in item.AudioMaterialList)\n                {\n                    List<object> roleAudios = role.Audio.Select(item => new AudioInfoWithCodecName(\n                        item.Id,\n                        item.BaseUrl,\n                        item.BackupUrl.ToList(),\n                        item.Bandwidth,\n                        \"M4A\"\n                    )).Cast<object>().ToList();\n\n                    roles.Add(new AudioMaterial(\n                        role.AudioId,\n                        role.Title ?? role.AudioId,\n                        role.PersonName ?? role.Edition ?? \"\",\n                        roleAudios\n                    ));\n                }\n            }\n        }\n\n        var json = new DashJson(\n            0,\n            \"0\",\n            1,\n            new DashData(\n                resp.VideoInfo.Timelength,\n                new DashInfo(\n                    videos,\n                    audios\n                ),\n                clips\n            ),\n            new DubbingInfo(\n                backgroundAudios,\n                roles\n            )\n        );\n\n        return JsonSerializer.Serialize(json, JsonContext.Default.DashJson);\n    }\n\n    private static byte[] GetPayload(long aid, long cid, long qn, PlayViewReq.Types.CodeType codec)\n    {\n        var obj = new PlayViewReq\n        {\n            EpId = aid,\n            Cid = cid,\n            //obj.Qn = qn;\n            Qn = 127,\n            Fnval = 4048,\n            Fourk = true,\n            Spmid = \"main.ugc-video-detail.0.0\",\n            FromSpmid = \"main.my-history.0.0\",\n            PreferCodecType = codec,\n            Download = 0, //0:播放 1:flv下载 2:dash下载\n            ForceHost = 2 //0:允许使用ip 1:使用http 2:使用https\n        };\n        LogDebug(\"PayLoadPlain: {0}\", JsonSerializer.Serialize(obj, JsonContext.Default.PlayViewReq));\n        return PackMessage(obj.ToByteArray());\n    }\n\n\n    #region 生成Headers相关方法\n\n    private static Dictionary<string, string> GetHeader(string appkey)\n    {\n        return new Dictionary<string, string>()\n        {\n            [\"Host\"] = \"grpc.biliapi.net\",\n            [\"user-agent\"] = $\"Dalvik/{dalvikVer} (Linux; U; Android {osVer}; {brand} {model}) {appVer} os/android model/{brand} mobi_app/android build/{build} channel/{channel} innerVer/{build} osVer/{osVer} network/2 grpc-java-cronet/{cronet}\",\n            [\"te\"] = \"trailers\",\n            [\"x-bili-fawkes-req-bin\"] = GenerateFawkesReqBin(),\n            [\"x-bili-metadata-bin\"] = GenerateMetadataBin(appkey),\n            [\"authorization\"] = $\"identify_v1 {Config.TOKEN}\",\n            [\"x-bili-device-bin\"] = GenerateDeviceBin(),\n            [\"x-bili-network-bin\"] = GenerateNetworkBin(),\n            [\"x-bili-restriction-bin\"] = \"\",\n            [\"x-bili-locale-bin\"] = GenerateLocaleBin(),\n            [\"x-bili-exps-bin\"] = \"\",\n            [\"grpc-encoding\"] = \"gzip\",\n            [\"grpc-accept-encoding\"] = \"identity,gzip\",\n            [\"grpc-timeout\"] = \"17996161u\",\n        };\n    }\n\n    private static string GenerateLocaleBin()\n    {\n        var obj = new Locale\n        {\n            CLocale = new Locale.Types.LocaleIds\n            {\n                Language = language,\n                Region = region\n            }\n        };\n        return Convert.ToBase64String(obj.ToByteArray());\n    }\n\n    private static string GenerateNetworkBin()\n    {\n        var obj = new Network\n        {\n            Type = networkType,\n            Oid = networkOid\n        };\n        return Convert.ToBase64String(obj.ToByteArray());\n    }\n\n    private static string GenerateDeviceBin()\n    {\n        var obj = new Device\n        {\n            AppId = appId,\n            Build = build,\n            Buvid = buvid,\n            MobiApp = mobiApp,\n            Platform = platform,\n            Channel = channel,\n            Brand = brand,\n            Model = model,\n            Osver = osVer\n        };\n        return Convert.ToBase64String(obj.ToByteArray());\n    }\n\n    private static string GenerateMetadataBin(string appkey)\n    {\n        var obj = new Metadata\n        {\n            AccessKey = appkey,\n            MobiApp = mobiApp,\n            Build = build,\n            Channel = channel,\n            Buvid = buvid,\n            Platform = platform\n        };\n        return Convert.ToBase64String(obj.ToByteArray());\n    }\n\n    private static string GenerateFawkesReqBin()\n    {\n        var obj = new FawkesReq\n        {\n            Appkey = appKey,\n            Env = env,\n            SessionId = sessionId\n        };\n        return Convert.ToBase64String(obj.ToByteArray());\n    }\n\n    #endregion\n\n    /// <summary>\n    /// 读取gRPC响应流 通过前5字节信息 解析/解压后面的报文体\n    /// </summary>\n    /// <param name=\"data\"></param>\n    /// <returns>字节流</returns>\n    public static byte[] ReadMessage(byte[] data)\n    {\n        byte first;\n        int size;\n        (first, size) = ReadInfo(data);\n        return first == 1 ? GzipDecompress(data[5..]) : data[5..(5 + size)];\n    }\n\n    /// <summary>\n    /// 读取报文长度\n    /// </summary>\n    /// <param name=\"data\"></param>\n    /// <returns></returns>\n    private static (byte first, int size) ReadInfo(byte[] data)\n    {\n        var value1 = data[0];\n        var value2 = data[1..5];\n\n        return (value1, BinaryPrimitives.ReadInt32BigEndian(value2));\n    }\n\n    /// <summary>\n    /// 给请求载荷添加头部信息\n    /// </summary>\n    /// <param name=\"input\"></param>\n    /// <returns></returns>\n    public static byte[] PackMessage(byte[] input)\n    {\n        using var stream = new MemoryStream();\n        using (var writer = new BinaryWriter(stream))\n        {\n            var comp = GzipCompress(input);\n            var reverse = (stackalloc byte[4]);\n            writer.Write((byte)1);\n            BinaryPrimitives.WriteInt32BigEndian(reverse, comp.Length);\n            writer.Write(reverse);\n            writer.Write(comp);\n        }\n        return stream.ToArray();\n    }\n\n    /// <summary>\n    /// gzip压缩\n    /// </summary>\n    /// <param name=\"data\"></param>\n    /// <returns></returns>\n    private static byte[] GzipCompress(byte[] data)\n    {\n        using var output = new MemoryStream();\n        using (var comp = new GZipStream(output, CompressionMode.Compress))\n        {\n            comp.Write(data, 0, data.Length);\n        }\n        return output.ToArray();\n    }\n\n    /// <summary>\n    /// gzip解压\n    /// </summary>\n    /// <param name=\"data\"></param>\n    /// <returns></returns>\n    private static byte[] GzipDecompress(byte[] data)\n    {\n        using var output = new MemoryStream();\n        using (var input = new MemoryStream(data))\n        {\n            using var decomp = new GZipStream(input, CompressionMode.Decompress);\n            decomp.CopyTo(output);\n        }\n        return output.ToArray();\n    }\n}\n\n\n[JsonSerializable(typeof(AudioMaterial))]\n[JsonSerializable(typeof(DubbingInfo))]\n[JsonSerializable(typeof(DashClip))]\n[JsonSerializable(typeof(AudioInfoWithCodecName))]\n[JsonSerializable(typeof(AudioInfoWitCodecId))]\n[JsonSerializable(typeof(DashJson))]\n[JsonSerializable(typeof(PlayViewReq))]\n[JsonSerializable(typeof(PlayViewReply))]\n[JsonSerializable(typeof(Dictionary<string, string>))]\ninternal partial class JsonContext : JsonSerializerContext { }\n\ninternal class AudioMaterial\n{\n    [JsonPropertyName(\"audio_id\")]\n    public string AudioId { get; }\n    [JsonPropertyName(\"title\")]\n    public string Title { get; }\n    [JsonPropertyName(\"person_name\")]\n    public string PersonName { get; }\n    [JsonPropertyName(\"audio\")]\n    public List<object> Audio { get; }\n\n    public AudioMaterial(string audio_id, string title, string person_name, List<object> audio)\n    {\n        AudioId = audio_id;\n        Title = title;\n        PersonName = person_name;\n        Audio = audio;\n    }\n\n    public override bool Equals(object? obj) => obj is AudioMaterial other && AudioId == other.AudioId && Title == other.Title && PersonName == other.PersonName && Audio == other.Audio;\n    public override int GetHashCode() => HashCode.Combine(Title, Audio);\n}\n\ninternal class DubbingInfo\n{\n    [JsonPropertyName(\"background_audio\")]\n    public List<object> BackgroundAudio { get; }\n    [JsonPropertyName(\"role_audio_list\")]\n    public List<object> RoleAudioList { get; }\n\n    public DubbingInfo(List<object> background_audio, List<object> role_audio_list)\n    {\n        BackgroundAudio = background_audio;\n        RoleAudioList = role_audio_list;\n    }\n\n    public override bool Equals(object? obj) => obj is DubbingInfo other && BackgroundAudio == other.BackgroundAudio && RoleAudioList == other.RoleAudioList;\n    public override int GetHashCode() => HashCode.Combine(BackgroundAudio, RoleAudioList);\n}\n\ninternal class DashClip\n{\n    [JsonPropertyName(\"start\")]\n    public int Start { get; }\n    [JsonPropertyName(\"end\")]\n    public int End { get; }\n    [JsonPropertyName(\"toastText\")]\n    public string ToastText { get; }\n\n    public DashClip(int start, int end, string toastText)\n    {\n        Start = start;\n        End = end;\n        ToastText = toastText;\n    }\n\n    public override bool Equals(object? obj) => obj is DashClip other && Start == other.Start && End == other.End && ToastText == other.ToastText;\n    public override int GetHashCode() => HashCode.Combine(Start, End, ToastText);\n}\n\ninternal class AudioInfoWithCodecName\n{\n    [JsonPropertyName(\"id\")]\n    public uint Id { get; }\n    [JsonPropertyName(\"base_url\")]\n    public string BaseUrl { get; }\n    [JsonPropertyName(\"backup_url\")]\n    public List<string> BackupUrl { get; }\n    [JsonPropertyName(\"bandwidth\")]\n    public uint Bandwidth { get; }\n    [JsonPropertyName(\"codecs\")]\n    public string Codecs { get; }\n\n    public AudioInfoWithCodecName(uint id, string base_url, List<string> backup_url, uint bandwidth, string codecs)\n    {\n        Id = id;\n        BaseUrl = base_url;\n        BackupUrl = backup_url;\n        Bandwidth = bandwidth;\n        Codecs = codecs;\n    }\n\n    public override bool Equals(object? obj) => obj is AudioInfoWithCodecName other && Id == other.Id && BaseUrl == other.BaseUrl && BackupUrl.SequenceEqual(other.BackupUrl) && Bandwidth == other.Bandwidth && Codecs == other.Codecs;\n    public override int GetHashCode() => HashCode.Combine(Id, BaseUrl, BackupUrl, Bandwidth, Codecs);\n}\n\ninternal class AudioInfoWitCodecId\n{\n    [JsonPropertyName(\"id\")]\n    public uint Id { get; }\n    [JsonPropertyName(\"base_url\")]\n    public string BaseUrl { get; }\n    [JsonPropertyName(\"backup_url\")]\n    public List<string> BackupUrl { get; }\n    [JsonPropertyName(\"bandwidth\")]\n    public uint Bandwidth { get; }\n    [JsonPropertyName(\"codecid\")]\n    public uint Codecid { get; }\n\n    public AudioInfoWitCodecId(uint id, string base_url, List<string> backup_url, uint bandwidth, uint codecid)\n    {\n        Id = id;\n        BaseUrl = base_url;\n        BackupUrl = backup_url;\n        Bandwidth = bandwidth;\n        Codecid = codecid;\n    }\n\n    public override bool Equals(object? obj) => obj is AudioInfoWitCodecId other && Id == other.Id && BaseUrl == other.BaseUrl && Bandwidth == other.Bandwidth && Codecid == other.Codecid;\n    public override int GetHashCode() => HashCode.Combine(Id, BaseUrl, Bandwidth, Codecid);\n}\n\ninternal class DashInfo\n{\n    [JsonPropertyName(\"video\")]\n    public List<object> Video { get; }\n    [JsonPropertyName(\"audio\")]\n    public List<object> Audio { get; }\n\n    public DashInfo(List<object> video, List<object> audio)\n    {\n        Video = video;\n        Audio = audio;\n    }\n\n    public override bool Equals(object? obj) => obj is DashInfo other && EqualityComparer<List<object>>.Default.Equals(Video, other.Video) && EqualityComparer<List<object>>.Default.Equals(Audio, other.Audio);\n    public override int GetHashCode() => HashCode.Combine(Video, Audio);\n}\n\ninternal class DashData\n{\n    [JsonPropertyName(\"timelength\")]\n    public ulong TimeLength { get; }\n    [JsonPropertyName(\"dash\")]\n    public DashInfo Dash { get; }\n    [JsonPropertyName(\"clip_info_list\")]\n    public List<object> ClipList { get; }\n\n    public DashData(ulong timelength, DashInfo dash, List<object> clipList)\n    {\n        TimeLength = timelength;\n        Dash = dash;\n        ClipList = clipList;\n    }\n\n    public override bool Equals(object? obj) => obj is DashData other && TimeLength == other.TimeLength && EqualityComparer<DashInfo>.Default.Equals(Dash, other.Dash) && EqualityComparer<List<object>>.Default.Equals(ClipList, other.ClipList);\n    public override int GetHashCode() => HashCode.Combine(TimeLength, Dash, ClipList);\n}\n\ninternal class DashJson\n{\n    [JsonPropertyName(\"code\")]\n    public int Code { get; }\n    [JsonPropertyName(\"message\")]\n    public string Message { get; }\n    [JsonPropertyName(\"ttl\")]\n    public int Ttl { get; }\n    [JsonPropertyName(\"data\")]\n    public DashData Data { get; }\n    [JsonPropertyName(\"dubbing_info\")]\n    public DubbingInfo DubbingInfo { get; }\n\n    public DashJson(int code, string message, int ttl, DashData data, DubbingInfo dubbingInfo)\n    {\n        Code = code;\n        Message = message;\n        Ttl = ttl;\n        Data = data;\n        DubbingInfo = dubbingInfo;\n    }\n\n    public override bool Equals(object? obj) => obj is DashJson other && Code == other.Code && Message == other.Message && Ttl == other.Ttl && EqualityComparer<DashData>.Default.Equals(Data, other.Data);\n    public override int GetHashCode() => HashCode.Combine(Code, Message, Ttl, Data, DubbingInfo);\n}"
  },
  {
    "path": "BBDown.Core/BBDown.Core.csproj",
    "content": "﻿<Project Sdk=\"Microsoft.NET.Sdk\">\n\n  <PropertyGroup>\n    <OutputType>library</OutputType>\n    <TargetFramework>net9.0</TargetFramework>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <Nullable>enable</Nullable>\n  </PropertyGroup>\n\n  <ItemGroup>\n    <PackageReference Include=\"Google.Protobuf\" Version=\"3.28.3\" />\n    <PackageReference Include=\"Grpc.Tools\" Version=\"2.67.0\">\n      <PrivateAssets>all</PrivateAssets>\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n    </PackageReference>\n  </ItemGroup>\n\n  <ItemGroup>\n    <Protobuf Include=\"APP\\**\\*.proto\" GrpcServices=\"Client\" />\n  </ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "BBDown.Core/Config.cs",
    "content": "﻿namespace BBDown.Core;\n\npublic static class Config\n{\n    //For WEB\n    public static string COOKIE { get; set; } = \"\";\n    //For APP/TV\n    public static string TOKEN { get; set; } = \"\";\n    //日志级别\n    public static bool DEBUG_LOG { get; set; } = false;\n    //BiliPlus Host\n    public static string HOST { get; set; } = \"api.bilibili.com\";\n    //BiliPlus EP Host\n    public static string EPHOST { get; set; } = \"api.bilibili.com\";\n    //Bili Tv Api Host\n    public static string TVHOST { get; set; } = \"api.snm0516.aisee.tv\";\n    //BiliPlus Area\n    public static string AREA { get; set; } = \"\";\n\n    public static string WBI { get; set; } = \"\";\n\n    public static readonly Dictionary<string, string> qualitys = new() {\n        {\"127\",\"8K 超高清\" }, {\"126\",\"杜比视界\" }, {\"125\",\"HDR 真彩\" }, {\"120\",\"4K 超清\" }, {\"116\",\"1080P 高帧率\" },\n        {\"112\",\"1080P 高码率\" }, {\"100\",\"智能修复\" }, {\"80\",\"1080P 高清\" }, {\"74\",\"720P 高帧率\" },\n        {\"64\",\"720P 高清\" }, {\"48\",\"720P 高清\" }, {\"32\",\"480P 清晰\" }, {\"16\",\"360P 流畅\" },\n        {\"5\",\"144P 流畅\" }, {\"6\",\"240P 流畅\" }\n    };\n}"
  },
  {
    "path": "BBDown.Core/DanmakuUtil.cs",
    "content": "﻿using static BBDown.Core.Logger;\nusing System.Text;\nusing System.Xml;\n\nnamespace BBDown.Core;\n\npublic static class DanmakuUtil\n{\n    private const int MONITOR_WIDTH = 1920;         //渲染字幕时的渲染范围的高度\n    private const int MONITOR_HEIGHT = 1080;        //渲染字幕时的渲染范围的高度\n    private const int FONT_SIZE = 40;               //字体大小\n    private const double MOVE_SPEND_TIME = 8.00;    //单条条滚动弹幕存在时间（控制速度）\n    private const double TOP_SPEND_TIME = 4.00;     //单条顶部或底部弹幕存在时间\n    private const int PROTECT_LENGTH = 50;          //滚动弹幕屏占百分比\n    public static readonly DanmakuComparer comparer = new();\n\n    /*public static async Task DownloadAsync(Page p, string xmlPath, bool aria2c, string aria2cProxy)\n    {\n        string danmakuUrl = \"https://comment.bilibili.com/\" + p.cid + \".xml\";\n        await DownloadFile(danmakuUrl, xmlPath, aria2c, aria2cProxy);\n    }*/\n\n    public static DanmakuItem[]? ParseXml(string xmlPath)\n    {\n        // 解析xml文件\n        XmlDocument xmlFile = new();\n        XmlReaderSettings settings = new()\n        {\n            IgnoreComments = true//忽略文档里面的注释\n        };\n        var danmakus = new List<DanmakuItem>();\n        using (var reader = XmlReader.Create(xmlPath, settings))\n        {\n            try\n            {\n                xmlFile.Load(reader);\n            }\n            catch (Exception ex)\n            {\n                LogDebug(\"解析字幕xml时出现异常: {0}\", ex.ToString());\n                return null;\n            }\n        }\n\n        XmlNode? rootNode = xmlFile.SelectSingleNode(\"i\");\n        if (rootNode != null)\n        {\n            XmlElement rootElement = (XmlElement)rootNode;\n            XmlNodeList? dNodeList = rootElement.SelectNodes(\"d\");\n            if (dNodeList != null)\n            {\n                foreach (XmlNode node in dNodeList)\n                {\n                    XmlElement dElement = (XmlElement)node;\n                    string attr = dElement.GetAttribute(\"p\").ToString();\n                    if (attr != null)\n                    {\n                        string[] vs = attr.Split(',');\n                        if (vs.Length >= 8)\n                        {\n                            DanmakuItem danmaku = new(vs, dElement.InnerText);\n                            danmakus.Add(danmaku);\n                        }\n                    }\n                }\n            }\n        }\n        return danmakus.ToArray();\n    }\n\n    /// <summary>\n    /// 保存为ASS字幕文件\n    /// </summary>\n    /// <param name=\"danmakus\">弹幕</param>\n    /// <param name=\"outputPath\">保存路径</param>\n    /// <returns></returns>\n    public static async Task SaveAsAssAsync(DanmakuItem[] danmakus, string outputPath)\n    {\n        var sb = new StringBuilder();\n        // ASS字幕文件头\n        sb.AppendLine(\"[Script Info]\");\n        sb.AppendLine(\"Script Updated By: BBDown(https://github.com/nilaoda/BBDown)\");\n        sb.AppendLine(\"ScriptType: v4.00+\");\n        sb.AppendLine($\"PlayResX: {MONITOR_WIDTH}\");\n        sb.AppendLine($\"PlayResY: {MONITOR_HEIGHT}\");\n        sb.AppendLine($\"Aspect Ratio: {MONITOR_WIDTH}:{MONITOR_HEIGHT}\");\n        sb.AppendLine(\"Collisions: Normal\");\n        sb.AppendLine(\"WrapStyle: 2\");\n        sb.AppendLine(\"ScaledBorderAndShadow: yes\");\n        sb.AppendLine(\"YCbCr Matrix: TV.601\");\n        sb.AppendLine(\"[V4+ Styles]\");\n        sb.AppendLine(\"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\");\n        sb.AppendLine($\"Style: BBDOWN_Style, 黑体, {FONT_SIZE}, &H00FFFFFF, &H00FFFFFF, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0.00, 0.00, 1, 2, 0, 7, 0, 0, 0, 0\");\n        sb.AppendLine(\"[Events]\");\n        sb.AppendLine(\"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\");\n            \n        PositionController controller = new();   // 弹幕位置控制器\n        Array.Sort(danmakus, comparer);\n        foreach (DanmakuItem danmaku in danmakus)\n        {\n            int height = controller.UpdatePosition(danmaku.DanmakuMode, danmaku.Second, danmaku.Content.Length);\n            if (height == -1) continue;\n            string effect = \"\";\n            effect += danmaku.DanmakuMode switch\n            {\n                3 => $\"\\\\an8\\\\pos({MONITOR_WIDTH / 2}, {MONITOR_HEIGHT - FONT_SIZE - height})\",\n                2 => $\"\\\\an8\\\\pos({MONITOR_WIDTH / 2}, {height})\",\n                _ => $\"\\\\move({MONITOR_WIDTH}, {height}, {-danmaku.Content.Length * FONT_SIZE}, {height})\",\n            };\n            if (danmaku.Color != \"FFFFFF\")\n            {\n                effect += $\"\\\\c&{danmaku.Color}&\";\n            }\n            sb.AppendLine($\"Dialogue: 2,{danmaku.StartTime},{danmaku.EndTime},BBDOWN_Style,,0000,0000,0000,,{{{effect}}}{danmaku.Content}\");\n        }\n\n        await File.WriteAllTextAsync(outputPath, sb.ToString(), Encoding.UTF8);\n    }\n\n    protected class PositionController\n    {\n        readonly int maxLine = MONITOR_HEIGHT * PROTECT_LENGTH / FONT_SIZE / 100;    //总行数\n        // 三个位置的弹幕队列，记录弹幕结束时间\n\n        readonly List<double> moveQueue = new();\n        readonly List<double> topQueue = new();\n        readonly List<double> bottomQueue = new();\n\n        public PositionController()\n        {\n            for (int i = 0; i < maxLine; i++)\n            {\n                moveQueue.Add(0.00);\n                topQueue.Add(0.00);\n                bottomQueue.Add(0.00);\n            }\n        }\n\n        public int UpdatePosition(int type, double time, int length)\n        {\n            // 获取可用位置\n            List<double> vs;\n            double displayTime = TOP_SPEND_TIME;\n            if (type == POS_BOTTOM)\n            {\n                vs = bottomQueue;\n            }\n            else if (type == POS_TOP)\n            {\n                vs = topQueue;\n            }\n            else\n            {\n                vs = moveQueue;\n                displayTime = MOVE_SPEND_TIME * (length + 5) * FONT_SIZE / (MONITOR_WIDTH + (length * MOVE_SPEND_TIME));\n            }\n            for (int i = 0; i < maxLine; i++)\n            {\n                if (time >= vs[i])\n                {   // 此条弹幕已结束，更新该位置信息\n                    vs[i] = time + displayTime;\n                    return i * FONT_SIZE;\n                }\n            }\n            return -1;\n        }\n    }\n\n    public class DanmakuItem\n    {\n        public DanmakuItem(string[] attrs, string content)\n        {\n            DanmakuMode = attrs[1] switch\n            {\n                \"4\" => POS_BOTTOM,\n                \"5\" => POS_TOP,\n                _ => POS_MOVE,\n            };\n            try\n            {\n                double second = double.Parse(attrs[0]);\n                Second = second;\n                StartTime = ComputeTime(second);\n                EndTime = ComputeTime(second + (DanmakuMode == 1 ? MOVE_SPEND_TIME : TOP_SPEND_TIME));\n            }\n            catch (Exception e)\n            {\n                Log(e.Message);\n            }\n            FontSize = attrs[2];\n            try\n            {\n                int colorD = int.Parse(attrs[3]);\n                Color = string.Format(\"{0:X6}\", colorD);\n            }\n            catch (FormatException e)\n            {\n                Log(e.Message);\n            }\n            Timestamp = attrs[4];\n            Content = content;\n        }\n        private static string ComputeTime(double second)\n        {\n            int hour = (int)second / 3600;\n            int minute = (int)(second - (hour * 3600)) / 60;\n            second -= (hour * 3600) + (minute * 60);\n            return hour.ToString() + string.Format(\":{0:D2}:\", minute) + string.Format(\"{0:00.00}\", second);\n        }\n        public string Content { get; set; } = \"\";\n        // 弹幕内容\n        public string StartTime { get; set; } = \"\";\n        // 出现时间\n        public double Second { get; set; } = 0.00;\n        // 出现时间（秒为单位）\n        public string EndTime { get; set; } = \"\";\n        // 消失时间\n        public int DanmakuMode { get; set; } = POS_MOVE;\n        // 弹幕类型\n        public string FontSize { get; set; } = \"\";\n        // 字号\n        public string Color { get; set; } = \"\";\n        // 颜色\n        public string Timestamp { get; set; } = \"\";\n        // 时间戳\n    }\n\n    public class DanmakuComparer : IComparer<DanmakuItem>\n    {\n        public int Compare(DanmakuItem? x, DanmakuItem? y)\n        {\n            if (x == null) return -1;\n            if (y == null) return 1;\n            return x.Second.CompareTo(y.Second);\n        }\n    }\n\n    private const int POS_MOVE = 1;     //滚动弹幕\n    private const int POS_TOP = 2;      //顶部弹幕\n    private const int POS_BOTTOM = 3;   //底部弹幕\n}"
  },
  {
    "path": "BBDown.Core/Entity/Entity.cs",
    "content": "﻿using BBDown.Core.Util;\nusing System.Diagnostics.CodeAnalysis;\n\nnamespace BBDown.Core.Entity;\n\npublic static class Entity\n{\n    public class Page\n    {\n        public required int index;\n        public required string aid;\n        public required string cid;\n        public required string epid;\n        public required string title;\n        public required int dur;\n        public required string res;\n        public required long pubTime;\n        public string? cover;\n        public string? desc;\n        public string? ownerName;\n        public string? ownerMid;\n        public string bvid\n        {\n            get => BilibiliBvConverter.Encode(long.Parse(aid));\n        }\n        public List<ViewPoint> points = new();\n\n        [SetsRequiredMembers]\n        public Page(int index, string aid, string cid, string epid, string title, int dur, string res, long pubTime)\n        {\n            this.aid = aid;\n            this.index = index;\n            this.cid = cid;\n            this.epid = epid;\n            this.title = title;\n            this.dur = dur;\n            this.res = res;\n            this.pubTime = pubTime;\n        }\n\n        [SetsRequiredMembers]\n        public Page(int index, string aid, string cid, string epid, string title, int dur, string res, long pubTime, string cover)\n        {\n            this.aid = aid;\n            this.index = index;\n            this.cid = cid;\n            this.epid = epid;\n            this.title = title;\n            this.dur = dur;\n            this.res = res;\n            this.pubTime = pubTime;\n            this.cover = cover;\n        }\n\n        [SetsRequiredMembers]\n        public Page(int index, string aid, string cid, string epid, string title, int dur, string res, long pubTime, string cover, string desc)\n        {\n            this.aid = aid;\n            this.index = index;\n            this.cid = cid;\n            this.epid = epid;\n            this.title = title;\n            this.dur = dur;\n            this.res = res;\n            this.pubTime = pubTime;\n            this.cover = cover;\n            this.desc = desc;\n        }\n\n        [SetsRequiredMembers]\n        public Page(int index, string aid, string cid, string epid, string title, int dur, string res, long pubTime, string cover, string desc, string ownerName, string ownerMid)\n        {\n            this.aid = aid;\n            this.index = index;\n            this.cid = cid;\n            this.epid = epid;\n            this.title = title;\n            this.dur = dur;\n            this.res = res;\n            this.pubTime = pubTime;\n            this.cover = cover;\n            this.desc = desc;\n            this.ownerName = ownerName;\n            this.ownerMid = ownerMid;\n        }\n\n        [SetsRequiredMembers]\n        public Page(int index, Page page)\n        {\n            this.index = index;\n            this.aid = page.aid;\n            this.cid = page.cid;\n            this.epid = page.epid;\n            this.title = page.title;\n            this.dur = page.dur;\n            this.res = page.res;\n            this.pubTime = page.pubTime;\n            this.cover = page.cover;\n            this.ownerName = page.ownerName;\n            this.ownerMid = page.ownerMid;\n        }\n\n        public override bool Equals(object? obj)\n        {\n            return obj is Page page &&\n                   aid == page.aid &&\n                   cid == page.cid &&\n                   epid == page.epid;\n        }\n\n        public override int GetHashCode()\n        {\n            return HashCode.Combine(aid, cid, epid);\n        }\n    }\n\n    public class ViewPoint\n    {\n        public required string title;\n        public required int start;\n        public required int end;\n    }\n\n    public class Video\n    {\n        public required string id;\n        public required string dfn;\n        public required string baseUrl;\n        public string? res;\n        public string? fps;\n        public required string codecs;\n        public long bandwith;\n        public int dur;\n        public double size;\n\n        public override bool Equals(object? obj)\n        {\n            return obj is Video video &&\n                   id == video.id &&\n                   dfn == video.dfn &&\n                   res == video.res &&\n                   fps == video.fps &&\n                   codecs == video.codecs &&\n                   bandwith == video.bandwith &&\n                   dur == video.dur;\n        }\n\n        public override int GetHashCode()\n        {\n            return HashCode.Combine(id, dfn, res, fps, codecs, bandwith, dur);\n        }\n    }\n\n    public class Audio\n    {\n        public required string id;\n        public required string dfn;\n        public required string baseUrl;\n        public required string codecs;\n        public required long bandwith;\n        public required int dur;\n        \n        // E-AC-3 => EAC3\n        public string shortCodecs => codecs.ToUpper().Replace(\"-\", string.Empty);\n\n        public override bool Equals(object? obj)\n        {\n            return obj is Audio audio &&\n                   id == audio.id &&\n                   dfn == audio.dfn &&\n                   codecs == audio.codecs &&\n                   bandwith == audio.bandwith &&\n                   dur == audio.dur;\n        }\n\n        public override int GetHashCode()\n        {\n            return HashCode.Combine(id, dfn, codecs, bandwith, dur);\n        }\n    }\n\n    public class Subtitle\n    {\n        public required string lan;\n        public required string url;\n        public required string path;\n    }\n\n    public class Clip\n    {\n        public required int index;\n        public required long from;\n        public required long to;\n    }\n\n    public class AudioMaterial\n    {\n        public required string title;\n        public required string personName;\n        public required string path;\n\n        [SetsRequiredMembers]\n        public AudioMaterial(string title, string personName, string path)\n        {\n            this.title = title;\n            this.personName = personName;\n            this.path = path;\n        }\n\n        [SetsRequiredMembers]\n        public AudioMaterial(AudioMaterialInfo audioMaterialInfo)\n        {\n            this.title = audioMaterialInfo.title;\n            this.personName = audioMaterialInfo.personName;\n            this.path = audioMaterialInfo.path;\n        }\n    }\n\n    public class AudioMaterialInfo\n    {\n        public required string title;\n        public required string personName;\n        public required string path;\n        public required List<Audio> audio;\n    }\n}"
  },
  {
    "path": "BBDown.Core/Entity/ParsedResult.cs",
    "content": "﻿using static BBDown.Core.Entity.Entity;\n\nnamespace BBDown.Core.Entity;\n\npublic class ParsedResult\n{\n    public string WebJsonString { get; set; }\n    public List<Video> VideoTracks { get; set; } = new();\n    public List<Audio> AudioTracks { get; set; } = new();\n    public List<Audio> BackgroundAudioTracks { get; set; } = new();\n    public List<AudioMaterialInfo> RoleAudioList { get; set; } = new();\n    public List<ViewPoint> ExtraPoints { get; set; } = new();\n    // ⬇⬇⬇⬇⬇ FOR FLV ⬇⬇⬇⬇⬇\n    public List<string> Clips { get; set; } = new();\n    public List<string> Dfns { get; set; } = new();\n}"
  },
  {
    "path": "BBDown.Core/Entity/VInfo.cs",
    "content": "﻿using static BBDown.Core.Entity.Entity;\n\nnamespace BBDown.Core.Entity;\n\npublic class VInfo\n{\n    /// <summary>\n    /// 视频标题\n    /// </summary>\n    public required string Title { get; set; }\n\n    /// <summary>\n    /// 视频描述\n    /// </summary>\n    public required string Desc { get; set; }\n\n    /// <summary>\n    /// 视频封面\n    /// </summary>\n    public required string Pic { get; set; }\n\n    /// <summary>\n    /// 视频发布时间\n    /// </summary>\n    public required long PubTime { get; set; }\n    public bool IsBangumi { get; set; }\n    public bool IsCheese { get; set; }\n\n    /// <summary>\n    /// 番剧是否完结\n    /// </summary>\n    public bool IsBangumiEnd { get; set; }\n\n    /// <summary>\n    /// 视频index 用于番剧或课程判断当前选择的是第几集\n    /// </summary>\n    public string? Index { get; set; }\n\n    /// <summary>\n    /// 视频分P信息\n    /// </summary>\n    public required List<Page> PagesInfo { get; set; }\n\n    /// <summary>\n    /// 是否为互动视频\n    /// </summary>\n    public bool IsSteinGate { get; set; }\n}"
  },
  {
    "path": "BBDown.Core/Fetcher/BangumiInfoFetcher.cs",
    "content": "﻿using BBDown.Core.Entity;\nusing System.Text.Json;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Util.HTTPUtil;\n\nnamespace BBDown.Core.Fetcher;\n\npublic class BangumiInfoFetcher : IFetcher\n{\n    public async Task<VInfo> FetchAsync(string id)\n    {\n        id = id[3..];\n        string index = \"\";\n        string api = $\"https://{Config.EPHOST}/pgc/view/web/season?ep_id={id}\";\n        string json = await GetWebSourceAsync(api);\n        using var infoJson = JsonDocument.Parse(json);\n        var result = infoJson.RootElement.GetProperty(\"result\");\n        string cover = result.GetProperty(\"cover\").ToString();\n        string title = result.GetProperty(\"title\").ToString();\n        string desc = result.GetProperty(\"evaluate\").ToString();\n        string pubTimeStr = result.GetProperty(\"publish\").GetProperty(\"pub_time\").ToString();\n        long pubTime = string.IsNullOrEmpty(pubTimeStr) ? 0 : DateTimeOffset.ParseExact(pubTimeStr, \"yyyy-MM-dd HH:mm:ss\", null).ToUnixTimeSeconds();\n        var pages = result.GetProperty(\"episodes\").EnumerateArray();\n        List<Page> pagesInfo = new();\n        int i = 1;\n\n        //episodes为空; 或者未包含对应epid，番外/花絮什么的\n        if (!(pages.Any() && result.GetProperty(\"episodes\").ToString().Contains($\"/ep{id}\")))\n        {\n            if (result.TryGetProperty(\"section\", out JsonElement sections))\n            {\n                foreach (var section in sections.EnumerateArray())\n                {\n                    if (section.ToString().Contains($\"/ep{id}\"))\n                    {\n                        title += \"[\" + section.GetProperty(\"title\").ToString() + \"]\";\n                        pages = section.GetProperty(\"episodes\").EnumerateArray();\n                        break;\n                    }\n                }\n            }\n        }\n\n        foreach (var page in pages)\n        {\n            //跳过预告\n            if (page.TryGetProperty(\"badge\", out JsonElement badge) && badge.ToString() == \"预告\") continue;\n            string res = \"\";\n            try\n            {\n                res = page.GetProperty(\"dimension\").GetProperty(\"width\").ToString() + \"x\" + page.GetProperty(\"dimension\").GetProperty(\"height\").ToString();\n            }\n            catch (Exception) { }\n            string _title = page.GetProperty(\"title\").ToString() + \" \" + page.GetProperty(\"long_title\").ToString();\n            _title = _title.Trim();\n            Page p = new(i++,\n                page.GetProperty(\"aid\").ToString(),\n                page.GetProperty(\"cid\").ToString(),\n                page.GetProperty(\"id\").ToString(),\n                _title,\n                0, res,\n                page.GetProperty(\"pub_time\").GetInt64());\n            if (p.epid == id) index = p.index.ToString();\n            pagesInfo.Add(p);\n        }\n\n\n        var info = new VInfo\n        {\n            Title = title.Trim(),\n            Desc = desc.Trim(),\n            Pic = cover,\n            PubTime = pubTime,\n            PagesInfo = pagesInfo,\n            IsBangumi = true,\n            IsCheese = true,\n            Index = index\n        };\n\n        return info;\n    }\n}"
  },
  {
    "path": "BBDown.Core/Fetcher/CheeseInfoFetcher.cs",
    "content": "﻿using BBDown.Core.Entity;\nusing System.Text.Json;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Util.HTTPUtil;\n\nnamespace BBDown.Core.Fetcher;\n\npublic class CheeseInfoFetcher : IFetcher\n{\n    public async Task<VInfo> FetchAsync(string id)\n    {\n        id = id[7..];\n        string index = \"\";\n        string api = $\"https://api.bilibili.com/pugv/view/web/season?ep_id={id}\";\n        string json = await GetWebSourceAsync(api);\n        using var infoJson = JsonDocument.Parse(json);\n        var data = infoJson.RootElement.GetProperty(\"data\");\n        string cover = data.GetProperty(\"cover\").ToString();\n        string title = data.GetProperty(\"title\").ToString();\n        string desc = data.GetProperty(\"subtitle\").ToString();\n        string ownerName = data.GetProperty(\"up_info\").GetProperty(\"uname\").ToString();\n        string ownerMid = data.GetProperty(\"up_info\").GetProperty(\"mid\").ToString();\n        var pages = data.GetProperty(\"episodes\").EnumerateArray();\n        List<Page> pagesInfo = new();\n        foreach (var page in pages)\n        {\n            Page p = new(page.GetProperty(\"index\").GetInt32(),\n                page.GetProperty(\"aid\").ToString(),\n                page.GetProperty(\"cid\").ToString(),\n                page.GetProperty(\"id\").ToString(),\n                page.GetProperty(\"title\").ToString().Trim(),\n                page.GetProperty(\"duration\").GetInt32(),\n                \"\",\n                page.GetProperty(\"release_date\").GetInt64(),\n                \"\",\n                \"\",\n                ownerName,\n                ownerMid);\n            if (p.epid == id) index = p.index.ToString();\n            pagesInfo.Add(p);\n        }\n        long pubTime = pagesInfo.Any() ? pagesInfo[0].pubTime : 0;\n\n        var info = new VInfo\n        {\n            Title = title.Trim(),\n            Desc = desc.Trim(),\n            Pic = cover,\n            PubTime = pubTime,\n            PagesInfo = pagesInfo,\n            IsBangumi = true,\n            IsCheese = true,\n            Index = index\n        };\n\n        return info;\n    }\n}"
  },
  {
    "path": "BBDown.Core/Fetcher/FavListFetcher.cs",
    "content": "﻿using BBDown.Core.Entity;\nusing System.Text.Json;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Util.HTTPUtil;\n\n\nnamespace BBDown.Core.Fetcher;\n\n/// <summary>\n/// 收藏夹解析\n/// https://space.bilibili.com/3/favlist\n///\n/// </summary>\npublic class FavListFetcher : IFetcher\n{\n    public async Task<VInfo> FetchAsync(string id)\n    {\n        id = id[6..];\n        var favId = id.Split(':')[0];\n        var mid = id.Split(':')[1];\n        //查找默认收藏夹\n        if (favId == \"\")\n        {\n            var favListApi = $\"https://api.bilibili.com/x/v3/fav/folder/created/list-all?up_mid={mid}\";\n            favId = JsonDocument.Parse(await GetWebSourceAsync(favListApi)).RootElement.GetProperty(\"data\").GetProperty(\"list\").EnumerateArray().First().GetProperty(\"id\").ToString();\n        }\n\n        int pageSize = 20;\n        int index = 1;\n        List<Page> pagesInfo = new();\n\n        var api = $\"https://api.bilibili.com/x/v3/fav/resource/list?media_id={favId}&pn=1&ps={pageSize}&order=mtime&type=2&tid=0&platform=web\";\n        var json = await GetWebSourceAsync(api);\n        using var infoJson = JsonDocument.Parse(json);\n        var data = infoJson.RootElement.GetProperty(\"data\");\n        int totalCount = data.GetProperty(\"info\").GetProperty(\"media_count\").GetInt32();\n        int totalPage = (int)Math.Ceiling((double)totalCount / pageSize);\n        var title = data.GetProperty(\"info\").GetProperty(\"title\").GetString()!;\n        var intro = data.GetProperty(\"info\").GetProperty(\"intro\").GetString()!;\n        long pubTime = data.GetProperty(\"info\").GetProperty(\"ctime\").GetInt64();\n        var userName = data.GetProperty(\"info\").GetProperty(\"upper\").GetProperty(\"name\").ToString();\n        var medias = data.GetProperty(\"medias\").EnumerateArray().ToList();\n\n        for (int page = 2; page <= totalPage; page++)\n        {\n            api = $\"https://api.bilibili.com/x/v3/fav/resource/list?media_id={favId}&pn={page}&ps={pageSize}&order=mtime&type=2&tid=0&platform=web\";\n            json = await GetWebSourceAsync(api);\n            var jsonDoc = JsonDocument.Parse(json);\n            data = jsonDoc.RootElement.GetProperty(\"data\");\n            medias.AddRange(data.GetProperty(\"medias\").EnumerateArray().ToList());\n        }\n\n        foreach (var m in medias)\n        {\n            //只处理视频类型(可以直接在query param上指定type=2)\n            // if (m.GetProperty(\"type\").GetInt32() != 2) continue;\n            //只处理未失效视频\n            if (m.GetProperty(\"attr\").GetInt32() != 0) continue;\n\n            var pageCount = m.GetProperty(\"page\").GetInt32();\n            if (pageCount > 1)\n            {\n                var tmpInfo = await new NormalInfoFetcher().FetchAsync(m.GetProperty(\"id\").ToString());\n                foreach (var item in tmpInfo.PagesInfo)\n                {\n                    Page p = new(index++, item)\n                    {\n                        title = m.GetProperty(\"title\").ToString() + $\"_P{item.index}_{item.title}\",\n                        cover = tmpInfo.Pic,\n                        desc = m.GetProperty(\"intro\").ToString()\n                    };\n                    if (!pagesInfo.Contains(p)) pagesInfo.Add(p);\n                }\n            }\n            else\n            {\n                Page p = new(index++,\n                    m.GetProperty(\"id\").ToString(),\n                    m.GetProperty(\"ugc\").GetProperty(\"first_cid\").ToString(),\n                    \"\", //epid\n                    m.GetProperty(\"title\").ToString(),\n                    m.GetProperty(\"duration\").GetInt32(),\n                    \"\",\n                    m.GetProperty(\"pubtime\").GetInt64(),\n                    m.GetProperty(\"cover\").ToString(),\n                    m.GetProperty(\"intro\").ToString(),\n                    m.GetProperty(\"upper\").GetProperty(\"name\").ToString(),\n                    m.GetProperty(\"upper\").GetProperty(\"mid\").ToString());\n                if (!pagesInfo.Contains(p)) pagesInfo.Add(p);\n            }\n        }\n\n        var info = new VInfo\n        {\n            Title = title.Trim(),\n            Desc = intro.Trim(),\n            Pic = \"\",\n            PubTime = pubTime,\n            PagesInfo = pagesInfo,\n            IsBangumi = false\n        };\n\n        return info;\n    }\n}"
  },
  {
    "path": "BBDown.Core/Fetcher/IntlBangumiInfoFetcher.cs",
    "content": "﻿using BBDown.Core.Entity;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Util.HTTPUtil;\n\nnamespace BBDown.Core.Fetcher;\n\npublic partial class IntlBangumiInfoFetcher : IFetcher\n{\n    public async Task<VInfo> FetchAsync(string id)\n    {\n        id = id[3..];\n        string index = \"\";\n        //string api = $\"https://api.global.bilibili.com/intl/gateway/ogv/m/view?ep_id={id}\";\n        string api = \"https://\" + (Config.HOST == \"api.bilibili.com\" ? \"api.bilibili.tv\" : Config.HOST) +\n                     $\"/intl/gateway/v2/ogv/view/app/season?ep_id={id}&platform=android&s_locale=zh_SG&mobi_app=bstar_a\" + (Config.TOKEN != \"\" ? $\"&access_key={Config.TOKEN}\" : \"\");\n        string json = (await GetWebSourceAsync(api)).Replace(\"\\\\/\", \"/\");\n        using var infoJson = JsonDocument.Parse(json);\n        var result = infoJson.RootElement.GetProperty(\"result\");\n        string seasonId = result.GetProperty(\"season_id\").ToString();\n        string cover = result.GetProperty(\"cover\").ToString();\n        string title = result.GetProperty(\"title\").ToString();\n        string desc = result.GetProperty(\"evaluate\").ToString();\n\n\n        if (cover == \"\")\n        {\n            string animeUrl = $\"https://bangumi.bilibili.com/anime/{seasonId}\";\n            var web = await GetWebSourceAsync(animeUrl);\n            if (web != \"\")\n            {\n                Regex regex = StateRegex();\n                string _json = regex.Match(web).Groups[1].Value;\n                using var _tempJson = JsonDocument.Parse(_json);\n                cover = _tempJson.RootElement.GetProperty(\"mediaInfo\").GetProperty(\"cover\").ToString();\n                title = _tempJson.RootElement.GetProperty(\"mediaInfo\").GetProperty(\"title\").ToString();\n                desc = _tempJson.RootElement.GetProperty(\"mediaInfo\").GetProperty(\"evaluate\").ToString();\n            }\n        }\n\n        string pubTimeStr = result.GetProperty(\"publish\").GetProperty(\"pub_time\").ToString();\n        long pubTime = string.IsNullOrEmpty(pubTimeStr) ? 0 : DateTimeOffset.ParseExact(pubTimeStr, \"yyyy-MM-dd HH:mm:ss\", null).ToUnixTimeSeconds();\n        var pages = new List<JsonElement>();\n        if (result.TryGetProperty(\"episodes\", out JsonElement episodes))\n        {\n            pages = episodes.EnumerateArray().ToList();\n        }\n        List<Page> pagesInfo = new();\n        int i = 1;\n\n        if (result.TryGetProperty(\"modules\", out JsonElement modules))\n        {\n            foreach (var section in modules.EnumerateArray())\n            {\n                if (section.ToString().Contains($\"/{id}\"))\n                {\n                    pages = section.GetProperty(\"data\").GetProperty(\"episodes\").EnumerateArray().ToList();\n                    break;\n                }\n            }\n        }\n\n        /*if (pages.Count == 0)\n        {\n            if (web != \"\")\n            {\n                string epApi = $\"https://api.bilibili.com/pgc/web/season/section?season_id={seasonId}\";\n                var _web = GetWebSource(epApi);\n                pages = JArray.Parse(JObject.Parse(_web)[\"result\"][\"main_section\"][\"episodes\"].ToString());\n            }\n            else if (infoJson[\"data\"][\"modules\"] != null)\n            {\n                foreach (JObject section in JArray.Parse(infoJson[\"data\"][\"modules\"].ToString()))\n                {\n                    if (section.ToString().Contains($\"ep_id={id}\"))\n                    {\n                        pages = JArray.Parse(section[\"data\"][\"episodes\"].ToString());\n                        break;\n                    }\n                }\n            }\n        }*/\n\n        foreach (var page in pages)\n        {\n            //跳过预告\n            if (page.TryGetProperty(\"badge\", out JsonElement badge) && badge.ToString() == \"预告\") continue;\n            string res = \"\";\n            try\n            {\n                res = page.GetProperty(\"dimension\").GetProperty(\"width\").ToString() + \"x\" + page.GetProperty(\"dimension\").GetProperty(\"height\").ToString();\n            }\n            catch (Exception) { }\n            string _title = page.GetProperty(\"title\").ToString() + \" \" + page.GetProperty(\"long_title\").ToString();\n            _title = _title.Trim();\n            Page p = new(i++,\n                page.GetProperty(\"aid\").ToString(),\n                page.GetProperty(\"cid\").ToString(),\n                page.GetProperty(\"id\").ToString(),\n                _title,\n                0, res,\n                page.TryGetProperty(\"pub_time\", out JsonElement pub_time) ? pub_time.GetInt64() : 0);\n            if (p.epid == id) index = p.index.ToString();\n            pagesInfo.Add(p);\n        }\n\n\n        var info = new VInfo\n        {\n            Title = title.Trim(),\n            Desc = desc.Trim(),\n            Pic = cover,\n            PubTime = pubTime,\n            PagesInfo = pagesInfo,\n            IsBangumi = true,\n            IsCheese = true,\n            Index = index\n        };\n\n        return info;\n    }\n\n    [GeneratedRegex(\"window.__INITIAL_STATE__=([\\\\s\\\\S].*?);\\\\(function\\\\(\\\\)\")]\n    private static partial Regex StateRegex();\n}"
  },
  {
    "path": "BBDown.Core/Fetcher/MediaListFetcher.cs",
    "content": "﻿using BBDown.Core.Entity;\nusing System.Text.Json;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Util.HTTPUtil;\n\nnamespace BBDown.Core.Fetcher;\n\n/// <summary>\n/// 合集解析\n/// https://space.bilibili.com/23630128/channel/collectiondetail?sid=2045\n/// https://www.bilibili.com/medialist/play/23630128?business=space_collection&business_id=2045 (无法从该链接打开合集)\n/// </summary>\npublic class MediaListFetcher : IFetcher\n{\n    public async Task<VInfo> FetchAsync(string id)\n    {\n        id = id[10..];\n        var api = $\"https://api.bilibili.com/x/v1/medialist/info?type=8&biz_id={id}&tid=0\";\n        var json = await GetWebSourceAsync(api);\n        using var infoJson = JsonDocument.Parse(json);\n        var root = infoJson.RootElement;\n        var data = root.GetProperty(\"data\");\n        if (data.ValueKind != JsonValueKind.Object)\n        {\n            // 部分情况下（合集被删除、设为私密或无权访问）data 会是 null\n            // 也有可能是“系列”却被误识别为合集，这里优先尝试按系列解析\n            try\n            {\n                return await new SeriesListFetcher().FetchAsync($\"seriesBizId:{id}\");\n            }\n            catch\n            {\n                var code = root.TryGetProperty(\"code\", out var codeElem) && codeElem.ValueKind == JsonValueKind.Number\n                    ? codeElem.GetInt32()\n                    : 0;\n                var message = root.TryGetProperty(\"message\", out var msgElem) && msgElem.ValueKind == JsonValueKind.String\n                    ? msgElem.GetString()\n                    : \"未知错误\";\n                throw new Exception($\"获取合集信息失败(code={code}): {message}\");\n            }\n        }\n        var listTitle = data.GetProperty(\"title\").GetString()!;\n        var intro = data.GetProperty(\"intro\").GetString()!;\n        long pubTime = data.GetProperty(\"ctime\").GetInt64()!;\n\n        List<Page> pagesInfo = new();\n        bool hasMore = true;\n        var oid = \"\";\n        int index = 1;\n        while (hasMore)\n        {\n            var listApi = $\"https://api.bilibili.com/x/v2/medialist/resource/list?type=8&oid={oid}&otype=2&biz_id={id}&with_current=true&mobi_app=web&ps=20&direction=false&sort_field=1&tid=0&desc=false\";\n            json = await GetWebSourceAsync(listApi);\n            using var listJson = JsonDocument.Parse(json);\n            var listRoot = listJson.RootElement;\n            data = listRoot.GetProperty(\"data\");\n            if (data.ValueKind != JsonValueKind.Object)\n            {\n                var code = listRoot.TryGetProperty(\"code\", out var codeElem) && codeElem.ValueKind == JsonValueKind.Number\n                    ? codeElem.GetInt32()\n                    : 0;\n                var message = listRoot.TryGetProperty(\"message\", out var msgElem) && msgElem.ValueKind == JsonValueKind.String\n                    ? msgElem.GetString()\n                    : \"未知错误\";\n                throw new Exception($\"获取合集视频列表失败(code={code}): {message}\");\n            }\n            hasMore = data.GetProperty(\"has_more\").GetBoolean();\n            foreach (var m in data.GetProperty(\"media_list\").EnumerateArray())\n            {\n                // 只处理未失效的视频条目（与收藏夹解析逻辑保持一致）\n                if (m.TryGetProperty(\"attr\", out var attrElem) && attrElem.GetInt32() != 0)\n                    continue;\n\n                var pageCount = m.GetProperty(\"page\").GetInt32();\n                var desc = m.GetProperty(\"intro\").GetString()!;\n                var ownerName = m.GetProperty(\"upper\").GetProperty(\"name\").ToString();\n                var ownerMid = m.GetProperty(\"upper\").GetProperty(\"mid\").ToString();\n                foreach (var page in m.GetProperty(\"pages\").EnumerateArray())\n                {\n                    Page p = new(index++,\n                        m.GetProperty(\"id\").ToString(),\n                        page.GetProperty(\"id\").ToString(),\n                        \"\", //epid\n                        pageCount == 1 ? m.GetProperty(\"title\").ToString() : $\"{m.GetProperty(\"title\")}_P{page.GetProperty(\"page\")}_{page.GetProperty(\"title\")}\", //单P使用外层标题 多P则拼接内层子标题\n                        page.GetProperty(\"duration\").GetInt32(),\n                        page.GetProperty(\"dimension\").GetProperty(\"width\").ToString() + \"x\" + page.GetProperty(\"dimension\").GetProperty(\"height\").ToString(),\n                        m.GetProperty(\"pubtime\").GetInt64(),\n                        m.GetProperty(\"cover\").ToString(),\n                        desc,\n                        ownerName,\n                        ownerMid);\n                    if (!pagesInfo.Contains(p)) pagesInfo.Add(p);\n                    else index--;\n                }\n                oid = m.GetProperty(\"id\").ToString();\n            }\n        }\n\n        var info = new VInfo\n        {\n            Title = listTitle.Trim(),\n            Desc = intro.Trim(),\n            Pic = \"\",\n            PubTime = pubTime,\n            PagesInfo = pagesInfo,\n            IsBangumi = false\n        };\n\n        return info;\n    }\n}"
  },
  {
    "path": "BBDown.Core/Fetcher/NormalInfoFetcher.cs",
    "content": "﻿using BBDown.Core.Entity;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing System.Xml;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Util.HTTPUtil;\n\nnamespace BBDown.Core.Fetcher;\n\npublic partial class NormalInfoFetcher : IFetcher\n{\n    public async Task<VInfo> FetchAsync(string id)\n    {\n        string api = $\"https://api.bilibili.com/x/web-interface/view?aid={id}\";\n        string json = await GetWebSourceAsync(api);\n        using var infoJson = JsonDocument.Parse(json);\n        var data = infoJson.RootElement.GetProperty(\"data\");\n        string title = data.GetProperty(\"title\").ToString();\n        string desc = data.GetProperty(\"desc\").ToString();\n        string pic = data.GetProperty(\"pic\").ToString();\n        var owner = data.GetProperty(\"owner\");\n        string ownerMid = owner.GetProperty(\"mid\").ToString();\n        string ownerName = owner.GetProperty(\"name\").ToString();\n        long pubTime = data.GetProperty(\"pubdate\").GetInt64();\n        bool bangumi = false;\n        var bvid = data.GetProperty(\"bvid\").ToString();\n        var cid = data.GetProperty(\"cid\").GetInt64();\n\n        // 互动视频 1:是 0:否\n        var isSteinGate = data.GetProperty(\"rights\").GetProperty(\"is_stein_gate\").GetInt16();\n\n        // 分p信息\n        List<Page> pagesInfo = new();\n        var pages = data.GetProperty(\"pages\").EnumerateArray().ToList();\n        foreach (var page in pages)\n        {\n            Page p = new(page.GetProperty(\"page\").GetInt32(),\n                id,\n                page.GetProperty(\"cid\").ToString(),\n                \"\", //epid\n                page.GetProperty(\"part\").ToString().Trim(),\n                page.GetProperty(\"duration\").GetInt32(),\n                page.GetProperty(\"dimension\").GetProperty(\"width\").ToString() + \"x\" + page.GetProperty(\"dimension\").GetProperty(\"height\").ToString(),\n                pubTime, //分p视频没有发布时间\n                \"\",\n                \"\",\n                ownerName,\n                ownerMid\n            );\n            pagesInfo.Add(p);\n        }\n\n        if (isSteinGate == 1) // 互动视频获取分P信息\n        {\n            var playerSoApi = $\"https://api.bilibili.com/x/player.so?bvid={bvid}&id=cid:{cid}\";\n            var playerSoText = await GetWebSourceAsync(playerSoApi);\n            var playerSoXml = new XmlDocument();\n            playerSoXml.LoadXml($\"<root>{playerSoText}</root>\");\n                \n            var interactionNode = playerSoXml.SelectSingleNode(\"//interaction\");\n\n            if (interactionNode is { InnerText.Length: > 0 })\n            {\n                var graphVersion = JsonDocument.Parse(interactionNode.InnerText).RootElement\n                    .GetProperty(\"graph_version\").GetInt64();\n                var edgeInfoApi = $\"https://api.bilibili.com/x/stein/edgeinfo_v2?graph_version={graphVersion}&bvid={bvid}\";\n                var edgeInfoJson = await GetWebSourceAsync(edgeInfoApi);\n                var edgeInfoData = JsonDocument.Parse(edgeInfoJson).RootElement.GetProperty(\"data\");\n                var questions = edgeInfoData.GetProperty(\"edges\").GetProperty(\"questions\").EnumerateArray()\n                    .ToList();\n                var index = 2; // 互动视频分P索引从2开始\n                foreach (var question in questions)\n                {\n                    var choices = question.GetProperty(\"choices\").EnumerateArray().ToList();\n                    foreach (var page in choices)\n                    {\n                        Page p = new(index++,\n                            id,\n                            page.GetProperty(\"cid\").ToString(),\n                            \"\", //epid\n                            page.GetProperty(\"option\").ToString().Trim(),\n                            0,\n                            \"\",\n                            pubTime, //分p视频没有发布时间\n                            \"\",\n                            \"\",\n                            ownerName,\n                            ownerMid\n                        );\n                        pagesInfo.Add(p);\n                    }\n                }\n            }\n            else\n            {\n                throw new Exception(\"互动视频获取分P信息失败\");\n            }\n        }\n\n        try\n        {\n            if (data.GetProperty(\"redirect_url\").ToString().Contains(\"bangumi\"))\n            {\n                bangumi = true;\n                string epId = EpIdRegex().Match(data.GetProperty(\"redirect_url\").ToString()).Groups[1].Value;\n                //番剧内容通常不会有分P，如果有分P则不需要epId参数\n                if (pages.Count == 1)\n                {\n                    pagesInfo.ForEach(p => p.epid = epId);\n                }\n            }\n        }\n        catch { }\n\n        var info = new VInfo\n        {\n            Title = title.Trim(),\n            Desc = desc.Trim(),\n            Pic = pic,\n            PubTime = pubTime,\n            PagesInfo = pagesInfo,\n            IsBangumi = bangumi,\n            IsSteinGate = isSteinGate == 1\n        };\n\n        return info;\n    }\n\n    [GeneratedRegex(\"ep(\\\\d+)\")]\n    private static partial Regex EpIdRegex();\n}"
  },
  {
    "path": "BBDown.Core/Fetcher/SeriesListFetcher.cs",
    "content": "﻿using BBDown.Core.Entity;\nusing System.Text.Json;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Util.HTTPUtil;\n\nnamespace BBDown.Core.Fetcher;\n\n/// <summary>\n/// 列表解析\n/// https://space.bilibili.com/23630128/channel/seriesdetail?sid=340933\n/// </summary>\npublic class SeriesListFetcher : IFetcher\n{\n    public async Task<VInfo> FetchAsync(string id)\n    {\n        //套用BBDownMediaListFetcher.cs的代码\n        //只修改id = id.Substring(12);以及api地址的type=5\n        id = id[12..];\n        var api = $\"https://api.bilibili.com/x/v1/medialist/info?type=5&biz_id={id}&tid=0\";\n        var json = await GetWebSourceAsync(api);\n        using var infoJson = JsonDocument.Parse(json);\n        var data = infoJson.RootElement.GetProperty(\"data\");\n        var listTitle = data.GetProperty(\"title\").GetString()!;\n        var intro = data.GetProperty(\"intro\").GetString()!;\n        long pubTime = data.GetProperty(\"ctime\").GetInt64();\n\n        List<Page> pagesInfo = new();\n        bool hasMore = true;\n        var oid = \"\";\n        int index = 1;\n        while (hasMore)\n        {\n            var listApi = $\"https://api.bilibili.com/x/v2/medialist/resource/list?type=5&oid={oid}&otype=2&biz_id={id}&bvid=&with_current=true&mobi_app=web&ps=20&direction=false&sort_field=1&tid=0&desc=true\";\n            json = await GetWebSourceAsync(listApi);\n            using var listJson = JsonDocument.Parse(json);\n            data = listJson.RootElement.GetProperty(\"data\");\n            hasMore = data.GetProperty(\"has_more\").GetBoolean();\n            foreach (var m in data.GetProperty(\"media_list\").EnumerateArray())\n            {\n                // 只处理未失效的视频条目（与收藏夹解析逻辑保持一致）\n                if (m.TryGetProperty(\"attr\", out var attrElem) && attrElem.GetInt32() != 0)\n                    continue;\n\n                var pageCount = m.GetProperty(\"page\").GetInt32();\n                var desc = m.GetProperty(\"intro\").GetString()!;\n                var ownerName = m.GetProperty(\"upper\").GetProperty(\"name\").ToString();\n                var ownerMid = m.GetProperty(\"upper\").GetProperty(\"mid\").ToString();\n                foreach (var page in m.GetProperty(\"pages\").EnumerateArray())\n                {\n                    Page p = new(index++,\n                        m.GetProperty(\"id\").ToString(),\n                        page.GetProperty(\"id\").ToString(),\n                        \"\", //epid\n                        pageCount == 1 ? m.GetProperty(\"title\").ToString() : $\"{m.GetProperty(\"title\")}_P{page.GetProperty(\"page\")}_{page.GetProperty(\"title\")}\", //单P使用外层标题 多P则拼接内层子标题\n                        page.GetProperty(\"duration\").GetInt32(),\n                        page.GetProperty(\"dimension\").GetProperty(\"width\").ToString() + \"x\" + page.GetProperty(\"dimension\").GetProperty(\"height\").ToString(),\n                        m.GetProperty(\"pubtime\").GetInt64(),\n                        m.GetProperty(\"cover\").ToString(),\n                        desc,\n                        ownerName,\n                        ownerMid);\n                    if (!pagesInfo.Contains(p)) pagesInfo.Add(p);\n                    else index--;\n                }\n                oid = m.GetProperty(\"id\").ToString();\n            }\n        }\n\n        var info = new VInfo\n        {\n            Title = listTitle.Trim(),\n            Desc = intro.Trim(),\n            Pic = \"\",\n            PubTime = pubTime,\n            PagesInfo = pagesInfo,\n            IsBangumi = false\n        };\n\n        return info;\n    }\n}"
  },
  {
    "path": "BBDown.Core/Fetcher/SpaceVideoFetcher.cs",
    "content": "﻿using BBDown.Core.Entity;\nusing System.Text.Json;\nusing static BBDown.Core.Util.HTTPUtil;\nusing static BBDown.Core.Logger;\n\nnamespace BBDown.Core.Fetcher;\n\npublic class SpaceVideoFetcher : IFetcher\n{\n    public async Task<VInfo> FetchAsync(string id)\n    {\n        id = id[4..];\n        // using the live API can bypass w_rid\n        string userInfoApi = $\"https://api.live.bilibili.com/live_user/v1/Master/info?uid={id}\";\n        string userName = GetValidFileName(JsonDocument.Parse(await GetWebSourceAsync(userInfoApi)).RootElement.GetProperty(\"data\").GetProperty(\"info\").GetProperty(\"uname\").ToString(), \".\", true);\n        List<string> urls = new();\n        int pageSize = 50;\n        int pageNumber = 1;\n        var api = Parser.WbiSign($\"mid={id}&order=pubdate&pn={pageNumber}&ps={pageSize}&tid=0&wts={DateTimeOffset.Now.ToUnixTimeSeconds().ToString()}\");\n        api = $\"https://api.bilibili.com/x/space/wbi/arc/search?{api}\";\n        string json = await GetWebSourceAsync(api);\n        var infoJson = JsonDocument.Parse(json);\n        var pages = infoJson.RootElement.GetProperty(\"data\").GetProperty(\"list\").GetProperty(\"vlist\").EnumerateArray();\n        foreach (var page in pages)\n        {\n            urls.Add($\"https://www.bilibili.com/video/av{page.GetProperty(\"aid\")}\");\n        }\n        int totalCount = infoJson.RootElement.GetProperty(\"data\").GetProperty(\"page\").GetProperty(\"count\").GetInt32();\n        int totalPage = (int)Math.Ceiling((double)totalCount / pageSize);\n        while (pageNumber < totalPage)\n        {\n            pageNumber++;\n            urls.AddRange(await GetVideosByPageAsync(pageNumber, pageSize, id));\n        }\n        await File.WriteAllTextAsync($\"{userName}的投稿视频.txt\", string.Join(Environment.NewLine, urls));\n        Log(\"目前下载器不支持下载用户的全部投稿视频，不过程序已经获取到了该用户的全部投稿视频地址，你可以自行使用批处理脚本等手段调用本程序进行批量下载。如在Windows系统你可以使用如下代码：\");\n        Console.WriteLine();\n        Console.WriteLine(@\"@echo Off\nFor /F %%a in (urls.txt) Do (BBDown.exe \"\"%%a\"\")\npause\");\n        Console.WriteLine();\n        throw new Exception(\"暂不支持该功能\");\n    }\n\n    static async Task<List<string>> GetVideosByPageAsync(int pageNumber, int pageSize, string mid)\n    {\n        List<string> urls = new();\n        var api = Parser.WbiSign($\"mid={mid}&order=pubdate&pn={pageNumber}&ps={pageSize}&tid=0&wts={DateTimeOffset.Now.ToUnixTimeSeconds().ToString()}\");\n        api = $\"https://api.bilibili.com/x/space/wbi/arc/search?{api}\";\n        string json = await GetWebSourceAsync(api);\n        var infoJson = JsonDocument.Parse(json);\n        var pages = infoJson.RootElement.GetProperty(\"data\").GetProperty(\"list\").GetProperty(\"vlist\").EnumerateArray();\n        foreach (var page in pages)\n        {\n            urls.Add($\"https://www.bilibili.com/video/av{page.GetProperty(\"aid\")}\");\n        }\n        return urls;\n    }\n\n    private static string GetValidFileName(string input, string re = \".\", bool filterSlash = false)\n    {\n        string title = input;\n        foreach (char invalidChar in Path.GetInvalidFileNameChars())\n        {\n            title = title.Replace(invalidChar.ToString(), re);\n        }\n        if (filterSlash)\n        {\n            title = title.Replace(\"/\", re);\n            title = title.Replace(\"\\\\\", re);\n        }\n        return title;\n    }\n}"
  },
  {
    "path": "BBDown.Core/FetcherFactory.cs",
    "content": "﻿using BBDown.Core.Fetcher;\n\nnamespace BBDown.Core;\n\npublic static class FetcherFactory\n{\n    /// <summary>\n    /// 根据不同场景获取不同的Info解析器\n    /// </summary>\n    /// <param name=\"aidOri\"></param>\n    /// <returns>IFetcher</returns>\n    public static IFetcher CreateFetcher(string aidOri, bool useIntlApi)\n    {\n        IFetcher fetcher = new NormalInfoFetcher();\n        if (aidOri.StartsWith(\"cheese\"))\n        {\n            fetcher = new CheeseInfoFetcher();\n        }\n        else if (aidOri.StartsWith(\"ep\"))\n        {\n            fetcher = useIntlApi ? new IntlBangumiInfoFetcher() : new BangumiInfoFetcher();\n        }\n        else if (aidOri.StartsWith(\"mid\"))\n        {\n            fetcher = new SpaceVideoFetcher();\n        }\n        else if (aidOri.StartsWith(\"listBizId\"))\n        {\n            fetcher = new MediaListFetcher();\n        }\n        else if (aidOri.StartsWith(\"seriesBizId\"))\n        {\n            fetcher = new SeriesListFetcher();\n        }\n        else if (aidOri.StartsWith(\"favId\"))\n        {\n            fetcher = new FavListFetcher();\n        }\n        return fetcher;\n    }\n}"
  },
  {
    "path": "BBDown.Core/IFetcher.cs",
    "content": "﻿namespace BBDown.Core;\n\npublic interface IFetcher\n{\n    Task<Entity.VInfo> FetchAsync(string id);\n}"
  },
  {
    "path": "BBDown.Core/Logger.cs",
    "content": "﻿namespace BBDown.Core;\n\npublic static class Logger\n{\n    public static void Log(object text, bool enter = true)\n    {\n        Console.Write(DateTime.Now.ToString(\"[yyyy-MM-dd HH:mm:ss.fff]\") + \" - \" + text);\n        if (enter) Console.WriteLine();\n    }\n\n    public static void LogError(object text)\n    {\n        Console.Write(DateTime.Now.ToString(\"[yyyy-MM-dd HH:mm:ss.fff]\") + \" - \");\n        Console.ForegroundColor = ConsoleColor.Red;\n        Console.Write(text);\n        Console.ResetColor();\n        Console.WriteLine();\n    }\n\n    public static void LogColor(object text, bool time = true)\n    {\n        if (time)\n            Console.Write(DateTime.Now.ToString(\"[yyyy-MM-dd HH:mm:ss.fff]\") + \" - \");\n        Console.ForegroundColor = ConsoleColor.Cyan;\n        if (time)\n            Console.Write(text);\n        else\n            Console.Write(\"                            \" + text);\n        Console.ResetColor();\n        Console.WriteLine();\n    }\n\n    public static void LogWarn(object text, bool time = true)\n    {\n        if (time)\n            Console.Write(DateTime.Now.ToString(\"[yyyy-MM-dd HH:mm:ss.fff]\") + \" - \");\n        Console.ForegroundColor = ConsoleColor.DarkYellow;\n        if (time)\n            Console.Write(text);\n        else\n            Console.Write(\"                            \" + text);\n        Console.ResetColor();\n        Console.WriteLine();\n    }\n\n    public static void LogDebug(string toFormat, params object[] args)\n    {\n        if (Config.DEBUG_LOG)\n        {\n            Console.ForegroundColor = ConsoleColor.DarkGray;\n            Console.Write(DateTime.Now.ToString(\"[yyyy-MM-dd HH:mm:ss.fff]\") + \" - \");\n            if (args.Length > 0)\n                Console.Write(string.Format(toFormat, args).Trim());\n            else\n                Console.Write(toFormat);\n            Console.ResetColor();\n            Console.WriteLine();\n        }\n    }\n}"
  },
  {
    "path": "BBDown.Core/Parser.cs",
    "content": "﻿using System.Text;\nusing System.Text.RegularExpressions;\nusing System.Text.Json;\nusing static BBDown.Core.Logger;\nusing static BBDown.Core.Util.HTTPUtil;\nusing static BBDown.Core.Entity.Entity;\nusing System.Security.Cryptography;\nusing BBDown.Core.Entity;\n\nnamespace BBDown.Core;\n\npublic static partial class Parser\n{\n    public static string WbiSign(string api)\n    {\n        return $\"{api}&w_rid=\" + string.Concat(MD5.HashData(Encoding.UTF8.GetBytes(api + Config.WBI)).Select(i => i.ToString(\"x2\")).ToArray());\n    }\n\n    private static async Task<string> GetPlayJsonAsync(string encoding, string aidOri, string aid, string cid, string epId, bool tvApi, bool intl, bool appApi, string qn = \"0\")\n    {\n        LogDebug(\"aid={0},cid={1},epId={2},tvApi={3},IntlApi={4},appApi={5},qn={6}\", aid, cid, epId, tvApi, intl, appApi, qn);\n\n        if (intl) return await GetPlayJsonAsync(aid, cid, epId, qn);\n\n\n        bool cheese = aidOri.StartsWith(\"cheese:\");\n        bool bangumi = cheese || aidOri.StartsWith(\"ep:\");\n        LogDebug(\"bangumi={0},cheese={1}\", bangumi, cheese);\n\n        if (appApi) return await AppHelper.DoReqAsync(aid, cid, epId, qn, bangumi, encoding, Config.TOKEN);\n\n        string prefix = tvApi ? bangumi ? $\"{Config.TVHOST}/pgc/player/api/playurltv\" : $\"{Config.TVHOST}/x/tv/playurl\"\n            : bangumi ? $\"{Config.HOST}/pgc/player/web/v2/playurl\" : \"api.bilibili.com/x/player/wbi/playurl\";\n        prefix = $\"https://{prefix}?\";\n\n        string api;\n        if (tvApi)\n        {\n            StringBuilder apiBuilder = new();\n            if (Config.TOKEN != \"\") apiBuilder.Append($\"access_key={Config.TOKEN}&\");\n            apiBuilder.Append($\"appkey=4409e2ce8ffd12b8&build=106500&cid={cid}&device=android\");\n            if (bangumi) apiBuilder.Append($\"&ep_id={epId}&expire=0\");\n            apiBuilder.Append($\"&fnval=4048&fnver=0&fourk=1&mid=0&mobi_app=android_tv_yst\");\n            apiBuilder.Append($\"&object_id={aid}&platform=android&playurl_type=1&qn={qn}&ts={GetTimeStamp(true)}\");\n            api = $\"{prefix}{apiBuilder}&sign={GetSign(apiBuilder.ToString(), false)}\";\n        }\n        else\n        {\n            // 尝试提高可读性\n            StringBuilder apiBuilder = new();\n            apiBuilder.Append($\"support_multi_audio=true&from_client=BROWSER&avid={aid}&cid={cid}&fnval=4048&fnver=0&fourk=1\");\n            if (Config.AREA != \"\") apiBuilder.Append($\"&access_key={Config.TOKEN}&area={Config.AREA}\");\n            apiBuilder.Append($\"&otype=json&qn={qn}\");\n            if (bangumi) apiBuilder.Append($\"&module=bangumi&ep_id={epId}&session=\");\n            if (Config.COOKIE == \"\") apiBuilder.Append(\"&try_look=1\");\n            apiBuilder.Append($\"&wts={GetTimeStamp(true)}\");\n            api = prefix + (bangumi ? apiBuilder.ToString() : WbiSign(apiBuilder.ToString()));\n        }\n\n        //课程接口\n        if (cheese) api = api.Replace(\"/pgc/\", \"/pugv/\");\n\n        //Console.WriteLine(api);\n        string webJson = await GetWebSourceAsync(api);\n        //以下情况从网页源代码尝试解析\n        if (webJson.Contains(\"\\\"大会员专享限制\\\"\"))\n        {\n            Log(\"此视频需要大会员，您大概率需要登录一个有大会员的账号才可以下载，尝试从网页源码解析\");\n            string webUrl = \"https://www.bilibili.com/bangumi/play/ep\" + epId;\n            string webSource = await GetWebSourceAsync(webUrl);\n            webJson = PlayerJsonRegex().Match(webSource).Groups[1].Value;\n        }\n        return webJson;\n    }\n\n    private static async Task<string> GetPlayJsonAsync(string aid, string cid, string epId, string qn, string code = \"0\")\n    {\n        bool isBiliPlus = Config.HOST != \"api.bilibili.com\";\n        string api = $\"https://{(isBiliPlus ? Config.HOST : \"api.biliintl.com\")}/intl/gateway/v2/ogv/playurl?\";\n\n        StringBuilder paramBuilder = new();\n        if (Config.TOKEN != \"\") paramBuilder.Append($\"access_key={Config.TOKEN}&\");\n        paramBuilder.Append($\"aid={aid}\");\n        if (isBiliPlus) paramBuilder.Append($\"&appkey=7d089525d3611b1c&area={(Config.AREA == \"\" ? \"th\" : Config.AREA)}\");\n        paramBuilder.Append($\"&cid={cid}&ep_id={epId}&platform=android&prefer_code_type={code}&qn={qn}\");\n        if (isBiliPlus) paramBuilder.Append($\"&ts={GetTimeStamp(true)}\");\n\n        paramBuilder.Append(\"&s_locale=zh_SG\");\n        string param = paramBuilder.ToString();\n        api += (isBiliPlus ? $\"{param}&sign={GetSign(param, true)}\" : param);\n\n        string webJson = await GetWebSourceAsync(api);\n        return webJson;\n    }\n\n    public static async Task<ParsedResult> ExtractTracksAsync(string aidOri, string aid, string cid, string epId, bool tvApi, bool intlApi, bool appApi, string encoding, string qn = \"0\")\n    {\n        var intlCode = \"0\";\n        ParsedResult parsedResult = new();\n\n        //调用解析\n        parsedResult.WebJsonString = await GetPlayJsonAsync(encoding, aidOri, aid, cid, epId, tvApi, intlApi, appApi, qn);\n\n        LogDebug(parsedResult.WebJsonString);\n\n        startParsing:\n        var respJson = JsonDocument.Parse(parsedResult.WebJsonString);\n        var data = respJson.RootElement;\n\n        //intl接口\n        if (parsedResult.WebJsonString.Contains(\"\\\"stream_list\\\"\"))\n        {\n            int pDur = data.GetProperty(\"data\").GetProperty(\"video_info\").GetProperty(\"timelength\").GetInt32() / 1000;\n            var audio = data.GetProperty(\"data\").GetProperty(\"video_info\").GetProperty(\"dash_audio\").EnumerateArray().ToList();\n            foreach (var stream in data.GetProperty(\"data\").GetProperty(\"video_info\").GetProperty(\"stream_list\").EnumerateArray())\n            {\n                if (stream.TryGetProperty(\"dash_video\", out JsonElement dashVideo))\n                {\n                    if (dashVideo.GetProperty(\"base_url\").ToString() != \"\")\n                    {\n                        var videoId = stream.GetProperty(\"stream_info\").GetProperty(\"quality\").ToString();\n                        var urlList = new List<string>() { dashVideo.GetProperty(\"base_url\").ToString() };\n                        urlList.AddRange(dashVideo.GetProperty(\"backup_url\").EnumerateArray().Select(i => i.ToString()));\n                        Video v = new()\n                        {\n                            dur = pDur,\n                            id = videoId,\n                            dfn = Config.qualitys[videoId],\n                            bandwith = Convert.ToInt64(dashVideo.GetProperty(\"bandwidth\").ToString()) / 1000,\n                            baseUrl = urlList.FirstOrDefault(i => !BaseUrlRegex().IsMatch(i), urlList.First()),\n                            codecs = GetVideoCodec(dashVideo.GetProperty(\"codecid\").ToString()),\n                            size = dashVideo.TryGetProperty(\"size\", out var sizeNode) ? Convert.ToDouble(sizeNode.ToString()) : 0\n                        };\n                        if (!parsedResult.VideoTracks.Contains(v)) parsedResult.VideoTracks.Add(v);\n                    }\n                }\n            }\n\n            foreach (var node in audio)\n            {\n                var urlList = new List<string>() { node.GetProperty(\"base_url\").ToString() };\n                urlList.AddRange(node.GetProperty(\"backup_url\").EnumerateArray().Select(i => i.ToString()));\n                Audio a = new()\n                {\n                    id = node.GetProperty(\"id\").ToString(),\n                    dfn = node.GetProperty(\"id\").ToString(),\n                    dur = pDur,\n                    bandwith = Convert.ToInt64(node.GetProperty(\"bandwidth\").ToString()) / 1000,\n                    baseUrl = urlList.FirstOrDefault(i => !BaseUrlRegex().IsMatch(i), urlList.First()),\n                    codecs = \"M4A\"\n                };\n                if (!parsedResult.AudioTracks.Contains(a)) parsedResult.AudioTracks.Add(a);\n            }\n\n            if (intlCode == \"0\")\n            {\n                intlCode = \"1\";\n                parsedResult.WebJsonString = await GetPlayJsonAsync(aid, cid, epId, qn, intlCode);\n                goto startParsing;\n            }\n\n            return parsedResult;\n        }\n        // data节点一次性判断完\n        string? nodeName = null;\n        if (parsedResult.WebJsonString.Contains(\"\\\"result\\\":{\"))\n        {\n            nodeName = \"result\";\n\n            // v2\n            if (parsedResult.WebJsonString.Contains(\"\\\"video_info\\\":{\"))\n            {\n                nodeName = \"video_info\";\n            }\n        }\n        else if (parsedResult.WebJsonString.Contains(\"\\\"data\\\":{\")) nodeName = \"data\";\n        var root = nodeName == null ? data : nodeName == \"video_info\" ? data.GetProperty(\"result\").GetProperty(nodeName) : data.GetProperty(nodeName);\n\n        bool bangumi = aidOri.StartsWith(\"ep:\");\n\n        if (parsedResult.WebJsonString.Contains(\"\\\"dash\\\":{\")) //dash\n        {\n            List<JsonElement>? audio = null;\n            List<JsonElement>? video = null;\n            List<JsonElement>? backgroundAudio = null;\n            List<JsonElement>? roleAudio = null;\n            int pDur = 0;\n\n            try { pDur = root.GetProperty(\"dash\").GetProperty(\"duration\").GetInt32(); } catch { }\n            try { pDur = root.GetProperty(\"timelength\").GetInt32() / 1000; } catch { }\n\n            bool reParse = false;\n            reParse:\n            if (reParse)\n            {\n                parsedResult.WebJsonString = await GetPlayJsonAsync(encoding, aidOri, aid, cid, epId, tvApi, intlApi, appApi, GetMaxQn());\n                respJson = JsonDocument.Parse(parsedResult.WebJsonString);\n                data = respJson.RootElement;\n                root = nodeName == null ? data : nodeName == \"video_info\" ? data.GetProperty(\"result\").GetProperty(nodeName) : data.GetProperty(nodeName);\n            }\n            try { video = root.GetProperty(\"dash\").GetProperty(\"video\").EnumerateArray().ToList(); } catch { }\n            try { audio = root.GetProperty(\"dash\").GetProperty(\"audio\").EnumerateArray().ToList(); } catch { }\n\n            if (appApi && bangumi)\n            {\n                try { backgroundAudio = data.GetProperty(\"dubbing_info\").GetProperty(\"background_audio\").EnumerateArray().ToList(); } catch { }\n                try { roleAudio = data.GetProperty(\"dubbing_info\").GetProperty(\"role_audio_list\").EnumerateArray().ToList(); } catch { }\n            }\n            //处理杜比音频\n            try\n            {\n                if (audio != null)\n                {\n                    if (!tvApi && root.GetProperty(\"dash\").TryGetProperty(\"dolby\", out JsonElement dolby))\n                    {\n                        if (dolby.TryGetProperty(\"audio\", out JsonElement db))\n                        {\n                            audio.AddRange(db.EnumerateArray());\n                        }\n                    }\n                }\n            }\n            catch (Exception) {; }\n\n            //处理Hi-Res无损\n            try\n            {\n                if (audio != null)\n                {\n                    if (!tvApi && root.GetProperty(\"dash\").TryGetProperty(\"flac\", out JsonElement hiRes))\n                    {\n                        if (hiRes.TryGetProperty(\"audio\", out JsonElement db))\n                        {\n                            if (db.ValueKind != JsonValueKind.Null)\n                                audio.Add(db);\n                        }\n                    }\n                }\n            }\n            catch (Exception) {; }\n\n            if (video != null)\n            {\n                foreach (var node in video)\n                {\n                    var urlList = new List<string>() { node.GetProperty(\"base_url\").ToString() };\n                    if (node.TryGetProperty(\"backup_url\", out JsonElement element) && element.ValueKind != JsonValueKind.Null)\n                    {\n                        urlList.AddRange(element.EnumerateArray().Select(i => i.ToString()));\n                    }\n                    var videoId = node.GetProperty(\"id\").ToString();\n                    Video v = new()\n                    {\n                        dur = pDur,\n                        id = videoId,\n                        dfn = Config.qualitys[videoId],\n                        bandwith = Convert.ToInt64(node.GetProperty(\"bandwidth\").ToString()) / 1000,\n                        baseUrl = urlList.FirstOrDefault(i => !BaseUrlRegex().IsMatch(i), urlList.First()),\n                        codecs = GetVideoCodec(node.GetProperty(\"codecid\").ToString()),\n                        size = node.TryGetProperty(\"size\", out var sizeNode) ? Convert.ToDouble(sizeNode.ToString()) : 0\n                    };\n                    if (!tvApi && !appApi)\n                    {\n                        v.res = node.GetProperty(\"width\").ToString() + \"x\" + node.GetProperty(\"height\").ToString();\n                        v.fps = node.GetProperty(\"frame_rate\").ToString();\n                    }\n                    if (!parsedResult.VideoTracks.Contains(v)) parsedResult.VideoTracks.Add(v);\n                }\n            }\n\n            //此处处理免二压视频，需要单独再请求一次\n            if (!reParse && !appApi)\n            {\n                reParse = true;\n                goto reParse;\n            }\n\n            if (audio != null)\n            {\n                foreach (var node in audio)\n                {\n                    var urlList = new List<string>() { node.GetProperty(\"base_url\").ToString() };\n                    if (node.TryGetProperty(\"backup_url\", out JsonElement element) && element.ValueKind != JsonValueKind.Null)\n                    {\n                        urlList.AddRange(element.EnumerateArray().Select(i => i.ToString()));\n                    }\n                    var audioId = node.GetProperty(\"id\").ToString();\n                    var codecs = node.GetProperty(\"codecs\").ToString();\n                    codecs = codecs switch\n                    {\n                        \"mp4a.40.2\" => \"M4A\",\n                        \"mp4a.40.5\" => \"M4A\",\n                        \"ec-3\" => \"E-AC-3\",\n                        \"fLaC\" => \"FLAC\",\n                        _ => codecs\n                    };\n\n                    parsedResult.AudioTracks.Add(new Audio()\n                    {\n                        id = audioId,\n                        dfn = audioId,\n                        dur = pDur,\n                        bandwith = Convert.ToInt64(node.GetProperty(\"bandwidth\").ToString()) / 1000,\n                        baseUrl = urlList.FirstOrDefault(i => !BaseUrlRegex().IsMatch(i), urlList.First()),\n                        codecs = codecs\n                    });\n                }\n            }\n\n            if (backgroundAudio != null && roleAudio != null)\n            {\n                foreach (var node in backgroundAudio)\n                {\n                    var audioId = node.GetProperty(\"id\").ToString();\n                    var urlList = new List<string> { node.GetProperty(\"base_url\").ToString() };\n                    urlList.AddRange(node.GetProperty(\"backup_url\").EnumerateArray().Select(i => i.ToString()));\n                    parsedResult.BackgroundAudioTracks.Add(new Audio()\n                    {\n                        id = audioId,\n                        dfn = audioId,\n                        dur = pDur,\n                        bandwith = Convert.ToInt64(node.GetProperty(\"bandwidth\").ToString()) / 1000,\n                        baseUrl = urlList.FirstOrDefault(i => !BaseUrlRegex().IsMatch(i), urlList.First()),\n                        codecs = node.GetProperty(\"codecs\").ToString()\n                    });\n                }\n\n                foreach (var role in roleAudio)\n                {\n                    var roleAudioTracks = new List<Audio>();\n                    foreach (var node in role.GetProperty(\"audio\").EnumerateArray())\n                    {\n                        var audioId = node.GetProperty(\"id\").ToString();\n                        var urlList = new List<string> { node.GetProperty(\"base_url\").ToString() };\n                        urlList.AddRange(node.GetProperty(\"backup_url\").EnumerateArray().Select(i => i.ToString()));\n                        roleAudioTracks.Add(new Audio()\n                        {\n                            id = audioId,\n                            dfn = audioId,\n                            dur = pDur,\n                            bandwith = Convert.ToInt64(node.GetProperty(\"bandwidth\").ToString()) / 1000,\n                            baseUrl = urlList.FirstOrDefault(i => !BaseUrlRegex().IsMatch(i), urlList.First()),\n                            codecs = node.GetProperty(\"codecs\").ToString()\n                        });\n                    }\n                    parsedResult.RoleAudioList.Add(new AudioMaterialInfo()\n                    {\n                        title = role.GetProperty(\"title\").ToString(),\n                        personName = role.GetProperty(\"person_name\").ToString(),\n                        path = $\"{aid}/{aid}.{cid}.{role.GetProperty(\"audio_id\").ToString()}.m4a\",\n                        audio = roleAudioTracks\n                    });\n                }\n            }\n        }\n        else if (parsedResult.WebJsonString.Contains(\"\\\"durl\\\":[\")) //flv\n        {\n            //默认以最高清晰度解析\n            parsedResult.WebJsonString = await GetPlayJsonAsync(encoding, aidOri, aid, cid, epId, tvApi, intlApi, appApi, GetMaxQn());\n            data = JsonDocument.Parse(parsedResult.WebJsonString).RootElement;\n            root = nodeName == null ? data : nodeName == \"video_info\" ? data.GetProperty(\"result\").GetProperty(nodeName) : data.GetProperty(nodeName);\n            string quality = \"\";\n            string videoCodecid = \"\";\n            string url = \"\";\n            double size = 0;\n            double length = 0;\n\n            quality = root.GetProperty(\"quality\").ToString();\n            videoCodecid = root.GetProperty(\"video_codecid\").ToString();\n            //获取所有分段\n            foreach (var node in root.GetProperty(\"durl\").EnumerateArray())\n            {\n                parsedResult.Clips.Add(node.GetProperty(\"url\").ToString());\n                size += node.GetProperty(\"size\").GetDouble();\n                length += node.GetProperty(\"length\").GetDouble();\n            }\n            //TV模式可用清晰度\n            if (root.TryGetProperty(\"qn_extras\", out JsonElement qnExtras))\n            {\n                parsedResult.Dfns.AddRange(qnExtras.EnumerateArray().Select(node => node.GetProperty(\"qn\").ToString()));\n            }\n            else if (root.TryGetProperty(\"accept_quality\", out JsonElement acceptQuality)) //非tv模式可用清晰度\n            {\n                parsedResult.Dfns.AddRange(acceptQuality.EnumerateArray()\n                    .Select(node => node.ToString())\n                    .Where(_qn => !string.IsNullOrEmpty(_qn)));\n            }\n\n            Video v = new()\n            {\n                id = quality,\n                dfn = Config.qualitys[quality],\n                baseUrl = url,\n                codecs = GetVideoCodec(videoCodecid),\n                dur = (int)length / 1000,\n                size = size\n            };\n            if (!parsedResult.VideoTracks.Contains(v)) parsedResult.VideoTracks.Add(v);\n        }\n\n        // 番剧片头片尾转分段信息, 预计效果: 正片? -> 片头 -> 正片 -> 片尾\n        if (bangumi)\n        {\n            if (root.TryGetProperty(\"clip_info_list\", out JsonElement clipList))\n            {\n                parsedResult.ExtraPoints.AddRange(clipList.EnumerateArray().Select(clip => new ViewPoint()\n                    {\n                        title = clip.GetProperty(\"toastText\").ToString().Replace(\"即将跳过\", \"\"),\n                        start = clip.GetProperty(\"start\").GetInt32(),\n                        end = clip.GetProperty(\"end\").GetInt32()\n                    })\n                );\n                parsedResult.ExtraPoints.Sort((p1, p2) => p1.start.CompareTo(p2.start));\n                var newPoints = new List<ViewPoint>();\n                int lastEnd = 0;\n                foreach (var point in parsedResult.ExtraPoints)\n                {\n                    if (lastEnd < point.start)\n                        newPoints.Add(new ViewPoint() { title = \"正片\", start = lastEnd, end = point.start });\n                    newPoints.Add(point);\n                    lastEnd = point.end;\n                }\n                parsedResult.ExtraPoints = newPoints;\n            }\n\n        }\n\n        return parsedResult;\n    }\n\n    /// <summary>\n    /// 编码转换\n    /// </summary>\n    /// <param name=\"code\"></param>\n    /// <returns></returns>\n    private static string GetVideoCodec(string code)\n    {\n        return code switch\n        {\n            \"13\" => \"AV1\",\n            \"12\" => \"HEVC\",\n            \"7\" => \"AVC\",\n            _ => \"UNKNOWN\"\n        };\n    }\n\n    private static string GetMaxQn()\n    {\n        return Config.qualitys.Keys.First();\n    }\n\n    private static string GetTimeStamp(bool bflag)\n    {\n        DateTimeOffset ts = DateTimeOffset.Now;\n        return bflag ? ts.ToUnixTimeSeconds().ToString() : ts.ToUnixTimeMilliseconds().ToString();\n    }\n\n    private static string GetSign(string parms, bool isBiliPlus)\n    {\n        string toEncode = parms + (isBiliPlus ? \"acd495b248ec528c2eed1e862d393126\" : \"59b43e04ad6965f34319062b478f83dd\");\n        return string.Concat(MD5.HashData(Encoding.UTF8.GetBytes(toEncode)).Select(i => i.ToString(\"x2\")).ToArray());\n    }\n\n    [GeneratedRegex(\"window.__playinfo__=([\\\\s\\\\S]*?)<\\\\/script>\")]\n    private static partial Regex PlayerJsonRegex();\n    [GeneratedRegex(\"http.*:\\\\d+\")]\n    private static partial Regex BaseUrlRegex();\n}"
  },
  {
    "path": "BBDown.Core/Util/BilibiliBvConverter.cs",
    "content": "﻿using System.Text;\n\nnamespace BBDown.Core.Util;\n\n//code from: https://github.com/Colerar/abv/blob/main/src/lib.rs\npublic static class BilibiliBvConverter\n{\n    private const long XOR_CODE = 23442827791579L;\n    private const long MASK_CODE = (1L << 51) - 1;\n\n    private const long MAX_AID = MASK_CODE + 1;\n    private const long MIN_AID = 1L;\n\n    private const long BASE = 58L;\n    private const byte BV_LEN = 9;\n\n    private static readonly byte[] ALPHABET = Encoding.ASCII.GetBytes(\"FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf\");\n\n    private static readonly Dictionary<byte, long> REV_ALPHABETA = new Dictionary<byte, long>();\n\n    static BilibiliBvConverter()\n    {\n        for (byte i = 0; i < ALPHABET.Length; i++)\n        {\n            REV_ALPHABETA[ALPHABET[i]] = i;\n        }\n    }\n\n    public static string Encode(long avid)\n    {\n        if (avid < MIN_AID)\n        {\n            throw new Exception($\"Av {avid} is smaller than {MIN_AID}\");\n        }\n        if (avid >= MAX_AID)\n        {\n            throw new Exception($\"Av {avid} is bigger than {MAX_AID}\");\n        }\n\n        var bvid = new byte[BV_LEN];\n        long tmp = (MAX_AID | avid) ^ XOR_CODE;\n\n        for (byte i = BV_LEN - 1; tmp != 0; i--)\n        {\n            bvid[i] = ALPHABET[tmp % BASE];\n            tmp /= BASE;\n        }\n\n        (bvid[0], bvid[6]) = (bvid[6], bvid[0]);\n        (bvid[1], bvid[4]) = (bvid[4], bvid[1]);\n\n        return \"BV1\" + Encoding.ASCII.GetString(bvid);\n    }\n\n    public static long Decode(string bvid_str)\n    {\n        if (bvid_str.Length != BV_LEN)\n        {\n            throw new Exception($\"Bv BV1{bvid_str} must to be 12 char\");\n        }\n\n        byte[] bvid = Encoding.ASCII.GetBytes(bvid_str);\n        (bvid[0], bvid[6]) = (bvid[6], bvid[0]);\n        (bvid[1], bvid[4]) = (bvid[4], bvid[1]);\n\n        long avid = 0;\n        foreach (byte b in bvid)\n        {\n            avid = avid * BASE + REV_ALPHABETA[b];\n        }\n\n        return (avid & MASK_CODE) ^ XOR_CODE;\n    }\n}"
  },
  {
    "path": "BBDown.Core/Util/HTTPUtil.cs",
    "content": "﻿using System.Net;\nusing System.Net.Http.Headers;\nusing static BBDown.Core.Logger;\n\nnamespace BBDown.Core.Util;\n\npublic static class HTTPUtil\n{\n\n    public static readonly HttpClient AppHttpClient = new(new HttpClientHandler\n    {\n        AllowAutoRedirect = true,\n        AutomaticDecompression = DecompressionMethods.All,\n        ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true\n    })\n    {\n        Timeout = TimeSpan.FromMinutes(2)\n    };\n\n    private static readonly Random random = new Random();\n    private static readonly string[] platforms = { \"Windows NT 10.0; Win64\", \"Macintosh; Intel Mac OS X 10_15\", \"X11; Linux x86_64\" };\n\n    private static string RandomVersion(int min, int max)\n    {\n        double version = random.NextDouble() * (max - min) + min;\n        return version.ToString(\"F3\");\n    }\n\n    private static string GetRandomUserAgent()\n    {\n        string[] browsers = { $\"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{RandomVersion(80, 110)} Safari/537.36\", $\"Gecko/20100101 Firefox/{RandomVersion(80, 110)}\" };\n        return $\"Mozilla/5.0 ({platforms[random.Next(platforms.Length)]}) {browsers[random.Next(browsers.Length)]}\";\n    }\n\n    public static string UserAgent { get; set; } = GetRandomUserAgent();\n\n    public static async Task<string> GetWebSourceAsync(string url, string? userAgent = null)\n    {\n        using var webRequest = new HttpRequestMessage(HttpMethod.Get, url);\n        webRequest.Headers.TryAddWithoutValidation(\"User-Agent\", userAgent ?? UserAgent);\n        webRequest.Headers.TryAddWithoutValidation(\"Accept-Encoding\", \"gzip, deflate\");\n        webRequest.Headers.TryAddWithoutValidation(\"Cookie\", (url.Contains(\"/ep\") || url.Contains(\"/ss\")) ? Config.COOKIE + \";CURRENT_FNVAL=4048;\" : Config.COOKIE);\n        if (url.Contains(\"api.bilibili.com\"))\n            webRequest.Headers.TryAddWithoutValidation(\"Referer\", \"https://www.bilibili.com/\");\n        if (url.Contains(\"api.bilibili.tv\"))\n            webRequest.Headers.TryAddWithoutValidation(\"sec-ch-ua\", \"\\\"Google Chrome\\\";v=\\\"131\\\", \\\"Chromium\\\";v=\\\"131\\\", \\\"Not_A Brand\\\";v=\\\"24\\\"\");\n        webRequest.Headers.CacheControl = CacheControlHeaderValue.Parse(\"no-cache\");\n        webRequest.Headers.Connection.Clear();\n\n        LogDebug(\"获取网页内容: Url: {0}, Headers: {1}\", url, webRequest.Headers);\n        var webResponse = (await AppHttpClient.SendAsync(webRequest, HttpCompletionOption.ResponseHeadersRead)).EnsureSuccessStatusCode();\n\n        string htmlCode = await webResponse.Content.ReadAsStringAsync();\n        LogDebug(\"Response: {0}\", htmlCode);\n        return htmlCode;\n    }\n\n    // 重写重定向处理, 自动跟随多次重定向\n    public static async Task<string> GetWebLocationAsync(string url)\n    {\n        using var webRequest = new HttpRequestMessage(HttpMethod.Head, url);\n        webRequest.Headers.TryAddWithoutValidation(\"User-Agent\", UserAgent);\n        webRequest.Headers.TryAddWithoutValidation(\"Accept-Encoding\", \"gzip, deflate\");\n        webRequest.Headers.CacheControl = CacheControlHeaderValue.Parse(\"no-cache\");\n        webRequest.Headers.Connection.Clear();\n\n        LogDebug(\"获取网页重定向地址: Url: {0}, Headers: {1}\", url, webRequest.Headers);\n        var webResponse = (await AppHttpClient.SendAsync(webRequest, HttpCompletionOption.ResponseHeadersRead)).EnsureSuccessStatusCode();\n        string location = webResponse.RequestMessage.RequestUri.AbsoluteUri;\n        LogDebug(\"Location: {0}\", location);\n        return location;\n    }\n\n    public static async Task<byte[]> GetPostResponseAsync(string Url, byte[] postData, Dictionary<string, string>? headers = null)\n    {\n        LogDebug(\"Post to: {0}, data: {1}\", Url, Convert.ToBase64String(postData));\n\n        ByteArrayContent content = new(postData);\n        content.Headers.ContentType = MediaTypeHeaderValue.Parse(\"application/grpc\");\n\n        HttpRequestMessage request = new()\n        {\n            RequestUri = new Uri(Url),\n            Method = HttpMethod.Post,\n            Content = content,\n            //Version = HttpVersion.Version20\n        };\n\n        if (headers != null)\n        {\n            foreach (KeyValuePair<string, string> header in headers)\n                request.Headers.TryAddWithoutValidation(header.Key, header.Value);\n        }\n        else\n        {\n            request.Headers.TryAddWithoutValidation(\"User-Agent\", \"Dalvik/2.1.0 (Linux; U; Android 6.0.1; oneplus a5010 Build/V417IR) 6.10.0 os/android model/oneplus a5010 mobi_app/android build/6100500 channel/bili innerVer/6100500 osVer/6.0.1 network/2\");\n            request.Headers.TryAddWithoutValidation(\"grpc-encoding\", \"gzip\");\n        }\n\n        HttpResponseMessage response = await AppHttpClient.SendAsync(request);\n        byte[] bytes = await response.Content.ReadAsByteArrayAsync();\n\n        return bytes;\n    }\n}"
  },
  {
    "path": "BBDown.Core/Util/SubUtil.cs",
    "content": "﻿using BBDown.Core.Protobuf;\nusing Google.Protobuf;\nusing System.Text;\nusing static BBDown.Core.Entity.Entity;\nusing static BBDown.Core.Util.HTTPUtil;\nusing System.Text.RegularExpressions;\nusing System.Text.Json;\n\nnamespace BBDown.Core.Util;\n\npublic static partial class SubUtil\n{\n    //https://i0.hdslb.com/bfs/subtitle/subtitle_lan.json\n    public static (string, string) GetSubtitleCode(string key)\n    {\n        //zh-hans => zh-Hans\n        if (NonCapsRegex().Match(key) is { Success: true } result)\n        {\n            var v = result.Value;\n            key = key.Replace(v, v.ToUpper());\n        }\n\n        return key switch\n        {\n            \"ai-Zh\"             => (\"chi\", \"中文（简体, AI识别）\"),\n            \"ai-En\"             => (\"eng\", \"English(generated by ai)\"),\n            \"zh-CN\"             => (\"chi\", \"中文（简体）\"),\n            \"zh-HK\"             => (\"chi\", \"中文（香港繁體）\"),\n            \"zh-Hans\"           => (\"chi\", \"中文（简体）\"),\n            \"zh-TW\"             => (\"chi\", \"中文（台灣繁體）\"),\n            \"zh-Hant\"           => (\"chi\", \"中文（繁體）\"),\n            \"en-US\"             => (\"eng\", \"English(USA)\"),\n            \"ja\"                => (\"jpn\", \"日本語\"),\n            \"ko\"                => (\"kor\", \"한국어\"),\n            \"zh-SG\"             => (\"chi\", \"中文（新加坡）\"),\n            \"ab\"                => (\"abk\", \"Аҳәынҭқарра\"),\n            \"aa\"                => (\"aar\", \"Qafár af\"),\n            \"af\"                => (\"afr\", \"Afrikaans\"),\n            \"sq\"                => (\"alb\", \"Gjuha shqipe\"),\n            \"ase\"               => (\"ase\", \"American Sign Language\"),\n            \"am\"                => (\"amh\", \"አማርኛ\"),\n            \"arc\"               => (\"arc\", \"ܐܪܡܝܐ\"),\n            \"hy\"                => (\"arm\", \"հայերեն\"),\n            \"as\"                => (\"asm\", \"অসমীয়া\"),\n            \"ay\"                => (\"aym\", \"Aymar aru\"),\n            \"az\"                => (\"aze\", \"Azərbaycan\"),\n            \"bn\"                => (\"ben\", \"বাংলা ভাষার\"),\n            \"ba\"                => (\"bak\", \"Башҡорттеле\"),\n            \"eu\"                => (\"baq\", \"Euskara\"),\n            \"be\"                => (\"bel\", \"беларуская мова biełaruskaja mova\"),\n            \"bh\"                => (\"bih\", \"Bihar\"),\n            \"bi\"                => (\"bis\", \"Bislama\"),\n            \"bs\"                => (\"bos\", \"босански\"),\n            \"br\"                => (\"bre\", \"Breton\"),\n            \"bg\"                => (\"bul\", \"български\"),\n            \"yue\"               => (\"chi\", \"粵語\"),\n            \"yue-HK\"            => (\"chi\", \"粵語（中國香港）\"),\n            \"ca\"                => (\"cat\", \"català\"),\n            \"chr\"               => (\"chr\", \"ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ\"),\n            \"cho\"               => (\"cho\", \"Chahta'\"),\n            \"co\"                => (\"cos\", \"lingua corsa\"),\n            \"hr\"                => (\"hrv\", \"Hrvatska\"),\n            \"cs\"                => (\"cze\", \"čeština\"),\n            \"da\"                => (\"dan\", \"Dansk\"),\n            \"nl\"                => (\"dut\", \"Nederlands\"),\n            \"nl-BE\"             => (\"dut\", \"Nederlands(Belgisch)\"),\n            \"nl-NL\"             => (\"dut\", \"Nederlands(Nederlands)\"),\n            \"dz\"                => (\"dzo\", \"རྫོང་ཁ།\"),\n            \"en\"                => (\"eng\", \"English\"),\n            \"en-CA\"             => (\"eng\", \"English(Canada)\"),\n            \"en-IE\"             => (\"eng\", \"English(Ireland)\"),\n            \"en-GB\"             => (\"eng\", \"English(UK)\"),\n            \"eo\"                => (\"epo\", \"Esperanto\"),\n            \"et\"                => (\"est\", \"Eestlane\"),\n            \"fo\"                => (\"fao\", \"føroyskt\"),\n            \"fj\"                => (\"fij\", \"Vakaviti\"),\n            \"fil\"               => (\"phi\", \"Pilipino\"),\n            \"fi\"                => (\"fin\", \"Suomi\"),\n            \"fr\"                => (\"fre\", \"Français\"),\n            \"fr-BE\"             => (\"fre\", \"Français(Belgique)\"),\n            \"fr-CA\"             => (\"fre\", \"Français(Canada)\"),\n            \"fr-FR\"             => (\"fre\", \"Français(La France)\"),\n            \"fr-CH\"             => (\"fre\", \"Français(Suisse)\"),\n            \"ff\"                => (\"ful\", \"Fulani\"),\n            \"gl\"                => (\"glg\", \"galego\"),\n            \"ka\"                => (\"geo\", \"ქართული ენა\"),\n            \"de\"                => (\"ger\", \"Deutsch\"),\n            \"de-AT\"             => (\"ger\", \"Deutsch(Österreich)\"),\n            \"de-DE\"             => (\"ger\", \"Deutsch(Deutschland)\"),\n            \"de-CH\"             => (\"ger\", \"Deutsch(Schweiz)\"),\n            \"el\"                => (\"gre\", \"Ελληνικά\"),\n            \"kl\"                => (\"kal\", \"Kalaallisut\"),\n            \"gn\"                => (\"grn\", \"avañe'ẽ\"),\n            \"gu\"                => (\"guj\", \"ગુજરાતી\"),\n            \"hak\"               => (\"hak\", \"Hak-kâ-fa\"),\n            \"hak-TW\"            => (\"hak\", \"Hak-kâ-fa\"),\n            \"ha\"                => (\"hau\", \"هَوُسَ\"),\n            \"iw\"                => (\"heb\", \"שפה עברית\"),\n            \"hi\"                => (\"hin\", \"हिन्दी\"),\n            \"hi-Latn\"           => (\"hin\", \"हिंदी(फोनेटिक)\"),\n            \"hu\"                => (\"hun\", \"Magyar\"),\n            \"is\"                => (\"ice\", \"icelandic\"),\n            \"ig\"                => (\"ibo\", \"Asụsụ Igbo\"),\n            \"id\"                => (\"ind\", \"Indonesia\"),\n            \"ia\"                => (\"ina\", \"Interlingua\"),\n            \"iu\"                => (\"iku\", \"ᐃᓄᒃᑎᑐᑦ\"),\n            \"ik\"                => (\"ipk\", \"Inupiat\"),\n            \"ga\"                => (\"gle\", \"Gaeilge na hÉireann\"),\n            \"it\"                => (\"ita\", \"Italiano\"),\n            \"jv\"                => (\"jav\", \"ꦧꦱꦗꦮ\"),\n            \"kn\"                => (\"kan\", \"ಕನ್ನಡ\"),\n            \"ks\"                => (\"kas\", \"कॉशुर\"),\n            \"kk\"                => (\"kaz\", \"Қазақ тілі\"),\n            \"km\"                => (\"khm\", \"ភាសាខ្មែរ\"),\n            \"rw\"                => (\"kin\", \"Ikinyarwanda\"),\n            \"tlh\"               => (\"tlh\", \"tlhIngan Hol\"),\n            \"ku\"                => (\"kur\", \"Kurdî\"),\n            \"ky\"                => (\"kir\", \"кыргыз тили\"),\n            \"lo\"                => (\"lao\", \"ພາສາລາວ\"),\n            \"la\"                => (\"lat\", \"latīna\"),\n            \"lv\"                => (\"lav\", \"latviešu valoda\"),\n            \"ln\"                => (\"lin\", \"Lingála\"),\n            \"lt\"                => (\"lit\", \"lietuvių kalba\"),\n            \"lb\"                => (\"ltz\", \"Lëtzebuergesch\"),\n            \"mk\"                => (\"mac\", \"Македонски јазик\"),\n            \"mg\"                => (\"mlg\", \"maa.laa.gaas\"),\n            \"ms\"                => (\"may\", \"Melayu\"),\n            \"ml\"                => (\"mal\", \"മലയാളം\"),\n            \"mt\"                => (\"mlt\", \"Lingwa Maltija\"),\n            \"mi\"                => (\"mao\", \"Māori\"),\n            \"mr\"                => (\"mar\", \"मराठी Marāṭhī\"),\n            \"mas\"               => (\"mas\", \"Maasai\"),\n            \"nan\"               => (\"nan\", \"閩南語\"),\n            \"nan-TW\"            => (\"nan\", \"閩南語(台灣)\"),\n            \"lus\"               => (\"lus\", \"Mizo ṭawng\"),\n            \"mo\"                => (\"mol\", \"Limba moldovenească\"),\n            \"mn\"                => (\"mon\", \"монгол хэл\"),\n            \"my\"                => (\"bur\", \"မြန်မာဘာသာ\"),\n            \"na\"                => (\"nau\", \"Dorerin Naoero\"),\n            \"nv\"                => (\"nav\", \"Diné bizaad\"),\n            \"ne\"                => (\"nep\", \"नेपाली Nepālī\"),\n            \"no\"                => (\"nor\", \"norsk språk\"),\n            \"fa\"                => (\"per\", \"فارسی\"),\n            \"fa-AF\"             => (\"per\", \"فارسی\"),\n            \"fa-IR\"             => (\"per\", \"فارسی\"),\n            \"pl\"                => (\"pol\", \"Polski\"),\n            \"pt\"                => (\"por\", \"Português\"),\n            \"pt-BR\"             => (\"por\", \"Português(brasil)\"),\n            \"pt-PT\"             => (\"por\", \"Português(portugal)\"),\n            \"ro\"                => (\"rum\", \"Română\"),\n            \"ru\"                => (\"rus\", \"Русский\"),\n            \"ru-Latn\"           => (\"rus\", \"Русский(фонетический)\"),\n            \"sr\"                => (\"srp\", \"Српски\"),\n            \"sr-Cyrl\"           => (\"srp\", \"Српски(ћирилица)\"),\n            \"sr-Latn\"           => (\"srp\", \"Српски(латиница)\"),\n            \"sh\"                => (\"scr\", \"srpskohrvatski\"),\n            \"sk\"                => (\"slo\", \"slovenský\"),\n            \"es\"                => (\"spa\", \"Español\"),\n            \"es-419\"            => (\"spa\", \"Español(Latinoamérica)\"),\n            \"es-MX\"             => (\"spa\", \"Español(México)\"),\n            \"es-ES\"             => (\"spa\", \"Español(España)\"),\n            \"es-US\"             => (\"spa\", \"Español(Estados Unidos)\"),\n            \"sv\"                => (\"swe\", \"Svenska\"),\n            \"tl\"                => (\"tgl\", \"Tagalog\"),\n            \"th\"                => (\"tha\", \"ไทย\"),\n            \"tr\"                => (\"tur\", \"Türkçe\"),\n            \"uk\"                => (\"ukr\", \"Українська\"),\n            \"ur\"                => (\"urd\", \"Urdu\"),\n            \"vi\"                => (\"vie\", \"Tiếng Việt\"),\n            //太多了，我蚌埠住了，后面懒得查\n            //\"ie\"                => (\"\", \"\"),\n            //\"oc\"                => (\"\",   \"\"),\n            //\"or\"                => (\"\",   \"\"),\n            //\"om\"                => (\"\",   \"\"),\n            //\"ps\"                => (\"\",   \"\"),\n            //\"pa\"                => (\"\",   \"\"),\n            //\"qu\"                => (\"\",   \"\"),\n            //\"rm\"                => (\"\",   \"\"),\n            //\"rn\"                => (\"\",   \"\"),\n            //\"sm\"                => (\"\",   \"\"),\n            //\"sg\"                => (\"\",   \"\"),\n            //\"sa\"                => (\"\",   \"\"),\n            //\"gd\"                => (\"\",   \"\"),\n            //\"sdp\"               => (\"\",   \"\"),\n            //\"sn\"                => (\"\",   \"\"),\n            //\"scn\"               => (\"\",   \"\"),\n            //\"sd\"                => (\"\",   \"\"),\n            //\"si\"                => (\"\",   \"\"),\n            //\"sl\"                => (\"\",   \"\"),\n            //\"so\"                => (\"\",   \"\"),\n            //\"st\"                => (\"\",   \"\"),\n            //\"su\"                => (\"\",   \"\"),\n            //\"sw\"                => (\"\",   \"\"),\n            //\"ss\"                => (\"\",   \"\"),\n            //\"tg\"                => (\"\",   \"\"),\n            //\"ta\"                => (\"\",   \"\"),\n            //\"tt\"                => (\"\",   \"\"),\n            //\"te\"                => (\"\",   \"\"),\n            //\"ti\"                => (\"\",   \"\"),\n            //\"to\"                => (\"\",   \"\"),\n            //\"ts\"                => (\"\",   \"\"),\n            //\"tn\"                => (\"\",   \"\"),\n            //\"tk\"                => (\"\",   \"\"),\n            //\"tw\"                => (\"\",   \"\"),\n            //\"uz\"                => (\"\",   \"\"),\n            //\"vo\"                => (\"\",   \"\"),\n            //\"cy\"                => (\"\",   \"\"),\n            //\"fy\"                => (\"\",   \"\"),\n            //\"wo\"                => (\"\",   \"\"),\n            //\"xh\"                => (\"\",   \"\"),\n            //\"yi\"                => (\"\",   \"\"),\n            //\"yo\"                => (\"\",   \"\"),\n            //\"zu\"                => (\"\",   \"\"),\n            _ => (\"und\", \"Undetermined\")\n        };\n    }\n\n    #region 字幕接口\n\n    private static async Task<List<Subtitle>?> GetIntlSubtitlesFromApi1Async(string aid, string cid, string epId, int index)\n    {\n        try\n        {\n            List<Subtitle> subtitles = new();\n            string api = \"https://\" + (Config.EPHOST == \"api.bilibili.com\" ? \"api.biliintl.com\" : Config.EPHOST) + $\"/intl/gateway/web/v2/subtitle?episode_id={epId}\";\n            string json = await GetWebSourceAsync(api);\n            using var infoJson = JsonDocument.Parse(json);\n            var subs = infoJson.RootElement.GetProperty(\"data\").GetProperty(\"subtitles\").EnumerateArray();\n            foreach (var sub in subs)\n            {\n                var lan = sub.GetProperty(\"lang_key\").ToString();\n                var url = sub.GetProperty(\"url\").ToString();\n                Subtitle subtitle = new()\n                {\n                    url = url,\n                    lan = lan,\n                    path = $\"{aid}/{aid}.{cid}.{lan}{(url.Contains(\".json\") ? \".srt\" : \".ass\")}\"\n                };\n\n                //有空的URL 不合法\n                if (subtitles.Any(s => string.IsNullOrEmpty(s.url)))\n                    throw new Exception(\"Bad url\");\n\n                subtitles.Add(subtitle);\n            }\n            return subtitles;\n        }\n        catch (Exception)\n        {\n            return null;\n        }\n    }\n\n    private static async Task<List<Subtitle>?> GetIntlSubtitlesFromApi2Async(string aid, string cid, string epId, int index)\n    {\n        try\n        {\n            List<Subtitle> subtitles = new();\n            string api = \"https://\" + (Config.HOST == \"api.bilibili.com\" ? \"api.bilibili.tv\" : Config.HOST) +\n                         $\"/intl/gateway/v2/ogv/view/app/season?ep_id={epId}&platform=android&s_locale=zh_SG\" + (Config.TOKEN != \"\" ? $\"&access_key={Config.TOKEN}\" : \"\");\n            string json = await GetWebSourceAsync(api);\n            using var infoJson = JsonDocument.Parse(json);\n            var subs = infoJson.RootElement.GetProperty(\"result\").GetProperty(\"modules\")[0].GetProperty(\"data\")\n                .GetProperty(\"episodes\")[index - 1].GetProperty(\"subtitles\").EnumerateArray();\n            foreach (var sub in subs)\n            {\n                var lan = sub.GetProperty(\"key\").ToString();\n                var url = sub.GetProperty(\"url\").ToString().Replace(\"\\\\\\\\/\", \"/\");\n                Subtitle subtitle = new()\n                {\n                    url = url,\n                    lan = lan,\n                    path = $\"{aid}/{aid}.{cid}.{lan}{(url.Contains(\".json\") ? \".srt\" : \".ass\")}\"\n                };\n\n                //有空的URL 不合法\n                if (subtitles.Any(s => string.IsNullOrEmpty(s.url)))\n                    throw new Exception(\"Bad url\");\n\n                subtitles.Add(subtitle);\n            }\n            return subtitles;\n        }\n        catch (Exception)\n        {\n            return null;\n        }\n    }\n\n    private static async Task<List<Subtitle>?> GetSubtitlesFromApi1Async(string aid, string cid, string epId, int index)\n    {\n        try\n        {\n            List<Subtitle> subtitles = new();\n            string api = $\"https://api.bilibili.com/x/web-interface/view?aid={aid}&cid={cid}\";\n            string json = await GetWebSourceAsync(api);\n            using var infoJson = JsonDocument.Parse(json);\n            var subs = infoJson.RootElement.GetProperty(\"data\").GetProperty(\"subtitle\").GetProperty(\"list\").EnumerateArray();\n            foreach (var sub in subs)\n            {\n                var lan = sub.GetProperty(\"lan\").ToString();\n                Subtitle subtitle = new()\n                {\n                    url = sub.GetProperty(\"subtitle_url\").ToString(),\n                    lan = lan,\n                    path = $\"{aid}/{aid}.{cid}.{lan}.srt\"\n                };\n                subtitles.Add(subtitle);\n            }\n\n            //有空的URL 不合法\n            if (subtitles.Any(s => string.IsNullOrEmpty(s.url)))\n                throw new Exception(\"Bad url\");\n\n            //无字幕片源 但是字幕没上导致的空列表，尝试从国际接口获取\n            //if (subtitles.Count == 0 && !string.IsNullOrEmpty(epId))\n            //{\n            //    return await GetSubtitlesAsync(aid, cid, epId, true);\n            //}\n            return subtitles;\n        }\n        catch (Exception)\n        {\n            return null;\n        }\n    }\n\n    private static async Task<List<Subtitle>?> GetSubtitlesFromApi2Async(string aid, string cid, string epId, int index)\n    {\n        try\n        {\n            List<Subtitle> subtitles = new();\n            string api = $\"https://api.bilibili.com/x/player/wbi/v2?cid={cid}&aid={aid}\";\n            string json = await GetWebSourceAsync(api);\n            using var infoJson = JsonDocument.Parse(json);\n            var subs = infoJson.RootElement.GetProperty(\"data\").GetProperty(\"subtitle\").GetProperty(\"subtitles\").EnumerateArray();\n            foreach (var sub in subs)\n            {\n                var lan = sub.GetProperty(\"lan\").ToString();\n                Subtitle subtitle = new()\n                {\n                    url = sub.GetProperty(\"subtitle_url\").ToString(),\n                    lan = lan,\n                    path = $\"{aid}/{aid}.{cid}.{lan}.srt\"\n                };\n                subtitles.Add(subtitle);\n            }\n\n            //有空的URL 不合法\n            if (subtitles.Any(s => string.IsNullOrEmpty(s.url)))\n                throw new Exception(\"Bad url\");\n\n            return subtitles;\n        }\n        catch (Exception)\n        {\n            return null;\n        }\n    }\n\n    private static byte[] GetPayload(long aid, long cid)\n    {\n        var obj = new DmViewReq\n        {\n            Pid = aid,\n            Oid = cid,\n            Type = 1,\n            Spmid = \"main.ugc-video-detail.0.0\",\n        };\n        return AppHelper.PackMessage(obj.ToByteArray());\n    }\n\n    private static async Task<List<Subtitle>?> GetSubtitlesFromApi3Async(string aid, string cid, string epId, int index)\n    {\n        try\n        {\n            List<Subtitle> subtitles = new();\n            //grpc调用接口 protobuf\n            string api = \"https://app.biliapi.net/bilibili.community.service.dm.v1.DM/DmView\";\n\n            var data = GetPayload(Convert.ToInt64(aid), Convert.ToInt64(cid));\n\n            var t = AppHelper.ReadMessage(await GetPostResponseAsync(api, data));\n            var resp = new MessageParser<DmViewReply>(() => new DmViewReply()).ParseFrom(t);\n\n            if (resp.Subtitle != null && resp.Subtitle.Subtitles != null)\n            {\n                subtitles.AddRange(resp.Subtitle.Subtitles.Select(item => new Subtitle() {\n                    url = item.SubtitleUrl,\n                    lan = item.Lan,\n                    path = $\"{aid}/{aid}.{cid}.{item.Lan}.srt\"\n                }));\n            }\n            //有空的URL 不合法\n            if (subtitles.Any(s => string.IsNullOrEmpty(s.url)))\n                throw new Exception(\"Bad url\");\n\n            return subtitles;\n        }\n        catch (Exception)\n        {\n            return null;\n        }\n    }\n\n    #endregion\n\n    public static async Task<List<Subtitle>> GetSubtitlesAsync(string aid, string cid, string epId, int index, bool intl)\n    {\n        List<Subtitle>? subtitles = new();\n        if (intl)\n        {\n            subtitles = await GetIntlSubtitlesFromApi1Async(aid, cid, epId, index) ?? await GetIntlSubtitlesFromApi2Async(aid, cid, epId, index);\n        }\n        else\n        {\n            if (Config.COOKIE == \"\")\n            {\n                subtitles = await GetSubtitlesFromApi3Async(aid, cid, epId, index); // 未登录只有APP可以拿到字幕了\n            }\n            else\n            {\n                subtitles = await GetSubtitlesFromApi2Async(aid, cid, epId, index)\n                            ?? await GetSubtitlesFromApi1Async(aid, cid, epId, index)\n                            ?? await GetSubtitlesFromApi3Async(aid, cid, epId, index);\n            }\n\n        }\n\n        if (subtitles == null)\n        {\n            return new List<Subtitle>(); //返回空列表\n        }\n\n        //修正 url 协议\n        foreach (var item in subtitles)\n        {\n            if (item.url.StartsWith(\"//\")) item.url = \"https:\" + item.url;\n        }\n\n        return subtitles;\n    }\n\n    public static async Task SaveSubtitleAsync(string url, string path)\n    {\n        if (path.EndsWith(\".srt\"))\n            await File.WriteAllTextAsync(path, ConvertSubFromJson(await GetWebSourceAsync(url)), Encoding.UTF8);\n        else\n            await File.WriteAllTextAsync(path, await GetWebSourceAsync(url), Encoding.UTF8);\n    }\n\n    private static string ConvertSubFromJson(string jsonString)\n    {\n        StringBuilder lines = new();\n        var json = JsonDocument.Parse(jsonString);\n        var sub = json.RootElement.GetProperty(\"body\").EnumerateArray().ToList();\n        for(int i = 0; i < sub.Count; i++)\n        {\n            var line = sub[i];\n            lines.AppendLine((i + 1).ToString());\n            if (line.TryGetProperty(\"from\", out JsonElement from))\n            {\n                lines.AppendLine($\"{FormatTime(from.GetDouble())} --> {FormatTime(line.GetProperty(\"to\").GetDouble())}\");\n            }\n            else\n            {\n                lines.AppendLine($\"{FormatTime(0.0)} --> {FormatTime(line.GetProperty(\"to\").GetDouble())}\");\n            }\n            //有的没有内容\n            if (line.TryGetProperty(\"content\", out JsonElement content))\n                lines.AppendLine(content.ToString());\n            lines.AppendLine();\n        }\n        return lines.ToString();\n    }\n\n    private static string FormatTime(double sec) //64.13\n    {\n        return TimeSpan.FromSeconds(sec).ToString(@\"hh\\:mm\\:ss\\,fff\");\n    }\n\n    [GeneratedRegex(\"-[a-z]\")]\n    private static partial Regex NonCapsRegex();\n}"
  },
  {
    "path": "BBDown.sln",
    "content": "﻿\nMicrosoft Visual Studio Solution File, Format Version 12.00\n# Visual Studio Version 17\nVisualStudioVersion = 17.2.32210.308\nMinimumVisualStudioVersion = 10.0.40219.1\nProject(\"{9A19103F-16F7-4668-BE54-9A1E7A4F7556}\") = \"BBDown\", \"BBDown\\BBDown.csproj\", \"{0D491417-BFEA-481B-AE1A-3BE69172D818}\"\nEndProject\nProject(\"{9A19103F-16F7-4668-BE54-9A1E7A4F7556}\") = \"BBDown.Core\", \"BBDown.Core\\BBDown.Core.csproj\", \"{932F1279-4AD3-4226-97C6-B0997B1568AC}\"\nEndProject\nGlobal\n\tGlobalSection(SolutionConfigurationPlatforms) = preSolution\n\t\tDebug|Any CPU = Debug|Any CPU\n\t\tRelease|Any CPU = Release|Any CPU\n\tEndGlobalSection\n\tGlobalSection(ProjectConfigurationPlatforms) = postSolution\n\t\t{0D491417-BFEA-481B-AE1A-3BE69172D818}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{0D491417-BFEA-481B-AE1A-3BE69172D818}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{0D491417-BFEA-481B-AE1A-3BE69172D818}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{0D491417-BFEA-481B-AE1A-3BE69172D818}.Release|Any CPU.Build.0 = Release|Any CPU\n\t\t{932F1279-4AD3-4226-97C6-B0997B1568AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{932F1279-4AD3-4226-97C6-B0997B1568AC}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{932F1279-4AD3-4226-97C6-B0997B1568AC}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{932F1279-4AD3-4226-97C6-B0997B1568AC}.Release|Any CPU.Build.0 = Release|Any CPU\n\tEndGlobalSection\n\tGlobalSection(SolutionProperties) = preSolution\n\t\tHideSolutionNode = FALSE\n\tEndGlobalSection\n\tGlobalSection(ExtensibilityGlobals) = postSolution\n\t\tSolutionGuid = {17E08D9B-5969-4301-9674-ADB3EA8E5DF1}\n\tEndGlobalSection\nEndGlobal\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM mcr.microsoft.com/dotnet/sdk:9.0 AS builder\n\nWORKDIR /app\n\nCOPY . .\n\nRUN dotnet build -c Release\n\nFROM mcr.microsoft.com/dotnet/aspnet:9.0\n\nWORKDIR /app\n\nCOPY --from=builder /app/BBDown/bin/Release/net9.0 .\n\nEXPOSE 23333\n\n# install ffmpeg\nRUN apt-get update && \\\n    apt-get install -y ffmpeg && \\\n    chmod +x /app/BBDown\n\nENTRYPOINT [\"/app/BBDown\", \"serve\", \"-l\", \"http://0.0.0.0:23333\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020 nilaoda\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "[![img](https://img.shields.io/github/stars/nilaoda/BBDown?label=%E7%82%B9%E8%B5%9E)](https://github.com/nilaoda/BBDown)  [![img](https://img.shields.io/github/last-commit/nilaoda/BBDown?label=%E6%9C%80%E8%BF%91%E6%8F%90%E4%BA%A4)](https://github.com/nilaoda/BBDown)  [![img](https://img.shields.io/github/release/nilaoda/BBDown?label=%E6%9C%80%E6%96%B0%E7%89%88%E6%9C%AC)](https://github.com/nilaoda/BBDown/releases)  [![img](https://img.shields.io/github/license/nilaoda/BBDown?label=%E8%AE%B8%E5%8F%AF%E8%AF%81)](https://github.com/nilaoda/BBDown)  [![Build Latest](https://github.com/nilaoda/BBDown/actions/workflows/build_latest.yml/badge.svg)](https://github.com/nilaoda/BBDown/actions/workflows/build_latest.yml)\n\n> 本项目仅供个人学习、研究和非商业性用途。用户在使用本工具时，需自行确保遵守相关法律法规，特别是与版权相关的法律条款。开发者不对因使用本工具而产生的任何版权纠纷或法律责任承担责任。请用户在使用时谨慎，确保其行为合法合规，并仅在有合法授权的情况下使用相关内容。\n\n# BBDown\n一个命令行式哔哩哔哩下载器. Bilibili Downloader.\n\n# 注意\n本软件混流时需要外部程序：\n\n* 普通视频：[ffmpeg](https://www.gyan.dev/ffmpeg/builds/) ，或 [mp4box](https://gpac.wp.imt.fr/downloads/)\n* 杜比视界：ffmpeg5.0以上或新版mp4box.\n\n# 快速开始\n本软件已经以 [Dotnet Tool](https://www.nuget.org/packages/BBDown/) 形式发布  \n\n如果你本地有dotnet环境，使用如下命令即可安装使用\n```\ndotnet tool install --global BBDown\n```\n\n如果需要更新bbdown，使用如下命令\n```\ndotnet tool update --global BBDown\n```\n\n# 下载\nRelease版本：https://github.com/nilaoda/BBDown/releases\n\n自动构建的测试版本：https://github.com/nilaoda/BBDown/actions\n\n# 开始使用\n目前命令行参数支持情况\n```\nDescription:\n  BBDown是一个免费且便捷高效的哔哩哔哩下载/解析软件.\n\nUsage:\n  BBDown <url> [command] [options]\n\nArguments:\n  <url>  视频地址 或 av|bv|BV|ep|ss\n\nOptions:\n  -tv, --use-tv-api                              使用TV端解析模式\n  -app, --use-app-api                            使用APP端解析模式\n  -intl, --use-intl-api                          使用国际版(东南亚视频)解析模式\n  --use-mp4box                                   使用MP4Box来混流\n  -e, --encoding-priority <encoding-priority>    视频编码的选择优先级, 用逗号分割 例: \"hevc,av1,avc\"\n  -q, --dfn-priority <dfn-priority>              画质优先级,用逗号分隔 例: \"8K 超高清, 1080P 高码率, HDR 真彩, 杜比视界\"\n  -info, --only-show-info                        仅解析而不进行下载\n  --show-all                                     展示所有分P标题\n  -aria2, --use-aria2c                           调用aria2c进行下载(你需要自行准备好二进制可执行文件)\n  -ia, --interactive                             交互式选择清晰度\n  -hs, --hide-streams                            不要显示所有可用音视频流\n  -mt, --multi-thread                            使用多线程下载(默认开启)\n  --video-only                                   仅下载视频\n  --audio-only                                   仅下载音频\n  --danmaku-only                                 仅下载弹幕\n  --sub-only                                     仅下载字幕\n  --cover-only                                   仅下载封面\n  --debug                                        输出调试日志\n  --skip-mux                                     跳过混流步骤\n  --skip-subtitle                                跳过字幕下载\n  --skip-cover                                   跳过封面下载\n  --force-http                                   下载音视频时强制使用HTTP协议替换HTTPS(默认开启)\n  -dd, --download-danmaku                        下载弹幕\n  --skip-ai                                      跳过AI字幕下载(默认开启)\n  --video-ascending                              视频升序(最小体积优先)\n  --audio-ascending                              音频升序(最小体积优先)\n  --allow-pcdn                                   不替换PCDN域名, 仅在正常情况与--upos-host均无法下载时使用\n  -F, --file-pattern <file-pattern>              使用内置变量自定义单P存储文件名:\n  \n                                                 <videoTitle>: 视频主标题\n                                                 <pageNumber>: 视频分P序号\n                                                 <pageNumberWithZero>: 视频分P序号(前缀补零)\n                                                 <pageTitle>: 视频分P标题\n                                                 <bvid>: 视频BV号\n                                                 <aid>: 视频aid\n                                                 <cid>: 视频cid\n                                                 <dfn>: 视频清晰度\n                                                 <res>: 视频分辨率\n                                                 <fps>: 视频帧率\n                                                 <videoCodecs>: 视频编码\n                                                 <videoBandwidth>: 视频码率\n                                                 <audioCodecs>: 音频编码\n                                                 <audioBandwidth>: 音频码率\n                                                 <ownerName>: 上传者名称\n                                                 <ownerMid>: 上传者mid\n                                                 <publishDate>: 收藏夹/番剧/合集发布时间\n                                                 <videoDate>: 视频发布时间(分p视频发布时间与<publishDate>相同)\n                                                 <apiType>: API类型(TV/APP/INTL/WEB)\n  \n                                                 默认为: <videoTitle>\n  -M, --multi-file-pattern <multi-file-pattern>  使用内置变量自定义多P存储文件名:\n  \n                                                 默认为: <videoTitle>/[P<pageNumberWithZero>]<pageTitle>\n  -p, --select-page <select-page>                选择指定分p或分p范围: (-p 8 或 -p 1,2 或 -p 3-5 或 -p ALL 或 -p LAST 或 -p 3,5,LATEST)\n  --language <language>                          设置混流的音频语言(代码), 如chi, jpn等\n  -ua, --user-agent <user-agent>                 指定user-agent, 否则使用随机user-agent\n  -c, --cookie <cookie>                          设置字符串cookie用以下载网页接口的会员内容\n  -token, --access-token <access-token>          设置access_token用以下载TV/APP接口的会员内容\n  --aria2c-args <aria2c-args>                    调用aria2c的附加参数(默认参数包含\"-x16 -s16 -j16 -k 5M\", 使用时注意字符串转义)\n  --work-dir <work-dir>                          设置程序的工作目录\n  --ffmpeg-path <ffmpeg-path>                    设置ffmpeg的路径\n  --mp4box-path <mp4box-path>                    设置mp4box的路径\n  --aria2c-path <aria2c-path>                    设置aria2c的路径\n  --upos-host <upos-host>                        自定义upos服务器\n  --force-replace-host                           强制替换下载服务器host(默认开启)\n  --save-archives-to-file                        将下载过的视频记录到本地文件中, 用于后续跳过下载同个视频\n  --delay-per-page <delay-per-page>              设置下载合集分P之间的下载间隔时间(单位: 秒, 默认无间隔)\n  --host <host>                                  指定BiliPlus host(使用BiliPlus需要access_token, 不需要cookie, 解析服务器能够获取你账号的大部分权限!)\n  --ep-host <ep-host>                            指定BiliPlus EP host(用于代理api.bilibili.com/pgc/view/web/season, 大部分解析服务器不支持代理该接口)\n  --tv-host <tv-host>                            自定义tv端接口请求Host(用于代理api.snm0516.aisee.tv)\n  --area <area>                                  (hk|tw|th) 使用BiliPlus时必选, 指定BiliPlus area\n  --config-file <config-file>                    读取指定的BBDown本地配置文件(默认为: BBDown.config)\n  --version                                      Show version information\n  -?, -h, --help                                 Show help and usage information\n\n\nCommands:\n  login    通过APP扫描二维码以登录您的WEB账号\n  logintv  通过APP扫描二维码以登录您的TV账号\n  serve    以服务器模式运行\n```\n\n# 功能\n- [x] 番剧下载(Web|TV|App)\n- [x] 课程下载(Web)\n- [x] 普通内容下载(Web|TV|App)\n- [x] 合集/列表/收藏夹/个人空间解析\n- [x] 多分P自动下载\n- [x] 选择指定分P进行下载\n- [x] 选择指定清晰度进行下载\n- [x] 下载外挂字幕并转换为srt格式\n- [x] 自动合并音频+视频流+字幕流+**章节信息**`(使用ffmpeg或mp4box)`\n- [x] 单独下载视频/音频/字幕\n- [x] 二维码登录账号\n- [x] 多线程下载\n- [x] 支持调用aria2c下载\n- [x] 支持AVC/HEVC/AV1编码\n- [x] **支持8K/HDR/杜比视界/杜比全景声下载**\n- [x] 自定义存储文件名\n\n# TODO\n- [ ] 自动刷新cookie\n- [ ] 支持更多自定义选项\n\n# 使用教程\n\n<details>\n<summary>配置文件 (NEW)</summary> \n\n---\n\n在`1.4.9`或更高版本中，BBDown支持读取本地配置文件以简化命令行的手动输入。\n\n如果用户没有指定`--config-file`，则默认读取程序同目录下的`BBDown.config`文件；若用户指定，则读取特定文件。\n\n一个典型的配置文件:\n```config\n#本文件是BBDown程序的配置文件\n#以#开头的都会被程序忽略\n#然后剩余非空白内容程序逐行读取，对于一个选项，其参数应当在下一行出现\n\n#例如下面将设置输出文件名格式\n--file-pattern\n<videoTitle>[<dfn>]\n\n--multi-file-pattern\n<videoTitle>/[P<pageNumberWithZero>]<pageTitle>[<dfn>]\n\n#下面设置下载多个分P时，每个分P的下载间隔为2秒\n--delay-per-page\n2\n\n#开启弹幕下载功能\n--download-danmaku\n```\n\n</details>\n\n<details>\n<summary>自定义输出文件名格式 (NEW)</summary> \n\n---\n\n在`1.4.9`或更高版本中，BBDown支持用户自定义合并时的文件名组成。\n|  代码   | 含义  |\n|  ----  | ----  |\n`<videoTitle>`|视频主标题\n`<pageNumber>`|视频分P序号\n`<pageNumberWithZero>`|视频分P序号(前缀补零)\n`<pageTitle>`|视频分P标题\n`<bvid>`|视频BV号\n`<aid>`|视频aid\n`<cid>`|视频cid\n`<dfn>`|视频清晰度\n`<res>`|视频分辨率\n`<fps>`|视频帧率\n`<videoCodecs>`|视频编码\n`<videoBandwidth>`|视频码率\n`<audioCodecs>`|音频编码\n`<audioBandwidth>`|音频码率\n`<ownerName>`|上传者名称(下载番剧时，该值为\"\")\n`<ownerMid>`|上传者mid(下载番剧时，该值为\"\")\n`<publishDate>`|发布时间(yyyy-MM-dd_HH-mm-ss)\n`<apiType>`|API类型（TV/APP/INTL/WEB）\n\n</details>\n\n<details>\n<summary>WEB/TV鉴权</summary>  \n\n---\n  \n扫码登录网页账号：\n```\nBBDown login\n```\n然后按照提示操作\n\n扫码登录云视听小电视账号：\n```\nBBDown logintv\n```\n然后按照提示操作\n \n*PS: 如果登录报错`The type initializer for 'Gdip' threw an exception`，请参考 [#37](https://github.com/nilaoda/BBDown/issues/37) 解决*\n\n手动加载网页cookie：\n```\nBBDown -c \"SESSDATA=******\" \"https://www.bilibili.com/video/BV1qt4y1X7TW\"\n```\n手动加载云视听小电视token：\n```\nBBDown -tv -token \"******\" \"https://www.bilibili.com/video/BV1qt4y1X7TW\"\n```\n\n</details>\n\n<details>\n<summary>APP鉴权</summary>  \n\n---\n\n> 根据 [#123](https://github.com/nilaoda/BBDown/issues/123#issuecomment-877583825) ，可以填写TV登录产生的`access_token`来给APP接口使用。可复制`BBDownTV.data`到`BBDownApp.data`使程序自动读取.\n\n目前程序无法自动获取鉴权信息，推荐通过**抓包**来获取.\n\n在请求Header中寻找键为`authorization`的项，其值形为`identify_v1 5227************1`，其中的`5227************1`就是token(access_key)\n\n获取后手动通过`-token`命令加载, 或写入`BBDownApp.data`使程序自动读取.\n  \n```\nBBDown -app -token \"******\" \"https://www.bilibili.com/video/BV1qt4y1X7TW\"\n```\n\n</details>\n\n<details>\n<summary>常用命令</summary>  \n\n---\n\n下载普通视频：\n```\nBBDown \"https://www.bilibili.com/video/BV1qt4y1X7TW\"\n```\n使用TV接口下载(粉丝量大的UP主基本上是无水印片源)：\n```\nBBDown -tv \"https://www.bilibili.com/video/BV1qt4y1X7TW\"\n```\n当分P过多时，默认会隐藏展示全部的分P信息，你可以使用如下命令来显示所有每一个分P。\n```\nBBDown --show-all \"https://www.bilibili.com/video/BV1At41167aj\"\n```\n选择下载某些分P的三种情况：\n* 单个分P：10\n```\nBBDown \"https://www.bilibili.com/video/BV1At41167aj?p=10\"\nBBDown -p 10 \"https://www.bilibili.com/video/BV1At41167aj\"\n```\n* 多个分P：1,2,10\n```\nBBDown -p 1,2,10 \"https://www.bilibili.com/video/BV1At41167aj\"\n```\n* 范围分P：1-10\n```\nBBDown -p 1-10 \"https://www.bilibili.com/video/BV1At41167aj\"\n```\n下载番剧全集：\n```\nBBDown -p ALL \"https://www.bilibili.com/bangumi/play/ss33073\"\n```\n\n</details>\n\n<details>\n<summary>API服务器</summary>\n\n启动服务器（自定义监听地址和端口）：\n\n```shell\nBBDown serve -l http://0.0.0.0:12450\n```\n\nAPI服务器不支持HTTPS配置，如果有需要请自行使用nginx等反向代理进行配置\n\nAPI详细请参考[json-api-doc.md](./json-api-doc.md)\n</details>\n\n# 演示\n![1](https://user-images.githubusercontent.com/20772925/88686407-a2001480-d129-11ea-8aac-97a0c71af115.gif)\n\n下载完毕后在当前目录查看MP4文件：\n\n![2](https://user-images.githubusercontent.com/20772925/88478901-5e1cdc00-cf7e-11ea-97c1-154b9226564e.png)\n\n# 致谢\n\n* https://github.com/codebude/QRCoder\n* https://github.com/icsharpcode/SharpZipLib\n* https://github.com/protocolbuffers/protobuf\n* https://github.com/grpc/grpc\n* https://github.com/dotnet/command-line-api\n* https://github.com/SocialSisterYi/bilibili-API-collect\n* https://github.com/SeeFlowerX/bilibili-grpc-api\n* https://github.com/FFmpeg/FFmpeg\n* https://github.com/gpac/gpac\n* https://github.com/aria2/aria2\n"
  },
  {
    "path": "json-api-doc.md",
    "content": "# JSON API文档\n\n## API\n\n如果以服务器模式启动BBDown，BBDown会在本地启动一个http server，该服务器有以下API：\n\n### 获取任务列表\n**Endpoint:** `/get-tasks/`\n\n**Method:** GET\n\n**Description:** 获取所有任务的列表，包括正在运行的任务和已完成的任务。\n\n**Response:** JSON格式的`DownloadTaskCollection`。\n\n### 获取正在运行的任务列表\n**Endpoint:** `/get-tasks/running`\n\n**Method:** GET\n\n**Description:** 获取所有正在运行的任务的列表。\n\n**Response:** JSON格式的`List<DownloadTask>`, 正在运行的任务列表。\n\n### 获取已完成的任务列表\n**Endpoint:** `/get-tasks/finished`\n\n**Method:** GET\n\n**Description:** 获取所有已完成的任务的列表。\n\n**Response:**  JSON格式的`List<DownloadTask>`, 已完成的任务列表。\n\n### 获取特定任务\n**Endpoint:** `/get-tasks/{id}`\n\n**Method:** GET\n\n**Description:** 获取特定任务的详细信息，根据视频的AID。\n\n**Parameters:**\n- `{id}` (路径参数): 视频的AID\n\n**Response:** 如果找到匹配的任务，将返回JSON格式的`DownloadTask`。如果未找到匹配的任务，将返回404 Not Found。\n\n### 添加任务\n**Endpoint:** `/add-task`\n\n**Method:** POST\n\n**Description:** 向任务列表中添加新任务。\n\n**Request Body:** JSON格式的任务信息，需要符合`MyOption`数据结构。并不要求带有MyOption中的每一个字段，只需要有`Url`字段就够了。\n\n**Response:**\n- 如果请求有效并成功添加任务，将返回200 OK。\n- 如果请求无效，将返回400 Bad Request，并附带错误消息`\"输入有误\"`。\n\n### 移除已完成的任务\n**Endpoint:** `/remove-finished`\n\n**Method:** GET\n\n**Description:** 移除所有已完成的任务\n\n**Response:**\n- 返回200 OK。\n\n### 移除已完成的任务\n**Endpoint:** `/remove-finished/failed`\n\n**Method:** GET\n\n**Description:** 移除所有已完成但是失败(`IsSuccessful == false`)的任务\n\n**Response:**\n- 返回200 OK。\n\n### 移除特定已完成的任务\n**Endpoint:** `/remove-finished/{id}`\n\n**Method:** GET\n\n**Description:** 移除特定已完成的任务，根据视频的AID。\n\n**Parameters:**\n- `{id}` (路径参数): 视频的AID\n\n**Response:**\n- 无论是否能找到对应ID的任务，均返回200 OK。\n\n## 数据结构\n\n### `DownloadTask` 数据结构\n`DownloadTask` 数据结构表示一个下载任务的信息。\n\n**属性：**\n- `Aid` `<string>`: 视频解析出的Aid，用作正在下载中的任务的唯一标识符，已完成任务中允许重复存在\n- `Url` `<string>`: 下载任务请求时的URL，不一定需要完整的URL，命令行支持的`av|bv|BV|ep|ss`都可以在这里使用。\n- `TaskCreateTime` `<long>`: 任务创建时间，Unix时间戳，精确到秒，本机时区。\n- `Title` `<string?>`: 视频的标题。\n- `Pic` `<string?>`: 视频的封面图片链接。\n- `VideoPubTime` `<long?>`: 视频发布时间，Unix时间戳，精确到秒。\n- `TaskFinishTime` `<long?>`: 任务完成时间，Unix时间戳，精确到秒，本机时区。\n- `Progress` `<double>`: 任务的下载进度，为0-1区间范围的小数。\n- `DownloadSpeed` `<double>`: 下载速度, 单位为Byte/s。下载中时为最后一次更新的实时速度，下载完成后为平均速度。\n- `TotalDownloadedBytes` `<double>`: 总下载字节(Byte)数，完成后的数字比实际文件偏小。\n- `IsSuccessful` `<bool>`: 标识任务是否成功完成。\n\n### `DownloadTaskCollection` 数据结构\n`DownloadTaskCollection` 数据结构包含两个列表，分别表示正在运行的任务和已完成的任务。\n\n**属性：**\n- `Running` `<List<DownloadTask>>`: 包含正在运行的任务的列表，每个元素都是 `DownloadTask` 数据结构。\n- `Finished` `<List<DownloadTask>>`: 包含已完成的任务的列表，每个元素都是 `DownloadTask` 数据结构。\n\n### `MyOption` 数据结构\n\n参考[BBDown/MyOption.cs](./BBDown/MyOption.cs)。属性和命令行参数几乎是一一对应的，相应的值填写使用命令行会使用的值即可。这个结构会随着版本变化，请参考对应版本时候的文件。\n\n### 注意事项\n- 由于BBDown的下载进度回报频率所限，`TotalDownloadedBytes`会比实际下载的文件略低，大概会少等效于1秒下载速度的文件体积，如果文件本身就非常小那这个数字偏差会较大。\n- BBDown目前内部机制没有太好的方法取消单个下载任务，因此目前任务提交以后只能等任务失败或者完成。\n- 目前服务器没有对同时执行的下载任务数量做任何限制，如果短时间频繁添加任务就会同时执行相当数量的下载任务，需要小心注意不要耗尽资源。未来考虑添加下载队列。\n\n### 使用例\n\n#### 用BV号添加任务\n\n```shell\ncurl -X POST -H 'Content-Type: application/json' -d '{ \"Url\": \"BV1qt4y1X7TW\" }' http://localhost:58682/add-task\n```\n\n#### 下载到指定目录\n\nWindows:\n```shell\ncurl -X POST -H 'Content-Type: application/json' -d '{ \"Url\": \"BV1qt4y1X7TW\", \"FilePattern\": \"C:\\\\Downloads\\\\<videoTitle>[<dfn>]\" }' http://localhost:58682/add-task\n```\n\nUnix-Like:\n```shell\ncurl -X POST -H 'Content-Type: application/json' -d '{ \"Url\": \"BV1qt4y1X7TW\", \"FilePattern\": \"/Downloads/<videoTitle>[<dfn>]\" }' http://localhost:58682/add-task\n```\n"
  }
]