Compare commits

...

487 Commits
10.8 ... 10.10

Author SHA1 Message Date
Kilian von Pflugk
3139c15eb1
remove older plugin version
prevent user installation of versions with known bugs
2024-11-26 21:58:10 +00:00
TwistedUmbrellaX
d7d3949887
Change a wiki heading 2024-11-26 13:09:46 -05:00
TwistedUmbrellaX
18d0847ae0
Cleaning up the UI (#401)
* Update configPage.html

* Update configPage.html

* Update configPage.html

* Wrap the larger paragraph
2024-11-26 09:59:39 -05:00
github-actions[bot]
724c237592 release v1.10.10.11 2024-11-25 17:07:33 +00:00
dependabot[bot]
1048eaf26d
chore(deps): bump Microsoft.NET.Test.Sdk from 17.11.1 to 17.12.0 (#400)
Bumps [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) from 17.11.1 to 17.12.0.
- [Release notes](https://github.com/microsoft/vstest/releases)
- [Changelog](https://github.com/microsoft/vstest/blob/main/docs/releases.md)
- [Commits](https://github.com/microsoft/vstest/compare/v17.11.1...v17.12.0)

---
updated-dependencies:
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-25 18:05:35 +01:00
dependabot[bot]
4995fc7b70
ci(deps): bump github/codeql-action from 3.27.4 to 3.27.5 (#399)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.27.4 to 3.27.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](ea9e4e3799...f09c1c0a94)

---
updated-dependencies:
- dependency-name: github/codeql-action
  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-11-25 18:04:58 +01:00
rlauu
d4f88e0f3e Create db before trying to restore timestamps 2024-11-25 17:32:04 +01:00
TwistedUmbrellaX
cb0fdb92ad
Clearer separation between erase types 2024-11-25 11:25:44 -05:00
github-actions[bot]
87ee56de65 release v1.10.10.10 2024-11-24 18:33:27 +00:00
TwistedUmbrellaX
9fce12bdbb
Allow existing settings to be bulk reset (#394)
* Allow existing settings to be bulk reset

* The box should also be checked

... so it can be unchecked

* Only check for checked once
2024-11-24 11:29:20 -05:00
Kilian von Pflugk
4293f17dd4 InjectSkipButton switch most output to debug 2024-11-24 17:28:18 +01:00
Kilian von Pflugk
e86832b571 better handle InjectSkipButton function
don't show a error if the button don't needs to injected and the install is stoc
2024-11-24 16:13:50 +01:00
Kilian von Pflugk
a89e61b919 try catch InjectSkipButton 2024-11-24 15:30:45 +01:00
TwistedUmbrellaX
89d3fe79ec
A more consistent layout (almost) (#392)
* A more consistent layout (almost)

* Begin working "Outro" into the verbiage

* Catch a straggler
2024-11-24 09:28:57 -05:00
Kilian von Pflugk
62683ede87
move migration functions to a new file (#395)
* move migration functions to a new file

* check if index.html exits
2024-11-24 15:21:46 +01:00
TwistedUmbrellaX
d48ea90190
Hide legacy options to avoid confusion (#389)
* Update configPage.html

* Update configPage.html

* Update configPage.html

* Even more covert

* Persistent to avoid accidental flush

* Imply requiring save

* Allow options on checked

* Mention they're not injected by default

* This just doesn't want to work

* Use id whenever poosible

* Helps to do it at the right time

* Add a restart note to flush

* Too many quirks for this way

* Not hidden anymore

* Looks better after the button
2024-11-21 14:20:53 -05:00
rlauuzo
6ccf002e51
Recaps and Previews Support (#357)
* Recaps and Previews Support

* Add draft UI of preview / recap edit

* remove intro/credit tasks

* Update configPage.html

* rename task

* Reorganize settings by relation

* More standardized formatting

* Some additional formatting

* fix a typo

* Update configPage.html

* Allow missing recap / prview data

* More risk to corrupt than benefit

* Update TimeStamps.cs

* Update PluginConfiguration.cs

* Update configPage.html

* Update PluginConfiguration.cs

* Add chapter regex to settings

* Move all UI into UI section

* Move ending seconds with similar

* Add default

* fixes

* Update SkipIntroController.cs

* Autoskip all segments

* Check if adjacent segment

* Update AutoSkip.cs

* Update AutoSkip.cs

* Settings apply to all segment types

* Update SegmentProvider

* Update configPage.html

Whoops

* Update Plugin.cs

* Update AutoSkip.cs

* Let’s call it missing instead

* Update BaseItemAnalyzerTask.cs

* Update BaseItemAnalyzerTask.cs

* Update BaseItemAnalyzerTask.cs

* Move "select" all below list

* Clarify button wording

* Update configPage.html

* Nope, long client list will hide it

* Simplify wording

* Update QueuedEpisode.cs

* fix unit test for ffmpeg7

* Add migration

* Restore DataContract

* update

* Update configPage.html

* remove analyzed status

* Update AutoSkip.cs

* Update configPage.html typo

* Store analyzed items in seasoninfo

* Update VisualizationController.cs

* update

* Update IntroSkipperDbContext.cs

* Add preview / recap delete

* This keeps changing itself

* Update SkipIntroController.cs

* Rather add it to be removed

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-11-21 09:42:55 -05:00
dependabot[bot]
6aa26fe9a7
ci(deps): bump github/codeql-action from 3.27.1 to 3.27.4 (#379)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-19 08:24:48 +00:00
Kilian von Pflugk
b511d9045e
remove v1.10.10.8 2024-11-17 21:59:17 +00:00
github-actions[bot]
a51bf5552b release v1.10.10.9 2024-11-17 21:14:18 +00:00
rlauu
f275e188da RebuildMediaSegments 2024-11-17 21:59:54 +01:00
rlauu
7309be0422 fix id 2024-11-17 21:43:29 +01:00
TwistedUmbrellaX
78e8943061 Drop the fallback and force overwrite 2024-11-17 09:50:48 -05:00
TwistedUmbrellaX
86f2c7e04c Revert "This needs to be false or the API is broken"
This reverts commit fcaff967f0610c59493015626ad2ef23c5856ab1.
2024-11-17 09:20:05 -05:00
TwistedUmbrellaX
fcaff967f0 This needs to be false or the API is broken 2024-11-17 09:15:40 -05:00
github-actions[bot]
0b31117772 release v1.10.10.8 2024-11-17 01:15:17 +00:00
TwistedUmbrellaX
6bb54ab3a5
10.10.2 (#378)
* Hotfix for the 10.10.2 API change

* Support 10.10.1 retroactively for now

* Log a message indicating the action
2024-11-16 20:14:01 -05:00
github-actions[bot]
72366e93b9 release v1.10.10.7 2024-11-12 18:43:25 +00:00
dependabot[bot]
247a5793d8
ci(deps): bump github/codeql-action from 3.27.0 to 3.27.1 (#376)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-11 16:32:29 +01:00
rlauuzo
e5f29a91c9
Update VisualizationController.cs 2024-11-10 21:28:43 +01:00
Kilian von Pflugk
f70207d002 version check no longer works in 10.10 2024-11-08 11:01:15 +01:00
TwistedUmbrellaX
ec4863dd5d
Uncheck erase fingerprint on complete 2024-11-07 13:38:01 -05:00
rlauuzo
dcb034ff89
Fix credits skip button 2024-11-06 22:39:26 +01:00
github-actions[bot]
1304acc312 release v1.10.10.6 2024-11-06 09:32:00 +00:00
rlauu
f68fd9d06a Fix bug where timestamps were reset to zero. 2024-11-06 10:30:00 +01:00
github-actions[bot]
327fe99de0 release v1.10.10.5 2024-11-05 21:09:51 +00:00
TwistedUmbrellaX
6c64d48fe5
Fixing the learning curve, one word at a time (#373)
* Specify the type of scan

* Update configPage.html

* Update configPage.html

* Update configPage.html
2024-11-05 14:19:02 -05:00
Kilian von Pflugk
79151fa893 fix: windows can't delete file because filestream is still open 2024-11-05 20:04:27 +01:00
TwistedUmbrellaX
86dec1478a
Update README.md 2024-11-05 10:19:24 -05:00
github-actions[bot]
48a087d437 release v1.10.10.4 2024-11-05 14:34:35 +00:00
rlauu
16b834b553 fix 2024-11-05 15:30:25 +01:00
Kilian von Pflugk
b9194334c6
ci: don't build on PRs (#370) 2024-11-05 14:11:27 +01:00
TwistedUmbrellaX
da935524bd
Hide button options without button (#372) 2024-11-05 06:19:15 -05:00
TwistedUmbrellaX
af1536fc28 Keep reiterating the separation 2024-11-05 05:25:41 -05:00
TwistedUmbrellaX
97b7585efb
Reduce retention of pull builds
Preventing draft builds will impede testing, but it's best not to keep the artifacts too long
2024-11-04 18:57:32 -05:00
TwistedUmbrellaX
9e2bbd0c0f
Update build.yml 2024-11-04 18:47:25 -05:00
Kilian von Pflugk
4598918bd7
Update README for 10.10 2024-11-04 20:54:56 +00:00
github-actions[bot]
d5043d3f04 release v1.10.10.3 2024-11-04 20:25:58 +00:00
TwistedUmbrellaX
71768972e5
Approach from a different angle (#368) 2024-11-04 20:22:04 +00:00
TwistedUmbrellaX
9af182fe98
Link to the wiki sections directly 2024-11-03 05:09:59 -05:00
Kilian von Pflugk
94da294316
add downloads stats 2024-11-02 19:25:39 +00:00
rlauuzo
a1d634b66e
Switch from XML Files to SQLite DB (#365)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-11-02 17:17:22 +00:00
TwistedUmbrellaX
29ca500ef1 Reorganize settings by relation 2024-11-02 11:57:08 -04:00
rlauu
c2eaed2f63 Clean Up 2024-10-31 11:58:56 +01:00
Kilian von Pflugk
55c6415022 parameter e hides outer local variable with the same name 2024-10-30 21:14:26 +01:00
Kilian von Pflugk
7db0f774e9 use primary constructor 2024-10-30 20:55:28 +01:00
Kilian von Pflugk
abb4cf44f4 check that old config is not null 2024-10-30 20:37:03 +01:00
Kilian von Pflugk
27c4843904 fix namespace 2024-10-30 20:36:08 +01:00
rlauu
69960bfa5b Update Media Segments when timestamps where changed in the editor 2024-10-30 16:57:23 +01:00
Kilian von Pflugk
87875c8a11 Revert "Temporary workaround"
This reverts commit f68cc3a68bbc3bc88d01de17fe8e02b8db51e882.
2024-10-29 21:22:52 +01:00
TwistedUmbrellaX
f68cc3a68b
Temporary workaround 2024-10-29 14:48:02 -04:00
TwistedUmbrellaX
1e93a3dca7 Rename a duplicate label 2024-10-29 10:19:36 -04:00
dependabot[bot]
cb588df512
ci(deps): bump github/codeql-action from 3.26.13 to 3.27.0 (#362)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 16:31:05 +01:00
rlauuzo
ce922d1b79
there is no modded 10.10 web interface 2024-10-28 16:18:06 +01:00
TwistedUmbrellaX
b286c78153 Update ignore for alternate IDE 2024-10-27 21:13:28 -04:00
Kilian von Pflugk
6d93df1c76
allow users to override the URL (#360)
e.g. intro-skipper.org is not available for use in mainland China
2024-10-27 21:38:46 +00:00
TwistedUmbrellaX
718a86945e
Swap editor and support dropdown 2024-10-27 12:35:39 -04:00
TwistedUmbrellaX
6ff2185f6c
Update README.md 2024-10-27 07:45:55 -04:00
github-actions[bot]
c4347a374a release v1.10.10.2 2024-10-27 09:38:32 +00:00
TwistedUmbrellaX
ccdea752ef Keep interface endpoint for legacy 2024-10-27 05:29:43 -04:00
TwistedUmbrellaX
964822cd94 Visual Studio forgot about these 2024-10-27 05:23:12 -04:00
TwistedUmbrellaX
fc400560e6 Force a button state reset on update 2024-10-27 05:08:22 -04:00
TwistedUmbrellaX
e9131b6710
Warn without disabling toggle 2024-10-27 04:39:59 -04:00
Kilian von Pflugk
3e25778173
first stable build 2024-10-26 18:10:11 +00:00
github-actions[bot]
f762f0f1a2 release v1.10.10.1 2024-10-26 18:08:27 +00:00
Kilian von Pflugk
1019cc30fb ci: leave beta 2024-10-26 20:05:09 +02:00
TwistedUmbrellaX
0e7d3b6aee
Realign versioning with jellyfin 2024-10-26 14:02:19 -04:00
rlauuzo
2f7c2ed95e
Delete IntroSkipper/Analyzers/SegmentAnalyzer.cs 2024-10-26 08:49:49 +02:00
TwistedUmbrellaX
28f93a8569 And Chrome is somehow worse 2024-10-25 22:11:46 -04:00
TwistedUmbrellaX
479f6401f1 JMP breaks inline strong tags 2024-10-25 20:16:58 -04:00
Kilian von Pflugk
03480eaa12 ci: remove cloudflare deploy 2024-10-25 21:39:08 +02:00
TwistedUmbrellaX
1cc80604c2 Fix identifier and name 2024-10-25 14:31:50 -04:00
TwistedUmbrellaX
b38bd7bb8c Implement SPDX GLPv3.0 LICENSE 2024-10-25 14:15:12 -04:00
TwistedUmbrellaX
6f79e5d2c5
Update LICENSE for Intro-Skipper 2024-10-25 12:21:30 -04:00
rlauuzo
c663e1dfde
Delete webui.patch 2024-10-25 16:10:39 +02:00
TwistedUmbrellaX
b12433d621
Update README.md 2024-10-24 22:29:02 -04:00
Kilian von Pflugk
6376e72862 switch to our new domain 2024-10-24 22:52:16 +02:00
rlauu
f282e51308 Skip Applying RemainingSecondsOfIntro for Segments at the End of the Video 2024-10-23 15:11:41 +02:00
rlauuzo
299db8d77e
typo 2024-10-23 11:47:26 +02:00
rlauuzo
21cf2d5e46
Apply formatting
and use a for...of loop instead of forEach
2024-10-23 10:19:04 +02:00
TwistedUmbrellaX
05038b6ead Formatting for error messages 2024-10-22 20:45:24 -04:00
Kilian von Pflugk
e20f87bb3b add recommend vs code settings 2024-10-21 21:00:16 +02:00
dependabot[bot]
5f9a308dca
ci(deps): bump github/codeql-action from 3.26.11 to 3.26.13 (#353)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.11 to 3.26.13.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](6db8d6351f...f779452ac5)

---
updated-dependencies:
- dependency-name: github/codeql-action
  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-10-21 17:38:36 +02:00
TwistedUmbrellaX
f47f05c04e Beware the small print 2024-10-21 06:34:58 -04:00
TwistedUmbrellaX
d9460b16fb Remove the EDL dropdown button 2024-10-21 05:42:27 -04:00
Kilian von Pflugk
0b27b6e297
remove EDL function and point to endrl's EDL plugin (#352)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-10-21 09:19:51 +02:00
rlauu
4b725aaaad simplify check for credits reaching end 2024-10-21 08:52:43 +02:00
TwistedUmbrellaX
b02403da84
UI hoopla (#351)
* Add a disclaimer about changing params

* fix formatting

* paragraphs are volatile
2024-10-20 15:14:03 -04:00
Kilian von Pflugk
721de97cc1 migrate old plugin config 2024-10-20 14:23:49 +02:00
Kilian von Pflugk
84da3f0a29 rename ConfusedPolarBear.Plugin.IntroSkipper -> IntroSkipper 2024-10-20 14:23:49 +02:00
TwistedUmbrellaX
5205965d0d Make the ignore button label specific 2024-10-20 07:59:04 -04:00
rlauu
aa1e0b8966 check if valid 2024-10-20 13:35:33 +02:00
rlauu
0735b41f3d Update SegmentProvider.cs 2024-10-20 13:27:05 +02:00
rlauu
00b272e77e more robust cancelling 2024-10-20 10:26:38 +02:00
rlauuzo
1a731e3acc
Update MediaSegments directly (#350)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-10-19 20:49:47 +00:00
Kilian von Pflugk
8f7c63172f
keep only 10.10 release 2024-10-18 14:29:44 +02:00
github-actions[bot]
3c2125ab82 release v1.0.1.1 2024-10-18 12:28:27 +00:00
rlauuzo
5bc8913668
analyze movies (#348)
* scan movies

* Update ConfusedPolarBear.Plugin.IntroSkipper.csproj

* fix

* Update SegmentProvider.cs

* fix

* update

* add movies to endpoints

* Update

* Update QueueManager.cs

* revert

* Update configPage.html

Battery died. I’ll be back

* “Borrow” show config to hide seasons

* Add IsMovie to ShowInfos

* remove unused usings

* Add option to enable/disble movies

* Use the left episode as movie editor

* Timestamp erasure for movies

* Add max credits duration for movies

* Formatting and button style cleanup

* remove fingerprint timings for movies

* remove x2 from MaximumCreditsDuration in blackframe analyzer

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update SegmentProvider.cs

* Update BaseItemAnalyzerTask.cs

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com>
Co-authored-by: TwistedUmbrellaX <twistedumbrella@gmail.com>
2024-10-18 14:15:09 +02:00
Kilian von Pflugk
f6c8fca28f ci: make github actions reusable 2024-10-18 12:56:41 +02:00
Kilian von Pflugk
7e65ad1685 ci: allow release of beta version 2024-10-17 17:39:03 +02:00
rlauu
fc830a5e6f Use primary constructor everywhere 2024-10-16 16:20:21 +02:00
rlauu
ca9a167ad5 Revert "Use Jellyfins MediaSegmentType (#344)"
This reverts commit 29ee3e0bc861d128f4f691d7eb8d159da28eab43.
2024-10-16 16:05:59 +02:00
rlauuzo
29ee3e0bc8
Use Jellyfins MediaSegmentType (#344)
* Use Jellyfins MediaSegmentType

* Use primary constructor

* fix autoskip

* fix skip button

* fix episodestate class

* Update configPage.html

* Update QueueManager.cs

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-10-16 14:47:20 +02:00
Kilian von Pflugk
73287b79a5 migrate own repo url (#345) 2024-10-16 14:40:10 +02:00
rlauu
b95a40b16e dont need send 2024-10-16 13:20:36 +02:00
rlauu
f14fd06b43 Update inject.js 2024-10-16 12:27:41 +02:00
rlauu
dc5fc951ed fix skip button 2024-10-16 11:11:42 +02:00
dependabot[bot]
ccabdaff19
ci(deps): bump github/codeql-action from 3.26.11 to 3.26.13 (#346)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.11 to 3.26.13.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](6db8d6351f...f779452ac5)

---
updated-dependencies:
- dependency-name: github/codeql-action
  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-10-14 11:45:39 -04:00
Kilian von Pflugk
c22fea2add ci: send the new manifest to cloudflare KV 2024-10-13 21:40:08 +02:00
Kilian von Pflugk
f5b3fdc324 ci: add jellyfin version 2024-10-13 21:26:27 +02:00
Kilian von Pflugk
92ce2fb704 pick only 10.10 PackageReferences 2024-10-13 14:51:12 +02:00
Kilian von Pflugk
44a121cb64 increase Version for 10.10 2024-10-13 13:50:41 +02:00
github-actions[bot]
8b06af0444 release v1.0.0.6 2024-10-12 16:26:16 +02:00
Kilian von Pflugk
0092b2a7bb
manifest.json 2024-10-12 14:38:28 +02:00
Kilian von Pflugk
a2c3409eff
remove 10.8 2024-10-12 14:16:28 +02:00
Kilian von Pflugk
0fbbcf9c84
use new manifest url 2024-10-12 14:15:50 +02:00
rlauu
3d8c08fcde do not collapse inline tag whitespace 2024-10-12 13:21:14 +02:00
TwistedUmbrellaX
50ab82a2e0
Non-destructive 10.10 config (#343) 2024-10-12 12:35:46 +02:00
rlauu
75352dd1c4 Remove the EDL option for the skip button since it's not working 2024-10-12 10:32:47 +02:00
rlauuzo
6c04f26407
Basic Media Segments (#341)
* Basic Media Segment

* add Condition based on env SOURCE_BUILD

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-10-11 21:08:12 +02:00
Kilian von Pflugk
71e8644483 ci: use beta jellyfins github version 2024-10-11 19:11:42 +02:00
rlauuzo
eb45e1f73a
Prettier Configpage (#337)
* Prettier Configpage

using https://github.com/prettier/prettier

* Update generateCheckboxList

---------

Co-authored-by: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com>
2024-10-11 18:01:16 +02:00
TwistedUmbrellaX
a81b367ffe
Hide player config when it’s locked by server (#329)
* add missing api

* Might as well get the client list, too

* Remove client setting when unavailable

---------

Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-10-11 10:11:22 -04:00
TwistedUmbrellaX
f07134cc55
All platforms ship with ffmpeg (#340)
* All platforms ship with ffmpeg

This is no longer necessary as a workaround for ffmpeg without chromaprint.

* Remove an obsolete reference

* Reset existing user setting to "true"
2024-10-10 11:38:47 -04:00
rlauuzo
49429b9ca4
No restart to enable autoscan (#338) 2024-10-10 15:49:18 +02:00
TwistedUmbrellaX
76c9d8013f
Update README.md 2024-10-10 04:42:51 -04:00
rlauu
fbe88e488d Formating 2024-10-09 19:03:17 +02:00
rlauuzo
3c02426532
Update AnalyzerHelper (#335)
* Update AnalyzerHelper

* single loop

* Update AnalyzerHelper.cs

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-10-09 18:33:41 +02:00
TwistedUmbrellaX
93f0bee301 Fix whitespace 2024-10-09 06:50:40 -04:00
github-actions[bot]
2f07e0acb7 release v1.0.0.5 2024-10-07 05:45:46 +00:00
rlauuzo
d3453ad937
Fix base directory
https://github.com/intro-skipper/intro-skipper/issues/332
2024-10-07 07:44:07 +02:00
github-actions[bot]
9ddac5c93c release v1.0.0.4 2024-10-06 20:31:39 +00:00
Kilian von Pflugk
2d3a7fd3f7 ci: npx is enough no install needed 2024-10-06 20:15:27 +02:00
dependabot[bot]
168b528d71
ci(deps): bump github/codeql-action from 3.26.10 to 3.26.11 (#331)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-06 19:06:43 +02:00
Kilian von Pflugk
27af7f29f4 ci: format github yml files 2024-10-06 19:03:15 +02:00
TwistedUmbrellaX
45afbed8b0
Remove Android Tv default in favor of mod 2024-10-06 10:04:28 -04:00
Kilian von Pflugk
50e1b739e1 ci: always use the latest Node LTS 2024-10-06 13:25:05 +02:00
TwistedUmbrellaX
c716eca03f
Swap the lock state from off to auto (#328)
Setting auto supersedes the client, but setting none allows the client to enable
2024-10-06 07:16:47 -04:00
Kilian von Pflugk
960ce1ff82
options: don't use type time (#305) 2024-10-06 10:13:30 +00:00
Kilian von Pflugk
486c9accf3 fix: can't save timestamps in Plugin Options 2024-10-05 22:55:15 +02:00
rlauuzo
6fb7d05dd7
return null if itemid is empty (#327)
also use IReadOnlyList

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-10-05 19:30:30 +02:00
Kilian von Pflugk
0841f1ba40
InjectSkipButton: try to read webVersion from jellyfin-web (#323)
* InjectSkipButton: try to read webVersion from jellyfin-web

* use correct regex

* use break

* compare against jellyfin version not the plugin version

* append Revision

* output if version match

* sound more urgent and tragic
2024-10-05 18:38:42 +02:00
rlauuzo
8a323144ed
Remove old Path (#326)
and clean up

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-10-05 18:15:48 +02:00
rlauuzo
bed71516ff
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-05 17:27:02 +02:00
Kilian von Pflugk
7c00fcdd1e better logs for InjectSkipButton 2024-10-05 13:17:30 +02:00
TwistedUmbrellaX
d21944d4da Mention gear icon in the description 2024-10-03 21:18:04 -04:00
github-actions[bot]
386b27fe38 release v1.0.0.3 2024-10-03 19:35:14 +00:00
TwistedUmbrellaX
423eb0a886 Don't leave margin when hidden 2024-10-03 13:12:48 -04:00
TwistedUmbrellaX
e732a71de3
Hide skip button settings when disabled (#321)
* Hide skip button settings when disabled

* let's include everyone
2024-10-03 12:58:12 -04:00
rlauuzo
af2f61b06c
Reduce autoskip delay at start of episode (#316) 2024-10-02 19:19:59 -04:00
TwistedUmbrellaX
3e84d5f80f
Clear up some confusion about auto skip (#317)
* Link Auto, Button, and Client List

* Clarify the client list priority by placement
2024-10-02 19:13:17 -04:00
rlauuzo
4f1ce3041a
Improve Anime Detection (#320)
* Improve Anime Detection
2024-10-02 14:10:42 +02:00
dependabot[bot]
3bce9a4370
ci(deps): bump github/codeql-action from 3.26.8 to 3.26.10 (#319)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.8 to 3.26.10.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](294a9d9291...e2b3eafc8d)

---
updated-dependencies:
- dependency-name: github/codeql-action
  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-10-01 14:31:17 -04:00
dependabot[bot]
ad4215f7a0
chore(deps): bump xunit from 2.9.1 to 2.9.2 (#318)
Bumps [xunit](https://github.com/xunit/xunit) from 2.9.1 to 2.9.2.
- [Commits](https://github.com/xunit/xunit/compare/v2-2.9.1...v2-2.9.2)

---
updated-dependencies:
- dependency-name: xunit
  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-09-30 17:46:33 +02:00
github-actions[bot]
458f092e55 release v1.0.0.2 2024-09-29 19:49:55 +00:00
rlauuzo
f4fd66e26e
Handle in-season specials as part of the season (#314)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-09-29 19:46:46 +00:00
rlauuzo
d2dc8daaed
Add warning and hide option when injection fails (#315)
* Add warning and hide option when injection fails

* Update configPage.html

* Update configPage.html

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-09-29 14:35:46 -04:00
Kilian von Pflugk
2ed22338e7 ci: switch to our github action 2024-09-28 17:42:46 +02:00
Kilian von Pflugk
fb6fb9ccf9 ci: run codeql only in public repos 2024-09-28 00:59:05 +02:00
Kilian von Pflugk
e313fc8cf8 ci: run the valid version step only in public repos 2024-09-28 00:41:06 +02:00
Kilian von Pflugk
e3962a1fc1 ci: remove old release if exits in test repo 2024-09-27 23:08:11 +02:00
Kilian von Pflugk
04315e7ad8 ci: update bug report as well 2024-09-27 22:00:09 +02:00
Kilian von Pflugk
aaff7a2be7 ci: use the nuget api to get latest ABI 2024-09-27 22:00:02 +02:00
TwistedUmbrellaX
12c89c320d
Update bug_report_form.yml 2024-09-27 14:45:53 -04:00
TwistedUmbrellaX
7bf52e3319
Restore tracked version in README.md 2024-09-27 14:42:56 -04:00
TwistedUmbrellaX
c2563028d6
Reduce priority of legacy redirect 2024-09-27 09:46:38 -04:00
TwistedUmbrellaX
12a3c137a1
Drop direct link to invalid version
There are multiple notes about getting the latest build already, so that is “general troubleshooting”
2024-09-27 09:44:54 -04:00
TwistedUmbrellaX
f46d799bd8
Update bug_report_form.yml 2024-09-27 08:41:27 -04:00
TwistedUmbrellaX
4a571fc963
Shorten points to promote reading 2024-09-27 08:40:43 -04:00
TwistedUmbrellaX
9ef1f639ac
Can’t put links in the checkbox text 2024-09-27 08:29:37 -04:00
TwistedUmbrellaX
4b1f7aa49e
Future proof the bug report form 2024-09-27 08:21:14 -04:00
TwistedUmbrellaX
4db22ffc4a
Always have the latest Jellyfin version 2024-09-27 07:04:29 -04:00
rlauu
53d838e880 Update TestAudioFingerprinting.cs 2024-09-25 18:50:41 +02:00
rlauu
5beaf35198 cleanup 2024-09-25 17:23:25 +02:00
Kilian von Pflugk
5e08381ed5
make it clear that 10.8 is not the current version 2024-09-25 00:00:03 +00:00
TwistedUmbrellaX
c3e918ca42
Update README.md Troubleshooting 2024-09-24 17:19:57 -04:00
rlauu
1a8a0c799f Update CleanCacheTask.cs 2024-09-23 20:14:59 +02:00
rlauu
9ff742013b clean up semaphore 2024-09-23 19:45:16 +02:00
rlauuzo
ad9d36ebd6
Update Entrypoint.cs 2024-09-23 18:48:26 +02:00
Kilian von Pflugk
133808eb35
cleanup IgnoreListSeason after switching to ids (#307) 2024-09-23 16:47:43 +00:00
dependabot[bot]
ed7e26d63e
chore(deps): bump xunit from 2.9.0 to 2.9.1 (#310)
Bumps [xunit](https://github.com/xunit/xunit) from 2.9.0 to 2.9.1.
- [Commits](https://github.com/xunit/xunit/compare/2.9.0...2.9.1)

---
updated-dependencies:
- dependency-name: xunit
  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-09-23 11:23:28 -04:00
Kilian von Pflugk
eaee1ac842
sort numbers & build the season string in the frontend (#306) 2024-09-22 11:30:35 +00:00
Kilian von Pflugk
f83a32e7dd
update jellyfin version 2024-09-22 10:05:17 +00:00
Kilian von Pflugk
473f6c4d23 remove version 2024-09-21 22:57:44 +02:00
rlauuzo
e829f52acc
use correct library name (#301)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-09-21 20:12:00 +02:00
github-actions[bot]
9f72647591 release v1.0.0.1 2024-09-21 17:10:42 +00:00
Kilian von Pflugk
f0527fa888 inject git hash directly 2024-09-21 18:48:10 +02:00
Kilian von Pflugk
002f82d6e6 cleanup and organize 2024-09-21 18:36:11 +02:00
Kilian von Pflugk
2e092543f5
options: work with Ids instead of names and much improved UI (#299)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: rlauuzo <46294892+rlauuzo@users.noreply.github.com>
2024-09-21 16:06:11 +00:00
TwistedUmbrellaX
2473f20d40
Add discord link to README 2024-09-21 08:26:34 -04:00
TwistedUmbrellaX
94959ae469
Update README.md TOC 2024-09-20 17:52:26 -04:00
theMasterpc
e677ddb3a1
fixes (#298) 2024-09-20 09:50:25 -04:00
Kilian von Pflugk
e4810a9583 prepare for 1.0 2024-09-20 13:44:15 +02:00
Kilian von Pflugk
59c46b4c00
end beta status
next version will be 1.0
2024-09-20 11:38:40 +00:00
github-actions[bot]
081dab9518 release v0.2.0.22 2024-09-20 11:27:27 +00:00
theMasterpc
d867ede882
add ignore list (#281)
* add block list

* better block list system.
todo:
-change the name blocklist.
-fixing small bugs.
-maybe moving from Dictionary to List<BlockListItem>.

* - moving from ConcurrentDictionary to List<BlackListItem>, for better xml file.
- changing block to black.
- small fixes.

todo:
- maybe changing the blacklist naming.

* moving to ignorelist.
moving the blacklisting to Manage Fingerprints.
changing the object BlackListItem.

todo:
- moving to the naming "ignorelist", instead of "blacklist".
- adding "save for series" button.
- improving the ui of the blacklist section".
- fixing some more bugs.
- changing the "Manage Fingerprints" to "Manage Timestamps & Fingerprints".

* adding the option to apply ignorelist changes into a series.
moving to ignorelist naming.
changing "Manage Fingerprints" to "Manage Timestamps & Fingerprints".
improving the ui of the ignorelist editor

* small fixes

* fix some bugs. improving the ignore feature

* fix some stuff

* Refactor CSS styles for ignore list checkboxes

* small fixes

* small changes

* small changes

* big changes

* small fixes

* Refactor IgnoreListItem to use SeasonId instead of Id

* Refactor IgnoreListItem to use SeasonId instead of Id.

changes to the ExecuteAsync function and to its documentation
2024-09-20 07:18:04 -04:00
rlauuzo
06da138a17
Fix Advanced Settings box width (#297) 2024-09-19 19:55:05 +02:00
dependabot[bot]
1e6ae7ab48
ci(deps): bump github/codeql-action from 3.26.6 to 3.26.8 (#295)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 11:50:57 +00:00
Kilian von Pflugk
201125d003 fix dependabot builds 2024-09-19 13:31:00 +02:00
Kilian von Pflugk
bedc5bc3d0 change urls after transfer repo to the new org 2024-09-19 13:06:37 +02:00
TwistedUmbrellaX
cd857b1db2
Customize the checkbox labels (#293)
Co-authored-by: rlauuzo <46294892+rlauuzo@users.noreply.github.com>
2024-09-17 18:40:47 +00:00
rlauuzo
b6231417da
Add SelectAllLibraries option (#292)
* Add SelectAllLibraries option

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-09-17 08:41:56 +02:00
TwistedUmbrellaX
08145e0731
Fix inconsistency in library selection UI (#289)
* Fix inconsistency in library selection UI

* Additional details are still below the input

* Fix wording to include mixed libraries

* Make the manifest a copy paste block
2024-09-16 07:02:56 -04:00
TwistedUmbrellaX
5028101fe2
Restore manifest for quick access 2024-09-15 21:13:38 -04:00
theMasterpc
e7aafd9dca
fix mixed library doesnt show in checkbox (#288)
* fix mixed library wont show

* now it works

* Refactor library selection UI to include more options

* small fix

* another small fix

* adding a line between the more checkbox.
small fixes

* fixes

* fixing, when there isn't any libraries in the "more" section show regular checkbox list

* Update configPage.html

* returning to the second commit, with some changes, now using "Library/VirtualFolders"

* small fix
2024-09-15 17:51:29 -04:00
TwistedUmbrellaX
7a8fded38f
More minor cleanup of headings 2024-09-15 12:02:20 -04:00
TwistedUmbrellaX
b567286b40
Fixed overlapping names and a typo 2024-09-15 11:09:27 -04:00
github-actions[bot]
798188643e release v0.2.0.21 2024-09-14 16:56:14 +00:00
rlauuzo
72e59f273a
Use Checkboxes to select libraries (#287)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-09-14 16:52:39 +00:00
Kilian von Pflugk
2438ba79f2
migrate Intro to Segment and make it generic (#268) 2024-09-12 10:37:47 +02:00
TwistedUmbrellaX
92d2a55c81
Fix broken link to Mac extras 2024-09-10 16:52:02 -04:00
Kilian von Pflugk
f430f4364e point url to the new wiki 2024-09-10 20:29:18 +02:00
rlauuzo
60c735282e
apply auto-fixes from VS Code (#283)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-09-10 18:08:42 +02:00
rlauuzo
d428efb1f2
select autoskip clients (#277)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-09-09 18:52:54 +00:00
TwistedUmbrellaX
899d5e1914
Restore installation subtopics 2024-09-09 14:46:59 -04:00
TwistedUmbrellaX
362962a8ed
README navigation using Wiki 2024-09-09 14:41:25 -04:00
dependabot[bot]
c45b2b4428
chore(deps): bump Microsoft.NET.Test.Sdk from 17.11.0 to 17.11.1 (#279)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kilian von Pflugk <github@jumoog.io>
2024-09-09 18:24:20 +00:00
MasterR3C0RD
35e4de6405
Update command in README to fix permissions on Ubuntu/Debian (#280) 2024-09-09 18:20:48 +00:00
TwistedUmbrellaX
4b732261b1
Update bug_report_form.yml 2024-09-09 13:41:48 -04:00
rlauuzo
4b9dafe3c4
Update webui 10.9.11 2024-09-08 10:45:26 +02:00
Kilian von Pflugk
155d9f8874
update targetAbi 10.9.11 2024-09-07 22:22:57 +00:00
theMasterpc
9d6ce7ed08
fixing the description for Automatically skip intros (#276) 2024-09-07 21:23:43 +00:00
github-actions[bot]
38c3433b5f release v0.2.0.20 2024-09-05 18:11:30 +00:00
rlauuzo
1f17792bc6
Add user settings to configure skip behavior on client site (alternative to #261) (#263)
* Update inject.js

* Update inject.js

* Update inject.js

* Update inject.js

* Update inject.js

* Update inject.js

* Update inject.js

* update

Update inject.js

---------

Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-09-03 16:55:06 -04:00
rlauuzo
b7889c44c1
Use configuration Release (#270) 2024-09-02 18:46:48 +00:00
github-actions[bot]
296aa0da76 release v0.2.0.19 2024-09-02 14:07:30 +00:00
Faheem Pervez
1c8b3ccbe1
Make auto skip work with Kodi (#269)
* Make auto skip work with Kodi

* Apply change to credits as well

---------

Co-authored-by: TwistedUmbrellaX <twistedumbrella@gmail.com>
2024-09-02 08:10:21 -04:00
github-actions[bot]
0685b343ec release v0.2.0.18 2024-09-01 16:39:14 +00:00
Kilian von Pflugk
ef59b789bf
undo 0.2.0.17 update 2024-09-01 18:38:07 +02:00
Kilian von Pflugk
7860910f3c undo namespace change for DataContract and migrate already changed xml files 2024-09-01 18:34:25 +02:00
Kilian von Pflugk
2f9471ad01 Revert "this changed after namespace change"
This reverts commit 7dc20cff0df59583ec9cd1860e77117db9208113.
2024-09-01 18:18:49 +02:00
Kilian von Pflugk
1b6ca78e5c only store relevant info in the xml and make it a lot smaller 2024-09-01 18:09:55 +02:00
dependabot[bot]
1cd4451c31
ci(deps): bump github/codeql-action from 3.26.5 to 3.26.6 (#267)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-01 18:08:45 +02:00
Kilian von Pflugk
f22b19dc70 make Autoskip less noisy 2024-09-01 16:56:33 +02:00
Kilian von Pflugk
7dc20cff0d this changed after namespace change 2024-09-01 16:08:05 +02:00
Kilian von Pflugk
bd1b71d390 extend regular expression for ending credit chapters 2024-09-01 14:00:58 +02:00
github-actions[bot]
83778eb734 release v0.2.0.17 2024-08-31 21:11:43 +00:00
rlauuzo
c4e890a9ac
Enhance Chromaprint Accuracy Using Chapters (#203)
* Use Helper Class and Limit silence scanning to the relevant time range only

* Include the timerange in the filename

* Update AnalyzerHelper.cs

* Update AnalyzerHelper.cs

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-08-31 19:32:37 +02:00
Kilian von Pflugk
55ee501cf5
check if intro skip button is build-in and skip injecting (#240)
Co-authored-by: rlauuzo <46294892+rlauuzo@users.noreply.github.com>
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-08-31 19:08:42 +02:00
Kilian von Pflugk
25fd56d83c use correct namespace 2024-08-31 18:56:48 +02:00
Kilian von Pflugk
88003edb21
automatic skip only for Apps without Skip Button (#208) 2024-08-31 18:48:31 +02:00
dependabot[bot]
1a13ef1a37
ci(deps): bump github/codeql-action from 3.26.3 to 3.26.5 (#258)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.3 to 3.26.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](883d8588e5...2c779ab0d0)

---
updated-dependencies:
- dependency-name: github/codeql-action
  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-08-27 19:08:44 +00:00
Kilian von Pflugk
5a74a2b3a4 CI: generate release notes 2024-08-27 20:58:55 +02:00
Kilian von Pflugk
9c2e3c6449
update targetAbi 10.9.10 2024-08-25 19:12:24 +00:00
Kilian von Pflugk
3fff1ba7d0 cleanup manifest.json
only keep entries with the highest and second highest targetAbi
2024-08-23 23:26:48 +02:00
Kilian von Pflugk
799f0ad63b CI: only keep entries with the highest and second highest targetAbi 2024-08-23 23:09:33 +02:00
dependabot[bot]
6348040293
chore(deps): bump Microsoft.NET.Test.Sdk from 17.10.0 to 17.11.0 (#253)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-20 18:23:40 +00:00
dependabot[bot]
17b97bd094
ci(deps): bump github/codeql-action from 3.25.15 to 3.26.3 (#252)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-20 18:23:20 +00:00
dependabot[bot]
7367a38b48
ci(deps): bump actions/upload-artifact from 4.3.4 to 4.3.6 (#251)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-20 18:23:02 +00:00
github-actions[bot]
9cb440065d release v0.2.0.16 2024-08-19 19:41:56 +00:00
rlauuzo
4ef9ea89ad
Apply "MaximumIntroDuration" exclusively to intros 2024-08-19 20:14:02 +02:00
Kilian von Pflugk
05513f1e24
update issue report
10.9.9
2024-08-18 13:59:56 +00:00
Kilian von Pflugk
e0046e95e2 CI: update the Jellyfin Version in Readme 2024-08-15 23:53:41 +02:00
Kilian von Pflugk
8ac4afa11d CI: remove unnecessary URL check 2024-08-15 23:22:08 +02:00
Kilian von Pflugk
67efab0726 CI: changelog point to release 2024-08-15 21:30:47 +02:00
Kilian von Pflugk
8afa083b39 CI: move Generate manifest keys into JS part 2024-08-15 20:42:14 +02:00
Kilian von Pflugk
6720f43298 CI: always use the latest LTS Version 2024-08-15 19:48:12 +02:00
Kilian von Pflugk
1bb6fcde06 CI: use buffer direct and exit immediately on success 2024-08-15 19:41:15 +02:00
rlauuzo
fbd542b920
Fix issue with Edit field not functioning correctly in Chrome 2024-08-14 20:53:19 +02:00
Kilian von Pflugk
6170f3886f
update minimum Jellyfin Version to 10.9.9 2024-08-10 13:56:22 +02:00
github-actions[bot]
09ec2c4adf release v0.2.0.15 2024-08-10 11:54:27 +00:00
Kilian von Pflugk
92aa5fef88
add option to get storage usage per library (#242)
See how much space each library uses.
2024-08-10 12:25:06 +02:00
Kilian von Pflugk
bf5402d2bd
update targetAbi 10.9.9 2024-08-08 18:57:08 +00:00
rlauuzo
8bd5dba338
Fix active element 2024-08-07 12:25:15 +02:00
rlauuzo
0118007cf2
Update inject.js 2024-08-06 19:16:31 +02:00
rlauuzo
182fe9f831
Update inject.js 2024-08-06 18:36:33 +02:00
Kilian von Pflugk
e8fcdf53de
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-02 13:41:03 +00:00
rlauuzo
50ea8955d6
Update inject.js 2024-08-02 10:36:56 +02:00
dependabot[bot]
82cabf7fc0
ci(deps): bump github/codeql-action from 3.25.11 to 3.25.15 (#236)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-01 18:45:22 +02:00
Foowy
1ab97f8515
Corrected spelling in Manage Fingerprints (#232) 2024-07-31 14:42:02 +02:00
github-actions[bot]
3ad42e7d53 release v0.2.0.14 2024-07-30 19:47:06 +00:00
rlauuzo
c0708528f0
Inject Timestamps into Episode Details (#224)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-07-30 21:30:41 +02:00
Kilian von Pflugk
08ccca266f append version to script URL at script injection time
This will force the browser to refresh its cache.
2024-07-30 21:22:09 +02:00
Sannidhya
907ecd65ce
fix: skip button icon (#228) 2024-07-30 18:45:58 +02:00
Kilian von Pflugk
11e648ffbb InjectSkipButton error: refer to the troubleshooting guide 2024-07-29 20:35:46 +02:00
Kilian von Pflugk
dc3ab7f123 check if episodes exists and update function docs 2024-07-28 19:38:09 +02:00
Kilian von Pflugk
7391bada8b only return timestamps for episodes 2024-07-28 18:53:23 +02:00
Kilian von Pflugk
a7db712acf
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-07-27 21:11:01 +00:00
rlauuzo
d7ce2bdb6a
Add Additional Visual Indicator When Hovering Over Button 2024-07-25 17:09:05 +02:00
Kilian von Pflugk
e1cb17a126
update minimum Jellyfin Version to 10.9.8 2024-07-24 14:20:24 +02:00
github-actions[bot]
b7dd4c5591 release v0.2.0.13 2024-07-24 12:19:42 +00:00
Kilian von Pflugk
e38c9154f9
update targetAbi 10.9.8 2024-07-24 14:17:47 +02:00
rlauuzo
96dc3a9763
Refactor inject.js (#216) 2024-07-24 12:56:12 +02:00
Kilian von Pflugk
29f79e8f84 add Upload artifacts back 2024-07-23 21:35:42 +02:00
Kilian von Pflugk
532aa76b19
fix broken link 2024-07-21 12:28:42 +02:00
rlauuzo
07db2e683d
Update Troubleshooting (#219) 2024-07-21 12:27:13 +02:00
TwistedUmbrellaX
06ac00ac4a
Relative link was still a bit long 2024-07-20 09:57:51 -04:00
TwistedUmbrellaX
d5db12fa6a
Update bug_report_form.yml 2024-07-20 09:56:09 -04:00
Kilian von Pflugk
5c50918a9a
remove old versions 2024-07-17 22:13:28 +00:00
dependabot[bot]
bb77f03824
chore(deps): bump xunit from 2.8.1 to 2.9.0 (#214)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-15 19:56:40 +02:00
dependabot[bot]
ff3fc31217
chore(deps): bump xunit.runner.visualstudio from 2.8.1 to 2.8.2 (#215)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-15 19:55:34 +02:00
Kilian von Pflugk
fbc0dbf45e cleanup GitHub action 2024-07-15 13:06:36 +02:00
github-actions[bot]
f90f55492b release v0.2.0.12 2024-07-15 10:29:32 +00:00
rlauuzo
a13733bdad
Update visualizer.js 2024-07-13 18:13:20 +02:00
rlauuzo
009917b2e1
Fix seek issue and improve button animation smoothness (#209) 2024-07-07 20:42:45 +00:00
rlauuzo
4081c72edd
increase timeout 2024-07-07 07:37:56 +02:00
rlauuzo
c7ed471d54
Start the next episode directly (#205) 2024-07-02 18:11:31 +00:00
dependabot[bot]
805405e00a
ci(deps): bump github/codeql-action from 3.25.8 to 3.25.11 (#207)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 16:20:39 +00:00
Kilian von Pflugk
38d4a65544
update Jellyfin Version 10.9.7 2024-06-28 19:08:02 +00:00
github-actions[bot]
63cb0506a9 release v0.2.0.11 2024-06-28 19:07:21 +00:00
Kilian von Pflugk
6f127ca7bf
update targetAbi 10.9.7 2024-06-28 19:05:45 +00:00
rlauu
7fa73a1d3d Correctly Dispose Entrypoint Resources 2024-06-28 17:06:41 +02:00
rlauuzo
b9b9e88765
Refactor ChapterAnalyzer: Unify intro and credits detection loop (#204) 2024-06-26 17:31:18 +02:00
rlauuzo
bcd302045b
Fix Skip Button Position After Up Next Dialog 2024-06-15 17:44:38 +02:00
Jam
bcca1f2466
Updated README to reflect new release (#201) 2024-06-15 17:42:31 +02:00
Kilian von Pflugk
c854c14901 update targetAbi 10.9.6 2024-06-15 13:28:01 +02:00
Kilian von Pflugk
bd43fe3ce5
new version needs 10.9.6 2024-06-15 11:25:39 +00:00
github-actions[bot]
fa756dcabb release v0.2.0.10 2024-06-15 11:22:25 +00:00
rlauuzo
9388f2a583
refactor item queue (#183)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-06-15 11:16:47 +00:00
rlauuzo
ddecb15a51
Clean Cache Task to Remove Unused Files (#195) 2024-06-15 10:57:20 +02:00
rlauuzo
a9cdaf66b0
Update FFmpegWrapper.cs (#200) 2024-06-14 11:11:04 -04:00
Kilian von Pflugk
508ab9897f fix for #197 2024-06-12 16:29:07 +02:00
rlauuzo
0c305d1bd7
Update README.md 2024-06-12 14:27:39 +02:00
rlauuzo
c9f87c58cf
Delete Cache (#192)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-06-07 17:09:48 +02:00
dependabot[bot]
afe6534ef2
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 11:37:42 +02:00
Kilian von Pflugk
57a1cdf920 update targetAbi for next release 2024-06-02 12:28:50 +02:00
Kilian von Pflugk
723c8b7d3c
remove old versions 2024-06-01 17:52:02 +02:00
github-actions[bot]
804068bb3b release v0.2.0.9 2024-06-01 15:47:34 +00:00
dependabot[bot]
ddd538c296
ci(deps): bump github/codeql-action from 3.25.4 to 3.25.7 (#191)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-01 17:23:01 +02:00
rlauuzo
50529c4a0b
Allow the intro skip button to be confirmed with the Enter key (#189)
* allow the intro skip button to be confirmed with the Enter key

tested with LG webOS

* add some comments

* Update inject.js (#173)

* Update inject.js

* Update inject.js

---------

Co-authored-by: Kilian von Pflugk <github@jumoog.io>
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-06-01 14:15:30 +02:00
Kilian von Pflugk
87272075ed
point to the correct bug 2024-05-30 13:27:27 +00:00
TwistedUmbrellaX
6b0684681d
Add a few common issues to the forms 2024-05-29 18:01:04 -04:00
Kilian von Pflugk
b661c702cc
remove old 10.8 releases
if you need them you can stil can find them here https://raw.githubusercontent.com/jumoog/intro-skipper/10.8/manifest.json
2024-05-29 21:53:56 +00:00
TwistedUmbrellaX
7aa79f96fe
Typo from typing on an iPad 2024-05-29 17:51:03 -04:00
TwistedUmbrellaX
aeba272ea8
Descriptions for logging stuff 2024-05-29 17:48:15 -04:00
Kilian von Pflugk
965f5100ee build: do delete and create in one step 2024-05-27 19:46:45 +02:00
Kilian von Pflugk
977913ffc8 replace most GitHub Actions with build functions 2024-05-27 19:33:51 +02:00
Kilian von Pflugk
b06ab6e41f update test project NuGet packages 2024-05-27 18:33:13 +02:00
Kilian von Pflugk
8ffe765037
the official Android app has a skip button 2024-05-27 18:28:06 +02:00
Kilian von Pflugk
32f1da6561
update targetAbi to latest jellyfin version 2024-05-27 18:24:05 +02:00
Kilian von Pflugk
03155befe4
change tragetAbi
0.2.0.8 needs Jellyfin 10.9.3
2024-05-27 18:22:52 +02:00
TwistedUmbrellaX
0b9ba93e1b
Update README.md 2024-05-27 11:21:04 -04:00
github-actions[bot]
07ee967d38 release v0.2.0.8 2024-05-27 08:27:45 +00:00
Kilian von Pflugk
d670378e8d
add official applications to the declaration
we are often asked about this, so add it to the readme file
2024-05-26 20:40:54 +00:00
Kilian von Pflugk
11be944e76 rephrase cache fingerprints warning 2024-05-26 22:16:25 +02:00
Kilian von Pflugk
9974e840bb delete broken xml files
There is no other way to recover from this state. If the cache still exists, rebuilding the files shouldn't take long.
2024-05-26 13:09:35 +02:00
Kilian von Pflugk
68582ede98 auto increment Version 2024-05-26 01:23:38 +02:00
Kilian von Pflugk
1973dee1f8 only validate new release 2024-05-26 01:11:38 +02:00
github-actions[bot]
2cf9f4fb99 release v0.2.0.7 2024-05-24 19:27:00 +00:00
rlauuzo
9b2954f16d
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-24 19:06:07 +02:00
rlauuzo
9af0e3f864
Meet minimum credit requirement 2024-05-23 18:07:31 +02:00
Kilian von Pflugk
9962d3bd4b
update version 2024-05-22 21:23:36 +00:00
Kilian von Pflugk
b24a7ed4f4 test adjust values for blackframes 2024-05-19 17:49:52 +02:00
Kilian von Pflugk
20eba27132 adjust unit test values after switching from 0.128 to 0.1238 2024-05-19 17:49:52 +02:00
Kilian von Pflugk
0eb980a067 bump version during release 2024-05-19 13:17:08 +02:00
Kilian von Pflugk
9b66123bb2 validate new version/manifest and update on release 2024-05-19 00:57:11 +02:00
Kilian von Pflugk
aaf1162b0e
output json for manifest 2024-05-18 21:34:33 +00:00
Pomee4
4f327e55aa
Fixed typo in manifest.json (#171) 2024-05-18 21:21:47 +00:00
Kilian von Pflugk
2f341be0c9
release v0.2.0.6 2024-05-18 22:49:00 +02:00
Kilian von Pflugk
46f280f0af v0.2.0.6
revert autoskip until its fixed for LG
2024-05-18 22:43:05 +02:00
Kilian von Pflugk
bd5eb83ea0 Revert "allow the intro skip button to be confirmed with the Enter key (#170)"
This reverts commit 1dbb80ec283557b8877ad03444e3d11df11557d8.
2024-05-18 22:43:05 +02:00
Kilian von Pflugk
3776d8e07e
release v0.2.0.5 2024-05-18 20:31:04 +02:00
Kilian von Pflugk
2c6f045071 v0.2.0.5 2024-05-18 20:26:10 +02:00
Kilian von Pflugk
1dbb80ec28
allow the intro skip button to be confirmed with the Enter key (#170) 2024-05-18 20:21:39 +02:00
rlauuzo
fc805da85e
Update Css (#167) 2024-05-18 20:05:04 +02:00
TwistedUmbrellaX
092d3f6d45
Fix a branch translation typo 2024-05-18 11:18:35 -04:00
Kilian von Pflugk
fe5d83a942 Revert "AutoSkipCredits don't seek use NextTrack"
This reverts commit bae53323feb0a5894f9c52a13218b9b293eea0bb.
2024-05-18 14:21:10 +02:00
Kilian von Pflugk
5bcc874122
release v0.2.0.4 2024-05-16 17:51:19 +00:00
Kilian von Pflugk
f1d3499116 v0.2.0.4 2024-05-16 19:43:18 +02:00
Cloud9Developer
0577126cb8
Fixed Skip button not displaying
when jellyfin context root/path is set (#165)
2024-05-16 17:23:35 +00:00
rlauuzo
fa22495be7
handle disposed token (#163)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-05-16 17:22:22 +00:00
TwistedUmbrellaX
48bfec8d32 Movin on up (movin on up) #166 2024-05-16 18:24:36 +02:00
TwistedUmbrellaX
445607235b Improve the PR artifact structure 2024-05-16 05:48:34 -04:00
Cloud9Developer
ef789c63f7
updated scheduled task href URI in settings (#162) 2024-05-14 10:49:30 +02:00
Kilian von Pflugk
b5f5c004ad use MediaBrowser.Common.Api 2024-05-13 23:50:51 +02:00
Kilian von Pflugk
bae53323fe AutoSkipCredits don't seek use NextTrack 2024-05-13 16:19:52 +02:00
Kilian von Pflugk
bbfc9e71a3 port AutoSkipCredits forward 2024-05-13 15:29:49 +02:00
Kilian von Pflugk
805a799de7 fix AutoSkip SendPlaystateCommand 2024-05-13 15:28:15 +02:00
Kilian von Pflugk
dd7a81410e restore 10.9 changes 2024-05-13 00:02:18 +02:00
Kilian von Pflugk
196dd4c30c apply 10.8 css changes
- No use optimizing conditionally
- Add animation on skipButton entry and exit
2024-05-12 23:59:41 +02:00
Kilian von Pflugk
a98b9c7a37
release 0.2.0.3 2024-05-12 21:26:46 +02:00
Kilian von Pflugk
8f60bc2948 v0.2.0.3 2024-05-12 20:47:46 +02:00
Kilian von Pflugk
4be60f2e08 check that intro.xml/credits.xml exits before moving 2024-05-12 20:31:35 +02:00
Kilian von Pflugk
57680f25cd
add 10.8 Instructions 2024-05-12 18:05:19 +00:00
Kilian von Pflugk
6765b282cd
build use nuget 2024-05-11 20:11:25 +00:00
Kilian von Pflugk
f0b8fb4fea
10.9 use nuget 2024-05-11 20:09:29 +00:00
Kilian von Pflugk
b83c258859
release v0.2.0.2
ready for 10.9
2024-05-11 19:50:35 +00:00
dependabot[bot]
1af591c4a4
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-11 13:59:46 -04:00
dependabot[bot]
f3d22c0d23
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-11 19:57:40 +02:00
Kilian von Pflugk
c3fe64eafa
add rlauuzo 2024-05-11 13:38:46 +02:00
rlauuzo
03af05c3c6
change chromaprint offset, reorder analyzers, improve prompt timing (#153) 2024-05-10 14:05:59 +02:00
Kilian von Pflugk
291fdebbb2
v0.2.0.2 2024-05-10 10:46:41 +02:00
rlauuzo
605c71f4aa
Refactor ScheduledTaskSemaphore (#148) 2024-05-10 06:35:52 +02:00
Kilian von Pflugk
482f573ecb
v0.1.18.0 2024-05-09 23:51:48 +02:00
rlauuzo
7ea26e8fea
Ensure no duplicate Ids are permitted 2024-05-08 16:42:56 +02:00
rlauuzo
fe60457091
Only add episodes without intro/credit to episodesWithoutIntros (#145) 2024-05-08 16:27:41 +02:00
rlauuzo
9d4cb0a4ec
Replace Dictionary + locks with ConcurrentDictionary (#143) 2024-05-08 16:27:16 +02:00
dependabot[bot]
e0aca4785a
chore(deps): bump xunit.runner.visualstudio from 2.5.8 to 2.8.0 (#135)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-05 12:04:51 +00:00
dependabot[bot]
d5ed7be323
chore(deps): bump xunit from 2.7.1 to 2.8.0 (#134)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-05 12:03:47 +00:00
Kilian von Pflugk
71bf5a2a68
Update bug_report_form.yml
add IMDb Id to bugreport template
2024-05-04 14:56:52 +02:00
rlauuzo
c20370c47f
fix negative index 2024-05-01 18:16:07 +02:00
TwistedUmbrellaX
c4c50ae79f
Merge pull request #139 from jumoog/dependabot/github_actions/github/codeql-action-3.25.3
ci(deps): bump github/codeql-action from 3.24.9 to 3.25.3
2024-05-01 11:21:15 -04:00
TwistedUmbrellaX
552a905596
Merge pull request #140 from jumoog/dependabot/github_actions/actions/upload-artifact-4.3.3
ci(deps): bump actions/upload-artifact from 4.3.1 to 4.3.3
2024-05-01 11:20:54 -04:00
rlauuzo
1e16ac4f7e
find the first credits chapter marker (#138) 2024-05-01 17:13:34 +02:00
dependabot[bot]
c40b537beb
ci(deps): bump actions/upload-artifact from 4.3.1 to 4.3.3
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.1 to 4.3.3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.3.1...v4.3.3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-01 15:11:23 +00:00
dependabot[bot]
f2680a3735
ci(deps): bump github/codeql-action from 3.24.9 to 3.25.3
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.24.9 to 3.25.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](1b1aada464...d39d31e687)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-01 15:11:20 +00:00
rlauuzo
920fa34a43
Change GetItem to return null to handle nullable LibraryManager (#137) 2024-05-01 13:45:57 +02:00
rlauuzo
c4fbfd43ab
adaptive black frame search range (#132) 2024-05-01 11:13:13 +02:00
rlauuzo
da37638a85
Merge pull request #133 from rlauuzo/reverse-credit-fingerprints
Use reversed fingerprints for credits
2024-05-01 11:10:25 +02:00
rlauuzo
8ccccd349c
Use reversed fingerprints for credits 2024-04-26 15:06:41 +02:00
Kilian von Pflugk
1bae72f764
add Jellyfin Media Analyzer to ACKNOWLEDGEMENTS
give Jellyfin Media Analyzer credit
2024-04-20 23:24:36 +00:00
rlauuzo
8e23df523b
Refactor BaseItemAnalyzerTask and add Edl support for credits (#123)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
Co-authored-by: TwistedUmbrellaX <twistedumbrella@gmail.com>
Co-authored-by: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com>
2024-04-20 21:12:04 +02:00
rlauuzo
1394926a3c
Update Plugin.cs 2024-04-20 16:23:47 +02:00
rlauuzo
a9bf646421
No more wasteful XML writes
update only the XML file corresponding to the analyzed mode
2024-04-20 16:21:20 +02:00
Kilian von Pflugk
12bed0560c
update versions for 10.9 2024-04-20 15:02:12 +02:00
Kilian von Pflugk
699483e0be use a local variable 2024-04-20 13:36:04 +02:00
Kilian von Pflugk
4108afebdb remove unused minimumSilence variable 2024-04-20 13:27:16 +02:00
Kilian von Pflugk
0e635d724c remove unused variable 2024-04-20 13:04:56 +02:00
Kilian von Pflugk
2eb6a873a8 more cleanup 2024-04-20 12:58:29 +02:00
Kilian von Pflugk
8ca0212db7 fix QueueLibraryContents 2024-04-20 12:37:20 +02:00
Kilian von Pflugk
451fc8a511 code cleanup 2024-04-20 12:36:38 +02:00
Kilian von Pflugk
169e08047c remove redundant qualifier 2024-04-20 12:22:34 +02:00
Kilian von Pflugk
58f4617ebc remove redundant suppression 2024-04-20 12:21:07 +02:00
TwistedUmbrellaX
28689a917a Ignores have to be applied by *event* 2024-04-19 21:44:10 -04:00
rlauuzo
13a317dce1
Bugfix-FFmpegWrapper (#125)
Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com>
2024-04-19 19:21:06 +00:00
TwistedUmbrellaX
a07111d5e1 Clear restrictions when overriding auto 2024-04-19 14:59:13 -04:00
TwistedUmbrellaX
141156dad7 Library scan should supersede add or modify (#117) 2024-04-19 14:54:12 -04:00
TwistedUmbrellaX
6cc69fc0ee 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-19 14:53:59 -04:00
TwistedUmbrellaX
26d9c76d2f Don't auto build for documentation 2024-04-19 14:51:01 -04:00
TwistedUmbrellaX
edd4652d8a
Merge pull request #122 from jtormalm/master
Add animation on skipButton entry and exit
2024-04-18 11:05:47 -04:00
Jakob Tormalm
3960fa779e Add animation on skipButton entry and exit 2024-04-17 21:21:25 +02:00
rlauuzo
ad3e0c4c08
Change task from being canceled to waiting (#118) 2024-04-16 18:19:05 +02:00
Kilian von Pflugk
d288a2b5cf 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:14:00 +02:00
dependabot[bot]
636630deb7
chore(deps): bump xunit.runner.visualstudio from 2.5.7 to 2.5.8 (#120)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-15 16:50:11 +00:00
dependabot[bot]
5f9ab3f04e
chore(deps): bump xunit from 2.7.0 to 2.7.1 (#121)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-15 16:49:02 +00:00
TwistedUmbrellaX
cd32952055 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:43 -04:00
TwistedUmbrellaX
2442e579e0 Add options for adjusting credits length 2024-04-13 14:11:49 -04:00
TwistedUmbrellaX
91240ff437
I think we've earned this one 2024-04-13 10:59:43 -04:00
TwistedUmbrellaX
bbf96ce5a7
Merge pull request #114 from rlauuzo/master
Add cancellation of automatic tasks from scheduled tasks
2024-04-13 10:54:55 -04:00
rlauu
676215c08f Make scheduled tasks cancel automatic tasks 2024-04-13 16:34:58 +02:00
TwistedUmbrellaX
630016aae6
Update configPage.html
The wording was accurate, but made me think a minute. That usually means it will confuse someone eventually.
2024-04-13 10:03:12 -04:00
Kilian von Pflugk
914fdbc124
bug report better log view 2024-04-12 18:45:27 +02:00
Kilian von Pflugk
06939ff330 use the ConfigurationChanged directly
this also fixes a restart issue
2024-04-12 14:09:19 +02:00
Kilian von Pflugk
8ef5f9760f Revert "0.2.0.1"
This reverts commit 4b57816f3200508a7e0552084b5e264b9ac3160d.
2024-04-12 11:26:13 +02:00
Kilian von Pflugk
4b57816f32
0.2.0.1 2024-04-12 11:16:57 +02:00
Kilian von Pflugk
1a8fbda49d
0.2.0.1 2024-04-12 11:13:23 +02:00
Kilian von Pflugk
4647a4ae84 better xml migration strategy
run only once, remove xmlns:xsi and xmlns:xsd
2024-04-11 21:59:17 +02:00
rlauuzo
e37de35445
Make Entrypoint run at start (#109)
* Update Entrypoint.cs

* Update PluginServiceRegistrator.cs
2024-04-11 16:58:37 +02:00
TwistedUmbrellaX
36afd6ce53 Grant GitHub access to ...GitHub
Why shouldn't the least secure process be the one to verify the security?
2024-04-10 23:14:16 -04:00
TwistedUmbrellaX
144a272115 Add permission to read packages 2024-04-10 22:54:21 -04:00
TwistedUmbrellaX
e02edf23c1 Remove regularly scheduled runs 2024-04-10 22:46:46 -04:00
Kilian von Pflugk
0af916f418
add 10.9 beta warning 2024-04-10 21:18:22 +00:00
Kilian von Pflugk
fde4a67130
remove old releases
only keep recent version + 1 old
2024-04-10 21:16:03 +00:00
TwistedUmbrellaX
4fa73d45fd Switch to stable actions versions 2024-04-10 14:40:29 -04:00
TwistedUmbrellaX
74f3acf979 NuGet should target repo, not commit
PRs would virtually never pass because the nuget configuration would run against the individual user
2024-04-10 09:53:10 -04:00
TwistedUmbrellaX
56af5d0776 Grant repo permission to CodeQL 2024-04-10 09:44:36 -04:00
TwistedUmbrellaX
3e34de254c Add a replacement CodeQL action 2024-04-10 09:36:05 -04:00
rlauuzo
ef95f3ec25
Update Entrypoint.cs (#107)
* Update Entrypoint.cs

* Update Entrypoint.cs
2024-04-10 09:13:37 -04:00
TwistedUmbrellaX
235e8f6494 Let's hope the bugs are fixed
Restoring the manifest with the assumption that whatever was causing the 10.9 versions to overwrite 10.8 has been worked out now
2024-04-10 09:12:36 -04:00
rlauu
0b30761fb0 Auto-Detection Configuration (#106)
* Update Entrypoint.cs

* Update Entrypoint.cs

* Update Entrypoint.cs

* Update Entrypoint.cs
2024-04-10 08:09:59 -04:00
TwistedUmbrellaX
2dd3f32aab Split progress bar for split tasks
This replaces the jarring effect of resetting the bar halfway through two concurrent scans.
2024-04-10 08:09:03 -04:00
rlauu
2c3eafd146 Update DetectIntrosCreditsTask.cs
reset progress
2024-04-10 08:08:52 -04:00
TwistedUmbrellaX
9df0d0b846 Shorten the task explanations 2024-04-10 08:08:39 -04:00
TwistedUmbrellaX
2f05cb70e5 Add back separate tasks as optional 2024-04-10 08:08:23 -04:00
rlauu
10e942ab7d add options to disable scans of either intros or credits 2024-04-10 08:06:57 -04:00
rlauu
93cd2a8a0d Merge Scheduled Tasks 2024-04-10 08:06:43 -04:00
rlauu
057352b212 analyze when item is added to the server (#96) 2024-04-10 08:06:23 -04:00
TwistedUmbrellaX
5d31b02de5 Fix typo in build output details 2024-04-10 07:42:50 -04:00
TwistedUmbrellaX
58272d1eb8 Drop incompatible versions for 10.9 2024-04-10 07:27:32 -04:00
TwistedUmbrellaX
90e19650be Fix the actions message filter 2024-04-10 07:26:03 -04:00
Kilian von Pflugk
c1a1b8b013
whoops 2024-04-10 11:44:27 +02:00
Kilian von Pflugk
963ba319a2
fix v0.2.0.0 2024-04-10 11:42:53 +02:00
Kilian von Pflugk
6b7dce5943
new 0.1.16.5 release for 10.8 and change version to 0.2 for 10.9 2024-04-10 11:33:55 +02:00
Kilian von Pflugk
488f178521 bump 10.9 to v0.2.0.0 2024-04-10 11:25:18 +02:00
TwistedUmbrellaX
7449da4ef5 Realign current build with master 2024-04-09 22:08:39 -04:00
TwistedUmbrellaX
3187b872b9 Print a copy paste manifest string 2024-04-04 09:39:39 -04:00
TwistedUmbrellaX
82b43a58ec Fix some button text running together 2024-04-02 21:09:41 -04:00
TwistedUmbrellaX
54e3a79c08 We want to keep the release notes 2024-03-31 16:08:44 -04:00
Kilian von Pflugk
71d59b5ab9 first 10.9 release (beta) 2024-03-31 16:08:44 -04:00
TwistedUmbrellaX
3d8df74d08 Let's stop logging every single step 2024-03-31 16:08:32 -04:00
Kilian von Pflugk
7af8ef7110 Revert "Update README.md for 10.9"
This reverts commit 65c6f804a36e1516af1d7b69f93d9417f201bed4.
2024-03-31 16:08:32 -04:00
Kilian von Pflugk
0f0a394895 10.9: 0.1.16.4 2024-03-31 13:15:14 +02:00
Kilian von Pflugk
d4cdff82c3 more elegant way to replace the namespace declaration 2024-03-31 13:07:55 +02:00
Kilian von Pflugk
5b3f850854 target 10.9 unstable 2024-03-29 20:53:24 +01:00
TwistedUmbrellaX
9642c6d40b update GitHub actions 2024-03-29 20:53:24 +01:00
Kilian von Pflugk
ef1e3f59c7 migrate from XMLSchema to DataContract 2024-03-29 20:53:24 +01:00
Kilian von Pflugk
5d743302e4 Target 10.9.z 2024-03-29 20:53:19 +01:00
138 changed files with 7647 additions and 4861 deletions

View File

@ -192,3 +192,5 @@ csharp_space_between_method_call_empty_parameter_list_parentheses = false
# Wrapping preferences
csharp_preserve_single_line_statements = 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]: "
labels: [bug]
body:
- type: checkboxes
id: requirements
attributes:
label: Self service debugging
description: |
Jellyfin 10.9 is actively updated. Please make sure you are using the [latest](https://github.com/jellyfin/jellyfin/releases/latest) release.
Many servers have permission issues that can be resolved with a few extra steps.
If your skip button is not shown, please see [Troubleshooting](https://github.com/intro-skipper/intro-skipper/wiki/Troubleshooting#skip-button-is-not-visible) before reporting.
options:
- label: I use Jellyfin 10.10.3 (or newer) and my permissions are correct
required: true
- type: textarea
attributes:
label: Describe the bug
@ -22,28 +34,38 @@ body:
- type: input
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.
validations:
required: true
- type: input
attributes:
label: Container image and tag
description: Only fill in this field if you are running Jellyfin in a container
placeholder: jellyfin/jellyfin:10.8.7, jellyfin-intro-skipper:latest, etc.
label: Container image/tag or Jellyfin version
description: The container for Docker or Jellyfin version for a native install
placeholder: jellyfin/jellyfin:10.9.9, jellyfin-intro-skipper:latest, etc.
validations:
required: true
- type: input
attributes:
label: Operating System
description: The operating system of the Jellyfin / Docker host computer
placeholder: Debian 11, Windows 11, etc.
validations:
required: true
- type: input
attributes:
label: IMDb ID of that TV Series
placeholder: tt0903747
- type: textarea
attributes:
label: Support Bundle
placeholder: go to Dashboard -> Plugins -> Intro Skipper -> Support Bundle (at the bottom of the page) and paste the contents of the textbox here
render: shell
validations:
required: true
@ -51,4 +73,4 @@ body:
attributes:
label: Jellyfin logs
placeholder: Paste any relevant logs here
render: shell

View File

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

View File

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

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

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

View File

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

41
.github/workflows/webui.yml vendored Normal file
View File

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

3
.gitignore vendored
View File

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

11
.vscode/extensions.json vendored Normal file
View File

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

9
.vscode/settings.json vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -1,34 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests;
using Xunit;
public class TestFlags
{
[Fact]
public void TestEmptyFlagSerialization()
{
WarningManager.Clear();
Assert.Equal("None", WarningManager.GetWarnings());
}
[Fact]
public void TestSingleFlagSerialization()
{
WarningManager.Clear();
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
Assert.Equal("UnableToAddSkipButton", WarningManager.GetWarnings());
}
[Fact]
public void TestDoubleFlagSerialization()
{
WarningManager.Clear();
WarningManager.SetFlag(PluginWarning.UnableToAddSkipButton);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
Assert.Equal(
"UnableToAddSkipButton, InvalidChromaprintFingerprint",
WarningManager.GetWarnings());
}
}

View File

@ -1,131 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.Logging;
/// <summary>
/// Media file analyzer used to detect end credits that consist of text overlaid on a black background.
/// Bisects the end of the video file to perform an efficient search.
/// </summary>
public class BlackFrameAnalyzer : IMediaFileAnalyzer
{
private readonly TimeSpan _maximumError = new(0, 0, 4);
private readonly ILogger<BlackFrameAnalyzer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="BlackFrameAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public BlackFrameAnalyzer(ILogger<BlackFrameAnalyzer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
ReadOnlyCollection<QueuedEpisode> analysisQueue,
AnalysisMode mode,
CancellationToken cancellationToken)
{
if (mode != AnalysisMode.Credits)
{
throw new NotImplementedException("mode must equal Credits");
}
var creditTimes = new Dictionary<Guid, Intro>();
foreach (var episode in analysisQueue)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var intro = AnalyzeMediaFile(
episode,
mode,
Plugin.Instance!.Configuration.BlackFrameMinimumPercentage);
if (intro is null)
{
continue;
}
creditTimes[episode.EpisodeId] = intro;
}
Plugin.Instance!.UpdateTimestamps(creditTimes, mode);
return analysisQueue
.Where(x => !creditTimes.ContainsKey(x.EpisodeId))
.ToList()
.AsReadOnly();
}
/// <summary>
/// Analyzes an individual media file. Only public because of unit tests.
/// </summary>
/// <param name="episode">Media file to analyze.</param>
/// <param name="mode">Analysis mode.</param>
/// <param name="minimum">Percentage of the frame that must be black.</param>
/// <returns>Credits timestamp.</returns>
public Intro? AnalyzeMediaFile(QueuedEpisode episode, AnalysisMode mode, int minimum)
{
var config = Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
// Start by analyzing the last N minutes of the file.
var start = TimeSpan.FromSeconds(config.MaximumEpisodeCreditsDuration);
var end = TimeSpan.FromSeconds(config.MinimumCreditsDuration);
var firstFrameTime = 0.0;
// Continue bisecting the end of the file until the range that contains the first black
// frame is smaller than the maximum permitted error.
while (start - end > _maximumError)
{
// Analyze the middle two seconds from the current bisected range
var midpoint = (start + end) / 2;
var scanTime = episode.Duration - midpoint.TotalSeconds;
var tr = new TimeRange(scanTime, scanTime + 2);
_logger.LogTrace(
"{Episode}, dur {Duration}, bisect [{BStart}, {BEnd}], time [{Start}, {End}]",
episode.Name,
episode.Duration,
start,
end,
tr.Start,
tr.End);
var frames = FFmpegWrapper.DetectBlackFrames(episode, tr, minimum);
_logger.LogTrace(
"{Episode} at {Start} has {Count} black frames",
episode.Name,
tr.Start,
frames.Length);
if (frames.Length == 0)
{
// Since no black frames were found, slide the range closer to the end
start = midpoint;
}
else
{
// Some black frames were found, slide the range closer to the start
end = midpoint;
firstFrameTime = frames[0].Time + scanTime;
}
}
if (firstFrameTime > 0)
{
return new(episode.EpisodeId, new TimeRange(firstFrameTime, episode.Duration));
}
return null;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Type of media file analysis to perform.
/// </summary>
public enum AnalysisMode
{
/// <summary>
/// Detect introduction sequences.
/// </summary>
Introduction,
/// <summary>
/// Detect credits.
/// </summary>
Credits,
}

View File

@ -1,28 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// A frame of video that partially (or entirely) consists of black pixels.
/// </summary>
public class BlackFrame
{
/// <summary>
/// Initializes a new instance of the <see cref="BlackFrame"/> class.
/// </summary>
/// <param name="percent">Percentage of the frame that is black.</param>
/// <param name="time">Time this frame appears at.</param>
public BlackFrame(int percent, double time)
{
Percentage = percent;
Time = time;
}
/// <summary>
/// Gets or sets the percentage of the frame that is black.
/// </summary>
public int Percentage { get; set; }
/// <summary>
/// Gets or sets the time (in seconds) this frame appeared at.
/// </summary>
public double Time { get; set; }
}

View File

@ -1,37 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL.
/// </summary>
public enum EdlAction
{
/// <summary>
/// Do not create EDL files.
/// </summary>
None = -1,
/// <summary>
/// Completely remove the intro from playback as if it was never in the original video.
/// </summary>
Cut,
/// <summary>
/// Mute audio, continue playback.
/// </summary>
Mute,
/// <summary>
/// Inserts a new scene marker.
/// </summary>
SceneMarker,
/// <summary>
/// Automatically skip the intro once during playback.
/// </summary>
CommercialBreak,
/// <summary>
/// Show a skip button.
/// </summary>
Intro,
}

View File

@ -1,30 +0,0 @@
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Episode name and internal ID as returned by the visualization controller.
/// </summary>
public class EpisodeVisualization
{
/// <summary>
/// Initializes a new instance of the <see cref="EpisodeVisualization"/> class.
/// </summary>
/// <param name="id">Episode id.</param>
/// <param name="name">Episode name.</param>
public EpisodeVisualization(Guid id, string name)
{
Id = id;
Name = name;
}
/// <summary>
/// Gets the id.
/// </summary>
public Guid Id { get; private set; }
/// <summary>
/// Gets the name.
/// </summary>
public string Name { get; private set; } = string.Empty;
}

View File

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

View File

@ -1,64 +0,0 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
/// <summary>
/// Support bundle warning.
/// </summary>
[Flags]
public enum PluginWarning
{
/// <summary>
/// No warnings have been added.
/// </summary>
None = 0,
/// <summary>
/// Attempted to add skip button to web interface, but was unable to.
/// </summary>
UnableToAddSkipButton = 1,
/// <summary>
/// At least one media file on the server was unable to be fingerprinted by Chromaprint.
/// </summary>
InvalidChromaprintFingerprint = 2,
/// <summary>
/// The version of ffmpeg installed on the system is not compatible with the plugin.
/// </summary>
IncompatibleFFmpegBuild = 4,
}
/// <summary>
/// Warning manager.
/// </summary>
public static class WarningManager
{
private static PluginWarning warnings;
/// <summary>
/// Set warning.
/// </summary>
/// <param name="warning">Warning.</param>
public static void SetFlag(PluginWarning warning)
{
warnings |= warning;
}
/// <summary>
/// Clear warnings.
/// </summary>
public static void Clear()
{
warnings = PluginWarning.None;
}
/// <summary>
/// Get warnings.
/// </summary>
/// <returns>Warnings.</returns>
public static string GetWarnings()
{
return warnings.ToString();
}
}

View File

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

View File

@ -1,89 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Server entrypoint.
/// </summary>
public class Entrypoint : IServerEntryPoint
{
private readonly IUserManager _userManager;
private readonly IUserViewManager _userViewManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<Entrypoint> _logger;
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="Entrypoint"/> class.
/// </summary>
/// <param name="userManager">User manager.</param>
/// <param name="userViewManager">User view manager.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="logger">Logger.</param>
/// <param name="loggerFactory">Logger factory.</param>
public Entrypoint(
IUserManager userManager,
IUserViewManager userViewManager,
ILibraryManager libraryManager,
ILogger<Entrypoint> logger,
ILoggerFactory loggerFactory)
{
_userManager = userManager;
_userViewManager = userViewManager;
_libraryManager = libraryManager;
_logger = logger;
_loggerFactory = loggerFactory;
}
/// <summary>
/// Registers event handler.
/// </summary>
/// <returns>Task.</returns>
public Task RunAsync()
{
FFmpegWrapper.Logger = _logger;
// TODO: when a new item is added to the server, immediately analyze the season it belongs to
// instead of waiting for the next task interval. The task start should be debounced by a few seconds.
try
{
// Enqueue all episodes at startup to ensure any FFmpeg errors appear as early as possible
_logger.LogInformation("Running startup enqueue");
var queueManager = new QueueManager(_loggerFactory.CreateLogger<QueueManager>(), _libraryManager);
queueManager.GetMediaItems();
}
catch (Exception ex)
{
_logger.LogError("Unable to run startup enqueue: {Exception}", ex);
}
return Task.CompletedTask;
}
/// <summary>
/// Dispose.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected dispose.
/// </summary>
/// <param name="dispose">Dispose.</param>
protected virtual void Dispose(bool dispose)
{
if (!dispose)
{
return;
}
}
}

View File

@ -1,10 +0,0 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// 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;
[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.WarningManager")]
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.IntroWithMetadata")]
[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Legacy TODO", Scope = "type", Target = "~T:ConfusedPolarBear.Plugin.IntroSkipper.TimeRangeHelpers")]

View File

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

View File

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

View File

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

View File

@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Analyze all television episodes for credits.
/// TODO: analyze all media files.
/// </summary>
public class DetectCreditsTask : IScheduledTask
{
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectCreditsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
public DetectCreditsTask(
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Credits";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes the audio and video of all television episodes to find credits.";
/// <summary>
/// Gets the task key.
/// </summary>
public string Key => "CPBIntroSkipperDetectCredits";
/// <summary>
/// Analyze all episodes in the queue. Only one instance of this task should be run at a time.
/// </summary>
/// <param name="progress">Task progress.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task.</returns>
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (_libraryManager is null)
{
throw new InvalidOperationException("Library manager was null");
}
var baseAnalyzer = new BaseItemAnalyzerTask(
AnalysisMode.Credits,
_loggerFactory.CreateLogger<DetectCreditsTask>(),
_loggerFactory,
_libraryManager);
baseAnalyzer.AnalyzeItems(progress, cancellationToken);
return Task.CompletedTask;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return Array.Empty<TaskTriggerInfo>();
}
}

View File

@ -1,92 +0,0 @@
using System;
using System.Collections.Generic;
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 DetectIntroductionsTask : IScheduledTask
{
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="DetectIntroductionsTask"/> class.
/// </summary>
/// <param name="loggerFactory">Logger factory.</param>
/// <param name="libraryManager">Library manager.</param>
public DetectIntroductionsTask(
ILoggerFactory loggerFactory,
ILibraryManager libraryManager)
{
_loggerFactory = loggerFactory;
_libraryManager = libraryManager;
}
/// <summary>
/// Gets the task name.
/// </summary>
public string Name => "Detect Introductions";
/// <summary>
/// Gets the task category.
/// </summary>
public string Category => "Intro Skipper";
/// <summary>
/// Gets the task description.
/// </summary>
public string Description => "Analyzes the audio of all television episodes to find introduction sequences.";
/// <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");
}
var baseAnalyzer = new BaseItemAnalyzerTask(
AnalysisMode.Introduction,
_loggerFactory.CreateLogger<DetectIntroductionsTask>(),
_loggerFactory,
_libraryManager);
baseAnalyzer.AnalyzeItems(progress, cancellationToken);
return Task.CompletedTask;
}
/// <summary>
/// Get task triggers.
/// </summary>
/// <returns>Task triggers.</returns>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(0).Ticks
}
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -17,7 +17,7 @@ function findIntros() {
// get the times of all similar fingerprint points
for (let i in fprDiffs) {
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.innerHTML = "";
const offset = Number(txtOffset.value) * 0.128;
const offset = Number(txtOffset.value) * 0.1238;
for (let r of ranges) {
let lStart, lEnd, rStart, rEnd;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper.Data;
/// <summary>
/// Exception raised when an error is encountered analyzing audio.

View File

@ -0,0 +1,53 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Runtime.Serialization;
namespace 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>
/// <remarks>
/// Initializes a new instance of the <see cref="Intro"/> class.
/// </remarks>
/// <param name="intro">intro.</param>
[DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper")]
public class Intro(Segment intro)
{
/// <summary>
/// Gets or sets the Episode ID.
/// </summary>
[DataMember]
public Guid EpisodeId { get; set; } = intro.EpisodeId;
/// <summary>
/// Gets a value indicating whether this introduction is valid or not.
/// Invalid results must not be returned through the API.
/// </summary>
public bool Valid => IntroEnd > 0;
/// <summary>
/// Gets or sets the introduction sequence start time.
/// </summary>
[DataMember]
public double IntroStart { get; set; } = intro.Start;
/// <summary>
/// Gets or sets the introduction sequence end time.
/// </summary>
[DataMember]
public double IntroEnd { get; set; } = intro.End;
/// <summary>
/// Gets or sets the recommended time to display the skip intro prompt.
/// </summary>
public double ShowSkipPromptAt { get; set; }
/// <summary>
/// Gets or sets the recommended time to hide the skip intro prompt.
/// </summary>
public double HideSkipPromptAt { get; set; }
}

View File

@ -0,0 +1,33 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
namespace IntroSkipper.Data;
/// <summary>
/// Support bundle warning.
/// </summary>
[Flags]
public enum PluginWarning
{
/// <summary>
/// No warnings have been added.
/// </summary>
None = 0,
/// <summary>
/// Attempted to add skip button to web interface, but was unable to.
/// </summary>
UnableToAddSkipButton = 1,
/// <summary>
/// At least one media file on the server was unable to be fingerprinted by Chromaprint.
/// </summary>
InvalidChromaprintFingerprint = 2,
/// <summary>
/// The version of ffmpeg installed on the system is not compatible with the plugin.
/// </summary>
IncompatibleFFmpegBuild = 4,
}

View File

@ -1,6 +1,9 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
namespace ConfusedPolarBear.Plugin.IntroSkipper;
namespace IntroSkipper.Data;
/// <summary>
/// Episode queued for analysis.
@ -22,6 +25,16 @@ public class QueuedEpisode
/// </summary>
public Guid EpisodeId { get; set; }
/// <summary>
/// Gets or sets the season id.
/// </summary>
public Guid SeasonId { get; set; }
/// <summary>
/// Gets or sets the series id.
/// </summary>
public Guid SeriesId { get; set; }
/// <summary>
/// Gets or sets the full path to episode.
/// </summary>
@ -32,6 +45,21 @@ public class QueuedEpisode
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether an episode is Anime.
/// </summary>
public bool IsAnime { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an item is a movie.
/// </summary>
public bool IsMovie { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an episode has been analyzed.
/// </summary>
public bool IsAnalyzed { get; set; }
/// <summary>
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
/// </summary>

View File

@ -0,0 +1,98 @@
// Copyright (C) 2024 Intro-Skipper contributors <intro-skipper.org>
// SPDX-License-Identifier: GPL-3.0-only.
using System;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
namespace 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>
[DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper.Segment")]
public class Segment
{
/// <summary>
/// Initializes a new instance of the <see cref="Segment"/> class.
/// </summary>
/// <param name="episode">Episode.</param>
/// <param name="segment">Introduction time range.</param>
public Segment(Guid episode, TimeRange segment)
{
EpisodeId = episode;
Start = segment.Start;
End = segment.End;
}
/// <summary>
/// Initializes a new instance of the <see cref="Segment"/> class.
/// </summary>
/// <param name="episode">Episode.</param>
public Segment(Guid episode)
{
EpisodeId = episode;
Start = 0.0;
End = 0.0;
}
/// <summary>
/// Initializes a new instance of the <see cref="Segment"/> class.
/// </summary>
/// <param name="intro">intro.</param>
public Segment(Segment intro)
{
EpisodeId = intro.EpisodeId;
Start = intro.Start;
End = intro.End;
}
/// <summary>
/// Initializes a new instance of the <see cref="Segment"/> class.
/// </summary>
/// <param name="intro">intro.</param>
public Segment(Intro intro)
{
EpisodeId = intro.EpisodeId;
Start = intro.IntroStart;
End = intro.IntroEnd;
}
/// <summary>
/// Initializes a new instance of the <see cref="Segment"/> class.
/// </summary>
public Segment()
{
}
/// <summary>
/// Gets or sets the Episode ID.
/// </summary>
[DataMember]
public Guid EpisodeId { get; set; }
/// <summary>
/// Gets or sets the introduction sequence start time.
/// </summary>
[DataMember]
public double Start { get; set; }
/// <summary>
/// Gets or sets the introduction sequence end time.
/// </summary>
[DataMember]
public double End { get; set; }
/// <summary>
/// Gets a value indicating whether this introduction is valid or not.
/// Invalid results must not be returned through the API.
/// </summary>
public bool Valid => End > 0.0;
/// <summary>
/// Gets the duration of this intro.
/// </summary>
[JsonIgnore]
public double Duration => End - Start;
}

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