From 8b1ac156f323cd2d98d14d7c616cfd891934bf77 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:34:44 +0200 Subject: [PATCH] fix(player): drop chapter-derived intro markers longer than three minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A movie whose first chapter is titled "Opening Credits" or "Introduction" got a Skip Intro button that skipped the whole chapter — several minutes of picture. Detected intros are short; only Plezy's own chapter-title fallback produced these. Cap chapter-derived intros at three minutes. Server-supplied markers and credits chapters are unaffected. close #2235 --- lib/media/media_source_info.dart | 12 ++++ test/media/media_source_info_test.dart | 81 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 6bbd5c109..f55b98530 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -376,11 +376,22 @@ class PlaybackExtras { return RegExp(source, caseSensitive: false); } + /// Longest chapter that may become a chapter-derived intro marker. + /// + /// Detected intros (Plex, Intro Skipper) fall well inside two minutes; a + /// movie's first chapter titled "Opening Credits" or "Introduction" runs + /// five to ten minutes of actual picture, and skipping it skips the film + /// (#2235). Applies only to markers minted here from chapter titles, never + /// to server-supplied markers, and never to credits, which are legitimately + /// long on movies. + static const maxChapterIntroDuration = Duration(minutes: 3); + /// Returns [PlaybackExtras] using real markers when available, filling any /// missing marker types from chapter titles matching intro/credits patterns. /// [forceChapterFallback] prefers chapter-derived markers for any type they /// provide. When real markers exist, reclassifies markers with unknown types /// against the patterns so non-standard type strings get recognized. + /// Chapter-derived intros longer than [maxChapterIntroDuration] are dropped. factory PlaybackExtras.withChapterFallback({ required List chapters, required List markers, @@ -411,6 +422,7 @@ class PlaybackExtras { final end = ch.endTimeOffset ?? (i + 1 < chapters.length ? chapters[i + 1].startTimeOffset : null); if (end == null) continue; + if (type == 'intro' && end - start > maxChapterIntroDuration.inMilliseconds) continue; synthetic.add(MediaMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end)); } diff --git a/test/media/media_source_info_test.dart b/test/media/media_source_info_test.dart index dc34ea958..2400e0af9 100644 --- a/test/media/media_source_info_test.dart +++ b/test/media/media_source_info_test.dart @@ -169,4 +169,85 @@ void main() { expect(extras.markers.single.startTimeOffset, 0); }); }); + + group('PlaybackExtras.withChapterFallback intro duration cap', () { + const cap = PlaybackExtras.maxChapterIntroDuration; + const movieEnd = 7200000; + + test('a movie-length "Opening Credits" chapter does not become an intro', () { + final extras = PlaybackExtras.withChapterFallback( + chapters: [ + MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 480000, title: 'Opening Credits'), + MediaChapter(id: 2, startTimeOffset: 480000, endTimeOffset: movieEnd, title: 'Chapter 2'), + ], + markers: [], + ); + + expect(extras.markers, isEmpty); + }); + + test('a chapter exactly at the cap still qualifies; one millisecond over does not', () { + final atCap = PlaybackExtras.withChapterFallback( + chapters: [ + MediaChapter(id: 1, startTimeOffset: 1000, endTimeOffset: 1000 + cap.inMilliseconds, title: 'Intro'), + ], + markers: [], + ); + final overCap = PlaybackExtras.withChapterFallback( + chapters: [ + MediaChapter(id: 1, startTimeOffset: 1000, endTimeOffset: 1001 + cap.inMilliseconds, title: 'Intro'), + ], + markers: [], + ); + + expect(atCap.markers.map((m) => m.type), ['intro']); + expect(overCap.markers, isEmpty); + }); + + test('the cap uses the next chapter start when the chapter has no end', () { + final extras = PlaybackExtras.withChapterFallback( + chapters: [ + MediaChapter(id: 1, startTimeOffset: 0, title: 'Opening'), + MediaChapter(id: 2, startTimeOffset: 600000, title: 'Chapter 2'), + ], + markers: [], + ); + + expect(extras.markers, isEmpty); + }); + + test('long credits chapters are still derived', () { + final extras = PlaybackExtras.withChapterFallback( + chapters: [ + MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 6600000, title: 'Chapter 1'), + MediaChapter(id: 2, startTimeOffset: 6600000, endTimeOffset: movieEnd, title: 'End Credits'), + ], + markers: [], + ); + + expect(extras.markers.map((m) => m.type), ['credits']); + expect(extras.markers.single.endTimeOffset, movieEnd); + }); + + test('server-supplied intro markers are never capped', () { + final extras = PlaybackExtras.withChapterFallback( + chapters: [], + markers: [MediaMarker(id: 1, type: 'intro', startTimeOffset: 0, endTimeOffset: 600000)], + ); + + expect(extras.markers.map((m) => m.type), ['intro']); + expect(extras.markers.single.endTimeOffset, 600000); + }); + + test('a dropped chapter intro leaves native markers intact under forced fallback', () { + final extras = PlaybackExtras.withChapterFallback( + chapters: [MediaChapter(id: 1, startTimeOffset: 0, endTimeOffset: 480000, title: 'Opening Credits')], + markers: [MediaMarker(id: 9, type: 'credits', startTimeOffset: 6600000, endTimeOffset: movieEnd)], + forceChapterFallback: true, + ); + + expect(extras.markers.map((m) => m.type), ['credits']); + expect(extras.markers.single.id, 9); + }); + }); }