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
53 changed files with 2189 additions and 542 deletions

View File

@ -192,3 +192,5 @@ csharp_space_between_method_call_empty_parameter_list_parentheses = false
# Wrapping preferences # Wrapping preferences
csharp_preserve_single_line_statements = true csharp_preserve_single_line_statements = true
csharp_preserve_single_line_blocks = true csharp_preserve_single_line_blocks = true
file_header_template = Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>\nSPDX-License-Identifier: GPL-3.0-only.

View File

@ -3,6 +3,18 @@ description: "Create a report to help us improve"
title: "[Bug]: " title: "[Bug]: "
labels: [bug] labels: [bug]
body: body:
- type: checkboxes
id: requirements
attributes:
label: Self service debugging
description: |
Jellyfin 10.9 is still being actively updated. Please make sure you are using the newest release
Docker containers have known permission issues that can be resolved with a few extra steps.
If your skip button is not shown, please see https://github.com/jumoog/intro-skipper/issues/104
options:
- label: Jellyfin is updated and my permissions are correct (or I did not use Docker)
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Describe the bug label: Describe the bug
@ -22,24 +34,33 @@ body:
- type: input - type: input
attributes: attributes:
label: Jellyfin installation method label: Jellyfin install method
description: How you installed Jellyfin or the tool used to install it
placeholder: Docker, Windows installer, etc. placeholder: Docker, Windows installer, etc.
validations: validations:
required: true required: true
- type: input - type: input
attributes: attributes:
label: Container image and tag label: Container image/tag or Jellyfin version
description: Only fill in this field if you are running Jellyfin in a container description: The container for Docker or Jellyfin version for a native install
placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc. placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
validations:
required: true
- type: input - type: input
attributes: attributes:
label: Operating System label: Operating System
description: The operating system of the Jellyfin / Docker host computer
placeholder: Debian 11, Windows 11, etc. placeholder: Debian 11, Windows 11, etc.
validations: validations:
required: true required: true
- type: input
attributes:
label: IMDb ID of that TV Series
placeholder: tt0903747
- type: textarea - type: textarea
attributes: attributes:
label: Support Bundle label: Support Bundle

View File

@ -2,16 +2,28 @@ name: 'Build Plugin'
on: on:
push: push:
branches: [ "master" ] branches: [ "10.8" ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
pull_request: pull_request:
branches: [ "master" ] branches: [ "10.8" ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
permissions: permissions:
contents: write contents: write
jobs: jobs:
build: build:
if: ${{ ! startsWith(github.event.head_commit.message, 'v0.1') }} if: ${{ ! startsWith(github.event.head_commit.message, 'v0.') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -23,6 +35,20 @@ jobs:
with: with:
dotnet-version: 6.0.x dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install html-minifier-terser
run: npm install terser html-minifier-terser
- name: Minify HTML
run: |
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 ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore
@ -38,12 +64,22 @@ jobs:
run: dotnet build --no-restore run: dotnet build --no-restore
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4.3.1 uses: actions/upload-artifact@v4.3.3
if: github.event_name != 'pull_request'
with: with:
name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll name: ConfusedPolarBear.Plugin.IntroSkipper-${{ env.GIT_HASH }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
if-no-files-found: error 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
- name: Create archive - name: Create archive
uses: vimtor/action-zip@v1.2 uses: vimtor/action-zip@v1.2
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@ -54,16 +90,33 @@ jobs:
- name: Generate md5 - name: Generate md5
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
run: md5sum intro-skipper-${{ env.GIT_HASH }}.zip > intro-skipper-${{ env.GIT_HASH }}.zip.md5 run: |
md5sum intro-skipper-${{ env.GIT_HASH }}.zip > intro-skipper-${{ env.GIT_HASH }}.md5
checksum="$(awk '{print $1}' intro-skipper-${{ env.GIT_HASH }}.md5)"
echo "CHECKSUM=$checksum" >> $GITHUB_ENV
- name: Publish prerelease - name: Publish prerelease
uses: 8bitDream/action-github-releases@v1.0.0 uses: 8bitDream/action-github-releases@v1.0.0
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
repo_token: "${{ secrets.GITHUB_TOKEN }}" repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: preview automatic_release_tag: 10.8/preview
prerelease: true prerelease: true
title: intro-skipper-${{ env.GIT_HASH }} title: intro-skipper-${{ env.GIT_HASH }}
files: | files: |
intro-skipper-${{ env.GIT_HASH }}.zip intro-skipper-${{ env.GIT_HASH }}.zip
intro-skipper-${{ env.GIT_HASH }}.zip.md5
- 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:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

52
.github/workflows/codeql.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: "CodeQL"
on:
push:
branches: [ 10.8 ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
pull_request:
branches: [ 10.8 ]
paths-ignore:
- '**/README.md'
- '.github/ISSUE_TEMPLATE/**'
- 'docs/**'
- 'images/**'
- 'manifest.json'
permissions: write-all
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 6.0.x
- name: Restore dependencies
run: dotnet restore
- name: Initialize CodeQL
uses: github/codeql-action/init@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2e230e8fe0ad3a14a340ad0815ddb96d599d2aff # v3.25.8

View File

@ -2,11 +2,6 @@ name: 'Release Plugin'
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: 'Version v'
required: true
type: string
permissions: permissions:
contents: write contents: write
@ -19,25 +14,47 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 6.0.x dotnet-version: 6.0.x
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install html-minifier-terser
run: npm install terser html-minifier-terser
- name: Minify HTML
run: |
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 ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/inject.js -c -m
npx terser ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -o ConfusedPolarBear.Plugin.IntroSkipper/Configuration/visualizer.js -c -m
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore run: dotnet restore
- name: Run update version
run: node update-version.js
- name: Embed version info - name: Embed version info
run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt run: echo "${{ github.sha }}" > ConfusedPolarBear.Plugin.IntroSkipper/Configuration/version.txt
- name: Build - name: Build
run: dotnet build --no-restore run: dotnet build --configuration Release --no-restore
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4.3.1 uses: actions/upload-artifact@v4.3.3
with: with:
name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ github.event.inputs.version }}.dll name: ConfusedPolarBear.Plugin.IntroSkipper-v${{ env.NEW_FILE_VERSION }}.dll
path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll path: ConfusedPolarBear.Plugin.IntroSkipper/bin/Release/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
if-no-files-found: error if-no-files-found: error
- name: Create archive - name: Create archive
@ -45,28 +62,48 @@ jobs:
with: with:
files: | files: |
ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll ConfusedPolarBear.Plugin.IntroSkipper/bin/Debug/net6.0/ConfusedPolarBear.Plugin.IntroSkipper.dll
dest: intro-skipper-v${{ github.event.inputs.version }}.zip dest: intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
- name: Generate md5 - name: Generate manifest keys
run: | run: |
md5sum intro-skipper-v${{ github.event.inputs.version }}.zip > intro-skipper-v${{ github.event.inputs.version }}.zip.md5 sourceUrl="https://github.com/${{ github.repository }}/releases/download/10.8/v${{ env.NEW_FILE_VERSION }}/intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip"
echo "sourceUrl: https://github.com/${{ github.repository }}/releases/download/v${{ github.event.inputs.version }}/intro-skipper-v${{ github.event.inputs.version }}.zip" echo "SOURCE_URL=$sourceUrl" >> $GITHUB_ENV
echo "checksum: $(awk '{print $1}' intro-skipper-v${{ github.event.inputs.version }}.zip.md5)" md5sum intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip > intro-skipper-v${{ env.NEW_FILE_VERSION }}.md5
echo "timestamp: $(date +%FT%TZ)" 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: Publish release - name: Publish release
uses: 8bitDream/action-github-releases@v1.0.0 uses: 8bitDream/action-github-releases@v1.0.0
with: with:
repo_token: "${{ secrets.GITHUB_TOKEN }}" repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: v${{ github.event.inputs.version }} automatic_release_tag: 10.8/v${{ env.NEW_FILE_VERSION }}
prerelease: false prerelease: false
title: v${{ github.event.inputs.version }} title: v${{ env.NEW_FILE_VERSION }}
files: | files: |
intro-skipper-v${{ github.event.inputs.version }}.zip intro-skipper-v${{ env.NEW_FILE_VERSION }}.zip
intro-skipper-v${{ github.event.inputs.version }}.zip.md5
# - name: Push changes - name: Publish release notes
# uses: ad-m/github-push-action@master uses: softprops/action-gh-release@v2.0.5
# with: with:
# github_token: ${{ secrets.GITHUB_TOKEN }} tag_name: 10.8/v${{ env.NEW_FILE_VERSION }}
# branch: master name: v${{ env.NEW_FILE_VERSION }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run validation and update script
run: node validate-and-update-manifest.js
env:
VERSION: ${{ env.NEW_FILE_VERSION }}
- name: Commit changes
if: success()
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add manifest.json ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj
git commit -m "release v${{ env.NEW_FILE_VERSION }}"
git push

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ docker/dist
# Visual Studio # Visual Studio
.vs/ .vs/
# JetBrains Rider
.idea/

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
/* These tests require that the host system has a version of FFmpeg installed /* These tests require that the host system has a version of FFmpeg installed
* which supports both chromaprint and the "-fp_format raw" flag. * which supports both chromaprint and the "-fp_format raw" flag.
*/ */
@ -81,7 +84,7 @@ public class TestAudioFingerprinting
{77, 5}, {77, 5},
}; };
var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr); var actual = FFmpegWrapper.CreateInvertedIndex(Guid.NewGuid(), fpr, AnalysisMode.Introduction);
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
} }
@ -104,11 +107,12 @@ public class TestAudioFingerprinting
Assert.True(lhs.Valid); Assert.True(lhs.Valid);
Assert.Equal(0, lhs.IntroStart); Assert.Equal(0, lhs.IntroStart);
Assert.Equal(17.792, lhs.IntroEnd); Assert.Equal(17.208, lhs.IntroEnd, 3);
Assert.True(rhs.Valid); Assert.True(rhs.Valid);
Assert.Equal(5.12, rhs.IntroStart); // because we changed for 0.128 to 0.1238 its 4,952 now but that's too early (<= 5)
Assert.Equal(22.912, rhs.IntroEnd); Assert.Equal(0, rhs.IntroStart);
Assert.Equal(22.1602, rhs.IntroEnd);
} }
/// <summary> /// <summary>

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using System; using System;
@ -13,7 +16,7 @@ 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));
@ -30,14 +33,15 @@ public class TestBlackFrames
[FactSkipFFmpegTests] [FactSkipFFmpegTests]
public void TestEndCreditDetection() public void TestEndCreditDetection()
{ {
var range = 1; // new strategy new range
var range = 3;
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, AnalysisMode.Credits, 85); var result = analyzer.AnalyzeMediaFile(episode, 240, 30, 85);
Assert.NotNull(result); Assert.NotNull(result);
Assert.InRange(result.IntroStart, 300 - range, 300 + range); Assert.InRange(result.IntroStart, 300 - range, 300 + range);
} }

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using System; using System;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using Xunit; using Xunit;
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using Xunit; using Xunit;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using Xunit; using Xunit;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;
@ -17,12 +20,23 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
private readonly ILogger<BlackFrameAnalyzer> _logger; private readonly ILogger<BlackFrameAnalyzer> _logger;
private int minimumCreditsDuration;
private int maximumCreditsDuration;
private int blackFrameMinimumPercentage;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class. /// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
/// </summary> /// </summary>
/// <param name="logger">Logger.</param> /// <param name="logger">Logger.</param>
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger) 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; _logger = logger;
} }
@ -39,6 +53,12 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
var creditTimes = new Dictionary<Guid, Intro>(); var creditTimes = new Dictionary<Guid, Intro>();
bool isFirstEpisode = true;
double searchStart = minimumCreditsDuration;
var searchDistance = 2 * minimumCreditsDuration;
foreach (var episode in analysisQueue) foreach (var episode in analysisQueue)
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
@ -46,16 +66,54 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
break; 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( var intro = AnalyzeMediaFile(
episode, episode,
mode, searchStart,
Plugin.Instance!.Configuration.BlackFrameMinimumPercentage); searchDistance,
blackFrameMinimumPercentage);
if (intro is null) 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; continue;
} }
searchStart = episode.Duration - intro.IntroStart + (0.5 * searchDistance);
creditTimes[episode.EpisodeId] = intro; creditTimes[episode.EpisodeId] = intro;
} }
@ -71,16 +129,17 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
/// Analyzes an individual media file. Only public because of unit tests. /// Analyzes an individual media file. Only public because of unit tests.
/// </summary> /// </summary>
/// <param name="episode">Media file to analyze.</param> /// <param name="episode">Media file to analyze.</param>
/// <param name="mode">Analysis mode.</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> /// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Credits timestamp.</returns> /// <returns>Credits timestamp.</returns>
public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum) public Intro? AnalyzeMediaFile(QueuedEpisode episode, double searchStart, int searchDistance, int minimum)
{ {
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
// Start by analyzing the last N minutes of the file. // Start by analyzing the last N minutes of the file.
var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration); var upperLimit = searchStart;
var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration); var lowerLimit = Math.Max(searchStart - searchDistance, minimumCreditsDuration);
var start = TimeSpan.FromSeconds(upperLimit);
var end = TimeSpan.FromSeconds(lowerLimit);
var firstFrameTime = 0.0; var firstFrameTime = 0.0;
// Continue bisecting the end of the file until the range that contains the first black // Continue bisecting the end of the file until the range that contains the first black
@ -111,13 +170,29 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
if (frames.Length == 0) if (frames.Length == 0)
{ {
// Since no black frames were found, slide the range closer to the end // Since no black frames were found, slide the range closer to the end
start = midpoint; 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 else
{ {
// Some black frames were found, slide the range closer to the start // Some black frames were found, slide the range closer to the start
end = midpoint; end = midpoint;
firstFrameTime = frames[0].Time + scanTime; 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);
}
} }
} }

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;
@ -91,10 +94,12 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration(); var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
var minDuration = config.MinimumIntroDuration; var minDuration = mode == AnalysisMode.Introduction ?
config.MinimumIntroDuration :
config.MinimumCreditsDuration;
int maxDuration = mode == AnalysisMode.Introduction ? int maxDuration = mode == AnalysisMode.Introduction ?
config.MaximumIntroDuration : config.MaximumIntroDuration :
config.MaximumEpisodeCreditsDuration; config.MaximumCreditsDuration;
if (chapters.Count == 0) if (chapters.Count == 0)
{ {
@ -111,10 +116,10 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
}); });
// Check all chapters in reverse order, skipping the virtual chapter // Check all chapters in reverse order, skipping the virtual chapter
for (int i = chapters.Count - 2; i >= 0; i--) for (int i = chapters.Count - 2; i > 0; i--)
{ {
var current = chapters[i]; var current = chapters[i];
var next = chapters[i + 1]; var previous = chapters[i - 1];
if (string.IsNullOrWhiteSpace(current.Name)) if (string.IsNullOrWhiteSpace(current.Name))
{ {
@ -123,7 +128,7 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
var currentRange = new TimeRange( var currentRange = new TimeRange(
TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds, TimeSpan.FromTicks(current.StartPositionTicks).TotalSeconds,
TimeSpan.FromTicks(next.StartPositionTicks).TotalSeconds); TimeSpan.FromTicks(chapters[i + 1].StartPositionTicks).TotalSeconds);
var baseMessage = string.Format( var baseMessage = string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
@ -153,6 +158,21 @@ public class ChapterAnalyzer : IMediaFileAnalyzer
continue; 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); matchingChapter = new(episode.EpisodeId, currentRange);
_logger.LogTrace("{Base}: okay", baseMessage); _logger.LogTrace("{Base}: okay", baseMessage);
break; break;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;
@ -16,7 +19,7 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
/// 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.128; private const double SamplesToSeconds = 0.1238;
private int minimumIntroDuration; private int minimumIntroDuration;
@ -75,6 +78,12 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
{ {
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode); fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
// Use reversed fingerprints for credits
if (_analysisMode == AnalysisMode.Credits)
{
Array.Reverse(fingerprintCache[episode.EpisodeId]);
}
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
{ {
return analysisQueue; return analysisQueue;
@ -123,14 +132,20 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
* While this is desired behavior for detecting introductions, it breaks credit * While this is desired behavior for detecting introductions, it breaks credit
* detection, as the audio we're analyzing was extracted from some point into the file. * detection, as the audio we're analyzing was extracted from some point into the file.
* *
* To fix this, add the starting time of the fingerprint to the reported time range. * 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.
*/ */
if (this._analysisMode == AnalysisMode.Credits) if (this._analysisMode == AnalysisMode.Credits)
{ {
currentIntro.IntroStart += currentEpisode.CreditsFingerprintStart; // Calculate new values for the current intro
currentIntro.IntroEnd += currentEpisode.CreditsFingerprintStart; double currentOriginalIntroStart = currentIntro.IntroStart;
remainingIntro.IntroStart += remainingEpisode.CreditsFingerprintStart; currentIntro.IntroStart = currentEpisode.Duration - currentIntro.IntroEnd;
remainingIntro.IntroEnd += remainingEpisode.CreditsFingerprintStart; currentIntro.IntroEnd = currentEpisode.Duration - currentOriginalIntroStart;
// Calculate new values for the remaining intro
double remainingIntroOriginalStart = remainingIntro.IntroStart;
remainingIntro.IntroStart = remainingEpisode.Duration - remainingIntro.IntroEnd;
remainingIntro.IntroEnd = remainingEpisode.Duration - remainingIntroOriginalStart;
} }
// Only save the discovered intro if it is: // Only save the discovered intro if it is:
@ -154,7 +169,10 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
} }
// If no intro is found at this point, the popped episode is not reinserted into the queue. // If no intro is found at this point, the popped episode is not reinserted into the queue.
episodesWithoutIntros.Add(currentEpisode); if (!seasonIntros.ContainsKey(currentEpisode.EpisodeId))
{
episodesWithoutIntros.Add(currentEpisode);
}
} }
// If cancellation was requested, report that no episodes were analyzed. // If cancellation was requested, report that no episodes were analyzed.
@ -261,8 +279,8 @@ public class ChromaprintAnalyzer : IMediaFileAnalyzer
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 = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints); var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, this._analysisMode);
var rhsIndex = FFmpegWrapper.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.

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
@ -109,7 +112,7 @@ public class AutoSkip : IServerEntryPoint
} }
// 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 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) if (Plugin.Instance!.Configuration.SkipFirstEpisode && episodeNumber == 1)
{ {
newState = true; newState = true;
} }
@ -149,15 +152,16 @@ public class AutoSkip : IServerEntryPoint
} }
// Seek is unreliable if called at the very start of an episode. // Seek is unreliable if called at the very start of an episode.
var adjustedStart = Math.Max(5, intro.IntroStart); var adjustedStart = Math.Max(5, intro.IntroStart + Plugin.Instance.Configuration.SecondsOfIntroStartToPlay);
var adjustedEnd = intro.IntroEnd - Plugin.Instance.Configuration.RemainingSecondsOfIntro;
_logger.LogTrace( _logger.LogTrace(
"Playback position is {Position}, intro runs from {Start} to {End}", "Playback position is {Position}, intro runs from {Start} to {End}",
position, position,
adjustedStart, adjustedStart,
intro.IntroEnd); adjustedEnd);
if (position < adjustedStart || position > intro.IntroEnd) if (position < adjustedStart || position > adjustedEnd)
{ {
continue; continue;
} }
@ -180,8 +184,6 @@ public class AutoSkip : IServerEntryPoint
_logger.LogDebug("Sending seek command to {Session}", deviceId); _logger.LogDebug("Sending seek command to {Session}", deviceId);
var introEnd = (long)intro.IntroEnd - Plugin.Instance!.Configuration.SecondsOfIntroToPlay;
_sessionManager.SendPlaystateCommand( _sessionManager.SendPlaystateCommand(
session.Id, session.Id,
session.Id, session.Id,
@ -189,7 +191,7 @@ public class AutoSkip : IServerEntryPoint
{ {
Command = PlaystateCommand.Seek, Command = PlaystateCommand.Seek,
ControllingUserId = session.UserId.ToString("N"), ControllingUserId = session.UserId.ToString("N"),
SeekPositionTicks = introEnd * TimeSpan.TicksPerSecond, SeekPositionTicks = (long)adjustedEnd * TimeSpan.TicksPerSecond,
}, },
CancellationToken.None); CancellationToken.None);

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

@ -1,3 +1,7 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
@ -27,6 +31,21 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public string SelectedLibraries { get; set; } = string.Empty; public string SelectedLibraries { get; set; } = string.Empty;
/// <summary>
/// Gets a temporary limitation on file paths to be analyzed. Should be empty when automatic scan is idle.
/// </summary>
public IList<string> PathRestrictions { get; } = new List<string>();
/// <summary>
/// Gets or sets a value indicating whether to scan for intros during a scheduled task.
/// </summary>
public bool AutoDetectIntros { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to scan for credits during a scheduled task.
/// </summary>
public bool AutoDetectCredits { get; set; } = false;
/// <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>
@ -86,7 +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 MaximumEpisodeCreditsDuration { get; set; } = 240; public int MaximumCreditsDuration { get; set; } = 300;
/// <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.
@ -103,7 +122,7 @@ public class PluginConfiguration : BasePluginConfiguration
/// 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)(\s|$)"; @"(^|\s)(Credits?|ED|Ending|End|Outro)(\s|$)";
// ===== Playback settings ===== // ===== Playback settings =====
@ -117,6 +136,11 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public bool AutoSkip { get; set; } public bool AutoSkip { get; set; }
/// <summary>
/// Gets or sets a value indicating whether credits should be automatically skipped.
/// </summary>
public bool AutoSkipCredits { 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>
@ -140,7 +164,17 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <summary>
/// Gets or sets the amount of intro to play (in seconds). /// Gets or sets the amount of intro to play (in seconds).
/// </summary> /// </summary>
public int SecondsOfIntroToPlay { get; set; } = 2; public int RemainingSecondsOfIntro { get; set; } = 2;
/// <summary>
/// Gets or sets the amount of intro at start to play (in seconds).
/// </summary>
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 =====
@ -188,6 +222,11 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public string AutoSkipNotificationText { get; set; } = "Intro skipped"; public string AutoSkipNotificationText { get; set; } = "Intro skipped";
/// <summary>
/// Gets or sets the notification text sent after automatically skipping credits.
/// </summary>
public string AutoSkipCreditsNotificationText { get; set; } = "Credits skipped";
/// <summary> /// <summary>
/// Gets or sets the number of threads for an ffmpeg process. /// Gets or sets the number of threads for an ffmpeg process.
/// </summary> /// </summary>

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration; namespace ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
/// <summary> /// <summary>

View File

@ -7,7 +7,7 @@
<body> <body>
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" <div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-input,emby-button,emby-select,emby-checkbox"> data-require="emby-input,emby-button,emby-select,emby-checkbox,emby-linkbutton">
<div data-role="content"> <div data-role="content">
<style> <style>
summary { summary {
@ -27,6 +27,31 @@
<fieldset class="verticalSection-extrabottompadding"> <fieldset class="verticalSection-extrabottompadding">
<legend>Analysis</legend> <legend>Analysis</legend>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoDetectIntros" type="checkbox" is="emby-checkbox" />
<span>Automatically Scan Intros</span>
</label>
<div class="fieldDescription">
If enabled, introductions will be automatically analyzed for new media
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoDetectCredits" type="checkbox" is="emby-checkbox" />
<span>Automatically Scan Credits</span>
</label>
<div class="fieldDescription">
If enabled, credits will be automatically analyzed for new media
<br />
<br />
Note: Not selecting at least one automatic detection type will disable automatic scans. To configure the scheduled task, see <a is="emby-linkbutton" class="button-link" href="scheduledtasks.html">scheduled tasks</a>.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" /> <input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" />
@ -36,6 +61,7 @@
<div class="fieldDescription"> <div class="fieldDescription">
If checked, season 0 (specials / extras) will be included in analysis. If checked, season 0 (specials / extras) will be included in analysis.
<br /> <br />
<br />
Note: Shows containing both a specials and extra folder will identify extras as season 0 Note: Shows containing both a specials and extra folder will identify extras as season 0
and ignore specials, regardless of this setting. and ignore specials, regardless of this setting.
</div> </div>
@ -63,7 +89,7 @@
</div> </div>
<details id="intro_reqs"> <details id="intro_reqs">
<summary>Modify Intro Parameters</summary> <summary>Modify Segment Parameters</summary>
<br /> <br />
<div class="inputContainer"> <div class="inputContainer">
@ -111,6 +137,26 @@
</div> </div>
</div> </div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="MinimumCreditsDuration">
Minimum credits duration (in seconds)
</label>
<input id="MinimumCreditsDuration" type="number" is="emby-input" min="1" />
<div class="fieldDescription">
Similar sounding audio which is shorter than this duration will not be considered credits.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="MaximumCreditsDuration">
Maximum credits duration (in seconds)
</label>
<input id="MaximumCreditsDuration" type="number" is="emby-input" min="1" />
<div class="fieldDescription">
Similar sounding audio which is longer than this duration will not be considered credits.
</div>
</div>
<p> <p>
The amount of each episode's audio that will be analyzed is determined using both The amount of each episode's audio that will be analyzed is determined using both
the percentage of audio and maximum runtime of audio to analyze. The minimum of the percentage of audio and maximum runtime of audio to analyze. The minimum of
@ -121,7 +167,7 @@
<p> <p>
If the audio percentage or maximum runtime settings are modified, the cached If the audio percentage or maximum runtime settings are modified, the cached
fingerprints and introduction timestamps for each season you want to analyze with the fingerprints and introduction timestamps for each season you want to analyze with the
modified settings <strong>will have to be deleted.</strong> modified settings <b>will have to be deleted.</b>
Increasing either of these settings will cause episode analysis to take much longer. Increasing either of these settings will cause episode analysis to take much longer.
</p> </p>
@ -147,7 +193,7 @@
</option> </option>
<option value="Intro"> <option value="Intro">
Intro (show a skip button, *experimental*) Intro/Credit (show a skip button, *experimental*)
</option> </option>
<option value="Mute"> <option value="Mute">
@ -176,8 +222,8 @@
</label> </label>
<div class="fieldDescription"> <div class="fieldDescription">
If checked, the plugin will <strong>overwrite all EDL files</strong> associated with If checked, the plugin will <b>overwrite all EDL files</b> associated with
your episodes with the currently discovered introduction timestamps and EDL action. your episodes with the currently discovered introduction/credit timestamps and EDL action.
</div> </div>
</div> </div>
</details> </details>
@ -191,7 +237,7 @@
Noise tolerance Noise tolerance
</label> </label>
<input id="SilenceDetectionMaximumNoise" type="number" is="emby-input" min="-90" <input id="SilenceDetectionMaximumNoise" type="number" is="emby-input" min="-90"
max="0" /> max="0" />
<div class="fieldDescription"> <div class="fieldDescription">
Noise tolerance in negative decibels. Noise tolerance in negative decibels.
</div> </div>
@ -202,7 +248,7 @@
Minimum silence duration Minimum silence duration
</label> </label>
<input id="SilenceDetectionMinimumDuration" type="number" is="emby-input" min="0" <input id="SilenceDetectionMinimumDuration" type="number" is="emby-input" min="0"
step="0.01" /> step="0.01" />
<div class="fieldDescription"> <div class="fieldDescription">
Minimum silence duration in seconds before adjusting introduction end time. Minimum silence duration in seconds before adjusting introduction end time.
</div> </div>
@ -212,7 +258,7 @@
<details id="detection"> <details id="detection">
<summary>Process Configuration</summary> <summary>Process Configuration</summary>
<br/> <br />
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="UseChromaprint" type="checkbox" is="emby-checkbox" /> <input id="UseChromaprint" type="checkbox" is="emby-checkbox" />
@ -222,7 +268,7 @@
<div class="fieldDescription"> <div class="fieldDescription">
If checked, analysis will use Chromaprint to compare episode audio and identify intros. If checked, analysis will use Chromaprint to compare episode audio and identify intros.
<br /> <br />
<strong>WARNING: Disabling this option may result in incomplete or innaccurate analysis!</strong> <b>WARNING: Disabling this option may result in incomplete or innaccurate analysis!</b>
<br /> <br />
</div> </div>
</div> </div>
@ -236,7 +282,7 @@
<div class="fieldDescription"> <div class="fieldDescription">
If checked, episode fingerprints will be saved on the filesystem to improve analysis speed. If checked, episode fingerprints will be saved on the filesystem to improve analysis speed.
<br /> <br />
<strong>WARNING: May result in lengthy detection! Not recommended for large libraries!</strong> <b>WARNING: Disabling the cache will cause all libraries to be re-scanned, which can take a very long time!</b>
<br /> <br />
</div> </div>
</div> </div>
@ -280,7 +326,7 @@
ffmpeg Threads ffmpeg Threads
</label> </label>
<input id="ProcessThreads" type="number" is="emby-input" min="0" <input id="ProcessThreads" type="number" is="emby-input" min="0"
max="16" /> max="16" />
<div class="fieldDescription"> <div class="fieldDescription">
Number of simultaneous processes to use for ffmpeg operations. Number of simultaneous processes to use for ffmpeg operations.
<br /> <br />
@ -310,12 +356,48 @@
<div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription"> <div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" /> <input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" />
<span>Ignore intro in the first episode of a season</span> <span>Play intro for first episode of a season</span>
</label> </label>
<div class="fieldDescription"> <div class="fieldDescription">
If checked, auto skip will ignore introduction in the first episode of a season.<br /> If checked, auto skip will play the introduction of the first episode in a season.<br />
</div> </div>
<br />
</div>
<div id="divSecondsOfIntroStartToPlay" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroStartToPlay">
Intro playback duration (in seconds)
</label>
<input id="SecondsOfIntroStartToPlay" type="number" is="emby-input" min="0" />
<div class="fieldDescription">
Seconds of introduction start that should be played. Defaults to 0.
</div>
<br />
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="AutoSkipCredits" type="checkbox" is="emby-checkbox" />
<span>Automatically skip credits</span>
</label>
<div class="fieldDescription">
If checked, credits will be automatically skipped. If you access Jellyfin through a
reverse proxy, it must be configured to proxy web
sockets.<br />
</div>
</div>
<div id="divSecondsOfCreditsStartToPlay" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SecondsOfCreditsStartToPlay">
Credit playback duration (in seconds)
</label>
<input id="SecondsOfCreditsStartToPlay" type="number" is="emby-input" min="0" />
<div class="fieldDescription">
Seconds of credits start that should be played. Defaults to 0.
</div>
<br />
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
@ -326,7 +408,7 @@
<div class="fieldDescription"> <div class="fieldDescription">
If checked, a skip button will be displayed at the start of an episode's introduction. If checked, a skip button will be displayed at the start of an episode's introduction.
<strong>Only applies to the web interface and compatible applications.</strong> <b>Only applies to the web interface and compatible applications.</b>
<br /> <br />
</div> </div>
</div> </div>
@ -338,7 +420,7 @@
</label> </label>
<div class="fieldDescription"> <div class="fieldDescription">
If checked, skip button will remain visible throught the intro (offset and timeout are ignored). If checked, skip button will remain visible for the entire intro (offset and timeout are ignored).
<br /> <br />
Note: If unchecked, button will only appear in the player controls after the set timeout. Note: If unchecked, button will only appear in the player controls after the set timeout.
</div> </div>
@ -353,6 +435,7 @@
Seconds to display skip prompt before introduction begins. Seconds to display skip prompt before introduction begins.
</div> </div>
</div> </div>
<br />
<div id="divHidePromptAdjustment" class="inputContainer"> <div id="divHidePromptAdjustment" class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="HidePromptAdjustment"> <label class="inputLabel inputLabelUnfocused" for="HidePromptAdjustment">
@ -363,12 +446,13 @@
Seconds after introduction before skip prompt is hidden. Seconds after introduction before skip prompt is hidden.
</div> </div>
</div> </div>
<br />
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroToPlay"> <label class="inputLabel inputLabelUnfocused" for="RemainingSecondsOfIntro">
Intro playback duration (in seconds) Intro playback duration (in seconds)
</label> </label>
<input id="SecondsOfIntroToPlay" type="number" is="emby-input" min="0" /> <input id="RemainingSecondsOfIntro" type="number" is="emby-input" min="0" />
<div class="fieldDescription"> <div class="fieldDescription">
Seconds of introduction ending that should be played. Defaults to 2. Seconds of introduction ending that should be played. Defaults to 2.
</div> </div>
@ -407,6 +491,16 @@
Message shown after automatically skipping an introduction. Leave blank to disable notification. Message shown after automatically skipping an introduction. Leave blank to disable notification.
</div> </div>
</div> </div>
<div id="divAutoSkipCreditsNotificationText" class="inputContainer">
<label class="inputLabel" for="AutoSkipCreditsNotificationText">
Automatic skip notification message
</label>
<input id="AutoSkipCreditsNotificationText" type="text" is="emby-input" />
<div class="fieldDescription">
Message shown after automatically skipping credits. Leave blank to disable notification.
</div>
</div>
</details> </details>
</fieldset> </fieldset>
@ -431,32 +525,54 @@
<summary>Manage Fingerprints</summary> <summary>Manage Fingerprints</summary>
<br /> <br />
<h3 style="margin:0">Select episodes to manage</h3> <label class="inputLabel" for="troubleshooterShow">Select TV Series to manage</label>
<br /> <select is="emby-select" id="troubleshooterShow" class="emby-select-withcolor emby-select"></select>
<select id="troubleshooterShow"></select> <label class="inputLabel" for="troubleshooterSeason">Select Season to manage</label>
<select id="troubleshooterSeason"></select> <select is="emby-select" id="troubleshooterSeason" class="emby-select-withcolor emby-select"></select>
<br /> <br />
<select id="troubleshooterEpisode1"></select> <label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label>
<select id="troubleshooterEpisode2"></select> <select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select>
<br /> <label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label>
<select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select>
<br /> <br />
<div id="timestampEditor" style="display:none"> <div id="timestampEditor" style="display:none">
<h3 style="margin:0">Introduction timestamp editor</h3> <h3 style="margin:0">Introduction timestamp editor</h3>
<p style="margin:0">All times are in seconds.</p> <p style="margin:0">All times are displayed in the format (HH:MM:SS)</p>
<p id="editLeftEpisodeTitle" style="margin-bottom:0"></p>
<input style="width:4em" type="number" min="0" id="editLeftEpisodeStart"> to
<input style="width:4em;margin-bottom:10px" type="number" min="0" id="editLeftEpisodeEnd">
<p id="editRightEpisodeTitle" style="margin-top:0;margin-bottom:0"></p>
<input style="width:4em" type="number" min="0" id="editRightEpisodeStart"> to
<input style="width:4em;margin-bottom:10px" type="number" min="0" id="editRightEpisodeEnd">
<br /> <br />
<table class="detailTable">
<tr>
<th scope="col" class="detailTableHeaderCell">Episode</th>
<th scope="col" class="detailTableHeaderCell">Section</th>
<th scope="col" class="detailTableHeaderCell">Start Time</th>
<th scope="col" class="detailTableHeaderCell">End Time</th>
</tr>
<tr>
<td rowspan="2" id="editLeftEpisodeTitle"></td>
<td>Intro</td>
<td><input type="time" step="1" id="editLeftIntroEpisodeStart"></td>
<td><input type="time" step="1" id="editLeftIntroEpisodeEnd"></td>
</tr>
<tr>
<td>Credits</td>
<td><input type="time" step="1" id="editLeftCreditEpisodeStart"></td>
<td><input type="time" step="1" id="editLeftCreditEpisodeEnd"></td>
</tr>
<tr>
<td rowspan="2" id="editRightEpisodeTitle"></td>
<td>Intro</td>
<td><input type="time" step="1" id="editRightIntroEpisodeStart"></td>
<td><input type="time" step="1" id="editRightIntroEpisodeEnd"></td>
</tr>
<tr>
<td>Credits</td>
<td><input type="time" step="1" id="editRightCreditEpisodeStart"></td>
<td><input type="time" step="1" id="editRightCreditEpisodeEnd"></td>
</tr>
</table>
<br /> <br />
<button is="emby-select" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">
<button id="btnUpdateTimestamps" type="button">
Update timestamps Update timestamps
</button> </button>
<br /> <br />
@ -476,21 +592,23 @@
</p> </p>
<table> <table>
<thead> <thead>
<td style="min-width: 100px; font-weight: bold">Key</td> <tr>
<td style="font-weight: bold">Function</td> <td style="min-width: 100px; font-weight: bold">Key</td>
<td style="font-weight: bold">Function</td>
</tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>Up arrow</td> <td>Up arrow</td>
<td> <td>
Shift the left episode up by 0.128 seconds. Shift the left episode up by 0.1238 seconds.
Holding control will shift the episode by 10 seconds. Holding control will shift the episode by 10 seconds.
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Down arrow</td> <td>Down arrow</td>
<td> <td>
Shift the left episode down by 0.128 seconds. Shift the left episode down by 0.1238 seconds.
Holding control will shift the episode by 10 seconds. Holding control will shift the episode by 10 seconds.
</td> </td>
</tr> </tr>
@ -568,28 +686,36 @@
"AnalysisLengthLimit", "AnalysisLengthLimit",
"MinimumIntroDuration", "MinimumIntroDuration",
"MaximumIntroDuration", "MaximumIntroDuration",
"MinimumCreditsDuration",
"MaximumCreditsDuration",
"EdlAction", "EdlAction",
"ProcessPriority", "ProcessPriority",
"ProcessThreads", "ProcessThreads",
// playback // playback
"ShowPromptAdjustment", "ShowPromptAdjustment",
"HidePromptAdjustment", "HidePromptAdjustment",
"SecondsOfIntroToPlay", "RemainingSecondsOfIntro",
"SecondsOfIntroStartToPlay",
"SecondsOfCreditsStartToPlay",
// internals // internals
"SilenceDetectionMaximumNoise", "SilenceDetectionMaximumNoise",
"SilenceDetectionMinimumDuration", "SilenceDetectionMinimumDuration",
// UI customization // UI customization
"SkipButtonIntroText", "SkipButtonIntroText",
"SkipButtonEndCreditsText", "SkipButtonEndCreditsText",
"AutoSkipNotificationText" "AutoSkipNotificationText",
"AutoSkipCreditsNotificationText"
] ]
var booleanConfigurationFields = [ var booleanConfigurationFields = [
"AutoDetectIntros",
"AutoDetectCredits",
"AnalyzeSeasonZero", "AnalyzeSeasonZero",
"RegenerateEdlFiles", "RegenerateEdlFiles",
"UseChromaprint", "UseChromaprint",
"CacheFingerprints", "CacheFingerprints",
"AutoSkip", "AutoSkip",
"AutoSkipCredits",
"SkipFirstEpisode", "SkipFirstEpisode",
"PersistSkipButton", "PersistSkipButton",
"SkipButtonVisible" "SkipButtonVisible"
@ -613,20 +739,38 @@
var autoSkip = document.querySelector("input#AutoSkip"); var autoSkip = document.querySelector("input#AutoSkip");
var skipFirstEpisode = document.querySelector("div#divSkipFirstEpisode"); var skipFirstEpisode = document.querySelector("div#divSkipFirstEpisode");
var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay");
var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay");
var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText"); var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText");
var autoSkipCredits = document.querySelector("input#AutoSkipCredits");
var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText");
async function autoSkipChanged() { async function autoSkipChanged() {
if (autoSkip.checked) { if (autoSkip.checked) {
skipFirstEpisode.style.display = 'unset'; skipFirstEpisode.style.display = 'unset';
autoSkipNotificationText.style.display = 'unset'; autoSkipNotificationText.style.display = 'unset';
secondsOfIntroStartToPlay.style.display = 'unset';
} else { } else {
skipFirstEpisode.style.display = 'none'; skipFirstEpisode.style.display = 'none';
autoSkipNotificationText.style.display = 'none'; autoSkipNotificationText.style.display = 'none';
secondsOfIntroStartToPlay.style.display = 'none';
} }
} }
autoSkip.addEventListener("change", autoSkipChanged); autoSkip.addEventListener("change", autoSkipChanged);
async function autoSkipCreditsChanged() {
if (autoSkipCredits.checked) {
autoSkipCreditsNotificationText.style.display = 'unset';
secondsOfCreditsStartToPlay.style.display = 'unset';
} else {
autoSkipCreditsNotificationText.style.display = 'none';
secondsOfCreditsStartToPlay.style.display = 'none';
}
}
autoSkipCredits.addEventListener("change", autoSkipCreditsChanged);
var persistSkip = document.querySelector("input#PersistSkipButton"); var persistSkip = document.querySelector("input#PersistSkipButton");
var showAdjustment = document.querySelector("div#divShowPromptAdjustment"); var showAdjustment = document.querySelector("div#divShowPromptAdjustment");
var hideAdjustment = document.querySelector("div#divHidePromptAdjustment"); var hideAdjustment = document.querySelector("div#divHidePromptAdjustment");
@ -782,27 +926,24 @@
// Get the title and ID of the left and right episodes // Get the title and ID of the left and right episodes
const leftEpisode = selectEpisode1.options[selectEpisode1.selectedIndex]; const leftEpisode = selectEpisode1.options[selectEpisode1.selectedIndex];
const rightEpisode = selectEpisode2.options[selectEpisode2.selectedIndex]; const rightEpisode = selectEpisode2.options[selectEpisode2.selectedIndex];
// Try to get the timestamps of each intro, falling back a default value of zero if no intro was found // Try to get the timestamps of each intro, falling back a default value of zero if no intro was found
let leftEpisodeIntro = await getJson("Episode/" + leftEpisode.value + "/IntroTimestamps/v1"); const leftEpisodeJson = await getJson("Episode/" + leftEpisode.value + "/Timestamps");
if (leftEpisodeIntro === null) { const rightEpisodeJson = await getJson("Episode/" + rightEpisode.value + "/Timestamps");
leftEpisodeIntro = { IntroStart: 0, IntroEnd: 0 };
}
let rightEpisodeIntro = await getJson("Episode/" + rightEpisode.value + "/IntroTimestamps/v1");
if (rightEpisodeIntro === null) {
rightEpisodeIntro = { IntroStart: 0, IntroEnd: 0 };
}
// Update the editor for the first and second episodes // Update the editor for the first and second episodes
timestampEditor.style.display = "unset"; timestampEditor.style.display = "unset";
document.querySelector("#editLeftEpisodeTitle").textContent = leftEpisode.text; document.querySelector("#editLeftEpisodeTitle").textContent = leftEpisode.text;
document.querySelector("#editLeftEpisodeStart").value = Math.round(leftEpisodeIntro.IntroStart); document.querySelector("#editLeftIntroEpisodeStart").value = setTime(Math.round(leftEpisodeJson.Introduction.IntroStart));
document.querySelector("#editLeftEpisodeEnd").value = Math.round(leftEpisodeIntro.IntroEnd); document.querySelector("#editLeftIntroEpisodeEnd").value = setTime(Math.round(leftEpisodeJson.Introduction.IntroEnd));
document.querySelector("#editLeftCreditEpisodeStart").value = setTime(Math.round(leftEpisodeJson.Credits.IntroStart));
document.querySelector("#editLeftCreditEpisodeEnd").value = setTime(Math.round(leftEpisodeJson.Credits.IntroEnd));
document.querySelector("#editRightEpisodeTitle").textContent = rightEpisode.text; document.querySelector("#editRightEpisodeTitle").textContent = rightEpisode.text;
document.querySelector("#editRightEpisodeStart").value = Math.round(rightEpisodeIntro.IntroStart); document.querySelector("#editRightIntroEpisodeStart").value = setTime(Math.round(rightEpisodeJson.Introduction.IntroStart));
document.querySelector("#editRightEpisodeEnd").value = Math.round(rightEpisodeIntro.IntroEnd); document.querySelector("#editRightIntroEpisodeEnd").value = setTime(Math.round(rightEpisodeJson.Introduction.IntroEnd));
document.querySelector("#editRightCreditEpisodeStart").value = setTime(Math.round(rightEpisodeJson.Credits.IntroStart));
document.querySelector("#editRightCreditEpisodeEnd").value = setTime(Math.round(rightEpisodeJson.Credits.IntroEnd));
} }
// adds an item to a dropdown // adds an item to a dropdown
@ -867,13 +1008,13 @@
case "ArrowDown": case "ArrowDown":
if (timestampError.value != "") { if (timestampError.value != "") {
// if the control key is pressed, shift LHS by 10s. Otherwise, shift by 1. // if the control key is pressed, shift LHS by 10s. Otherwise, shift by 1.
offsetDelta = e.ctrlKey ? 10 / 0.128 : 1; offsetDelta = e.ctrlKey ? 10 / 0.1238 : 1;
} }
break; break;
case "ArrowUp": case "ArrowUp":
if (timestampError.value != "") { if (timestampError.value != "") {
offsetDelta = e.ctrlKey ? -10 / 0.128 : -1; offsetDelta = e.ctrlKey ? -10 / 0.1238 : -1;
} }
break; break;
@ -928,7 +1069,7 @@
// converts seconds to a readable timestamp (i.e. 127 becomes "02:07"). // converts seconds to a readable timestamp (i.e. 127 becomes "02:07").
function secondsToString(seconds) { function secondsToString(seconds) {
return new Date(seconds * 1000).toISOString().substr(14, 5); return new Date(seconds * 1000).toISOString().slice(14, 19);
} }
// erase all intro/credits timestamps // erase all intro/credits timestamps
@ -966,6 +1107,7 @@
} }
autoSkipChanged(); autoSkipChanged();
autoSkipCreditsChanged();
persistSkipChanged(); persistSkipChanged();
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
@ -1030,19 +1172,30 @@
}); });
btnUpdateTimestamps.addEventListener("click", () => { btnUpdateTimestamps.addEventListener("click", () => {
const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value; const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value;
const newLhsIntro = { const newLhs = {
IntroStart: document.querySelector("#editLeftEpisodeStart").value, Introduction: {
IntroEnd: document.querySelector("#editLeftEpisodeEnd").value, IntroStart: getTimeInSeconds(document.getElementById('editLeftIntroEpisodeStart').value),
IntroEnd: getTimeInSeconds(document.getElementById('editLeftIntroEpisodeEnd').value)
},
Credits: {
IntroStart: getTimeInSeconds(document.getElementById('editLeftCreditEpisodeStart').value),
IntroEnd: getTimeInSeconds(document.getElementById('editLeftCreditEpisodeEnd').value)
}
}; };
const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value; const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value;
const newRhsIntro = { const newRhs = {
IntroStart: document.querySelector("#editRightEpisodeStart").value, Introduction: {
IntroEnd: document.querySelector("#editRightEpisodeEnd").value, IntroStart: getTimeInSeconds(document.getElementById('editRightIntroEpisodeStart').value),
IntroEnd: getTimeInSeconds(document.getElementById('editRightIntroEpisodeEnd').value)
},
Credits: {
IntroStart: getTimeInSeconds(document.getElementById('editRightCreditEpisodeStart').value),
IntroEnd: getTimeInSeconds(document.getElementById('editRightCreditEpisodeEnd').value)
}
}; };
fetchWithAuth("Episode/" + lhsId + "/Timestamps", "POST", JSON.stringify(newLhs));
fetchWithAuth("Intros/Episode/" + lhsId + "/UpdateIntroTimestamps", "POST", JSON.stringify(newLhsIntro)); fetchWithAuth("Episode/" + rhsId + "/Timestamps", "POST", JSON.stringify(newRhs));
fetchWithAuth("Intros/Episode/" + rhsId + "/UpdateIntroTimestamps", "POST", JSON.stringify(newRhsIntro));
Dashboard.alert("New introduction timestamps saved"); Dashboard.alert("New introduction timestamps saved");
}); });
@ -1056,12 +1209,12 @@
let lTime, rTime, diffPos; let lTime, rTime, diffPos;
if (shift < 0) { if (shift < 0) {
lTime = y * 0.128; lTime = y * 0.1238;
rTime = (y + shift) * 0.128; rTime = (y + shift) * 0.1238;
diffPos = y + shift; diffPos = y + shift;
} else { } else {
lTime = (y - shift) * 0.128; lTime = (y - shift) * 0.1238;
rTime = y * 0.128; rTime = y * 0.1238;
diffPos = y - shift; diffPos = y - shift;
} }
@ -1086,6 +1239,27 @@
timeContainer.style.left = "25px"; timeContainer.style.left = "25px";
timeContainer.style.top = (-1 * rect.height + y).toString() + "px"; timeContainer.style.top = (-1 * rect.height + y).toString() + "px";
}); });
function setTime(seconds) {
// Calculate hours, minutes, and remaining seconds
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds % 3600) / 60);
let remainingSeconds = seconds % 60;
// Format as HH:MM:SS
let formattedTime =
String(hours).padStart(2, '0') + ':' +
String(minutes).padStart(2, '0') + ':' +
String(remainingSeconds).padStart(2, '0');
// Set the value of the time input
return formattedTime;
}
function getTimeInSeconds(time) {
let [hours, minutes, seconds] = time.split(':').map(Number);
return (hours * 3600) + (minutes * 60) + seconds;
}
</script> </script>
</div> </div>
</body> </body>

View File

@ -6,105 +6,106 @@ let introSkipper = {
}; };
introSkipper.d = function (msg) { introSkipper.d = function (msg) {
console.debug("[intro skipper] ", msg); console.debug("[intro skipper] ", msg);
} }
/** Setup event listeners */ /** Setup event listeners */
introSkipper.setup = function () { introSkipper.setup = function () {
document.addEventListener("viewshow", introSkipper.viewShow); document.addEventListener("viewshow", introSkipper.viewShow);
window.fetch = introSkipper.fetchWrapper; window.fetch = introSkipper.fetchWrapper;
introSkipper.d("Registered hooks"); introSkipper.d("Registered hooks");
} }
/** Wrapper around fetch() that retrieves skip segments for the currently playing item. */ /** Wrapper around fetch() that retrieves skip segments for the currently playing item. */
introSkipper.fetchWrapper = async function (...args) { introSkipper.fetchWrapper = async function (...args) {
// Based on JellyScrub's trickplay.js // Based on JellyScrub's trickplay.js
let [resource, options] = args; let [resource, options] = args;
let response = await introSkipper.originalFetch(resource, options); let response = await introSkipper.originalFetch(resource, options);
// Bail early if this isn't a playback info URL // Bail early if this isn't a playback info URL
try { try {
let path = new URL(resource).pathname; let path = new URL(resource).pathname;
if (!path.includes("/PlaybackInfo")) { return response; } if (!path.includes("/PlaybackInfo")) { return response; }
introSkipper.d("Retrieving skip segments from URL"); introSkipper.d("Retrieving skip segments from URL");
introSkipper.d(path); introSkipper.d(path);
let id = path.split("/")[2];
introSkipper.skipSegments = await introSkipper.secureFetch(`Episode/${id}/IntroSkipperSegments`); // Check for context root and set id accordingly
introSkipper.d("Successfully retrieved skip segments"); let path_arr = path.split("/");
introSkipper.d(introSkipper.skipSegments); 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) { catch (e) {
console.error("Unable to get skip segments from", resource, e); console.error("Unable to get skip segments from", resource, e);
} }
return response; return response;
} }
/** /**
* Event handler that runs whenever the current view changes. * Event handler that runs whenever the current view changes.
* Used to detect the start of video playback. * Used to detect the start of video playback.
*/ */
introSkipper.viewShow = function () { introSkipper.viewShow = function () {
const location = window.location.hash; const location = window.location.hash;
introSkipper.d("Location changed to " + location); introSkipper.d("Location changed to " + location);
if (location !== "#!/video") { if (location !== "#!/video") {
introSkipper.d("Ignoring location change"); introSkipper.d("Ignoring location change");
return; return;
} }
introSkipper.injectCss(); introSkipper.injectCss();
introSkipper.injectButton(); introSkipper.injectButton();
introSkipper.videoPlayer = document.querySelector("video"); introSkipper.videoPlayer = document.querySelector("video");
if (introSkipper.videoPlayer != null) { if (introSkipper.videoPlayer != null) {
introSkipper.d("Hooking video timeupdate"); introSkipper.d("Hooking video timeupdate");
introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged); introSkipper.videoPlayer.addEventListener("timeupdate", introSkipper.videoPositionChanged);
} }
} }
/** /**
* Injects the CSS used by the skip intro button. * Injects the CSS used by the skip intro button.
* Calling this function is a no-op if the CSS has already been injected. * Calling this function is a no-op if the CSS has already been injected.
*/ */
introSkipper.injectCss = function () { introSkipper.injectCss = function () {
if (introSkipper.testElement("style#introSkipperCss")) { if (introSkipper.testElement("style#introSkipperCss")) {
introSkipper.d("CSS already added"); introSkipper.d("CSS already added");
return; return;
} }
introSkipper.d("Adding CSS"); introSkipper.d("Adding CSS");
let styleElement = document.createElement("style"); let styleElement = document.createElement("style");
styleElement.id = "introSkipperCss"; styleElement.id = "introSkipperCss";
styleElement.innerText = ` styleElement.innerText = `
:root {
--rounding: .2em;
--accent: 0, 164, 220;
}
#skipIntro.upNextContainer {
width: unset;
}
#skipIntro { #skipIntro {
padding: 0 1px;
position: absolute; position: absolute;
right: 10em; bottom: 6em;
bottom: 9em; right: 4.5em;
background-color: rgba(25, 25, 25, 0.66); background-color: transparent;
border: 1px solid; font-size: 1.2em;
border-radius: 0px; }
display: inline-block; #skipIntro .emby-button {
cursor: pointer; text-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
box-shadow: inset 0 0 0 0 #f9f9f9; border-radius: var(--rounding);
-webkit-transition: ease-out 0.4s; background-color: rgba(0, 0, 0, 0.3);
-moz-transition: ease-out 0.4s; will-change: opacity, transform;
transition: ease-out 0.4s; opacity: 0;
@media (max-width: 1080px) { transition: opacity 0.3s ease-in, transform 0.3s ease-out;
right: 10%; }
} #skipIntro .emby-button:hover,
&:hover { #skipIntro .emby-button:focus {
box-shadow: inset 400px 0 0 0 #f9f9f9; background-color: rgba(var(--accent),0.7);
-webkit-transition: ease-in 1s; transform: scale(1.05);
-moz-transition: ease-in 1s; }
transition: ease-in 1s; #btnSkipSegmentText {
} padding-right: 0.15em;
&.upNextContainer { padding-left: 0.2em;
width: unset; margin-top: -0.1em;
}
@media (hover:hover) and (pointer:fine) {
.paper-icon-button-light:hover:not(:disabled) {
color: black !important;
background-color: rgba(47, 93, 98, 0) !important;
}
}
.paper-icon-button-light.show-focus:focus {
transform: scale(1.04) !important;
}
#btnSkipSegmentText {
padding-right: 3px;
padding-bottom: 2px;
}
} }
`; `;
document.querySelector("head").appendChild(styleElement); document.querySelector("head").appendChild(styleElement);
@ -135,7 +136,7 @@ introSkipper.injectButton = async function () {
button.classList.add("hide"); button.classList.add("hide");
button.addEventListener("click", introSkipper.doSkip); button.addEventListener("click", introSkipper.doSkip);
button.innerHTML = ` button.innerHTML = `
<button is="paper-icon-button-light" class="btnSkipIntro paper-icon-button-light injected"> <button is="emby-button" type="button" class="btnSkipIntro injected">
<span id="btnSkipSegmentText"></span> <span id="btnSkipSegmentText"></span>
<span class="material-icons skip_next"></span> <span class="material-icons skip_next"></span>
</button> </button>
@ -174,21 +175,31 @@ introSkipper.videoPositionChanged = function () {
if (!skipButton) { if (!skipButton) {
return; return;
} }
const embyButton = skipButton.querySelector(".emby-button");
const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime); const segment = introSkipper.getCurrentSegment(introSkipper.videoPlayer.currentTime);
switch (segment["SegmentType"]) { switch (segment.SegmentType) {
case "None": case "None":
skipButton.classList.add("hide"); if (embyButton.style.opacity === '0') return;
embyButton.style.opacity = '0';
embyButton.addEventListener("transitionend", () => {
skipButton.classList.add("hide");
}, { once: true });
return; return;
case "Introduction": case "Introduction":
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.intro_text;
skipButton.dataset["intro_text"];
break; break;
case "Credits": case "Credits":
skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.querySelector("#btnSkipSegmentText").textContent = skipButton.dataset.credits_text;
skipButton.dataset["credits_text"];
break; break;
} }
if (!skipButton.classList.contains("hide")) return;
skipButton.classList.remove("hide"); skipButton.classList.remove("hide");
embyButton.offsetWidth; // Force reflow
requestAnimationFrame(() => {
embyButton.style.opacity = '1';
});
} }
/** Seeks to the end of the intro. */ /** Seeks to the end of the intro. */
introSkipper.doSkip = function (e) { introSkipper.doSkip = function (e) {

View File

@ -17,7 +17,7 @@ function findIntros() {
// get the times of all similar fingerprint points // get the times of all similar fingerprint points
for (let i in fprDiffs) { for (let i in fprDiffs) {
if (fprDiffs[i] > fprDiffMinimum) { if (fprDiffs[i] > fprDiffMinimum) {
times.push(i * 0.128); times.push(i * 0.1238);
} }
} }
@ -57,7 +57,7 @@ function findIntros() {
introsLog.style.left = "115px"; introsLog.style.left = "115px";
introsLog.innerHTML = ""; introsLog.innerHTML = "";
const offset = Number(txtOffset.value) * 0.128; const offset = Number(txtOffset.value) * 0.1238;
for (let r of ranges) { for (let r of ranges) {
let lStart, lEnd, rStart, rEnd; let lStart, lEnd, rStart, rEnd;

View File

@ -2,8 +2,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace> <RootNamespace>ConfusedPolarBear.Plugin.IntroSkipper</RootNamespace>
<AssemblyVersion>0.1.16.3</AssemblyVersion> <AssemblyVersion>0.10.8.1</AssemblyVersion>
<FileVersion>0.1.16.3</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>

View File

@ -1,7 +1,11 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net.Mime; using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -47,6 +51,74 @@ public class SkipIntroController : ControllerBase
return intro; 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> /// <summary>
/// Gets a dictionary of all skippable segments. /// Gets a dictionary of all skippable segments.
/// </summary> /// </summary>
@ -86,19 +158,19 @@ public class SkipIntroController : ControllerBase
// Operate on a copy to avoid mutating the original Intro object stored in the dictionary. // Operate on a copy to avoid mutating the original Intro object stored in the dictionary.
var segment = new Intro(timestamp); var segment = new Intro(timestamp);
var config = Plugin.Instance!.Configuration; var config = Plugin.Instance.Configuration;
segment.IntroEnd -= config.SecondsOfIntroToPlay; segment.IntroEnd -= config.RemainingSecondsOfIntro;
if (config.PersistSkipButton) if (config.PersistSkipButton)
{ {
segment.ShowSkipPromptAt = segment.IntroStart; segment.ShowSkipPromptAt = segment.IntroStart;
segment.HideSkipPromptAt = segment.IntroEnd; segment.HideSkipPromptAt = segment.IntroEnd - 1;
} }
else else
{ {
segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment); segment.ShowSkipPromptAt = Math.Max(0, segment.IntroStart - config.ShowPromptAdjustment);
segment.HideSkipPromptAt = Math.Min( segment.HideSkipPromptAt = Math.Min(
segment.IntroStart + config.HidePromptAdjustment, segment.IntroStart + config.HidePromptAdjustment,
segment.IntroEnd); segment.IntroEnd - 1);
} }
return segment; return segment;
@ -153,8 +225,8 @@ public class SkipIntroController : ControllerBase
foreach (var intro in timestamps) foreach (var intro in timestamps)
{ {
// Get the details of the item from Jellyfin // Get the details of the item from Jellyfin
var rawItem = Plugin.Instance!.GetItem(intro.Key); var rawItem = Plugin.Instance.GetItem(intro.Key);
if (rawItem is not Episode episode) if (rawItem == null || rawItem is not Episode episode)
{ {
throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode"); throw new InvalidCastException("Unable to cast item id " + intro.Key + " to an Episode");
} }

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Net.Mime; using System.Net.Mime;
using System.Text; using System.Text;

View File

@ -1,7 +1,11 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Net.Mime; using System.Net.Mime;
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -140,6 +144,7 @@ public class VisualizationController : ControllerBase
foreach (var e in episodes) foreach (var e in episodes)
{ {
Plugin.Instance!.Intros.Remove(e.EpisodeId); Plugin.Instance!.Intros.Remove(e.EpisodeId);
Plugin.Instance!.Credits.Remove(e.EpisodeId);
} }
Plugin.Instance!.SaveTimestamps(); Plugin.Instance!.SaveTimestamps();
@ -148,18 +153,22 @@ public class VisualizationController : ControllerBase
} }
/// <summary> /// <summary>
/// Updates the timestamps for the provided episode. /// Updates the introduction timestamps for the provided episode.
/// </summary> /// </summary>
/// <param name="id">Episode ID to update timestamps for.</param> /// <param name="id">Episode ID to update timestamps for.</param>
/// <param name="timestamps">New introduction start and end times.</param> /// <param name="timestamps">New introduction start and end times.</param>
/// <response code="204">New introduction timestamps saved.</response> /// <response code="204">New introduction timestamps saved.</response>
/// <returns>No content.</returns> /// <returns>No content.</returns>
[HttpPost("Episode/{Id}/UpdateIntroTimestamps")] [HttpPost("Episode/{Id}/UpdateIntroTimestamps")]
public ActionResult UpdateTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps) [Obsolete("deprecated use Episode/{Id}/Timestamps")]
public ActionResult UpdateIntroTimestamps([FromRoute] Guid id, [FromBody] Intro timestamps)
{ {
var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd); if (timestamps.IntroEnd > 0.0)
Plugin.Instance!.Intros[id] = new Intro(id, tr); {
Plugin.Instance!.SaveTimestamps(); var tr = new TimeRange(timestamps.IntroStart, timestamps.IntroEnd);
Plugin.Instance!.Intros[id] = new Intro(id, tr);
Plugin.Instance.SaveTimestamps();
}
return NoContent(); return NoContent();
} }

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>
@ -34,4 +37,9 @@ public enum EdlAction
/// Show a skip button. /// Show a skip button.
/// </summary> /// </summary>
Intro, Intro,
/// <summary>
/// Show a skip button.
/// </summary>
Credit,
} }

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;

View File

@ -0,0 +1,22 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper.Data
{
/// <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 TimeStamps
{
/// <summary>
/// Gets or sets Introduction.
/// </summary>
public Intro Introduction { get; set; } = new Intro();
/// <summary>
/// Gets or sets Credits.
/// </summary>
public Intro Credits { get; set; } = new Intro();
}
}

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;
@ -63,14 +66,12 @@ public static class EdlManager
{ {
var id = episode.EpisodeId; var id = episode.EpisodeId;
if (!Plugin.Instance!.Intros.TryGetValue(id, out var intro)) 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} did not have an introduction, skipping", id); _logger?.LogDebug("Episode {Id} has neither a valid intro nor credit, skipping", id);
continue;
}
else if (!intro.Valid)
{
_logger?.LogDebug("Episode {Id} did not have a valid introduction, skipping", id);
continue; continue;
} }
@ -84,7 +85,31 @@ public static class EdlManager
continue; continue;
} }
File.WriteAllText(edlPath, intro.ToEdl(action)); 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);
} }
} }

View File

@ -1,8 +1,15 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.IO; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
@ -14,9 +21,15 @@ public class Entrypoint : IServerEntryPoint
{ {
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IUserViewManager _userViewManager; private readonly IUserViewManager _userViewManager;
private readonly ITaskManager _taskManager;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly ILogger<Entrypoint> _logger; private readonly ILogger<Entrypoint> _logger;
private readonly ILoggerFactory _loggerFactory; 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> /// <summary>
/// Initializes a new instance of the <see cref="Entrypoint"/> class. /// Initializes a new instance of the <see cref="Entrypoint"/> class.
@ -24,20 +37,51 @@ public class Entrypoint : IServerEntryPoint
/// <param name="userManager">User manager.</param> /// <param name="userManager">User manager.</param>
/// <param name="userViewManager">User view manager.</param> /// <param name="userViewManager">User view manager.</param>
/// <param name="libraryManager">Library manager.</param> /// <param name="libraryManager">Library manager.</param>
/// <param name="taskManager">Task manager.</param>
/// <param name="logger">Logger.</param> /// <param name="logger">Logger.</param>
/// <param name="loggerFactory">Logger factory.</param> /// <param name="loggerFactory">Logger factory.</param>
public Entrypoint( public Entrypoint(
IUserManager userManager, IUserManager userManager,
IUserViewManager userViewManager, IUserViewManager userViewManager,
ILibraryManager libraryManager, ILibraryManager libraryManager,
ITaskManager taskManager,
ILogger<Entrypoint> logger, ILogger<Entrypoint> logger,
ILoggerFactory loggerFactory) ILoggerFactory loggerFactory)
{ {
_userManager = userManager; _userManager = userManager;
_userViewManager = userViewManager; _userViewManager = userViewManager;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_taskManager = taskManager;
_logger = logger; _logger = logger;
_loggerFactory = loggerFactory; _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> /// <summary>
@ -46,17 +90,17 @@ public class Entrypoint : IServerEntryPoint
/// <returns>Task.</returns> /// <returns>Task.</returns>
public Task RunAsync() public Task RunAsync()
{ {
FFmpegWrapper.Logger = _logger; _libraryManager.ItemAdded += OnItemAdded;
_libraryManager.ItemUpdated += OnItemModified;
_taskManager.TaskCompleted += OnLibraryRefresh;
// TODO: when a new item is added to the server, immediately analyze the season it belongs to FFmpegWrapper.Logger = _logger;
// instead of waiting for the next task interval. The task start should be debounced by a few seconds.
try try
{ {
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible // Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
_logger.LogInformation("Running startup enqueue"); _logger.LogInformation("Running startup enqueue");
var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager); _queueManager.GetMediaItems();
queueManager.GetMediaItems();
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -66,6 +110,217 @@ public class Entrypoint : IServerEntryPoint
return Task.CompletedTask; 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> /// <summary>
/// Dispose. /// Dispose.
/// </summary> /// </summary>
@ -83,6 +338,19 @@ public class Entrypoint : IServerEntryPoint
{ {
if (!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; return;
} }
} }

View File

@ -1,4 +1,8 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
@ -14,8 +18,6 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// </summary> /// </summary>
public static class FFmpegWrapper public static class FFmpegWrapper
{ {
private static readonly object InvertedIndexCacheLock = new();
/// <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>
@ -34,7 +36,7 @@ public static class FFmpegWrapper
private static Dictionary<string, string> ChromaprintLogs { get; set; } = new(); private static Dictionary<string, string> ChromaprintLogs { get; set; } = new();
private static Dictionary<Guid, Dictionary<uint, int>> InvertedIndexCache { 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.
@ -137,15 +139,16 @@ public static class FFmpegWrapper
/// </summary> /// </summary>
/// <param name="id">Episode ID.</param> /// <param name="id">Episode ID.</param>
/// <param name="fingerprint">Chromaprint fingerprint.</param> /// <param name="fingerprint">Chromaprint fingerprint.</param>
/// <param name="mode">Mode.</param>
/// <returns>Inverted index.</returns> /// <returns>Inverted index.</returns>
public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint) public static Dictionary<uint, int> CreateInvertedIndex(Guid id, uint[] fingerprint, AnalysisMode mode)
{ {
lock (InvertedIndexCacheLock) var innerDictionary = InvertedIndexCache.GetOrAdd(mode, _ => new ConcurrentDictionary<Guid, Dictionary<uint, int>>());
// Check if cached for the ID
if (innerDictionary.TryGetValue(id, out var cached))
{ {
if (InvertedIndexCache.TryGetValue(id, out var cached)) return cached;
{
return cached;
}
} }
var invIndex = new Dictionary<uint, int>(); var invIndex = new Dictionary<uint, int>();
@ -159,10 +162,7 @@ public static class FFmpegWrapper
invIndex[point] = i; invIndex[point] = i;
} }
lock (InvertedIndexCacheLock) innerDictionary[id] = invIndex;
{
InvertedIndexCache[id] = invIndex;
}
return invIndex; return invIndex;
} }
@ -261,24 +261,29 @@ public static class FFmpegWrapper
var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true)); var raw = Encoding.UTF8.GetString(GetOutput(args, cacheKey, true));
foreach (var line in raw.Split('\n')) foreach (var line in raw.Split('\n'))
{ {
var matches = BlackFrameRegex.Matches(line); // There is no FFmpeg flag to hide metadata such as description
if (matches.Count != 2) // In our case, the metadata contained something that matched the regex.
if (line.StartsWith("[Parsed_blackframe_", StringComparison.OrdinalIgnoreCase))
{ {
continue; var matches = BlackFrameRegex.Matches(line);
} if (matches.Count != 2)
{
continue;
}
var (strPercent, strTime) = ( var (strPercent, strTime) = (
matches[0].Value.Split(':')[1], matches[0].Value.Split(':')[1],
matches[1].Value.Split(':')[1] matches[1].Value.Split(':')[1]
); );
var bf = new BlackFrame( var bf = new BlackFrame(
Convert.ToInt32(strPercent, CultureInfo.InvariantCulture), Convert.ToInt32(strPercent, CultureInfo.InvariantCulture),
Convert.ToDouble(strTime, CultureInfo.InvariantCulture)); Convert.ToDouble(strTime, CultureInfo.InvariantCulture));
if (bf.Percentage > minimum) if (bf.Percentage > minimum)
{ {
blackFrames.Add(bf); blackFrames.Add(bf);
}
} }
} }
@ -415,52 +420,46 @@ public static class FFmpegWrapper
RedirectStandardError = stderr RedirectStandardError = stderr
}; };
var ffmpeg = new Process using (var ffmpeg = new Process { StartInfo = info })
{ {
StartInfo = info Logger?.LogDebug("Starting ffmpeg with the following arguments: {Arguments}", ffmpeg.StartInfo.Arguments);
};
Logger?.LogDebug( ffmpeg.Start();
"Starting ffmpeg with the following arguments: {Arguments}",
ffmpeg.StartInfo.Arguments);
ffmpeg.Start(); try
try
{
ffmpeg.PriorityClass = Plugin.Instance?.Configuration.ProcessPriority ?? ProcessPriorityClass.BelowNormal;
}
catch (Exception e)
{
Logger?.LogDebug(
"ffmpeg priority could not be modified. {Message}",
e.Message);
}
using (MemoryStream ms = new MemoryStream())
{
var buf = new byte[4096];
var bytesRead = 0;
do
{ {
var streamReader = stderr ? ffmpeg.StandardError : ffmpeg.StandardOutput; ffmpeg.PriorityClass = Plugin.Instance?.Configuration.ProcessPriority ?? ProcessPriorityClass.BelowNormal;
bytesRead = streamReader.BaseStream.Read(buf, 0, buf.Length);
ms.Write(buf, 0, bytesRead);
} }
while (bytesRead > 0); catch (Exception e)
ffmpeg.WaitForExit(timeout);
var output = ms.ToArray();
// If caching is enabled, cache the output of this command.
if (cacheOutput)
{ {
File.WriteAllBytes(cacheFilename, output); Logger?.LogDebug("ffmpeg priority could not be modified. {Message}", e.Message);
} }
return output; using (var ms = new MemoryStream())
{
var buf = new byte[4096];
int bytesRead;
using (var streamReader = stderr ? ffmpeg.StandardError : ffmpeg.StandardOutput)
{
while ((bytesRead = streamReader.BaseStream.Read(buf, 0, buf.Length)) > 0)
{
ms.Write(buf, 0, bytesRead);
}
}
ffmpeg.WaitForExit(timeout);
var output = ms.ToArray();
// If caching is enabled, cache the output of this command.
if (cacheOutput)
{
File.WriteAllBytes(cacheFilename, output);
}
return output;
}
} }
} }

View File

@ -1,7 +1,5 @@
// This file is used by Code Analysis to maintain SuppressMessage // Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// attributes that are applied to this project. // SPDX-License-Identifier: GPL-3.0-only.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;

View File

@ -1,18 +1,22 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Updates;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
@ -61,15 +65,27 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay; FFmpegPath = serverConfiguration.GetEncodingOptions().EncoderAppPathDisplay;
var introsDirectory = Path.Join(applicationPaths.CachePath, "introskipper"); var pluginDirName = "introskipper";
FingerprintCachePath = Path.Join(introsDirectory, "chromaprints"); var pluginCachePath = "chromaprints";
_introPath = Path.Join(applicationPaths.CachePath, "introskipper", "intros.xml");
_creditsPath = Path.Join(applicationPaths.CachePath, "introskipper", "credits.xml");
var oldintrosDirectory = Path.Join(applicationPaths.PluginConfigurationsPath, "intros"); var introsDirectory = Path.Join(applicationPaths.DataPath, pluginDirName);
_oldFingerprintCachePath = Path.Join(oldintrosDirectory, "cache"); FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath);
_oldintroPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "intros.xml"); _introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml");
_oldcreditsPath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "credits.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). // Create the base & cache directories (if needed).
if (!Directory.Exists(FingerprintCachePath)) if (!Directory.Exists(FingerprintCachePath))
@ -79,9 +95,19 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
// Check if the old cache directory exists // Check if the old cache directory exists
if (Directory.Exists(_oldFingerprintCachePath)) 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 // Move the contents from old directory to new directory
File.Move(_oldintroPath, _introPath);
File.Move(_oldcreditsPath, _creditsPath);
string[] files = Directory.GetFiles(_oldFingerprintCachePath); string[] files = Directory.GetFiles(_oldFingerprintCachePath);
foreach (string file in files) foreach (string file in files)
{ {
@ -97,6 +123,8 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
ConfigurationChanged += OnConfigurationChanged; ConfigurationChanged += OnConfigurationChanged;
MigrateRepoUrl(serverConfiguration);
// TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete // TODO: remove when https://github.com/jellyfin/jellyfin-meta/discussions/30 is complete
try try
{ {
@ -144,6 +172,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary> /// </summary>
public event EventHandler? AutoSkipChanged; 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> /// <summary>
/// Gets the results of fingerprinting all episodes. /// Gets the results of fingerprinting all episodes.
/// </summary> /// </summary>
@ -200,7 +233,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
var introList = new List<Intro>(); var introList = new List<Intro>();
// Serialize intros // Serialize intros
foreach (var intro in Plugin.Instance!.Intros) foreach (var intro in Instance!.Intros)
{ {
introList.Add(intro.Value); introList.Add(intro.Value);
} }
@ -210,7 +243,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
// Serialize credits // Serialize credits
introList.Clear(); introList.Clear();
foreach (var intro in Plugin.Instance!.Credits) foreach (var intro in Instance!.Credits)
{ {
introList.Add(intro.Value); introList.Add(intro.Value);
} }
@ -233,7 +266,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
foreach (var intro in introList) foreach (var intro in introList)
{ {
Plugin.Instance!.Intros[intro.EpisodeId] = intro; Instance!.Intros[intro.EpisodeId] = intro;
} }
} }
@ -245,7 +278,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
foreach (var credit in creditList) foreach (var credit in creditList)
{ {
Plugin.Instance!.Credits[credit.EpisodeId] = credit; Instance!.Credits[credit.EpisodeId] = credit;
} }
} }
} }
@ -302,7 +335,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
return commit; return commit;
} }
internal BaseItem GetItem(Guid id) internal BaseItem? GetItem(Guid id)
{ {
return _libraryManager.GetItemById(id); return _libraryManager.GetItemById(id);
} }
@ -314,7 +347,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <returns>Full path to item.</returns> /// <returns>Full path to item.</returns>
internal string GetItemPath(Guid id) internal string GetItemPath(Guid id)
{ {
return GetItem(id).Path; 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> /// <summary>
@ -324,7 +365,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <returns>List of chapters.</returns> /// <returns>List of chapters.</returns>
internal List<ChapterInfo> GetChapters(Guid id) internal List<ChapterInfo> GetChapters(Guid id)
{ {
return _itemRepository.GetChapters(GetItem(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) internal void UpdateTimestamps(Dictionary<Guid, Intro> newTimestamps, AnalysisMode mode)
@ -335,21 +384,69 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
if (mode == AnalysisMode.Introduction) if (mode == AnalysisMode.Introduction)
{ {
Plugin.Instance!.Intros[intro.Key] = intro.Value; Instance!.Intros[intro.Key] = intro.Value;
} }
else if (mode == AnalysisMode.Credits) else if (mode == AnalysisMode.Credits)
{ {
Plugin.Instance!.Credits[intro.Key] = intro.Value; Instance!.Credits[intro.Key] = intro.Value;
} }
} }
Plugin.Instance!.SaveTimestamps(); Instance!.SaveTimestamps();
} }
} }
private void OnConfigurationChanged(object? sender, BasePluginConfiguration e) private void OnConfigurationChanged(object? sender, BasePluginConfiguration e)
{ {
AutoSkipChanged?.Invoke(this, EventArgs.Empty); 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> /// <summary>
@ -363,7 +460,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
_logger.LogDebug("Reading index.html from {Path}", indexPath); _logger.LogDebug("Reading index.html from {Path}", indexPath);
var contents = File.ReadAllText(indexPath); var contents = File.ReadAllText(indexPath);
_logger.LogDebug("Successfully read index.html");
var scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js\"></script>"; var scriptTag = "<script src=\"configurationpage?name=skip-intro-button.js\"></script>";
@ -376,12 +472,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
// Inject a link to the script at the end of the <head> section. // 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. // A regex is used here to ensure the replacement is only done once.
_logger.LogDebug("Injecting script tag");
var headEnd = new Regex("</head>", RegexOptions.IgnoreCase); var headEnd = new Regex("</head>", RegexOptions.IgnoreCase);
contents = headEnd.Replace(contents, scriptTag + "</head>", 1); contents = headEnd.Replace(contents, scriptTag + "</head>", 1);
// Write the modified file contents // Write the modified file contents
_logger.LogDebug("Saving modified file");
File.WriteAllText(indexPath, contents); File.WriteAllText(indexPath, contents);
_logger.LogInformation("Skip intro button successfully added"); _logger.LogInformation("Skip intro button successfully added");

View File

@ -1,3 +1,6 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;
@ -57,15 +60,21 @@ public class QueueManager
continue; continue;
} }
_logger.LogInformation( _logger.LogInformation("Running enqueue of items in library {Name}", folder.Name);
"Running enqueue of items in library {Name}",
folder.Name);
try try
{ {
foreach (var location in folder.Locations) foreach (var location in folder.Locations)
{ {
QueueLibraryContents(_libraryManager.FindByPath(location, true).Id); 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) catch (Exception ex)
@ -114,7 +123,7 @@ public class QueueManager
if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15) if (config.AnalysisLengthLimit != 10 || config.AnalysisPercent != 25 || config.MinimumIntroDuration != 15)
{ {
_logger.LogInformation( _logger.LogInformation(
"Analysis settings have been changed to: {Percent}%/{Minutes}m and a minimum of {Minimum}s", "Analysis settings have been changed to: {Percent}% / {Minutes}m and a minimum of {Minimum}s",
config.AnalysisPercent, config.AnalysisPercent,
config.AnalysisLengthLimit, config.AnalysisLengthLimit,
config.MinimumIntroDuration); config.MinimumIntroDuration);
@ -140,8 +149,6 @@ public class QueueManager
IsVirtualItem = false IsVirtualItem = false
}; };
_logger.LogDebug("Getting items");
var items = _libraryManager.GetItemList(query, false); var items = _libraryManager.GetItemList(query, false);
if (items is null) if (items is null)
@ -161,13 +168,25 @@ public class QueueManager
continue; continue;
} }
if (Plugin.Instance!.Configuration.PathRestrictions.Count > 0)
{
if (!Plugin.Instance!.Configuration.PathRestrictions.Contains(item.ContainingFolderPath))
{
continue;
}
}
QueueEpisode(episode); QueueEpisode(episode);
} }
_logger.LogDebug("Queued {Count} episodes", items.Count); _logger.LogDebug("Queued {Count} episodes", items.Count);
} }
private void QueueEpisode(Episode episode) /// <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) if (Plugin.Instance is null)
{ {
@ -184,6 +203,19 @@ public class QueueManager
return; 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. // Limit analysis to the first X% of the episode and at most Y minutes.
// X and Y default to 25% and 10 minutes. // X and Y default to 25% and 10 minutes.
var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds; var duration = TimeSpan.FromTicks(episode.RunTimeTicks ?? 0).TotalSeconds;
@ -198,11 +230,8 @@ public class QueueManager
fingerprintDuration, fingerprintDuration,
60 * Plugin.Instance!.Configuration.AnalysisLengthLimit); 60 * Plugin.Instance!.Configuration.AnalysisLengthLimit);
// Allocate a new list for each new season
_queuedEpisodes.TryAdd(episode.SeasonId, new List<QueuedEpisode>());
// Queue the episode for analysis // Queue the episode for analysis
var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumEpisodeCreditsDuration; var maxCreditsDuration = Plugin.Instance!.Configuration.MaximumCreditsDuration;
_queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode() _queuedEpisodes[episode.SeasonId].Add(new QueuedEpisode()
{ {
SeriesName = episode.SeriesName, SeriesName = episode.SeriesName,
@ -223,17 +252,16 @@ public class QueueManager
/// 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. /// 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> /// </summary>
/// <param name="candidates">Queued media items.</param> /// <param name="candidates">Queued media items.</param>
/// <param name="mode">Analysis mode.</param> /// <param name="modes">Analysis mode.</param>
/// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns> /// <returns>Media items that have been verified to exist in Jellyfin and in storage.</returns>
public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, bool AnyUnanalyzed) public (ReadOnlyCollection<QueuedEpisode> VerifiedItems, ReadOnlyCollection<AnalysisMode> RequiredModes)
VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, AnalysisMode mode) VerifyQueue(ReadOnlyCollection<QueuedEpisode> candidates, ReadOnlyCollection<AnalysisMode> modes)
{ {
var unanalyzed = false;
var verified = new List<QueuedEpisode>(); var verified = new List<QueuedEpisode>();
var reqModes = new List<AnalysisMode>();
var timestamps = mode == AnalysisMode.Introduction ? var requiresIntroAnalysis = modes.Contains(AnalysisMode.Introduction);
Plugin.Instance!.Intros : var requiresCreditsAnalysis = modes.Contains(AnalysisMode.Credits);
Plugin.Instance!.Credits;
foreach (var candidate in candidates) foreach (var candidate in candidates)
{ {
@ -244,24 +272,31 @@ public class QueueManager
if (File.Exists(path)) if (File.Exists(path))
{ {
verified.Add(candidate); verified.Add(candidate);
}
if (!timestamps.ContainsKey(candidate.EpisodeId)) if (requiresIntroAnalysis && (!Plugin.Instance!.Intros.TryGetValue(candidate.EpisodeId, out var intro) || !intro.Valid))
{ {
unanalyzed = true; 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) catch (Exception ex)
{ {
_logger.LogDebug( _logger.LogDebug(
"Skipping {Mode} analysis of {Name} ({Id}): {Exception}", "Skipping {Mode} analysis of {Name} ({Id}): {Exception}",
mode, modes,
candidate.Name, candidate.Name,
candidate.EpisodeId, candidate.EpisodeId,
ex); ex);
} }
} }
return (verified.AsReadOnly(), unanalyzed); return (verified.AsReadOnly(), reqModes.AsReadOnly());
} }
} }

View File

@ -1,6 +1,10 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
namespace ConfusedPolarBear.Plugin.IntroSkipper; namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -12,7 +16,7 @@ using Microsoft.Extensions.Logging;
/// </summary> /// </summary>
public class BaseItemAnalyzerTask public class BaseItemAnalyzerTask
{ {
private readonly AnalysisMode _analysisMode; private readonly ReadOnlyCollection<AnalysisMode> _analysisModes;
private readonly ILogger _logger; private readonly ILogger _logger;
@ -23,22 +27,22 @@ public class BaseItemAnalyzerTask
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class. /// Initializes a new instance of the <see cref="BaseItemAnalyzerTask"/> class.
/// </summary> /// </summary>
/// <param name="mode">Analysis mode.</param> /// <param name="modes">Analysis mode.</param>
/// <param name="logger">Task logger.</param> /// <param name="logger">Task logger.</param>
/// <param name="loggerFactory">Logger factory.</param> /// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param> /// <param name="libraryManager">Library manager.</param>
public BaseItemAnalyzerTask( public BaseItemAnalyzerTask(
AnalysisMode mode, ReadOnlyCollection<AnalysisMode> modes,
ILogger logger, ILogger logger,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
ILibraryManager libraryManager) ILibraryManager libraryManager)
{ {
_analysisMode = mode; _analysisModes = modes;
_logger = logger; _logger = logger;
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_libraryManager = libraryManager; _libraryManager = libraryManager;
if (mode == AnalysisMode.Introduction) if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{ {
EdlManager.Initialize(_logger); EdlManager.Initialize(_logger);
} }
@ -73,18 +77,21 @@ public class BaseItemAnalyzerTask
totalQueued += kvp.Value.Count; totalQueued += kvp.Value.Count;
} }
totalQueued *= _analysisModes.Count;
if (totalQueued == 0) if (totalQueued == 0)
{ {
throw new FingerprintException( 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."); "No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly.");
} }
if (this._analysisMode == AnalysisMode.Introduction) if (Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
{ {
EdlManager.LogConfiguration(); EdlManager.LogConfiguration();
} }
var totalProcessed = 0; var totalProcessed = 0;
var modeCount = _analysisModes.Count;
var options = new ParallelOptions() var options = new ParallelOptions()
{ {
MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism MaxDegreeOfParallelism = Plugin.Instance!.Configuration.MaxParallelism
@ -94,29 +101,55 @@ public class BaseItemAnalyzerTask
{ {
var writeEdl = false; 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 // 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. // of the current media items were deleted from Jellyfin since the task was started.
var (episodes, unanalyzed) = queueManager.VerifyQueue( var (episodes, requiredModes) = queueManager.VerifyQueue(
season.Value.AsReadOnly(), season.Value.AsReadOnly(),
this._analysisMode); _analysisModes);
if (episodes.Count == 0) var episodeCount = episodes.Count;
if (episodeCount == 0)
{ {
return; return;
} }
var first = episodes[0]; var first = episodes[0];
var requiredModeCount = requiredModes.Count;
if (!unanalyzed) if (requiredModeCount == 0)
{ {
_logger.LogDebug( _logger.LogDebug(
"All episodes in {Name} season {Season} have already been analyzed", "All episodes in {Name} season {Season} have already been analyzed",
first.SeriesName, first.SeriesName,
first.SeasonNumber); first.SeasonNumber);
Interlocked.Add(ref totalProcessed, episodeCount * modeCount); // Update total Processed directly
progress.Report((totalProcessed * 100) / totalQueued);
return; return;
} }
if (modeCount != requiredModeCount)
{
Interlocked.Add(ref totalProcessed, episodeCount);
progress.Report((totalProcessed * 100) / totalQueued); // Partial analysis some modes have already been analyzed
}
try try
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
@ -124,10 +157,15 @@ public class BaseItemAnalyzerTask
return; return;
} }
var analyzed = AnalyzeItems(episodes, cancellationToken); foreach (AnalysisMode mode in requiredModes)
Interlocked.Add(ref totalProcessed, analyzed); {
var analyzed = AnalyzeItems(episodes, mode, cancellationToken);
Interlocked.Add(ref totalProcessed, analyzed);
writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles; writeEdl = analyzed > 0 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
progress.Report((totalProcessed * 100) / totalQueued);
}
} }
catch (FingerprintException ex) catch (FingerprintException ex)
{ {
@ -138,20 +176,13 @@ public class BaseItemAnalyzerTask
ex); ex);
} }
if ( if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None)
writeEdl &&
Plugin.Instance!.Configuration.EdlAction != EdlAction.None &&
_analysisMode == AnalysisMode.Introduction)
{ {
EdlManager.UpdateEDLFiles(episodes); EdlManager.UpdateEDLFiles(episodes);
} }
progress.Report((totalProcessed * 100) / totalQueued);
}); });
if ( if (Plugin.Instance!.Configuration.RegenerateEdlFiles)
_analysisMode == AnalysisMode.Introduction &&
Plugin.Instance!.Configuration.RegenerateEdlFiles)
{ {
_logger.LogInformation("Turning EDL file regeneration flag off"); _logger.LogInformation("Turning EDL file regeneration flag off");
Plugin.Instance!.Configuration.RegenerateEdlFiles = false; Plugin.Instance!.Configuration.RegenerateEdlFiles = false;
@ -163,10 +194,12 @@ public class BaseItemAnalyzerTask
/// Analyze a group of media items for skippable segments. /// Analyze a group of media items for skippable segments.
/// </summary> /// </summary>
/// <param name="items">Media items to analyze.</param> /// <param name="items">Media items to analyze.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of items that were successfully analyzed.</returns> /// <returns>Number of items that were successfully analyzed.</returns>
private int AnalyzeItems( private int AnalyzeItems(
ReadOnlyCollection<QueuedEpisode> items, ReadOnlyCollection<QueuedEpisode> items,
AnalysisMode mode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var totalItems = items.Count; var totalItems = items.Count;
@ -179,7 +212,8 @@ public class BaseItemAnalyzerTask
} }
_logger.LogInformation( _logger.LogInformation(
"Analyzing {Count} files from {Name} season {Season}", "[Mode: {Mode}] Analyzing {Count} files from {Name} season {Season}",
mode,
items.Count, items.Count,
first.SeriesName, first.SeriesName,
first.SeasonNumber); first.SeasonNumber);
@ -187,21 +221,21 @@ public class BaseItemAnalyzerTask
var analyzers = new Collection<IMediaFileAnalyzer>(); var analyzers = new Collection<IMediaFileAnalyzer>();
analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>())); analyzers.Add(new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()));
if (mode == AnalysisMode.Credits)
{
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
}
if (Plugin.Instance!.Configuration.UseChromaprint) if (Plugin.Instance!.Configuration.UseChromaprint)
{ {
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>())); analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
} }
if (this._analysisMode == AnalysisMode.Credits)
{
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
}
// Use each analyzer to find skippable ranges in all media files, removing successfully // Use each analyzer to find skippable ranges in all media files, removing successfully
// analyzed items from the queue. // analyzed items from the queue.
foreach (var analyzer in analyzers) foreach (var analyzer in analyzers)
{ {
items = analyzer.AnalyzeMediaFiles(items, this._analysisMode, cancellationToken); items = analyzer.AnalyzeMediaFiles(items, mode, cancellationToken);
} }
return totalItems; return totalItems;

View File

@ -1,5 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@ -14,6 +18,8 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// </summary> /// </summary>
public class DetectCreditsTask : IScheduledTask public class DetectCreditsTask : IScheduledTask
{ {
private readonly ILogger<DetectCreditsTask> _logger;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
@ -23,10 +29,13 @@ public class DetectCreditsTask : IScheduledTask
/// </summary> /// </summary>
/// <param name="loggerFactory">Logger factory.</param> /// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param> /// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
public DetectCreditsTask( public DetectCreditsTask(
ILogger<DetectCreditsTask> logger,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
ILibraryManager libraryManager) ILibraryManager libraryManager)
{ {
_logger = logger;
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_libraryManager = libraryManager; _libraryManager = libraryManager;
} }
@ -44,7 +53,7 @@ public class DetectCreditsTask : IScheduledTask
/// <summary> /// <summary>
/// Gets the task description. /// Gets the task description.
/// </summary> /// </summary>
public string Description => "Analyzes the audio and video of all television episodes to find credits."; public string Description => "Analyzes media to determine the timestamp and length of credits";
/// <summary> /// <summary>
/// Gets the task key. /// Gets the task key.
@ -64,14 +73,35 @@ public class DetectCreditsTask : IScheduledTask
throw new InvalidOperationException("Library manager was null"); throw new InvalidOperationException("Library manager was null");
} }
var baseAnalyzer = new BaseItemAnalyzerTask( // abort automatic analyzer if running
AnalysisMode.Credits, 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.CreateLogger<DetectCreditsTask>(),
_loggerFactory, _loggerFactory,
_libraryManager); _libraryManager);
baseAnalyzer.AnalyzeItems(progress, cancellationToken); baseCreditAnalyzer.AnalyzeItems(progress, cancellationToken);
ScheduledTaskSemaphore.Release();
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@ -1,5 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@ -11,21 +15,26 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary> /// <summary>
/// Analyze all television episodes for introduction sequences. /// Analyze all television episodes for introduction sequences.
/// </summary> /// </summary>
public class DetectIntroductionsTask : IScheduledTask public class DetectIntrosCreditsTask : IScheduledTask
{ {
private readonly ILogger<DetectIntrosCreditsTask> _logger;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="DetectIntroductionsTask"/> class. /// Initializes a new instance of the <see cref="DetectIntrosCreditsTask"/> class.
/// </summary> /// </summary>
/// <param name="loggerFactory">Logger factory.</param> /// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param> /// <param name="libraryManager">Library manager.</param>
public DetectIntroductionsTask( /// <param name="logger">Logger.</param>
public DetectIntrosCreditsTask(
ILogger<DetectIntrosCreditsTask> logger,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
ILibraryManager libraryManager) ILibraryManager libraryManager)
{ {
_logger = logger;
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_libraryManager = libraryManager; _libraryManager = libraryManager;
} }
@ -33,7 +42,7 @@ public class DetectIntroductionsTask : IScheduledTask
/// <summary> /// <summary>
/// Gets the task name. /// Gets the task name.
/// </summary> /// </summary>
public string Name => "Detect Introductions"; public string Name => "Detect Intros and Credits";
/// <summary> /// <summary>
/// Gets the task category. /// Gets the task category.
@ -43,12 +52,12 @@ public class DetectIntroductionsTask : IScheduledTask
/// <summary> /// <summary>
/// Gets the task description. /// Gets the task description.
/// </summary> /// </summary>
public string Description => "Analyzes the audio of all television episodes to find introduction sequences."; public string Description => "Analyzes media to determine the timestamp and length of intros and credits.";
/// <summary> /// <summary>
/// Gets the task key. /// Gets the task key.
/// </summary> /// </summary>
public string Key => "CPBIntroSkipperDetectIntroductions"; public string Key => "CPBIntroSkipperDetectIntrosCredits";
/// <summary> /// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time. /// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
@ -63,14 +72,35 @@ public class DetectIntroductionsTask : IScheduledTask
throw new InvalidOperationException("Library manager was null"); throw new InvalidOperationException("Library manager was null");
} }
var baseAnalyzer = new BaseItemAnalyzerTask( // abort automatic analyzer if running
AnalysisMode.Introduction, if (Entrypoint.AutomaticTaskState == TaskState.Running || Entrypoint.AutomaticTaskState == TaskState.Cancelling)
_loggerFactory.CreateLogger<DetectIntroductionsTask>(), {
_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, _loggerFactory,
_libraryManager); _libraryManager);
baseAnalyzer.AnalyzeItems(progress, cancellationToken); baseIntroAnalyzer.AnalyzeItems(progress, cancellationToken);
ScheduledTaskSemaphore.Release();
return Task.CompletedTask; return Task.CompletedTask;
} }

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,5 +1,4 @@
# Intro Skipper (beta) # Intro Skipper (beta)
<div align="center"> <div align="center">
<p> <p>
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png" /> <img alt="Plugin Banner" src="https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png" />
@ -9,6 +8,12 @@
</p> </p>
</div> </div>
## Manifest URL (All Jellyfin Versions)
```
https://manifest.intro-skipper.org/manifest.json
```
## System requirements ## System requirements
* Jellyfin 10.8.4 (or newer) * Jellyfin 10.8.4 (or newer)
@ -16,73 +21,25 @@
* `jellyfin/jellyfin` 10.8.z container: preinstalled * `jellyfin/jellyfin` 10.8.z container: preinstalled
* `linuxserver/jellyfin` 10.8.z container: preinstalled * `linuxserver/jellyfin` 10.8.z container: preinstalled
* Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package * Debian Linux based native installs: provided by the `jellyfin-ffmpeg5` package
* MacOS native installs: build ffmpeg with chromaprint support ([instructions](#installation-instructions-for-macos)) * MacOS native installs: build ffmpeg with chromaprint support ([instructions](https://github.com/jumoog/intro-skipper/wiki/Custom-FFMPEG-(MacOS)))
## Detection parameters ## Limitations
Show introductions will be detected if they are: * SyncPlay is not (yet) compatible with any method of skipping due to the nature of how the clients are synced.
* Located within the first 25% of an episode or the first 10 minutes, whichever is smaller ## [Detection parameters](https://github.com/intro-skipper/intro-skipper/wiki#detection-parameters)
* Between 15 seconds and 2 minutes long
Ending credits will be detected if they are shorter than 4 minutes. ## [Detection types](https://github.com/intro-skipper/intro-skipper/wiki#detection-types)
These parameters can be configured by opening the plugin settings ## [Installation](https://github.com/intro-skipper/intro-skipper/wiki/Installation)
## Installation ## [Jellyfin Skip Options](https://github.com/intro-skipper/intro-skipper/wiki/Jellyfin-Skip-Options)
### Step 1: Install the plugin ## [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting)
1. Add this plugin repository to your server: `https://raw.githubusercontent.com/jumoog/intro-skipper/master/manifest.json`
2. Install the Intro Skipper plugin from the General section
3. Restart Jellyfin
### Step 2: Configure the plugin
4. OPTIONAL: Enable automatic skipping or skip button
1. Go to Dashboard -> Plugins -> Intro Skipper
2. Check "Automatically skip intros" or "Show skip intro button" and click Save
5. Go to Dashboard -> Scheduled Tasks -> Analyze Episodes and click the play button
6. After a season has completed analyzing, play some episodes from it and observe the results
1. Status updates are logged before analyzing each season of a show
## Troubleshooting ## [API Documentation](https://github.com/intro-skipper/intro-skipper/blob/master/docs/api.md)
#### Scheduled tasks fail instantly
- Verify that Intro Skipper can detect ffmpeg with Chromaprint
- Dashboard -> Plugins -> Intro Skipper -> Support Bundle Info
- Verify that ffmpeg is installed and detected by jellyfin
- Dashboard -> Playback -> FFmpeg path
- Verify that Chromaprint is enabled in ffmpeg (`--enable-chromaprint`)
#### Skip button is not visible <br />
- Verify you have successfully completed the scheduled task at least once <p align="center">
- Clear your browser cache and reload the Jellyfin server webpage <a href="https://discord.gg/AYZ7RJ3BuA"><img src="https://invidget.switchblade.xyz/AYZ7RJ3BuA"></a>
- Fix any permission mismatches between the web folder and Jellyfin server </p>
* <b>Docker -</b> the container is being run as a non-root user while having been built as a root user, causing the web files to be owned by root. To solve this, you can remove any lines like `User: 1000:1000`, `GUID:`, `PID:`, etc. from the jellyfin docker compose file.
* <b>Install from distro repositories -</b> the jellyfin-server will execute as `jellyfin` user while the web files will be owned by `root`, `www-data`, etc. This can <i>likely</i> be fixed by adding the `jellyfin` user (or whichever user executes the jellyfin server) to the same group that owns the jellyfin-web folders. **You should only do this if they are owned by a group other than root**.
## Installation (MacOS)
1. Build ffmpeg with chromaprint support using brew:
- macOS 12 or newer can install the [portable jellyfin-ffmpeg](https://github.com/jellyfin/jellyfin-ffmpeg)
```
brew uninstall --force --ignore-dependencies ffmpeg
brew install chromaprint amiaopensource/amiaos/decklinksdk
brew tap homebrew-ffmpeg/ffmpeg
brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-chromaprint
brew link --overwrite ffmpeg
```
2. Open ~/.config/jellyfin/encoding.xml and add or edit the following lines
- Replace [FFMPEG_PATH] with the path returned by `whereis ffmpeg`
```
<EncoderAppPath>[FFMPEG_PATH]</EncoderAppPath>
<EncoderAppPathDisplay>[FFMPEG_PATH]</EncoderAppPathDisplay>
```
4. Follow the [general installation instructions](#installation) above
## Documentation
Documentation about how the API works can be found in [api.md](docs/api.md).

View File

@ -4,73 +4,17 @@
"name": "Intro Skipper", "name": "Intro Skipper",
"overview": "Automatically detect and skip intros in television episodes", "overview": "Automatically detect and skip intros in television episodes",
"description": "Analyzes the audio of television episodes and detects introduction sequences.", "description": "Analyzes the audio of television episodes and detects introduction sequences.",
"owner": "jumoog, AbandonedCart (forked from ConfusedPolarBear)", "owner": "AbandonedCart, rlauuzo, jumoog (forked from ConfusedPolarBear)",
"category": "General", "category": "General",
"imageUrl": "https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png", "imageUrl": "https://raw.githubusercontent.com/jumoog/intro-skipper/master/images/logo.png",
"versions": [ "versions": [
{
"version": "0.1.16.3",
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
"targetAbi": "10.8.4.0",
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16.3/intro-skipper-v0.1.16.3.zip",
"checksum": "f4832c852684409206e0f4191a135779",
"timestamp": "2024-03-11T22:00:13Z"
},
{
"version": "0.1.16.2",
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
"targetAbi": "10.8.4.0",
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16.2/intro-skipper-v0.1.16.2.zip",
"checksum": "714e084cb02d159da57216e2ceec3509",
"timestamp": "2024-03-07T21:20:34Z"
},
{
"version": "0.1.16.1",
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
"targetAbi": "10.8.4.0",
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16.1/intro-skipper-v0.1.16.1.zip",
"checksum": "d8e5370f974bd5624206f87b3fed05bb",
"timestamp": "2024-03-06T10:17:25Z"
},
{ {
"version": "0.1.16.0", "version": "0.10.8.1",
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n", "changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
"targetAbi": "10.8.4.0", "targetAbi": "10.8.4.0",
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.16/intro-skipper-v0.1.16.zip", "sourceUrl": "https://github.com/intro-skipper/intro-skipper/releases/download/10.8/v0.10.8.1/intro-skipper-v0.10.8.1.zip",
"checksum": "8989cbe9c438d5a14fab3002e21c26ba", "checksum": "76cdb2bad9582d23c1f6f4d868218d6c",
"timestamp": "2024-03-04T10:10:35Z" "timestamp": "2024-10-26T18:10:00Z"
},
{
"version": "0.1.14.0",
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
"targetAbi": "10.8.4.0",
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.14/intro-skipper-v0.1.14.zip",
"checksum": "704ecc32588243545c44b2eed130b033",
"timestamp": "2024-03-02T21:10:57Z"
},
{
"version": "0.1.10.0",
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
"targetAbi": "10.8.4.0",
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.10/intro-skipper-v0.1.10.zip",
"checksum": "50b16e5131f3389d7261691c301c2d70",
"timestamp": "2024-03-01T16:50:00Z"
},
{
"version": "0.1.9.0",
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
"targetAbi": "10.8.4.0",
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.9/intro-skipper-v0.1.9.zip",
"checksum": "a85ff99d7fcde37aecf3fdd355708f49",
"timestamp": "2024-03-01T10:23:43Z"
},
{
"version": "0.1.8.0",
"changelog": "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
"targetAbi": "10.8.4.0",
"sourceUrl": "https://github.com/jumoog/intro-skipper/releases/download/v0.1.7/intro-skipper-v0.1.7.zip",
"checksum": "97a0208376adbdd1ebb17b4ce358ab9c",
"timestamp": "2022-10-27T03:27:27Z"
} }
] ]
} }

58
update-version.js Normal file
View File

@ -0,0 +1,58 @@
const fs = require('fs');
// Read csproj
const csprojPath = './ConfusedPolarBear.Plugin.IntroSkipper/ConfusedPolarBear.Plugin.IntroSkipper.csproj';
if (!fs.existsSync(csprojPath)) {
console.error('ConfusedPolarBear.Plugin.IntroSkipper.csproj file not found');
process.exit(1);
}
function updateCsprojVersion() {
const newVersion = process.env.VERSION
const csprojContent = fs.readFileSync(csprojPath, 'utf8');
const updatedContent = csprojContent
.replace(/<AssemblyVersion>.*<\/AssemblyVersion>/, `<AssemblyVersion>${newVersion}</AssemblyVersion>`)
.replace(/<FileVersion>.*<\/FileVersion>/, `<FileVersion>${newVersion}</FileVersion>`);
fs.writeFileSync(csprojPath, updatedContent);
console.log('Updated .csproj file with new version.');
}
// Function to increment version string
function incrementVersion(version) {
const parts = version.split('.').map(Number);
parts[parts.length - 1] += 1; // Increment the last part of the version
return parts.join('.');
}
// Read the .csproj file
fs.readFile(csprojPath, 'utf8', (err, data) => {
if (err) {
return console.error('Failed to read .csproj file:', err);
}
let newAssemblyVersion = null;
let newFileVersion = null;
// Use regex to find and increment versions
const updatedData = data.replace(/<AssemblyVersion>(.*?)<\/AssemblyVersion>/, (match, version) => {
newAssemblyVersion = incrementVersion(version);
return `<AssemblyVersion>${newAssemblyVersion}</AssemblyVersion>`;
}).replace(/<FileVersion>(.*?)<\/FileVersion>/, (match, version) => {
newFileVersion = incrementVersion(version);
return `<FileVersion>${newFileVersion}</FileVersion>`;
});
// Write the updated XML back to the .csproj file
fs.writeFile(csprojPath, updatedData, 'utf8', (err) => {
if (err) {
return console.error('Failed to write .csproj file:', err);
}
console.log('Version incremented successfully!');
// Write the new versions to GitHub Actions environment files
fs.appendFileSync(process.env.GITHUB_ENV, `NEW_ASSEMBLY_VERSION=${newAssemblyVersion}\n`);
fs.appendFileSync(process.env.GITHUB_ENV, `NEW_FILE_VERSION=${newFileVersion}\n`);
});
});

View File

@ -0,0 +1,110 @@
const https = require('https');
const crypto = require('crypto');
const fs = require('fs');
const { URL } = require('url');
// Read manifest.json
const manifestPath = './manifest.json';
if (!fs.existsSync(manifestPath)) {
console.error('manifest.json file not found');
process.exit(1);
}
const jsonData = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const newVersion = {
version: process.env.VERSION, // replace with the actual new version
changelog: "- See the full changelog at [GitHub](https://github.com/jumoog/intro-skipper/blob/master/CHANGELOG.md)\n",
targetAbi: "10.8.4.0",
sourceUrl: process.env.SOURCE_URL,
checksum: process.env.CHECKSUM,
timestamp: process.env.TIMESTAMP
};
async function updateManifest() {
await validVersion(newVersion);
// Add the new version to the manifest
jsonData[0].versions.unshift(newVersion);
// Write the updated manifest to file if validation is successful
fs.writeFileSync(manifestPath, JSON.stringify(jsonData, null, 4));
console.log('Manifest updated successfully.');
}
async function validVersion(version) {
console.log(`Validating version ${version.version}...`);
const isValidUrl = await checkUrl(version.sourceUrl);
if (!isValidUrl) {
console.error(`Invalid URL: ${version.sourceUrl}`);
process.exit(1); // Exit with an error code
}
const isValidChecksum = await verifyChecksum(version.sourceUrl, version.checksum);
if (!isValidChecksum) {
console.error(`Checksum mismatch for URL: ${version.sourceUrl}`);
process.exit(1); // Exit with an error code
} else {
console.log(`Version ${version.version} is valid.`);
}
}
function checkUrl(url) {
return new Promise((resolve) => {
https.get(url, (res) => {
resolve(res.statusCode === 302);
}).on('error', () => {
resolve(false);
});
});
}
async function verifyChecksum(url, expectedChecksum) {
const tempFilePath = `tempfile_${Math.random().toString(36).substring(2, 15) + Math.random().toString(23).substring(2, 5)}.zip`;
try {
await downloadFile(url, tempFilePath);
const fileBuffer = fs.readFileSync(tempFilePath);
const hash = crypto.createHash('md5').update(fileBuffer).digest('hex');
fs.unlinkSync(tempFilePath); // Clean up temp file
return hash === expectedChecksum;
} catch (error) {
console.error(`Error verifying checksum for URL: ${url}`, error);
return false;
}
}
async function downloadFile(url, destinationPath, redirects = 5) {
if (redirects === 0) {
throw new Error('Too many redirects');
}
const file = fs.createWriteStream(destinationPath);
return new Promise((resolve, reject) => {
https.get(url, (response) => {
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
// Follow redirect
const redirectUrl = new URL(response.headers.location, url).toString();
downloadFile(redirectUrl, destinationPath, redirects - 1)
.then(resolve)
.catch(reject);
} else if (response.statusCode === 200) {
response.pipe(file);
file.on('finish', () => {
file.close(resolve);
});
} else {
reject(new Error(`Failed to get '${url}' (${response.statusCode})`));
}
}).on('error', (err) => {
fs.unlink(destinationPath, () => reject(err));
});
});
}
async function run() {
await updateManifest();
}
run();