├── .github └── workflows │ ├── build-cpp.yml │ └── build-rust.yml ├── .gitignore ├── .gitmodules ├── AnnictRecorder.ini ├── AnnictRecorder ├── AnnictApi.h ├── AnnictRecorder.h ├── AnnictRecorder.vcxproj ├── AnnictRecorder.vcxproj.filters ├── ArmApi.h ├── Capture.h ├── Common.h ├── Config.h ├── Debug.h ├── Exports.def ├── Plugin.cpp ├── SayaApi.h ├── SyoboCalApi.h ├── Title.h ├── TvtPlay.h ├── Utils.h └── pch.h ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── TVTestAnnictRecorder.sln ├── TVTestAnnictRecorder.sln.DotSettings ├── Thirdparty └── TVTestPlugin.h ├── renovate.json ├── src └── lib.rs └── vcpkg.json /.github/workflows/build-cpp.yml: -------------------------------------------------------------------------------- 1 | name: build-cpp 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'AnnictRecorder/**' 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: windows-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | platform: 18 | - x64 19 | - x86 20 | 21 | steps: 22 | - name: Checkout Repository 23 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 24 | 25 | - name: Setup MSBuild 26 | uses: microsoft/setup-msbuild@v2 27 | 28 | - name: Setup vcpkg 29 | uses: lukka/run-vcpkg@v11 30 | with: 31 | runVcpkgInstall: true 32 | vcpkgGitCommitId: 962e5e39f8a25f42522f51fffc574e05a3efd26b 33 | env: 34 | VCPKG_DEFAULT_TRIPLET: ${{ matrix.platform }}-windows 35 | 36 | - name: Install MSBuild integration 37 | shell: powershell 38 | run: vcpkg integrate install 39 | env: 40 | VCPKG_DEFAULT_TRIPLET: ${{ matrix.platform }}-windows 41 | 42 | - name: MSBuild 43 | run: msbuild TVTestAnnictRecorder.sln -property:Configuration="Release" -property:Platform="${{ matrix.platform }}" -m -maxcpucount 44 | env: 45 | VCPKG_DEFAULT_TRIPLET: ${{ matrix.platform }}-windows 46 | 47 | - name: Prepare Artifacts 48 | shell: powershell 49 | run: | 50 | New-Item -Path Artifacts -ItemType Directory 51 | New-Item -Path Artifacts/Plugins -ItemType Directory 52 | Copy-Item -Path ${{ matrix.platform }}/Release/AnnictRecorder.tvtp -Destination Artifacts/Plugins/ 53 | Copy-Item -Path ${{ matrix.platform }}/Release/*.dll -Destination Artifacts/ 54 | Copy-Item -Path AnnictRecorder.ini -Destination Artifacts/Plugins/ 55 | 56 | - name: Upload Artifacts 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: AnnictRecorder_${{ matrix.platform }} 60 | if-no-files-found: error 61 | path: Artifacts/ 62 | 63 | - name: Create Release 64 | shell: powershell 65 | if: startsWith(github.ref, 'refs/tags/') 66 | run: Compress-Archive -Path Artifacts/* -DestinationPath AnnictRecorder_${{ matrix.platform }}.zip 67 | 68 | - name: Upload Release 69 | uses: softprops/action-gh-release@v2 70 | if: startsWith(github.ref, 'refs/tags/') 71 | with: 72 | files: AnnictRecorder_${{ matrix.platform }}.zip 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | -------------------------------------------------------------------------------- /.github/workflows/build-rust.yml: -------------------------------------------------------------------------------- 1 | name: build-rust 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/**' 7 | 8 | workflow_dispatch: 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | build: 15 | runs-on: windows-2022 16 | 17 | steps: 18 | # https://zenn.dev/kt3k/articles/d557cc874961ab 19 | - name: Checkout Repository 20 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 21 | with: 22 | submodules: recursive 23 | fetch-depth: 0 24 | - name: Install Python 25 | uses: actions/setup-python@v5 26 | - name: Download git-restore-mtime 27 | run: curl -O https://raw.githubusercontent.com/MestreLion/git-tools/main/git-restore-mtime 28 | - name: Restore mtime 29 | run: python ./git-restore-mtime 30 | 31 | - name: Build 32 | run: cargo build --verbose --release 33 | 34 | - name: Prepare Artifacts 35 | shell: powershell 36 | run: | 37 | New-Item -Path Artifacts -ItemType Directory 38 | Copy-Item -Path target/release/annict_recorder.dll -Destination Artifacts/annict_recorder.tvtp 39 | Copy-Item -Path AnnictRecorder.ini -Destination Artifacts/ 40 | 41 | - name: Upload Artifacts 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: annict-recorder-rs 45 | if-no-files-found: error 46 | path: Artifacts/ 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | 352 | # Ignore vcpkg package cache 353 | vcpkg_installed/ 354 | 355 | .idea 356 | 357 | # Added by cargo 358 | 359 | /target 360 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "Thirdparty/TVTestSDK-rs"] 2 | path = Thirdparty/TVTestSDK-rs 3 | url = https://github.com/SlashNephy/TVTestSDK-rs 4 | -------------------------------------------------------------------------------- /AnnictRecorder.ini: -------------------------------------------------------------------------------- 1 | [Annict] 2 | ; Annict の個人用アクセストークンを設定します。トークンが設定されていない場合, 記録は行いません。 3 | ; https://annict.com/settings/tokens/new で発行できます。 4 | ; スコープを「読み取り + 書き込み」に設定する必要があります。 5 | ; Token=xxx 6 | 7 | [Record] 8 | ; 視聴を開始してから Annict に記録するまでの閾値 (%) 9 | ; 例えば 20 の場合, 番組の放送時間の 20% 視聴した場合に記録します。 10 | ; 途中から視聴した場合は視聴開始時間から 20% 分視聴した場合に記録します。 11 | ; TvtPlay で再生時にも同様な判定が行われます。 12 | ; デフォルト値: 20 13 | ; ThresholdPercent=20 14 | 15 | ; エピソードの記録と同時に Twitter に投稿するかどうか。 16 | ; Annict に登録されている Twitter アカウントが使用されます。 17 | ; デフォルト値: 0 18 | ; ShareOnTwitter=1 19 | 20 | ; エピソードの記録と同時に Facebook に投稿するかどうか。 21 | ; Annict に登録されている Facebook アカウントが使用されます。 22 | ; デフォルト値: 0 23 | ; ShareOnFacebook=1 24 | 25 | ; 第1話を視聴した際に作品のステータスを「見てる」に変更するかどうか。 26 | ; デフォルト値: 0 27 | ; SetWatchingStatusInFirstEpisode=1 28 | 29 | ; 第1話に限らず視聴した作品のステータスを「見てる」に変更するかどうか。 30 | ; デフォルト値: 0 31 | ; SetWatchingStatusInAnyEpisodes=1 32 | 33 | ; 最終話を視聴した際に作品のステータスを「見た」に変更するかどうか。 34 | ; デフォルト値: 0 35 | ; SetWatchedInLastEpisode=1 36 | 37 | ; 既に「見た」になっている作品では上記のオプションにより「見てる」に変更されないようにするかどうか。 38 | ; デフォルト値: 0 39 | ; SkipUpdateStatusIfAlreadyWatched=1 40 | 41 | ; 既に「見た」となっている作品でも [新] フラグが付いている番組を視聴した際に上記のオプションにより「見てる」に変更されるようにするかどうか。 42 | ; 分割2クールのように同じ作品であっても [終] のあとに [新] となるようなケースで有効です。 43 | ; デフォルト値: 0 44 | ; SetWatchingStatusOnFirstEpisodeEvenIfWatched=1 45 | 46 | ; 1 に変更すると実際に記録は行いません。デバッグ用です。 47 | ; デフォルト値: 0 48 | ; DryRun=1 49 | 50 | [Discord] 51 | ; Token= 52 | ; ChannelId= 53 | ; DryRun=1 54 | -------------------------------------------------------------------------------- /AnnictRecorder/AnnictApi.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | #include "Common.h" 6 | #include "Debug.h" 7 | #include "Utils.h" 8 | 9 | namespace Annict 10 | { 11 | static void PostRecord(const uint32_t episodeId, const bool shareOnTwitter, const bool shareOnFacebook, const std::string &annictToken) 12 | { 13 | cpr::Post( 14 | cpr::Url{"https://api.annict.com/v1/me/records"}, 15 | cpr::Parameters{ 16 | {"episode_id", std::to_string(episodeId)}, 17 | {"access_token", annictToken}, 18 | {"share_twitter", shareOnTwitter ? "true" : "false"}, 19 | {"share_facebook", shareOnFacebook ? "true" : "false"}, 20 | }, 21 | cpr::UserAgent{AnnictRecorderUserAgent}); 22 | } 23 | 24 | static void PostMyStatus(const uint32_t workId, const std::string &kind, const std::string &annictToken) 25 | { 26 | cpr::Post( 27 | cpr::Url{"https://api.annict.com/v1/me/statuses"}, 28 | cpr::Parameters{ 29 | {"work_id", std::to_string(workId)}, 30 | {"kind", kind}, 31 | {"access_token", annictToken}, 32 | }, 33 | cpr::UserAgent{AnnictRecorderUserAgent}); 34 | } 35 | 36 | struct Work 37 | { 38 | uint32_t id; 39 | std::string title; 40 | bool hasNoEpisodes; 41 | }; 42 | 43 | static std::optional GetWorkById(const uint32_t workId, const std::string &annictToken) 44 | { 45 | const auto response = cpr::Get( 46 | cpr::Url{"https://api.annict.com/v1/works"}, 47 | cpr::Parameters{ 48 | {"filter_ids", std::to_string(workId)}, 49 | {"fields", "id,title,no_episodes"}, 50 | {"access_token", annictToken}, 51 | }); 52 | 53 | const auto json = nlohmann::json::parse(response.text); 54 | if (json["total_count"].get() == 0) 55 | { 56 | return std::nullopt; 57 | } 58 | 59 | const auto item = json["works"][0]; 60 | return Work{ 61 | item["id"].get(), 62 | item["title"].get(), 63 | item["no_episodes"].get(), 64 | }; 65 | } 66 | 67 | static std::optional GetWorkByTitle(const std::string &title, const std::string &annictToken) 68 | { 69 | const auto response = cpr::Get( 70 | cpr::Url{"https://api.annict.com/v1/works"}, 71 | cpr::Parameters{ 72 | {"filter_title", title}, 73 | {"fields", "id,title,no_episodes"}, 74 | {"per_page", "50"}, 75 | {"access_token", annictToken}, 76 | }); 77 | 78 | for (const auto json = nlohmann::json::parse(response.text); auto &item : json["works"]) 79 | { 80 | if (const auto itemTitle = item["title"].get(); title == itemTitle) 81 | { 82 | return Work{ 83 | item["id"].get(), 84 | itemTitle, 85 | item["no_episodes"].get(), 86 | }; 87 | } 88 | } 89 | 90 | return std::nullopt; 91 | } 92 | 93 | struct Episode 94 | { 95 | uint32_t id; 96 | std::optional title; 97 | std::optional number; 98 | std::optional numberText; 99 | Work work; 100 | }; 101 | 102 | static std::vector GetEpisodes(const uint32_t workId, const std::string &annictToken) 103 | { 104 | // Paging not supported 105 | const auto response = cpr::Get( 106 | cpr::Url{"https://api.annict.com/v1/episodes"}, 107 | cpr::Parameters{ 108 | {"filter_work_id", std::to_string(workId)}, 109 | {"fields", "id,title,number,number_text,work.id,work.title,work.no_episodes"}, 110 | {"sort_id", "desc"}, 111 | {"per_page", "50"}, 112 | {"access_token", annictToken}, 113 | }); 114 | 115 | const auto json = nlohmann::json::parse(response.text); 116 | 117 | auto results = std::vector(); 118 | for (auto &item : json["episodes"]) 119 | { 120 | results.push_back( 121 | { 122 | item["id"].get(), 123 | item["title"].is_string() ? std::optional(item["title"].get()) : std::nullopt, 124 | item["number"].is_number() ? std::optional(item["number"].get()) : std::nullopt, 125 | item["number_text"].is_string() ? std::optional(item["number_text"].get()) : std::nullopt, 126 | { 127 | item["work"]["id"].get(), 128 | item["work"]["title"].get(), 129 | item["work"]["no_episodes"].get(), 130 | }, 131 | }); 132 | } 133 | 134 | return results; 135 | } 136 | 137 | static std::optional GetMyWorkStatus(const uint32_t workId, const std::string &annictToken) 138 | { 139 | const auto response = cpr::Get( 140 | cpr::Url{"https://api.annict.com/v1/me/works"}, 141 | cpr::Parameters{ 142 | {"filter_ids", std::to_string(workId)}, 143 | {"fields", "status.kind"}, 144 | {"access_token", annictToken}, 145 | }); 146 | 147 | const auto json = nlohmann::json::parse(response.text); 148 | if (json["total_count"].get() == 0) 149 | { 150 | return std::nullopt; 151 | } 152 | 153 | const auto item = json["works"][0]["status"]["kind"]; 154 | return item.is_string() ? std::optional(item.get()) : std::nullopt; 155 | } 156 | 157 | struct Program 158 | { 159 | Work work; 160 | Episode episode; 161 | }; 162 | 163 | static std::vector GetMyPrograms(const int channelId, const SYSTEMTIME &start, const DWORD seconds, const std::string &annictToken) 164 | { 165 | const auto stTime = std::format("{:04d}/{:02d}/{:02d} {:02d}:{:02d}", start.wYear, start.wMonth, start.wDay, start.wHour, start.wMinute); 166 | const auto endTimestamp = SystemTime2Timet(start) + seconds; 167 | tm end{}; 168 | localtime_s(&end, &endTimestamp); 169 | const auto endTime = std::format("{:04d}/{:02d}/{:02d} {:02d}:{:02d}", end.tm_year + 1900, end.tm_mon + 1, end.tm_mday, end.tm_hour, end.tm_min); 170 | 171 | PrintDebugW(L"stTime", stTime); 172 | PrintDebugW(L"endTime", endTime); 173 | 174 | const auto response = cpr::Get( 175 | cpr::Url{"https://api.annict.com/v1/me/programs"}, 176 | cpr::Parameters{ 177 | {"filter_channel_ids", std::to_string(channelId)}, 178 | {"filter_started_at_gt", stTime}, 179 | {"filter_started_at_lt", endTime}, 180 | {"fields", "work.id,work.title,work.no_episodes,episode.id,episode.title,episode.number,episode.number_text"}, 181 | {"sort_started_at", "asc"}, 182 | {"per_page", "1"}, 183 | {"access_token", annictToken}, 184 | }); 185 | 186 | const auto json = nlohmann::json::parse(response.text); 187 | 188 | auto results = std::vector(); 189 | for (auto &item : json["programs"]) 190 | { 191 | const auto work = Work{ 192 | item["work"]["id"].get(), 193 | item["work"]["title"].get(), 194 | item["work"]["no_episodes"].get(), 195 | }; 196 | 197 | results.push_back( 198 | { 199 | work, 200 | { 201 | item["episode"]["id"].get(), 202 | item["episode"]["title"].is_string() ? std::optional(item["episode"]["title"].get()) : std::nullopt, 203 | item["episode"]["number"].is_number() ? std::optional(item["episode"]["number"].get()) : std::nullopt, 204 | item["episode"]["number_text"].is_string() ? std::optional(item["episode"]["number_text"].get()) : std::nullopt, 205 | work, 206 | }, 207 | }); 208 | } 209 | 210 | return results; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /AnnictRecorder/AnnictRecorder.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | #include "AnnictApi.h" 6 | #include "Config.h" 7 | #include "Debug.h" 8 | #include "SayaApi.h" 9 | #include "Title.h" 10 | #include "Utils.h" 11 | #include "SyoboCalApi.h" 12 | 13 | namespace AnnictRecorder 14 | { 15 | struct CreateRecordResult 16 | { 17 | bool success = false; 18 | std::wstring message{L"AnnictRecorder"}; 19 | std::optional work = std::nullopt; 20 | std::optional episode = std::nullopt; 21 | std::optional url = std::nullopt; 22 | }; 23 | 24 | static void UpdateWorkStatus( 25 | const uint32_t annictWorkId, 26 | const bool isFirstEpisode, 27 | const bool isLastEpisode, 28 | const Config &Config) 29 | { 30 | std::optional newStatus; 31 | if (isLastEpisode && Config.SetWatchedInLastEpisode) 32 | { 33 | newStatus = "watched"; 34 | } 35 | else if ((isFirstEpisode && Config.SetWatchingStatusInFirstEpisode) || (!isLastEpisode && Config.SetWatchingStatusInAnyEpisodes)) 36 | { 37 | newStatus = "watching"; 38 | } 39 | 40 | if (newStatus.has_value()) 41 | { 42 | try 43 | { 44 | const auto workStatus = Annict::GetMyWorkStatus(annictWorkId, Config.AnnictToken); 45 | const auto status = workStatus.value_or("no_select"); 46 | 47 | if (isFirstEpisode && status == "watched" && Config.SetWatchingStatusOnFirstEpisodeEvenIfWatched) 48 | { 49 | // [新] フラグが付いていて視聴済みの場合であってもステータスを更新する 50 | // 分割2クールの場合など 51 | } 52 | else if (status == "watched" && Config.SkipUpdateStatusIfAlreadyWatched) 53 | { 54 | return; 55 | } 56 | 57 | if (status != newStatus.value()) 58 | { 59 | if (!Config.RecordDryRun) 60 | { 61 | Annict::PostMyStatus(annictWorkId, newStatus.value(), Config.AnnictToken); 62 | } 63 | 64 | PrintDebug(L"作品のステータスを「{}」→「{}」に変更しました。(WorkID={})", Multi2Wide(status), Multi2Wide(newStatus.value()), annictWorkId); 65 | } 66 | } 67 | catch (...) 68 | { 69 | } 70 | } 71 | } 72 | 73 | static CreateRecordResult CreateEpisodeRecord(const Annict::Episode &episode, const Config &Config) 74 | { 75 | std::wstring workTitle; 76 | if (episode.numberText.has_value()) 77 | { 78 | workTitle = std::format( 79 | L"{}「{}」", 80 | Multi2Wide(episode.numberText.value()), 81 | Multi2Wide(episode.title.value_or("???"))); 82 | } 83 | else if (episode.number.has_value()) 84 | { 85 | workTitle = std::format( 86 | L"#{}「{}」", 87 | episode.number.value(), 88 | Multi2Wide(episode.title.value_or("???"))); 89 | } 90 | else if (episode.title.has_value()) 91 | { 92 | workTitle = std::format(L"「{}」", Multi2Wide(episode.title.value())); 93 | } 94 | else 95 | { 96 | workTitle = std::format(L"「{}」", Multi2Wide(episode.work.title)); 97 | } 98 | 99 | if (!Config.RecordDryRun) 100 | { 101 | try 102 | { 103 | Annict::PostRecord(episode.id, Config.ShareOnTwitter, Config.ShareOnFacebook, Config.AnnictToken); 104 | } 105 | catch (...) 106 | { 107 | return { 108 | false, 109 | std::format(L"Annictとの通信に失敗しました。{}を記録できませんでした。", workTitle), 110 | episode.work, 111 | episode, 112 | std::format(L"https://annict.com/works/{}/episodes/{}", episode.work.id, episode.id), 113 | }; 114 | } 115 | } 116 | 117 | return { 118 | true, 119 | std::format(L"{}を記録しました。", workTitle), 120 | episode.work, 121 | episode, 122 | std::format(L"https://annict.com/works/{}/episodes/{}", episode.work.id, episode.id), 123 | }; 124 | } 125 | 126 | static std::optional FindEpisode( 127 | const uint32_t annictWorkId, 128 | const float_t episodeCount, 129 | const std::optional &episodeTitle, 130 | const Config &Config) 131 | { 132 | try 133 | { 134 | // Annict からエピソード一覧を取得して, 該当のエピソードを見つける 135 | const auto episodes = Annict::GetEpisodes(annictWorkId, Config.AnnictToken); 136 | const auto episodeIterator = std::ranges::find_if(episodes, [episodeCount, episodeTitle](const Annict::Episode &episode) 137 | { 138 | // 話数が一致 139 | if (episode.number.has_value() && episodeCount == episode.number.value()) // NOLINT(clang-diagnostic-float-equal) 140 | { 141 | return true; 142 | } 143 | 144 | // 話数 (テキスト) が一致 145 | // まれに Annict 側に number だけ設定されていないデータがあるので文字列比較する 146 | auto numberText = std::format("第{:.0f}話", episodeCount); 147 | if (episode.numberText.has_value() && numberText == episode.numberText.value()) 148 | { 149 | return true; 150 | } 151 | 152 | numberText = std::format("#{:.0f}", episodeCount); 153 | if (episode.numberText.has_value() && numberText == episode.numberText.value()) 154 | { 155 | return true; 156 | } 157 | 158 | // サブタイトルが一致 159 | return episodeTitle.has_value() && episode.title.has_value() && episodeTitle.value() == episode.title.value(); }); 160 | 161 | return episodeIterator != episodes.end() ? std::optional(*episodeIterator) : std::nullopt; 162 | } 163 | catch (...) 164 | { 165 | return std::nullopt; 166 | } 167 | } 168 | 169 | static CreateRecordResult CreateEpisodeRecord( 170 | const uint32_t annictWorkId, 171 | const float_t episodeCount, 172 | const std::optional &episodeTitle, 173 | const Config &Config) 174 | { 175 | const auto episode = FindEpisode(annictWorkId, episodeCount, episodeTitle, Config); 176 | if (!episode.has_value()) 177 | { 178 | PrintDebug(L"Annict でのエピソードデータが見つかりませんでした。スキップします。(WorkID={}, Count={})", annictWorkId, episodeCount); 179 | 180 | return { 181 | false, 182 | L"Annictにエピソードデータが見つかりません。", 183 | }; 184 | } 185 | 186 | return CreateEpisodeRecord(episode.value(), Config); 187 | } 188 | 189 | static CreateRecordResult CreateWorkRecord(const Annict::Work &work, const Config &Config) 190 | { 191 | if (!Config.RecordDryRun) 192 | { 193 | try 194 | { 195 | Annict::PostMyStatus(work.id, "watched", Config.AnnictToken); 196 | } 197 | catch (...) 198 | { 199 | return { 200 | false, 201 | std::format(L"Annictとの通信に失敗しました。「{}」を記録できませんでした。", Multi2Wide(work.title)), 202 | work, 203 | std::nullopt, 204 | std::format(L"https://annict.com/works/{}", work.id), 205 | }; 206 | } 207 | } 208 | 209 | return { 210 | true, 211 | std::format(L"「{}」を記録しました。", Multi2Wide(work.title)), 212 | work, 213 | std::nullopt, 214 | std::format(L"https://annict.com/works/{}", work.id), 215 | }; 216 | } 217 | 218 | static std::vector CreateRecordEach( 219 | const Annict::Work &work, 220 | // 開始話数, 終了話数 221 | const float_t episodeNumberStart, 222 | const float_t episodeNumberEnd, 223 | // エピソード名 224 | const std::optional &episodeTitle, 225 | // 最初のエピソードであるか 226 | const bool isFirstEpisode, 227 | // 最後のエピソードであるか 228 | const bool isLastEpisode, 229 | // エピソードを持っているか 230 | const bool hasNoEpisodes, 231 | const Config &Config) 232 | { 233 | // 劇場版など 234 | if (hasNoEpisodes) 235 | { 236 | // エピソードで別れていない場合, 作品自体を「見た」に設定 237 | return { 238 | CreateWorkRecord(work, Config), 239 | }; 240 | } 241 | 242 | // エピソードで別れている場合, 該当のエピソードを記録 243 | auto results = std::vector(); 244 | for (auto count = episodeNumberStart; count <= episodeNumberEnd; count++) // NOLINT(cert-flp30-c) 245 | { 246 | const auto result = CreateEpisodeRecord(work.id, count, episodeTitle, Config); 247 | results.push_back(result); 248 | } 249 | 250 | UpdateWorkStatus(work.id, isFirstEpisode, isLastEpisode, Config); 251 | 252 | return results; 253 | } 254 | 255 | static std::vector CreateRecord( 256 | const Config &Config, 257 | const TVTest::ProgramInfo &Program, 258 | std::map &AnnictIds, 259 | const YAML::Node &SayaDefinitions) 260 | { 261 | // saya のチャンネル定義 262 | const auto ChannelDefinition = Saya::FindChannel(SayaDefinitions, Program.ServiceID); 263 | if (!ChannelDefinition.has_value()) 264 | { 265 | PrintDebug(L"saya のチャンネル定義に存在しないチャンネルです。スキップします。(サービス ID: {})", Program.ServiceID); 266 | 267 | return { 268 | {false, L"sayaのチャンネル定義に存在しないチャンネルです。"}, 269 | }; 270 | } 271 | 272 | // [新] フラグから第1話判定を行う 273 | auto isFirstEpisode = IsFirstEpisode(Program); 274 | // [終] フラグから最終話判定を行う 275 | auto isLastEpisode = IsLastEpisode(Program); 276 | 277 | // しょぼいカレンダーの ChID が登録されている場合 278 | if (ChannelDefinition.value().syobocalId.has_value()) 279 | { 280 | // しょぼいカレンダー ChID 281 | const auto syoboCalChId = ChannelDefinition.value().syobocalId.value(); 282 | 283 | // しょぼいカレンダーに放送時間が登録されている場合 284 | try 285 | { 286 | if (const auto syoboCalPrograms = SyoboCal::LookupProgram(Program.StartTime, Program.Duration, syoboCalChId); !syoboCalPrograms.empty()) 287 | { 288 | auto results = std::vector>{}; 289 | for (const auto &syoboCalProgram : syoboCalPrograms) 290 | { 291 | // kawaiioverflow/arm から しょぼいカレンダー TID → Annict 作品 ID を見つける 292 | const auto syoboCalTID = syoboCalProgram.titleId; 293 | if (AnnictIds.contains(syoboCalTID)) 294 | { 295 | // しょぼいカレンダー TID と Annict 作品 ID が紐付いている 296 | const auto annictWorkId = AnnictIds[syoboCalTID]; 297 | 298 | try 299 | { 300 | const auto annictWork = Annict::GetWorkById(annictWorkId, Config.AnnictToken); 301 | 302 | results.push_back( 303 | CreateRecordEach( 304 | annictWork.value(), 305 | syoboCalProgram.countStart, 306 | syoboCalProgram.countEnd, 307 | syoboCalProgram.subTitle, 308 | isFirstEpisode || syoboCalProgram.isFirstEpisode || syoboCalProgram.countStart <= 1, 309 | isLastEpisode || syoboCalProgram.isLastEpisode, 310 | annictWork.value().hasNoEpisodes, 311 | Config)); 312 | } 313 | catch (...) 314 | { 315 | continue; 316 | } 317 | } 318 | else 319 | { 320 | // 紐ついていないので、作品名から Annict 作品 ID を見つける 321 | try 322 | { 323 | if (const auto title = SyoboCal::LookupTitle(syoboCalTID); title.has_value()) 324 | { 325 | const auto workTitle = Wide2Multi(title.value()); 326 | if (const auto annictWork = Annict::GetWorkByTitle(workTitle, Config.AnnictToken); annictWork.has_value()) 327 | { 328 | results.push_back( 329 | CreateRecordEach( 330 | annictWork.value(), 331 | syoboCalProgram.countStart, 332 | syoboCalProgram.countEnd, 333 | syoboCalProgram.subTitle, 334 | isFirstEpisode || syoboCalProgram.isFirstEpisode || syoboCalProgram.countStart <= 1, 335 | isLastEpisode || syoboCalProgram.isLastEpisode, 336 | annictWork.value().hasNoEpisodes, 337 | Config)); 338 | } 339 | } 340 | } 341 | catch (...) 342 | { 343 | continue; 344 | } 345 | } 346 | } 347 | 348 | if (results.empty()) 349 | { 350 | return { 351 | {false, L"Annictに作品データが見つかりません。"}, 352 | }; 353 | } 354 | 355 | return flatten(results); 356 | } 357 | } 358 | catch (...) 359 | { 360 | } 361 | } 362 | 363 | // Annict の ChID が登録されている場合 364 | if (ChannelDefinition.value().annictId.has_value()) 365 | { 366 | const auto annictChId = ChannelDefinition.value().annictId.value(); 367 | 368 | FILETIME localFt, utcFt; 369 | SYSTEMTIME utcStartTime; 370 | SystemTimeToFileTime(&Program.StartTime, &localFt); 371 | LocalFileTimeToFileTime(&localFt, &utcFt); 372 | FileTimeToSystemTime(&utcFt, &utcStartTime); 373 | 374 | // Annict に放送時間が登録されている場合 375 | try 376 | { 377 | if (const auto annictPrograms = Annict::GetMyPrograms(annictChId, utcStartTime, Program.Duration, Config.AnnictToken); !annictPrograms.empty()) 378 | { 379 | auto results = std::vector>{}; 380 | for (const auto &annictProgram : annictPrograms) 381 | { 382 | if (!annictProgram.episode.number.has_value()) 383 | { 384 | continue; 385 | } 386 | 387 | results.push_back( 388 | CreateRecordEach( 389 | annictProgram.work, 390 | annictProgram.episode.number.value(), 391 | annictProgram.episode.number.value(), 392 | annictProgram.episode.title, 393 | isFirstEpisode || annictProgram.episode.number.value() <= 1, // NOLINT(clang-diagnostic-float-equal) 394 | isLastEpisode, 395 | annictProgram.work.hasNoEpisodes, 396 | Config)); 397 | } 398 | 399 | return flatten(results); 400 | } 401 | } 402 | catch (...) 403 | { 404 | } 405 | } 406 | 407 | // しょぼいカレンダー / Annict に放送時間が未登録の場合は正規表現で検出を試みる 408 | 409 | // AT-X 410 | if (Program.ServiceID == 333) 411 | { 412 | const auto extraction = Title::ExtractAtxTitle(Program.pszEventName); 413 | if (!extraction.found) 414 | { 415 | return { 416 | {false, L"タイトルの抽出に失敗しました。"}, 417 | }; 418 | } 419 | 420 | PrintDebug(L"抽出されたタイトル情報: Title={}, Count={} ~ {}", extraction.title, extraction.countStart, extraction.countEnd); 421 | 422 | // タイトルから Annict 作品 ID を見つける 423 | const auto workTitle = Wide2Multi(extraction.title); 424 | try 425 | { 426 | if (const auto annictWork = Annict::GetWorkByTitle(workTitle, Config.AnnictToken); annictWork.has_value()) 427 | { 428 | return CreateRecordEach( 429 | annictWork.value(), 430 | extraction.countStart, 431 | extraction.countEnd, 432 | std::nullopt, 433 | isFirstEpisode || extraction.countStart == 1, // NOLINT(clang-diagnostic-float-equal) 434 | isLastEpisode, 435 | annictWork.value().hasNoEpisodes, 436 | Config); 437 | } 438 | } 439 | catch (...) 440 | { 441 | } 442 | 443 | return { 444 | {false, L"Annictに作品データが見つかりません。"}, 445 | }; 446 | } 447 | 448 | PrintDebug(L"しょぼいカレンダー / Annict に放送時刻が登録されていません。スキップします。(SID={})", Program.ServiceID); 449 | 450 | return { 451 | {false, L"放送時刻データがありません。"}, 452 | }; 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /AnnictRecorder/AnnictRecorder.vcxproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Debug 6 | Win32 7 | 8 | 9 | Release 10 | Win32 11 | 12 | 13 | Debug 14 | x64 15 | 16 | 17 | Release 18 | x64 19 | 20 | 21 | 22 | 16.0 23 | Win32Proj 24 | {918c47c2-f57d-48b7-a5a8-0f1806025b05} 25 | TVTestAnnictRecorder 26 | 10.0 27 | AnnictRecorder 28 | 29 | 30 | 31 | DynamicLibrary 32 | true 33 | v143 34 | Unicode 35 | false 36 | 37 | 38 | DynamicLibrary 39 | false 40 | v143 41 | true 42 | Unicode 43 | false 44 | 45 | 46 | DynamicLibrary 47 | true 48 | v143 49 | Unicode 50 | false 51 | 52 | 53 | DynamicLibrary 54 | false 55 | v143 56 | true 57 | Unicode 58 | false 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | true 80 | .tvtp 81 | $(SolutionDir)Thirdparty 82 | $(SolutionDir)$(PlatformShortName)\$(Configuration)\ 83 | $(PlatformShortName)\$(Configuration)\ 84 | 85 | 86 | false 87 | .tvtp 88 | $(SolutionDir)Thirdparty 89 | $(SolutionDir)$(PlatformShortName)\$(Configuration)\ 90 | $(PlatformShortName)\$(Configuration)\ 91 | 92 | 93 | true 94 | .tvtp 95 | $(SolutionDir)Thirdparty 96 | $(SolutionDir)$(PlatformShortName)\$(Configuration)\ 97 | $(PlatformShortName)\$(Configuration)\ 98 | 99 | 100 | false 101 | .tvtp 102 | $(SolutionDir)Thirdparty 103 | $(SolutionDir)$(PlatformShortName)\$(Configuration)\ 104 | $(PlatformShortName)\$(Configuration)\ 105 | 106 | 107 | true 108 | 109 | 110 | 111 | Level3 112 | true 113 | WIN32;_DEBUG;TVTESTANNICTRECORDER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) 114 | true 115 | Create 116 | pch.h 117 | stdcpplatest 118 | true 119 | TurnOffAllWarnings 120 | 4996 121 | 122 | 123 | Windows 124 | true 125 | false 126 | Exports.def 127 | 128 | 129 | 130 | 131 | Level3 132 | true 133 | true 134 | true 135 | WIN32;NDEBUG;TVTESTANNICTRECORDER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) 136 | true 137 | Create 138 | pch.h 139 | stdcpplatest 140 | true 141 | TurnOffAllWarnings 142 | 4996 143 | 144 | 145 | Windows 146 | true 147 | true 148 | true 149 | false 150 | Exports.def 151 | 152 | 153 | 154 | 155 | Level3 156 | true 157 | _DEBUG;TVTESTANNICTRECORDER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) 158 | true 159 | Create 160 | pch.h 161 | stdcpplatest 162 | true 163 | TurnOffAllWarnings 164 | 4996 165 | 166 | 167 | Windows 168 | true 169 | false 170 | 171 | 172 | 173 | 174 | Level3 175 | true 176 | true 177 | true 178 | NDEBUG;TVTESTANNICTRECORDER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) 179 | true 180 | Create 181 | pch.h 182 | stdcpplatest 183 | true 184 | TurnOffAllWarnings 185 | 4996 186 | 187 | 188 | Windows 189 | true 190 | true 191 | true 192 | false 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | -------------------------------------------------------------------------------- /AnnictRecorder/AnnictRecorder.vcxproj.filters: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | {4FC737F1-C7A5-4376-A066-2A32D752A2FF} 6 | cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx 7 | 8 | 9 | {93995380-89BD-4b04-88EB-625FBE52EBFB} 10 | h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd 11 | 12 | 13 | {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} 14 | rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms 15 | 16 | 17 | 18 | 19 | ヘッダー ファイル 20 | 21 | 22 | ヘッダー ファイル 23 | 24 | 25 | ヘッダー ファイル 26 | 27 | 28 | ヘッダー ファイル 29 | 30 | 31 | ヘッダー ファイル 32 | 33 | 34 | ヘッダー ファイル 35 | 36 | 37 | ヘッダー ファイル 38 | 39 | 40 | ヘッダー ファイル 41 | 42 | 43 | ヘッダー ファイル 44 | 45 | 46 | ヘッダー ファイル 47 | 48 | 49 | ヘッダー ファイル 50 | 51 | 52 | ヘッダー ファイル 53 | 54 | 55 | ヘッダー ファイル 56 | 57 | 58 | 59 | 60 | ソース ファイル 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /AnnictRecorder/ArmApi.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | #include "Common.h" 6 | 7 | namespace Arm 8 | { 9 | constexpr auto ArmJsonUrl = "https://raw.githubusercontent.com/SlashNephy/arm-supplementary/master/dist/arm.json"; 10 | 11 | static void LoadArmJson(std::map &map) 12 | { 13 | const auto response = cpr::Get( 14 | cpr::Url{ArmJsonUrl}, 15 | cpr::UserAgent{AnnictRecorderUserAgent}); 16 | 17 | for (auto &it : nlohmann::json::parse(response.text)) 18 | { 19 | if (!it["syobocal_tid"].is_number_unsigned() || !it["annict_id"].is_number_unsigned()) 20 | { 21 | continue; 22 | } 23 | 24 | const auto syobocalTid = it["syobocal_tid"].get(); 25 | const auto annictId = it["annict_id"].get(); 26 | map[syobocalTid] = annictId; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /AnnictRecorder/Capture.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | namespace Capture 6 | { 7 | static int DecodeBmp(std::vector &image, unsigned &w, unsigned &h, const void *ptr) 8 | { 9 | const auto header = static_cast(ptr); 10 | 11 | PrintDebug(L"biSize = {}", header->biSize); 12 | PrintDebug(L"biSizeImage = {}", header->biSizeImage); 13 | 14 | if (header->biSize < 40) 15 | return -1; 16 | // if (bmp[0] != 'B' || bmp[1] != 'M') return 1; //It's not a BMP file if it doesn't start with marker 'BM' 17 | const auto pixeloffset = header->biSize; 18 | // read width and height from BMP header 19 | w = header->biWidth; 20 | h = header->biHeight; 21 | // read number of channels from BMP header 22 | if (header->biBitCount != 24 && header->biBitCount != 32) 23 | return 2; // only 24-bit and 32-bit BMPs are supported. 24 | const auto numChannels = header->biBitCount / 8; 25 | 26 | // The amount of scanline bytes is width of image times channels, with extra bytes added if needed 27 | // to make it a multiple of 4 bytes. 28 | auto scanlineBytes = w * numChannels; 29 | if (scanlineBytes % 4 != 0) 30 | scanlineBytes = (scanlineBytes / 4) * 4 + 4; 31 | 32 | const auto dataSize = scanlineBytes * h; 33 | if (header->biSizeImage < dataSize) 34 | return 3; // BMP file too small to contain all pixels 35 | 36 | image.resize(w * h * 4); 37 | 38 | /* 39 | There are 3 differences between BMP and the raw image buffer for LodePNG: 40 | -it's upside down 41 | -it's in BGR instead of RGB format (or BRGA instead of RGBA) 42 | -each scanline has padding bytes to make it a multiple of 4 if needed 43 | The 2D for loop below does all these 3 conversions at once. 44 | */ 45 | for (unsigned y = 0; y < h; y++) 46 | { 47 | for (unsigned x = 0; x < w; x++) 48 | { 49 | // pixel start byte position in the BMP 50 | const auto bmpos = 51 | reinterpret_cast(ptr) + pixeloffset + (h - y - 1) * scanlineBytes + numChannels * x; 52 | // pixel start byte position in the new raw image 53 | const auto newpos = 4 * y * w + 4 * x; 54 | 55 | image[newpos + 0] = *reinterpret_cast(bmpos + 2); // R 56 | image[newpos + 1] = *reinterpret_cast(bmpos + 1); // G 57 | image[newpos + 2] = *reinterpret_cast(bmpos + 0); // B 58 | // 透明度情報が保持できないバグらしい 59 | // https://github.com/lvandeve/lodepng/issues/42 60 | image[newpos + 3] = 255; // numChannels == 3 ? 255 : *reinterpret_cast(bmpos + 3); //A 61 | } 62 | } 63 | 64 | return 0; 65 | } 66 | 67 | static std::optional> ConvertToPng(const void *ptr) 68 | { 69 | std::vector bmp, png; 70 | unsigned w, h; 71 | 72 | if (const auto error = DecodeBmp(bmp, w, h, ptr)) 73 | { 74 | PrintDebug(L"BMP decoding error: {}", error); 75 | return std::nullopt; 76 | } 77 | 78 | if (const auto error = lodepng::encode(png, bmp, w, h)) 79 | { 80 | PrintDebug(L"PNG encoding error: {}", error); 81 | return std::nullopt; 82 | } 83 | 84 | PrintDebug(L"BMP -> PNG の変換に成功しました。"); 85 | 86 | #ifdef _DEBUG 87 | lodepng::save_file(png, "output.png"); 88 | #endif 89 | 90 | return png; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /AnnictRecorder/Common.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | constexpr auto AnnictRecorderVersion = "1.0.0"; 4 | constexpr auto AnnictRecorderUserAgent = "TVTestAnnictRecorder/1.0.0 (+https://github.com/SlashNephy/TVTestAnnictRecorder)"; 5 | -------------------------------------------------------------------------------- /AnnictRecorder/Config.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | namespace AnnictRecorder 4 | { 5 | constexpr auto MaxAnnictTokenLength = 64; 6 | constexpr auto MaxDiscordTokenLength = 256; 7 | constexpr auto MaxDiscordChannelIdLength = 32; 8 | 9 | struct Config 10 | { 11 | // Annict 12 | char AnnictToken[MaxAnnictTokenLength]{}; 13 | 14 | // Record 15 | bool Enabled = true; 16 | int RecordThresholdPercent = 20; 17 | bool ShareOnTwitter = false; 18 | bool ShareOnFacebook = false; 19 | bool SetWatchingStatusInFirstEpisode = false; 20 | bool SetWatchingStatusInAnyEpisodes = false; 21 | bool SetWatchedInLastEpisode = false; 22 | bool SkipUpdateStatusIfAlreadyWatched = false; 23 | bool SetWatchingStatusOnFirstEpisodeEvenIfWatched = false; 24 | bool RecordDryRun = false; 25 | 26 | // Discord 27 | char DiscordToken[MaxDiscordTokenLength]{}; 28 | char DiscordChannelId[MaxDiscordChannelIdLength]{}; 29 | bool DiscordDryRun = false; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /AnnictRecorder/Debug.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | #ifdef _DEBUG 6 | template 7 | void PrintDebug(const wchar_t *format, const Args &...args) 8 | { 9 | std::wstring message; 10 | 11 | try 12 | { 13 | message = std::vformat(format, {std::make_wformat_args(args...)}); 14 | message.append(L"\n"); 15 | } 16 | catch (std::format_error &error) 17 | { 18 | wchar_t errorText[512 + 1]; 19 | mbstowcs_s(nullptr, errorText, error.what(), 512); 20 | message = std::format(L"フォーマットの書式が間違っています。({})\n{}\n", format, errorText); 21 | } 22 | 23 | OutputDebugString(message.c_str()); 24 | } 25 | 26 | inline void PrintDebugW(const std::wstring &label, const std::string &str) 27 | { 28 | // ロケールの設定 29 | setlocale(LC_ALL, ".utf8"); 30 | 31 | wchar_t buf[1024]; 32 | mbstowcs_s(nullptr, buf, str.c_str(), 1023); 33 | PrintDebug(L"{}: {}", label.c_str(), buf); 34 | } 35 | #else 36 | #define PrintDebug __noop 37 | #define PrintDebugW __noop 38 | #endif 39 | -------------------------------------------------------------------------------- /AnnictRecorder/Exports.def: -------------------------------------------------------------------------------- 1 | EXPORTS 2 | TVTGetVersion 3 | TVTGetPluginInfo 4 | TVTInitialize 5 | TVTFinalize 6 | -------------------------------------------------------------------------------- /AnnictRecorder/Plugin.cpp: -------------------------------------------------------------------------------- 1 | #include "pch.h" 2 | 3 | #include "AnnictRecorder.h" 4 | #include "ArmApi.h" 5 | #include "Capture.h" 6 | #include "Config.h" 7 | #include "Debug.h" 8 | #include "SayaApi.h" 9 | #include "TvtPlay.h" 10 | #include "Utils.h" 11 | 12 | constexpr auto AnnictRecorderWindowClass = L"AnnictRecorder Window"; 13 | constexpr auto AnnictRecorderStatusItemId = WM_APP + 1; 14 | constexpr auto AnnictRecorderTimerId = WM_APP + 2; 15 | constexpr auto AnnictRecorderTimerIntervalMs = 10 * 1000; 16 | constexpr auto MaxEventNameLength = 64; 17 | 18 | class CAnnictRecorderPlugin final : public TVTest::CTVTestPlugin 19 | { 20 | // 設定ファイルへのパス 21 | wchar_t m_iniFileName[MAX_PATH]{}; 22 | // Window 23 | HWND m_hWnd{}; 24 | 25 | // saya のチャンネル定義 yml 26 | YAML::Node m_definitions{}; 27 | std::future m_definitionsFuture{}; 28 | // しょぼいカレンダー TID をキーとして Annict 作品 ID を保持する map 29 | std::map m_annictIds{}; 30 | std::future m_annictIdsFuture{}; 31 | // Program ID をキーとして番組の視聴を開始したエポック秒を保持する map 32 | std::map m_watchStartTime{}; 33 | // Program ID をキーとして Annict に記録済かを保持する map 34 | std::map m_recorded{}; 35 | // 前回の Annict の記録の結果を表す構造体 36 | AnnictRecorder::CreateRecordResult m_lastRecordResult{}; 37 | // CheckCurrentProgram 内の排他ロック 38 | std::mutex m_mutex{}; 39 | 40 | AnnictRecorder::Config m_config{}; 41 | bool m_isReady = false; 42 | bool m_isEnabled = false; 43 | 44 | void Enable(); 45 | 46 | void LoadConfig(); 47 | 48 | void CheckCurrentProgram(); 49 | 50 | static LRESULT CALLBACK EventCallback(UINT Event, LPARAM lParam1, LPARAM lParam2, void *pClientData); 51 | static LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam); 52 | 53 | public: 54 | /* 55 | * プラグインのバージョンを返す 56 | */ 57 | DWORD GetVersion() override 58 | { 59 | // TVTest API 0.0.14 以上 60 | // (TVTest ver.0.9.0 or later) 61 | return TVTEST_PLUGIN_VERSION_(0, 0, 14); 62 | } 63 | 64 | /* 65 | * プラグインの情報を返す 66 | */ 67 | bool GetPluginInfo(TVTest::PluginInfo *pInfo) override 68 | { 69 | pInfo->Type = TVTest::PLUGIN_TYPE_NORMAL; 70 | pInfo->Flags = 0; 71 | pInfo->pszPluginName = L"Annict Recorder"; 72 | pInfo->pszCopyright = L"© 2023 SlashNephy"; 73 | pInfo->pszDescription = L"視聴したアニメの視聴記録を自動で Annict に送信します。"; 74 | 75 | return true; 76 | } 77 | 78 | /* 79 | * プラグインの初期化を行う 80 | * プラグインがロードされたときの処理を記述する (ロード != 有効化であることに注意) 81 | */ 82 | bool Initialize() override 83 | { 84 | // TVTest イベントコールバックの設定 85 | m_pApp->SetEventCallback(EventCallback, this); 86 | 87 | // ウィンドウクラスの登録 88 | WNDCLASS wc; 89 | wc.style = 0; 90 | wc.lpfnWndProc = WndProc; 91 | wc.cbClsExtra = 0; 92 | wc.cbWndExtra = 0; 93 | wc.hInstance = g_hinstDLL; 94 | wc.hIcon = nullptr; 95 | wc.hCursor = nullptr; 96 | wc.hbrBackground = nullptr; 97 | wc.lpszMenuName = nullptr; 98 | wc.lpszClassName = AnnictRecorderWindowClass; 99 | if (::RegisterClass(&wc) == 0) 100 | { 101 | m_pApp->AddLog(L"ウィンドウクラスの登録に失敗しました。"); 102 | return false; 103 | } 104 | 105 | // ウィンドウの作成 106 | m_hWnd = ::CreateWindowEx( 107 | 0, AnnictRecorderWindowClass, nullptr, WS_POPUP, 108 | 0, 0, 0, 0, HWND_MESSAGE, nullptr, g_hinstDLL, this); 109 | if (m_hWnd == nullptr) 110 | { 111 | m_pApp->AddLog(L"ウィンドウの作成に失敗しました。"); 112 | return false; 113 | } 114 | 115 | // ステータス項目の登録 116 | TVTest::StatusItemInfo StatusItem{ 117 | sizeof StatusItem, 118 | TVTest::STATUS_ITEM_FLAG_TIMERUPDATE, 119 | 0, 120 | AnnictRecorderStatusItemId, 121 | L"AnnictRecorder", 122 | L"Annict Recorder", 123 | 0, 124 | -1, 125 | TVTest::StatusItemWidthByFontSize(30), 126 | 0, 127 | }; 128 | if (!m_pApp->RegisterStatusItem(&StatusItem)) 129 | { 130 | m_pApp->AddLog(L"ステータス項目の登録に失敗しました。"); 131 | return false; 132 | } 133 | 134 | return true; 135 | } 136 | 137 | /* 138 | * プラグインの終了処理を行う 139 | */ 140 | bool Finalize() override 141 | { 142 | // タイマー・ウィンドウの破棄 143 | ::KillTimer(m_hWnd, AnnictRecorderTimerId); 144 | ::DestroyWindow(m_hWnd); 145 | 146 | return true; 147 | } 148 | }; 149 | 150 | /* 151 | * プラグインのインスタンスを作成する 152 | */ 153 | TVTest::CTVTestPlugin *CreatePluginClass() 154 | { 155 | return new CAnnictRecorderPlugin; 156 | } 157 | 158 | /* 159 | * プラグインが有効化されたときの処理を記述する 160 | */ 161 | void CAnnictRecorderPlugin::Enable() 162 | { 163 | // 設定の読み込み 164 | LoadConfig(); 165 | } 166 | 167 | /* 168 | * 設定を読み込む 169 | */ 170 | void CAnnictRecorderPlugin::LoadConfig() 171 | { 172 | ::GetModuleFileName(g_hinstDLL, m_iniFileName, MAX_PATH); 173 | ::PathRenameExtension(m_iniFileName, L".ini"); 174 | 175 | wchar_t annictTokenW[AnnictRecorder::MaxAnnictTokenLength]; 176 | ::GetPrivateProfileString(L"Annict", L"Token", L"", annictTokenW, AnnictRecorder::MaxAnnictTokenLength, m_iniFileName); 177 | wcstombs_s(nullptr, m_config.AnnictToken, annictTokenW, AnnictRecorder::MaxAnnictTokenLength - 1); 178 | 179 | const auto record = GetPrivateProfileSectionBuffer(L"Record", m_iniFileName); 180 | m_config.RecordThresholdPercent = GetBufferedProfileInt(record.data(), L"ThresholdPercent", m_config.RecordThresholdPercent); 181 | m_config.ShareOnTwitter = GetBufferedProfileInt(record.data(), L"ShareOnTwitter", m_config.ShareOnTwitter) > 0; 182 | m_config.ShareOnFacebook = GetBufferedProfileInt(record.data(), L"ShareOnFacebook", m_config.ShareOnFacebook) > 0; 183 | m_config.SetWatchingStatusInFirstEpisode = GetBufferedProfileInt(record.data(), L"SetWatchingStatusInFirstEpisode", m_config.SetWatchingStatusInFirstEpisode) > 0; 184 | m_config.SetWatchingStatusInAnyEpisodes = GetBufferedProfileInt(record.data(), L"SetWatchingStatusInAnyEpisodes", m_config.SetWatchingStatusInAnyEpisodes) > 0; 185 | m_config.SetWatchedInLastEpisode = GetBufferedProfileInt(record.data(), L"SetWatchedInLastEpisode", m_config.SetWatchedInLastEpisode) > 0; 186 | m_config.SkipUpdateStatusIfAlreadyWatched = GetBufferedProfileInt(record.data(), L"SkipUpdateStatusIfAlreadyWatched", m_config.SkipUpdateStatusIfAlreadyWatched) > 0; 187 | m_config.SetWatchingStatusOnFirstEpisodeEvenIfWatched = GetBufferedProfileInt(record.data(), L"SetWatchingStatusOnFirstEpisodeEvenIfWatched", m_config.SetWatchingStatusOnFirstEpisodeEvenIfWatched) > 0; 188 | m_config.RecordDryRun = GetBufferedProfileInt(record.data(), L"DryRun", m_config.RecordDryRun) > 0; 189 | m_config.Enabled = GetBufferedProfileInt(record.data(), L"Enabled", m_config.Enabled) > 0; 190 | 191 | wchar_t discordTokenW[AnnictRecorder::MaxDiscordTokenLength]; 192 | ::GetPrivateProfileString(L"Discord", L"Token", L"", discordTokenW, AnnictRecorder::MaxDiscordTokenLength, m_iniFileName); 193 | wcstombs_s(nullptr, m_config.DiscordToken, discordTokenW, AnnictRecorder::MaxDiscordTokenLength - 1); 194 | wchar_t discordChannelIdW[AnnictRecorder::MaxDiscordChannelIdLength]; 195 | ::GetPrivateProfileString(L"Discord", L"ChannelId", L"", discordChannelIdW, AnnictRecorder::MaxDiscordChannelIdLength, m_iniFileName); 196 | wcstombs_s(nullptr, m_config.DiscordChannelId, discordChannelIdW, AnnictRecorder::MaxDiscordChannelIdLength - 1); 197 | const auto discord = GetPrivateProfileSectionBuffer(L"Discord", m_iniFileName); 198 | m_config.DiscordDryRun = GetBufferedProfileInt(discord.data(), L"DryRun", m_config.DiscordDryRun) > 0; 199 | 200 | m_definitionsFuture = std::async( 201 | std::launch::async, 202 | [this] 203 | { 204 | try 205 | { 206 | m_definitions = Saya::LoadSayaDefinitions(); 207 | 208 | m_pApp->AddLog(std::format(L"sayaのチャンネル定義ファイルを読み込みました。(チャンネル数: {})", m_definitions["channels"].size()).c_str()); 209 | } 210 | catch (...) 211 | { 212 | m_lastRecordResult = { 213 | false, 214 | L"sayaのチャンネル定義ファイルが利用できません。", 215 | }; 216 | m_pApp->AddLog(std::format(L"sayaのチャンネル定義ファイルの読み込みに失敗しました。").c_str(), TVTest::LOG_TYPE_ERROR); 217 | } 218 | }); 219 | 220 | m_annictIdsFuture = std::async( 221 | std::launch::async, 222 | [this] 223 | { 224 | try 225 | { 226 | Arm::LoadArmJson(m_annictIds); 227 | 228 | m_pApp->AddLog(std::format(L"SlashNephy/arm-supplementaryの定義ファイルを読み込みました。(作品数: {})", m_annictIds.size()).c_str()); 229 | } 230 | catch (...) 231 | { 232 | m_lastRecordResult = { 233 | false, 234 | L"SlashNephy/arm-supplementaryの定義ファイルが利用できません。", 235 | }; 236 | m_pApp->AddLog(std::format(L"SlashNephy/arm-supplementaryの定義ファイルの読み込みに失敗しました。").c_str(), TVTest::LOG_TYPE_ERROR); 237 | } 238 | }); 239 | 240 | m_isReady = strlen(m_config.AnnictToken) > 0; 241 | } 242 | 243 | /* 244 | * 現在の番組をチェックする 245 | */ 246 | void CAnnictRecorderPlugin::CheckCurrentProgram() 247 | { 248 | // トークンが設定されていないかプラグインが無効 249 | if (!m_isReady || !m_isEnabled) 250 | { 251 | PrintDebug(L"プラグインは無効化されています。"); 252 | return; 253 | } 254 | 255 | if (m_definitionsFuture.wait_for(std::chrono::seconds(0)) != std::future_status::ready || m_definitions.size() == 0) 256 | { 257 | PrintDebug(L"saya のチャンネル定義ファイルが利用できません。"); 258 | return; 259 | } 260 | 261 | if (m_annictIdsFuture.wait_for(std::chrono::seconds(0)) != std::future_status::ready || m_annictIds.empty()) 262 | { 263 | PrintDebug(L"kawaiioverflow/arm の定義ファイルが利用できません。"); 264 | return; 265 | } 266 | 267 | // ロック 268 | { 269 | std::lock_guard lock(m_mutex); 270 | PrintDebug(L"クリティカルセクションに入りました。"); 271 | 272 | // ProgramInfo 273 | TVTest::ProgramInfo Program{}; 274 | wchar_t pszEventName[MaxEventNameLength]{}; 275 | Program.pszEventName = pszEventName; 276 | Program.MaxEventName = _countof(pszEventName); 277 | Program.pszEventText = nullptr; 278 | Program.pszEventExtText = nullptr; 279 | 280 | if (!m_pApp->GetCurrentProgramInfo(&Program)) 281 | { 282 | PrintDebug(L"番組情報の取得に失敗しました。スキップします。"); 283 | return; 284 | } 285 | 286 | // 番組の固有 ID 287 | const auto programId = 100000ul * Program.ServiceID + Program.EventID; 288 | if (m_recorded[programId]) 289 | { 290 | PrintDebug(L"既に Annict に記録済です。スキップします。"); 291 | return; 292 | } 293 | 294 | // TvtPlayHwnd 295 | const auto tvtPlayHwnd = FindTvtPlayFrame(); 296 | 297 | // 記録を付ける閾値に達しているかをチェック 298 | auto shouldRecord = false; 299 | double percent; 300 | if (const auto duration = static_cast(Program.Duration); tvtPlayHwnd) 301 | { 302 | const auto pos = GetTvtPlayPositionSec(tvtPlayHwnd); 303 | percent = duration > 0 ? 100.0 * pos / duration : 100; 304 | 305 | shouldRecord = percent >= m_config.RecordThresholdPercent; 306 | PrintDebug(L"視聴位置 = {:.1f} %", percent); 307 | } 308 | else 309 | { 310 | time_t watchStartTime; 311 | if (m_watchStartTime.contains(programId)) 312 | { 313 | watchStartTime = m_watchStartTime[programId]; 314 | } 315 | else 316 | { 317 | watchStartTime = time(nullptr); 318 | m_watchStartTime[programId] = watchStartTime; 319 | return; 320 | } 321 | 322 | const auto pos = time(nullptr) - watchStartTime; 323 | percent = duration > 0 ? 100.0 * static_cast(pos) / duration : 100; 324 | 325 | shouldRecord = percent >= m_config.RecordThresholdPercent; 326 | PrintDebug(L"視聴位置 = {:.1f} %", percent); 327 | } 328 | 329 | if (!shouldRecord) 330 | { 331 | PrintDebug(L"記録するための閾値 ({} %) に達していません。スキップします。", m_config.RecordThresholdPercent); 332 | if (m_config.Enabled) 333 | { 334 | m_lastRecordResult = { 335 | false, 336 | std::format(L"AnnictRecorder 待機中... ({:.0f}%)", std::floor(percent)), 337 | }; 338 | } 339 | else 340 | { 341 | m_lastRecordResult = { 342 | false, 343 | L"AnnictRecorder 一時停止中...", 344 | }; 345 | } 346 | 347 | return; 348 | } 349 | 350 | if (!m_config.Enabled) 351 | { 352 | return; 353 | } 354 | 355 | // IsAnime 356 | bool IsAnime = true; 357 | TVTest::ChannelInfo Channel{}; 358 | if (m_pApp->GetCurrentChannelInfo(&Channel)) 359 | { 360 | TVTest::EpgEventQueryInfo EpgEventQuery{}; 361 | EpgEventQuery.NetworkID = Channel.NetworkID; 362 | EpgEventQuery.TransportStreamID = Channel.TransportStreamID; 363 | EpgEventQuery.ServiceID = Channel.ServiceID; 364 | EpgEventQuery.Type = TVTest::EPG_EVENT_QUERY_EVENTID; 365 | EpgEventQuery.EventID = Program.EventID; 366 | EpgEventQuery.Flags = 0; 367 | 368 | if (const auto EpgEvent = m_pApp->GetEpgEventInfo(&EpgEventQuery); EpgEvent == nullptr) 369 | { 370 | // BonDriver_Pipe やチャンネルスキャンしていない場合を考慮して暫定的にアニメジャンルの判定を無視 371 | PrintDebug(L"EPG 情報の取得に失敗しました。(ネットワークID: {}, サービス ID: {}, 番組名: {})", Channel.NetworkID, Program.ServiceID, Program.pszEventName); 372 | } 373 | else 374 | { 375 | IsAnime = IsAnimeGenre(*EpgEvent); 376 | 377 | // EpgEventInfo の解放 378 | m_pApp->FreeEpgEventInfo(EpgEvent); 379 | } 380 | } 381 | else 382 | { 383 | PrintDebug(L"チャンネル情報の取得に失敗しました。(サービス ID: {}, 番組名: {})", Program.ServiceID, Program.pszEventName); 384 | } 385 | 386 | if (!IsAnime) 387 | { 388 | PrintDebug(L"アニメジャンルではありません。スキップします。"); 389 | m_lastRecordResult = { 390 | false, 391 | L"アニメジャンルではありません。", 392 | }; 393 | 394 | return; 395 | } 396 | 397 | AnnictRecorder::CreateRecordResult Success{}; 398 | AnnictRecorder::CreateRecordResult Failed{true}; 399 | for (const auto &result : CreateRecord(m_config, Program, m_annictIds, m_definitions)) 400 | { 401 | if (result.success) 402 | { 403 | m_pApp->AddLog(L"Annict に視聴記録を送信しました。"); 404 | m_pApp->AddLog(result.message.c_str()); 405 | Success = result; 406 | } 407 | else 408 | { 409 | Failed = result; 410 | } 411 | } 412 | 413 | if (!Failed.success) 414 | { 415 | m_pApp->AddLog(L"Annict に視聴記録を送信できませんでした。Annict 上に見つからない作品か, しょぼいカレンダーに放送時間が登録されていません。", TVTest::LOG_TYPE_WARNING); 416 | m_pApp->AddLog(Failed.message.c_str()); 417 | m_pApp->AddLog(std::format(L"番組名: {}, ネットワークID: {}, サービスID: {}", Program.pszEventName, Channel.NetworkID, Program.ServiceID).c_str()); 418 | } 419 | 420 | m_lastRecordResult = Failed.success ? Success : Failed; 421 | m_recorded[programId] = true; 422 | } 423 | 424 | PrintDebug(L"クリティカルセクションから出ました。"); 425 | } 426 | 427 | /* 428 | * TVTest のイベントコールバック 429 | */ 430 | LRESULT CALLBACK CAnnictRecorderPlugin::EventCallback(const UINT Event, const LPARAM lParam1, LPARAM, void *pClientData) 431 | { 432 | auto *pThis = static_cast(pClientData); 433 | 434 | switch (Event) 435 | { 436 | case TVTest::EVENT_PLUGINENABLE: 437 | pThis->m_isEnabled = lParam1 == 1; 438 | 439 | if (pThis->m_isEnabled) 440 | { 441 | pThis->Enable(); 442 | } 443 | 444 | return true; 445 | 446 | case TVTest::EVENT_CHANNELCHANGE: 447 | case TVTest::EVENT_SERVICECHANGE: 448 | case TVTest::EVENT_SERVICEUPDATE: 449 | pThis->m_lastRecordResult = {}; 450 | 451 | std::thread([pThis] 452 | { pThis->CheckCurrentProgram(); }) 453 | .detach(); 454 | 455 | return true; 456 | 457 | case TVTest::EVENT_STATUSITEM_DRAW: 458 | // ステータス項目の描画 459 | { 460 | const auto pInfo = reinterpret_cast(lParam1); 461 | 462 | std::wstring status; 463 | if ((pInfo->Flags & TVTest::STATUS_ITEM_DRAW_FLAG_PREVIEW) == 0) 464 | { 465 | // 通常の項目の描画 466 | status = pThis->m_lastRecordResult.message; 467 | } 468 | else 469 | { 470 | // プレビュー(設定ダイアログ)の項目の描画 471 | status = L"第8話「あたしって、ほんとバカ」を記録しました。"; 472 | } 473 | 474 | pThis->m_pApp->ThemeDrawText( 475 | pInfo->pszStyle, 476 | pInfo->hdc, 477 | status.c_str(), 478 | pInfo->DrawRect, 479 | DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS, 480 | pInfo->Color); 481 | } 482 | 483 | return true; 484 | 485 | // ステータス項目の通知 486 | case TVTest::EVENT_STATUSITEM_NOTIFY: 487 | { 488 | switch (const auto *pInfo = reinterpret_cast(lParam1); pInfo->Event) 489 | { 490 | // 項目が作成された 491 | case TVTest::STATUS_ITEM_EVENT_CREATED: 492 | { 493 | TVTest::StatusItemSetInfo StatusItemSet{}; 494 | StatusItemSet.Size = sizeof StatusItemSet; 495 | StatusItemSet.Mask = TVTest::STATUS_ITEM_SET_INFO_MASK_STATE; 496 | StatusItemSet.ID = AnnictRecorderStatusItemId; 497 | StatusItemSet.StateMask = TVTest::STATUS_ITEM_STATE_VISIBLE; 498 | // プラグインが有効であれば項目を表示状態にする 499 | StatusItemSet.State = pThis->m_pApp->IsPluginEnabled() ? TVTest::STATUS_ITEM_STATE_VISIBLE : 0; 500 | 501 | pThis->m_pApp->SetStatusItem(&StatusItemSet); 502 | } 503 | 504 | return true; 505 | 506 | // 更新タイマー 507 | case TVTest::STATUS_ITEM_EVENT_UPDATETIMER: 508 | // true を返すと再描画される 509 | return true; 510 | 511 | default: 512 | return false; 513 | } 514 | } 515 | 516 | // ステータス項目のマウス操作 517 | case TVTest::EVENT_STATUSITEM_MOUSE: 518 | { 519 | switch (const auto pInfo = reinterpret_cast(lParam1); pInfo->Action) 520 | { 521 | // マウスの左ボタン 522 | case TVTest::STATUS_ITEM_MOUSE_ACTION_LDOWN: 523 | { 524 | if (pThis->m_lastRecordResult.url.has_value()) 525 | { 526 | ShellExecute(nullptr, nullptr, pThis->m_lastRecordResult.url.value().c_str(), nullptr, nullptr, SW_SHOW); 527 | } 528 | else 529 | { 530 | pThis->m_config.Enabled = !pThis->m_config.Enabled; 531 | WritePrivateProfileInt(L"Record", L"Enabled", pThis->m_config.Enabled, pThis->m_iniFileName); 532 | 533 | std::thread( 534 | [pThis] 535 | { pThis->CheckCurrentProgram(); }) 536 | .detach(); 537 | } 538 | 539 | return true; 540 | } 541 | case TVTest::STATUS_ITEM_MOUSE_ACTION_RDOWN: 542 | { 543 | std::thread( 544 | [pThis] 545 | { 546 | auto bitmap = pThis->m_pApp->CaptureImage(); 547 | if (bitmap != nullptr) 548 | { 549 | const auto result = Capture::ConvertToPng(bitmap); 550 | if (result.has_value()) 551 | { 552 | const auto json = nlohmann::json( 553 | { 554 | {"content", ""}, 555 | // {"nonce", ""}, 556 | {"channel_id", pThis->m_config.DiscordChannelId}, 557 | {"type", 0}, 558 | {"sticker_ids", nlohmann::json::array()}, 559 | { 560 | "attachments", 561 | nlohmann::json::array( 562 | { 563 | { 564 | {"id", "0"}, 565 | {"filename", "unknown.png"}, 566 | }, 567 | }), 568 | }, 569 | }); 570 | 571 | const auto jsonContent = json.dump(); 572 | if (!pThis->m_config.DiscordDryRun) 573 | { 574 | try 575 | { 576 | const auto response = cpr::Post( 577 | cpr::Url{std::format("https://discord.com/api/v9/channels/{}/messages", pThis->m_config.DiscordChannelId)}, 578 | cpr::Header{ 579 | {"accept", "*/*"}, 580 | {"accept-language", "ja"}, 581 | {"authorization", pThis->m_config.DiscordToken}, 582 | {"origin", "https://discord.com"}, 583 | {"x-discord-locale", "ja"}, 584 | }, 585 | cpr::UserAgent{"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) discord/1.0.9005 Chrome/91.0.4472.164 Electron/13.6.6 Safari/537.36"}, 586 | cpr::Multipart{ 587 | { 588 | "files[0]", 589 | cpr::Buffer{ 590 | result.value().begin(), 591 | result.value().end(), 592 | "unknown.png", 593 | }, 594 | "image/png", 595 | }, 596 | {"payload_json", jsonContent, "application/json"}, 597 | }); 598 | 599 | PrintDebug(L"Status = {}", response.status_code); 600 | } 601 | catch (...) 602 | { 603 | } 604 | } 605 | } 606 | 607 | pThis->m_pApp->MemoryFree(bitmap); 608 | } 609 | }) 610 | .detach(); 611 | 612 | return true; 613 | } 614 | default: 615 | return false; 616 | } 617 | } 618 | 619 | default: 620 | return false; 621 | } 622 | } 623 | 624 | /* 625 | * ウィンドウプロシージャ 626 | * タイマー処理を行う 627 | */ 628 | LRESULT CALLBACK CAnnictRecorderPlugin::WndProc(const HWND hWnd, const UINT uMsg, const WPARAM wParam, const LPARAM lParam) 629 | { 630 | switch (uMsg) 631 | { 632 | case WM_CREATE: 633 | { 634 | auto *const pcs = reinterpret_cast(lParam); 635 | auto *pThis = static_cast(pcs->lpCreateParams); 636 | 637 | ::SetWindowLongPtr(hWnd, GWLP_USERDATA, reinterpret_cast(pThis)); 638 | SetTimer(hWnd, AnnictRecorderTimerId, AnnictRecorderTimerIntervalMs, nullptr); 639 | } 640 | 641 | return true; 642 | 643 | case WM_TIMER: 644 | if (wParam == AnnictRecorderTimerId) 645 | { 646 | auto *pThis = reinterpret_cast(::GetWindowLongPtr(hWnd, GWLP_USERDATA)); 647 | 648 | std::thread( 649 | [pThis] 650 | { pThis->CheckCurrentProgram(); }) 651 | .detach(); 652 | } 653 | 654 | return false; 655 | 656 | default: 657 | return ::DefWindowProc(hWnd, uMsg, wParam, lParam); 658 | } 659 | } 660 | -------------------------------------------------------------------------------- /AnnictRecorder/SayaApi.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SlashNephy/TVTestAnnictRecorder/1e358ee2959442f2f4bc06434608c0fbadece3fe/AnnictRecorder/SayaApi.h -------------------------------------------------------------------------------- /AnnictRecorder/SyoboCalApi.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | #include "Common.h" 6 | #include "Debug.h" 7 | #include "Utils.h" 8 | 9 | namespace SyoboCal 10 | { 11 | struct LookupProgramResult 12 | { 13 | uint32_t titleId; 14 | float_t countStart; 15 | float_t countEnd; 16 | bool isFirstEpisode; 17 | bool isLastEpisode; 18 | std::optional subTitle; 19 | }; 20 | 21 | static std::vector LookupProgram(const SYSTEMTIME &start, const DWORD seconds, const int chId) 22 | { 23 | const auto stTime = std::format("{:04d}{:02d}{:02d}_{:02d}{:02d}{:02d}-", start.wYear, start.wMonth, start.wDay, start.wHour, start.wMinute, start.wSecond); 24 | const auto endTimestamp = SystemTime2Timet(start) + seconds; 25 | tm end{}; 26 | localtime_s(&end, &endTimestamp); 27 | const auto range = std::format("{}{:04d}{:02d}{:02d}_{:02d}{:02d}{:02d}", stTime, end.tm_year + 1900, end.tm_mon + 1, end.tm_mday, end.tm_hour, end.tm_min, end.tm_sec); 28 | 29 | const auto response = cpr::Get( 30 | cpr::Url{"https://cal.syoboi.jp/db.php"}, 31 | cpr::Parameters{ 32 | {"Command", "ProgLookup"}, 33 | {"ChID", std::to_string(chId)}, 34 | {"StTime", stTime}, 35 | {"Range", range}, 36 | {"JOIN", "SubTitles"}, 37 | }, 38 | cpr::UserAgent{AnnictRecorderUserAgent}); 39 | 40 | PrintDebugW(L"ProgLookup", response.text); 41 | 42 | pugi::xml_document doc; 43 | if (!doc.load_string(response.text.c_str())) 44 | { 45 | return {}; 46 | } 47 | 48 | const auto items = doc.select_nodes("/ProgLookupResponse/ProgItems/ProgItem"); 49 | if (items.empty()) 50 | { 51 | return {}; 52 | } 53 | 54 | auto results = std::vector{}; 55 | for (auto &item : items) 56 | { 57 | const auto node = item.node(); 58 | // 削除されたエントリを無視 59 | if (strtol(node.child_value("Deleted"), nullptr, 10) > 0) 60 | { 61 | continue; 62 | } 63 | 64 | const auto titleId = strtol(node.child_value("TID"), nullptr, 10); 65 | 66 | const auto rawSTSubTitle = node.child("STSubTitle"); 67 | const auto stSubTitle = rawSTSubTitle.empty() ? std::nullopt : std::optional(std::string(rawSTSubTitle.child_value())); 68 | 69 | // https://docs.cal.syoboi.jp/spec/proginfo-flag/ 70 | const auto isFirstEpisode = (strtol(node.child_value("Flag"), nullptr, 10) & 2) > 0; 71 | const auto isLastEpisode = (strtol(node.child_value("Flag"), nullptr, 10) & 4) > 0; 72 | 73 | float_t countStart = 0; 74 | float_t countEnd = 0; 75 | 76 | // 複数話同時放送の場合 SubTitle フィールドに #[\d\.]+~#[\d\.]+ という形式で格納されている 77 | // マルチバイトな正規表現に難があるので wchar を経由する 78 | const auto subTitle = Multi2Wide(node.child_value("SubTitle")); 79 | const auto subTitleRegex = std::wregex(LR"(^#([\d\.]+)~#([\d\.]+)$)"); 80 | if (std::wcmatch match; std::regex_match(subTitle.c_str(), match, subTitleRegex)) 81 | { 82 | countStart = static_cast(_wtof(match[1].str().c_str())); 83 | countEnd = static_cast(_wtof(match[2].str().c_str())); 84 | } 85 | // 通常 (単話放送) 時 86 | else 87 | { 88 | const auto count = strtof(node.child_value("Count"), nullptr); 89 | 90 | countStart = count; 91 | countEnd = count; 92 | } 93 | 94 | PrintDebug(L"TID = {}, Count = {} ~ {}, SubTitle = {}", titleId, countStart, countEnd, 95 | stSubTitle.has_value()); 96 | PrintDebugW(L"SubTitle", stSubTitle.value_or("n/a")); 97 | 98 | results.push_back( 99 | { 100 | static_cast(titleId), 101 | countStart, 102 | countEnd, 103 | isFirstEpisode, 104 | isLastEpisode, 105 | stSubTitle, 106 | }); 107 | } 108 | 109 | return results; 110 | } 111 | 112 | static std::optional LookupTitle(const uint32_t tid) 113 | { 114 | const auto response = cpr::Get( 115 | cpr::Url{"https://cal.syoboi.jp/db.php"}, 116 | cpr::Parameters{ 117 | {"Command", "TitleLookup"}, 118 | {"TID", std::to_string(tid)}, 119 | }, 120 | cpr::UserAgent{AnnictRecorderUserAgent}); 121 | 122 | PrintDebugW(L"TitleLookup", response.text); 123 | 124 | pugi::xml_document doc; 125 | if (!doc.load_string(response.text.c_str())) 126 | { 127 | return std::nullopt; 128 | } 129 | 130 | const auto items = doc.select_nodes("/TitleLookupResponse/TitleItems/TitleItem"); 131 | if (items.size() != 1) 132 | { 133 | return std::nullopt; 134 | } 135 | 136 | const auto item = items.first(); 137 | const auto node = item.node(); 138 | return Multi2Wide(node.child_value("Title")); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /AnnictRecorder/Title.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | namespace Title 6 | { 7 | struct AtxTitleResult 8 | { 9 | bool found = false; 10 | std::wstring title{}; 11 | float_t countStart{}; 12 | float_t countEnd{}; 13 | }; 14 | 15 | static AtxTitleResult ExtractAtxTitle(const std::wstring &eventName) 16 | { 17 | const auto multipleEpisodeRegex = std::wregex(LR"(^(?:\[無\])?(.+)\s#([\d\.]+)[-・]#([\d\.]+).*$)"); 18 | if (std::wsmatch match; std::regex_match(eventName, match, multipleEpisodeRegex)) 19 | { 20 | return { 21 | true, 22 | match[1].str(), 23 | static_cast(_wtof(match[2].str().c_str())), 24 | static_cast(_wtof(match[3].str().c_str())), 25 | }; 26 | } 27 | 28 | const auto singleEpisodeRegex = std::wregex(LR"(^(?:\[無\])?(.+)\s#([\d\.]+).*$)"); 29 | if (std::wsmatch match; std::regex_match(eventName, match, singleEpisodeRegex)) 30 | { 31 | const auto start = static_cast(_wtof(match[2].str().c_str())); 32 | 33 | return { 34 | true, 35 | match[1].str(), 36 | start, 37 | start, 38 | }; 39 | } 40 | 41 | return {}; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /AnnictRecorder/TvtPlay.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | #pragma region TvtPlay 6 | // https://github.com/xtne6f/TvtPlay/blob/work/src/_getinfo_test.cpp 7 | 8 | constexpr auto WM_TVTP_GET_POSITION = WM_APP + 52; 9 | 10 | static BOOL CALLBACK 11 | 12 | FindTvtPlayFrameEnumProc(const HWND hwnd, const LPARAM lParam) 13 | { 14 | TCHAR className[32]; 15 | if (GetClassName(hwnd, className, _countof(className)) && !lstrcmp(className, L"TvtPlay Frame")) 16 | { 17 | *reinterpret_cast(lParam) = hwnd; 18 | return false; 19 | } 20 | 21 | return true; 22 | } 23 | 24 | static HWND FindTvtPlayFrame() 25 | { 26 | HWND hwnd = nullptr; 27 | EnumThreadWindows(GetCurrentThreadId(), FindTvtPlayFrameEnumProc, reinterpret_cast(&hwnd)); 28 | 29 | return hwnd; 30 | } 31 | 32 | static int GetTvtPlayPositionSec(const HWND hwnd) 33 | { 34 | return static_cast(SendMessage(hwnd, WM_TVTP_GET_POSITION, 0, 0)) / 1000; 35 | } 36 | 37 | #pragma endregion 38 | -------------------------------------------------------------------------------- /AnnictRecorder/Utils.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "pch.h" 4 | 5 | /* 6 | * SYSTEMTIME 構造体を time_t に変換する 7 | */ 8 | static time_t SystemTime2Timet(const SYSTEMTIME &st) 9 | { 10 | struct tm gm = { 11 | st.wSecond, 12 | st.wMinute, 13 | st.wHour, 14 | st.wDay, 15 | st.wMonth - 1, 16 | st.wYear - 1900, 17 | st.wDayOfWeek, 18 | 0, 19 | 0, 20 | }; 21 | 22 | return mktime(&gm); 23 | } 24 | 25 | /* 26 | * この番組がアニメジャンルであるかどうか判定する 27 | */ 28 | static bool IsAnimeGenre(const TVTest::EpgEventInfo &EpgEvent) 29 | { 30 | if (EpgEvent.ContentList == nullptr) 31 | { 32 | return false; 33 | } 34 | 35 | bool result = false; 36 | for (auto i = 0; i < EpgEvent.ContentListLength; i++) 37 | { 38 | // ReSharper disable once CppTooWideScope 39 | const auto [ContentNibbleLevel1, ContentNibbleLevel2, _, __] = EpgEvent.ContentList[i]; 40 | 41 | // 「アニメ」 or 「映画」→「アニメ」 42 | if (ContentNibbleLevel1 == 0x7 || (ContentNibbleLevel1 == 0x6 && ContentNibbleLevel2 == 0x2)) 43 | { 44 | result = true; 45 | break; 46 | } 47 | } 48 | 49 | return result; 50 | } 51 | 52 | /* 53 | * この番組が第1話であるかどうか判定する 54 | */ 55 | static bool IsFirstEpisode(const TVTest::ProgramInfo &Program) 56 | { 57 | return std::wstring(Program.pszEventName).find(L"[新]") != std::string::npos; 58 | } 59 | 60 | /* 61 | * この番組が最終話であるかどうか判定する 62 | */ 63 | static bool IsLastEpisode(const TVTest::ProgramInfo &Program) 64 | { 65 | return std::wstring(Program.pszEventName).find(L"[終]") != std::string::npos; 66 | } 67 | 68 | static std::string Wide2Multi(const std::wstring source) 69 | { 70 | // ロケールの設定 71 | setlocale(LC_ALL, ".utf8"); 72 | 73 | char buf[256]; 74 | wcstombs_s(nullptr, buf, source.c_str(), 255); 75 | 76 | return buf; 77 | } 78 | 79 | static std::wstring Multi2Wide(const std::string source) 80 | { 81 | // ロケールの設定 82 | setlocale(LC_ALL, ".utf8"); 83 | 84 | wchar_t buf[256]; 85 | mbstowcs_s(nullptr, buf, source.c_str(), 255); 86 | 87 | return buf; 88 | } 89 | 90 | template 91 | std::vector flatten(const std::vector> &v) 92 | { 93 | const auto totalSize = std::reduce( 94 | v.begin(), 95 | v.end(), 96 | static_cast(0), 97 | [](const size_t acc, const std::vector &vector) 98 | { 99 | return acc + vector.size(); 100 | }); 101 | 102 | std::vector result; 103 | result.reserve(totalSize); 104 | for (const auto &sub : v) 105 | { 106 | result.insert(result.end(), sub.begin(), sub.end()); 107 | } 108 | 109 | return result; 110 | } 111 | 112 | #pragma region NicoJK 113 | // https://github.com/xtne6f/NicoJK/blob/83e7212b8cf4dfd50ac38d71ff1bb2b57c997318/Util.cpp#L45 114 | 115 | // 必要なバッファを確保してGetPrivateProfileSection()を呼ぶ 116 | static std::vector GetPrivateProfileSectionBuffer(const LPCTSTR lpAppName, const LPCTSTR lpFileName) 117 | { 118 | std::vector buf(4096); 119 | 120 | for (;;) 121 | { 122 | const size_t len = GetPrivateProfileSection(lpAppName, buf.data(), static_cast(buf.size()), lpFileName); 123 | if (len < buf.size() - 2) 124 | { 125 | buf.resize(len + 1); 126 | break; 127 | } 128 | 129 | buf.resize(buf.size() * 2); 130 | } 131 | 132 | return buf; 133 | } 134 | 135 | // GetPrivateProfileSection()で取得したバッファから、キーに対応する文字列を取得する 136 | static void GetBufferedProfileString(LPCTSTR lpBuff, const LPCTSTR lpKeyName, const LPCTSTR lpDefault, const LPTSTR lpReturnedString, const DWORD nSize) 137 | { 138 | const size_t nKeyLen = wcslen(lpKeyName); 139 | 140 | while (*lpBuff) 141 | { 142 | const size_t nLen = wcslen(lpBuff); 143 | 144 | if (!_wcsnicmp(lpBuff, lpKeyName, nKeyLen) && lpBuff[nKeyLen] == L'=') 145 | { 146 | if ((lpBuff[nKeyLen + 1] == L'\'' || lpBuff[nKeyLen + 1] == L'"') && nLen >= nKeyLen + 3 && 147 | lpBuff[nKeyLen + 1] == lpBuff[nLen - 1]) 148 | { 149 | wcsncpy_s(lpReturnedString, nSize, lpBuff + nKeyLen + 2, 150 | min(nLen - nKeyLen - 3, static_cast(nSize - 1))); 151 | } 152 | else 153 | { 154 | wcsncpy_s(lpReturnedString, nSize, lpBuff + nKeyLen + 1, _TRUNCATE); 155 | } 156 | 157 | return; 158 | } 159 | 160 | lpBuff += nLen + 1; 161 | } 162 | 163 | wcsncpy_s(lpReturnedString, nSize, lpDefault, _TRUNCATE); 164 | } 165 | 166 | // GetPrivateProfileSection()で取得したバッファから、キーに対応する文字列を std::wstring で取得する 167 | static std::wstring GetBufferedProfileToString(LPCTSTR lpBuff, const LPCTSTR lpKeyName, const LPCTSTR lpDefault) 168 | { 169 | const size_t nKeyLen = wcslen(lpKeyName); 170 | 171 | while (*lpBuff) 172 | { 173 | const size_t nLen = wcslen(lpBuff); 174 | 175 | if (!_wcsnicmp(lpBuff, lpKeyName, nKeyLen) && lpBuff[nKeyLen] == L'=') 176 | { 177 | if ((lpBuff[nKeyLen + 1] == L'\'' || lpBuff[nKeyLen + 1] == L'"') && nLen >= nKeyLen + 3 && lpBuff[nKeyLen + 1] == lpBuff[nLen - 1]) 178 | { 179 | return std::wstring(lpBuff + nKeyLen + 2, nLen - nKeyLen - 3); 180 | } 181 | 182 | return std::wstring(lpBuff + nKeyLen + 1, nLen - nKeyLen - 1); 183 | } 184 | 185 | lpBuff += nLen + 1; 186 | } 187 | 188 | return lpDefault; 189 | } 190 | 191 | // GetPrivateProfileSection()で取得したバッファから、キーに対応する数値を取得する 192 | static int GetBufferedProfileInt(const LPCTSTR lpBuff, const LPCTSTR lpKeyName, int nDefault) 193 | { 194 | wchar_t sz[16]; 195 | GetBufferedProfileString(lpBuff, lpKeyName, L"", sz, _countof(sz)); 196 | wchar_t *endPtr; 197 | int nRet = wcstol(sz, &endPtr, 10); 198 | 199 | return endPtr == sz ? nDefault : nRet; 200 | } 201 | 202 | static void 203 | WritePrivateProfileInt(const LPCWSTR lpAppName, const LPCTSTR lpKeyName, const int value, const LPCTSTR lpFileName) 204 | { 205 | wchar_t sz[16]; 206 | swprintf_s(sz, L"%d", value); 207 | WritePrivateProfileString(lpAppName, lpKeyName, sz, lpFileName); 208 | } 209 | 210 | #pragma region endregion 211 | -------------------------------------------------------------------------------- /AnnictRecorder/pch.h: -------------------------------------------------------------------------------- 1 | // pch.h: プリコンパイル済みヘッダー ファイルです。 2 | // 次のファイルは、その後のビルドのビルド パフォーマンスを向上させるため 1 回だけコンパイルされます。 3 | // コード補完や多くのコード参照機能などの IntelliSense パフォーマンスにも影響します。 4 | // ただし、ここに一覧表示されているファイルは、ビルド間でいずれかが更新されると、すべてが再コンパイルされます。 5 | // 頻繁に更新するファイルをここに追加しないでください。追加すると、パフォーマンス上の利点がなくなります。 6 | 7 | #pragma once 8 | 9 | // Windows ヘッダーからほとんど使用されていない部分を除外する 10 | #define WIN32_LEAN_AND_MEAN 11 | 12 | #include 13 | 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | #include 24 | #include 25 | #include 26 | 27 | #include 28 | #pragma comment(lib, "shlwapi.lib") 29 | 30 | #include "cpr/cpr.h" 31 | #pragma comment(lib, "cpr.lib") 32 | 33 | #include "yaml-cpp/yaml.h" 34 | #ifdef _DEBUG 35 | #pragma comment(lib, "yaml-cppd.lib") 36 | #else 37 | #pragma comment(lib, "yaml-cpp.lib") 38 | #endif 39 | 40 | #include "nlohmann/json.hpp" 41 | 42 | #include "pugixml.hpp" 43 | #pragma comment(lib, "pugixml.lib") 44 | 45 | #include "lodepng.h" 46 | #pragma comment(lib, "lodepng.lib") 47 | 48 | // プラグインをクラスとして実装 49 | #define TVTEST_PLUGIN_CLASS_IMPLEMENT 50 | 51 | #include "TVTestPlugin.h" 52 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "annict-recorder" 7 | version = "0.1.0" 8 | dependencies = [ 9 | "tvtest", 10 | ] 11 | 12 | [[package]] 13 | name = "enumflags2" 14 | version = "0.7.5" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "e75d4cd21b95383444831539909fbb14b9dc3fdceb2a6f5d36577329a1f55ccb" 17 | dependencies = [ 18 | "enumflags2_derive", 19 | ] 20 | 21 | [[package]] 22 | name = "enumflags2_derive" 23 | version = "0.7.4" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "f58dc3c5e468259f19f2d46304a6b28f1c3d034442e14b322d2b850e36f6d5ae" 26 | dependencies = [ 27 | "proc-macro2", 28 | "quote", 29 | "syn", 30 | ] 31 | 32 | [[package]] 33 | name = "num_enum" 34 | version = "0.5.7" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "cf5395665662ef45796a4ff5486c5d41d29e0c09640af4c5f17fd94ee2c119c9" 37 | dependencies = [ 38 | "num_enum_derive", 39 | ] 40 | 41 | [[package]] 42 | name = "num_enum_derive" 43 | version = "0.5.7" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "3b0498641e53dd6ac1a4f22547548caa6864cc4933784319cd1775271c5a46ce" 46 | dependencies = [ 47 | "proc-macro-crate", 48 | "proc-macro2", 49 | "quote", 50 | "syn", 51 | ] 52 | 53 | [[package]] 54 | name = "proc-macro-crate" 55 | version = "1.1.3" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "e17d47ce914bf4de440332250b0edd23ce48c005f59fab39d3335866b114f11a" 58 | dependencies = [ 59 | "thiserror", 60 | "toml", 61 | ] 62 | 63 | [[package]] 64 | name = "proc-macro2" 65 | version = "1.0.37" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "ec757218438d5fda206afc041538b2f6d889286160d649a86a24d37e1235afd1" 68 | dependencies = [ 69 | "unicode-xid", 70 | ] 71 | 72 | [[package]] 73 | name = "quote" 74 | version = "1.0.18" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" 77 | dependencies = [ 78 | "proc-macro2", 79 | ] 80 | 81 | [[package]] 82 | name = "serde" 83 | version = "1.0.136" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" 86 | 87 | [[package]] 88 | name = "syn" 89 | version = "1.0.91" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "b683b2b825c8eef438b77c36a06dc262294da3d5a5813fac20da149241dcd44d" 92 | dependencies = [ 93 | "proc-macro2", 94 | "quote", 95 | "unicode-xid", 96 | ] 97 | 98 | [[package]] 99 | name = "thiserror" 100 | version = "1.0.30" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" 103 | dependencies = [ 104 | "thiserror-impl", 105 | ] 106 | 107 | [[package]] 108 | name = "thiserror-impl" 109 | version = "1.0.30" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" 112 | dependencies = [ 113 | "proc-macro2", 114 | "quote", 115 | "syn", 116 | ] 117 | 118 | [[package]] 119 | name = "toml" 120 | version = "0.5.9" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" 123 | dependencies = [ 124 | "serde", 125 | ] 126 | 127 | [[package]] 128 | name = "tvtest" 129 | version = "0.1.0" 130 | dependencies = [ 131 | "enumflags2", 132 | "num_enum", 133 | "windows", 134 | ] 135 | 136 | [[package]] 137 | name = "unicode-xid" 138 | version = "0.2.2" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 141 | 142 | [[package]] 143 | name = "windows" 144 | version = "0.35.0" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "08746b4b7ac95f708b3cccceb97b7f9a21a8916dd47fc99b0e6aaf7208f26fd7" 147 | dependencies = [ 148 | "windows_aarch64_msvc", 149 | "windows_i686_gnu", 150 | "windows_i686_msvc", 151 | "windows_x86_64_gnu", 152 | "windows_x86_64_msvc", 153 | ] 154 | 155 | [[package]] 156 | name = "windows_aarch64_msvc" 157 | version = "0.35.0" 158 | source = "registry+https://github.com/rust-lang/crates.io-index" 159 | checksum = "db3bc5134e8ce0da5d64dcec3529793f1d33aee5a51fc2b4662e0f881dd463e6" 160 | 161 | [[package]] 162 | name = "windows_i686_gnu" 163 | version = "0.35.0" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "0343a6f35bf43a07b009b8591b78b10ea03de86b06f48e28c96206cd0f453b50" 166 | 167 | [[package]] 168 | name = "windows_i686_msvc" 169 | version = "0.35.0" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "1acdcbf4ca63d8e7a501be86fee744347186275ec2754d129ddeab7a1e3a02e4" 172 | 173 | [[package]] 174 | name = "windows_x86_64_gnu" 175 | version = "0.35.0" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "893c0924c5a990ec73cd2264d1c0cba1773a929e1a3f5dbccffd769f8c4edebb" 178 | 179 | [[package]] 180 | name = "windows_x86_64_msvc" 181 | version = "0.35.0" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "a29bd61f32889c822c99a8fdf2e93378bd2fae4d7efd2693fab09fcaaf7eff4b" 184 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "annict-recorder" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | tvtest = { path = "Thirdparty/TVTestSDK-rs/sdk" } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nep 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TVTestAnnictRecorder 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/SlashNephy/TVTestAnnictRecorder/build-cpp.yml?style=flat-square)](https://github.com/SlashNephy/TVTestAnnictRecorder/actions) 4 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/SlashNephy/TVTestAnnictRecorder?style=flat-square)](https://github.com/SlashNephy/TVTestAnnictRecorder/releases) 5 | 6 | 📝 視聴したアニメを自動で [Annict](https://annict.com/) に記録する TVTest プラグイン (TVTest 0.9.0 以降が必要) 7 | 8 | [![statusbar.png](https://i.imgur.com/vZDneZO.png)](https://github.com/SlashNephy/TVTestAnnictRecorder) 9 | 10 | ## Features 11 | 12 | - Annict へ視聴したアニメの該当エピソードの記録を自動で行います。 13 | - [TvtPlay](https://github.com/xtne6f/TvtPlay) でファイル再生時にも動作します。 14 | - 指定した時間だけ視聴したときに記録を行います。(デフォルト値は放送時間の 20%) 15 | - [しょぼいカレンダー](https://cal.syoboi.jp/) に登録されている番組で機能します。(ほとんど網羅されています。) 16 | - しょぼいカレンダーにデータのない AT-X のリピート放送や, 一挙放送にも対応しています。 17 | - 番組に複数のエピソードがある場合 (2話同時放送等) すべてのエピソードに記録を行います。 18 | - ステータスバーに Annict への記録状況が通知され, クリックした際には該当の Annict のページに飛ぶようになっています。 19 | - 次のオプションがあります。 20 | - 記録と同時に Twitter や Facebook に投稿する。 21 | - 第1話 (または最終話以外のエピソード) を視聴した際に Annict での作品のステータスを「見てる」に変更する。 22 | - 最終話を視聴した際に Annict での作品のステータスを「見た」に変更する。 23 | - 既に「見た」になっている作品では「見てる」に変更しない。 24 | 25 | ## Limitations 26 | 27 | TVTestAnnictRecorder が記録を行うためには以下の条件を満たす必要があります。 28 | 29 | - [しょぼいカレンダー](https://cal.syoboi.jp) に放送局が登録されている 30 | - 一覧はしょぼいカレンダーの [登録チャンネル](https://cal.syoboi.jp/mng?Action=ShowChList) から確認できます。 31 | - 一覧にない場合 (放送局が在京キー局の系列局の場合) も問題ありません。 32 | - しょぼいカレンダーに放送時間が登録されている 33 | - 有志の方々がデータを登録されています。ありがとうございます。 34 | - AT-X の場合, リピート放送はしょぼいカレンダーに放送時間が登録されていません。その場合は番組名から作品名と話数を抽出します。Annict 35 | に完全一致する作品名が見つかった場合にのみ記録が行われます。 36 | - しょぼいカレンダーと Annict で相互変換できる 37 | - [kawaiioverflow/arm](https://github.com/kawaiioverflow/arm) を利用しています。 38 | - 頻繁に更新されており, 最近のアニメなら間違いなく追加されています。ありがとうございます。 39 | - 代替ソースとして [SlashNephy/arm-supplementary](https://github.com/SlashNephy/arm-supplementary) を利用しています。 40 | 41 | ## Configuration 42 | 43 | `AnnictRecorder.ini` を編集してください。 44 | 45 | ```ini 46 | [Annict] 47 | ; Annict の個人用アクセストークンを設定します。トークンが設定されていない場合, 記録は行いません。 48 | ; https://annict.com/settings/tokens/new で発行できます。 49 | ; スコープを「読み取り + 書き込み」に設定する必要があります。 50 | ; Token=xxx 51 | 52 | [Record] 53 | ; 視聴を開始してから Annict に記録するまでの閾値 (%) 54 | ; 例えば 20 の場合, 番組の放送時間の 20% 視聴した場合に記録します。 55 | ; 途中から視聴した場合は視聴開始時間から 20% 分視聴した場合に記録します。 56 | ; TvtPlay で再生時にも同様な判定が行われます。 57 | ; デフォルト値: 20 58 | ; ThresholdPercent=20 59 | 60 | ; エピソードの記録と同時に Twitter に投稿するかどうか。 61 | ; Annict に登録されている Twitter アカウントが使用されます。 62 | ; デフォルト値: 0 63 | ; ShareOnTwitter=1 64 | 65 | ; エピソードの記録と同時に Facebook に投稿するかどうか。 66 | ; Annict に登録されている Facebook アカウントが使用されます。 67 | ; デフォルト値: 0 68 | ; ShareOnFacebook=1 69 | 70 | ; 第1話を視聴した際に作品のステータスを「見てる」に変更するかどうか。 71 | ; デフォルト値: 0 72 | ; SetWatchingStatusInFirstEpisode=1 73 | 74 | ; 第1話に限らず視聴した作品のステータスを「見てる」に変更するかどうか。(最終話を除く) 75 | ; デフォルト値: 0 76 | ; SetWatchingStatusInAnyEpisodes=1 77 | 78 | ; 最終話を視聴した際に作品のステータスを「見た」に変更するかどうか。 79 | ; デフォルト値: 0 80 | ; SetWatchedInLastEpisode=1 81 | 82 | ; 既に「見た」になっている作品では上記のオプションにより「見てる」に変更されないようにするかどうか。 83 | ; デフォルト値: 0 84 | ; SkipUpdateStatusIfAlreadyWatched=1 85 | 86 | ; 既に「見た」となっている作品でも [新] フラグが付いている番組を視聴した際に上記のオプションにより「見てる」に変更されるようにするかどうか。 87 | ; 分割2クールのように同じ作品であっても [終] のあとに [新] となるようなケースで有効です。 88 | ; デフォルト値: 0 89 | ; SetWatchingStatusOnFirstEpisodeEvenIfWatched=1 90 | 91 | ; 1 に変更すると実際に記録は行いません。デバッグ用です。 92 | ; デフォルト値: 0 93 | ; DryRun=1 94 | ``` 95 | 96 | ## Build 97 | 98 | 依存関係は [vcpkg](https://github.com/microsoft/vcpkg) で管理されています。 99 | 100 | ```bat 101 | vcpkg integrate install 102 | 103 | msbuild TVTestAnnictRecorder.sln -property:Configuration="Release" -property:Platform="x64" -m 104 | ``` 105 | 106 | ## Acknowledgements 107 | 108 | TVTestAnnictRecorder は以下のサービス, OSS プロジェクトを利用しています。ありがとうございます。 109 | 110 | - [しょぼいカレンダー](https://cal.syoboi.jp) 111 | - [Annict](https://annict.com) 112 | - [kawaiioverflow/arm](https://github.com/kawaiioverflow/arm) 113 | - [xtne6f/TvtPlay](https://github.com/xtne6f/TvtPlay) 114 | - [whoshuu/cpr](https://github.com/whoshuu/cpr) 115 | - [jbeder/yaml-cpp](https://github.com/jbeder/yaml-cpp) 116 | - [nlohmann/json](https://github.com/nlohmann/json) 117 | - [zeux/pugixml](https://github.com/zeux/pugixml) 118 | - [microsoft/vcpkg](https://github.com/microsoft/vcpkg) 119 | 120 | ## License 121 | 122 | TVTestAnnictRecorder is provided under the MIT license. 123 | -------------------------------------------------------------------------------- /TVTestAnnictRecorder.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.31313.79 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "AnnictRecorder", "AnnictRecorder\AnnictRecorder.vcxproj", "{918C47C2-F57D-48B7-A5A8-0F1806025B05}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|x64 = Debug|x64 11 | Debug|x86 = Debug|x86 12 | Release|x64 = Release|x64 13 | Release|x86 = Release|x86 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {918C47C2-F57D-48B7-A5A8-0F1806025B05}.Debug|x64.ActiveCfg = Debug|x64 17 | {918C47C2-F57D-48B7-A5A8-0F1806025B05}.Debug|x64.Build.0 = Debug|x64 18 | {918C47C2-F57D-48B7-A5A8-0F1806025B05}.Debug|x86.ActiveCfg = Debug|Win32 19 | {918C47C2-F57D-48B7-A5A8-0F1806025B05}.Debug|x86.Build.0 = Debug|Win32 20 | {918C47C2-F57D-48B7-A5A8-0F1806025B05}.Release|x64.ActiveCfg = Release|x64 21 | {918C47C2-F57D-48B7-A5A8-0F1806025B05}.Release|x64.Build.0 = Release|x64 22 | {918C47C2-F57D-48B7-A5A8-0F1806025B05}.Release|x86.ActiveCfg = Release|Win32 23 | {918C47C2-F57D-48B7-A5A8-0F1806025B05}.Release|x86.Build.0 = Release|Win32 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {6E3EC0F9-3A77-4CBC-9A68-067D190384CC} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /TVTestAnnictRecorder.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | True 3 | True 4 | True 5 | True 6 | True 7 | True 8 | True 9 | True 10 | True 11 | True -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>SlashNephy/.github:renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | use std::sync::atomic::{AtomicI32, Ordering}; 3 | use tvtest::{export_plugin, TVTestEventHandler, TVTestPlugin}; 4 | use tvtest::api::PluginApi; 5 | use tvtest::enumflags2::BitFlag; 6 | use tvtest::plugin::{PluginFlag, PluginInfo, PluginKind}; 7 | use tvtest::version::{DEFAULT_API_VERSION, Version}; 8 | use tvtest::win32::{IntoRustString, UnsafeIntoRustString, WideStringPtr}; 9 | 10 | pub struct AnnictRecorderPlugin { 11 | api: PluginApi, 12 | } 13 | 14 | impl TVTestPlugin for AnnictRecorderPlugin { 15 | fn new(api: PluginApi) -> Self { 16 | AnnictRecorderPlugin { 17 | api, 18 | } 19 | } 20 | 21 | fn get_api_version() -> Version { 22 | DEFAULT_API_VERSION 23 | } 24 | 25 | fn get_info() -> PluginInfo { 26 | PluginInfo { 27 | kind: PluginKind::Normal, 28 | flags: PluginFlag::empty(), 29 | name: "Example".into(), 30 | copyright: "© 2021 @SlashNephy ".into(), 31 | description: "TVTestSDK-rs のサンプルプラグイン".into(), 32 | } 33 | } 34 | 35 | fn initialize(&self) -> bool { 36 | true 37 | } 38 | 39 | fn finalize(&self) -> bool { 40 | true 41 | } 42 | } 43 | 44 | impl TVTestEventHandler for AnnictRecorderPlugin {} 45 | 46 | export_plugin!(AnnictRecorderPlugin); 47 | -------------------------------------------------------------------------------- /vcpkg.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", 3 | "name": "tvtest-annict-recorder-plugin", 4 | "version-string": "0.0.1", 5 | "port-version": 6, 6 | "homepage": "https://github.com/SlashNephy/TVTestAnnictRecorder", 7 | "builtin-baseline": "962e5e39f8a25f42522f51fffc574e05a3efd26b", 8 | "dependencies": [ 9 | { 10 | "name": "cpr", 11 | "version>=": "1.10.2+3" 12 | }, 13 | { 14 | "name": "yaml-cpp", 15 | "version>=": "0.7.0#1" 16 | }, 17 | { 18 | "name": "nlohmann-json", 19 | "version>=": "3.11.2" 20 | }, 21 | { 22 | "name": "pugixml", 23 | "version>=": "1.13.0" 24 | }, 25 | { 26 | "name": "lodepng", 27 | "version>=": "2021-12-04#1" 28 | } 29 | ] 30 | } 31 | --------------------------------------------------------------------------------