Compare commits

..

156 Commits
10.10 ... 10.8

Author SHA1 Message Date
TwistedUmbrellaX
54ade16186
Titles only for old versions 2024-11-26 13:14:49 -05:00
TwistedUmbrellaX
efae66a541 Link to the wiki sections directly 2024-11-03 10:11:16 -05:00
TwistedUmbrellaX
e646ea874a Revert "Temporary workaround"
This reverts commit 7cd1b705fc9edc084d5cd7eb8645a4bfd1612c90.
2024-10-29 16:24:14 -04:00
TwistedUmbrellaX
7cd1b705fc Temporary workaround 2024-10-29 14:52:57 -04:00
TwistedUmbrellaX
9162dbcc76 fix a typo 2024-10-28 20:52:41 -04:00
TwistedUmbrellaX
e75bf05819 Update ignore for alternate IDE 2024-10-27 21:15:53 -04:00
TwistedUmbrellaX
282c975a0b
Restart versioning for manifest 2024-10-26 14:13:52 -04:00
github-actions[bot]
4e7d325fc2 release v0.10.8.1 2024-10-26 18:11:16 +00:00
TwistedUmbrellaX
a666c61bbb
Realign versioning with jellyfin
0.x.x.x indicates `ConfusedPolarBear.Plugin.IntroSkipper`, while 1.x.x.x indicates the conversion to `IntroSkipper`
2024-10-26 14:05:59 -04:00
TwistedUmbrellaX
44d5a24e03 JMP / Chrome break inline strong tags 2024-10-25 22:14:31 -04:00
TwistedUmbrellaX
c52ad5dc05 Fix identifier and name 2024-10-25 14:35:56 -04:00
TwistedUmbrellaX
a606203231 Implement SPDX GLPv3.0 LICENSE 2024-10-25 13:39:12 -04:00
TwistedUmbrellaX
2adff3590a Update LICENSE for Intro-Skipper 2024-10-25 12:23:43 -04:00
TwistedUmbrellaX
c43ad03635 switch to our new domain
Co-Authored-By: Kilian von Pflugk <github@jumoog.io>
2024-10-24 22:33:41 -04:00
TwistedUmbrellaX
3fc11c7e02 Update README.md 2024-10-24 22:30:07 -04:00
github-actions[bot]
cb08420e8d release v0.1.18.1 2024-10-12 21:11:39 +00:00
TwistedUmbrellaX
2678c69e38 Make list compatible with 10.8 (maybe) 2024-10-12 11:27:11 -04:00
Kilian von Pflugk
c0513b1f22 migrate own repo url (#345) 2024-10-12 11:13:52 -04:00
Kilian von Pflugk
89ae0b41e3
manifest.json 2024-10-12 14:38:57 +02:00
Kilian von Pflugk
3ffb71cd4c
switch to new url 2024-10-12 14:18:55 +02:00
Kilian von Pflugk
08d1e6e0b5 ci: always use the latest Node LTS 2024-10-06 07:33:09 -04:00
rlauuzo
507bb64d45 Minify Configpage (#324)
* minify

* update node

* Update ConfusedPolarBear.Plugin.IntroSkipper.csproj

* fix

* minimize inject and visualizer js

* also add to build

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-10-06 07:32:48 -04:00
Foowy
9922f97bc8 Corrected spelling in Manage Fingerprints (#232) 2024-10-06 07:26:09 -04:00
TwistedUmbrellaX
f8dba7f005
Update README.md Discord 2024-09-24 17:22:21 -04:00
TwistedUmbrellaX
8bf31b6ad1
Switch to abbreviated TOC on 10.8 2024-09-24 17:21:27 -04:00
rlauuzo
f444ca018b Use configuration Release (#270) 2024-09-16 07:35:41 -04:00
Kilian von Pflugk
8a27f15fe1 extend regular expression for ending credit chapters 2024-09-16 07:33:31 -04:00
TwistedUmbrellaX
98c9c03f28 Restore manifest for quick access 2024-09-16 06:54:49 -04:00
TwistedUmbrellaX
d2d0714e66
Fix broken link for Mac extras 2024-09-10 16:53:15 -04:00
TwistedUmbrellaX
c397a180e8
Include the manifest URL
Until there is a better place for it.
2024-09-10 16:46:01 -04:00
TwistedUmbrellaX
6d9cacd2ec
Update README.md 2024-09-10 16:44:01 -04:00
TwistedUmbrellaX
e619114ec3 Convert polixy declaration for 10.8 2024-08-08 04:58:50 -04:00
Kilian von Pflugk
71f1148f07 check if episodes exists and update function docs 2024-08-08 04:51:57 -04:00
Kilian von Pflugk
3117db57c5 only return timestamps for episodes 2024-08-08 04:51:37 -04:00
rlauuzo
c2966d81e8 Update visualizer.js 2024-08-08 04:49:18 -04:00
Kilian von Pflugk
0d4bb295cc add option to edit intros/credits (#223)
new API Endpoint: ´Episode/{Id}/Timestamps´

HTTP POST to update Timestamps.
HTTP GET to receive the unmodified Timestamps. If Intro/Outro not exists the API returns 0
2024-08-08 04:48:01 -04:00
Kilian von Pflugk
252e30cde0 AutoSkip: allow to adjust the intro/credit playback duration (#238)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: rlauuzo <46294892+rlauuzo@users.noreply.github.com>
Co-authored-by: CasuallyFilthy <adamsdeloach@yahoo.com>
2024-08-08 04:36:33 -04:00
rlauuzo
d351a6225e Update FFmpegWrapper.cs 2024-06-14 11:09:03 -04:00
TwistedUmbrellaX
b101d8f8a4 Dispose of ffmpeg if terminated 2024-06-14 03:30:30 -04:00
dependabot[bot]
63b27f0292 ci(deps): bump github/codeql-action from 3.25.7 to 3.25.8 (#194)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-07 09:41:45 -04:00
Kilian von Pflugk
a679327185 point to the correct bug 2024-06-07 09:40:47 -04:00
TwistedUmbrellaX
7035641c55 Add a few common issues to the forms 2024-06-07 09:40:34 -04:00
TwistedUmbrellaX
ec451acd1d Typo from typing on an iPad 2024-06-07 09:40:25 -04:00
TwistedUmbrellaX
cd2d6aeee9 Descriptions for logging stuff 2024-06-07 09:40:15 -04:00
Kilian von Pflugk
78365d58c3 the official Android app has a skip button 2024-06-07 09:40:04 -04:00
Kilian von Pflugk
8503b3a9cc add official applications to the declaration
we are often asked about this, so add it to the readme file
2024-06-07 09:39:48 -04:00
Kilian von Pflugk
1516a0fd9a rephrase cache fingerprints warning 2024-06-07 09:39:36 -04:00
Kilian von Pflugk
6aee3a755a auto increment Version 2024-05-27 15:12:56 -04:00
Kilian von Pflugk
2ca64a974c only validate new release 2024-05-27 15:11:33 -04:00
rlauuzo
aa1faf0f6f check if timestamps are invalid (#177)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-05-27 15:11:00 -04:00
rlauuzo
1f2088453b Meet minimum credit requirement 2024-05-27 15:10:36 -04:00
Kilian von Pflugk
58f2c0ce0f test adjust values for blackframes 2024-05-27 15:10:15 -04:00
Kilian von Pflugk
7fbffc4738 adjust unit test values after switching from 0.128 to 0.1238 2024-05-27 15:10:06 -04:00
Kilian von Pflugk
766ba2daa0 bump version during release 2024-05-27 15:09:41 -04:00
TwistedUmbrellaX
87a5148610 Restore 10.8.4.0 as the jelly version 2024-05-27 15:09:24 -04:00
Kilian von Pflugk
5552cd7ff1 validate new version/manifest and update on release 2024-05-27 15:08:29 -04:00
Kilian von Pflugk
51028d9efb output json for manifest 2024-05-27 15:07:20 -04:00
rlauuzo
cc7a5f9639 Update Css (#167) 2024-05-27 15:06:14 -04:00
Cloud9Developer
8cd7ff8993 Fixed Skip button not displaying
when jellyfin context root/path is set (#165)
2024-05-17 07:34:38 -04:00
TwistedUmbrellaX
bee1026738 Movin on up (movin on up) (#166) 2024-05-17 07:34:38 -04:00
TwistedUmbrellaX
0709628e83 ci(deps): bump actions/upload-artifact from 4.3.1 to 4.3.3 2024-05-17 07:34:38 -04:00
TwistedUmbrellaX
f1972f56e5 Improve the PR artifact structure 2024-05-17 07:34:38 -04:00
Kilian von Pflugk
20df7ebff6 Update bug_report_form.yml
add IMDb Id to bugreport template
2024-05-17 07:34:38 -04:00
dependabot[bot]
656b727b33 ci(deps): bump softprops/action-gh-release from 2.0.4 to 2.0.5 (#154)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.4 to 2.0.5.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2.0.4...v2.0.5)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-17 07:34:38 -04:00
dependabot[bot]
ce1f59d3b5 ci(deps): bump github/codeql-action from 3.25.3 to 3.25.4 (#155)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-17 07:34:37 -04:00
rlauuzo
57249bfe87 change chromaprint offset, reorder analyzers, improve prompt timing (#153) 2024-05-17 07:34:37 -04:00
Kilian von Pflugk
e451a214ff check if XML file exists 2024-05-17 07:34:37 -04:00
Kilian von Pflugk
db3b23cd36 Update manifest url 2024-05-17 07:34:37 -04:00
Kilian von Pflugk
7c349b2431 Update 10.8 releases 2024-05-17 07:34:37 -04:00
rlauuzo
cfba1ed39b Ensure no duplicate Ids are permitted. (#147) 2024-05-17 07:34:37 -04:00
rlauuzo
ce7fa682d8 Only add episodes without intro/credit to episodesWithoutIntros (#145) 2024-05-17 07:34:37 -04:00
rlauuzo
4669a4fc9b fix negative index 2024-05-17 07:34:37 -04:00
rlauuzo
e58e74b5d4 find the first credits chapter marker (#138) 2024-05-17 07:34:36 -04:00
Kilian von Pflugk
d41ec2ae4d v0.1.18.0 2024-05-17 07:32:54 -04:00
rlauuzo
d1a4cacb8b Change GetItem to return null to handle nullable LibraryManager (#137) 2024-05-01 08:10:46 -04:00
rlauuzo
fd3cf0c075 adaptive black frame search range (#132) 2024-05-01 08:10:45 -04:00
rlauuzo
8053ed6c04 Use reversed fingerprints for credits 2024-05-01 08:10:45 -04:00
TwistedUmbrellaX
f2eae80c54
Merge pull request #128 from RepoDevil/10.8
Attempt to verify queue when finishing
2024-04-26 19:43:01 -04:00
TwistedUmbrellaX
d53e443925 Update the queue when it changed 2024-04-25 23:11:57 -04:00
TwistedUmbrellaX
28673a807a Use the minimum credit in chapter 2024-04-25 23:05:19 -04:00
TwistedUmbrellaX
85ea6de26c
Update BaseItemAnalyzerTask.cs 2024-04-23 18:26:31 -04:00
TwistedUmbrellaX
c106811e8e Attempt to verify queue when finishing 2024-04-22 11:00:19 -04:00
rlauuzo
26db24d187 Update BaseItemAnalyzerTask.cs 2024-04-20 09:27:27 -04:00
TwistedUmbrellaX
10ec9eff83 Prevent a possible runtime mistake
Technically, the instance is already validated by intros, but if they get skipped for some reason, the variable might lose its confidence. Automated code correction does things like that a lot.
2024-04-20 09:26:05 -04:00
Kilian von Pflugk
489e39033d remove redundant qualifier 2024-04-20 09:26:05 -04:00
rlauu
3acf041087 Update BaseItemAnalyzerTask.cs 2024-04-20 09:26:05 -04:00
rlauu
7800b48193 more progress bar fixes 2024-04-20 09:26:05 -04:00
rlauu
250c8acfbd Update configPage.html 2024-04-20 09:26:05 -04:00
rlauu
8c80a6aff3 Mention Credits in Edl config 2024-04-20 09:26:05 -04:00
rlauu
3b4c4bf464 Bugfix-FFmpegWrapper 2024-04-20 08:37:42 +02:00
TwistedUmbrellaX
9abcf253a7 Ignores have to be applied by *event* 2024-04-19 21:45:37 -04:00
Jakob Tormalm
419273ecc6 Add animation on skipButton entry and exit 2024-04-19 14:51:23 -04:00
TwistedUmbrellaX
49fe896b18 Don't auto build for documentation 2024-04-19 14:49:47 -04:00
rlauu
33c2cce6fd fix progress bar 2024-04-19 12:50:35 -04:00
rlauu
4556524f7e Refactor BaseItemAnalyzerTask and add Edl support for credits 2024-04-19 12:49:58 -04:00
TwistedUmbrellaX
46d446718f Clear restrictions when overriding auto 2024-04-16 14:01:48 -04:00
TwistedUmbrellaX
f36a2d9bec Remove refs to AnalyzerTaskIsRunning 2024-04-16 13:52:15 -04:00
rlauuzo
34c4e6eaab
Update Entrypoint.cs 2024-04-16 19:17:35 +02:00
rlauuzo
0c967180f6 Change task from being canceled to waiting (#118) 2024-04-16 13:06:00 -04:00
TwistedUmbrellaX
0b77809e9f
Merge pull request #119 from RepoDevil/10.8
When a scan is already running, append it
2024-04-16 10:42:23 -04:00
Kilian von Pflugk
94116b77a5 only process lines that start with "[Parsed_blackframe_"
There is no FFmpeg flag to hide metadata such as description
In our case, the metadata contained something that matched the regex.
2024-04-15 22:16:13 +02:00
TwistedUmbrellaX
75688772b1 Only shared should split progress bar 2024-04-14 10:49:28 -04:00
TwistedUmbrellaX
de60fd236f Still cancel automation for scheduled
The priority should starts at scheduled task (a full scan), but automatic scans will add items to them. Library scan is next, but again it should allow appending. Item scans should be last and get overridden by the other two.
2024-04-14 10:31:12 -04:00
TwistedUmbrellaX
3096f3fe6a When a scan is already running, append it 2024-04-14 10:31:12 -04:00
TwistedUmbrellaX
b1e94bd24c
Library scan should supersede add or modify (#117) 2024-04-14 09:36:27 -04:00
TwistedUmbrellaX
d35c337c28
Limit the scope of automatic scanning (#116)
* Add temporary scope limit for auto

* Fix a missing credits variable rename

* Add some extra padding to notes

* Only use path limits when filled in
2024-04-14 01:13:43 -04:00
TwistedUmbrellaX
445459afbb Credits are included here, too
It doesn't make sense to branch credits off into a section, but it's also proven that we need to be very clear in the wording.
2024-04-13 14:58:19 -04:00
TwistedUmbrellaX
54f69e7e09 Add options for adjusting credits length 2024-04-13 14:08:19 -04:00
TwistedUmbrellaX
de956c8081
Update PluginConfiguration.cs
Sets max duration to 10 minutes. The longest known credits to date are just under 9.5
2024-04-13 13:54:21 -04:00
TwistedUmbrellaX
ebb3b81d48 Forgot to click save on this one 2024-04-13 12:40:12 -04:00
TwistedUmbrellaX
177604e391 Clean up the copy pasta explosion 2024-04-13 12:38:55 -04:00
rlauu
0a9394b244 Add cancellation of automatic tasks from scheduled tasks 2024-04-13 17:41:02 +02:00
TwistedUmbrellaX
2e45949e0c Grant GitHub access to ...GitHub
Why shouldn't the least secure process be the one to verify the security?
2024-04-10 23:13:45 -04:00
TwistedUmbrellaX
9da5bfcfb0 Set 10.8 CodeQL to track 10.8 2024-04-10 22:55:23 -04:00
TwistedUmbrellaX
77d0523a5f Remove regularly scheduled runs 2024-04-10 22:46:04 -04:00
Kilian von Pflugk
344cc1b546
remove 10.9 warning from 10.8 branch 2024-04-10 21:17:28 +00:00
TwistedUmbrellaX
e8629a6be3 Switch to stable actions versions 2024-04-10 14:40:59 -04:00
TwistedUmbrellaX
a0419de97c Grant repo permission to CodeQL 2024-04-10 09:43:55 -04:00
TwistedUmbrellaX
9d8dc37aad Add a replacement CodeQL action 2024-04-10 09:42:38 -04:00
TwistedUmbrellaX
b2f17920c3 v0.1.17.0 - manifest 2024-04-10 09:15:42 -04:00
TwistedUmbrellaX
e8b4ba08a6 v0.1.17.0
Dump intermediate 0.1.16 releases
2024-04-10 09:15:41 -04:00
TwistedUmbrellaX
4a6b2a4de7 Fix typo in build output details 2024-04-10 09:15:41 -04:00
TwistedUmbrellaX
804f02c2de Fix an alignment issue in manifest 2024-04-10 09:15:41 -04:00
TwistedUmbrellaX
e085a8538e Fix the actions message filter 2024-04-10 09:15:41 -04:00
Kilian von Pflugk
04b60282af fix sourceUrl, make JSON valid 2024-04-10 09:15:41 -04:00
TwistedUmbrellaX
5f7cde8be1 v0.1.16.5 - manifest 2024-04-10 09:15:41 -04:00
TwistedUmbrellaX
1b7bce579f v0.1.16.5 2024-04-10 09:15:41 -04:00
TwistedUmbrellaX
42a2339978 Add a mirrored class to skip credits 2024-04-10 09:15:41 -04:00
TwistedUmbrellaX
d405ef9a52 Enable building 10.8 from 10.8 2024-04-10 09:15:41 -04:00
TwistedUmbrellaX
291c8cd716 Keep 10.8 manifest to 10.8 versions 2024-04-10 09:15:40 -04:00
rlauu
d5ac3aba8c Auto-Detection Configuration (#106)
* Update Entrypoint.cs

* Update Entrypoint.cs

* Update Entrypoint.cs

* Update Entrypoint.cs
2024-04-09 22:00:47 -04:00
TwistedUmbrellaX
6c30244b53 Print a copy paste manifest string 2024-04-04 09:39:14 -04:00
TwistedUmbrellaX
41d0dd3f30 Add proprietary tag for 10.8 branch 2024-04-04 09:26:32 -04:00
TwistedUmbrellaX
ab6c2de227 Fix some button text running together 2024-04-02 21:11:06 -04:00
Kilian von Pflugk
39534edb6c
add notes for 10.9 beta 2024-04-02 19:22:19 +00:00
TwistedUmbrellaX
de91f861f5 We want to keep the release notes 2024-03-31 16:00:25 -04:00
Kilian von Pflugk
c71f0d8d98
first 10.9 release (beta) 2024-03-31 15:03:18 +02:00
TwistedUmbrellaX
ca47492b9b Split progress bar for split tasks
This replaces the jarring effect of resetting the bar halfway through two concurrent scans.
2024-03-31 07:30:44 -04:00
TwistedUmbrellaX
9097addc50
Merge pull request #102 from rlauu/master
reset progress bar
2024-03-31 06:28:27 -04:00
rlauu
bfc47f4567
Update DetectIntrosCreditsTask.cs
reset progress
2024-03-31 07:47:39 +02:00
TwistedUmbrellaX
239e3a34fb Shorten the task explanations 2024-03-30 20:16:03 -04:00
TwistedUmbrellaX
3cc9a66990 Add back separate tasks as optional 2024-03-30 19:22:41 -04:00
rlauu
2e67a4fb5e add options to disable scans of either intros or credits 2024-03-30 18:04:54 -04:00
rlauu
3446c180aa Merge Scheduled Tasks 2024-03-30 18:04:17 -04:00
TwistedUmbrellaX
0b870c7db7 Let's stop logging every single step 2024-03-30 19:00:57 +01:00
Kilian von Pflugk
179711b930 Revert "Update README.md for 10.9"
This reverts commit 65c6f804a36e1516af1d7b69f93d9417f201bed4.
2024-03-30 19:00:57 +01:00
TwistedUmbrellaX
3bb605d125 All we need is the body support
One source can't write a body and the other can't replace the entire release. For now, we can chain them together.
2024-03-30 19:00:57 +01:00
rlauu
ded3c3d43a analyze when item is added to the server (#96) 2024-03-30 19:00:51 +01:00
TwistedUmbrellaX
78c05a7e29 Make the stats more accessible 2024-03-29 09:25:59 -04:00
TwistedUmbrellaX
9e7d0a74f0 No use optimizing conditionally
If a specific platform is broken and we'd need to check for that platform and not optimize, then it takes away what little gain everyone else had by checking.
2024-03-28 09:40:00 -04:00
TwistedUmbrellaX
e177919630
Merge pull request #92 from RepoDevil/master
Update README.md for 10.9
2024-03-27 12:39:34 -04:00
TwistedUmbrellaX
65c6f804a3
Update README.md for 10.9 2024-03-27 12:18:26 -04:00
TwistedUmbrellaX
cc35daedea v0.1.16.4 2024-03-26 16:46:10 -04:00
TwistedUmbrellaX
ccb3bbc683 Testing the new release actions 2024-03-26 16:43:22 -04:00
TwistedUmbrellaX
7892250dc2 Fix double negatives and woording 2024-03-26 16:37:23 -04:00
TwistedUmbrellaX
5e8681c44e Remove a possible action source
Doesn't provide the necessary support
2024-03-23 14:39:21 -04:00
139 changed files with 6273 additions and 7412 deletions

View File

@ -8,12 +8,12 @@ body:
attributes: attributes:
label: Self service debugging label: Self service debugging
description: | description: |
Jellyfin 10.9 is actively updated. Please make sure you are using the [latest](https://github.com/jellyfin/jellyfin/releases/latest) release. Jellyfin 10.9 is still being actively updated. Please make sure you are using the newest release
Many servers have permission issues that can be resolved with a few extra steps. Docker containers have known permission issues that can be resolved with a few extra steps.
If your skip button is not shown, please see [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible) before reporting. If your skip button is not shown, please see https://github.com/jumoog/intro-skipper/issues/104
options: options:
- label: I use Jellyfin 10.10.3 (or newer) and my permissions are correct - label: Jellyfin is updated and my permissions are correct (or I did not use Docker)
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
@ -44,7 +44,7 @@ body:
attributes: attributes:
label: Container image/tag or Jellyfin version label: Container image/tag or Jellyfin version
description: The container for Docker or Jellyfin version for a native install description: The container for Docker or Jellyfin version for a native install
placeholder: jellyfin/jellyfin:10.9.9, jellyfin-intro-skipper:latest, etc. placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
validations: validations:
required: true required: true
@ -65,7 +65,6 @@ body:
attributes: attributes:
label: Support Bundle label: Support Bundle
placeholder: go to Dashboard -> Plugins -> Intro Skipper -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here placeholder: go to Dashboard -> Plugins -> Intro Skipper -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here
render: shell
validations: validations:
required: true required: true
@ -73,4 +72,4 @@ body:
attributes: attributes:
label: Jellyfin logs label: Jellyfin logs
placeholder: Paste any relevant logs here placeholder: Paste any relevant logs here
render: shell

View File

@ -18,7 +18,7 @@ updates:
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: / directory: /
schedule: schedule:
interval: weekly interval: monthly
open-pull-requests-limit: 10 open-pull-requests-limit: 10
labels: labels:
- ci - ci

View File

@ -1,19 +1,25 @@
name: "Build Plugin" name: 'Build Plugin'
on: on:
push: push:
branches: branches: [ "10.8" ]
- '*' # Triggers on any branch push
paths-ignore: paths-ignore:
- "**/README.md" - '**/README.md'
- ".github/ISSUE_TEMPLATE/**" - '.github/ISSUE_TEMPLATE/**'
- "docs/**" - 'docs/**'
- "images/**" - 'images/**'
- "manifest.json" - 'manifest.json'
pull_request:
branches: [ "10.8" ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
permissions: permissions:
contents: write contents: write
packages: write
jobs: jobs:
build: build:
@ -22,63 +28,32 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Sanitize head_ref
run: |
# Get the branch name and sanitize it
SANITIZED_BRANCH_NAME=$(echo "${{ github.head_ref }}" | sed 's/[^a-zA-Z0-9.-]/_/g')
# Export it as an environment variable
echo "SANITIZED_BRANCH_NAME=$SANITIZED_BRANCH_NAME" >> $GITHUB_ENV
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Read version from VERSION.txt
id: read-version
run: |
MAIN_VERSION=$(cat VERSION.txt)
echo "MAIN_VERSION=${MAIN_VERSION}"
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
- name: Check for BETA file
id: check-beta
run: |
if [ -f "BETA" ]; then
echo "IS_BETA=true" >> $GITHUB_ENV
else
echo "IS_BETA=false" >> $GITHUB_ENV
fi
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 6.0.x
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "lts/*" node-version: 'lts/*'
- name: Install html-minifier-terser
run: npm install terser html-minifier-terser
- name: Minify HTML - name: Minify HTML
run: | run: |
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o IntroSkipper/Configuration/configPage.html IntroSkipper/Configuration/configPage.html npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --collapse-inline-tag-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
npx terser IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m
- name: Restore Beta dependencies
if: ${{env.IS_BETA == 'true' }}
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
dotnet tool install --global dotnet-outdated-tool
dotnet outdated -pre Always -u -inc Jellyfin
- name: Restore dependencies - name: Restore dependencies
if: ${{env.IS_BETA == 'false' }}
run: dotnet restore run: dotnet restore
- name: Embed version info - name: Embed version info
run: | run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
GITHUB_SHA=${{ github.sha }}
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
- name: Retrieve commit identification - name: Retrieve commit identification
run: | run: |
@ -89,20 +64,59 @@ jobs:
run: dotnet build --no-restore run: dotnet build --no-restore
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4.3.6 uses: actions/upload-artifact@v4.3.3
if: github.event_name != 'pull_request'
with: with:
name: IntroSkipper-${{ env.GIT_HASH }}.dll name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll
path: IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
if-no-files-found: error
- name: Upload artifact
uses: actions/upload-artifact@v4.3.3
if: github.event_name == 'pull_request'
with:
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ github.head_ref }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
retention-days: 7
if-no-files-found: error if-no-files-found: error
- name: Create archive - name: Create archive
if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches uses: vimtor/action-zip@v1.2
run: zip -j "intro-skipper-${{ env.GIT_HASH }}.zip" IntroSkipper/bin/Debug/net8.0/IntroSkipper.dll if: github.event_name != 'pull_request'
with:
files: |
ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
dest: intro-skipper-${{ env.GIT_HASH }}.zip
- name: Create/replace the preview release and upload artifacts - name: Generate md5
if: startsWith(github.ref_name, '10.') # only do a preview release on 10.x branches if: github.event_name != 'pull_request'
run: | run: |
gh release delete "${{ env.MAIN_VERSION }}/preview" --cleanup-tag --yes || true md5sum intro-skipper-${{ env.GIT_HASH }}.zip > intro-skipper-${{ env.GIT_HASH }}.md5
gh release create "${{ env.MAIN_VERSION }}/preview" "intro-skipper-${{ env.GIT_HASH }}.zip" --prerelease --title "intro-skipper-${{ env.GIT_HASH }}" --notes "This is a prerelease version." --target ${{ env.MAIN_VERSION }} checksum="$(awk '{print $1}' intro-skipper-${{ env.GIT_HASH }}.md5)"
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
- name: Publish prerelease
uses: 8bitDream/action-github-releases@v1.0.0
if: github.event_name != 'pull_request'
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: 10.8/preview
prerelease: true
title: intro-skipper-${{ env.GIT_HASH }}
files: |
intro-skipper-${{ env.GIT_HASH }}.zip
- name: Publish prerelease notes
uses: softprops/action-gh-release@v2.0.5
if: github.event_name != 'pull_request'
with:
tag_name: 10.8/preview
name: intro-skipper-${{ env.GIT_HASH }}
append_body: true
body: |
---
checksum: ${{ env.CHECKSUM }}
draft: false
prerelease: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -2,14 +2,21 @@ name: "CodeQL"
on: on:
push: push:
branches: branches: [ 10.8 ]
- '*' # Triggers on any branch push
paths-ignore: paths-ignore:
- "**/README.md" - '**/README.md'
- ".github/ISSUE_TEMPLATE/**" - '.github/ISSUE_TEMPLATE/**'
- "docs/**" - 'docs/**'
- "images/**" - 'images/**'
- "manifest.json" - 'manifest.json'
pull_request:
branches: [ 10.8 ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
permissions: write-all permissions: write-all
@ -18,50 +25,28 @@ jobs:
name: Analyze name: Analyze
runs-on: ubuntu-latest runs-on: ubuntu-latest
# This job will only run if the repository is public
if: ${{ github.event.repository.private == false }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: ["csharp"] language: [ 'csharp' ]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Check for BETA file
id: check-beta
run: |
if [ -f "BETA" ]; then
echo "IS_BETA=true" >> $GITHUB_ENV
else
echo "IS_BETA=false" >> $GITHUB_ENV
fi
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 6.0.x
- name: Restore Beta dependencies
if: ${{env.IS_BETA == 'true' }}
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
dotnet tool install --global dotnet-outdated-tool
dotnet outdated -pre Always -u -inc Jellyfin
- name: Restore dependencies - name: Restore dependencies
if: ${{env.IS_BETA == 'false' }}
run: dotnet restore run: dotnet restore
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 uses: github/codeql-action/init@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended queries: +security-extended
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 uses: github/codeql-action/autobuild@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 uses: github/codeql-action/analyze@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8

View File

@ -1,103 +1,109 @@
name: "Release Plugin" name: 'Release Plugin'
on: on:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: write contents: write
packages: write
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Read version from VERSION.txt - name: Set up Node.js
id: read-version uses: actions/setup-node@v4
run: | with:
MAIN_VERSION=$(cat VERSION.txt) node-version: '20'
echo "MAIN_VERSION=${MAIN_VERSION}"
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV
- name: Check for BETA file
id: check-beta
run: |
if [ -f "BETA" ]; then
echo "IS_BETA=true" >> $GITHUB_ENV
else
echo "IS_BETA=false" >> $GITHUB_ENV
fi
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 6.0.x
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "lts/*" node-version: 'lts/*'
- name: Install html-minifier-terser
run: npm install terser html-minifier-terser
- name: Minify HTML - name: Minify HTML
run: | run: |
npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o IntroSkipper/Configuration/configPage.html IntroSkipper/Configuration/configPage.html npx html-minifier-terser --collapse-boolean-attributes --collapse-whitespace --collapse-inline-tag-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --use-short-doctype --minify-css true --minify-js true -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html
npx terser IntroSkipper/Configuration/inject.js -o IntroSkipper/Configuration/inject.js -c -m npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
npx terser IntroSkipper/Configuration/visualizer.js -o IntroSkipper/Configuration/visualizer.js -c -m npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m
- name: Restore Beta dependencies
if: ${{env.IS_BETA == 'true' }}
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name jellyfin-pre "https://nuget.pkg.github.com/jellyfin/index.json"
dotnet tool install --global dotnet-outdated-tool
dotnet outdated -pre Always -u -inc Jellyfin
- name: Restore dependencies - name: Restore dependencies
if: ${{env.IS_BETA == 'false' }}
run: dotnet restore run: dotnet restore
- name: Run update version - name: Run update version
uses: intro-skipper/intro-skipper-action-ts@main run: node update-version.js
with:
task-type: "updateVersion"
- name: Embed version info - name: Embed version info
run: | run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
GITHUB_SHA=${{ github.sha }}
sed -i "s/string\.Empty/\"$GITHUB_SHA\"/g" IntroSkipper/Helper/Commit.cs
- name: Build - name: Build
run: dotnet build --configuration Release --no-restore run: dotnet build --configuration Release --no-restore
- name: Upload artifact
uses: actions/upload-artifact@v4.3.3
with:
name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ env.NEW_FILE_VERSION }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Release/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
if-no-files-found: error
- name: Create archive - name: Create archive
run: zip -j "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" IntroSkipper/bin/Release/net8.0/IntroSkipper.dll uses: vimtor/action-zip@v1.2
with:
files: |
ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
dest: intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
- name: Remove old release if exits - name: Generate manifest keys
if: ${{ github.repository == 'intro-skipper/intro-skipper-test' }} run: |
run: gh release delete "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" --cleanup-tag --yes || true sourceUrl="https://github.com/${{ github.repository }}/releases/download/10.8/v${{ env.NEW_FILE_VERSION }}/intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip"
env: echo "SOURCE_URL=$sourceUrl" >> $GITHUB_ENV
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} md5sum intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip > intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5
checksum="$(awk '{print $1}' intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5)"
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
timestamp="$(date +%FT%TZ)"
echo "TIMESTAMP=$timestamp" >> $GITHUB_ENV
- name: Create new release with tag - name: Publish release
if: github.event_name != 'pull_request' uses: 8bitDream/action-github-releases@v1.0.0
run: gh release create "${{ env.MAIN_VERSION }}/v${{ env.NEW_FILE_VERSION }}" "intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip" --title "v${{ env.NEW_FILE_VERSION }}" --latest --generate-notes --target ${{ env.MAIN_VERSION }} with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: 10.8/v${{ env.NEW_FILE_VERSION }}
prerelease: false
title: v${{ env.NEW_FILE_VERSION }}
files: |
intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
- name: Publish release notes
uses: softprops/action-gh-release@v2.0.5
with:
tag_name: 10.8/v${{ env.NEW_FILE_VERSION }}
name: v${{ env.NEW_FILE_VERSION }}
draft: false
prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run validation and update script - name: Run validation and update script
uses: intro-skipper/intro-skipper-action-ts@main run: node validate-and-update-manifest.js
with:
task-type: "updateManifest"
env: env:
GITHUB_REPO_VISIBILITY: ${{ github.event.repository.visibility }} VERSION: ${{ env.NEW_FILE_VERSION }}
MAIN_VERSION: ${{ env.MAIN_VERSION }}
- name: Commit changes - name: Commit changes
if: success() if: success()
run: | run: |
git config --global user.name "github-actions[bot]" git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add README.md manifest.json IntroSkipper/IntroSkipper.csproj .github/ISSUE_TEMPLATE/bug_report_form.yml git add manifest.json ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
git commit -m "release v${{ env.NEW_FILE_VERSION }}" git commit -m "release v${{ env.NEW_FILE_VERSION }}"
git push git push

View File

@ -1,41 +0,0 @@
name: Create Jellyfin-web artifact
on:
release:
types: [published]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
jellyfin-web-version: [10.9.11]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: ">=20"
- name: Checkout official jellyfin-web
uses: actions/checkout@v4
with:
repository: jellyfin/jellyfin-web
ref: v${{ matrix.jellyfin-web-version }}
path: web
- name: Apply intro skipper patch
run: |
cd web
git apply ../webui.patch
- name: Build web interface
run: |
cd web
npm ci --no-audit
npm run build:production
- name: Upload web interface
uses: actions/upload-artifact@v4
with:
name: jellyfin-web-${{ matrix.jellyfin-web-version }}+${{ github.sha }}
path: web/dist
if-no-files-found: error

View File

@ -1,11 +0,0 @@
{
"recommendations": [
"ms-dotnettools.csharp",
"editorconfig.editorconfig",
"github.vscode-github-actions",
"ms-dotnettools.vscode-dotnet-runtime",
"ms-dotnettools.csdevkit",
"eamodio.gitlens"
],
"unwantedRecommendations": []
}

View File

@ -1,9 +0,0 @@
{
"editor.insertSpaces": true,
"editor.tabSize": 4,
"files.encoding": "utf8",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"editor.rulers": [],
"dotnet.defaultSolution": "IntroSkipper.sln"
}

View File

@ -4,4 +4,3 @@ Intro Skipper is made possible by the following open source projects:
* [chromaprint](https://github.com/acoustid/chromaprint) (LGPL 2.1) * [chromaprint](https://github.com/acoustid/chromaprint) (LGPL 2.1)
* [JellyScrub](https://github.com/nicknsy/jellyscrub) (MIT) * [JellyScrub](https://github.com/nicknsy/jellyscrub) (MIT)
* [Jellyfin](https://github.com/jellyfin/jellyfin) (GPL) * [Jellyfin](https://github.com/jellyfin/jellyfin) (GPL)
* [Jellyfin Media Analyzer](https://github.com/endrl/jellyfin-plugin-media-analyzer) (GPL)

93
CHANGELOG.md Normal file
View File

@ -0,0 +1,93 @@
# Changelog
## v0.1.8.0 (no eta)
* New features
* Support adding skip intro button to web interface without using a fork
* Add localization support for the skip intro button and the automatic skip notification message
* Detect ending credits in television episodes
* Add support for using chapter names to locate introductions and ending credits
* Add support for using black frames to locate ending credits
* Show skip button when on screen controls are visible (#149 by @DualScorch)
* Internal changes
* Move Chromaprint analysis code out of the episode analysis task
* Add support for multiple analysis techinques
## v0.1.7.0 (2022-10-26)
* New features
* Rewrote fingerprint comparison algorithm to be faster (~30x speedup) and detect more introductions
* Detect silence at the end of introductions and use it to avoid skipping over the beginning of an episode
* If you are upgrading from a previous release and want to use the silence detection feature on shows that have already been analyzed, you must click the `Erase introduction timestamps` button at the bottom of the plugin settings page
* Add support bundle
* Add maximum introduction duration
* Support playing a few seconds from the end of the introduction to verify that no episode content was skipped over
* Amount played is customizable and defaults to 2 seconds
* Support modifying introduction detection algorithm settings
* Add option to not skip the introduction in the first episode of a season
* Add option to analyze show extras (specials)
* Fixes
* Fix scheduled task interval (#79)
* Prevent show names from becoming duplicated in the show name dropdown under the advanced section
* Prevent virtual episodes from being inserted into the analysis queue
## v0.1.6.0 (2022-08-04)
* New features
* Generate EDL files with intro timestamps ([documentation](docs/edl.md)) (#21)
* Support selecting which libraries are analyzed (#37)
* Support customizing [introduction requirements](README.md#introduction-requirements) (#38, #51)
* Changing these settings will increase episode analysis times
* Support adding and editing intro timestamps (#26)
* Report how CPU time is being spent while analyzing episodes
* CPU time reports can be viewed under "Analysis Statistics (experimental)" in the plugin configuration page
* Sped up fingerprint analysis (not including fingerprint generation time) by 40%
* Support erasing discovered introductions by season
* Suggest potential shifts in the fingerprint visualizer
* Fixes
* Ensure episode analysis queue matches the current filesystem and library state (#42, #60)
* Fixes a bug where renamed or deleted episodes were being analyzed
* Fix automatic intro skipping on Android TV (#57, #61)
* Restore per season status updates in the log
* Prevent null key in `/Intros/Shows` endpoint (#27)
* Fix positioning of skip intro button on mobile devices (#43)
* Ensure video playback always resumes after clicking the skip intro button (#44)
## v0.1.5.0 (2022-06-17)
* Use `ffmpeg` to generate audio fingerprints instead of `fpcalc`
* Requires that the installed version of `ffmpeg`:
* Was compiled with the `--enable-chromaprint` option
* Understands the `-fp_format raw` flag
* `jellyfin-ffmpeg 5.0.1-5` meets both of these requirements
* Version API endpoints
* See [api.md](docs/api.md) for detailed documentation on how clients can work with this plugin
* Add commit hash to unstable builds
* Log media paths that are unable to be fingerprinted
* Report failure to the UI if the episode analysis queue is empty
* Allow customizing degrees of parallelism
* Warning: Using a value that is too high will result in system instability
* Remove restart requirement to change auto skip setting
* Rewrite startup enqueue
* Fix deadlock issue on Windows (#23 by @nyanmisaka)
* Improve skip intro button styling & positioning (ConfusedPolarBear/jellyfin-web#91 by @Fallenbagel)
* Order episodes by `IndexNumber` (#25 reported by @Flo56958)
## v0.1.0.0 (2022-06-09)
* Add option to automatically skip intros
* Cache audio fingerprints by default
* Add fingerprint visualizer
* Add button to erase all previously discovered intro timestamps
* Made saving settings more reliable
* Switch to new fingerprint comparison algorithm
* If you would like to test the new comparison algorithm, you will have to erase all previously discovered introduction timestamps.
## v0.0.0.3 (2022-05-21)
* Fix `fpcalc` version check
## v0.0.0.2 (2022-05-21)
* Analyze multiple seasons in parallel
* Reanalyze episodes with an unusually short or long intro sequence
* Check installed `fpcalc` version
* Clarify installation instructions
## v0.0.0.1 (2022-05-10)
* First alpha build

View File

@ -1,16 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
@ -21,7 +21,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\IntroSkipper\IntroSkipper.csproj" /> <ProjectReference Include="..\ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -7,12 +7,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using IntroSkipper.Analyzers;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
using Xunit; using Xunit;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
public class TestAudioFingerprinting public class TestAudioFingerprinting
{ {
@ -31,7 +29,8 @@ public class TestAudioFingerprinting
[InlineData(19, 2_465_585_877)] [InlineData(19, 2_465_585_877)]
public void TestBitCounting(int expectedBits, uint number) public void TestBitCounting(int expectedBits, uint number)
{ {
Assert.Equal(expectedBits, ChromaprintAnalyzer.CountBits(number)); var chromaprint = CreateChromaprintAnalyzer();
Assert.Equal(expectedBits, chromaprint.CountBits(number));
} }
[FactSkipFFmpegTests] [FactSkipFFmpegTests]
@ -64,7 +63,7 @@ public class TestAudioFingerprinting
}; };
var actual = FFmpegWrapper.Fingerprint( var actual = FFmpegWrapper.Fingerprint(
QueueEpisode("audio/big_buck_bunny_intro.mp3"), queueEpisode("audio/big_buck_bunny_intro.mp3"),
AnalysisMode.Introduction); AnalysisMode.Introduction);
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
@ -85,8 +84,7 @@ public class TestAudioFingerprinting
{77, 5}, {77, 5},
}; };
var analyzer = CreateChromaprintAnalyzer(); var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
var actual = analyzer.CreateInvertedIndex(Guid.NewGuid(), fpr);
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
} }
@ -96,8 +94,8 @@ public class TestAudioFingerprinting
{ {
var chromaprint = CreateChromaprintAnalyzer(); var chromaprint = CreateChromaprintAnalyzer();
var lhsEpisode = QueueEpisode("audio/big_buck_bunny_intro.mp3"); var lhsEpisode = queueEpisode("audio/big_buck_bunny_intro.mp3");
var rhsEpisode = QueueEpisode("audio/big_buck_bunny_clip.mp3"); var rhsEpisode = queueEpisode("audio/big_buck_bunny_clip.mp3");
var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction); var lhsFingerprint = FFmpegWrapper.Fingerprint(lhsEpisode, AnalysisMode.Introduction);
var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction); var rhsFingerprint = FFmpegWrapper.Fingerprint(rhsEpisode, AnalysisMode.Introduction);
@ -108,13 +106,13 @@ public class TestAudioFingerprinting
rhsFingerprint); rhsFingerprint);
Assert.True(lhs.Valid); Assert.True(lhs.Valid);
Assert.Equal(0, lhs.Start); Assert.Equal(0, lhs.IntroStart);
Assert.Equal(17.208, lhs.End, 3); Assert.Equal(17.208, lhs.IntroEnd, 3);
Assert.True(rhs.Valid); Assert.True(rhs.Valid);
// because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5) // because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5)
Assert.Equal(0, rhs.Start); Assert.Equal(0, rhs.IntroStart);
Assert.Equal(22.1602, rhs.End); Assert.Equal(22.1602, rhs.IntroEnd);
} }
/// <summary> /// <summary>
@ -123,25 +121,24 @@ public class TestAudioFingerprinting
[FactSkipFFmpegTests] [FactSkipFFmpegTests]
public void TestSilenceDetection() public void TestSilenceDetection()
{ {
var clip = QueueEpisode("audio/big_buck_bunny_clip.mp3"); var clip = queueEpisode("audio/big_buck_bunny_clip.mp3");
var expected = new TimeRange[] var expected = new TimeRange[]
{ {
new(44.631042, 44.807167), new TimeRange(44.6310, 44.8072),
new(53.590521, 53.806979), new TimeRange(53.5905, 53.8070),
new(53.845833, 54.202417), new TimeRange(53.8458, 54.2024),
new(54.261104, 54.593479), new TimeRange(54.2611, 54.5935),
new(54.709792, 54.929312), new TimeRange(54.7098, 54.9293),
new(54.929396, 55.258979), new TimeRange(54.9294, 55.2590),
}; };
var range = new TimeRange(0, 60); var actual = FFmpegWrapper.DetectSilence(clip, 60);
var actual = FFmpegWrapper.DetectSilence(clip, range);
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
} }
private static QueuedEpisode QueueEpisode(string path) private QueuedEpisode queueEpisode(string path)
{ {
return new QueuedEpisode() return new QueuedEpisode()
{ {
@ -151,7 +148,7 @@ public class TestAudioFingerprinting
}; };
} }
private static ChromaprintAnalyzer CreateChromaprintAnalyzer() private ChromaprintAnalyzer CreateChromaprintAnalyzer()
{ {
var logger = new LoggerFactory().CreateLogger<ChromaprintAnalyzer>(); var logger = new LoggerFactory().CreateLogger<ChromaprintAnalyzer>();
return new(logger); return new(logger);

View File

@ -1,12 +1,10 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using IntroSkipper.Analyzers;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Xunit; using Xunit;
@ -18,11 +16,11 @@ public class TestBlackFrames
var range = 1e-5; var range = 1e-5;
var expected = new List<BlackFrame>(); var expected = new List<BlackFrame>();
expected.AddRange(CreateFrameSequence(2, 3)); expected.AddRange(CreateFrameSequence(2.04, 3));
expected.AddRange(CreateFrameSequence(5, 6)); expected.AddRange(CreateFrameSequence(5, 6));
expected.AddRange(CreateFrameSequence(8, 9.96)); expected.AddRange(CreateFrameSequence(8, 9.96));
var actual = FFmpegWrapper.DetectBlackFrames(QueueFile("rainbow.mp4"), new(0, 10), 85); var actual = FFmpegWrapper.DetectBlackFrames(queueFile("rainbow.mp4"), new(0, 10), 85);
for (var i = 0; i < expected.Count; i++) for (var i = 0; i < expected.Count; i++)
{ {
@ -40,15 +38,15 @@ public class TestBlackFrames
var analyzer = CreateBlackFrameAnalyzer(); var analyzer = CreateBlackFrameAnalyzer();
var episode = QueueFile("credits.mp4"); var episode = queueFile("credits.mp4");
episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds; episode.Duration = (int)new TimeSpan(0, 5, 30).TotalSeconds;
var result = analyzer.AnalyzeMediaFile(episode, 240, 85); var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85);
Assert.NotNull(result); Assert.NotNull(result);
Assert.InRange(result.Start, 300 - range, 300 + range); Assert.InRange(result.IntroStart, 300 - range, 300 + range);
} }
private static QueuedEpisode QueueFile(string path) private QueuedEpisode queueFile(string path)
{ {
return new() return new()
{ {
@ -58,7 +56,7 @@ public class TestBlackFrames
}; };
} }
private static BlackFrame[] CreateFrameSequence(double start, double end) private BlackFrame[] CreateFrameSequence(double start, double end)
{ {
var frames = new List<BlackFrame>(); var frames = new List<BlackFrame>();
@ -67,10 +65,10 @@ public class TestBlackFrames
frames.Add(new(100, i)); frames.Add(new(100, i));
} }
return [.. frames]; return frames.ToArray();
} }
private static BlackFrameAnalyzer CreateBlackFrameAnalyzer() private BlackFrameAnalyzer CreateBlackFrameAnalyzer()
{ {
var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>(); var logger = new LoggerFactory().CreateLogger<BlackFrameAnalyzer>();
return new(logger); return new(logger);

View File

@ -1,13 +1,11 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using IntroSkipper.Analyzers;
using IntroSkipper.Data;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Xunit; using Xunit;
@ -26,8 +24,8 @@ public class TestChapterAnalyzer
var introChapter = FindChapter(chapters, AnalysisMode.Introduction); var introChapter = FindChapter(chapters, AnalysisMode.Introduction);
Assert.NotNull(introChapter); Assert.NotNull(introChapter);
Assert.Equal(60, introChapter.Start); Assert.Equal(60, introChapter.IntroStart);
Assert.Equal(90, introChapter.End); Assert.Equal(90, introChapter.IntroEnd);
} }
[Theory] [Theory]
@ -42,11 +40,11 @@ public class TestChapterAnalyzer
var creditsChapter = FindChapter(chapters, AnalysisMode.Credits); var creditsChapter = FindChapter(chapters, AnalysisMode.Credits);
Assert.NotNull(creditsChapter); Assert.NotNull(creditsChapter);
Assert.Equal(1890, creditsChapter.Start); Assert.Equal(1890, creditsChapter.IntroStart);
Assert.Equal(2000, creditsChapter.End); Assert.Equal(2000, creditsChapter.IntroEnd);
} }
private Segment? FindChapter(Collection<ChapterInfo> chapters, AnalysisMode mode) private Intro? FindChapter(Collection<ChapterInfo> chapters, AnalysisMode mode)
{ {
var logger = new LoggerFactory().CreateLogger<ChapterAnalyzer>(); var logger = new LoggerFactory().CreateLogger<ChapterAnalyzer>();
var analyzer = new ChapterAnalyzer(logger); var analyzer = new ChapterAnalyzer(logger);
@ -77,7 +75,7 @@ public class TestChapterAnalyzer
/// <param name="name">Chapter name.</param> /// <param name="name">Chapter name.</param>
/// <param name="position">Chapter position (in seconds).</param> /// <param name="position">Chapter position (in seconds).</param>
/// <returns>ChapterInfo.</returns> /// <returns>ChapterInfo.</returns>
private static ChapterInfo CreateChapter(string name, int position) private ChapterInfo CreateChapter(string name, int position)
{ {
return new() return new()
{ {

View File

@ -1,10 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using IntroSkipper.Data;
using Xunit; using Xunit;
namespace IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
public class TestTimeRanges public class TestTimeRanges
{ {

View File

@ -0,0 +1,47 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
public class TestEdl
{
// Test data is from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL
[Theory]
[InlineData(5.3, 7.1, EdlAction.Cut, "5.3 7.1 0")]
[InlineData(15, 16.7, EdlAction.Mute, "15 16.7 1")]
[InlineData(420, 822, EdlAction.CommercialBreak, "420 822 3")]
[InlineData(1, 255.3, EdlAction.SceneMarker, "1 255.3 2")]
[InlineData(1.123456789, 5.654647987, EdlAction.CommercialBreak, "1.12 5.65 3")]
public void TestEdlSerialization(double start, double end, EdlAction action, string expected)
{
var intro = MakeIntro(start, end);
var actual = intro.ToEdl(action);
Assert.Equal(expected, actual);
}
[Fact]
public void TestEdlInvalidSerialization()
{
Assert.Throws<ArgumentException>(() => {
var intro = MakeIntro(0, 5);
intro.ToEdl(EdlAction.None);
});
}
[Theory]
[InlineData("Death Note - S01E12 - Love.mkv", "Death Note - S01E12 - Love.edl")]
[InlineData("/full/path/to/file.rm", "/full/path/to/file.edl")]
public void TestEdlPath(string mediaPath, string edlPath)
{
Assert.Equal(edlPath, EdlManager.GetEdlPath(mediaPath));
}
private Intro MakeIntro(double start, double end)
{
return new Intro(Guid.Empty, new TimeRange(start, end));
}
}

View File

@ -0,0 +1,37 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using Xunit;
public class TestFlags
{
[Fact]
public void TestEmptyFlagSerialization()
{
WarningManager.Clear();
Assert.Equal("None", WarningManager.GetWarnings());
}
[Fact]
public void TestSingleFlagSerialization()
{
WarningManager.Clear();
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
Assert.Equal("UnableToAddSkipButton", WarningManager.GetWarnings());
}
[Fact]
public void TestDoubleFlagSerialization()
{
WarningManager.Clear();
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
Assert.Equal(
"UnableToAddSkipButton, InvalidChromaprintFingerprint",
WarningManager.GetWarnings());
}
}

View File

@ -39,6 +39,7 @@ Selenium is used to verify that the plugin's web interface works as expected. It
* Changing settings (will be added in the future) * Changing settings (will be added in the future)
* Maximum degree of parallelism * Maximum degree of parallelism
* Selecting libraries for analysis * Selecting libraries for analysis
* EDL settings
* Introduction requirements * Introduction requirements
* Auto skip * Auto skip
* Show/hide skip prompt * Show/hide skip prompt

View File

@ -1,8 +1,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# #
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper", "IntroSkipper\IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper", "ConfusedPolarBear.Plugin.IntroSkipper\ConfusedPolarBear.Plugin.IntroSkipper.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntroSkipper.Tests", "IntroSkipper.Tests\IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfusedPolarBear.Plugin.IntroSkipper.Tests", "ConfusedPolarBear.Plugin.IntroSkipper.Tests\ConfusedPolarBear.Plugin.IntroSkipper.Tests.csproj", "{9E30DA42-983E-46E0-A3BF-A2BA56FE9718}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@ -0,0 +1,206 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.Logging;
/// <summary>
/// Media file analyzer used to detect end credits that consist of text overlaid on a black background.
/// Bisects the end of the video file to perform an efficient search.
/// </summary>
public class BlackFrameAnalyzer : IMediaFileAnalyzer
{
private readonly TimeSpan _maximumError = new(0, 0, 4);
private readonly ILogger<BlackFrameAnalyzer> _logger;
private int minimumCreditsDuration;
private int maximumCreditsDuration;
private int blackFrameMinimumPercentage;
/// <summary>
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger)
{
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
minimumCreditsDuration = config.MinimumCreditsDuration;
maximumCreditsDuration = 2 * config.MaximumCreditsDuration;
blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
if (mode != AnalysisMode.Credits)
{
throw new NotImplementedException("mode must equal Credits");
}
var creditTimes = new Dictionary<Guid, Intro>();
bool isFirstEpisode = true;
double searchStart = minimumCreditsDuration;
var searchDistance = 2 * minimumCreditsDuration;
foreach (var episode in analysisQueue)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
// Pre-check to find reasonable starting point.
if (isFirstEpisode)
{
var scanTime = episode.Duration - searchStart;
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, blackFrameMinimumPercentage);
while (frames.Length > 0) // While black frames are found increase searchStart
{
searchStart += searchDistance;
scanTime = episode.Duration - searchStart;
tr = new TimeRange(scanTime - 0.5, scanTime);
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, blackFrameMinimumPercentage);
if (searchStart > maximumCreditsDuration)
{
searchStart = maximumCreditsDuration;
break;
}
}
if (searchStart == minimumCreditsDuration) // Skip if no black frames were found
{
continue;
}
isFirstEpisode = false;
}
var intro = AnalyzeMediaFile(
episode,
searchStart,
searchDistance,
blackFrameMinimumPercentage);
if (intro is null)
{
// If no credits were found, reset the first-episode search logic for the next episode in the sequence.
searchStart = minimumCreditsDuration;
isFirstEpisode = true;
continue;
}
searchStart = episode.Duration - intro.IntroStart + (0.5 * searchDistance);
creditTimes[episode.EpisodeId] = intro;
}
Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
return analysisQueue
.Where(x => !creditTimes.ContainsKey(x.EpisodeId))
.ToList()
.AsReadOnly();
}
/// <summary>
/// Analyzes an individual media file. Only public because of unit tests.
/// </summary>
/// <param name="episode">Media file to analyze.</param>
/// <param name="searchStart">Search Start Piont.</param>
/// <param name="searchDistance">Search Distance.</param>
/// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Credits timestamp.</returns>
public Intro? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum)
{
// Start by analyzing the last N minutes of the file.
var upperLimit = searchStart;
var lowerLimit = Math.Max(searchStart - searchDistance, minimumCreditsDuration);
var start = TimeSpan.FromSeconds(upperLimit);
var end = TimeSpan.FromSeconds(lowerLimit);
var firstFrameTime = 0.0;
// Continue bisecting the end of the file until the range that contains the first black
// frame is smaller than the maximum permitted error.
while (start - end > _maximumError)
{
// Analyze the middle two seconds from the current bisected range
var midpoint = (start + end) / 2;
var scanTime = episode.Duration - midpoint.TotalSeconds;
var tr = new TimeRange(scanTime, scanTime + 2);
_logger.LogTrace(
"{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
episode.Name,
episode.Duration,
start,
end,
tr.Start,
tr.End);
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
_logger.LogTrace(
"{Episode} at {Start} has {Count} black frames",
episode.Name,
tr.Start,
frames.Length);
if (frames.Length == 0)
{
// Since no black frames were found, slide the range closer to the end
start = midpoint - TimeSpan.FromSeconds(2);
if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError)
{
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), minimumCreditsDuration);
// Reset end for a new search with the increased duration
end = TimeSpan.FromSeconds(lowerLimit);
}
}
else
{
// Some black frames were found, slide the range closer to the start
end = midpoint;
firstFrameTime = frames[0].Time + scanTime;
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
{
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), maximumCreditsDuration);
// Reset start for a new search with the increased duration
start = TimeSpan.FromSeconds(upperLimit);
}
}
}
if (firstFrameTime > 0)
{
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
}
return null;
}
}

View File

@ -0,0 +1,249 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
/// <summary>
/// Chapter name analyzer.
/// </summary>
public class ChapterAnalyzer : IMediaFileAnalyzer
{
private ILogger<ChapterAnalyzer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ChapterAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ChapterAnalyzer(ILogger<ChapterAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var skippableRanges = new Dictionary<Guid, Intro>();
var expression = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Configuration.ChapterAnalyzerIntroductionPattern :
Plugin.Instance!.Configuration.ChapterAnalyzerEndCreditsPattern;
if (string.IsNullOrWhiteSpace(expression))
{
return analysisQueue;
}
foreach (var episode in analysisQueue)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var skipRange = FindMatchingChapter(
episode,
new(Plugin.Instance!.GetChapters(episode.EpisodeId)),
expression,
mode);
if (skipRange is null)
{
continue;
}
skippableRanges.Add(episode.EpisodeId, skipRange);
}
Plugin.Instance!.UpdateTimestamps(skippableRanges, mode);
return analysisQueue
.Where(x => !skippableRanges.ContainsKey(x.EpisodeId))
.ToList()
.AsReadOnly();
}
/// <summary>
/// Searches a list of chapter names for one that matches the provided regular expression.
/// Only public to allow for unit testing.
/// </summary>
/// <param name="episode">Episode.</param>
/// <param name="chapters">Media item chapters.</param>
/// <param name="expression">Regular expression pattern.</param>
/// <param name="mode">Analysis mode.</param>
/// <returns>Intro object containing skippable time range, or null if no chapter matched.</returns>
public Intro? FindMatchingChapter(
QueuedEpisode episode,
Collection<ChapterInfo> chapters,
string expression,
AnalysisMode mode)
{
Intro? matchingChapter = null;
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
var minDuration = mode == AnalysisMode.Introduction ?
config.MinimumIntroDuration :
config.MinimumCreditsDuration;
int maxDuration = mode == AnalysisMode.Introduction ?
config.MaximumIntroDuration :
config.MaximumCreditsDuration;
if (chapters.Count == 0)
{
return null;
}
if (mode == AnalysisMode.Credits)
{
// Since the ending credits chapter may be the last chapter in the file, append a virtual
// chapter at the very end of the file.
chapters.Add(new()
{
StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks
});
// Check all chapters in reverse order, skipping the virtual chapter
for (int i = chapters.Count - 2; i > 0; i--)
{
var current = chapters[i];
var previous = chapters[i - 1];
if (string.IsNullOrWhiteSpace(current.Name))
{
continue;
}
var currentRange = new TimeRange(
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(chapters[i + 1].StartPositionTicks).TotalSeconds);
var baseMessage = string.Format(
CultureInfo.InvariantCulture,
"{0}: Chapter \"{1}\" ({2} - {3})",
episode.Path,
current.Name,
currentRange.Start,
currentRange.End);
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
{
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
continue;
}
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
// between function invocations.
var match = Regex.IsMatch(
current.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (!match)
{
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
continue;
}
if (!string.IsNullOrWhiteSpace(previous.Name))
{
// Check for possibility of overlapping keywords
var overlap = Regex.IsMatch(
previous.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (overlap)
{
continue;
}
}
matchingChapter = new(episode.EpisodeId, currentRange);
_logger.LogTrace("{Base}: okay", baseMessage);
break;
}
}
else
{
// Check all chapters
for (int i = 0; i < chapters.Count - 1; i++)
{
var current = chapters[i];
var next = chapters[i + 1];
if (string.IsNullOrWhiteSpace(current.Name))
{
continue;
}
var currentRange = new TimeRange(
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
var baseMessage = string.Format(
CultureInfo.InvariantCulture,
"{0}: Chapter \"{1}\" ({2} - {3})",
episode.Path,
current.Name,
currentRange.Start,
currentRange.End);
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
{
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
continue;
}
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
// between function invocations.
var match = Regex.IsMatch(
current.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (!match)
{
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
continue;
}
if (!string.IsNullOrWhiteSpace(next.Name))
{
// Check for possibility of overlapping keywords
var overlap = Regex.IsMatch(
next.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (overlap)
{
continue;
}
}
matchingChapter = new(episode.EpisodeId, currentRange);
_logger.LogTrace("{Base}: okay", baseMessage);
break;
}
}
return matchingChapter;
}
}

View File

@ -1,56 +1,76 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Collections.ObjectModel;
using System.Numerics; using System.Numerics;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace IntroSkipper.Analyzers;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class. /// Chromaprint audio analyzer.
/// </summary> /// </summary>
/// <param name="logger">Logger.</param> public class ChromaprintAnalyzer : IMediaFileAnalyzer
public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFileAnalyzer
{ {
/// <summary> /// <summary>
/// Seconds of audio in one fingerprint point. /// Seconds of audio in one fingerprint point.
/// This value is defined by the Chromaprint library and should not be changed. /// This value is defined by the Chromaprint library and should not be changed.
/// </summary> /// </summary>
private const double SamplesToSeconds = 0.1238; private const double SamplesToSeconds = 0.1238;
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
private readonly ILogger<ChromaprintAnalyzer> _logger = logger; private int minimumIntroDuration;
private readonly Dictionary<Guid, Dictionary<uint, int>> _invertedIndexCache = [];
private int maximumDifferences;
private int invertedIndexShift;
private double maximumTimeSkip;
private double silenceDetectionMinimumDuration;
private ILogger<ChromaprintAnalyzer> _logger;
private AnalysisMode _analysisMode; private AnalysisMode _analysisMode;
/// <summary>
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger)
{
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
maximumDifferences = config.MaximumFingerprintPointDifferences;
invertedIndexShift = config.InvertedIndexShift;
maximumTimeSkip = config.MaximumTimeSkip;
silenceDetectionMinimumDuration = config.SilenceDetectionMinimumDuration;
minimumIntroDuration = config.MinimumIntroDuration;
_logger = logger;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles( public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue, ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode, AnalysisMode mode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Episodes that were not analyzed.
var episodeAnalysisQueue = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
if (episodeAnalysisQueue.Count <= 1)
{
return analysisQueue;
}
_analysisMode = mode;
// All intros for this season. // All intros for this season.
var seasonIntros = new Dictionary<Guid, Segment>(); var seasonIntros = new Dictionary<Guid, Intro>();
// Cache of all fingerprints for this season. // Cache of all fingerprints for this season.
var fingerprintCache = new Dictionary<Guid, uint[]>(); var fingerprintCache = new Dictionary<Guid, uint[]>();
// Episode analysis queue.
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
// Episodes that were analyzed and do not have an introduction.
var episodesWithoutIntros = new List<QueuedEpisode>();
this._analysisMode = mode;
// Compute fingerprints for all episodes in the season // Compute fingerprints for all episodes in the season
foreach (var episode in episodeAnalysisQueue) foreach (var episode in episodeAnalysisQueue)
{ {
@ -75,7 +95,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint); WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
// Fallback to an empty fingerprint on any error // Fallback to an empty fingerprint on any error
fingerprintCache[episode.EpisodeId] = []; fingerprintCache[episode.EpisodeId] = Array.Empty<uint>();
} }
} }
@ -101,7 +121,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
// - the introduction exceeds the configured limit // - the introduction exceeds the configured limit
if ( if (
!remainingIntro.Valid || !remainingIntro.Valid ||
(_analysisMode == AnalysisMode.Introduction && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)) remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration)
{ {
continue; continue;
} }
@ -115,17 +135,17 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
* To fix this, the starting and ending times need to be switched, as they were previously reversed * To fix this, the starting and ending times need to be switched, as they were previously reversed
* and subtracted from the episode duration to get the reported time range. * and subtracted from the episode duration to get the reported time range.
*/ */
if (_analysisMode == AnalysisMode.Credits) if (this._analysisMode == AnalysisMode.Credits)
{ {
// Calculate new values for the current intro // Calculate new values for the current intro
double currentOriginalIntroStart = currentIntro.Start; double currentOriginalIntroStart = currentIntro.IntroStart;
currentIntro.Start = currentEpisode.Duration - currentIntro.End; currentIntro.IntroStart = currentEpisode.Duration - currentIntro.IntroEnd;
currentIntro.End = currentEpisode.Duration - currentOriginalIntroStart; currentIntro.IntroEnd = currentEpisode.Duration - currentOriginalIntroStart;
// Calculate new values for the remaining intro // Calculate new values for the remaining intro
double remainingIntroOriginalStart = remainingIntro.Start; double remainingIntroOriginalStart = remainingIntro.IntroStart;
remainingIntro.Start = remainingEpisode.Duration - remainingIntro.End; remainingIntro.IntroStart = remainingEpisode.Duration - remainingIntro.IntroEnd;
remainingIntro.End = remainingEpisode.Duration - remainingIntroOriginalStart; remainingIntro.IntroEnd = remainingEpisode.Duration - remainingIntroOriginalStart;
} }
// Only save the discovered intro if it is: // Only save the discovered intro if it is:
@ -148,17 +168,30 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
break; break;
} }
// If an intro is found for this episode, adjust its times and save it else add it to the list of episodes without intros. // If no intro is found at this point, the popped episode is not reinserted into the queue.
if (seasonIntros.TryGetValue(currentEpisode.EpisodeId, out var intro)) if (!seasonIntros.ContainsKey(currentEpisode.EpisodeId))
{ {
currentEpisode.IsAnalyzed = true; episodesWithoutIntros.Add(currentEpisode);
await Plugin.Instance!.UpdateTimestampAsync(intro, mode).ConfigureAwait(false);
} }
} }
// If cancellation was requested, report that no episodes were analyzed.
if (cancellationToken.IsCancellationRequested)
{
return analysisQueue; return analysisQueue;
} }
if (this._analysisMode == AnalysisMode.Introduction)
{
// Adjust all introduction end times so that they end at silence.
seasonIntros = AdjustIntroEndTimes(analysisQueue, seasonIntros);
}
Plugin.Instance!.UpdateTimestamps(seasonIntros, this._analysisMode);
return episodesWithoutIntros.AsReadOnly();
}
/// <summary> /// <summary>
/// Analyze two episodes to find an introduction sequence shared between them. /// Analyze two episodes to find an introduction sequence shared between them.
/// </summary> /// </summary>
@ -167,7 +200,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
/// <param name="rhsId">Second episode id.</param> /// <param name="rhsId">Second episode id.</param>
/// <param name="rhsPoints">Second episode fingerprint points.</param> /// <param name="rhsPoints">Second episode fingerprint points.</param>
/// <returns>Intros for the first and second episodes.</returns> /// <returns>Intros for the first and second episodes.</returns>
public (Segment Lhs, Segment Rhs) CompareEpisodes( public (Intro Lhs, Intro Rhs) CompareEpisodes(
Guid lhsId, Guid lhsId,
uint[] lhsPoints, uint[] lhsPoints,
Guid rhsId, Guid rhsId,
@ -189,7 +222,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
lhsId, lhsId,
rhsId); rhsId);
return (new Segment(lhsId), new Segment(rhsId)); return (new Intro(lhsId), new Intro(rhsId));
} }
/// <summary> /// <summary>
@ -200,7 +233,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
/// <param name="rhsId">Second episode id.</param> /// <param name="rhsId">Second episode id.</param>
/// <param name="rhsRanges">Second episode shared timecodes.</param> /// <param name="rhsRanges">Second episode shared timecodes.</param>
/// <returns>Intros for the first and second episodes.</returns> /// <returns>Intros for the first and second episodes.</returns>
private static (Segment Lhs, Segment Rhs) GetLongestTimeRange( private (Intro Lhs, Intro Rhs) GetLongestTimeRange(
Guid lhsId, Guid lhsId,
List<TimeRange> lhsRanges, List<TimeRange> lhsRanges,
Guid rhsId, Guid rhsId,
@ -225,7 +258,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
} }
// Create Intro classes for each time range. // Create Intro classes for each time range.
return (new Segment(lhsId, lhsIntro), new Segment(rhsId, rhsIntro)); return (new Intro(lhsId, lhsIntro), new Intro(rhsId, rhsIntro));
} }
/// <summary> /// <summary>
@ -246,8 +279,8 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
var rhsRanges = new List<TimeRange>(); var rhsRanges = new List<TimeRange>();
// Generate inverted indexes for the left and right episodes. // Generate inverted indexes for the left and right episodes.
var lhsIndex = CreateInvertedIndex(lhsId, lhsPoints); var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, this._analysisMode);
var rhsIndex = CreateInvertedIndex(rhsId, rhsPoints); var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, this._analysisMode);
var indexShifts = new HashSet<int>(); var indexShifts = new HashSet<int>();
// For all audio points in the left episode, check if the right episode has a point which matches exactly. // For all audio points in the left episode, check if the right episode has a point which matches exactly.
@ -256,14 +289,14 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
{ {
var originalPoint = kvp.Key; var originalPoint = kvp.Key;
for (var i = -1 * _config.InvertedIndexShift; i <= _config.InvertedIndexShift; i++) for (var i = -1 * invertedIndexShift; i <= invertedIndexShift; i++)
{ {
var modifiedPoint = (uint)(originalPoint + i); var modifiedPoint = (uint)(originalPoint + i);
if (rhsIndex.TryGetValue(modifiedPoint, out var rhsModifiedPoint)) if (rhsIndex.TryGetValue(modifiedPoint, out var rhsModifiedPoint))
{ {
var lhsFirst = lhsIndex[originalPoint]; var lhsFirst = (int)lhsIndex[originalPoint];
var rhsFirst = rhsModifiedPoint; var rhsFirst = (int)rhsModifiedPoint;
indexShifts.Add(rhsFirst - lhsFirst); indexShifts.Add(rhsFirst - lhsFirst);
} }
} }
@ -321,7 +354,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
var diff = lhs[lhsPosition] ^ rhs[rhsPosition]; var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
// If the difference between the samples is small, flag both times as similar. // If the difference between the samples is small, flag both times as similar.
if (CountBits(diff) > _config.MaximumFingerprintPointDifferences) if (CountBits(diff) > maximumDifferences)
{ {
continue; continue;
} }
@ -338,148 +371,112 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
rhsTimes.Add(double.MaxValue); rhsTimes.Add(double.MaxValue);
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range. // Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
var lContiguous = TimeRangeHelpers.FindContiguous([.. lhsTimes], _config.MaximumTimeSkip); var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), maximumTimeSkip);
if (lContiguous is null || lContiguous.Duration < _config.MinimumIntroDuration) if (lContiguous is null || lContiguous.Duration < minimumIntroDuration)
{ {
return (new TimeRange(), new TimeRange()); return (new TimeRange(), new TimeRange());
} }
// Since LHS had a contiguous time range, RHS must have one also. // Since LHS had a contiguous time range, RHS must have one also.
var rContiguous = TimeRangeHelpers.FindContiguous([.. rhsTimes], _config.MaximumTimeSkip)!; var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), maximumTimeSkip)!;
if (this._analysisMode == AnalysisMode.Introduction)
{
// Tweak the end timestamps just a bit to ensure as little content as possible is skipped over.
// TODO: remove this
if (lContiguous.Duration >= 90)
{
lContiguous.End -= 2 * maximumTimeSkip;
rContiguous.End -= 2 * maximumTimeSkip;
}
else if (lContiguous.Duration >= 30)
{
lContiguous.End -= maximumTimeSkip;
rContiguous.End -= maximumTimeSkip;
}
}
return (lContiguous, rContiguous); return (lContiguous, rContiguous);
} }
/// <summary> /// <summary>
/// Adjusts the end timestamps of all intros so that they end at silence. /// Adjusts the end timestamps of all intros so that they end at silence.
/// </summary> /// </summary>
/// <param name="episode">QueuedEpisode to adjust.</param> /// <param name="episodes">QueuedEpisodes to adjust.</param>
/// <param name="originalIntro">Original introduction.</param> /// <param name="originalIntros">Original introductions.</param>
private Segment AdjustIntroTimes( private Dictionary<Guid, Intro> AdjustIntroEndTimes(
QueuedEpisode episode, ReadOnlyCollection<QueuedEpisode> episodes,
Segment originalIntro) Dictionary<Guid, Intro> originalIntros)
{ {
// The minimum duration of audio that must be silent before adjusting the intro's end.
var minimumSilence = Plugin.Instance!.Configuration.SilenceDetectionMinimumDuration;
Dictionary<Guid, Intro> modifiedIntros = new();
// For all episodes
foreach (var episode in episodes)
{
_logger.LogTrace(
"Adjusting introduction end time for {Name} ({Id})",
episode.Name,
episode.EpisodeId);
// If no intro was found for this episode, skip it.
if (!originalIntros.TryGetValue(episode.EpisodeId, out var originalIntro))
{
_logger.LogTrace("{Name} does not have an intro", episode.Name);
continue;
}
// Only adjust the end timestamp of the intro
var originalIntroEnd = new TimeRange(originalIntro.IntroEnd - 15, originalIntro.IntroEnd);
_logger.LogTrace( _logger.LogTrace(
"{Name} original intro: {Start} - {End}", "{Name} original intro: {Start} - {End}",
episode.Name, episode.Name,
originalIntro.Start, originalIntro.IntroStart,
originalIntro.End); originalIntro.IntroEnd);
var originalIntroStart = new TimeRange( // Detect silence in the media file up to the end of the intro.
Math.Max(0, (int)originalIntro.Start - 5), var silence = FFmpegWrapper.DetectSilence(episode, (int)originalIntro.IntroEnd + 2);
(int)originalIntro.Start + 10);
var originalIntroEnd = new TimeRange( // For all periods of silence
(int)originalIntro.End - 10, foreach (var currentRange in silence)
Math.Min(episode.Duration, (int)originalIntro.End + 5));
// Try to adjust based on chapters first, fall back to silence detection for intros
if (!AdjustIntroBasedOnChapters(episode, originalIntro, originalIntroStart, originalIntroEnd) &&
_analysisMode == AnalysisMode.Introduction)
{ {
AdjustIntroBasedOnSilence(episode, originalIntro, originalIntroEnd); _logger.LogTrace(
"{Name} silence: {Start} - {End}",
episode.Name,
currentRange.Start,
currentRange.End);
// Ignore any silence that:
// * doesn't intersect the ending of the intro, or
// * is shorter than the user defined minimum duration, or
// * starts before the introduction does
if (
!originalIntroEnd.Intersects(currentRange) ||
currentRange.Duration < silenceDetectionMinimumDuration ||
currentRange.Start < originalIntro.IntroStart)
{
continue;
}
// Adjust the end timestamp of the intro to match the start of the silence region.
originalIntro.IntroEnd = currentRange.Start;
break;
} }
_logger.LogTrace( _logger.LogTrace(
"{Name} adjusted intro: {Start} - {End}", "{Name} adjusted intro: {Start} - {End}",
episode.Name, episode.Name,
originalIntro.Start, originalIntro.IntroStart,
originalIntro.End); originalIntro.IntroEnd);
return originalIntro; // Add the (potentially) modified intro back.
modifiedIntros[episode.EpisodeId] = originalIntro;
} }
private bool AdjustIntroBasedOnChapters( return modifiedIntros;
QueuedEpisode episode,
Segment intro,
TimeRange originalIntroStart,
TimeRange originalIntroEnd)
{
var chapters = Plugin.Instance?.GetChapters(episode.EpisodeId) ?? [];
double previousTime = 0;
for (int i = 0; i <= chapters.Count; i++)
{
double currentTime = i < chapters.Count
? TimeSpan.FromTicks(chapters[i].StartPositionTicks).TotalSeconds
: episode.Duration;
if (IsTimeWithinRange(previousTime, originalIntroStart))
{
intro.Start = previousTime;
_logger.LogTrace("{Name} chapter found close to intro start: {Start}", episode.Name, previousTime);
}
if (IsTimeWithinRange(currentTime, originalIntroEnd))
{
intro.End = currentTime;
_logger.LogTrace("{Name} chapter found close to intro end: {End}", episode.Name, currentTime);
return true;
}
previousTime = currentTime;
}
return false;
}
private void AdjustIntroBasedOnSilence(QueuedEpisode episode, Segment intro, TimeRange originalIntroEnd)
{
var silenceRanges = FFmpegWrapper.DetectSilence(episode, originalIntroEnd);
foreach (var silenceRange in silenceRanges)
{
_logger.LogTrace("{Name} silence: {Start} - {End}", episode.Name, silenceRange.Start, silenceRange.End);
if (IsValidSilenceForIntroAdjustment(silenceRange, originalIntroEnd, intro))
{
intro.End = silenceRange.Start;
break;
}
}
}
private bool IsValidSilenceForIntroAdjustment(
TimeRange silenceRange,
TimeRange originalIntroEnd,
Segment adjustedIntro)
{
return originalIntroEnd.Intersects(silenceRange) &&
silenceRange.Duration >= _config.SilenceDetectionMinimumDuration &&
silenceRange.Start >= adjustedIntro.Start;
}
private static bool IsTimeWithinRange(double time, TimeRange range)
{
return range.Start < time && time < range.End;
}
/// <summary>
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
/// </summary>
/// <param name="id">Episode ID.</param>
/// <param name="fingerprint">Chromaprint fingerprint.</param>
/// <returns>Inverted index.</returns>
public Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint)
{
if (_invertedIndexCache.TryGetValue(id, out var cached))
{
return cached;
}
var invIndex = new Dictionary<uint, int>();
for (int i = 0; i < fingerprint.Length; i++)
{
// Get the current point.
var point = fingerprint[i];
// Append the current sample's timecode to the collection for this point.
invIndex[point] = i;
}
_invertedIndexCache[id] = invIndex;
return invIndex;
} }
/// <summary> /// <summary>
@ -487,7 +484,7 @@ public class ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger) : IMediaFi
/// </summary> /// </summary>
/// <param name="number">Number to count bits in.</param> /// <param name="number">Number to count bits in.</param>
/// <returns>Number of bits that are equal to 1.</returns> /// <returns>Number of bits that are equal to 1.</returns>
public static int CountBits(uint number) public int CountBits(uint number)
{ {
return BitOperations.PopCount(number); return BitOperations.PopCount(number);
} }

View File

@ -1,12 +1,10 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using System.Collections.Generic; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data;
namespace IntroSkipper.Analyzers; using System.Collections.ObjectModel;
using System.Threading;
/// <summary> /// <summary>
/// Media file analyzer interface. /// Media file analyzer interface.
@ -20,8 +18,8 @@ public interface IMediaFileAnalyzer
/// <param name="mode">Analysis mode.</param> /// <param name="mode">Analysis mode.</param>
/// <param name="cancellationToken">Cancellation token from scheduled task.</param> /// <param name="cancellationToken">Cancellation token from scheduled task.</param>
/// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns> /// <returns>Collection of media files that were **unsuccessfully analyzed**.</returns>
Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles( public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue, ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode, AnalysisMode mode,
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }

View File

@ -0,0 +1,39 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
/// <summary>
/// Chapter name analyzer.
/// </summary>
public class SegmentAnalyzer : IMediaFileAnalyzer
{
private ILogger<SegmentAnalyzer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SegmentAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public SegmentAnalyzer(ILogger<SegmentAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
return analysisQueue;
}
}

View File

@ -0,0 +1,231 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Automatically skip past introduction sequences.
/// Commands clients to seek to the end of the intro as soon as they start playing it.
/// </summary>
public class AutoSkip : IServerEntryPoint
{
private readonly object _sentSeekCommandLock = new();
private ILogger<AutoSkip> _logger;
private IUserDataManager _userDataManager;
private ISessionManager _sessionManager;
private System.Timers.Timer _playbackTimer = new(1000);
private Dictionary<string, bool> _sentSeekCommand;
/// <summary>
/// Initializes a new instance of the <see cref="AutoSkip"/> class.
/// </summary>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public AutoSkip(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkip> logger)
{
_userDataManager = userDataManager;
_sessionManager = sessionManager;
_logger = logger;
_sentSeekCommand = new Dictionary<string, bool>();
}
/// <summary>
/// If introduction auto skipping is enabled, set it up.
/// </summary>
/// <returns>Task.</returns>
public Task RunAsync()
{
_logger.LogDebug("Setting up automatic skipping");
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
Plugin.Instance!.AutoSkipChanged += AutoSkipChanged;
// Make the timer restart automatically and set enabled to match the configuration value.
_playbackTimer.AutoReset = true;
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
AutoSkipChanged(null, EventArgs.Empty);
return Task.CompletedTask;
}
private void AutoSkipChanged(object? sender, EventArgs e)
{
var newState = Plugin.Instance!.Configuration.AutoSkip;
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
_playbackTimer.Enabled = newState;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
{
return;
}
// Lookup the session for this item.
SessionInfo? session = null;
try
{
foreach (var needle in _sessionManager.Sessions)
{
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
{
session = needle;
break;
}
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
// If this is the first episode in the season, and SkipFirstEpisode is false, pretend that we've already sent the seek command for this playback session.
if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
{
newState = true;
}
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
var device = session.DeviceId;
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
}
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions)
{
var deviceId = session.DeviceId;
var itemId = session.NowPlayingItem.Id;
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
// Don't send the seek command more than once in the same session.
lock (_sentSeekCommandLock)
{
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
{
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
continue;
}
}
// Assert that an intro was detected for this item.
if (!Plugin.Instance!.Intros.TryGetValue(itemId, out var intro) || !intro.Valid)
{
continue;
}
// Seek is unreliable if called at the very start of an episode.
var adjustedStart = Math.Max(5, intro.IntroStart + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay);
var adjustedEnd = intro.IntroEnd - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
_logger.LogTrace(
"Playback position is {Position}, intro runs from {Start} to {End}",
position,
adjustedStart,
adjustedEnd);
if (position < adjustedStart || position > adjustedEnd)
{
continue;
}
// Notify the user that an introduction is being skipped for them.
var notificationText = Plugin.Instance!.Configuration.AutoSkipNotificationText;
if (!string.IsNullOrWhiteSpace(notificationText))
{
_sessionManager.SendMessageCommand(
session.Id,
session.Id,
new MessageCommand()
{
Header = string.Empty, // some clients require header to be a string instead of null
Text = notificationText,
TimeoutMs = 2000,
},
CancellationToken.None);
}
_logger.LogDebug("Sending seek command to {Session}", deviceId);
_sessionManager.SendPlaystateCommand(
session.Id,
session.Id,
new PlaystateRequest
{
Command = PlaystateCommand.Seek,
ControllingUserId = session.UserId.ToString("N"),
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
},
CancellationToken.None);
// Flag that we've sent the seek command so that it's not sent repeatedly
lock (_sentSeekCommandLock)
{
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
_sentSeekCommand[deviceId] = true;
}
}
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
_playbackTimer.Stop();
_playbackTimer.Dispose();
}
}

View File

@ -0,0 +1,225 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Automatically skip past credit sequences.
/// Commands clients to seek to the end of the credits as soon as they start playing it.
/// </summary>
public class AutoSkipCredits : IServerEntryPoint
{
private readonly object _sentSeekCommandLock = new();
private ILogger<AutoSkipCredits> _logger;
private IUserDataManager _userDataManager;
private ISessionManager _sessionManager;
private System.Timers.Timer _playbackTimer = new(1000);
private Dictionary<string, bool> _sentSeekCommand;
/// <summary>
/// Initializes a new instance of the <see cref="AutoSkipCredits"/> class.
/// </summary>
/// <param name="userDataManager">User data manager.</param>
/// <param name="sessionManager">Session manager.</param>
/// <param name="logger">Logger.</param>
public AutoSkipCredits(
IUserDataManager userDataManager,
ISessionManager sessionManager,
ILogger<AutoSkipCredits> logger)
{
_userDataManager = userDataManager;
_sessionManager = sessionManager;
_logger = logger;
_sentSeekCommand = new Dictionary<string, bool>();
}
/// <summary>
/// If credits auto skipping is enabled, set it up.
/// </summary>
/// <returns>Task.</returns>
public Task RunAsync()
{
_logger.LogDebug("Setting up automatic skipping");
_userDataManager.UserDataSaved += UserDataManager_UserDataSaved;
Plugin.Instance!.AutoSkipCreditsChanged += AutoSkipCreditsChanged;
// Make the timer restart automatically and set enabled to match the configuration value.
_playbackTimer.AutoReset = true;
_playbackTimer.Elapsed += PlaybackTimer_Elapsed;
AutoSkipCreditsChanged(null, EventArgs.Empty);
return Task.CompletedTask;
}
private void AutoSkipCreditsChanged(object? sender, EventArgs e)
{
var newState = Plugin.Instance!.Configuration.AutoSkipCredits;
_logger.LogDebug("Setting playback timer enabled to {NewState}", newState);
_playbackTimer.Enabled = newState;
}
private void UserDataManager_UserDataSaved(object? sender, UserDataSaveEventArgs e)
{
var itemId = e.Item.Id;
var newState = false;
var episodeNumber = e.Item.IndexNumber.GetValueOrDefault(-1);
// Ignore all events except playback start & end
if (e.SaveReason != UserDataSaveReason.PlaybackStart && e.SaveReason != UserDataSaveReason.PlaybackFinished)
{
return;
}
// Lookup the session for this item.
SessionInfo? session = null;
try
{
foreach (var needle in _sessionManager.Sessions)
{
if (needle.UserId == e.UserId && needle.NowPlayingItem.Id == itemId)
{
session = needle;
break;
}
}
if (session == null)
{
_logger.LogInformation("Unable to find session for {Item}", itemId);
return;
}
}
catch (Exception ex) when (ex is NullReferenceException || ex is ResourceNotFoundException)
{
return;
}
// Reset the seek command state for this device.
lock (_sentSeekCommandLock)
{
var device = session.DeviceId;
_logger.LogDebug("Resetting seek command state for session {Session}", device);
_sentSeekCommand[device] = newState;
}
}
private void PlaybackTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
foreach (var session in _sessionManager.Sessions)
{
var deviceId = session.DeviceId;
var itemId = session.NowPlayingItem.Id;
var position = session.PlayState.PositionTicks / TimeSpan.TicksPerSecond;
// Don't send the seek command more than once in the same session.
lock (_sentSeekCommandLock)
{
if (_sentSeekCommand.TryGetValue(deviceId, out var sent) && sent)
{
_logger.LogTrace("Already sent seek command for session {Session}", deviceId);
continue;
}
}
// Assert that credits were detected for this item.
if (!Plugin.Instance!.Credits.TryGetValue(itemId, out var credit) || !credit.Valid)
{
continue;
}
// Seek is unreliable if called at the very end of an episode.
var adjustedStart = credit.IntroStart + Plugin.Instance.Configuration.SecondsOfCreditsStartToPlay;
var adjustedEnd = credit.IntroEnd - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
_logger.LogTrace(
"Playback position is {Position}, credits run from {Start} to {End}",
position,
adjustedStart,
adjustedEnd);
if (position < adjustedStart || position > adjustedEnd)
{
continue;
}
// Notify the user that credits are being skipped for them.
var notificationText = Plugin.Instance!.Configuration.AutoSkipCreditsNotificationText;
if (!string.IsNullOrWhiteSpace(notificationText))
{
_sessionManager.SendMessageCommand(
session.Id,
session.Id,
new MessageCommand()
{
Header = string.Empty, // some clients require header to be a string instead of null
Text = notificationText,
TimeoutMs = 2000,
},
CancellationToken.None);
}
_logger.LogDebug("Sending seek command to {Session}", deviceId);
_sessionManager.SendPlaystateCommand(
session.Id,
session.Id,
new PlaystateRequest
{
Command = PlaystateCommand.Seek,
ControllingUserId = session.UserId.ToString("N"),
SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
},
CancellationToken.None);
// Flag that we've sent the seek command so that it's not sent repeatedly
lock (_sentSeekCommandLock)
{
_logger.LogTrace("Setting seek command state for session {Session}", deviceId);
_sentSeekCommand[deviceId] = true;
}
}
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_userDataManager.UserDataSaved -= UserDataManager_UserDataSaved;
_playbackTimer.Stop();
_playbackTimer.Dispose();
}
}

View File

@ -3,10 +3,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using IntroSkipper.Data;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
namespace IntroSkipper.Configuration; namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
/// <summary> /// <summary>
/// Plugin configuration. /// Plugin configuration.
@ -23,34 +22,34 @@ public class PluginConfiguration : BasePluginConfiguration
// ===== Analysis settings ===== // ===== Analysis settings =====
/// <summary> /// <summary>
/// Gets or sets the comma separated list of library names to analyze. /// Gets or sets the max degree of parallelism used when analyzing episodes.
/// </summary>
public int MaxParallelism { get; set; } = 2;
/// <summary>
/// Gets or sets the comma separated list of library names to analyze. If empty, all libraries will be analyzed.
/// </summary> /// </summary>
public string SelectedLibraries { get; set; } = string.Empty; public string SelectedLibraries { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether all libraries should be analyzed. /// Gets a temporary limitation on file paths to be analyzed. Should be empty when automatic scan is idle.
/// </summary> /// </summary>
public bool SelectAllLibraries { get; set; } = true; public IList<string> PathRestrictions { get; } = new List<string>();
/// <summary> /// <summary>
/// Gets or sets a value indicating whether movies should be analyzed. /// Gets or sets a value indicating whether to scan for intros during a scheduled task.
/// </summary> /// </summary>
public bool AnalyzeMovies { get; set; } public bool AutoDetectIntros { get; set; } = false;
/// <summary> /// <summary>
/// Gets or sets the list of client to auto skip for. /// Gets or sets a value indicating whether to scan for credits during a scheduled task.
/// </summary> /// </summary>
public string ClientList { get; set; } = string.Empty; public bool AutoDetectCredits { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to automatically scan newly added items.
/// </summary>
public bool AutoDetectIntros { get; set; } = true;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to analyze season 0. /// Gets or sets a value indicating whether to analyze season 0.
/// </summary> /// </summary>
public bool AnalyzeSeasonZero { get; set; } public bool AnalyzeSeasonZero { get; set; } = false;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem. /// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem.
@ -60,44 +59,24 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets a value indicating whether analysis will use Chromaprint to determine fingerprints. /// Gets or sets a value indicating whether analysis will use Chromaprint to determine fingerprints.
/// </summary> /// </summary>
public bool WithChromaprint { get; set; } = true; public bool UseChromaprint { get; set; } = true;
// ===== Media Segment handling ===== // ===== EDL handling =====
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to update Media Segments. /// Gets or sets a value indicating the action to write to created EDL files.
/// </summary> /// </summary>
public bool UpdateMediaSegments { get; set; } = true; public EdlAction EdlAction { get; set; } = EdlAction.None;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to regenerate all Media Segments during the next scan. /// Gets or sets a value indicating whether to regenerate all EDL files during the next scan.
/// By default, Media Segments are only written for a season if the season had at least one newly analyzed episode. /// By default, EDL files are only written for a season if the season had at least one newly analyzed episode.
/// If this is set, all Media Segments will be regenerated and overwrite any existing Media Segemnts. /// If this is set, all EDL files will be regenerated and overwrite any existing EDL file.
/// </summary> /// </summary>
public bool RebuildMediaSegments { get; set; } = true; public bool RegenerateEdlFiles { get; set; } = false;
// ===== Custom analysis settings ===== // ===== Custom analysis settings =====
/// <summary>
/// Gets or sets a value indicating whether Introductions should be analyzed.
/// </summary>
public bool ScanIntroduction { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Credits should be analyzed.
/// </summary>
public bool ScanCredits { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Recaps should be analyzed.
/// </summary>
public bool ScanRecap { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether Previews should be analyzed.
/// </summary>
public bool ScanPreview { get; set; } = true;
/// <summary> /// <summary>
/// Gets or sets the percentage of each episode's audio track to analyze. /// Gets or sets the percentage of each episode's audio track to analyze.
/// </summary> /// </summary>
@ -126,12 +105,7 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits. /// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits.
/// </summary> /// </summary>
public int MaximumCreditsDuration { get; set; } = 450; public int MaximumCreditsDuration { get; set; } = 300;
/// <summary>
/// Gets or sets the upper limit (in seconds) on the length of a movie segment that will be analyzed when searching for ending credits.
/// </summary>
public int MaximumMovieCreditsDuration { get; set; } = 900;
/// <summary> /// <summary>
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame. /// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
@ -142,68 +116,31 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the regular expression used to detect introduction chapters. /// Gets or sets the regular expression used to detect introduction chapters.
/// </summary> /// </summary>
public string ChapterAnalyzerIntroductionPattern { get; set; } = public string ChapterAnalyzerIntroductionPattern { get; set; } =
@"(^|\s)(Intro|Introduction|OP|Opening)(?!\sEnd)(\s|$)"; @"(^|\s)(Intro|Introduction|OP|Opening)(\s|$)";
/// <summary> /// <summary>
/// Gets or sets the regular expression used to detect ending credit chapters. /// Gets or sets the regular expression used to detect ending credit chapters.
/// </summary> /// </summary>
public string ChapterAnalyzerEndCreditsPattern { get; set; } = public string ChapterAnalyzerEndCreditsPattern { get; set; } =
@"(^|\s)(Credits?|ED|Ending|Outro)(?!\sEnd)(\s|$)"; @"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)";
/// <summary>
/// Gets or sets the regular expression used to detect Preview chapters.
/// </summary>
public string ChapterAnalyzerPreviewPattern { get; set; } =
@"(^|\s)(Preview|PV|Sneak\s?Peek|Coming\s?(Up|Soon)|Next\s+(time|on|episode)|Extra|Teaser|Trailer)(?!\sEnd)(\s|:|$)";
/// <summary>
/// Gets or sets the regular expression used to detect Recap chapters.
/// </summary>
public string ChapterAnalyzerRecapPattern { get; set; } =
@"(^|\s)(Re?cap|Sum{1,2}ary|Prev(ious(ly)?)?|(Last|Earlier)(\s\w+)?|Catch[ -]up)(?!\sEnd)(\s|:|$)";
// ===== Playback settings ===== // ===== Playback settings =====
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to show the skip intro button. /// Gets or sets a value indicating whether to show the skip intro button.
/// </summary> /// </summary>
public bool SkipButtonEnabled { get; set; } public bool SkipButtonVisible { get; set; } = true;
/// <summary>
/// Gets a value indicating whether to show the skip intro warning.
/// </summary>
public bool SkipButtonWarning { get => WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton); }
/// <summary>
/// Gets or sets a value indicating whether plugin options are presented to the user.
/// </summary>
public bool PluginSkip { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether introductions should be automatically skipped. /// Gets or sets a value indicating whether introductions should be automatically skipped.
/// </summary> /// </summary>
public bool AutoSkip { get; set; } public bool AutoSkip { get; set; }
/// <summary>
/// Gets or sets the list of segment types to auto skip.
/// </summary>
public string TypeList { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether credits should be automatically skipped. /// Gets or sets a value indicating whether credits should be automatically skipped.
/// </summary> /// </summary>
public bool AutoSkipCredits { get; set; } public bool AutoSkipCredits { get; set; }
/// <summary>
/// Gets or sets a value indicating whether recap should be automatically skipped.
/// </summary>
public bool AutoSkipRecap { get; set; }
/// <summary>
/// Gets or sets a value indicating whether preview should be automatically skipped.
/// </summary>
public bool AutoSkipPreview { get; set; }
/// <summary> /// <summary>
/// Gets or sets the seconds before the intro starts to show the skip prompt at. /// Gets or sets the seconds before the intro starts to show the skip prompt at.
/// </summary> /// </summary>
@ -232,7 +169,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the amount of intro at start to play (in seconds). /// Gets or sets the amount of intro at start to play (in seconds).
/// </summary> /// </summary>
public int SecondsOfIntroStartToPlay { get; set; } public int SecondsOfIntroStartToPlay { get; set; } = 0;
/// <summary>
/// Gets or sets the amount of credit at start to play (in seconds).
/// </summary>
public int SecondsOfCreditsStartToPlay { get; set; } = 0;
// ===== Internal algorithm settings ===== // ===== Internal algorithm settings =====
@ -278,25 +220,20 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the notification text sent after automatically skipping an introduction. /// Gets or sets the notification text sent after automatically skipping an introduction.
/// </summary> /// </summary>
public string AutoSkipNotificationText { get; set; } = "Segment skipped"; public string AutoSkipNotificationText { get; set; } = "Intro skipped";
/// <summary> /// <summary>
/// Gets or sets the max degree of parallelism used when analyzing episodes. /// Gets or sets the notification text sent after automatically skipping credits.
/// </summary> /// </summary>
public int MaxParallelism { get; set; } = 2; public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
/// <summary> /// <summary>
/// Gets or sets the number of threads for a ffmpeg process. /// Gets or sets the number of threads for an ffmpeg process.
/// </summary> /// </summary>
public int ProcessThreads { get; set; } public int ProcessThreads { get; set; } = 0;
/// <summary> /// <summary>
/// Gets or sets the relative priority for a ffmpeg process. /// Gets or sets the relative priority for an ffmpeg process.
/// </summary> /// </summary>
public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal; public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal;
/// <summary>
/// Gets or sets a value indicating whether the ManifestUrl is self-managed, e.g. for mainland China.
/// </summary>
public bool OverrideManifestUrl { get; set; }
} }

View File

@ -0,0 +1,38 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
/// <summary>
/// User interface configuration.
/// </summary>
public class UserInterfaceConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="UserInterfaceConfiguration"/> class.
/// </summary>
/// <param name="visible">Skip button visibility.</param>
/// <param name="introText">Skip button intro text.</param>
/// <param name="creditsText">Skip button end credits text.</param>
public UserInterfaceConfiguration(bool visible, string introText, string creditsText)
{
SkipButtonVisible = visible;
SkipButtonIntroText = introText;
SkipButtonEndCreditsText = creditsText;
}
/// <summary>
/// Gets or sets a value indicating whether to show the skip intro button.
/// </summary>
public bool SkipButtonVisible { get; set; }
/// <summary>
/// Gets or sets the text to display in the skip intro button in introduction mode.
/// </summary>
public string SkipButtonIntroText { get; set; }
/// <summary>
/// Gets or sets the text to display in the skip intro button in end credits mode.
/// </summary>
public string SkipButtonEndCreditsText { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,225 @@
let introSkipper = {
skipSegments: {},
videoPlayer: {},
// .bind() is used here to prevent illegal invocation errors
originalFetch: window.fetch.bind(window),
};
introSkipper.d = function (msg) {
console.debug("[intro skipper] ", msg);
}
/** Setup event listeners */
introSkipper.setup = function () {
document.addEventListener("viewshow", introSkipper.viewShow);
window.fetch = introSkipper.fetchWrapper;
introSkipper.d("Registered hooks");
}
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
introSkipper.fetchWrapper = async function (...args) {
// Based on JellyScrub's trickplay.js
let [resource, options] = args;
let response = await introSkipper.originalFetch(resource, options);
// Bail early if this isn't a playback info URL
try {
let path = new URL(resource).pathname;
if (!path.includes("/PlaybackInfo")) { return response; }
introSkipper.d("Retrieving skip segments from URL");
introSkipper.d(path);
// Check for context root and set id accordingly
let path_arr = path.split("/");
let id = "";
if (path_arr[1] == "Items") {
id = path_arr[2];
} else {
id = path_arr[3];
}
introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`);
introSkipper.d("Successfully retrieved skip segments");
introSkipper.d(introSkipper.skipSegments);
}
catch (e) {
console.error("Unable to get skip segments from", resource, e);
}
return response;
}
/**
* Event handler that runs whenever the current view changes.
* Used to detect the start of video playback.
*/
introSkipper.viewShow = function () {
const location = window.location.hash;
introSkipper.d("Location changed to " + location);
if (location !== "#!/video") {
introSkipper.d("Ignoring location change");
return;
}
introSkipper.injectCss();
introSkipper.injectButton();
introSkipper.videoPlayer = document.querySelector("video");
if (introSkipper.videoPlayer != null) {
introSkipper.d("Hooking video timeupdate");
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
}
}
/**
* Injects the CSS used by the skip intro button.
* Calling this function is a no-op if the CSS has already been injected.
*/
introSkipper.injectCss = function () {
if (introSkipper.testElement("style#introSkipperCss")) {
introSkipper.d("CSS already added");
return;
}
introSkipper.d("Adding CSS");
let styleElement = document.createElement("style");
styleElement.id = "introSkipperCss";
styleElement.innerText = `
:root {
--rounding: .2em;
--accent: 0, 164, 220;
}
#skipIntro.upNextContainer {
width: unset;
}
#skipIntro {
position: absolute;
bottom: 6em;
right: 4.5em;
background-color: transparent;
font-size: 1.2em;
}
#skipIntro .emby-button {
text-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
border-radius: var(--rounding);
background-color: rgba(0, 0, 0, 0.3);
will-change: opacity, transform;
opacity: 0;
transition: opacity 0.3s ease-in, transform 0.3s ease-out;
}
#skipIntro .emby-button:hover,
#skipIntro .emby-button:focus {
background-color: rgba(var(--accent),0.7);
transform: scale(1.05);
}
#btnSkipSegmentText {
padding-right: 0.15em;
padding-left: 0.2em;
margin-top: -0.1em;
}
`;
document.querySelector("head").appendChild(styleElement);
}
/**
* Inject the skip intro button into the video player.
* Calling this function is a no-op if the CSS has already been injected.
*/
introSkipper.injectButton = async function () {
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
const preExistingButton = introSkipper.testElement("div.skipIntro");
if (preExistingButton) {
preExistingButton.style.display = "none";
}
if (introSkipper.testElement(".btnSkipIntro.injected")) {
introSkipper.d("Button already added");
return;
}
introSkipper.d("Adding button");
let config = await introSkipper.secureFetch("Intros/UserInterfaceConfiguration");
if (!config.SkipButtonVisible) {
introSkipper.d("Not adding button: not visible");
return;
}
// Construct the skip button div
const button = document.createElement("div");
button.id = "skipIntro"
button.classList.add("hide");
button.addEventListener("click", introSkipper.doSkip);
button.innerHTML = `
<button is="emby-button" type="button" class="btnSkipIntro injected">
<span id="btnSkipSegmentText"></span>
<span class="material-icons skip_next"></span>
</button>
`;
button.dataset["intro_text"] = config.SkipButtonIntroText;
button.dataset["credits_text"] = config.SkipButtonEndCreditsText;
/*
* Alternative workaround for #44. Jellyfin's video component registers a global click handler
* (located at src/controllers/playback/video/index.js:1492) that pauses video playback unless
* the clicked element has a parent with the class "videoOsdBottom" or "upNextContainer".
*/
button.classList.add("upNextContainer");
// Append the button to the video OSD
let controls = document.querySelector("div#videoOsdPage");
controls.appendChild(button);
}
/** Tests if the OSD controls are visible. */
introSkipper.osdVisible = function () {
const osd = document.querySelector("div.videoOsdBottom");
return osd ? !osd.classList.contains("hide") : false;
}
/** Get the currently playing skippable segment. */
introSkipper.getCurrentSegment = function (position) {
for (let key in introSkipper.skipSegments) {
const segment = introSkipper.skipSegments[key];
if ((position >= segment.ShowSkipPromptAt && position < segment.HideSkipPromptAt) || (introSkipper.osdVisible() && position >= segment.IntroStart && position < segment.IntroEnd)) {
segment["SegmentType"] = key;
return segment;
}
}
return { "SegmentType": "None" };
}
/** Playback position changed, check if the skip button needs to be displayed. */
introSkipper.videoPositionChanged = function () {
const skipButton = document.querySelector("#skipIntro");
if (!skipButton) {
return;
}
const embyButton = skipButton.querySelector(".emby-button");
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
switch (segment.SegmentType) {
case "None":
if (embyButton.style.opacity === '0') return;
embyButton.style.opacity = '0';
embyButton.addEventListener("transitionend", () => {
skipButton.classList.add("hide");
}, { once: true });
return;
case "Introduction":
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.intro_text;
break;
case "Credits":
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.credits_text;
break;
}
if (!skipButton.classList.contains("hide")) return;
skipButton.classList.remove("hide");
embyButton.offsetWidth; // Force reflow
requestAnimationFrame(() => {
embyButton.style.opacity = '1';
});
}
/** Seeks to the end of the intro. */
introSkipper.doSkip = function (e) {
introSkipper.d("Skipping intro");
introSkipper.d(introSkipper.skipSegments);
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
if (segment["SegmentType"] === "None") {
console.warn("[intro skipper] doSkip() called without an active segment");
return;
}
introSkipper.videoPlayer.currentTime = segment["IntroEnd"];
}
/** Tests if an element with the provided selector exists. */
introSkipper.testElement = function (selector) { return document.querySelector(selector); }
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
introSkipper.secureFetch = async function (url) {
url = ApiClient.serverAddress() + "/" + url;
const reqInit = { headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() } };
const res = await fetch(url, reqInit);
if (res.status !== 200) { throw new Error(`Expected status 200 from ${url}, but got ${res.status}`); }
return await res.json();
}
introSkipper.setup();

View File

@ -0,0 +1 @@
unknown

View File

@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<RootNamespace>IntroSkipper</RootNamespace> <RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
<AssemblyVersion>1.10.10.11</AssemblyVersion> <AssemblyVersion>0.10.8.1</AssemblyVersion>
<FileVersion>1.10.10.11</FileVersion> <FileVersion>0.10.8.1</FileVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -11,10 +11,8 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.10.*-*" /> <PackageReference Include="Jellyfin.Controller" Version="10.8.*" />
<PackageReference Include="Jellyfin.Model" Version="10.10.*-*" /> <PackageReference Include="Jellyfin.Model" Version="10.8.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556" PrivateAssets="All" />
@ -24,5 +22,6 @@
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\visualizer.js" /> <EmbeddedResource Include="Configuration\visualizer.js" />
<EmbeddedResource Include="Configuration\inject.js" /> <EmbeddedResource Include="Configuration\inject.js" />
<EmbeddedResource Include="Configuration\version.txt" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,261 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
/// <summary>
/// Skip intro controller.
/// </summary>
[Authorize]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
public class SkipIntroController : ControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SkipIntroController"/> class.
/// </summary>
public SkipIntroController()
{
}
/// <summary>
/// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
/// </summary>
/// <param name="id">ID of the episode. Required.</param>
/// <param name="mode">Timestamps to return. Optional. Defaults to Introduction for backwards compatibility.</param>
/// <response code="200">Episode contains an intro.</response>
/// <response code="404">Failed to find an intro in the provided episode.</response>
/// <returns>Detected intro.</returns>
[HttpGet("Episode/{id}/IntroTimestamps")]
[HttpGet("Episode/{id}/IntroTimestamps/v1")]
public ActionResult<Intro> GetIntroTimestamps(
[FromRoute] Guid id,
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{
var intro = GetIntro(id, mode);
if (intro is null || !intro.Valid)
{
return NotFound();
}
return intro;
}
/// <summary>
/// Updates the timestamps for the provided episode.
/// </summary>
/// <param name="id">Episode ID to update timestamps for.</param>
/// <param name="timestamps">New timestamps Introduction/Credits start and end times.</param>
/// <response code="204">New timestamps saved.</response>
/// <response code="404">Given ID is not an Episode.</response>
/// <returns>No content.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpPost("Episode/{Id}/Timestamps")]
public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] TimeStamps timestamps)
{
// only update existing episodes
var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem == null || rawItem is not Episode episode)
{
return NotFound();
}
if (timestamps?.Introduction.IntroEnd > 0.0)
{
var tr = new TimeRange(timestamps.Introduction.IntroStart, timestamps.Introduction.IntroEnd);
Plugin.Instance!.Intros[id] = new Intro(id, tr);
}
if (timestamps?.Credits.IntroEnd > 0.0)
{
var cr = new TimeRange(timestamps.Credits.IntroStart, timestamps.Credits.IntroEnd);
Plugin.Instance!.Credits[id] = new Intro(id, cr);
}
Plugin.Instance!.SaveTimestamps();
return NoContent();
}
/// <summary>
/// Gets the timestamps for the provided episode.
/// </summary>
/// <param name="id">Episode ID.</param>
/// <response code="200">Sucess.</response>
/// <response code="404">Given ID is not an Episode.</response>
/// <returns>Episode Timestamps.</returns>
[HttpGet("Episode/{Id}/Timestamps")]
[ActionName("UpdateTimestamps")]
public ActionResult<TimeStamps> GetTimestamps([FromRoute] Guid id)
{
// only get return content for episodes
var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem == null || rawItem is not Episode episode)
{
return NotFound();
}
var times = new TimeStamps();
if (Plugin.Instance!.Intros.TryGetValue(id, out var introValue))
{
times.Introduction = introValue;
}
if (Plugin.Instance!.Credits.TryGetValue(id, out var creditValue))
{
times.Credits = creditValue;
}
return times;
}
/// <summary>
/// Gets a dictionary of all skippable segments.
/// </summary>
/// <param name="id">Media ID.</param>
/// <response code="200">Skippable segments dictionary.</response>
/// <returns>Dictionary of skippable segments.</returns>
[HttpGet("Episode/{id}/IntroSkipperSegments")]
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
{
var segments = new Dictionary<AnalysisMode, Intro>();
if (GetIntro(id, AnalysisMode.Introduction) is Intro intro)
{
segments[AnalysisMode.Introduction] = intro;
}
if (GetIntro(id, AnalysisMode.Credits) is Intro credits)
{
segments[AnalysisMode.Credits] = credits;
}
return segments;
}
/// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
/// <param name="id">Unique identifier of this episode.</param>
/// <param name="mode">Mode.</param>
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
private Intro? GetIntro(Guid id, AnalysisMode mode)
{
try
{
var timestamp = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Intros[id] :
Plugin.Instance!.Credits[id];
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
var segment = new Intro(timestamp);
var config = Plugin.Instance.Configuration;
segment.IntroEnd -= config.RemainingSecondsOfIntro;
if (config.PersistSkipButton)
{
segment.ShowSkipPromptAt = segment.IntroStart;
segment.HideSkipPromptAt = segment.IntroEnd - 1;
}
else
{
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
segment.HideSkipPromptAt = Math.Min(
segment.IntroStart + config.HidePromptAdjustment,
segment.IntroEnd - 1);
}
return segment;
}
catch (KeyNotFoundException)
{
return null;
}
}
/// <summary>
/// Erases all previously discovered introduction timestamps.
/// </summary>
/// <param name="mode">Mode.</param>
/// <response code="204">Operation successful.</response>
/// <returns>No content.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpPost("Intros/EraseTimestamps")]
public ActionResult ResetIntroTimestamps([FromQuery] AnalysisMode mode)
{
if (mode == AnalysisMode.Introduction)
{
Plugin.Instance!.Intros.Clear();
}
else if (mode == AnalysisMode.Credits)
{
Plugin.Instance!.Credits.Clear();
}
Plugin.Instance!.SaveTimestamps();
return NoContent();
}
/// <summary>
/// Get all introductions or credits. Only used by the end to end testing script.
/// </summary>
/// <param name="mode">Mode.</param>
/// <response code="200">All timestamps have been returned.</response>
/// <returns>List of IntroWithMetadata objects.</returns>
[Authorize(Policy = "RequiresElevation")]
[HttpGet("Intros/All")]
public ActionResult<List<IntroWithMetadata>> GetAllTimestamps(
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{
List<IntroWithMetadata> intros = new();
var timestamps = mode == AnalysisMode.Introduction ?
Plugin.Instance!.Intros :
Plugin.Instance!.Credits;
// Get metadata for all intros
foreach (var intro in timestamps)
{
// Get the details of the item from Jellyfin
var rawItem = Plugin.Instance.GetItem(intro.Key);
if (rawItem == null || rawItem is not Episode episode)
{
throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
}
// Associate the metadata with the intro
intros.Add(
new IntroWithMetadata(
episode.SeriesName,
episode.AiredSeasonNumber ?? 0,
episode.Name,
intro.Value));
}
return intros;
}
/// <summary>
/// Gets the user interface configuration.
/// </summary>
/// <response code="200">UserInterfaceConfiguration returned.</response>
/// <returns>UserInterfaceConfiguration.</returns>
[HttpGet]
[Route("Intros/UserInterfaceConfiguration")]
public ActionResult<UserInterfaceConfiguration> GetUserInterfaceConfiguration()
{
var config = Plugin.Instance!.Configuration;
return new UserInterfaceConfiguration(
config.SkipButtonVisible,
config.SkipButtonIntroText,
config.SkipButtonEndCreditsText);
}
}

View File

@ -0,0 +1,88 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Net.Mime;
using System.Text;
using MediaBrowser.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
/// <summary>
/// Troubleshooting controller.
/// </summary>
[Authorize(Policy = "RequiresElevation")]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Route("IntroSkipper")]
public class TroubleshootingController : ControllerBase
{
private readonly IApplicationHost _applicationHost;
private readonly ILogger<TroubleshootingController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TroubleshootingController"/> class.
/// </summary>
/// <param name="applicationHost">Application host.</param>
/// <param name="logger">Logger.</param>
public TroubleshootingController(
IApplicationHost applicationHost,
ILogger<TroubleshootingController> logger)
{
_applicationHost = applicationHost;
_logger = logger;
}
/// <summary>
/// Gets a Markdown formatted support bundle.
/// </summary>
/// <response code="200">Support bundle created.</response>
/// <returns>Support bundle.</returns>
[HttpGet("SupportBundle")]
[Produces(MediaTypeNames.Text.Plain)]
public ActionResult<string> GetSupportBundle()
{
var config = Plugin.Instance!.Configuration;
var bundle = new StringBuilder();
bundle.Append("* Jellyfin version: ");
bundle.Append(_applicationHost.ApplicationVersionString);
bundle.Append('\n');
var version = Plugin.Instance!.Version.ToString(3);
try
{
var commit = Plugin.Instance!.GetCommit();
if (!string.IsNullOrWhiteSpace(commit))
{
version += string.Concat("+", commit.AsSpan(0, 12));
}
}
catch (Exception ex)
{
_logger.LogWarning("Unable to append commit to version: {Exception}", ex);
}
bundle.Append("* Plugin version: ");
bundle.Append(version);
bundle.Append('\n');
bundle.Append("* Queue contents: ");
bundle.Append(Plugin.Instance!.TotalQueued);
bundle.Append(" episodes, ");
bundle.Append(Plugin.Instance!.TotalSeasons);
bundle.Append(" seasons\n");
bundle.Append("* Warnings: `");
bundle.Append(WarningManager.GetWarnings());
bundle.Append("`\n");
bundle.Append(FFmpegWrapper.GetChromaprintLogs());
return bundle.ToString();
}
}

View File

@ -0,0 +1,210 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers;
/// <summary>
/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
/// </summary>
[Authorize(Policy = "RequiresElevation")]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Route("Intros")]
public class VisualizationController : ControllerBase
{
private readonly ILogger<VisualizationController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="VisualizationController"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public VisualizationController(ILogger<VisualizationController> logger)
{
_logger = logger;
}
/// <summary>
/// Returns all show names and seasons.
/// </summary>
/// <returns>Dictionary of show names to a list of season names.</returns>
[HttpGet("Shows")]
public ActionResult<Dictionary<string, HashSet<string>>> GetShowSeasons()
{
_logger.LogDebug("Returning season names by series");
var showSeasons = new Dictionary<string, HashSet<string>>();
// Loop through all seasons in the analysis queue
foreach (var kvp in Plugin.Instance!.QueuedMediaItems)
{
// Check that this season contains at least one episode.
var episodes = kvp.Value;
if (episodes is null || episodes.Count == 0)
{
_logger.LogDebug("Skipping season {Id} (null or empty)", kvp.Key);
continue;
}
// Peek at the top episode from this season and store the series name and season number.
var first = episodes[0];
var series = first.SeriesName;
var season = GetSeasonName(first);
// Validate the series and season before attempting to store it.
if (string.IsNullOrWhiteSpace(series) || string.IsNullOrWhiteSpace(season))
{
_logger.LogDebug("Skipping season {Id} (no name or number)", kvp.Key);
continue;
}
// TryAdd is used when adding the HashSet since it is a no-op if one was already created for this series.
showSeasons.TryAdd(series, new HashSet<string>());
showSeasons[series].Add(season);
}
return showSeasons;
}
/// <summary>
/// Returns the names and unique identifiers of all episodes in the provided season.
/// </summary>
/// <param name="series">Show name.</param>
/// <param name="season">Season name.</param>
/// <returns>List of episode titles.</returns>
[HttpGet("Show/{Series}/{Season}")]
public ActionResult<List<EpisodeVisualization>> GetSeasonEpisodes(
[FromRoute] string series,
[FromRoute] string season)
{
var visualEpisodes = new List<EpisodeVisualization>();
if (!LookupSeasonByName(series, season, out var episodes))
{
return NotFound();
}
foreach (var e in episodes)
{
visualEpisodes.Add(new EpisodeVisualization(e.EpisodeId, e.Name));
}
return visualEpisodes;
}
/// <summary>
/// Fingerprint the provided episode and returns the uncompressed fingerprint data points.
/// </summary>
/// <param name="id">Episode id.</param>
/// <returns>Read only collection of fingerprint points.</returns>
[HttpGet("Episode/{Id}/Chromaprint")]
public ActionResult<uint[]> GetEpisodeFingerprint([FromRoute] Guid id)
{
// Search through all queued episodes to find the requested id
foreach (var season in Plugin.Instance!.QueuedMediaItems)
{
foreach (var needle in season.Value)
{
if (needle.EpisodeId == id)
{
return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
}
}
}
return NotFound();
}
/// <summary>
/// Erases all timestamps for the provided season.
/// </summary>
/// <param name="series">Show name.</param>
/// <param name="season">Season name.</param>
/// <response code="204">Season timestamps erased.</response>
/// <response code="404">Unable to find season in provided series.</response>
/// <returns>No content.</returns>
[HttpDelete("Show/{Series}/{Season}")]
public ActionResult EraseSeason([FromRoute] string series, [FromRoute] string season)
{
if (!LookupSeasonByName(series, season, out var episodes))
{
return NotFound();
}
_logger.LogInformation("Erasing timestamps for {Series} {Season} at user request", series, season);
foreach (var e in episodes)
{
Plugin.Instance!.Intros.Remove(e.EpisodeId);
Plugin.Instance!.Credits.Remove(e.EpisodeId);
}
Plugin.Instance!.SaveTimestamps();
return NoContent();
}
/// <summary>
/// Updates the introduction timestamps for the provided episode.
/// </summary>
/// <param name="id">Episode ID to update timestamps for.</param>
/// <param name="timestamps">New introduction start and end times.</param>
/// <response code="204">New introduction timestamps saved.</response>
/// <returns>No content.</returns>
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
[Obsolete("deprecated use Episode/{Id}/Timestamps")]
public ActionResult UpdateIntroTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
{
if (timestamps.IntroEnd > 0.0)
{
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
Plugin.Instance!.Intros[id] = new Intro(id, tr);
Plugin.Instance.SaveTimestamps();
}
return NoContent();
}
private string GetSeasonName(QueuedEpisode episode)
{
return "Season " + episode.SeasonNumber.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// Lookup a named season of a series and return all queued episodes.
/// </summary>
/// <param name="series">Series name.</param>
/// <param name="season">Season name.</param>
/// <param name="episodes">Episodes.</param>
/// <returns>Boolean indicating if the requested season was found.</returns>
private bool LookupSeasonByName(string series, string season, out List<QueuedEpisode> episodes)
{
foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems)
{
var first = queuedEpisodes.Value[0];
var firstSeasonName = GetSeasonName(first);
// Assert that the queued episode series and season are equal to what was requested
if (
!string.Equals(first.SeriesName, series, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(firstSeasonName, season, StringComparison.OrdinalIgnoreCase))
{
continue;
}
episodes = queuedEpisodes.Value;
return true;
}
episodes = new List<QueuedEpisode>();
return false;
}
}

View File

@ -1,7 +1,7 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data; namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>
/// Type of media file analysis to perform. /// Type of media file analysis to perform.
@ -17,14 +17,4 @@ public enum AnalysisMode
/// Detect credits. /// Detect credits.
/// </summary> /// </summary>
Credits, Credits,
/// <summary>
/// Detect previews.
/// </summary>
Preview,
/// <summary>
/// Detect recaps.
/// </summary>
Recap,
} }

View File

@ -0,0 +1,31 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// A frame of video that partially (or entirely) consists of black pixels.
/// </summary>
public class BlackFrame
{
/// <summary>
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
/// </summary>
/// <param name="percent">Percentage of the frame that is black.</param>
/// <param name="time">Time this frame appears at.</param>
public BlackFrame(int percent, double time)
{
Percentage = percent;
Time = time;
}
/// <summary>
/// Gets or sets the percentage of the frame that is black.
/// </summary>
public int Percentage { get; set; }
/// <summary>
/// Gets or sets the time (in seconds) this frame appeared at.
/// </summary>
public double Time { get; set; }
}

View File

@ -0,0 +1,45 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL.
/// </summary>
public enum EdlAction
{
/// <summary>
/// Do not create EDL files.
/// </summary>
None = -1,
/// <summary>
/// Completely remove the intro from playback as if it was never in the original video.
/// </summary>
Cut,
/// <summary>
/// Mute audio, continue playback.
/// </summary>
Mute,
/// <summary>
/// Inserts a new scene marker.
/// </summary>
SceneMarker,
/// <summary>
/// Automatically skip the intro once during playback.
/// </summary>
CommercialBreak,
/// <summary>
/// Show a skip button.
/// </summary>
Intro,
/// <summary>
/// Show a skip button.
/// </summary>
Credit,
}

View File

@ -0,0 +1,33 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Episode name and internal ID as returned by the visualization controller.
/// </summary>
public class EpisodeVisualization
{
/// <summary>
/// Initializes a new instance of the <see cref="EpisodeVisualization"/> class.
/// </summary>
/// <param name="id">Episode id.</param>
/// <param name="name">Episode name.</param>
public EpisodeVisualization(Guid id, string name)
{
Id = id;
Name = name;
}
/// <summary>
/// Gets the id.
/// </summary>
public Guid Id { get; private set; }
/// <summary>
/// Gets the name.
/// </summary>
public string Name { get; private set; } = string.Empty;
}

View File

@ -3,7 +3,7 @@
using System; using System;
namespace IntroSkipper.Data; namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>
/// Exception raised when an error is encountered analyzing audio. /// Exception raised when an error is encountered analyzing audio.

View File

@ -0,0 +1,149 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Text.Json.Serialization;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Result of fingerprinting and analyzing two episodes in a season.
/// All times are measured in seconds relative to the beginning of the media file.
/// </summary>
public class Intro
{
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
/// <param name="episode">Episode.</param>
/// <param name="intro">Introduction time range.</param>
public Intro(Guid episode, TimeRange intro)
{
EpisodeId = episode;
IntroStart = intro.Start;
IntroEnd = intro.End;
}
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
/// <param name="episode">Episode.</param>
public Intro(Guid episode)
{
EpisodeId = episode;
IntroStart = 0;
IntroEnd = 0;
}
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
/// <param name="intro">intro.</param>
public Intro(Intro intro)
{
EpisodeId = intro.EpisodeId;
IntroStart = intro.IntroStart;
IntroEnd = intro.IntroEnd;
}
/// <summary>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </summary>
public Intro()
{
}
/// <summary>
/// Gets or sets the Episode ID.
/// </summary>
public Guid EpisodeId { get; set; }
/// <summary>
/// Gets a value indicating whether this introduction is valid or not.
/// Invalid results must not be returned through the API.
/// </summary>
public bool Valid => IntroEnd > 0;
/// <summary>
/// Gets the duration of this intro.
/// </summary>
[JsonIgnore]
public double Duration => IntroEnd - IntroStart;
/// <summary>
/// Gets or sets the introduction sequence start time.
/// </summary>
public double IntroStart { get; set; }
/// <summary>
/// Gets or sets the introduction sequence end time.
/// </summary>
public double IntroEnd { get; set; }
/// <summary>
/// Gets or sets the recommended time to display the skip intro prompt.
/// </summary>
public double ShowSkipPromptAt { get; set; }
/// <summary>
/// Gets or sets the recommended time to hide the skip intro prompt.
/// </summary>
public double HideSkipPromptAt { get; set; }
/// <summary>
/// Convert this Intro object to a Kodi compatible EDL entry.
/// </summary>
/// <param name="action">User specified configuration EDL action.</param>
/// <returns>String.</returns>
public string ToEdl(EdlAction action)
{
if (action == EdlAction.None)
{
throw new ArgumentException("Cannot serialize an EdlAction of None");
}
var start = Math.Round(IntroStart, 2);
var end = Math.Round(IntroEnd, 2);
return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action);
}
}
/// <summary>
/// An Intro class with episode metadata. Only used in end to end testing programs.
/// </summary>
public class IntroWithMetadata : Intro
{
/// <summary>
/// Initializes a new instance of the <see cref="IntroWithMetadata"/> class.
/// </summary>
/// <param name="series">Series name.</param>
/// <param name="season">Season number.</param>
/// <param name="title">Episode title.</param>
/// <param name="intro">Intro timestamps.</param>
public IntroWithMetadata(string series, int season, string title, Intro intro)
{
Series = series;
Season = season;
Title = title;
EpisodeId = intro.EpisodeId;
IntroStart = intro.IntroStart;
IntroEnd = intro.IntroEnd;
}
/// <summary>
/// Gets or sets the series name of the TV episode associated with this intro.
/// </summary>
public string Series { get; set; }
/// <summary>
/// Gets or sets the season number of the TV episode associated with this intro.
/// </summary>
public int Season { get; set; }
/// <summary>
/// Gets or sets the title of the TV episode associated with this intro.
/// </summary>
public string Title { get; set; }
}

View File

@ -1,9 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using System; namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper.Data; using System;
/// <summary> /// <summary>
/// Support bundle warning. /// Support bundle warning.
@ -31,3 +31,37 @@ public enum PluginWarning
/// </summary> /// </summary>
IncompatibleFFmpegBuild = 4, IncompatibleFFmpegBuild = 4,
} }
/// <summary>
/// Warning manager.
/// </summary>
public static class WarningManager
{
private static PluginWarning warnings;
/// <summary>
/// Set warning.
/// </summary>
/// <param name="warning">Warning.</param>
public static void SetFlag(PluginWarning warning)
{
warnings |= warning;
}
/// <summary>
/// Clear warnings.
/// </summary>
public static void Clear()
{
warnings = PluginWarning.None;
}
/// <summary>
/// Get warnings.
/// </summary>
/// <returns>Warnings.</returns>
public static string GetWarnings()
{
return warnings.ToString();
}
}

View File

@ -3,7 +3,7 @@
using System; using System;
namespace IntroSkipper.Data; namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>
/// Episode queued for analysis. /// Episode queued for analysis.
@ -25,16 +25,6 @@ public class QueuedEpisode
/// </summary> /// </summary>
public Guid EpisodeId { get; set; } public Guid EpisodeId { get; set; }
/// <summary>
/// Gets or sets the season id.
/// </summary>
public Guid SeasonId { get; set; }
/// <summary>
/// Gets or sets the series id.
/// </summary>
public Guid SeriesId { get; set; }
/// <summary> /// <summary>
/// Gets or sets the full path to episode. /// Gets or sets the full path to episode.
/// </summary> /// </summary>
@ -45,21 +35,6 @@ public class QueuedEpisode
/// </summary> /// </summary>
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether an episode is Anime.
/// </summary>
public bool IsAnime { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an item is a movie.
/// </summary>
public bool IsMovie { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an episode has been analyzed.
/// </summary>
public bool IsAnalyzed { get; set; }
/// <summary> /// <summary>
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at. /// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
/// </summary> /// </summary>

View File

@ -2,8 +2,9 @@
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic;
namespace IntroSkipper.Data; namespace ConfusedPolarBear.Plugin.IntroSkipper;
#pragma warning disable CA1036 // Override methods on comparable types #pragma warning disable CA1036 // Override methods on comparable types
@ -64,7 +65,7 @@ public class TimeRange : IComparable
/// <returns>int.</returns> /// <returns>int.</returns>
public int CompareTo(object? obj) public int CompareTo(object? obj)
{ {
if (obj is not TimeRange tr) if (!(obj is TimeRange tr))
{ {
throw new ArgumentException("obj must be a TimeRange"); throw new ArgumentException("obj must be a TimeRange");
} }
@ -84,3 +85,51 @@ public class TimeRange : IComparable
(Start < tr.End && tr.End < End); (Start < tr.End && tr.End < End);
} }
} }
#pragma warning restore CA1036
/// <summary>
/// Time range helpers.
/// </summary>
public static class TimeRangeHelpers
{
/// <summary>
/// Finds the longest contiguous time range.
/// </summary>
/// <param name="times">Sorted timestamps to search.</param>
/// <param name="maximumDistance">Maximum distance permitted between contiguous timestamps.</param>
/// <returns>The longest contiguous time range (if one was found), or null (if none was found).</returns>
public static TimeRange? FindContiguous(double[] times, double maximumDistance)
{
if (times.Length == 0)
{
return null;
}
Array.Sort(times);
var ranges = new List<TimeRange>();
var currentRange = new TimeRange(times[0], times[0]);
// For all provided timestamps, check if it is contiguous with its neighbor.
for (var i = 0; i < times.Length - 1; i++)
{
var current = times[i];
var next = times[i + 1];
if (next - current <= maximumDistance)
{
currentRange.End = next;
continue;
}
ranges.Add(new TimeRange(currentRange));
currentRange = new TimeRange(next, next);
}
// Find and return the longest contiguous range.
ranges.Sort();
return (ranges.Count > 0) ? ranges[0] : null;
}
}

View File

@ -1,7 +1,7 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org> // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only. // SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data namespace ConfusedPolarBear.Plugin.IntroSkipper.Data
{ {
/// <summary> /// <summary>
/// Result of fingerprinting and analyzing two episodes in a season. /// Result of fingerprinting and analyzing two episodes in a season.
@ -12,21 +12,11 @@ namespace IntroSkipper.Data
/// <summary> /// <summary>
/// Gets or sets Introduction. /// Gets or sets Introduction.
/// </summary> /// </summary>
public Segment Introduction { get; set; } = new Segment(); public Intro Introduction { get; set; } = new Intro();
/// <summary> /// <summary>
/// Gets or sets Credits. /// Gets or sets Credits.
/// </summary> /// </summary>
public Segment Credits { get; set; } = new Segment(); public Intro Credits { get; set; } = new Intro();
/// <summary>
/// Gets or sets Recap.
/// </summary>
public Segment Recap { get; set; } = new Segment();
/// <summary>
/// Gets or sets Preview.
/// </summary>
public Segment Preview { get; set; } = new Segment();
} }
} }

View File

@ -0,0 +1,125 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.ObjectModel;
using System.IO;
using Microsoft.Extensions.Logging;
/// <summary>
/// Update EDL files associated with a list of episodes.
/// </summary>
public static class EdlManager
{
private static ILogger? _logger;
/// <summary>
/// Initialize EDLManager with a logger.
/// </summary>
/// <param name="logger">ILogger.</param>
public static void Initialize(ILogger logger)
{
_logger = logger;
}
/// <summary>
/// Logs the configuration that will be used during EDL file creation.
/// </summary>
public static void LogConfiguration()
{
if (_logger is null)
{
throw new InvalidOperationException("Logger must not be null");
}
var config = Plugin.Instance!.Configuration;
if (config.EdlAction == EdlAction.None)
{
_logger.LogDebug("EDL action: None - taking no further action");
return;
}
_logger.LogDebug("EDL action: {Action}", config.EdlAction);
_logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles);
}
/// <summary>
/// If the EDL action is set to a value other than None, update EDL files for the provided episodes.
/// </summary>
/// <param name="episodes">Episodes to update EDL files for.</param>
public static void UpdateEDLFiles(ReadOnlyCollection<QueuedEpisode> episodes)
{
var regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles;
var action = Plugin.Instance!.Configuration.EdlAction;
if (action == EdlAction.None)
{
_logger?.LogDebug("EDL action is set to none, not updating EDL files");
return;
}
_logger?.LogDebug("Updating EDL files with action {Action}", action);
foreach (var episode in episodes)
{
var id = episode.EpisodeId;
bool hasIntro = Plugin.Instance!.Intros.TryGetValue(id, out var intro) && intro.Valid;
bool hasCredit = Plugin.Instance!.Credits.TryGetValue(id, out var credit) && credit.Valid;
if (!hasIntro && !hasCredit)
{
_logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id);
continue;
}
var edlPath = GetEdlPath(Plugin.Instance!.GetItemPath(id));
_logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath);
if (!regenerate && File.Exists(edlPath))
{
_logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath);
continue;
}
var edlContent = string.Empty;
if (hasIntro)
{
edlContent += intro?.ToEdl(action);
}
if (hasCredit)
{
if (edlContent.Length > 0)
{
edlContent += Environment.NewLine;
}
if (action == EdlAction.Intro)
{
edlContent += credit?.ToEdl(EdlAction.Credit);
}
else
{
edlContent += credit?.ToEdl(action);
}
}
File.WriteAllText(edlPath, edlContent);
}
}
/// <summary>
/// Given the path to an episode, return the path to the associated EDL file.
/// </summary>
/// <param name="mediaPath">Full path to episode.</param>
/// <returns>Full path to EDL file.</returns>
public static string GetEdlPath(string mediaPath)
{
return Path.ChangeExtension(mediaPath, "edl");
}
}

View File

@ -0,0 +1,357 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Server entrypoint.
/// </summary>
public class Entrypoint : IServerEntryPoint
{
private readonly IUserManager _userManager;
private readonly IUserViewManager _userViewManager;
private readonly ITaskManager _taskManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<Entrypoint> _logger;
private readonly ILoggerFactory _loggerFactory;
private Timer _queueTimer;
private bool _analyzeAgain;
private static CancellationTokenSource? _cancellationTokenSource;
private static ManualResetEventSlim _autoTaskCompletEvent = new ManualResetEventSlim(false);
private QueueManager _queueManager;
/// <summary>
/// Initializes a new instance of the <see cref="Entrypoint"/> class.
/// </summary>
/// <param name="userManager">User manager.</param>
/// <param name="userViewManager">User view manager.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="taskManager">Task manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
public Entrypoint(
IUserManager userManager,
IUserViewManager userViewManager,
ILibraryManager libraryManager,
ITaskManager taskManager,
ILogger<Entrypoint> logger,
ILoggerFactory loggerFactory)
{
_userManager = userManager;
_userViewManager = userViewManager;
_libraryManager = libraryManager;
_taskManager = taskManager;
_logger = logger;
_loggerFactory = loggerFactory;
_queueTimer = new Timer(
OnTimerCallback,
null,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan);
_queueManager = new QueueManager(
_loggerFactory.CreateLogger<QueueManager>(),
_libraryManager);
}
/// <summary>
/// Gets State of the automatic task.
/// </summary>
public static TaskState AutomaticTaskState
{
get
{
if (_cancellationTokenSource is not null)
{
return _cancellationTokenSource.IsCancellationRequested
? TaskState.Cancelling
: TaskState.Running;
}
return TaskState.Idle;
}
}
/// <summary>
/// Registers event handler.
/// </summary>
/// <returns>Task.</returns>
public Task RunAsync()
{
_libraryManager.ItemAdded += OnItemAdded;
_libraryManager.ItemUpdated += OnItemModified;
_taskManager.TaskCompleted += OnLibraryRefresh;
FFmpegWrapper.Logger = _logger;
try
{
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
_logger.LogInformation("Running startup enqueue");
_queueManager.GetMediaItems();
}
catch (Exception ex)
{
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
}
return Task.CompletedTask;
}
// Disclose source for inspiration
// Implementation based on the principles of jellyfin-plugin-media-analyzer:
// https://github.com/endrl/jellyfin-plugin-media-analyzer
/// <summary>
/// Library item was added.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void OnItemAdded(object? sender, ItemChangeEventArgs itemChangeEventArgs)
{
// Don't do anything if auto detection is disabled
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.AutoDetectCredits)
{
return;
}
// Don't do anything if it's not a supported media type
if (itemChangeEventArgs.Item is not Episode episode)
{
return;
}
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
{
return;
}
if (Entrypoint.AutomaticTaskState == TaskState.Running)
{
_queueManager.QueueEpisode(episode);
}
else
{
Plugin.Instance!.Configuration.PathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath);
StartTimer();
}
}
/// <summary>
/// Library item was modified.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
private void OnItemModified(object? sender, ItemChangeEventArgs itemChangeEventArgs)
{
// Don't do anything if auto detection is disabled
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.AutoDetectCredits)
{
return;
}
// Don't do anything if it's not a supported media type
if (itemChangeEventArgs.Item is not Episode episode)
{
return;
}
if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual)
{
return;
}
if (Entrypoint.AutomaticTaskState == TaskState.Running)
{
_queueManager.QueueEpisode(episode);
}
else
{
Plugin.Instance!.Configuration.PathRestrictions.Add(itemChangeEventArgs.Item.ContainingFolderPath);
StartTimer();
}
}
/// <summary>
/// TaskManager task ended.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="eventArgs">The <see cref="TaskCompletionEventArgs"/>.</param>
private void OnLibraryRefresh(object? sender, TaskCompletionEventArgs eventArgs)
{
// Don't do anything if auto detection is disabled
if (!Plugin.Instance!.Configuration.AutoDetectIntros && !Plugin.Instance!.Configuration.AutoDetectCredits)
{
return;
}
var result = eventArgs.Result;
if (result.Key != "RefreshLibrary")
{
return;
}
if (result.Status != TaskCompletionStatus.Completed)
{
return;
}
// Unless user initiated, this is likely an overlap
if (AutomaticTaskState == TaskState.Running)
{
return;
}
StartTimer();
}
/// <summary>
/// Start timer to debounce analyzing.
/// </summary>
private void StartTimer()
{
if (AutomaticTaskState == TaskState.Running)
{
_analyzeAgain = true; // Items added during a scan will be included later.
}
else if (ScheduledTaskSemaphore.CurrentCount > 0)
{
_logger.LogInformation("Media Library changed, analyzis will start soon!");
_queueTimer.Change(TimeSpan.FromMilliseconds(20000), Timeout.InfiniteTimeSpan);
}
}
/// <summary>
/// Wait for timer callback to be completed.
/// </summary>
private void OnTimerCallback(object? state)
{
try
{
PerformAnalysis();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in PerformAnalysis");
}
}
/// <summary>
/// Wait for timer to be completed.
/// </summary>
private void PerformAnalysis()
{
_logger.LogInformation("Timer elapsed - start analyzing");
_autoTaskCompletEvent.Reset();
using (_cancellationTokenSource = new CancellationTokenSource())
{
var progress = new Progress<double>();
var cancellationToken = _cancellationTokenSource.Token;
var modes = new List<AnalysisMode>();
var tasklogger = _loggerFactory.CreateLogger("DefaultLogger");
if (Plugin.Instance!.Configuration.AutoDetectIntros && Plugin.Instance!.Configuration.AutoDetectCredits)
{
modes.Add(AnalysisMode.Introduction);
modes.Add(AnalysisMode.Credits);
tasklogger = _loggerFactory.CreateLogger<DetectIntrosCreditsTask>();
}
else if (Plugin.Instance!.Configuration.AutoDetectIntros)
{
modes.Add(AnalysisMode.Introduction);
tasklogger = _loggerFactory.CreateLogger<DetectIntrosTask>();
}
else if (Plugin.Instance!.Configuration.AutoDetectCredits)
{
modes.Add(AnalysisMode.Credits);
tasklogger = _loggerFactory.CreateLogger<DetectCreditsTask>();
}
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
modes.AsReadOnly(),
tasklogger,
_loggerFactory,
_libraryManager);
baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
}
Plugin.Instance!.Configuration.PathRestrictions.Clear();
_autoTaskCompletEvent.Set();
_cancellationTokenSource = null;
// New item detected, start timer again
if (_analyzeAgain)
{
_logger.LogInformation("Analyzing ended, but we need to analyze again!");
_analyzeAgain = false;
StartTimer();
}
}
/// <summary>
/// Method to cancel the automatic task.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public static void CancelAutomaticTask(CancellationToken cancellationToken)
{
if (_cancellationTokenSource != null)
{
if (!_cancellationTokenSource.IsCancellationRequested)
{
_cancellationTokenSource.Cancel();
}
_autoTaskCompletEvent.Wait(TimeSpan.FromSeconds(60), cancellationToken); // Wait for the signal
}
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="dispose">Dispose.</param>
protected virtual void Dispose(bool dispose)
{
if (!dispose)
{
Plugin.Instance!.Configuration.PathRestrictions.Clear();
_libraryManager.ItemAdded -= OnItemAdded;
_libraryManager.ItemUpdated -= OnItemModified;
_taskManager.TaskCompleted -= OnLibraryRefresh;
if (_cancellationTokenSource != null) // Null Check
{
_cancellationTokenSource.Dispose();
_cancellationTokenSource = null;
}
_queueTimer.Dispose();
return;
}
}
}

View File

@ -9,32 +9,34 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>
/// Wrapper for libchromaprint and the silencedetect filter. /// Wrapper for libchromaprint and the silencedetect filter.
/// </summary> /// </summary>
public static partial class FFmpegWrapper public static class FFmpegWrapper
{ {
/// <summary> /// <summary>
/// Used with FFmpeg's silencedetect filter to extract the start and end times of silence. /// Used with FFmpeg's silencedetect filter to extract the start and end times of silence.
/// </summary> /// </summary>
private static readonly Regex _silenceDetectionExpression = SilenceRegex(); private static readonly Regex SilenceDetectionExpression = new(
"silence_(?<type>start|end): (?<time>[0-9\\.]+)");
/// <summary> /// <summary>
/// Used with FFmpeg's blackframe filter to extract the time and percentage of black pixels. /// Used with FFmpeg's blackframe filter to extract the time and percentage of black pixels.
/// </summary> /// </summary>
private static readonly Regex _blackFrameRegex = BlackFrameRegex(); private static readonly Regex BlackFrameRegex = new("(pblack|t):[0-9.]+");
/// <summary> /// <summary>
/// Gets or sets the logger. /// Gets or sets the logger.
/// </summary> /// </summary>
public static ILogger? Logger { get; set; } public static ILogger? Logger { get; set; }
private static Dictionary<string, string> ChromaprintLogs { get; set; } = []; private static Dictionary<string, string> ChromaprintLogs { get; set; } = new();
private static ConcurrentDictionary<AnalysisMode, ConcurrentDictionary<Guid, Dictionary<uint, int>>> InvertedIndexCache { get; set; } = new();
/// <summary> /// <summary>
/// Check that the installed version of ffmpeg supports chromaprint. /// Check that the installed version of ffmpeg supports chromaprint.
@ -126,44 +128,70 @@ public static partial class FFmpegWrapper
} }
else else
{ {
throw new ArgumentException("Unknown analysis mode " + mode); throw new ArgumentException("Unknown analysis mode " + mode.ToString());
} }
return Fingerprint(episode, mode, start, end); return Fingerprint(episode, mode, start, end);
} }
/// <summary>
/// Transforms a Chromaprint into an inverted index of fingerprint points to the last index it appeared at.
/// </summary>
/// <param name="id">Episode ID.</param>
/// <param name="fingerprint">Chromaprint fingerprint.</param>
/// <param name="mode">Mode.</param>
/// <returns>Inverted index.</returns>
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode)
{
var innerDictionary = InvertedIndexCache.GetOrAdd(mode, _ => new ConcurrentDictionary<Guid, Dictionary<uint, int>>());
// Check if cached for the ID
if (innerDictionary.TryGetValue(id, out var cached))
{
return cached;
}
var invIndex = new Dictionary<uint, int>();
for (int i = 0; i < fingerprint.Length; i++)
{
// Get the current point.
var point = fingerprint[i];
// Append the current sample's timecode to the collection for this point.
invIndex[point] = i;
}
innerDictionary[id] = invIndex;
return invIndex;
}
/// <summary> /// <summary>
/// Detect ranges of silence in the provided episode. /// Detect ranges of silence in the provided episode.
/// </summary> /// </summary>
/// <param name="episode">Queued episode.</param> /// <param name="episode">Queued episode.</param>
/// <param name="range">Time range to search.</param> /// <param name="limit">Maximum amount of audio (in seconds) to detect silence in.</param>
/// <returns>Array of TimeRange objects that are silent in the queued episode.</returns> /// <returns>Array of TimeRange objects that are silent in the queued episode.</returns>
public static TimeRange[] DetectSilence(QueuedEpisode episode, TimeRange range) public static TimeRange[] DetectSilence(QueuedEpisode episode, int limit)
{ {
Logger?.LogTrace( Logger?.LogTrace(
"Detecting silence in \"{File}\" (range {Start}-{End}, id {Id})", "Detecting silence in \"{File}\" (limit {Limit}, id {Id})",
episode.Path, episode.Path,
range.Start, limit,
range.End,
episode.EpisodeId); episode.EpisodeId);
// -vn, -sn, -dn: ignore video, subtitle, and data tracks // -vn, -sn, -dn: ignore video, subtitle, and data tracks
var args = string.Format( var args = string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"-vn -sn -dn " + "-vn -sn -dn " +
"-ss {0} -i \"{1}\" -to {2} -af \"silencedetect=noise={3}dB:duration=0.1\" -f null -", "-i \"{0}\" -to {1} -af \"silencedetect=noise={2}dB:duration=0.1\" -f null -",
range.Start,
episode.Path, episode.Path,
range.End - range.Start, limit,
Plugin.Instance?.Configuration.SilenceDetectionMaximumNoise ?? -50); Plugin.Instance?.Configuration.SilenceDetectionMaximumNoise ?? -50);
// Cache the output of this command to "GUID-intro-silence-v2" // Cache the output of this command to "GUID-intro-silence-v1"
var cacheKey = string.Format( var cacheKey = episode.EpisodeId.ToString("N") + "-intro-silence-v1";
CultureInfo.InvariantCulture,
"{0}-silence-{1}-{2}-v2",
episode.EpisodeId.ToString("N"),
range.Start,
range.End);
var currentRange = new TimeRange(); var currentRange = new TimeRange();
var silenceRanges = new List<TimeRange>(); var silenceRanges = new List<TimeRange>();
@ -175,18 +203,18 @@ public static partial class FFmpegWrapper
* [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783 * [silencedetect @ 0x000000000000] silence_end: 56.123 | silence_duration: 43.783
*/ */
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true)); var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
foreach (Match match in _silenceDetectionExpression.Matches(raw)) foreach (Match match in SilenceDetectionExpression.Matches(raw))
{ {
var isStart = match.Groups["type"].Value == "start"; var isStart = match.Groups["type"].Value == "start";
var time = Convert.ToDouble(match.Groups["time"].Value, CultureInfo.InvariantCulture); var time = Convert.ToDouble(match.Groups["time"].Value, CultureInfo.InvariantCulture);
if (isStart) if (isStart)
{ {
currentRange.Start = time + range.Start; currentRange.Start = time;
} }
else else
{ {
currentRange.End = time + range.Start; currentRange.End = time;
silenceRanges.Add(new TimeRange(currentRange)); silenceRanges.Add(new TimeRange(currentRange));
} }
} }
@ -237,7 +265,7 @@ public static partial class FFmpegWrapper
// In our case, the metadata contained something that matched the regex. // In our case, the metadata contained something that matched the regex.
if (line.StartsWith("[Parsed_blackframe_", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith("[Parsed_blackframe_", StringComparison.OrdinalIgnoreCase))
{ {
var matches = _blackFrameRegex.Matches(line); var matches = BlackFrameRegex.Matches(line);
if (matches.Count != 2) if (matches.Count != 2)
{ {
continue; continue;
@ -392,7 +420,8 @@ public static partial class FFmpegWrapper
RedirectStandardError = stderr RedirectStandardError = stderr
}; };
using var ffmpeg = new Process { StartInfo = info }; using (var ffmpeg = new Process { StartInfo = info })
{
Logger?.LogDebug("Starting ffmpeg with the following arguments: {Arguments}", ffmpeg.StartInfo.Arguments); Logger?.LogDebug("Starting ffmpeg with the following arguments: {Arguments}", ffmpeg.StartInfo.Arguments);
ffmpeg.Start(); ffmpeg.Start();
@ -406,7 +435,8 @@ public static partial class FFmpegWrapper
Logger?.LogDebug("ffmpeg priority could not be modified. {Message}", e.Message); Logger?.LogDebug("ffmpeg priority could not be modified. {Message}", e.Message);
} }
using var ms = new MemoryStream(); using (var ms = new MemoryStream())
{
var buf = new byte[4096]; var buf = new byte[4096];
int bytesRead; int bytesRead;
@ -430,6 +460,8 @@ public static partial class FFmpegWrapper
return output; return output;
} }
}
}
/// <summary> /// <summary>
/// Fingerprint a queued episode. /// Fingerprint a queued episode.
@ -575,44 +607,20 @@ public static partial class FFmpegWrapper
/// <summary> /// <summary>
/// Remove a cached episode fingerprint from disk. /// Remove a cached episode fingerprint from disk.
/// </summary> /// </summary>
/// <param name="id">Episode to remove from cache.</param> /// <param name="episodeId">Episode to remove from cache.</param>
public static void DeleteEpisodeCache(Guid id) /// <param name="mode">Analysis mode.</param>
public static void DeleteEpisodeCache(string episodeId, AnalysisMode mode)
{ {
var cachePath = Path.Join( var cachePath = Path.Join(
Plugin.Instance!.FingerprintCachePath, Plugin.Instance!.FingerprintCachePath,
id.ToString("N")); episodeId);
// File.Delete(cachePath); if (mode == AnalysisMode.Credits)
// File.Delete(cachePath + "-intro-silence-v1");
// File.Delete(cachePath + "-credits");
var filePattern = Path.GetFileName(cachePath) + "*";
foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath, filePattern))
{ {
Logger?.LogDebug("DeleteEpisodeCache {FilePath}", filePath); cachePath += "-credits";
File.Delete(filePath);
}
} }
/// <summary> File.Delete(cachePath);
/// Remove cached fingerprints from disk by mode.
/// </summary>
/// <param name="mode">Analysis mode.</param>
public static void DeleteCacheFiles(AnalysisMode mode)
{
foreach (var filePath in Directory.EnumerateFiles(Plugin.Instance!.FingerprintCachePath))
{
var shouldDelete = (mode == AnalysisMode.Introduction)
? !filePath.Contains("credit", StringComparison.OrdinalIgnoreCase)
&& !filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase)
: filePath.Contains("credit", StringComparison.OrdinalIgnoreCase)
|| filePath.Contains("blackframes", StringComparison.OrdinalIgnoreCase);
if (shouldDelete)
{
File.Delete(filePath);
}
}
} }
/// <summary> /// <summary>
@ -621,8 +629,7 @@ public static partial class FFmpegWrapper
/// </summary> /// </summary>
/// <param name="episode">Episode.</param> /// <param name="episode">Episode.</param>
/// <param name="mode">Analysis mode.</param> /// <param name="mode">Analysis mode.</param>
/// <returns>Path.</returns> private static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode)
public static string GetFingerprintCachePath(QueuedEpisode episode, AnalysisMode mode)
{ {
var basePath = Path.Join( var basePath = Path.Join(
Plugin.Instance!.FingerprintCachePath, Plugin.Instance!.FingerprintCachePath,
@ -632,13 +639,14 @@ public static partial class FFmpegWrapper
{ {
return basePath; return basePath;
} }
else if (mode == AnalysisMode.Credits)
if (mode == AnalysisMode.Credits)
{ {
return basePath + "-credits"; return basePath + "-credits";
} }
else
throw new ArgumentException("Unknown analysis mode " + mode); {
throw new ArgumentException("Unknown analysis mode " + mode.ToString());
}
} }
private static string FormatFFmpegLog(string key) private static string FormatFFmpegLog(string key)
@ -663,10 +671,4 @@ public static partial class FFmpegWrapper
return formatted; return formatted;
} }
[GeneratedRegex("silence_(?<type>start|end): (?<time>[0-9\\.]+)")]
private static partial Regex SilenceRegex();
[GeneratedRegex("(pblack|t):[0-9.]+")]
private static partial Regex BlackFrameRegex();
} }

View File

@ -0,0 +1,8 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.WarningManager")]
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.IntroWithMetadata")]
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.TimeRangeHelpers")]

View File

@ -0,0 +1,483 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Updates;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Intro skipper plugin. Uses audio analysis to find common sequences of audio shared between episodes.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
private readonly object _serializationLock = new();
private readonly object _introsLock = new();
private IXmlSerializer _xmlSerializer;
private ILibraryManager _libraryManager;
private IItemRepository _itemRepository;
private ILogger<Plugin> _logger;
private string _introPath;
private string _creditsPath;
private string _oldintroPath;
private string _oldcreditsPath;
private string _oldFingerprintCachePath;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="serverConfiguration">Server configuration manager.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="itemRepository">Item repository.</param>
/// <param name="logger">Logger.</param>
public Plugin(
IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer,
IServerConfigurationManager serverConfiguration,
ILibraryManager libraryManager,
IItemRepository itemRepository,
ILogger<Plugin> logger)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
_xmlSerializer = xmlSerializer;
_libraryManager = libraryManager;
_itemRepository = itemRepository;
_logger = logger;
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
var pluginDirName = "introskipper";
var pluginCachePath = "chromaprints";
var introsDirectory = Path.Join(applicationPaths.DataPath, pluginDirName);
FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath);
_introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
_creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml");
var cacheRoot = applicationPaths.CachePath;
var oldintrosDirectory = Path.Join(cacheRoot, pluginDirName);
if (!Directory.Exists(oldintrosDirectory))
{
pluginDirName = "intros";
pluginCachePath = "cache";
cacheRoot = applicationPaths.PluginConfigurationsPath;
oldintrosDirectory = Path.Join(cacheRoot, pluginDirName);
}
_oldFingerprintCachePath = Path.Join(oldintrosDirectory, pluginCachePath);
_oldintroPath = Path.Join(cacheRoot, pluginDirName, "intros.xml");
_oldcreditsPath = Path.Join(cacheRoot, pluginDirName, "credits.xml");
// Create the base & cache directories (if needed).
if (!Directory.Exists(FingerprintCachePath))
{
Directory.CreateDirectory(FingerprintCachePath);
// Check if the old cache directory exists
if (Directory.Exists(_oldFingerprintCachePath))
{
// move intro.xml if exists
if (File.Exists(_oldintroPath))
{
File.Move(_oldintroPath, _introPath);
}
// move credits.xml if exits
if (File.Exists(_oldcreditsPath))
{
File.Move(_oldcreditsPath, _creditsPath);
}
// Move the contents from old directory to new directory
string[] files = Directory.GetFiles(_oldFingerprintCachePath);
foreach (string file in files)
{
string fileName = Path.GetFileName(file);
string destFile = Path.Combine(FingerprintCachePath, fileName);
File.Move(file, destFile);
}
// Optionally, you may delete the old directory after moving its contents
Directory.Delete(oldintrosDirectory, true);
}
}
ConfigurationChanged += OnConfigurationChanged;
MigrateRepoUrl(serverConfiguration);
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
try
{
RestoreTimestamps();
}
catch (Exception ex)
{
_logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex);
}
// Inject the skip intro button code into the web interface.
var indexPath = Path.Join(applicationPaths.WebPath, "index.html");
try
{
InjectSkipButton(indexPath);
}
catch (Exception ex)
{
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
if (ex is UnauthorizedAccessException)
{
var suggestion = OperatingSystem.IsLinux() ?
"running `sudo chown jellyfin PATH` (if this is a native installation)" :
"changing the permissions of PATH";
suggestion = suggestion.Replace("PATH", indexPath, StringComparison.Ordinal);
_logger.LogError(
"Failed to add skip button to web interface. Try {Suggestion} and restarting the server. Error: {Error}",
suggestion,
ex);
}
else
{
_logger.LogError("Unknown error encountered while adding skip button: {Error}", ex);
}
}
FFmpegWrapper.CheckFFmpegVersion();
}
/// <summary>
/// Fired after configuration has been saved so the auto skip timer can be stopped or started.
/// </summary>
public event EventHandler? AutoSkipChanged;
/// <summary>
/// Fired after configuration has been saved so the auto skip timer can be stopped or started.
/// </summary>
public event EventHandler? AutoSkipCreditsChanged;
/// <summary>
/// Gets the results of fingerprinting all episodes.
/// </summary>
public Dictionary<Guid, Intro> Intros { get; } = new();
/// <summary>
/// Gets all discovered ending credits.
/// </summary>
public Dictionary<Guid, Intro> Credits { get; } = new();
/// <summary>
/// Gets the most recent media item queue.
/// </summary>
public Dictionary<Guid, List<QueuedEpisode>> QueuedMediaItems { get; } = new();
/// <summary>
/// Gets or sets the total number of episodes in the queue.
/// </summary>
public int TotalQueued { get; set; }
/// <summary>
/// Gets or sets the number of seasons in the queue.
/// </summary>
public int TotalSeasons { get; set; }
/// <summary>
/// Gets the directory to cache fingerprints in.
/// </summary>
public string FingerprintCachePath { get; private set; }
/// <summary>
/// Gets the full path to FFmpeg.
/// </summary>
public string FFmpegPath { get; private set; }
/// <inheritdoc />
public override string Name => "Intro Skipper";
/// <inheritdoc />
public override Guid Id => Guid.Parse("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b");
/// <summary>
/// Gets the plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <summary>
/// Save timestamps to disk.
/// </summary>
public void SaveTimestamps()
{
lock (_serializationLock)
{
var introList = new List<Intro>();
// Serialize intros
foreach (var intro in Instance!.Intros)
{
introList.Add(intro.Value);
}
_xmlSerializer.SerializeToFile(introList, _introPath);
// Serialize credits
introList.Clear();
foreach (var intro in Instance!.Credits)
{
introList.Add(intro.Value);
}
_xmlSerializer.SerializeToFile(introList, _creditsPath);
}
}
/// <summary>
/// Restore previous analysis results from disk.
/// </summary>
public void RestoreTimestamps()
{
if (File.Exists(_introPath))
{
// Since dictionaries can't be easily serialized, analysis results are stored on disk as a list.
var introList = (List<Intro>)_xmlSerializer.DeserializeFromFile(
typeof(List<Intro>),
_introPath);
foreach (var intro in introList)
{
Instance!.Intros[intro.EpisodeId] = intro;
}
}
if (File.Exists(_creditsPath))
{
var creditList = (List<Intro>)_xmlSerializer.DeserializeFromFile(
typeof(List<Intro>),
_creditsPath);
foreach (var credit in creditList)
{
Instance!.Credits[credit.EpisodeId] = credit;
}
}
}
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = this.Name,
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
},
new PluginPageInfo
{
Name = "visualizer.js",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.visualizer.js"
},
new PluginPageInfo
{
Name = "skip-intro-button.js",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.inject.js"
}
};
}
/// <summary>
/// Gets the commit used to build the plugin.
/// </summary>
/// <returns>Commit.</returns>
public string GetCommit()
{
var commit = string.Empty;
var path = GetType().Namespace + ".Configuration.version.txt";
using var stream = GetType().Assembly.GetManifestResourceStream(path);
if (stream is null)
{
_logger.LogWarning("Unable to read embedded version information");
return commit;
}
using var reader = new StreamReader(stream);
commit = reader.ReadToEnd().TrimEnd();
if (commit == "unknown")
{
_logger.LogTrace("Embedded version information was not valid, ignoring");
return string.Empty;
}
_logger.LogInformation("Unstable plugin version built from commit {Commit}", commit);
return commit;
}
internal BaseItem? GetItem(Guid id)
{
return _libraryManager.GetItemById(id);
}
/// <summary>
/// Gets the full path for an item.
/// </summary>
/// <param name="id">Item id.</param>
/// <returns>Full path to item.</returns>
internal string GetItemPath(Guid id)
{
var item = GetItem(id);
if (item == null)
{
// Handle the case where the item is not found
_logger.LogWarning("Item with ID {Id} not found.", id);
return string.Empty;
}
return item.Path;
}
/// <summary>
/// Gets all chapters for this item.
/// </summary>
/// <param name="id">Item id.</param>
/// <returns>List of chapters.</returns>
internal List<ChapterInfo> GetChapters(Guid id)
{
var item = GetItem(id);
if (item == null)
{
// Handle the case where the item is not found
_logger.LogWarning("Item with ID {Id} not found.", id);
return new List<ChapterInfo>();
}
return _itemRepository.GetChapters(item);
}
internal void UpdateTimestamps(Dictionary<Guid, Intro> newTimestamps, AnalysisMode mode)
{
lock (_introsLock)
{
foreach (var intro in newTimestamps)
{
if (mode == AnalysisMode.Introduction)
{
Instance!.Intros[intro.Key] = intro.Value;
}
else if (mode == AnalysisMode.Credits)
{
Instance!.Credits[intro.Key] = intro.Value;
}
}
Instance!.SaveTimestamps();
}
}
private void OnConfigurationChanged(object? sender, BasePluginConfiguration e)
{
AutoSkipChanged?.Invoke(this, EventArgs.Empty);
AutoSkipCreditsChanged?.Invoke(this, EventArgs.Empty);
}
private void MigrateRepoUrl(IServerConfigurationManager serverConfiguration)
{
try
{
List<string> oldRepos = new List<string>
{
"https://raw.githubusercontent.com/intro-skipper/intro-skipper/master/manifest.json",
"https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json",
"https://manifest.intro-skipper.workers.dev/manifest.json"
};
// Access the current server configuration
var config = serverConfiguration.Configuration;
// Get the list of current plugin repositories
var pluginRepositories = config.PluginRepositories?.ToList() ?? new List<RepositoryInfo>();
// check if old plugins exits
if (pluginRepositories.Exists(repo => repo != null && repo.Url != null && oldRepos.Contains(repo.Url)))
{
// remove all old plugins
pluginRepositories.RemoveAll(repo => repo != null && repo.Url != null && oldRepos.Contains(repo.Url));
// Add repository only if it does not exit
if (!pluginRepositories.Exists(repo => repo.Url == "https://manifest.intro-skipper.org/manifest.json"))
{
// Add the new repository to the list
pluginRepositories.Add(new RepositoryInfo
{
Name = "intro skipper (automatically migrated by plugin)",
Url = "https://manifest.intro-skipper.org/manifest.json",
Enabled = true,
});
}
// Update the configuration with the new repository list
config.PluginRepositories = pluginRepositories.ToList();
// Save the updated configuration
serverConfiguration.SaveConfiguration();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while migrating repo URL");
}
}
/// <summary>
/// Inject the skip button script into the web interface.
/// </summary>
/// <param name="indexPath">Full path to index.html.</param>
private void InjectSkipButton(string indexPath)
{
// Parts of this code are based off of JellyScrub's script injection code.
// https://github.com/nicknsy/jellyscrub/blob/main/Nick.Plugin.Jellyscrub/JellyscrubPlugin.cs#L38
_logger.LogDebug("Reading index.html from {Path}", indexPath);
var contents = File.ReadAllText(indexPath);
var scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js\"></script>";
// Only inject the script tag once
if (contents.Contains(scriptTag, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Skip button already added");
return;
}
// Inject a link to the script at the end of the <head> section.
// A regex is used here to ensure the replacement is only done once.
var headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
// Write the modified file contents
File.WriteAllText(indexPath, contents);
_logger.LogInformation("Skip intro button successfully added");
}
}

View File

@ -0,0 +1,302 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
/// <summary>
/// Manages enqueuing library items for analysis.
/// </summary>
public class QueueManager
{
private ILibraryManager _libraryManager;
private ILogger<QueueManager> _logger;
private double analysisPercent;
private List<string> selectedLibraries;
private Dictionary<Guid, List<QueuedEpisode>> _queuedEpisodes;
/// <summary>
/// Initializes a new instance of the <see cref="QueueManager"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
public QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryManager)
{
_logger = logger;
_libraryManager = libraryManager;
selectedLibraries = new();
_queuedEpisodes = new();
}
/// <summary>
/// Gets all media items on the server.
/// </summary>
/// <returns>Queued media items.</returns>
public ReadOnlyDictionary<Guid, List<QueuedEpisode>> GetMediaItems()
{
Plugin.Instance!.TotalQueued = 0;
LoadAnalysisSettings();
// For all selected libraries, enqueue all contained episodes.
foreach (var folder in _libraryManager.GetVirtualFolders())
{
// If libraries have been selected for analysis, ensure this library was selected.
if (selectedLibraries.Count > 0 && !selectedLibraries.Contains(folder.Name))
{
_logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name);
continue;
}
_logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
try
{
foreach (var location in folder.Locations)
{
var item = _libraryManager.FindByPath(location, true);
if (item is null)
{
_logger.LogWarning("Unable to find linked item at path {0}", location);
continue;
}
QueueLibraryContents(item.Id);
}
}
catch (Exception ex)
{
_logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex);
}
}
Plugin.Instance!.TotalSeasons = _queuedEpisodes.Count;
Plugin.Instance!.QueuedMediaItems.Clear();
foreach (var kvp in _queuedEpisodes)
{
Plugin.Instance!.QueuedMediaItems[kvp.Key] = kvp.Value;
}
return new(_queuedEpisodes);
}
/// <summary>
/// Loads the list of libraries which have been selected for analysis and the minimum intro duration.
/// Settings which have been modified from the defaults are logged.
/// </summary>
private void LoadAnalysisSettings()
{
var config = Plugin.Instance!.Configuration;
// Store the analysis percent
analysisPercent = Convert.ToDouble(config.AnalysisPercent) / 100;
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
selectedLibraries = config.SelectedLibraries
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
// If any libraries have been selected for analysis, log their names.
if (selectedLibraries.Count > 0)
{
_logger.LogInformation("Limiting analysis to the following libraries: {Selected}", selectedLibraries);
}
else
{
_logger.LogDebug("Not limiting analysis by library name");
}
// If analysis settings have been changed from the default, log the modified settings.
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
{
_logger.LogInformation(
"Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
config.AnalysisPercent,
config.AnalysisLengthLimit,
config.MinimumIntroDuration);
}
}
private void QueueLibraryContents(Guid id)
{
_logger.LogDebug("Constructing anonymous internal query");
var query = new InternalItemsQuery()
{
// Order by series name, season, and then episode number so that status updates are logged in order
ParentId = id,
OrderBy = new[]
{
("SeriesSortName", SortOrder.Ascending),
("ParentIndexNumber", SortOrder.Ascending),
("IndexNumber", SortOrder.Ascending),
},
IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode },
Recursive = true,
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query, false);
if (items is null)
{
_logger.LogError("Library query result is null");
return;
}
// Queue all episodes on the server for fingerprinting.
_logger.LogDebug("Iterating through library items");
foreach (var item in items)
{
if (item is not Episode episode)
{
_logger.LogDebug("Item {Name} is not an episode", item.Name);
continue;
}
if (Plugin.Instance!.Configuration.PathRestrictions.Count > 0)
{
if (!Plugin.Instance!.Configuration.PathRestrictions.Contains(item.ContainingFolderPath))
{
continue;
}
}
QueueEpisode(episode);
}
_logger.LogDebug("Queued {Count} episodes", items.Count);
}
/// <summary>
/// Adds a single episode to the current queue for analyzing.
/// </summary>
/// <param name="episode">The episode to analyze.</param>
public void QueueEpisode(Episode episode)
{
if (Plugin.Instance is null)
{
throw new InvalidOperationException("plugin instance was null");
}
if (string.IsNullOrEmpty(episode.Path))
{
_logger.LogWarning(
"Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
// Allocate a new list for each new season
_queuedEpisodes.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
if (_queuedEpisodes[episode.SeasonId].Any(e => e.EpisodeId == episode.Id))
{
_logger.LogDebug(
"\"{Name}\" from series \"{Series}\" ({Id}) is already queued",
episode.Name,
episode.SeriesName,
episode.Id);
return;
}
// Limit analysis to the first X% of the episode and at most Y minutes.
// X and Y default to 25% and 10 minutes.
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
var fingerprintDuration = duration;
if (fingerprintDuration >= 5 * 60)
{
fingerprintDuration *= analysisPercent;
}
fingerprintDuration = Math.Min(
fingerprintDuration,
60 * Plugin.Instance!.Configuration.AnalysisLengthLimit);
// Queue the episode for analysis
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumCreditsDuration;
_queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode()
{
SeriesName = episode.SeriesName,
SeasonNumber = episode.AiredSeasonNumber ?? 0,
EpisodeId = episode.Id,
Name = episode.Name,
Path = episode.Path,
Duration = Convert.ToInt32(duration),
IntroFingerprintEnd = Convert.ToInt32(fingerprintDuration),
CreditsFingerprintStart = Convert.ToInt32(duration - maxCreditsDuration),
});
Plugin.Instance!.TotalQueued++;
}
/// <summary>
/// Verify that a collection of queued media items still exist in Jellyfin and in storage.
/// This is done to ensure that we don't analyze items that were deleted between the call to GetMediaItems() and popping them from the queue.
/// </summary>
/// <param name="candidates">Queued media items.</param>
/// <param name="modes">Analysis mode.</param>
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, ReadOnlyCollection<AnalysisMode> RequiredModes)
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, ReadOnlyCollection<AnalysisMode> modes)
{
var verified = new List<QueuedEpisode>();
var reqModes = new List<AnalysisMode>();
var requiresIntroAnalysis = modes.Contains(AnalysisMode.Introduction);
var requiresCreditsAnalysis = modes.Contains(AnalysisMode.Credits);
foreach (var candidate in candidates)
{
try
{
var path = Plugin.Instance!.GetItemPath(candidate.EpisodeId);
if (File.Exists(path))
{
verified.Add(candidate);
if (requiresIntroAnalysis && (!Plugin.Instance!.Intros.TryGetValue(candidate.EpisodeId, out var intro) || !intro.Valid))
{
reqModes.Add(AnalysisMode.Introduction);
requiresIntroAnalysis = false; // No need to check again
}
if (requiresCreditsAnalysis && (!Plugin.Instance!.Credits.TryGetValue(candidate.EpisodeId, out var credit) || !credit.Valid))
{
reqModes.Add(AnalysisMode.Credits);
requiresCreditsAnalysis = false; // No need to check again
}
}
}
catch (Exception ex)
{
_logger.LogDebug(
"Skipping {Mode} analysis of {Name} ({Id}): {Exception}",
modes,
candidate.Name,
candidate.EpisodeId,
ex);
}
}
return (verified.AsReadOnly(), reqModes.AsReadOnly());
}
}

View File

@ -0,0 +1,243 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
/// <summary>
/// Common code shared by all media item analyzer tasks.
/// </summary>
public class BaseItemAnalyzerTask
{
private readonly ReadOnlyCollection<AnalysisMode> _analysisModes;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
/// </summary>
/// <param name="modes">Analysis mode.</param>
/// <param name="logger">Task logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
public BaseItemAnalyzerTask(
ReadOnlyCollection<AnalysisMode> modes,
ILogger logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_analysisModes = modes;
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{
EdlManager.Initialize(_logger);
}
}
/// <summary>
/// Analyze all media items on the server.
/// </summary>
/// <param name="progress">Progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public void AnalyzeItems(
IProgress<double> progress,
CancellationToken cancellationToken)
{
var ffmpegValid = FFmpegWrapper.CheckFFmpegVersion();
// Assert that ffmpeg with chromaprint is installed
if (Plugin.Instance!.Configuration.UseChromaprint && !ffmpegValid)
{
throw new FingerprintException(
"Analysis terminated! Chromaprint is not enabled in the current ffmpeg. If Jellyfin is running natively, install jellyfin-ffmpeg5. If Jellyfin is running in a container, upgrade to version 10.8.0 or newer.");
}
var queueManager = new QueueManager(
_loggerFactory.CreateLogger<QueueManager>(),
_libraryManager);
var queue = queueManager.GetMediaItems();
var totalQueued = 0;
foreach (var kvp in queue)
{
totalQueued += kvp.Value.Count;
}
totalQueued *= _analysisModes.Count;
if (totalQueued == 0)
{
throw new FingerprintException(
"No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
}
if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{
EdlManager.LogConfiguration();
}
var totalProcessed = 0;
var modeCount = _analysisModes.Count;
var options = new ParallelOptions()
{
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
};
Parallel.ForEach(queue, options, (season) =>
{
var writeEdl = false;
var totalRemaining = (Plugin.Instance!.TotalQueued * modeCount) - totalProcessed;
if (totalRemaining >= queue.Count * modeCount)
{
queue = new(Plugin.Instance!.QueuedMediaItems);
totalQueued = 0;
foreach (var kvp in queue)
{
totalQueued += kvp.Value.Count;
}
totalQueued *= _analysisModes.Count;
}
// Since the first run of the task can run for multiple hours, ensure that none
// of the current media items were deleted from Jellyfin since the task was started.
var (episodes, requiredModes) = queueManager.VerifyQueue(
season.Value.AsReadOnly(),
_analysisModes);
var episodeCount = episodes.Count;
if (episodeCount == 0)
{
return;
}
var first = episodes[0];
var requiredModeCount = requiredModes.Count;
if (requiredModeCount == 0)
{
_logger.LogDebug(
"All episodes in {Name} season {Season} have already been analyzed",
first.SeriesName,
first.SeasonNumber);
Interlocked.Add(ref totalProcessed, episodeCount * modeCount); // Update total Processed directly
progress.Report((totalProcessed * 100) / totalQueued);
return;
}
if (modeCount != requiredModeCount)
{
Interlocked.Add(ref totalProcessed, episodeCount);
progress.Report((totalProcessed * 100) / totalQueued); // Partial analysis some modes have already been analyzed
}
try
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
foreach (AnalysisMode mode in requiredModes)
{
var analyzed = AnalyzeItems(episodes, mode, cancellationToken);
Interlocked.Add(ref totalProcessed, analyzed);
writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
progress.Report((totalProcessed * 100) / totalQueued);
}
}
catch (FingerprintException ex)
{
_logger.LogWarning(
"Unable to analyze {Series} season {Season}: unable to fingerprint: {Ex}",
first.SeriesName,
first.SeasonNumber,
ex);
}
if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{
EdlManager.UpdateEDLFiles(episodes);
}
});
if (Plugin.Instance!.Configuration.RegenerateEdlFiles)
{
_logger.LogInformation("Turning EDL file regeneration flag off");
Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
Plugin.Instance!.SaveConfiguration();
}
}
/// <summary>
/// Analyze a group of media items for skippable segments.
/// </summary>
/// <param name="items">Media items to analyze.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of items that were successfully analyzed.</returns>
private int AnalyzeItems(
ReadOnlyCollection<QueuedEpisode> items,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var totalItems = items.Count;
// Only analyze specials (season 0) if the user has opted in.
var first = items[0];
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
{
return 0;
}
_logger.LogInformation(
"[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
mode,
items.Count,
first.SeriesName,
first.SeasonNumber);
var analyzers = new Collection<IMediaFileAnalyzer>();
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
if (mode == AnalysisMode.Credits)
{
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
}
if (Plugin.Instance!.Configuration.UseChromaprint)
{
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
}
// Use each analyzer to find skippable ranges in all media files, removing successfully
// analyzed items from the queue.
foreach (var analyzer in analyzers)
{
items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken);
}
return totalItems;
}
}

View File

@ -0,0 +1,116 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for credits.
/// TODO: analyze all media files.
/// </summary>
public class DetectCreditsTask : IScheduledTask
{
private readonly ILogger<DetectCreditsTask> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectCreditsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectCreditsTask(
ILogger<DetectCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Credits";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes media to determine the timestamp and length of credits";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectCredits";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
Entrypoint.CancelAutomaticTask(cancellationToken);
}
ScheduledTaskSemaphore.Wait(-1, cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
ScheduledTaskSemaphore.Release();
return Task.CompletedTask;
}
_logger.LogInformation("Scheduled Task is starting");
Plugin.Instance!.Configuration.PathRestrictions.Clear();
var modes = new List<AnalysisMode> { AnalysisMode.Credits };
var baseCreditAnalyzer = new BaseItemAnalyzerTask(
modes.AsReadOnly(),
_loggerFactory.CreateLogger<DetectCreditsTask>(),
_loggerFactory,
_libraryManager);
baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
ScheduledTaskSemaphore.Release();
return Task.CompletedTask;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return Array.Empty<TaskTriggerInfo>();
}
}

View File

@ -0,0 +1,122 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
public class DetectIntrosCreditsTask : IScheduledTask
{
private readonly ILogger<DetectIntrosCreditsTask> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectIntrosCreditsTask(
ILogger<DetectIntrosCreditsTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Intros and Credits";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes media to determine the timestamp and length of intros and credits.";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectIntrosCredits";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
Entrypoint.CancelAutomaticTask(cancellationToken);
}
ScheduledTaskSemaphore.Wait(-1, cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
ScheduledTaskSemaphore.Release();
return Task.CompletedTask;
}
_logger.LogInformation("Scheduled Task is starting");
Plugin.Instance!.Configuration.PathRestrictions.Clear();
var modes = new List<AnalysisMode> { AnalysisMode.Introduction, AnalysisMode.Credits };
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes.AsReadOnly(),
_loggerFactory.CreateLogger<DetectIntrosCreditsTask>(),
_loggerFactory,
_libraryManager);
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
ScheduledTaskSemaphore.Release();
return Task.CompletedTask;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
}
};
}
}

View File

@ -0,0 +1,115 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for introduction sequences.
/// </summary>
public class DetectIntrosTask : IScheduledTask
{
private readonly ILogger<DetectIntrosTask> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectIntrosTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectIntrosTask(
ILogger<DetectIntrosTask> logger,
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_logger = logger;
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Intros";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes media to determine the timestamp and length of intros.";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectIntroductions";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
// abort automatic analyzer if running
if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
{
_logger.LogInformation("Automatic Task is {0} and will be canceled.", Entrypoint.AutomaticTaskState);
Entrypoint.CancelAutomaticTask(cancellationToken);
}
ScheduledTaskSemaphore.Wait(-1, cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
ScheduledTaskSemaphore.Release();
return Task.CompletedTask;
}
_logger.LogInformation("Scheduled Task is starting");
Plugin.Instance!.Configuration.PathRestrictions.Clear();
var modes = new List<AnalysisMode> { AnalysisMode.Introduction };
var baseIntroAnalyzer = new BaseItemAnalyzerTask(
modes.AsReadOnly(),
_loggerFactory.CreateLogger<DetectIntrosTask>(),
_loggerFactory,
_libraryManager);
baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
ScheduledTaskSemaphore.Release();
return Task.CompletedTask;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return Array.Empty<TaskTriggerInfo>();
}
}

View File

@ -0,0 +1,51 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Threading;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
internal sealed class ScheduledTaskSemaphore : IDisposable
{
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
private ScheduledTaskSemaphore()
{
}
public static int CurrentCount => _semaphore.CurrentCount;
public static bool Wait(int timeout, CancellationToken cancellationToken)
{
return _semaphore.Wait(timeout, cancellationToken);
}
public static int Release()
{
return _semaphore.Release();
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="disposing">Dispose.</param>
private void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_semaphore.Dispose();
}
}

View File

@ -1,52 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Tests;
using IntroSkipper.Data;
using Xunit;
public class TestFlags
{
[Fact]
public void TestEmptyFlagSerialization()
{
WarningManager.Clear();
Assert.Equal("None", WarningManager.GetWarnings());
}
[Fact]
public void TestSingleFlagSerialization()
{
WarningManager.Clear();
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
Assert.Equal("UnableToAddSkipButton", WarningManager.GetWarnings());
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton));
}
[Fact]
public void TestDoubleFlagSerialization()
{
WarningManager.Clear();
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
Assert.Equal(
"UnableToAddSkipButton, InvalidChromaprintFingerprint",
WarningManager.GetWarnings());
}
[Fact]
public void TestHasFlag()
{
WarningManager.Clear();
Assert.True(WarningManager.HasFlag(PluginWarning.None));
Assert.False(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
Assert.True(WarningManager.HasFlag(PluginWarning.UnableToAddSkipButton) && WarningManager.HasFlag(PluginWarning.InvalidChromaprintFingerprint));
Assert.False(WarningManager.HasFlag(PluginWarning.IncompatibleFFmpegBuild));
Assert.True(WarningManager.HasFlag(PluginWarning.None));
}
}

View File

@ -1,218 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Analyzers;
/// <summary>
/// Media file analyzer used to detect end credits that consist of text overlaid on a black background.
/// Bisects the end of the video file to perform an efficient search.
/// </summary>
public class BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) : IMediaFileAnalyzer
{
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
private readonly TimeSpan _maximumError = new(0, 0, 4);
private readonly ILogger<BlackFrameAnalyzer> _logger = logger;
/// <inheritdoc />
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
if (mode != AnalysisMode.Credits)
{
throw new NotImplementedException("mode must equal Credits");
}
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
var searchStart = 0.0;
foreach (var episode in episodesWithoutIntros)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
if (!AnalyzeChapters(episode, out var credit))
{
if (searchStart < _config.MinimumCreditsDuration)
{
searchStart = FindSearchStart(episode);
}
credit = AnalyzeMediaFile(
episode,
searchStart,
_config.BlackFrameMinimumPercentage);
}
if (credit is null || !credit.Valid)
{
continue;
}
episode.IsAnalyzed = true;
await Plugin.Instance!.UpdateTimestampAsync(credit, mode).ConfigureAwait(false);
searchStart = episode.Duration - credit.Start + _config.MinimumCreditsDuration;
}
return analysisQueue;
}
/// <summary>
/// Analyzes an individual media file. Only public because of unit tests.
/// </summary>
/// <param name="episode">Media file to analyze.</param>
/// <param name="searchStart">Search Start Piont.</param>
/// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Credits timestamp.</returns>
public Segment? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int minimum)
{
// Start by analyzing the last N minutes of the file.
var searchDistance = 2 * _config.MinimumCreditsDuration;
var upperLimit = searchStart;
var lowerLimit = Math.Max(searchStart - searchDistance, _config.MinimumCreditsDuration);
var start = TimeSpan.FromSeconds(upperLimit);
var end = TimeSpan.FromSeconds(lowerLimit);
var firstFrameTime = 0.0;
// Continue bisecting the end of the file until the range that contains the first black
// frame is smaller than the maximum permitted error.
while (start - end > _maximumError)
{
// Analyze the middle two seconds from the current bisected range
var midpoint = (start + end) / 2;
var scanTime = episode.Duration - midpoint.TotalSeconds;
var tr = new TimeRange(scanTime, scanTime + 2);
_logger.LogTrace(
"{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
episode.Name,
episode.Duration,
start,
end,
tr.Start,
tr.End);
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
_logger.LogTrace(
"{Episode} at {Start} has {Count} black frames",
episode.Name,
tr.Start,
frames.Length);
if (frames.Length == 0)
{
// Since no black frames were found, slide the range closer to the end
start = midpoint - TimeSpan.FromSeconds(2);
if (midpoint - TimeSpan.FromSeconds(lowerLimit) < _maximumError)
{
lowerLimit = Math.Max(lowerLimit - (0.5 * searchDistance), _config.MinimumCreditsDuration);
// Reset end for a new search with the increased duration
end = TimeSpan.FromSeconds(lowerLimit);
}
}
else
{
// Some black frames were found, slide the range closer to the start
end = midpoint;
firstFrameTime = frames[0].Time + scanTime;
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
{
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), episode.Duration - episode.CreditsFingerprintStart);
// Reset start for a new search with the increased duration
start = TimeSpan.FromSeconds(upperLimit);
}
}
}
if (firstFrameTime > 0)
{
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
}
return null;
}
private bool AnalyzeChapters(QueuedEpisode episode, out Segment? segment)
{
// Get last chapter that falls within the valid credits duration range
var suitableChapters = Plugin.Instance!.GetChapters(episode.EpisodeId)
.Select(c => TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds)
.Where(s => s >= episode.CreditsFingerprintStart &&
s <= episode.Duration - _config.MinimumCreditsDuration)
.OrderByDescending(s => s).ToList();
// If suitable chapters found, use them to find the search start point
foreach (var chapterStart in suitableChapters)
{
// Check for black frames at chapter start
var startRange = new TimeRange(chapterStart, chapterStart + 1);
var hasBlackFramesAtStart = FFmpegWrapper.DetectBlackFrames(
episode,
startRange,
_config.BlackFrameMinimumPercentage).Length > 0;
if (!hasBlackFramesAtStart)
{
break;
}
// Verify no black frames before chapter start
var beforeRange = new TimeRange(chapterStart - 5, chapterStart - 4);
var hasBlackFramesBefore = FFmpegWrapper.DetectBlackFrames(
episode,
beforeRange,
_config.BlackFrameMinimumPercentage).Length > 0;
if (!hasBlackFramesBefore)
{
segment = new(episode.EpisodeId, new TimeRange(chapterStart, episode.Duration));
return true;
}
}
segment = null;
return false;
}
private double FindSearchStart(QueuedEpisode episode)
{
var searchStart = 3 * _config.MinimumCreditsDuration;
var scanTime = episode.Duration - searchStart;
var tr = new TimeRange(scanTime - 0.5, scanTime); // Short search range since accuracy isn't important here.
// Keep increasing search start time while black frames are found, to avoid false positives
while (FFmpegWrapper.DetectBlackFrames(episode, tr, _config.BlackFrameMinimumPercentage).Length > 0)
{
// Increase by 2x minimum credits duration each iteration
searchStart += 2 * _config.MinimumCreditsDuration;
scanTime = episode.Duration - searchStart;
tr = new TimeRange(scanTime - 0.5, scanTime);
// Don't search past the required credits duration from the end
if (searchStart > episode.Duration - episode.CreditsFingerprintStart)
{
searchStart = episode.Duration - episode.CreditsFingerprintStart;
break;
}
}
return searchStart;
}
}

View File

@ -1,172 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Analyzers;
/// <summary>
/// Chapter name analyzer.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="ChapterAnalyzer"/> class.
/// </remarks>
/// <param name="logger">Logger.</param>
public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyzer
{
private readonly ILogger<ChapterAnalyzer> _logger = logger;
private readonly PluginConfiguration _config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <inheritdoc />
public async Task<IReadOnlyList<QueuedEpisode>> AnalyzeMediaFiles(
IReadOnlyList<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
var expression = mode switch
{
AnalysisMode.Introduction => _config.ChapterAnalyzerIntroductionPattern,
AnalysisMode.Credits => _config.ChapterAnalyzerEndCreditsPattern,
AnalysisMode.Recap => _config.ChapterAnalyzerRecapPattern,
AnalysisMode.Preview => _config.ChapterAnalyzerPreviewPattern,
_ => throw new ArgumentOutOfRangeException(nameof(mode), $"Unexpected analysis mode: {mode}")
};
if (string.IsNullOrWhiteSpace(expression))
{
return analysisQueue;
}
var episodesWithoutIntros = analysisQueue.Where(e => !e.IsAnalyzed).ToList();
foreach (var episode in episodesWithoutIntros)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var skipRange = FindMatchingChapter(
episode,
Plugin.Instance!.GetChapters(episode.EpisodeId),
expression,
mode);
if (skipRange is null || !skipRange.Valid)
{
continue;
}
episode.IsAnalyzed = true;
await Plugin.Instance!.UpdateTimestampAsync(skipRange, mode).ConfigureAwait(false);
}
return analysisQueue;
}
/// <summary>
/// Searches a list of chapter names for one that matches the provided regular expression.
/// Only public to allow for unit testing.
/// </summary>
/// <param name="episode">Episode.</param>
/// <param name="chapters">Media item chapters.</param>
/// <param name="expression">Regular expression pattern.</param>
/// <param name="mode">Analysis mode.</param>
/// <returns>Intro object containing skippable time range, or null if no chapter matched.</returns>
public Segment? FindMatchingChapter(
QueuedEpisode episode,
IReadOnlyList<ChapterInfo> chapters,
string expression,
AnalysisMode mode)
{
var count = chapters.Count;
if (count == 0)
{
return null;
}
var creditDuration = episode.IsMovie ? _config.MaximumMovieCreditsDuration : _config.MaximumCreditsDuration;
var reversed = mode == AnalysisMode.Credits;
var (minDuration, maxDuration) = reversed
? (_config.MinimumCreditsDuration, creditDuration)
: (_config.MinimumIntroDuration, _config.MaximumIntroDuration);
// Check all chapters
for (int i = reversed ? count - 1 : 0; reversed ? i >= 0 : i < count; i += reversed ? -1 : 1)
{
var chapter = chapters[i];
var next = chapters.ElementAtOrDefault(i + 1) ??
new ChapterInfo { StartPositionTicks = TimeSpan.FromSeconds(episode.Duration).Ticks }; // Since the ending credits chapter may be the last chapter in the file, append a virtual chapter.
if (string.IsNullOrWhiteSpace(chapter.Name))
{
continue;
}
var currentRange = new TimeRange(
TimeSpan.FromTicks(chapter.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds);
var baseMessage = string.Format(
CultureInfo.InvariantCulture,
"{0}: Chapter \"{1}\" ({2} - {3})",
episode.Path,
chapter.Name,
currentRange.Start,
currentRange.End);
if (currentRange.Duration < minDuration || currentRange.Duration > maxDuration)
{
_logger.LogTrace("{Base}: ignoring (invalid duration)", baseMessage);
continue;
}
// Regex.IsMatch() is used here in order to allow the runtime to cache the compiled regex
// between function invocations.
var match = Regex.IsMatch(
chapter.Name,
expression,
RegexOptions.IgnoreCase,
TimeSpan.FromSeconds(1));
if (!match)
{
_logger.LogTrace("{Base}: ignoring (does not match regular expression)", baseMessage);
continue;
}
// Check if the next (or previous for Credits) chapter also matches
var adjacentChapter = reversed ? chapters.ElementAtOrDefault(i - 1) : next;
if (adjacentChapter != null && !string.IsNullOrWhiteSpace(adjacentChapter.Name))
{
// Check for possibility of overlapping keywords
var overlap = Regex.IsMatch(
adjacentChapter.Name,
expression,
RegexOptions.None,
TimeSpan.FromSeconds(1));
if (overlap)
{
_logger.LogTrace("{Base}: ignoring (adjacent chapter also matches)", baseMessage);
continue;
}
}
_logger.LogTrace("{Base}: okay", baseMessage);
return new Segment(episode.EpisodeId, currentRange);
}
return null;
}
}

View File

@ -1,61 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Configuration;
/// <summary>
/// User interface configuration.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="UserInterfaceConfiguration"/> class.
/// </remarks>
/// <param name="visible">Skip button visibility.</param>
/// <param name="introText">Skip button intro text.</param>
/// <param name="creditsText">Skip button end credits text.</param>
/// <param name="autoSkip">Auto Skip Intro.</param>
/// <param name="autoSkipCredits">Auto Skip Credits.</param>
/// <param name="autoSkipRecap">Auto Skip Recap.</param>
/// <param name="autoSkipPreview">Auto Skip Preview.</param>
/// <param name="clientList">Auto Skip Clients.</param>
public class UserInterfaceConfiguration(bool visible, string introText, string creditsText, bool autoSkip, bool autoSkipCredits, bool autoSkipRecap, bool autoSkipPreview, string clientList)
{
/// <summary>
/// Gets or sets a value indicating whether to show the skip intro button.
/// </summary>
public bool SkipButtonVisible { get; set; } = visible;
/// <summary>
/// Gets or sets the text to display in the skip intro button in introduction mode.
/// </summary>
public string SkipButtonIntroText { get; set; } = introText;
/// <summary>
/// Gets or sets the text to display in the skip intro button in end credits mode.
/// </summary>
public string SkipButtonEndCreditsText { get; set; } = creditsText;
/// <summary>
/// Gets or sets a value indicating whether auto skip intro.
/// </summary>
public bool AutoSkip { get; set; } = autoSkip;
/// <summary>
/// Gets or sets a value indicating whether auto skip credits.
/// </summary>
public bool AutoSkipCredits { get; set; } = autoSkipCredits;
/// <summary>
/// Gets or sets a value indicating whether auto skip recap.
/// </summary>
public bool AutoSkipRecap { get; set; } = autoSkipRecap;
/// <summary>
/// Gets or sets a value indicating whether auto skip preview.
/// </summary>
public bool AutoSkipPreview { get; set; } = autoSkipPreview;
/// <summary>
/// Gets or sets a value indicating clients to auto skip for.
/// </summary>
public string ClientList { get; set; } = clientList;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,597 +0,0 @@
const introSkipper = {
originalFetch: window.fetch.bind(window),
originalXHROpen: XMLHttpRequest.prototype.open,
d: (msg) => console.debug("[intro skipper] ", msg),
setup() {
const self = this;
this.initializeState();
this.initializeObserver();
this.currentOption =
localStorage.getItem("introskipperOption") || "Show Button";
window.fetch = this.fetchWrapper.bind(this);
XMLHttpRequest.prototype.open = function (...args) {
self.xhrOpenWrapper(this, ...args);
};
document.addEventListener("viewshow", this.viewShow.bind(this));
this.videoPositionChanged = this.videoPositionChanged.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.d("Registered hooks");
},
initializeState() {
Object.assign(this, {
allowEnter: true,
skipSegments: {},
videoPlayer: null,
skipButton: null,
osdElement: null,
skipperData: null,
currentEpisodeId: null,
injectMetadata: false,
});
},
initializeObserver() {
this.observer = new MutationObserver((mutations) => {
const actionSheet =
mutations[mutations.length - 1].target.querySelector(".actionSheet");
if (
actionSheet &&
!actionSheet.querySelector(`[data-id="${"introskipperMenu"}"]`)
)
this.injectIntroSkipperOptions(actionSheet);
});
},
fetchWrapper(resource, options) {
const response = this.originalFetch(resource, options);
const url = new URL(resource);
if (this.injectMetadata && url.pathname.includes("/MetadataEditor")) {
this.processMetadata(url.pathname);
}
return response;
},
xhrOpenWrapper(xhr, method, url, ...rest) {
url.includes("/PlaybackInfo") && this.processPlaybackInfo(url);
return this.originalXHROpen.apply(xhr, [method, url, ...rest]);
},
async processPlaybackInfo(url) {
const id = this.extractId(url);
if (id) {
try {
this.skipSegments = await this.secureFetch(
`Episode/${id}/IntroSkipperSegments`,
);
} catch (error) {
this.d(`Error fetching skip segments: ${error.message}`);
}
}
},
async processMetadata(url) {
const id = this.extractId(url);
if (id) {
try {
this.skipperData = await this.secureFetch(`Episode/${id}/Timestamps`);
if (this.skipperData) {
this.currentEpisodeId = id;
requestAnimationFrame(() => {
const metadataFormFields = document.querySelector(
".metadataFormFields",
);
metadataFormFields && this.injectSkipperFields(metadataFormFields);
});
}
} catch (e) {
console.error("Error processing", e);
}
}
},
extractId(searchString) {
const startIndex = searchString.indexOf("Items/") + 6;
const endIndex = searchString.indexOf("/", startIndex);
return endIndex !== -1
? searchString.substring(startIndex, endIndex)
: searchString.substring(startIndex);
},
/**
* Event handler that runs whenever the current view changes.
* Used to detect the start of video playback.
*/
viewShow() {
const location = window.location.hash;
this.d(`Location changed to ${location}`);
this.allowEnter = true;
this.injectMetadata = /#\/(tv|details|home|search)/.test(location);
if (location === "#/video") {
this.injectCss();
this.injectButton();
this.videoPlayer = document.querySelector("video");
if (this.videoPlayer) {
this.d("Hooking video timeupdate");
this.videoPlayer.addEventListener(
"timeupdate",
this.videoPositionChanged,
);
this.osdElement = document.querySelector("div.videoOsdBottom");
this.observer.observe(document.body, {
childList: true,
subtree: false,
});
}
} else {
this.observer.disconnect();
}
},
/**
* Injects the CSS used by the skip intro button.
* Calling this function is a no-op if the CSS has already been injected.
*/
injectCss() {
if (document.querySelector("style#introSkipperCss")) {
this.d("CSS already added");
return;
}
this.d("Adding CSS");
const styleElement = document.createElement("style");
styleElement.id = "introSkipperCss";
styleElement.textContent = `
:root {
--rounding: 4px;
--accent: 0, 164, 220;
}
#skipIntro.upNextContainer {
width: unset;
margin: unset;
}
#skipIntro {
position: absolute;
bottom: 7.5em;
right: 5em;
background-color: transparent;
}
#skipIntro .emby-button {
color: #ffffff;
font-size: 110%;
background: rgba(0, 0, 0, 0.7);
border-radius: var(--rounding);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
transition: opacity 0.3s cubic-bezier(0.4,0,0.2,1),
transform 0.3s cubic-bezier(0.4,0,0.2,1),
background-color 0.2s ease-out,
box-shadow 0.2s ease-out;
opacity: 0;
transform: translateY(50%);
}
#skipIntro.show .emby-button {
opacity: 1;
transform: translateY(0);
}
#skipIntro .emby-button:hover {
background: rgb(var(--accent));
box-shadow: 0 0 8px rgba(var(--accent), 0.6);
filter: brightness(1.2);
}
#skipIntro .emby-button:focus {
background: rgb(var(--accent));
box-shadow: 0 0 8px rgba(var(--accent), 0.6);
}
#btnSkipSegmentText {
letter-spacing: 0.5px;
padding: 0 5px 0 5px;
}
`;
document.querySelector("head").appendChild(styleElement);
},
/**
* Inject the skip intro button into the video player.
* Calling this function is a no-op if the CSS has already been injected.
*/
async injectButton() {
// Ensure the button we're about to inject into the page doesn't conflict with a pre-existing one
const preExistingButton = document.querySelector("div.skipIntro");
if (preExistingButton) {
preExistingButton.style.display = "none";
}
if (document.querySelector(".btnSkipIntro.injected")) {
this.d("Button already added");
this.skipButton = document.querySelector("#skipIntro");
return;
}
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
if (!config.SkipButtonVisible) {
this.d("Not adding button: not visible");
return;
}
this.d("Adding button");
this.skipButton = document.createElement("div");
this.skipButton.id = "skipIntro";
this.skipButton.classList.add("hide", "upNextContainer");
this.skipButton.addEventListener("click", this.doSkip.bind(this));
this.skipButton.addEventListener("keydown", this.eventHandler.bind(this));
this.skipButton.innerHTML = `
<button is="emby-button" type="button" class="btnSkipIntro injected">
<span id="btnSkipSegmentText"></span>
<span class="material-icons skip_next"></span>
</button>
`;
this.skipButton.dataset.Introduction = config.SkipButtonIntroText;
this.skipButton.dataset.Credits = config.SkipButtonEndCreditsText;
const controls = document.querySelector("div#videoOsdPage");
controls.appendChild(this.skipButton);
},
/** Tests if the OSD controls are visible. */
osdVisible() {
return this.osdElement
? !this.osdElement.classList.contains("hide")
: false;
},
/** Get the currently playing skippable segment. */
getCurrentSegment(position) {
for (const key in this.skipSegments) {
const segment = this.skipSegments[key];
if (
(position > segment.ShowSkipPromptAt &&
position < segment.HideSkipPromptAt) ||
(this.osdVisible() &&
position > segment.IntroStart &&
position < segment.IntroEnd - 3)
) {
segment["SegmentType"] = key;
return segment;
}
}
return { SegmentType: "None" };
},
overrideBlur(button) {
if (!button.originalBlur) {
button.originalBlur = button.blur;
button.blur = function () {
if (!this.contains(document.activeElement)) {
this.originalBlur();
}
};
}
},
/** Playback position changed, check if the skip button needs to be displayed. */
videoPositionChanged() {
if (!this.skipButton || !this.allowEnter) return;
const { SegmentType: segmentType } = this.getCurrentSegment(this.videoPlayer.currentTime);
if (
segmentType === "None" ||
this.currentOption === "Off"
) {
this.hideSkipButton();
return;
}
if (
this.currentOption === "Automatically Skip" ||
(this.currentOption === "Button w/ auto PiP" &&
document.pictureInPictureElement)
) {
this.doSkip();
return;
}
const button = this.skipButton.querySelector(".emby-button");
this.skipButton.querySelector("#btnSkipSegmentText").textContent =
this.skipButton.dataset[segmentType];
if (!this.skipButton.classList.contains("hide")) {
if (!this.osdVisible() && !button.contains(document.activeElement)) {
button.focus();
}
return;
}
requestAnimationFrame(() => {
this.skipButton.classList.remove("hide");
requestAnimationFrame(() => {
this.skipButton.classList.add("show");
this.overrideBlur(button);
button.focus();
});
});
},
hideSkipButton() {
if (this.skipButton.classList.contains("show")) {
this.skipButton.classList.remove("show");
const button = this.skipButton.querySelector(".emby-button");
button.addEventListener(
"transitionend",
() => {
this.skipButton.classList.add("hide");
if (this.osdVisible()) {
this.osdElement.querySelector("button.btnPause").focus();
} else {
button.originalBlur();
}
},
{ once: true },
);
}
},
/** Seeks to the end of the intro. */
doSkip() {
if (!this.allowEnter) return;
const segment = this.getCurrentSegment(this.videoPlayer.currentTime);
if (segment.SegmentType === "None") {
console.warn("[intro skipper] doSkip() called without an active segment");
return;
}
this.d(`Skipping ${segment.SegmentType}`);
const seekedHandler = () => {
this.videoPlayer.removeEventListener("seeked", seekedHandler);
setTimeout(() => {
this.allowEnter = true;
}, 700);
};
this.videoPlayer.addEventListener("seeked", seekedHandler);
this.videoPlayer.currentTime = segment.IntroEnd;
this.hideSkipButton();
},
createButton(ref, id, innerHTML, clickHandler) {
const button = ref.cloneNode(true);
button.setAttribute("data-id", id);
button.innerHTML = innerHTML;
button.addEventListener("click", clickHandler);
return button;
},
closeSubmenu(fullscreen) {
document.querySelector(".dialogContainer").remove();
document.querySelector(".dialogBackdrop").remove();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Control" }));
if (!fullscreen) return;
document.removeEventListener("keydown", this.handleEscapeKey);
document.querySelector(".btnVideoOsdSettings").focus();
},
openSubmenu(ref, menu) {
const options = [
"Show Button",
"Button w/ auto PiP",
"Automatically Skip",
"Off",
];
const submenu = menu.cloneNode(true);
const scroller = submenu.querySelector(".actionSheetScroller");
scroller.innerHTML = "";
for (const option of options) {
if (option !== "Button w/ auto PiP" || document.pictureInPictureEnabled) {
const button = this.createButton(
ref,
`introskipper-${option.toLowerCase().replace(" ", "-")}`,
`<span class="actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons check" aria-hidden="true" style="visibility:${option === this.currentOption ? "visible" : "hidden"};"></span><div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">${option}</div></div>`,
() => this.selectOption(option),
);
scroller.appendChild(button);
}
}
const backdrop = document.createElement("div");
backdrop.className = "dialogBackdrop dialogBackdropOpened";
document.body.append(backdrop, submenu);
const actionSheet = submenu.querySelector(".actionSheet");
if (actionSheet.classList.contains("actionsheet-not-fullscreen")) {
this.adjustPosition(
actionSheet,
document.querySelector(".btnVideoOsdSettings"),
);
submenu.addEventListener("click", () => this.closeSubmenu(false));
} else {
submenu
.querySelector(".btnCloseActionSheet")
.addEventListener("click", () => this.closeSubmenu(true));
scroller.addEventListener("click", () => this.closeSubmenu(true));
document.addEventListener("keydown", this.handleEscapeKey);
setTimeout(() => scroller.firstElementChild.focus(), 240);
}
},
selectOption(option) {
this.currentOption = option;
localStorage.setItem("introskipperOption", option);
this.d(`Introskipper option selected and saved: ${option}`);
},
isAutoSkipLocked(config) {
const isAutoSkip = config.AutoSkip && config.AutoSkipCredits;
const isAutoSkipClient = new Set(config.ClientList.split(",")).has(
ApiClient.appName(),
);
return isAutoSkip || (config.SkipButtonVisible && isAutoSkipClient);
},
async injectIntroSkipperOptions(actionSheet) {
if (!this.skipButton) return;
const config = await this.secureFetch("Intros/UserInterfaceConfiguration");
if (this.isAutoSkipLocked(config)) {
this.d("Auto skip enforced by server");
return;
}
const statsButton = actionSheet.querySelector('[data-id="stats"]');
if (!statsButton) return;
const menuItem = this.createButton(
statsButton,
"introskipperMenu",
`<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">Intro Skipper</div></div><div class="listItemAside actionSheetItemAsideText">${this.currentOption}</div>`,
() =>
this.openSubmenu(statsButton, actionSheet.closest(".dialogContainer")),
);
const originalWidth = actionSheet.offsetWidth;
statsButton.before(menuItem);
if (actionSheet.classList.contains("actionsheet-not-fullscreen"))
this.adjustPosition(actionSheet, menuItem, originalWidth);
},
adjustPosition(element, reference, originalWidth) {
if (originalWidth) {
const currentTop = Number.parseInt(element.style.top, 10) || 0;
element.style.top = `${currentTop - reference.offsetHeight}px`;
const newWidth = Math.max(reference.offsetWidth - originalWidth, 0);
const originalLeft = Number.parseInt(element.style.left, 10) || 0;
element.style.left = `${originalLeft - newWidth / 2}px`;
} else {
const rect = reference.getBoundingClientRect();
element.style.left = `${Math.min(rect.left - (element.offsetWidth - rect.width) / 2, window.innerWidth - element.offsetWidth - 10)}px`;
element.style.top = `${rect.top - element.offsetHeight + rect.height}px`;
}
},
injectSkipperFields(metadataFormFields) {
const skipperFields = document.createElement("div");
skipperFields.className = "detailSection introskipperSection";
skipperFields.innerHTML = `
<h2>Intro Skipper</h2>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
<input type="text" id="introStartDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="introStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
<input type="text" id="introEndDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="introEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
</div>
<div class="inlineForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
<input type="text" id="creditsStartDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="creditsStartEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
<input type="text" id="creditsEndDisplay" class="emby-input custom-time-input" readonly>
<input type="number" id="creditsEndEdit" class="emby-input custom-time-input" style="display: none;" step="any" min="0">
</div>
</div>
`;
metadataFormFields
.querySelector("#metadataSettingsCollapsible")
.insertAdjacentElement("afterend", skipperFields);
this.attachSaveListener(metadataFormFields);
this.updateSkipperFields(skipperFields);
this.setTimeInputs(skipperFields);
},
updateSkipperFields(skipperFields) {
const { Introduction = {}, Credits = {} } = this.skipperData;
skipperFields.querySelector("#introStartEdit").value =
Introduction.Start || 0;
skipperFields.querySelector("#introEndEdit").value = Introduction.End || 0;
skipperFields.querySelector("#creditsStartEdit").value = Credits.Start || 0;
skipperFields.querySelector("#creditsEndEdit").value = Credits.End || 0;
},
attachSaveListener(metadataFormFields) {
const saveButton = metadataFormFields.querySelector(
".formDialogFooter .btnSave",
);
if (saveButton) {
saveButton.addEventListener("click", this.saveSkipperData.bind(this));
} else {
console.error("Save button not found");
}
},
setTimeInputs(skipperFields) {
const inputContainers = skipperFields.querySelectorAll(".inputContainer");
for (const container of inputContainers) {
const displayInput = container.querySelector('[id$="Display"]');
const editInput = container.querySelector('[id$="Edit"]');
displayInput.addEventListener("pointerdown", (e) => {
e.preventDefault();
this.switchToEdit(displayInput, editInput);
});
editInput.addEventListener("blur", () =>
this.switchToDisplay(displayInput, editInput),
);
displayInput.value = this.formatTime(
Number.parseFloat(editInput.value) || 0,
);
}
},
formatTime(totalSeconds) {
if (!totalSeconds) return "0 seconds";
const totalRoundedSeconds = Math.round(totalSeconds);
const hours = Math.floor(totalRoundedSeconds / 3600);
const minutes = Math.floor((totalRoundedSeconds % 3600) / 60);
const seconds = totalRoundedSeconds % 60;
const result = [];
if (hours) result.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
if (minutes) result.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
if (seconds || !result.length)
result.push(`${seconds} second${seconds !== 1 ? "s" : ""}`);
return result.join(" ");
},
switchToEdit(displayInput, editInput) {
displayInput.style.display = "none";
editInput.style.display = "";
editInput.focus();
},
switchToDisplay(displayInput, editInput) {
editInput.style.display = "none";
displayInput.style.display = "";
displayInput.value = this.formatTime(
Number.parseFloat(editInput.value) || 0,
);
},
async saveSkipperData() {
const newTimestamps = {
Introduction: {
Start: Number.parseFloat(
document.getElementById("introStartEdit").value || 0,
),
End: Number.parseFloat(
document.getElementById("introEndEdit").value || 0,
),
},
Credits: {
Start: Number.parseFloat(
document.getElementById("creditsStartEdit").value || 0,
),
End: Number.parseFloat(
document.getElementById("creditsEndEdit").value || 0,
),
},
};
const { Introduction = {}, Credits = {} } = this.skipperData;
if (
newTimestamps.Introduction.Start !== (Introduction.Start || 0) ||
newTimestamps.Introduction.End !== (Introduction.End || 0) ||
newTimestamps.Credits.Start !== (Credits.Start || 0) ||
newTimestamps.Credits.End !== (Credits.End || 0)
) {
const response = await this.secureFetch(
`Episode/${this.currentEpisodeId}/Timestamps`,
"POST",
JSON.stringify(newTimestamps),
);
this.d(
response.ok
? "Timestamps updated successfully"
: "Failed to update timestamps:",
response.status,
);
} else {
this.d("Timestamps have not changed, skipping update");
}
},
/** Make an authenticated fetch to the Jellyfin server and parse the response body as JSON. */
async secureFetch(url, method = "GET", body = null) {
const response = await fetch(`${ApiClient.serverAddress()}/${url}`, {
method,
headers: Object.assign(
{ Authorization: `MediaBrowser Token=${ApiClient.accessToken()}` },
method === "POST" ? { "Content-Type": "application/json" } : {},
),
body,
});
return response.ok
? method === "POST"
? response
: response.json()
: response.status === 404
? null
: console.error(`Error ${response.status} from ${url}`) || null;
},
/** Handle keydown events. */
eventHandler(e) {
if (e.key !== "Enter") return;
e.stopPropagation();
e.preventDefault();
this.doSkip();
},
handleEscapeKey(e) {
if (e.key === "Escape" || e.keyCode === 461 || e.keyCode === 10009) {
e.stopPropagation();
this.closeSubmenu(true);
}
},
};
introSkipper.setup();

View File

@ -1,274 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Configuration;
using IntroSkipper.Data;
using IntroSkipper.Db;
using IntroSkipper.Manager;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace IntroSkipper.Controllers;
/// <summary>
/// Skip intro controller.
/// </summary>
[Authorize]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
public class SkipIntroController(MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase
{
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
/// <summary>
/// Returns the timestamps of the introduction in a television episode. Responses are in API version 1 format.
/// </summary>
/// <param name="id">ID of the episode. Required.</param>
/// <param name="mode">Timestamps to return. Optional. Defaults to Introduction for backwards compatibility.</param>
/// <response code="200">Episode contains an intro.</response>
/// <response code="404">Failed to find an intro in the provided episode.</response>
/// <returns>Detected intro.</returns>
[HttpGet("Episode/{id}/IntroTimestamps")]
[HttpGet("Episode/{id}/IntroTimestamps/v1")]
public ActionResult<Intro> GetIntroTimestamps(
[FromRoute] Guid id,
[FromQuery] AnalysisMode mode = AnalysisMode.Introduction)
{
var intros = GetIntros(id);
if (!intros.TryGetValue(mode, out var intro))
{
return NotFound();
}
return intro;
}
/// <summary>
/// Updates the timestamps for the provided episode.
/// </summary>
/// <param name="id">Episode ID to update timestamps for.</param>
/// <param name="timestamps">New timestamps Introduction/Credits start and end times.</param>
/// <param name="cancellationToken">Cancellation Token.</param>
/// <response code="204">New timestamps saved.</response>
/// <response code="404">Given ID is not an Episode.</response>
/// <returns>No content.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("Episode/{Id}/Timestamps")]
public async Task<ActionResult> UpdateTimestampsAsync([FromRoute] Guid id, [FromBody] TimeStamps timestamps, CancellationToken cancellationToken = default)
{
// only update existing episodes
var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem is not Episode and not Movie)
{
return NotFound();
}
if (timestamps == null)
{
return NoContent();
}
var segmentTypes = new[]
{
(AnalysisMode.Introduction, timestamps.Introduction),
(AnalysisMode.Credits, timestamps.Credits),
(AnalysisMode.Recap, timestamps.Recap),
(AnalysisMode.Preview, timestamps.Preview)
};
foreach (var (mode, segment) in segmentTypes)
{
if (segment.Valid)
{
await Plugin.Instance!.UpdateTimestampAsync(segment, mode).ConfigureAwait(false);
}
}
if (Plugin.Instance.Configuration.UpdateMediaSegments)
{
var episode = Plugin.Instance!.QueuedMediaItems[rawItem is Episode e ? e.SeasonId : rawItem.Id]
.FirstOrDefault(q => q.EpisodeId == rawItem.Id);
if (episode is not null)
{
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync([episode], cancellationToken).ConfigureAwait(false);
}
}
return NoContent();
}
/// <summary>
/// Gets the timestamps for the provided episode.
/// </summary>
/// <param name="id">Episode ID.</param>
/// <response code="200">Sucess.</response>
/// <response code="404">Given ID is not an Episode.</response>
/// <returns>Episode Timestamps.</returns>
[HttpGet("Episode/{Id}/Timestamps")]
[ActionName("UpdateTimestamps")]
public ActionResult<TimeStamps> GetTimestamps([FromRoute] Guid id)
{
// only get return content for episodes
var rawItem = Plugin.Instance!.GetItem(id);
if (rawItem is not Episode and not Movie)
{
return NotFound();
}
var times = new TimeStamps();
var segments = Plugin.Instance!.GetTimestamps(id);
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
{
times.Introduction = introSegment;
}
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
{
times.Credits = creditSegment;
}
if (segments.TryGetValue(AnalysisMode.Recap, out var recapSegment))
{
times.Recap = recapSegment;
}
if (segments.TryGetValue(AnalysisMode.Preview, out var previewSegment))
{
times.Preview = previewSegment;
}
return times;
}
/// <summary>
/// Gets a dictionary of all skippable segments.
/// </summary>
/// <param name="id">Media ID.</param>
/// <response code="200">Skippable segments dictionary.</response>
/// <returns>Dictionary of skippable segments.</returns>
[HttpGet("Episode/{id}/IntroSkipperSegments")]
public ActionResult<Dictionary<AnalysisMode, Intro>> GetSkippableSegments([FromRoute] Guid id)
{
var segments = GetIntros(id);
var result = new Dictionary<AnalysisMode, Intro>();
if (segments.TryGetValue(AnalysisMode.Introduction, out var introSegment))
{
result[AnalysisMode.Introduction] = introSegment;
}
if (segments.TryGetValue(AnalysisMode.Credits, out var creditSegment))
{
result[AnalysisMode.Credits] = creditSegment;
}
return result;
}
/// <summary>Lookup and return the skippable timestamps for the provided item.</summary>
/// <param name="id">Unique identifier of this episode.</param>
/// <returns>Intro object if the provided item has an intro, null otherwise.</returns>
internal static Dictionary<AnalysisMode, Intro> GetIntros(Guid id)
{
var timestamps = Plugin.Instance!.GetTimestamps(id);
var intros = new Dictionary<AnalysisMode, Intro>();
var runTime = TimeSpan.FromTicks(Plugin.Instance!.GetItem(id)?.RunTimeTicks ?? 0).TotalSeconds;
var config = Plugin.Instance.Configuration;
foreach (var (mode, timestamp) in timestamps)
{
if (!timestamp.Valid)
{
continue;
}
// Create new Intro to avoid mutating the original stored in dictionary
var segment = new Intro(timestamp);
// Calculate intro end time
segment.IntroEnd = runTime > 0 && runTime < segment.IntroEnd + 1
? runTime
: segment.IntroEnd - config.RemainingSecondsOfIntro;
// Set skip button prompt visibility times
const double MIN_REMAINING_TIME = 3.0; // Minimum seconds before end to hide prompt
if (config.PersistSkipButton)
{
segment.ShowSkipPromptAt = segment.IntroStart;
segment.HideSkipPromptAt = segment.IntroEnd - MIN_REMAINING_TIME;
}
else
{
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
segment.HideSkipPromptAt = Math.Min(
segment.IntroStart + config.HidePromptAdjustment,
segment.IntroEnd - MIN_REMAINING_TIME);
}
intros[mode] = segment;
}
return intros;
}
/// <summary>
/// Erases all previously discovered introduction timestamps.
/// </summary>
/// <param name="mode">Mode.</param>
/// <param name="eraseCache">Erase cache.</param>
/// <response code="204">Operation successful.</response>
/// <returns>No content.</returns>
[Authorize(Policy = Policies.RequiresElevation)]
[HttpPost("Intros/EraseTimestamps")]
public async Task<ActionResult> ResetIntroTimestamps([FromQuery] AnalysisMode mode, [FromQuery] bool eraseCache = false)
{
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath);
var segments = await db.DbSegment
.Where(s => s.Type == mode)
.ToListAsync()
.ConfigureAwait(false);
db.DbSegment.RemoveRange(segments);
await db.SaveChangesAsync().ConfigureAwait(false);
if (eraseCache && mode is AnalysisMode.Introduction or AnalysisMode.Credits)
{
await Task.Run(() => FFmpegWrapper.DeleteCacheFiles(mode)).ConfigureAwait(false);
}
return NoContent();
}
/// <summary>
/// Gets the user interface configuration.
/// </summary>
/// <response code="200">UserInterfaceConfiguration returned.</response>
/// <returns>UserInterfaceConfiguration.</returns>
[HttpGet]
[Route("Intros/UserInterfaceConfiguration")]
public ActionResult<UserInterfaceConfiguration> GetUserInterfaceConfiguration()
{
var config = Plugin.Instance!.Configuration;
return new UserInterfaceConfiguration(
config.SkipButtonEnabled,
config.SkipButtonIntroText,
config.SkipButtonEndCreditsText,
config.AutoSkip,
config.AutoSkipCredits,
config.AutoSkipRecap,
config.AutoSkipPreview,
config.ClientList);
}
}

View File

@ -1,156 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Globalization;
using System.IO;
using System.Net.Mime;
using System.Text;
using IntroSkipper.Data;
using IntroSkipper.Helper;
using MediaBrowser.Common;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Controllers;
/// <summary>
/// Troubleshooting controller.
/// </summary>
[Authorize(Policy = Policies.RequiresElevation)]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Route("IntroSkipper")]
public class TroubleshootingController : ControllerBase
{
private readonly ILibraryManager _libraryManager;
private readonly IApplicationHost _applicationHost;
private readonly ILogger<TroubleshootingController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TroubleshootingController"/> class.
/// </summary>
/// <param name="applicationHost">Application host.</param>
/// <param name="libraryManager">Library Manager.</param>
/// <param name="logger">Logger.</param>
public TroubleshootingController(
IApplicationHost applicationHost,
ILibraryManager libraryManager,
ILogger<TroubleshootingController> logger)
{
_libraryManager = libraryManager;
_applicationHost = applicationHost;
_logger = logger;
}
/// <summary>
/// Gets a Markdown formatted support bundle.
/// </summary>
/// <response code="200">Support bundle created.</response>
/// <returns>Support bundle.</returns>
[HttpGet("SupportBundle")]
[Produces(MediaTypeNames.Text.Plain)]
public ActionResult<string> GetSupportBundle()
{
ArgumentNullException.ThrowIfNull(Plugin.Instance);
var bundle = new StringBuilder();
bundle.Append("* Jellyfin version: ");
bundle.Append(_applicationHost.ApplicationVersionString);
bundle.Append('\n');
var version = Plugin.Instance.Version.ToString(3);
try
{
var commit = Commit.CommitHash;
if (!string.IsNullOrWhiteSpace(commit))
{
version += string.Concat("+", commit.AsSpan(0, 12));
}
}
catch (Exception ex)
{
_logger.LogWarning("Unable to append commit to version: {Exception}", ex);
}
bundle.Append("* Plugin version: ");
bundle.Append(version);
bundle.Append('\n');
bundle.Append("* Queue contents: ");
bundle.Append(Plugin.Instance.TotalQueued);
bundle.Append(" episodes, ");
bundle.Append(Plugin.Instance.TotalSeasons);
bundle.Append(" seasons\n");
bundle.Append("* Warnings: `");
bundle.Append(WarningManager.GetWarnings());
bundle.Append("`\n");
bundle.Append(FFmpegWrapper.GetChromaprintLogs());
return bundle.ToString();
}
/// <summary>
/// Gets a Markdown formatted support bundle.
/// </summary>
/// <response code="200">Support bundle created.</response>
/// <returns>Support bundle.</returns>
[HttpGet("Storage")]
[Produces(MediaTypeNames.Text.Plain)]
public ActionResult<string> GetFreeSpace()
{
ArgumentNullException.ThrowIfNull(Plugin.Instance);
var bundle = new StringBuilder();
var libraries = _libraryManager.GetVirtualFolders();
foreach (var library in libraries)
{
try
{
DriveInfo driveInfo = new DriveInfo(library.Locations[0]);
// Get available free space in bytes
long availableFreeSpace = driveInfo.AvailableFreeSpace;
// Get total size of the drive in bytes
long totalSize = driveInfo.TotalSize;
// Get total used space in Percentage
double usedSpacePercentage = totalSize > 0 ? (totalSize - availableFreeSpace) / (double)totalSize * 100 : 0;
bundle.Append(CultureInfo.CurrentCulture, $"Library: {library.Name}\n");
bundle.Append(CultureInfo.CurrentCulture, $"Drive: {driveInfo.Name}\n");
bundle.Append(CultureInfo.CurrentCulture, $"Total Size: {GetHumanReadableSize(totalSize)}\n");
bundle.Append(CultureInfo.CurrentCulture, $"Available Free Space: {GetHumanReadableSize(availableFreeSpace)}\n");
bundle.Append(CultureInfo.CurrentCulture, $"Total used in Percentage: {Math.Round(usedSpacePercentage, 2)}%\n\n");
}
catch (Exception ex)
{
_logger.LogWarning("Unable to get DriveInfo: {Exception}", ex);
}
}
return bundle.ToString().TrimEnd('\n');
}
private static string GetHumanReadableSize(long bytes)
{
string[] sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len /= 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}

View File

@ -1,249 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using IntroSkipper.Data;
using IntroSkipper.Db;
using IntroSkipper.Manager;
using MediaBrowser.Common.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace IntroSkipper.Controllers;
/// <summary>
/// Audio fingerprint visualization controller. Allows browsing fingerprints on a per episode basis.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="VisualizationController"/> class.
/// </remarks>
/// <param name="logger">Logger.</param>
/// <param name="mediaSegmentUpdateManager">Media Segment Update Manager.</param>
[Authorize(Policy = Policies.RequiresElevation)]
[ApiController]
[Produces(MediaTypeNames.Application.Json)]
[Route("Intros")]
public class VisualizationController(ILogger<VisualizationController> logger, MediaSegmentUpdateManager mediaSegmentUpdateManager) : ControllerBase
{
private readonly ILogger<VisualizationController> _logger = logger;
private readonly MediaSegmentUpdateManager _mediaSegmentUpdateManager = mediaSegmentUpdateManager;
/// <summary>
/// Returns all show names and seasons.
/// </summary>
/// <returns>Dictionary of show names to a list of season names.</returns>
[HttpGet("Shows")]
public ActionResult<Dictionary<Guid, ShowInfos>> GetShowSeasons()
{
_logger.LogDebug("Returning season IDs by series name");
var showSeasons = new Dictionary<Guid, ShowInfos>();
foreach (var kvp in Plugin.Instance!.QueuedMediaItems)
{
if (kvp.Value.FirstOrDefault() is QueuedEpisode first)
{
var seriesId = first.SeriesId;
var seasonId = kvp.Key;
var seasonNumber = first.SeasonNumber;
if (!showSeasons.TryGetValue(seriesId, out var showInfo))
{
showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), IsMovie = first.IsMovie, Seasons = [] };
showSeasons[seriesId] = showInfo;
}
showInfo.Seasons[seasonId] = seasonNumber;
}
}
// Sort the dictionary by SeriesName and the seasons by SeasonName
var sortedShowSeasons = showSeasons
.OrderBy(kvp => kvp.Value.SeriesName)
.ToDictionary(
kvp => kvp.Key,
kvp => new ShowInfos
{
SeriesName = kvp.Value.SeriesName,
ProductionYear = kvp.Value.ProductionYear,
LibraryName = kvp.Value.LibraryName,
IsMovie = kvp.Value.IsMovie,
Seasons = kvp.Value.Seasons
.OrderBy(s => s.Value)
.ToDictionary(s => s.Key, s => s.Value)
});
return sortedShowSeasons;
}
/// <summary>
/// Returns the analyzer actions for the provided season.
/// </summary>
/// <param name="seasonId">Season ID.</param>
/// <returns>List of episode titles.</returns>
[HttpGet("AnalyzerActions/{SeasonId}")]
public ActionResult<IReadOnlyDictionary<AnalysisMode, AnalyzerAction>> GetAnalyzerAction([FromRoute] Guid seasonId)
{
if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(seasonId))
{
return NotFound();
}
var analyzerActions = new Dictionary<AnalysisMode, AnalyzerAction>();
foreach (var mode in Enum.GetValues<AnalysisMode>())
{
analyzerActions[mode] = Plugin.Instance!.GetAnalyzerAction(seasonId, mode);
}
return Ok(analyzerActions);
}
/// <summary>
/// Returns the names and unique identifiers of all episodes in the provided season.
/// </summary>
/// <param name="seriesId">Show ID.</param>
/// <param name="seasonId">Season ID.</param>
/// <returns>List of episode titles.</returns>
[HttpGet("Show/{SeriesId}/{SeasonId}")]
public ActionResult<List<EpisodeVisualization>> GetSeasonEpisodes([FromRoute] Guid seriesId, [FromRoute] Guid seasonId)
{
if (!Plugin.Instance!.QueuedMediaItems.TryGetValue(seasonId, out var episodes))
{
return NotFound();
}
if (!episodes.Any(e => e.SeriesId == seriesId))
{
return NotFound();
}
var showName = episodes.FirstOrDefault()?.SeriesName!;
return episodes.Select(e => new EpisodeVisualization(e.EpisodeId, e.Name)).ToList();
}
/// <summary>
/// Fingerprint the provided episode and returns the uncompressed fingerprint data points.
/// </summary>
/// <param name="id">Episode id.</param>
/// <returns>Read only collection of fingerprint points.</returns>
[HttpGet("Episode/{Id}/Chromaprint")]
public ActionResult<uint[]> GetEpisodeFingerprint([FromRoute] Guid id)
{
// Search through all queued episodes to find the requested id
foreach (var season in Plugin.Instance!.QueuedMediaItems)
{
foreach (var needle in season.Value)
{
if (needle.EpisodeId == id)
{
return FFmpegWrapper.Fingerprint(needle, AnalysisMode.Introduction);
}
}
}
return NotFound();
}
/// <summary>
/// Erases all timestamps for the provided season.
/// </summary>
/// <param name="seriesId">Show ID.</param>
/// <param name="seasonId">Season ID.</param>
/// <param name="eraseCache">Erase cache.</param>
/// <param name="cancellationToken">Cancellation Token.</param>
/// <response code="204">Season timestamps erased.</response>
/// <response code="404">Unable to find season in provided series.</response>
/// <returns>No content.</returns>
[HttpDelete("Show/{SeriesId}/{SeasonId}")]
public async Task<ActionResult> EraseSeasonAsync([FromRoute] Guid seriesId, [FromRoute] Guid seasonId, [FromQuery] bool eraseCache = false, CancellationToken cancellationToken = default)
{
var episodes = Plugin.Instance!.QueuedMediaItems[seasonId];
if (episodes.Count == 0)
{
return NotFound();
}
_logger.LogInformation("Erasing timestamps for series {SeriesId} season {SeasonId} at user request", seriesId, seasonId);
try
{
using var db = new IntroSkipperDbContext(Plugin.Instance!.DbPath);
foreach (var episode in episodes)
{
cancellationToken.ThrowIfCancellationRequested();
var existingSegments = db.DbSegment.Where(s => s.ItemId == episode.EpisodeId);
db.DbSegment.RemoveRange(existingSegments);
if (eraseCache)
{
await Task.Run(() => FFmpegWrapper.DeleteEpisodeCache(episode.EpisodeId), cancellationToken).ConfigureAwait(false);
}
}
var seasonInfo = db.DbSeasonInfo.Where(s => s.SeasonId == seasonId);
foreach (var info in seasonInfo)
{
db.Entry(info).Property(s => s.EpisodeIds).CurrentValue = [];
}
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (Plugin.Instance.Configuration.UpdateMediaSegments)
{
await _mediaSegmentUpdateManager.UpdateMediaSegmentsAsync(episodes, cancellationToken).ConfigureAwait(false);
}
return NoContent();
}
catch (Exception ex)
{
return StatusCode(500, ex.Message);
}
}
/// <summary>
/// Updates the analyzer actions for the provided season.
/// </summary>
/// <param name="request">Update analyzer actions request.</param>
/// <returns>No content.</returns>
[HttpPost("AnalyzerActions/UpdateSeason")]
public async Task<ActionResult> UpdateAnalyzerActions([FromBody] UpdateAnalyzerActionsRequest request)
{
await Plugin.Instance!.SetAnalyzerActionAsync(request.Id, request.AnalyzerActions).ConfigureAwait(false);
return NoContent();
}
private static string GetProductionYear(Guid seriesId)
{
return seriesId == Guid.Empty
? "Unknown"
: Plugin.Instance?.GetItem(seriesId)?.ProductionYear?.ToString(CultureInfo.InvariantCulture) ?? "Unknown";
}
private static string GetLibraryName(Guid seriesId)
{
if (seriesId == Guid.Empty)
{
return "Unknown";
}
var collectionFolders = Plugin.Instance?.GetCollectionFolders(seriesId);
return collectionFolders?.Count > 0
? string.Join(", ", collectionFolders.Select(folder => folder.Name))
: "Unknown";
}
}

View File

@ -1,35 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data;
/// <summary>
/// Type of media file analysis to perform.
/// </summary>
public enum AnalyzerAction
{
/// <summary>
/// Default action.
/// </summary>
Default,
/// <summary>
/// Detect chapters.
/// </summary>
Chapter,
/// <summary>
/// Detect chromaprint fingerprints.
/// </summary>
Chromaprint,
/// <summary>
/// Detect black frames.
/// </summary>
BlackFrame,
/// <summary>
/// No action.
/// </summary>
None,
}

View File

@ -1,25 +0,0 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace IntroSkipper.Data;
/// <summary>
/// A frame of video that partially (or entirely) consists of black pixels.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
/// </remarks>
/// <param name="percent">Percentage of the frame that is black.</param>
/// <param name="time">Time this frame appears at.</param>
public class BlackFrame(int percent, double time)
{
/// <summary>
/// Gets or sets the percentage of the frame that is black.
/// </summary>
public int Percentage { get; set; } = percent;
/// <summary>
/// Gets or sets the time (in seconds) this frame appeared at.
/// </summary>
public double Time { get; set; } = time;
}

Some files were not shown because too many files have changed in this diff Show More