From 20063e27f7a11bed19ac30dd321642fbee534a7d Mon Sep 17 00:00:00 2001 From: Capy Agent Date: Fri, 11 Sep 2026 12:50:23 +0000 Subject: [PATCH 1/2] Register SkipMe with compatible Intro Skipper analyzers --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- README.md | 31 ++- SkipMe.Db.Plugin.Tests/RegistrationTests.cs | 140 +++++++++++++ .../SegmentHandoverServiceTests.cs | 154 ++++++++++++++ .../SegmentProviderTests.cs | 196 ++++++++++++++++++ .../SegmentRefreshServiceTests.cs | 53 +++++ .../SkipMe.Db.Plugin.Tests.csproj | 19 ++ .../SyncSegmentsTaskTests.cs | 90 ++++++++ SkipMe.Db.Plugin.sln | 6 + SkipMe.Db.Plugin/Plugin.cs | 18 +- SkipMe.Db.Plugin/PluginServiceRegistrator.cs | 28 ++- SkipMe.Db.Plugin/Providers/SegmentProvider.cs | 18 +- .../Services/IntroSkipperRegistration.cs | 57 +++++ .../Services/SegmentHandoverService.cs | 64 ++++++ .../Services/SegmentRefreshService.cs | 63 ++++++ SkipMe.Db.Plugin/Services/SkipMeApiClient.cs | 4 +- SkipMe.Db.Plugin/SkipMe.Db.Plugin.csproj | 4 + SkipMe.Db.Plugin/Tasks/SyncSegmentsTask.cs | 37 +--- 19 files changed, 938 insertions(+), 48 deletions(-) create mode 100644 SkipMe.Db.Plugin.Tests/RegistrationTests.cs create mode 100644 SkipMe.Db.Plugin.Tests/SegmentHandoverServiceTests.cs create mode 100644 SkipMe.Db.Plugin.Tests/SegmentProviderTests.cs create mode 100644 SkipMe.Db.Plugin.Tests/SegmentRefreshServiceTests.cs create mode 100644 SkipMe.Db.Plugin.Tests/SkipMe.Db.Plugin.Tests.csproj create mode 100644 SkipMe.Db.Plugin.Tests/SyncSegmentsTaskTests.cs create mode 100644 SkipMe.Db.Plugin/Services/IntroSkipperRegistration.cs create mode 100644 SkipMe.Db.Plugin/Services/SegmentHandoverService.cs create mode 100644 SkipMe.Db.Plugin/Services/SegmentRefreshService.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 21291f2..db6c44c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore SkipMe.Db.Plugin.sln diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b243b5b..788f6d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,7 +103,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore SkipMe.Db.Plugin.sln diff --git a/README.md b/README.md index 5ebdf98..99c7e4e 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,38 @@ To populate the local segment database immediately: 1. Go to Dashboard -> Scheduled Tasks. 2. Run `Sync SkipMe.db Segment Database`. -3. After a successful sync, the plugin queues Jellyfin's media segment scan so - Jellyfin can pick up the new timestamps. +3. After a successful sync, the plugin queues Intro Skipper's segment detection + when a compatible Intro Skipper is installed, or Jellyfin's media segment scan + when running standalone. By default, the sync task runs weekly on Sunday at 1:00 AM. +## Intro Skipper Integration + +When Intro Skipper exposes the compatible SkipMe integration API, SkipMe.db +automatically supplies its local timestamps to Intro Skipper instead of registering +as a separate Jellyfin media segment provider. Intro Skipper treats available +SkipMe timestamps as authoritative and publishes the resulting segments. Saving +SkipMe's enable/disable settings also queues Intro Skipper detection. + +The Sync and Share tabs and the SkipMe.db API remain available in both modes. +Series, season, movie, specials, and existing per-library SkipMe.db exclusions +remain respected by the integrated source. In integrated mode, enable Intro +Skipper as the library's Jellyfin segment provider; SkipMe.db no longer appears as +a separate provider. If Intro Skipper is absent or incompatible, SkipMe.db keeps +its standalone provider behavior. Restart Jellyfin after installing or removing +either plugin to reevaluate the integration. + +Once Jellyfin finishes startup, integrated mode queues initial Intro Skipper +detection and removes legacy SkipMe-owned rows from Jellyfin's segment database. +Jellyfin already hides these rows when the standalone provider is no longer +registered. The local SkipMe database and other providers' segments are untouched. +Cleanup failures are logged and retried; Intro Skipper publishes replacement +segments asynchronously according to its own mirroring settings. + ## Enabling, Disabling, and Priority -Jellyfin controls media segment providers per library. +Jellyfin controls media segment providers per library. For standalone SkipMe.db: 1. Navigate to Dashboard -> Libraries -> Libraries. 2. Open the desired library menu (`...`) -> Manage library. @@ -110,6 +134,7 @@ Build the plugin: npm ci --prefix web dotnet restore SkipMe.Db.Plugin.sln dotnet build SkipMe.Db.Plugin.sln --configuration Release --no-restore +dotnet test SkipMe.Db.Plugin.sln --configuration Release --no-build ``` The web settings UI is built automatically during the .NET build and embedded in diff --git a/SkipMe.Db.Plugin.Tests/RegistrationTests.cs b/SkipMe.Db.Plugin.Tests/RegistrationTests.cs new file mode 100644 index 0000000..a77c7bf --- /dev/null +++ b/SkipMe.Db.Plugin.Tests/RegistrationTests.cs @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 Intro Skipper contributors +// SPDX-License-Identifier: GPL-3.0-only + +using System.Reflection; +using System.Reflection.Emit; +using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; +using SkipMe.Db.Plugin.Providers; +using SkipMe.Db.Plugin.Services; +using Xunit; + +namespace SkipMe.Db.Plugin.Tests; + +public sealed class RegistrationTests +{ + [Fact] + public void AbsentHostPreservesStandaloneProvider() + { + var services = CreateServices(); + PluginServiceRegistrator.RegisterServices(services, []); + + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IMediaSegmentProvider)); + Assert.DoesNotContain(services, descriptor => descriptor.ImplementationType == typeof(SegmentHandoverService)); + using var provider = services.BuildServiceProvider(); + Assert.False(provider.GetRequiredService().IsIntegrated); + } + + [Theory] + [InlineData("OtherPlugin", "RegisterV1", false)] + [InlineData("IntroSkipper", "RegisterV2", false)] + [InlineData("IntroSkipper", "RegisterV1", true)] + public void IncompatibleHostPreservesStandaloneProvider(string assemblyName, string methodName, bool returnsValue) + { + var services = CreateServices(); + var assembly = BuildHost((_, _) => throw new InvalidOperationException("Must not be invoked"), assemblyName, methodName, returnsValue); + PluginServiceRegistrator.RegisterServices(services, [assembly]); + + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IMediaSegmentProvider)); + using var provider = services.BuildServiceProvider(); + Assert.False(provider.GetRequiredService().IsIntegrated); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CompatibleHostRegistersOnlyConcreteProviderRegardlessOfRegistratorOrder(bool hostFirst) + { + var services = CreateServices(); + var calls = 0; + var assembly = BuildHost((collection, factory) => + { + calls++; + collection.AddSingleton>(factory); + }); + if (hostFirst) + { + services.AddSingleton(new HostService()); + } + + PluginServiceRegistrator.RegisterServices(services, [assembly]); + PluginServiceRegistrator.RegisterServices(services, [assembly]); + if (!hostFirst) + { + services.AddSingleton(new HostService()); + } + + Assert.Equal(1, calls); + Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IMediaSegmentProvider)); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(SegmentProvider)); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IHostedService) && descriptor.ImplementationType == typeof(SegmentHandoverService)); + var segmentProvider = new SegmentProvider(null!, null!, null!); + services.AddSingleton(segmentProvider); + using var provider = services.BuildServiceProvider(); + Assert.True(provider.GetRequiredService().IsIntegrated); + Assert.Same(segmentProvider, provider.GetRequiredService>()(provider)); + Assert.NotNull(provider.GetRequiredService()); + } + + [Fact] + public void FailedHostRegistrationDoesNotLeavePartialServices() + { + var services = CreateServices(); + var assembly = BuildHost((collection, _) => + { + collection.Clear(); + collection.AddSingleton(new HostService()); + throw new InvalidOperationException("Incompatible host"); + }); + PluginServiceRegistrator.RegisterServices(services, [assembly]); + + Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(HostService)); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(SegmentStore)); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IMediaSegmentProvider)); + } + + [Fact] + public void PluginHasNoIntroSkipperAssemblyReference() + { + Assert.DoesNotContain(typeof(Plugin).Assembly.GetReferencedAssemblies(), assembly => assembly.Name == "IntroSkipper"); + } + + private static ServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Mock.Of()); + return services; + } + + private static Assembly BuildHost( + Action> callback, + string assemblyName = "IntroSkipper", + string methodName = "RegisterV1", + bool returnsValue = false) + { + var assembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(assemblyName), AssemblyBuilderAccess.RunAndCollect); + var type = assembly.DefineDynamicModule("Host").DefineType("IntroSkipper.Integrations.SkipMeIntegration", TypeAttributes.Public | TypeAttributes.Sealed); + var callbackType = callback.GetType(); + var field = type.DefineField("Callback", callbackType, FieldAttributes.Public | FieldAttributes.Static); + var method = type.DefineMethod(methodName, MethodAttributes.Public | MethodAttributes.Static, returnsValue ? typeof(bool) : typeof(void), [typeof(IServiceCollection), typeof(Func)]); + var il = method.GetILGenerator(); + il.Emit(OpCodes.Ldsfld, field); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldarg_1); + il.Emit(OpCodes.Callvirt, callbackType.GetMethod("Invoke")!); + if (returnsValue) + { + il.Emit(OpCodes.Ldc_I4_1); + } + + il.Emit(OpCodes.Ret); + type.CreateType()!.GetField("Callback")!.SetValue(null, callback); + return assembly; + } + + private sealed class HostService; +} diff --git a/SkipMe.Db.Plugin.Tests/SegmentHandoverServiceTests.cs b/SkipMe.Db.Plugin.Tests/SegmentHandoverServiceTests.cs new file mode 100644 index 0000000..5af1929 --- /dev/null +++ b/SkipMe.Db.Plugin.Tests/SegmentHandoverServiceTests.cs @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2026 Intro Skipper contributors +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Locking; +using MediaBrowser.Controller; +using MediaBrowser.Model.Tasks; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SkipMe.Db.Plugin.Services; +using Xunit; + +namespace SkipMe.Db.Plugin.Tests; + +public sealed class SegmentHandoverServiceTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + private readonly Mock> _factory = new(); + private readonly Mock _host = new(); + private readonly Mock _tasks = SegmentRefreshServiceTests.CreateTaskManager("IntroSkipperDetectSegmentsTask", "TaskExtractMediaSegments"); + private readonly DbContextOptions _options; + private readonly NoLockBehavior _locking = new(NullLogger.Instance); + + public SegmentHandoverServiceTests() + { + _connection.Open(); + var builder = new DbContextOptionsBuilder().UseSqlite(_connection); + _locking.Initialise(builder); + _options = builder.Options; + _factory.Setup(factory => factory.CreateDbContextAsync(It.IsAny())).Returns(() => Task.FromResult(CreateContext())); + using var context = CreateContext(); + context.Database.EnsureCreated(); + } + + [Fact] + public void OwnershipIdMatchesJellyfinsProviderNameHash() + { + var expected = new Guid(MD5.HashData(Encoding.Unicode.GetBytes("skipme.db"))).ToString("N", CultureInfo.InvariantCulture); + Assert.Equal(expected, SegmentHandoverService.SkipMeProviderId); + } + + [Fact] + public async Task RetirementDeletesOnlySkipMeOwnershipIncludingUnreplacedModes() + { + var itemId = Guid.NewGuid(); + var foreign = Row(itemId, "other-provider", MediaSegmentType.Intro); + var host = Row(itemId, "intro-skipper-provider", MediaSegmentType.Intro); + var rawName = Row(itemId, "SkipMe.db", MediaSegmentType.Intro); + await SeedAsync( + Row(itemId, SegmentHandoverService.SkipMeProviderId, MediaSegmentType.Intro), + Row(Guid.NewGuid(), SegmentHandoverService.SkipMeProviderId, MediaSegmentType.Outro), + foreign, + host, + rawName); + using var service = CreateService(); + + Assert.Equal(2, await service.RetireLegacySegmentsAsync(CancellationToken.None)); + Assert.Equal(0, await service.RetireLegacySegmentsAsync(CancellationToken.None)); + + using var context = CreateContext(); + Assert.Equal(new[] { foreign.Id, host.Id, rawName.Id }.Order(), context.MediaSegments.Select(segment => segment.Id).ToArray().Order()); + } + + [Fact] + public async Task FailedRetirementCanRetryWithoutDeletingForeignRows() + { + var foreign = Row(Guid.NewGuid(), "other-provider", MediaSegmentType.Intro); + await SeedAsync(Row(Guid.NewGuid(), SegmentHandoverService.SkipMeProviderId, MediaSegmentType.Intro), foreign); + _factory.SetupSequence(factory => factory.CreateDbContextAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Transient database failure")) + .ReturnsAsync(CreateContext()); + using var service = CreateService(); + + await Assert.ThrowsAsync(() => service.RetireLegacySegmentsAsync(CancellationToken.None)); + using (var beforeRetry = CreateContext()) + { + Assert.Equal(2, beforeRetry.MediaSegments.Count()); + } + + Assert.Equal(1, await service.RetireLegacySegmentsAsync(CancellationToken.None)); + using var context = CreateContext(); + Assert.Equal(foreign.Id, Assert.Single(context.MediaSegments).Id); + } + + [Fact] + public async Task StartupWaitsForJellyfinTaskInitializationBeforeHandover() + { + var observedStartup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready = false; + _host.SetupGet(host => host.CoreStartupHasCompleted).Returns(() => + { + var result = Volatile.Read(ref ready); + observedStartup.TrySetResult(); + return result; + }); + using var service = CreateService(); + + await service.StartAsync(CancellationToken.None); + await observedStartup.Task.WaitAsync(TimeSpan.FromSeconds(5)); + _tasks.Verify(tasks => tasks.QueueScheduledTask(It.IsAny(), It.IsAny()), Times.Never); + _factory.Verify(factory => factory.CreateDbContextAsync(It.IsAny()), Times.Never); + Volatile.Write(ref ready, true); + await service.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5)); + + _tasks.Verify(tasks => tasks.QueueScheduledTask(It.Is(task => task.Key == "IntroSkipperDetectSegmentsTask"), It.IsAny()), Times.Once); + _factory.Verify(factory => factory.CreateDbContextAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ShutdownBeforeStartupDoesNotQueueOrDelete() + { + using var service = CreateService(); + + await service.StartAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + + _tasks.Verify(tasks => tasks.QueueScheduledTask(It.IsAny(), It.IsAny()), Times.Never); + _factory.Verify(factory => factory.CreateDbContextAsync(It.IsAny()), Times.Never); + } + + public void Dispose() => _connection.Dispose(); + + private JellyfinDbContext CreateContext() => new(_options, NullLogger.Instance, Mock.Of(), _locking); + + private SegmentHandoverService CreateService() => new( + _host.Object, + _factory.Object, + new SegmentRefreshService(_tasks.Object, NullLogger.Instance, true), + NullLogger.Instance); + + private async Task SeedAsync(params MediaSegment[] segments) + { + using var context = CreateContext(); + context.MediaSegments.AddRange(segments); + await context.SaveChangesAsync(); + } + + private static MediaSegment Row(Guid itemId, string providerId, MediaSegmentType type) => new() + { + Id = Guid.NewGuid(), + ItemId = itemId, + Type = type, + StartTicks = 1000, + EndTicks = 2000, + SegmentProviderId = providerId + }; +} diff --git a/SkipMe.Db.Plugin.Tests/SegmentProviderTests.cs b/SkipMe.Db.Plugin.Tests/SegmentProviderTests.cs new file mode 100644 index 0000000..a93aeae --- /dev/null +++ b/SkipMe.Db.Plugin.Tests/SegmentProviderTests.cs @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2026 Intro Skipper contributors +// SPDX-License-Identifier: GPL-3.0-only + +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SkipMe.Db.Plugin.Configuration; +using SkipMe.Db.Plugin.Models; +using SkipMe.Db.Plugin.Providers; +using SkipMe.Db.Plugin.Services; +using Xunit; + +namespace SkipMe.Db.Plugin.Tests; + +public sealed class SegmentProviderTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "skipme-tests", Guid.NewGuid().ToString("N")); + private readonly Mock _library = new(); + private readonly Mock _tasks = SegmentRefreshServiceTests.CreateTaskManager("IntroSkipperDetectSegmentsTask", "TaskExtractMediaSegments"); + private readonly SegmentStore _store; + private readonly SegmentProvider _provider; + private readonly Mock _paths = new(); + private readonly Mock _serializer = new(); + private readonly Plugin _plugin; + + public SegmentProviderTests() + { + _paths.SetupGet(paths => paths.DataPath).Returns(_directory); + _paths.SetupGet(paths => paths.PluginsPath).Returns(_directory); + _paths.SetupGet(paths => paths.PluginConfigurationsPath).Returns(_directory); + _serializer.Setup(serializer => serializer.DeserializeFromFile(typeof(PluginConfiguration), It.IsAny())).Returns(new PluginConfiguration()); + _library.Setup(library => library.GetLibraryOptions(It.IsAny())).Returns(new LibraryOptions()); + BaseItem.LibraryManager = _library.Object; + _store = new SegmentStore(_paths.Object, NullLogger.Instance); + _provider = new SegmentProvider(_store, _library.Object, NullLogger.Instance); + _plugin = CreatePlugin(true); + } + + [Theory] + [InlineData("SkipMe.db")] + [InlineData("SKIPME.DB")] + public async Task LibraryDisabledProviderSuppressesStoredSegments(string disabledName) + { + var item = new Movie { Id = Guid.NewGuid() }; + await StoreAsync(item); + _library.Setup(library => library.GetLibraryOptions(item)).Returns(new LibraryOptions { DisabledMediaSegmentProviders = [disabledName] }); + + Assert.Empty(await ReadAsync(item.Id)); + Assert.NotEmpty(_store.GetSegments(item.Id)!); + } + + [Fact] + public async Task DisabledMovieSuppressesStoredSegments() + { + var item = new Movie { Id = Guid.NewGuid() }; + await StoreAsync(item); + _plugin.Configuration.DisabledMovieIds.Add(item.Id); + + Assert.Empty(await ReadAsync(item.Id)); + } + + [Fact] + public async Task DisabledSeriesSuppressesStoredSegments() + { + var series = new Series { Id = Guid.NewGuid() }; + var episode = new Episode { Id = Guid.NewGuid(), SeriesId = series.Id, ParentIndexNumber = 1, ParentId = Guid.NewGuid() }; + _library.Setup(library => library.GetItemById(series.Id)).Returns(series); + await StoreAsync(episode); + _plugin.Configuration.DisabledSeriesIds.Add(series.Id); + + Assert.Empty(await ReadAsync(episode.Id)); + } + + [Fact] + public async Task DisabledSeasonSuppressesStoredSegments() + { + var episode = CreateEpisode(1); + await StoreAsync(episode); + _plugin.Configuration.DisabledSeasonIds.Add(episode.ParentId); + + Assert.Empty(await ReadAsync(episode.Id)); + } + + [Fact] + public async Task SpecialsStayDisabledUntilExplicitlyEnabled() + { + var episode = CreateEpisode(0); + await StoreAsync(episode); + + Assert.Empty(await ReadAsync(episode.Id)); + _plugin.Configuration.EnabledSpecialsSeasonIds.Add(episode.ParentId); + Assert.Single(await ReadAsync(episode.Id)); + _plugin.Configuration.DisabledSeasonIds.Add(episode.ParentId); + Assert.Empty(await ReadAsync(episode.Id)); + } + + [Fact] + public async Task AllowedMoviePreservesRangesAndTypeMapping() + { + var item = new Movie { Id = Guid.NewGuid() }; + _library.Setup(library => library.GetItemById(item.Id)).Returns(item); + await _store.ReplaceAllAsync(new Dictionary> + { + [item.Id] = [ + new() { Type = "intro", StartMs = 1001, EndMs = 9009 }, + new() { Type = "credits", StartMs = 10000, EndMs = 20000 }, + new() { Type = "recap", StartMs = 10, EndMs = 100 }, + new() { Type = "preview", StartMs = 20001, EndMs = 30000 }, + new() { Type = "commercial", StartMs = 40000, EndMs = 50000 }, + new() { Type = "unknown", StartMs = 0, EndMs = 100 } + ] + }); + + var segments = await ReadAsync(item.Id); + + Assert.Equal(5, segments.Count); + var intro = Assert.Single(segments, segment => segment.Type == MediaSegmentType.Intro); + Assert.Equal(1001 * TimeSpan.TicksPerMillisecond, intro.StartTicks); + Assert.Equal(9009 * TimeSpan.TicksPerMillisecond, intro.EndTicks); + Assert.Contains(segments, segment => segment.Type == MediaSegmentType.Outro); + Assert.Contains(segments, segment => segment.Type == MediaSegmentType.Recap); + Assert.Contains(segments, segment => segment.Type == MediaSegmentType.Preview); + Assert.Contains(segments, segment => segment.Type == MediaSegmentType.Commercial); + } + + [Fact] + public async Task MissingItemAndMissingDataReturnNoSegments() + { + Assert.Empty(await ReadAsync(Guid.NewGuid())); + var item = new Movie { Id = Guid.NewGuid() }; + _library.Setup(library => library.GetItemById(item.Id)).Returns(item); + Assert.Empty(await ReadAsync(item.Id)); + } + + [Fact] + public async Task SupportsOnlyMoviesAndEpisodes() + { + Assert.True(await _provider.Supports(new Movie())); + Assert.True(await _provider.Supports(new Episode())); + Assert.False(await _provider.Supports(new Series())); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConfigurationChangesQueueDetectionOnlyWhenIntegrated(bool integrated) + { + var plugin = CreatePlugin(integrated); + + plugin.UpdateConfiguration(new PluginConfiguration()); + + _serializer.Verify(serializer => serializer.SerializeToFile(It.IsAny(), It.IsAny()), Times.Once); + _tasks.Verify(tasks => tasks.QueueScheduledTask(It.Is(task => task.Key == "IntroSkipperDetectSegmentsTask"), It.IsAny()), integrated ? Times.Once() : Times.Never()); + _tasks.Verify(tasks => tasks.QueueScheduledTask(It.Is(task => task.Key == "TaskExtractMediaSegments"), It.IsAny()), Times.Never); + } + + public void Dispose() + { + _store.Dispose(); + Directory.Delete(_directory, true); + } + + private Plugin CreatePlugin(bool integrated) => new( + _paths.Object, + _serializer.Object, + _library.Object, + new SegmentRefreshService(_tasks.Object, NullLogger.Instance, integrated)); + + private Episode CreateEpisode(int seasonNumber) + { + var series = new Series { Id = Guid.NewGuid() }; + _library.Setup(library => library.GetItemById(series.Id)).Returns(series); + return new Episode { Id = Guid.NewGuid(), SeriesId = series.Id, ParentIndexNumber = seasonNumber, ParentId = Guid.NewGuid() }; + } + + private Task StoreAsync(BaseItem item) + { + _library.Setup(library => library.GetItemById(item.Id)).Returns(item); + return _store.ReplaceAllAsync(new Dictionary> + { + [item.Id] = [new() { Type = "intro", StartMs = 1000, EndMs = 2000 }] + }); + } + + private Task> ReadAsync(Guid itemId) => + _provider.GetMediaSegments(new MediaSegmentGenerationRequest { ItemId = itemId, ExistingSegments = [] }, CancellationToken.None); +} diff --git a/SkipMe.Db.Plugin.Tests/SegmentRefreshServiceTests.cs b/SkipMe.Db.Plugin.Tests/SegmentRefreshServiceTests.cs new file mode 100644 index 0000000..cc725fc --- /dev/null +++ b/SkipMe.Db.Plugin.Tests/SegmentRefreshServiceTests.cs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Intro Skipper contributors +// SPDX-License-Identifier: GPL-3.0-only + +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SkipMe.Db.Plugin.Services; +using Xunit; + +namespace SkipMe.Db.Plugin.Tests; + +public sealed class SegmentRefreshServiceTests +{ + [Theory] + [InlineData(true, "IntroSkipperDetectSegmentsTask")] + [InlineData(false, "TaskExtractMediaSegments")] + public void RefreshQueuesOnlyTheActiveOwner(bool integrated, string expectedKey) + { + var taskManager = CreateTaskManager("IntroSkipperDetectSegmentsTask", "TaskExtractMediaSegments"); + var service = new SegmentRefreshService(taskManager.Object, NullLogger.Instance, integrated); + + service.QueueRefresh(); + + taskManager.Verify(manager => manager.QueueScheduledTask(It.Is(task => task.Key == expectedKey), It.IsAny()), Times.Once); + taskManager.Verify(manager => manager.QueueScheduledTask(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void MissingHostTaskDoesNotFallBackToJellyfinScan() + { + var taskManager = CreateTaskManager("TaskExtractMediaSegments"); + var service = new SegmentRefreshService(taskManager.Object, NullLogger.Instance, true); + + service.QueueRefresh(); + + taskManager.Verify(manager => manager.QueueScheduledTask(It.IsAny(), It.IsAny()), Times.Never); + } + + internal static Mock CreateTaskManager(params string[] keys) + { + var workers = keys.Select(key => + { + var task = new Mock(); + task.SetupGet(value => value.Key).Returns(key); + var worker = new Mock(); + worker.SetupGet(value => value.ScheduledTask).Returns(task.Object); + return worker.Object; + }).ToArray(); + var manager = new Mock(); + manager.SetupGet(value => value.ScheduledTasks).Returns(workers); + return manager; + } +} diff --git a/SkipMe.Db.Plugin.Tests/SkipMe.Db.Plugin.Tests.csproj b/SkipMe.Db.Plugin.Tests/SkipMe.Db.Plugin.Tests.csproj new file mode 100644 index 0000000..657f17f --- /dev/null +++ b/SkipMe.Db.Plugin.Tests/SkipMe.Db.Plugin.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + diff --git a/SkipMe.Db.Plugin.Tests/SyncSegmentsTaskTests.cs b/SkipMe.Db.Plugin.Tests/SyncSegmentsTaskTests.cs new file mode 100644 index 0000000..227e2ef --- /dev/null +++ b/SkipMe.Db.Plugin.Tests/SyncSegmentsTaskTests.cs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 Intro Skipper contributors +// SPDX-License-Identifier: GPL-3.0-only + +using System.Net; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using SkipMe.Db.Plugin.Models; +using SkipMe.Db.Plugin.Services; +using SkipMe.Db.Plugin.Tasks; +using Xunit; + +namespace SkipMe.Db.Plugin.Tests; + +public sealed class SyncSegmentsTaskTests +{ + [Theory] + [InlineData(true, false, "IntroSkipperDetectSegmentsTask")] + [InlineData(true, true, "IntroSkipperDetectSegmentsTask")] + [InlineData(false, false, "TaskExtractMediaSegments")] + public async Task SuccessfulSyncQueuesOwnerAfterReplacingData(bool integrated, bool noResults, string expectedTask) + { + await RunSyncAsync(integrated, noResults ? "[null]" : "[{\"intro\":[{\"start_ms\":1000,\"end_ms\":2000}]}]", HttpStatusCode.OK, (store, item, manager) => + { + manager.Verify(value => value.QueueScheduledTask(It.Is(task => task.Key == expectedTask), It.IsAny()), Times.Once); + manager.Verify(value => value.QueueScheduledTask(It.IsAny(), It.IsAny()), Times.Once); + if (noResults) + { + Assert.Null(store.GetSegments(item.Id)); + } + else + { + Assert.Equal(1000, Assert.Single(store.GetSegments(item.Id)!).StartMs); + } + }); + } + + [Fact] + public async Task FailedSyncKeepsDataAndDoesNotQueueDetection() + { + await RunSyncAsync(true, "{}", HttpStatusCode.ServiceUnavailable, (store, item, manager) => + { + manager.Verify(value => value.QueueScheduledTask(It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(500, Assert.Single(store.GetSegments(item.Id)!).StartMs); + }); + } + + private static async Task RunSyncAsync(bool integrated, string json, HttpStatusCode status, Action> verify) + { + var directory = Path.Combine(Path.GetTempPath(), "skipme-sync-tests", Guid.NewGuid().ToString("N")); + var paths = Mock.Of(value => value.DataPath == directory); + try + { + using var store = new SegmentStore(paths, NullLogger.Instance); + var item = new Movie { Id = Guid.NewGuid(), RunTimeTicks = TimeSpan.FromMinutes(90).Ticks, ProviderIds = new Dictionary { ["Tmdb"] = "123" } }; + await store.ReplaceAllAsync(new Dictionary> + { + [item.Id] = [new() { Type = "intro", StartMs = 500, EndMs = 900 }] + }); + var library = new Mock(); + library.Setup(value => value.GetItemList(It.IsAny())).Returns((InternalItemsQuery query) => query.IncludeItemTypes.Contains(BaseItemKind.Movie) ? [item] : []); + var handler = new Mock(); + handler.Protected().Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage(status) { Content = new StringContent(json) }); + using var client = new HttpClient(handler.Object); + var factory = Mock.Of(value => value.CreateClient(nameof(SkipMeApiClient)) == client); + var manager = SegmentRefreshServiceTests.CreateTaskManager("IntroSkipperDetectSegmentsTask", "TaskExtractMediaSegments"); + var task = new SyncSegmentsTask( + library.Object, + new SkipMeApiClient(factory, NullLogger.Instance), + store, + new SegmentRefreshService(manager.Object, NullLogger.Instance, integrated), + NullLogger.Instance); + + await task.ExecuteAsync(new Progress(), CancellationToken.None); + + verify(store, item, manager); + } + finally + { + Directory.Delete(directory, true); + } + } +} diff --git a/SkipMe.Db.Plugin.sln b/SkipMe.Db.Plugin.sln index a4a29cf..23f0592 100644 --- a/SkipMe.Db.Plugin.sln +++ b/SkipMe.Db.Plugin.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SkipMe.Db.Plugin", "SkipMe.Db.Plugin\SkipMe.Db.Plugin.csproj", "{3A1F8C2E-5D7B-4A9C-8E6F-1B0D2C3E4F5A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SkipMe.Db.Plugin.Tests", "SkipMe.Db.Plugin.Tests\SkipMe.Db.Plugin.Tests.csproj", "{39294974-CFFE-4692-98D0-CE39E3A1370E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,5 +17,9 @@ Global {3A1F8C2E-5D7B-4A9C-8E6F-1B0D2C3E4F5A}.Debug|Any CPU.Build.0 = Debug|Any CPU {3A1F8C2E-5D7B-4A9C-8E6F-1B0D2C3E4F5A}.Release|Any CPU.ActiveCfg = Release|Any CPU {3A1F8C2E-5D7B-4A9C-8E6F-1B0D2C3E4F5A}.Release|Any CPU.Build.0 = Release|Any CPU + {39294974-CFFE-4692-98D0-CE39E3A1370E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39294974-CFFE-4692-98D0-CE39E3A1370E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39294974-CFFE-4692-98D0-CE39E3A1370E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39294974-CFFE-4692-98D0-CE39E3A1370E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/SkipMe.Db.Plugin/Plugin.cs b/SkipMe.Db.Plugin/Plugin.cs index a20ed86..2a67e0c 100644 --- a/SkipMe.Db.Plugin/Plugin.cs +++ b/SkipMe.Db.Plugin/Plugin.cs @@ -9,6 +9,7 @@ using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; using SkipMe.Db.Plugin.Configuration; +using SkipMe.Db.Plugin.Services; namespace SkipMe.Db.Plugin; @@ -19,20 +20,25 @@ namespace SkipMe.Db.Plugin; /// public class Plugin : BasePlugin, IHasWebPages { + private readonly SegmentRefreshService _segmentRefresh; + /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Routes configuration changes to the active segment analyzer. public Plugin( IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + SegmentRefreshService segmentRefresh) : base(applicationPaths, xmlSerializer) { Instance = this; LibraryManager = libraryManager; + _segmentRefresh = segmentRefresh; } /// @@ -54,6 +60,16 @@ public Plugin( /// public ILibraryManager LibraryManager { get; } + /// + public override void UpdateConfiguration(BasePluginConfiguration configuration) + { + base.UpdateConfiguration(configuration); + if (_segmentRefresh.IsIntegrated) + { + _segmentRefresh.QueueRefresh(); + } + } + /// public IEnumerable GetPages() { diff --git a/SkipMe.Db.Plugin/PluginServiceRegistrator.cs b/SkipMe.Db.Plugin/PluginServiceRegistrator.cs index b8c401d..a2db441 100644 --- a/SkipMe.Db.Plugin/PluginServiceRegistrator.cs +++ b/SkipMe.Db.Plugin/PluginServiceRegistrator.cs @@ -1,11 +1,13 @@ // SPDX-FileCopyrightText: 2026 Intro Skipper contributors // SPDX-License-Identifier: GPL-3.0-only +using System.Reflection; using MediaBrowser.Controller; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using SkipMe.Db.Plugin.Providers; using SkipMe.Db.Plugin.Services; using SkipMe.Db.Plugin.Tasks; @@ -23,6 +25,16 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator /// public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) { + RegisterServices(serviceCollection, AppDomain.CurrentDomain.GetAssemblies()); + } + + internal static void RegisterServices(IServiceCollection serviceCollection, IEnumerable assemblies) + { + if (serviceCollection.Any(descriptor => descriptor.ServiceType == typeof(SegmentRefreshService))) + { + return; + } + serviceCollection.AddHttpClient(nameof(SkipMeApiClient)) .ConfigureHttpClient(c => { @@ -39,7 +51,21 @@ public void RegisterServices(IServiceCollection serviceCollection, IServerApplic serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + var isIntegrated = IntroSkipperRegistration.TryRegister(serviceCollection, assemblies); + if (!isIntegrated) + { + serviceCollection.AddSingleton(static provider => provider.GetRequiredService()); + } + else + { + serviceCollection.AddHostedService(); + } + + serviceCollection.AddSingleton(provider => new SegmentRefreshService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + isIntegrated)); serviceCollection.AddSingleton(); } } diff --git a/SkipMe.Db.Plugin/Providers/SegmentProvider.cs b/SkipMe.Db.Plugin/Providers/SegmentProvider.cs index 1c7d8b1..3ee2f3a 100644 --- a/SkipMe.Db.Plugin/Providers/SegmentProvider.cs +++ b/SkipMe.Db.Plugin/Providers/SegmentProvider.cs @@ -55,7 +55,7 @@ public SegmentProvider(SegmentStore segmentStore, ILibraryManager libraryManager } /// - public string Name => Plugin.Instance!.Name; + public string Name => "SkipMe.db"; /// public ValueTask Supports(BaseItem item) => ValueTask.FromResult(item is Episode or Movie); @@ -67,7 +67,13 @@ public Task> GetMediaSegments( { ArgumentNullException.ThrowIfNull(request); - if (IsItemDisabled(request.ItemId)) + var item = _libraryManager.GetItemById(request.ItemId); + if (item is null || _libraryManager.GetLibraryOptions(item).DisabledMediaSegmentProviders.Contains(Name, StringComparer.OrdinalIgnoreCase)) + { + return Task.FromResult>([]); + } + + if (IsItemDisabled(item)) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -136,8 +142,8 @@ private List BuildSegments(Guid itemId, IReadOnlyList. /// For movies, the check is against the movie ID directly. /// - /// The Jellyfin item ID to check. - private bool IsItemDisabled(Guid itemId) + /// The Jellyfin item to check. + private static bool IsItemDisabled(BaseItem item) { var config = Plugin.Instance?.Configuration; if (config is null) @@ -145,11 +151,9 @@ private bool IsItemDisabled(Guid itemId) return false; } - var item = _libraryManager.GetItemById(itemId); - if (item is Movie) { - return config.DisabledMovieIds.Contains(itemId); + return config.DisabledMovieIds.Contains(item.Id); } if (item is not Episode episode) diff --git a/SkipMe.Db.Plugin/Services/IntroSkipperRegistration.cs b/SkipMe.Db.Plugin/Services/IntroSkipperRegistration.cs new file mode 100644 index 0000000..65c42ea --- /dev/null +++ b/SkipMe.Db.Plugin/Services/IntroSkipperRegistration.cs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Intro Skipper contributors +// SPDX-License-Identifier: GPL-3.0-only + +using System.Reflection; +using MediaBrowser.Controller.MediaSegments; +using Microsoft.Extensions.DependencyInjection; +using SkipMe.Db.Plugin.Providers; + +namespace SkipMe.Db.Plugin.Services; + +internal static class IntroSkipperRegistration +{ + internal static bool TryRegister(IServiceCollection services, IEnumerable assemblies) + { + foreach (var assembly in assemblies) + { + if (!string.Equals(assembly.GetName().Name, "IntroSkipper", StringComparison.Ordinal)) + { + continue; + } + + try + { + var method = assembly.GetType("IntroSkipper.Integrations.SkipMeIntegration")?.GetMethod( + "RegisterV1", + BindingFlags.Public | BindingFlags.Static, + [typeof(IServiceCollection), typeof(Func)]); + if (method is null || method.ReturnType != typeof(void) || method.ContainsGenericParameters) + { + continue; + } + + var register = method.CreateDelegate>>(); + IServiceCollection candidate = new ServiceCollection(); + foreach (var descriptor in services) + { + candidate.Add(descriptor); + } + + register(candidate, static provider => provider.GetRequiredService()); + services.Clear(); + foreach (var descriptor in candidate) + { + services.Add(descriptor); + } + + return true; + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + return false; + } + } + + return false; + } +} diff --git a/SkipMe.Db.Plugin/Services/SegmentHandoverService.cs b/SkipMe.Db.Plugin/Services/SegmentHandoverService.cs new file mode 100644 index 0000000..7343b7e --- /dev/null +++ b/SkipMe.Db.Plugin/Services/SegmentHandoverService.cs @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Intro Skipper contributors +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using Jellyfin.Database.Implementations; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SkipMe.Db.Plugin.Services; + +internal sealed class SegmentHandoverService( + IServerApplicationHost applicationHost, + IDbContextFactory contextFactory, + SegmentRefreshService segmentRefresh, + ILogger logger) : BackgroundService +{ + internal static readonly string SkipMeProviderId = "skipme.db".GetMD5().ToString("N", CultureInfo.InvariantCulture); + + internal async Task RetireLegacySegmentsAsync(CancellationToken cancellationToken) + { + var context = await contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + return await context.MediaSegments + .Where(segment => segment.SegmentProviderId == SkipMeProviderId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!applicationHost.CoreStartupHasCompleted) + { + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken).ConfigureAwait(false); + } + + stoppingToken.ThrowIfCancellationRequested(); + segmentRefresh.QueueRefresh(); + while (!stoppingToken.IsCancellationRequested) + { + try + { + var deleted = await RetireLegacySegmentsAsync(stoppingToken).ConfigureAwait(false); + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Retired {SegmentCount} legacy SkipMe.db Jellyfin segments after activating Intro Skipper integration", deleted); + } + + return; + } + catch (Exception exception) when (exception is not OutOfMemoryException + && (exception is not OperationCanceledException || !stoppingToken.IsCancellationRequested)) + { + logger.LogWarning(exception, "Could not retire legacy SkipMe.db Jellyfin segments; retrying in one minute"); + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken).ConfigureAwait(false); + } + } +} diff --git a/SkipMe.Db.Plugin/Services/SegmentRefreshService.cs b/SkipMe.Db.Plugin/Services/SegmentRefreshService.cs new file mode 100644 index 0000000..6e047e9 --- /dev/null +++ b/SkipMe.Db.Plugin/Services/SegmentRefreshService.cs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Intro Skipper contributors +// SPDX-License-Identifier: GPL-3.0-only + +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace SkipMe.Db.Plugin.Services; + +/// +/// Routes segment refreshes to the active owner of SkipMe.db analysis. +/// +public sealed class SegmentRefreshService +{ + private const string MediaSegmentScanTaskKey = "TaskExtractMediaSegments"; + private const string IntroSkipperTaskKey = "IntroSkipperDetectSegmentsTask"; + + private readonly ITaskManager _taskManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The Jellyfin task manager. + /// The logger. + /// Whether Intro Skipper accepted the provider registration. + public SegmentRefreshService(ITaskManager taskManager, ILogger logger, bool isIntegrated) + { + _taskManager = taskManager; + _logger = logger; + IsIntegrated = isIntegrated; + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("SkipMe.db segment analysis is running in {Mode} mode", isIntegrated ? "Intro Skipper" : "standalone"); + } + } + + /// + /// Gets a value indicating whether Intro Skipper owns SkipMe.db analysis. + /// + public bool IsIntegrated { get; } + + /// + /// Queues the active segment analysis task after a data or configuration change. + /// + public void QueueRefresh() + { + var taskKey = IsIntegrated ? IntroSkipperTaskKey : MediaSegmentScanTaskKey; + var worker = _taskManager.ScheduledTasks + .FirstOrDefault(task => string.Equals(task.ScheduledTask.Key, taskKey, StringComparison.Ordinal)); + if (worker is null) + { + _logger.LogWarning("Could not find scheduled task with key '{TaskKey}' β€” segment analysis will not be triggered", taskKey); + return; + } + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Queuing segment analysis ('{TaskKey}')", taskKey); + } + + _taskManager.QueueScheduledTask(worker.ScheduledTask, new TaskOptions()); + } +} diff --git a/SkipMe.Db.Plugin/Services/SkipMeApiClient.cs b/SkipMe.Db.Plugin/Services/SkipMeApiClient.cs index f565a5b..f37e39a 100644 --- a/SkipMe.Db.Plugin/Services/SkipMeApiClient.cs +++ b/SkipMe.Db.Plugin/Services/SkipMeApiClient.cs @@ -62,8 +62,8 @@ public SkipMeApiClient(IHttpClientFactory httpClientFactory, ILoggerPOST /v1/movies. /// /// The lookup requests. - /// Cancellation token. /// Optional callback invoked after each request batch finishes. + /// Cancellation token. /// A response list plus whether all batches completed reliably. internal Task> GetByMoviesBatchWithStatusAsync( IReadOnlyList requests, @@ -91,8 +91,8 @@ internal Task> GetByMoviesBatchWithStatusAsync( /// Fetches segment timestamps for many show lookups via POST /v1/shows. /// /// The lookup requests. - /// Cancellation token. /// Optional callback invoked after each request batch finishes. + /// Cancellation token. /// A response list plus whether all batches completed reliably. internal Task> GetByShowsBatchWithStatusAsync( IReadOnlyList requests, diff --git a/SkipMe.Db.Plugin/SkipMe.Db.Plugin.csproj b/SkipMe.Db.Plugin/SkipMe.Db.Plugin.csproj index 0e9cd21..7d2ee90 100644 --- a/SkipMe.Db.Plugin/SkipMe.Db.Plugin.csproj +++ b/SkipMe.Db.Plugin/SkipMe.Db.Plugin.csproj @@ -38,6 +38,10 @@ + + + + diff --git a/SkipMe.Db.Plugin/Tasks/SyncSegmentsTask.cs b/SkipMe.Db.Plugin/Tasks/SyncSegmentsTask.cs index cc5112d..fd8a807 100644 --- a/SkipMe.Db.Plugin/Tasks/SyncSegmentsTask.cs +++ b/SkipMe.Db.Plugin/Tasks/SyncSegmentsTask.cs @@ -31,15 +31,13 @@ public class SyncSegmentsTask : IScheduledTask private const double MovieResponseProcessingProgressEnd = 55.0; private const double ShowLookupProgressEnd = 90.0; private const double ResponseProcessingProgressEnd = 95.0; - private const string MediaSegmentScanTaskKey = "TaskExtractMediaSegments"; private static readonly SemaphoreSlim SyncExecutionGate = new(1, 1); - private static readonly TaskOptions DefaultTaskOptions = new(); private readonly ILibraryManager _libraryManager; private readonly SkipMeApiClient _apiClient; private readonly SegmentStore _segmentStore; - private readonly ITaskManager _taskManager; + private readonly SegmentRefreshService _segmentRefresh; private readonly ILogger _logger; /// @@ -48,19 +46,19 @@ public class SyncSegmentsTask : IScheduledTask /// The Jellyfin library manager. /// The SkipMe.db API client. /// The local segment store. - /// The Jellyfin task manager, used to trigger the media segment scan after syncing. + /// Routes segment analysis after syncing. /// The logger. public SyncSegmentsTask( ILibraryManager libraryManager, SkipMeApiClient apiClient, SegmentStore segmentStore, - ITaskManager taskManager, + SegmentRefreshService segmentRefresh, ILogger logger) { _libraryManager = libraryManager; _apiClient = apiClient; _segmentStore = segmentStore; - _taskManager = taskManager; + _segmentRefresh = segmentRefresh; _logger = logger; } @@ -321,7 +319,7 @@ void ReportShowBatchProgress(int batchCount) totalItems); } - TriggerMediaSegmentScan(); + _segmentRefresh.QueueRefresh(); } finally { @@ -329,31 +327,6 @@ void ReportShowBatchProgress(int batchCount) } } - private void TriggerMediaSegmentScan() - { - var worker = _taskManager.ScheduledTasks - .FirstOrDefault(t => string.Equals(t.ScheduledTask.Key, MediaSegmentScanTaskKey, StringComparison.Ordinal)); - - if (worker is null) - { - if (_logger.IsEnabled(LogLevel.Warning)) - { - _logger.LogWarning( - "Could not find scheduled task with key '{TaskKey}' β€” media segment scan will not be triggered", - MediaSegmentScanTaskKey); - } - - return; - } - - if (_logger.IsEnabled(LogLevel.Information)) - { - _logger.LogInformation("Queuing Jellyfin media segment scan ('{TaskKey}')", MediaSegmentScanTaskKey); - } - - _taskManager.QueueScheduledTask(worker.ScheduledTask, DefaultTaskOptions); - } - // Instance method because it resolves season context via _libraryManager through GetEpisodeProviderIds(). private bool TryBuildShowLookup(Episode episode, out string key, out ShowLookupRequest request) { From 9441f2efd51cb2b850bd7615c16acddff01c27b7 Mon Sep 17 00:00:00 2001 From: Capy Agent Date: Sat, 12 Sep 2026 05:12:35 +0000 Subject: [PATCH 2/2] Make Intro Skipper integration opt-in --- README.md | 24 ++-- SkipMe.Db.Plugin.Tests/RegistrationTests.cs | 116 +++++++++++++++++- .../SegmentProviderTests.cs | 14 +++ .../Configuration/PluginConfiguration.cs | 7 ++ .../Configuration/skipme-index.css | 2 +- .../Configuration/skipme-index.js | 23 +++- .../Services/IntroSkipperRegistration.cs | 29 +++++ web/src/main.ts | 52 +++++++- web/src/styles/main.css | 46 +++++++ web/src/types.ts | 1 + 10 files changed, 294 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 99c7e4e..3c654f8 100644 --- a/README.md +++ b/README.md @@ -40,26 +40,34 @@ To populate the local segment database immediately: 1. Go to Dashboard -> Scheduled Tasks. 2. Run `Sync SkipMe.db Segment Database`. 3. After a successful sync, the plugin queues Intro Skipper's segment detection - when a compatible Intro Skipper is installed, or Jellyfin's media segment scan - when running standalone. + when integration is enabled and a compatible Intro Skipper is installed, or + Jellyfin's media segment scan when running standalone. By default, the sync task runs weekly on Sunday at 1:00 AM. ## Intro Skipper Integration -When Intro Skipper exposes the compatible SkipMe integration API, SkipMe.db -automatically supplies its local timestamps to Intro Skipper instead of registering +Intro Skipper integration is **off by default**, including for existing SkipMe.db +installations. Installing both plugins does not change the standalone provider. +To opt in, open SkipMe.db's **Sync** tab, enable **Use Intro Skipper for segment +analysis**, save settings, and restart Jellyfin. + +When that preference is enabled and Intro Skipper exposes the compatible integration +API, SkipMe.db supplies its local timestamps to Intro Skipper instead of registering as a separate Jellyfin media segment provider. Intro Skipper treats available SkipMe timestamps as authoritative and publishes the resulting segments. Saving -SkipMe's enable/disable settings also queues Intro Skipper detection. +SkipMe's item enable/disable settings also queues Intro Skipper detection while +integrated mode is active. The Sync and Share tabs and the SkipMe.db API remain available in both modes. Series, season, movie, specials, and existing per-library SkipMe.db exclusions remain respected by the integrated source. In integrated mode, enable Intro Skipper as the library's Jellyfin segment provider; SkipMe.db no longer appears as -a separate provider. If Intro Skipper is absent or incompatible, SkipMe.db keeps -its standalone provider behavior. Restart Jellyfin after installing or removing -either plugin to reevaluate the integration. +a separate provider. If integration is off, the saved preference cannot be read, or +Intro Skipper is absent or incompatible, SkipMe.db keeps its standalone provider +behavior. Restart Jellyfin after changing the integration preference or installing +or removing either plugin. Saving the preference does not switch the running +provider; the new choice takes effect on restart. Once Jellyfin finishes startup, integrated mode queues initial Intro Skipper detection and removes legacy SkipMe-owned rows from Jellyfin's segment database. diff --git a/SkipMe.Db.Plugin.Tests/RegistrationTests.cs b/SkipMe.Db.Plugin.Tests/RegistrationTests.cs index a77c7bf..8198ff8 100644 --- a/SkipMe.Db.Plugin.Tests/RegistrationTests.cs +++ b/SkipMe.Db.Plugin.Tests/RegistrationTests.cs @@ -3,11 +3,16 @@ using System.Reflection; using System.Reflection.Emit; +using System.Xml; +using System.Xml.Serialization; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Moq; +using SkipMe.Db.Plugin.Configuration; using SkipMe.Db.Plugin.Providers; using SkipMe.Db.Plugin.Services; using Xunit; @@ -16,6 +21,110 @@ namespace SkipMe.Db.Plugin.Tests; public sealed class RegistrationTests { + [Fact] + public void CompatibleHostDoesNotActivateIntegrationByDefault() + { + Assert.False(new PluginConfiguration().EnableIntroSkipperIntegration); + var services = CreateServices(enableIntegration: false); + var assembly = BuildHost((_, _) => throw new InvalidOperationException("Opt-in is required")); + + PluginServiceRegistrator.RegisterServices(services, [assembly]); + + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IMediaSegmentProvider)); + Assert.DoesNotContain(services, descriptor => descriptor.ImplementationType == typeof(SegmentHandoverService)); + using var provider = services.BuildServiceProvider(); + Assert.False(provider.GetRequiredService().IsIntegrated); + } + + [Theory] + [InlineData("")] + [InlineData("false")] + public void ExistingConfigurationWithoutOptInKeepsStandaloneProvider(string xml) + { + using var reader = new StringReader(xml); + var configuration = (PluginConfiguration)new XmlSerializer(typeof(PluginConfiguration)).Deserialize(reader)!; + var services = CreateServices(enableIntegration: configuration.EnableIntroSkipperIntegration); + var calls = 0; + var assembly = BuildHost((_, _) => calls++); + + PluginServiceRegistrator.RegisterServices(services, [assembly]); + + Assert.Equal(0, calls); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IMediaSegmentProvider)); + } + + [Theory] + [InlineData("missing")] + [InlineData("unreadable")] + [InlineData("malformed")] + [InlineData("deserialization")] + public void UnreadableStartupConfigurationCannotOptIn(string failure) + { + var services = CreateServices(); + Exception exception = failure switch + { + "missing" => new FileNotFoundException(), + "unreadable" => new UnauthorizedAccessException(), + "malformed" => new XmlException(), + _ => new InvalidOperationException(), + }; + var serializer = new Mock(); + serializer.Setup(value => value.DeserializeFromFile(typeof(PluginConfiguration), It.IsAny())).Throws(exception); + services.AddSingleton(serializer.Object); + var calls = 0; + var assembly = BuildHost((_, _) => calls++); + + PluginServiceRegistrator.RegisterServices(services, [assembly]); + + Assert.Equal(0, calls); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IMediaSegmentProvider)); + Assert.DoesNotContain(services, descriptor => descriptor.ImplementationType == typeof(SegmentHandoverService)); + } + + [Fact] + public void StartupUsesRegisteredInstancesWithoutBuildingAServiceProvider() + { + var services = CreateServices(); + var factoryCalls = 0; + services.AddSingleton(_ => + { + factoryCalls++; + throw new InvalidOperationException("Do not instantiate services during registration"); + }); + var calls = 0; + var assembly = BuildHost((_, _) => calls++); + + PluginServiceRegistrator.RegisterServices(services, [assembly]); + + Assert.Equal(0, factoryCalls); + Assert.Equal(0, calls); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IMediaSegmentProvider)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IntegrationPreferenceRoundTripsWithExistingExclusions(bool enabled) + { + var configuration = new PluginConfiguration { EnableIntroSkipperIntegration = enabled }; + configuration.DisabledSeriesIds.Add(Guid.NewGuid()); + configuration.DisabledSeasonIds.Add(Guid.NewGuid()); + configuration.DisabledMovieIds.Add(Guid.NewGuid()); + configuration.EnabledSpecialsSeasonIds.Add(Guid.NewGuid()); + var serializer = new XmlSerializer(typeof(PluginConfiguration)); + using var writer = new StringWriter(); + serializer.Serialize(writer, configuration); + using var reader = new StringReader(writer.ToString()); + + var restored = (PluginConfiguration)serializer.Deserialize(reader)!; + + Assert.Equal(enabled, restored.EnableIntroSkipperIntegration); + Assert.Equal(configuration.DisabledSeriesIds, restored.DisabledSeriesIds); + Assert.Equal(configuration.DisabledSeasonIds, restored.DisabledSeasonIds); + Assert.Equal(configuration.DisabledMovieIds, restored.DisabledMovieIds); + Assert.Equal(configuration.EnabledSpecialsSeasonIds, restored.EnabledSpecialsSeasonIds); + } + [Fact] public void AbsentHostPreservesStandaloneProvider() { @@ -102,11 +211,16 @@ public void PluginHasNoIntroSkipperAssemblyReference() Assert.DoesNotContain(typeof(Plugin).Assembly.GetReferencedAssemblies(), assembly => assembly.Name == "IntroSkipper"); } - private static ServiceCollection CreateServices() + private static ServiceCollection CreateServices(bool enableIntegration = true) { var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of(paths => paths.PluginConfigurationsPath == "/config/plugins")); + var serializer = new Mock(); + serializer.Setup(value => value.DeserializeFromFile(typeof(PluginConfiguration), Path.Combine("/config/plugins", "SkipMe.Db.Plugin.xml"))) + .Returns(new PluginConfiguration { EnableIntroSkipperIntegration = enableIntegration }); + services.AddSingleton(serializer.Object); return services; } diff --git a/SkipMe.Db.Plugin.Tests/SegmentProviderTests.cs b/SkipMe.Db.Plugin.Tests/SegmentProviderTests.cs index a93aeae..a9d3a41 100644 --- a/SkipMe.Db.Plugin.Tests/SegmentProviderTests.cs +++ b/SkipMe.Db.Plugin.Tests/SegmentProviderTests.cs @@ -163,6 +163,20 @@ public void ConfigurationChangesQueueDetectionOnlyWhenIntegrated(bool integrated _tasks.Verify(tasks => tasks.QueueScheduledTask(It.Is(task => task.Key == "TaskExtractMediaSegments"), It.IsAny()), Times.Never); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ChangingIntegrationPreferenceDoesNotSwitchProviderUntilRestart(bool integrated) + { + var plugin = CreatePlugin(integrated); + + plugin.UpdateConfiguration(new PluginConfiguration { EnableIntroSkipperIntegration = !integrated }); + + Assert.Equal(!integrated, plugin.Configuration.EnableIntroSkipperIntegration); + _tasks.Verify(tasks => tasks.QueueScheduledTask(It.Is(task => task.Key == "IntroSkipperDetectSegmentsTask"), It.IsAny()), integrated ? Times.Once() : Times.Never()); + _tasks.Verify(tasks => tasks.QueueScheduledTask(It.Is(task => task.Key == "TaskExtractMediaSegments"), It.IsAny()), Times.Never); + } + public void Dispose() { _store.Dispose(); diff --git a/SkipMe.Db.Plugin/Configuration/PluginConfiguration.cs b/SkipMe.Db.Plugin/Configuration/PluginConfiguration.cs index bc6065e..37f7976 100644 --- a/SkipMe.Db.Plugin/Configuration/PluginConfiguration.cs +++ b/SkipMe.Db.Plugin/Configuration/PluginConfiguration.cs @@ -12,6 +12,13 @@ namespace SkipMe.Db.Plugin.Configuration; /// public class PluginConfiguration : BasePluginConfiguration { + /// + /// Gets or sets a value indicating whether SkipMe should register with a compatible + /// Intro Skipper instead of providing segments independently. Disabled by default; + /// changing this setting requires restarting Jellyfin. + /// + public bool EnableIntroSkipperIntegration { get; set; } + /// /// Gets the set of Jellyfin series item IDs for which crowd-sourced segments are disabled. /// When a series ID is present, no segments will be surfaced for any episode in that series. diff --git a/SkipMe.Db.Plugin/Configuration/skipme-index.css b/SkipMe.Db.Plugin/Configuration/skipme-index.css index b385402..95a1561 100644 --- a/SkipMe.Db.Plugin/Configuration/skipme-index.css +++ b/SkipMe.Db.Plugin/Configuration/skipme-index.css @@ -1 +1 @@ -.skipme-series-list{display:flex;flex-direction:column;gap:5px;margin:.4em 0}.skipme-series-card{border-radius:8px;overflow:hidden;background:#ffffff0a;border:1px solid rgba(255,255,255,.09);transition:border-color .18s}.skipme-series-card:hover{border-color:#ffffff2e}.skipme-series-header{display:flex;align-items:center;gap:14px;padding:10px 14px;cursor:pointer;-webkit-user-select:none;user-select:none;min-height:76px}.skipme-series-poster{width:46px;height:68px;border-radius:4px;object-fit:cover;flex-shrink:0;background:#ffffff12;display:block}.skipme-series-poster-placeholder{width:46px;height:68px;border-radius:4px;flex-shrink:0;background:#ffffff12;display:flex;align-items:center;justify-content:center;font-size:1.4em}.skipme-series-info{flex:1;min-width:0}.skipme-series-name{font-size:.95em;font-weight:600;line-height:1.35;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.skipme-series-hint{font-size:.73em;margin-top:4px;opacity:.5;transition:color .2s,opacity .2s}.skipme-series-hint.is-off{opacity:1;color:#f5a623}.skipme-series-controls{display:flex;align-items:center;gap:10px;flex-shrink:0}.skipme-segment-count{min-width:1.55em;padding:.25em .5em;border-radius:999px;background:#00a4dcd1;color:#fff;font-size:.72em;font-weight:700;line-height:1.2;text-align:center;white-space:nowrap}.skipme-chevron{width:18px;height:18px;opacity:.4;flex-shrink:0;transition:transform .2s ease,opacity .2s}.skipme-series-card.is-expanded .skipme-chevron{transform:rotate(180deg);opacity:.65}.skipme-toggle{position:relative;display:inline-flex;align-items:center;cursor:pointer;flex-shrink:0}.skipme-toggle input{position:absolute;opacity:0;width:0;height:0;pointer-events:none}.skipme-toggle-track{display:inline-block;position:relative;width:42px;height:24px;background:#ffffff2e;border-radius:12px;transition:background .2s}.skipme-toggle-thumb{position:absolute;top:3px;left:3px;width:18px;height:18px;background:#fff;border-radius:50%;box-shadow:0 1px 3px #0006;transition:transform .2s}.skipme-toggle input:checked~.skipme-toggle-track{background:#00a4dc}.skipme-toggle input:checked~.skipme-toggle-track .skipme-toggle-thumb{transform:translate(18px)}.skipme-toggle input:disabled~.skipme-toggle-track{opacity:.35;cursor:not-allowed}.skipme-seasons-panel{display:none;padding:12px 14px 16px;background:#0000002e;border-top:1px solid rgba(255,255,255,.06)}.skipme-series-card.is-expanded .skipme-seasons-panel{display:block}.skipme-seasons-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));column-gap:16px;row-gap:10px}.skipme-season-card{border-radius:6px;overflow:hidden;background:#ffffff0a;border:1px solid rgba(255,255,255,.07);display:flex;flex-direction:column;transition:border-color .15s,opacity .2s}.skipme-season-poster-wrap{position:relative;aspect-ratio:2 / 3;overflow:hidden;background:#ffffff0f}.skipme-season-poster{width:100%;height:100%;object-fit:cover;display:block}.skipme-season-no-image{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2em}.skipme-season-overlay{position:absolute;top:0;right:0;bottom:0;left:0;background:#0000008c;display:flex;align-items:center;justify-content:center;text-align:center;font-size:.62em;color:#ffffffa6;padding:4px;line-height:1.3}.skipme-season-footer{padding:6px 8px 8px;display:flex;flex-direction:column;align-items:center;gap:7px}.skipme-season-name{font-size:.74em;text-align:center;opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%}.skipme-loading{display:flex;flex-direction:column;align-items:center;gap:14px;padding:48px 20px;opacity:.5}.skipme-spinner{width:34px;height:34px;border:3px solid rgba(255,255,255,.12);border-top-color:#00a4dc;border-radius:50%;animation:skipme-spin .7s linear infinite}@keyframes skipme-spin{to{transform:rotate(360deg)}}.skipme-message{padding:40px 20px;text-align:center;opacity:.55}.skipme-error{opacity:1;color:#f44336}.skipme-seasons-loading{display:flex;align-items:center;gap:10px;padding:8px 0;opacity:.45;font-size:.85em}.skipme-seasons-loading .skipme-spinner{width:20px;height:20px;border-width:2px}.skipme-truncation-note{font-size:.82em;color:#f5a623;margin:0 0 .8em;padding:6px 10px;border-left:3px solid #f5a623;background:#f5a62314;border-radius:0 4px 4px 0}.skipme-footer{margin-top:1.5em;display:flex;flex-direction:column;align-items:stretch;gap:8px}#skipme-save-btn,#skipme-share-btn{border-radius:24px;text-align:center;justify-content:center}#skipme-share-btn.is-loading{opacity:.75}.skipme-status{font-size:.85em;transition:opacity .3s;text-align:center}.skipme-status.ok{color:#4caf50}.skipme-status.err{color:#f44336}.skipme-section{margin-bottom:2em}.skipme-filter-controls{margin-top:1em}.skipme-tabs{margin:1em 0 .7em;display:inline-flex;gap:6px;padding:4px;border-radius:999px;background:#ffffff14}.skipme-tab-button{border:0;background:transparent;color:inherit;border-radius:999px;padding:.45em 1em;cursor:pointer;opacity:.7;font-weight:600}.skipme-tab-button.is-active{background:#ffffff38;opacity:1}.skipme-section-title{font-size:1em;font-weight:600;margin:1.2em 0 .6em;opacity:.8;text-transform:uppercase;letter-spacing:.04em}.skipme-movies-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));column-gap:16px;row-gap:20px;margin:.4em 0}.skipme-movie-card{border-radius:6px;overflow:hidden;background:#ffffff0a;border:1px solid rgba(255,255,255,.07);display:flex;flex-direction:column;transition:border-color .15s,opacity .2s}.skipme-movie-card:hover{border-color:#ffffff29}.skipme-movie-poster-wrap{position:relative;aspect-ratio:2 / 3;overflow:hidden;background:#ffffff0f}.skipme-movie-poster{width:100%;height:100%;object-fit:cover;display:block}.skipme-movie-no-image{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2em}.skipme-movie-footer{padding:6px 8px 8px;display:flex;flex-direction:column;align-items:stretch;flex:1;gap:7px}.skipme-movie-controls{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto}.skipme-movie-controls .skipme-toggle{margin-left:auto}.skipme-movie-name{font-size:.74em;text-align:center;opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%}.skipme-library-section{margin-bottom:1.8em}.skipme-library-section:last-child{margin-bottom:0}.skipme-library-header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:.8em}.skipme-library-title{font-size:.82em;font-weight:600;margin:0;opacity:.55;text-transform:uppercase;letter-spacing:.05em} +.skipme-series-list{display:flex;flex-direction:column;gap:5px;margin:.4em 0}.skipme-series-card{border-radius:8px;overflow:hidden;background:#ffffff0a;border:1px solid rgba(255,255,255,.09);transition:border-color .18s}.skipme-series-card:hover{border-color:#ffffff2e}.skipme-series-header{display:flex;align-items:center;gap:14px;padding:10px 14px;cursor:pointer;-webkit-user-select:none;user-select:none;min-height:76px}.skipme-series-poster{width:46px;height:68px;border-radius:4px;object-fit:cover;flex-shrink:0;background:#ffffff12;display:block}.skipme-series-poster-placeholder{width:46px;height:68px;border-radius:4px;flex-shrink:0;background:#ffffff12;display:flex;align-items:center;justify-content:center;font-size:1.4em}.skipme-series-info{flex:1;min-width:0}.skipme-series-name{font-size:.95em;font-weight:600;line-height:1.35;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.skipme-series-hint{font-size:.73em;margin-top:4px;opacity:.5;transition:color .2s,opacity .2s}.skipme-series-hint.is-off{opacity:1;color:#f5a623}.skipme-series-controls{display:flex;align-items:center;gap:10px;flex-shrink:0}.skipme-segment-count{min-width:1.55em;padding:.25em .5em;border-radius:999px;background:#00a4dcd1;color:#fff;font-size:.72em;font-weight:700;line-height:1.2;text-align:center;white-space:nowrap}.skipme-chevron{width:18px;height:18px;opacity:.4;flex-shrink:0;transition:transform .2s ease,opacity .2s}.skipme-series-card.is-expanded .skipme-chevron{transform:rotate(180deg);opacity:.65}.skipme-toggle{position:relative;display:inline-flex;align-items:center;cursor:pointer;flex-shrink:0}.skipme-toggle input{position:absolute;opacity:0;width:0;height:0;pointer-events:none}.skipme-toggle-track{display:inline-block;position:relative;width:42px;height:24px;background:#ffffff2e;border-radius:12px;transition:background .2s}.skipme-toggle-thumb{position:absolute;top:3px;left:3px;width:18px;height:18px;background:#fff;border-radius:50%;box-shadow:0 1px 3px #0006;transition:transform .2s}.skipme-toggle input:checked~.skipme-toggle-track{background:#00a4dc}.skipme-toggle input:checked~.skipme-toggle-track .skipme-toggle-thumb{transform:translate(18px)}.skipme-toggle input:disabled~.skipme-toggle-track{opacity:.35;cursor:not-allowed}.skipme-seasons-panel{display:none;padding:12px 14px 16px;background:#0000002e;border-top:1px solid rgba(255,255,255,.06)}.skipme-series-card.is-expanded .skipme-seasons-panel{display:block}.skipme-seasons-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));column-gap:16px;row-gap:10px}.skipme-season-card{border-radius:6px;overflow:hidden;background:#ffffff0a;border:1px solid rgba(255,255,255,.07);display:flex;flex-direction:column;transition:border-color .15s,opacity .2s}.skipme-season-poster-wrap{position:relative;aspect-ratio:2 / 3;overflow:hidden;background:#ffffff0f}.skipme-season-poster{width:100%;height:100%;object-fit:cover;display:block}.skipme-season-no-image{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2em}.skipme-season-overlay{position:absolute;top:0;right:0;bottom:0;left:0;background:#0000008c;display:flex;align-items:center;justify-content:center;text-align:center;font-size:.62em;color:#ffffffa6;padding:4px;line-height:1.3}.skipme-season-footer{padding:6px 8px 8px;display:flex;flex-direction:column;align-items:center;gap:7px}.skipme-season-name{font-size:.74em;text-align:center;opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%}.skipme-loading{display:flex;flex-direction:column;align-items:center;gap:14px;padding:48px 20px;opacity:.5}.skipme-spinner{width:34px;height:34px;border:3px solid rgba(255,255,255,.12);border-top-color:#00a4dc;border-radius:50%;animation:skipme-spin .7s linear infinite}@keyframes skipme-spin{to{transform:rotate(360deg)}}.skipme-message{padding:40px 20px;text-align:center;opacity:.55}.skipme-error{opacity:1;color:#f44336}.skipme-seasons-loading{display:flex;align-items:center;gap:10px;padding:8px 0;opacity:.45;font-size:.85em}.skipme-seasons-loading .skipme-spinner{width:20px;height:20px;border-width:2px}.skipme-truncation-note{font-size:.82em;color:#f5a623;margin:0 0 .8em;padding:6px 10px;border-left:3px solid #f5a623;background:#f5a62314;border-radius:0 4px 4px 0}.skipme-footer{margin-top:1.5em;display:flex;flex-direction:column;align-items:stretch;gap:8px}#skipme-save-btn,#skipme-share-btn{border-radius:24px;text-align:center;justify-content:center}#skipme-share-btn.is-loading{opacity:.75}.skipme-status{font-size:.85em;transition:opacity .3s;text-align:center}.skipme-status.ok{color:#4caf50}.skipme-status.err{color:#f44336}.skipme-section{margin-bottom:2em}.skipme-filter-controls{margin-top:1em}.skipme-tabs{margin:1em 0 .7em;display:inline-flex;gap:6px;padding:4px;border-radius:999px;background:#ffffff14}.skipme-tab-button{border:0;background:transparent;color:inherit;border-radius:999px;padding:.45em 1em;cursor:pointer;opacity:.7;font-weight:600}.skipme-tab-button.is-active{background:#ffffff38;opacity:1}.skipme-integration{margin:.5em 0 1.5em;padding:16px;border:1px solid rgba(255,255,255,.09);border-radius:8px;background:#ffffff0a}.skipme-integration-label{display:flex;align-items:center;gap:10px;font-weight:600;cursor:pointer}.skipme-integration-label input{width:18px;height:18px;margin:0;flex-shrink:0;accent-color:#00a4dc}.skipme-integration-label input:focus-visible{outline:2px solid #00a4dc;outline-offset:3px}.skipme-integration-label input:disabled{opacity:.5;cursor:not-allowed}.skipme-integration-help{margin:8px 0 0 28px;font-size:.85em;line-height:1.5;opacity:.7}.skipme-integration-restart{color:#f5a623;opacity:1}.skipme-section-title{font-size:1em;font-weight:600;margin:1.2em 0 .6em;opacity:.8;text-transform:uppercase;letter-spacing:.04em}.skipme-movies-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));column-gap:16px;row-gap:20px;margin:.4em 0}.skipme-movie-card{border-radius:6px;overflow:hidden;background:#ffffff0a;border:1px solid rgba(255,255,255,.07);display:flex;flex-direction:column;transition:border-color .15s,opacity .2s}.skipme-movie-card:hover{border-color:#ffffff29}.skipme-movie-poster-wrap{position:relative;aspect-ratio:2 / 3;overflow:hidden;background:#ffffff0f}.skipme-movie-poster{width:100%;height:100%;object-fit:cover;display:block}.skipme-movie-no-image{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2em}.skipme-movie-footer{padding:6px 8px 8px;display:flex;flex-direction:column;align-items:stretch;flex:1;gap:7px}.skipme-movie-controls{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto}.skipme-movie-controls .skipme-toggle{margin-left:auto}.skipme-movie-name{font-size:.74em;text-align:center;opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%}.skipme-library-section{margin-bottom:1.8em}.skipme-library-section:last-child{margin-bottom:0}.skipme-library-header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:.8em}.skipme-library-title{font-size:.82em;font-weight:600;margin:0;opacity:.55;text-transform:uppercase;letter-spacing:.05em} diff --git a/SkipMe.Db.Plugin/Configuration/skipme-index.js b/SkipMe.Db.Plugin/Configuration/skipme-index.js index 8cd1945..71ece17 100644 --- a/SkipMe.Db.Plugin/Configuration/skipme-index.js +++ b/SkipMe.Db.Plugin/Configuration/skipme-index.js @@ -1,4 +1,4 @@ -(function(){"use strict";const se="b2a63e62-0ac5-4575-9ad2-2c7534ccb83d";async function g(e){const t=window.ApiClient.serverAddress().replace(/\/+$/,""),s=window.ApiClient.accessToken();return fetch(`${t}/${e}`,{headers:{Authorization:`MediaBrowser Token=${s}`}})}let V=null;async function q(){if(V)return V;const e=await g("Users/Me");if(!e.ok)throw new Error(`Failed to get current user (HTTP ${e.status})`);const t=await e.json();return V=t.Id,t.Id}function ne(){return window.ApiClient.getPluginConfiguration(se)}function ce(e){return window.ApiClient.updatePluginConfiguration(se,e)}async function me(e){const t=await q(),s=new URLSearchParams({ParentId:e,IncludeItemTypes:"Series",Recursive:"true",Fields:"ImageTags",SortBy:"SortName",SortOrder:"Ascending",UserId:t}),n=await g(`Items?${s.toString()}`);if(!n.ok)throw new Error(`Failed to fetch series for library (HTTP ${n.status})`);const i=await n.json();return{items:i.Items??[],total:i.TotalRecordCount??0}}async function pe(e){const t=new URLSearchParams({ParentId:e,IncludeItemTypes:"Season",Fields:"ImageTags",SortBy:"IndexNumber",SortOrder:"Ascending"}),s=await g(`Items?${t.toString()}`);if(!s.ok)throw new Error(`Failed to fetch seasons (HTTP ${s.status})`);return(await s.json()).Items??[]}async function ue(e){const t=await q(),s=new URLSearchParams({ParentId:e,IncludeItemTypes:"Movie",Recursive:"true",Fields:"ImageTags",SortBy:"SortName",SortOrder:"Ascending",UserId:t}),n=await g(`Items?${s.toString()}`);if(!n.ok)throw new Error(`Failed to fetch movies for library (HTTP ${n.status})`);const i=await n.json();return{items:i.Items??[],total:i.TotalRecordCount??0}}async function fe(){const e=await q(),t=await g(`Users/${encodeURIComponent(e)}/Views`);if(!t.ok)throw new Error(`Failed to fetch libraries (HTTP ${t.status})`);return(await t.json()).Items??[]}async function be(){const e=await g("Library/VirtualFolders");if(!e.ok)throw new Error(`Failed to fetch virtual folders (HTTP ${e.status})`);return await e.json()??[]}async function he(e){const t=window.ApiClient.serverAddress().replace(/\/+$/,""),s=window.ApiClient.accessToken(),n=new AbortController,i=window.setTimeout(()=>n.abort(),3e5);try{const a=await fetch(`${t}/SkipMeDb/Share`,{method:"POST",headers:{Authorization:`MediaBrowser Token=${s}`,"Content-Type":"application/json"},body:JSON.stringify(e),signal:n.signal});if(!a.ok)throw new Error(`Failed to share segments (HTTP ${a.status})`);return await a.json()}finally{window.clearTimeout(i)}}async function ye(){const e=await g("SkipMeDb/Segments/Counts");if(!e.ok)throw new Error(`Failed to fetch segment counts (HTTP ${e.status})`);return await e.json()}async function ie(){const e=await g("SkipMeDb/Share/Counts");if(!e.ok)throw new Error(`Failed to fetch shareable segment counts (HTTP ${e.status})`);return await e.json()}function W(e,t=136){return`${window.ApiClient.serverAddress().replace(/\/+$/,"")}/Items/${encodeURIComponent(e)}/Images/Primary?fillHeight=${t}&quality=90`}const ve="#skipme-root",ke=3e4,ge="skipme.db",Se="4dbabcc18d37fdc81c1dd513a47b70cb",Ie="Toggle crowd-sourced segment data on or off for individual libraries, series, seasons, or movies. Segments remain in the local database but will not be surfaced to Jellyfin when disabled.",we="Toggle local segment data on or off for individual libraries, series, seasons, or movies that will be shared with SkipMe.db. Each unique segment type can only be shared once per episode.";let D=new Set,F=new Set,$=new Set,R=new Set,S=[],U="";const H=new Map;let K=0,O=!1,Q=!1,y="sync",N=new Set,L=new Set,_=null,M=null;function ae(e,t){const s=y==="share"?M:_;if(!s)return null;if(y==="share"){const i=t==="series"?s.Series:s.Movies;return{count:oe(i,e),label:"Segments available to share"}}const n=t==="series"?s.Series:s.Movies;return{count:oe(n,e),label:"Currently synced segments"}}function oe(e,t){const s=e[t];if(s!==void 0)return s;const n=t.replaceAll("-","").toLowerCase(),i=Object.keys(e).find(a=>a.replaceAll("-","").toLowerCase()===n);return i===void 0?0:e[i]}let A=new Set,G=new Set,x=new Set,J=new Set;function f(e){return document.getElementById(e)}function B(e){const t=f(e);t&&(t.style.display="")}function E(e){const t=f(e);t&&(t.style.display="none")}function w(e,t){const s=f("skipme-status");s&&(s.textContent=e,s.className="skipme-status"+(t?" "+t:""),t==="ok"&&window.setTimeout(()=>{s.textContent="",s.className="skipme-status"},3e3))}function Ce(){ie().then(e=>{M=e,y==="share"&&I()}).catch(e=>{console.error("[SkipMe.db] Failed to refresh shareable segment counts:",e)})}function Ee(){const e=f("skipme-description");e&&(e.textContent=y==="share"?we:Ie)}function Y(e){y=e;const t=f("skipme-tab-sync"),s=f("skipme-tab-share"),n=f("skipme-save-btn"),i=f("skipme-share-btn"),a=e==="sync",l=e==="share";t==null||t.classList.toggle("is-active",a),s==null||s.classList.toggle("is-active",l),t==null||t.setAttribute("aria-selected",a?"true":"false"),s==null||s.setAttribute("aria-selected",l?"true":"false"),n&&(n.style.display=a?"":"none"),i&&(i.style.display=a?"none":""),Ee(),I()}function j(e,t,s,n){const i=document.createElement("label");i.className="skipme-toggle",i.title=n;const a=document.createElement("input");a.type="checkbox",a.id=e,a.checked=t;const l=document.createElement("span");l.className="skipme-toggle-track";const m=document.createElement("span");return m.className="skipme-toggle-thumb",l.appendChild(m),i.appendChild(a),i.appendChild(l),a.addEventListener("change",()=>{i.title=a.checked?"Enabled – click to disable":"Disabled – click to enable",s(a.checked)}),i.addEventListener("click",p=>p.stopPropagation()),{element:i,input:a}}function Te(){const e="http://www.w3.org/2000/svg",t=document.createElementNS(e,"svg");t.setAttribute("viewBox","0 0 24 24"),t.setAttribute("fill","none"),t.setAttribute("stroke","currentColor"),t.setAttribute("stroke-width","2.5"),t.setAttribute("stroke-linecap","round"),t.setAttribute("stroke-linejoin","round"),t.setAttribute("class","skipme-chevron"),t.setAttribute("aria-hidden","true");const s=document.createElementNS(e,"polyline");return s.setAttribute("points","6 9 12 15 18 9"),t.appendChild(s),t}function C(){return y==="sync"?D:A}function X(){return y==="sync"?F:G}function T(){return y==="sync"?$:x}function Z(){return y==="sync"?R:J}function Ne(e,t){var o;const s=C().has(t),n=e.IndexNumber===0,i=n?!Z().has(e.Id):X().has(e.Id),a=document.createElement("div");a.className="skipme-season-card",i&&(a.style.opacity="0.6");const l=document.createElement("div");if(l.className="skipme-season-poster-wrap",(o=e.ImageTags)!=null&&o.Primary){const d=document.createElement("img");d.className="skipme-season-poster",d.alt="",d.loading="lazy",d.decoding="async",d.src=W(e.Id,200),d.onerror=()=>{d.style.display="none";const r=document.createElement("div");r.className="skipme-season-no-image",r.textContent="πŸ“Ί",l.insertBefore(r,l.firstChild)},l.appendChild(d)}else{const d=document.createElement("div");d.className="skipme-season-no-image",d.textContent="πŸ“Ί",l.appendChild(d)}if(s){const d=document.createElement("div");d.className="skipme-season-overlay",d.textContent="Disabled via series",l.appendChild(d)}a.appendChild(l);const m=document.createElement("div");m.className="skipme-season-footer";const p=document.createElement("div");p.className="skipme-season-name";const h=e.Name??"Season "+(e.IndexNumber??"?");p.textContent=h,p.title=h;const b=j("skipme-season-"+e.Id,!i,d=>{n?d?Z().add(e.Id):Z().delete(e.Id):d?X().delete(e.Id):X().add(e.Id),a.style.opacity=d?"":"0.6"},i?"Season disabled – click to enable":"Season enabled – click to disable");return s&&(b.input.disabled=!0,b.element.title="Enable the series to manage seasons individually"),m.appendChild(p),m.appendChild(b.element),a.appendChild(m),a}function ee(e,t,s){if(e.innerHTML="",!t.length){const a=document.createElement("p");a.style.cssText="opacity:0.45;font-size:0.85em;margin:4px 0",a.textContent="No seasons found.",e.appendChild(a);return}const n=[...t].sort((a,l)=>{const m=a.IndexNumber===0,p=l.IndexNumber===0;return m!==p?m?1:-1:(a.IndexNumber??0)-(l.IndexNumber??0)}),i=document.createElement("div");i.className="skipme-seasons-grid";for(const a of n)i.appendChild(Ne(a,s));e.appendChild(i)}function Le(e,t){const s=H.get(t);if(s){ee(e,s,t);return}e.innerHTML="";const n=document.createElement("div");n.className="skipme-seasons-loading",n.innerHTML='
Loading seasons…',e.appendChild(n),pe(t).then(i=>{H.set(t,i),ee(e,i,t)}).catch(()=>{e.innerHTML='

Failed to load seasons.

'})}function Me(e){var u;const t=C().has(e.Id),s=document.createElement("div");s.className="skipme-series-card";const n=document.createElement("div");n.className="skipme-series-header",n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-expanded","false");let i;if((u=e.ImageTags)!=null&&u.Primary){const c=document.createElement("img");c.className="skipme-series-poster",c.alt="",c.loading="lazy",c.decoding="async",c.src=W(e.Id,96),c.onerror=()=>{c.style.visibility="hidden"},i=c}else i=document.createElement("div"),i.className="skipme-series-poster-placeholder",i.setAttribute("aria-hidden","true"),i.textContent="πŸ“Ί";const a=document.createElement("div");a.className="skipme-series-info";const l=document.createElement("div");l.className="skipme-series-name",l.textContent=e.Name??"Unknown Series";const m=document.createElement("div");m.className="skipme-series-hint"+(t?" is-off":""),m.textContent=t?"Segments disabled for all episodes":"Expand to manage individual seasons",a.appendChild(l),a.appendChild(m);const p=document.createElement("div");p.className="skipme-series-controls";const h=Te(),b=ae(e.Id,"series");if(b){const c=document.createElement("span");c.className="skipme-segment-count",c.textContent=String(b.count),c.title=b.label,c.setAttribute("aria-label",`${b.count} ${b.label.toLowerCase()}`),p.appendChild(c)}const o=document.createElement("div");o.className="skipme-seasons-panel";const d=j("skipme-series-"+e.Id,!t,c=>{if(c?(C().delete(e.Id),m.textContent="Expand to manage individual seasons",m.className="skipme-series-hint"):(C().add(e.Id),m.textContent="Segments disabled for all episodes",m.className="skipme-series-hint is-off"),s.classList.contains("is-expanded")){const v=H.get(e.Id);v&&ee(o,v,e.Id)}},t?"Series disabled – click to enable":"Series enabled – click to disable");p.appendChild(h),p.appendChild(d.element),n.appendChild(i),n.appendChild(a),n.appendChild(p);const r=()=>{const c=s.classList.toggle("is-expanded");n.setAttribute("aria-expanded",c?"true":"false"),c&&!o.dataset.loaded&&(o.dataset.loaded="true",Le(o,e.Id))};return n.addEventListener("click",r),n.addEventListener("keydown",c=>{(c.key==="Enter"||c.key===" ")&&(c.preventDefault(),r())}),s.appendChild(n),s.appendChild(o),s}function Ae(e){var b;const t=T().has(e.Id),s=document.createElement("div");s.className="skipme-movie-card",t&&(s.style.opacity="0.6");const n=document.createElement("div");if(n.className="skipme-movie-poster-wrap",(b=e.ImageTags)!=null&&b.Primary){const o=document.createElement("img");o.className="skipme-movie-poster",o.alt="",o.loading="lazy",o.decoding="async",o.src=W(e.Id,200),o.onerror=()=>{o.style.display="none";const d=document.createElement("div");d.className="skipme-movie-no-image",d.textContent="🎬",n.insertBefore(d,n.firstChild)},n.appendChild(o)}else{const o=document.createElement("div");o.className="skipme-movie-no-image",o.textContent="🎬",n.appendChild(o)}s.appendChild(n);const i=document.createElement("div");i.className="skipme-movie-footer";const a=document.createElement("div");a.className="skipme-movie-name";const l=e.Name??"Unknown Movie";a.textContent=l,a.title=l;const m=j("skipme-movie-"+e.Id,!t,o=>{o?(T().delete(e.Id),s.style.opacity=""):(T().add(e.Id),s.style.opacity="0.6")},t?"Movie disabled – click to enable":"Movie enabled – click to disable"),p=document.createElement("div");p.className="skipme-movie-controls";const h=ae(e.Id,"movie");if(h){const o=document.createElement("span");o.className="skipme-segment-count",o.textContent=String(h.count),o.title=h.label,o.setAttribute("aria-label",`${h.count} ${h.label.toLowerCase()}`),p.appendChild(o)}return p.appendChild(m.element),i.appendChild(a),i.appendChild(p),s.appendChild(i),s}function xe(e){const t=e.seriesItems.every(n=>!C().has(n.Id)),s=e.movieItems.every(n=>!T().has(n.Id));return t&&s}function Pe(e,t){for(const s of e.seriesItems)t?C().delete(s.Id):C().add(s.Id);for(const s of e.movieItems)t?T().delete(s.Id):T().add(s.Id)}function I(){const e=f("skipme-library-sections");if(!e)return;e.innerHTML="",N=new Set,L=new Set;const t=U;let s=!1;for(const n of S){const i=t?n.seriesItems.filter(o=>(o.Name??"").toLowerCase().includes(t)):n.seriesItems,a=t?n.movieItems.filter(o=>(o.Name??"").toLowerCase().includes(t)):n.movieItems;if(!i.length&&!a.length)continue;s=!0;const l=document.createElement("div");l.className="skipme-library-section";const m=document.createElement("div");m.className="skipme-library-header";const p=document.createElement("h4");p.className="skipme-library-title",p.textContent=n.libraryName,m.appendChild(p);const h=xe(n),b=j("skipme-library-"+n.libraryId,h,o=>{Pe(n,o),I()},h?"Library enabled – click to disable":"Library disabled – click to enable");if(m.appendChild(b.element),l.appendChild(m),i.length){const o=document.createElement("div");o.className="skipme-series-list";const d=document.createDocumentFragment();for(const r of i)N.add(r.Id),d.appendChild(Me(r));o.appendChild(d),l.appendChild(o)}if(a.length){const o=document.createElement("div");o.className="skipme-movies-grid";for(const d of a)L.add(d.Id),o.appendChild(Ae(d));l.appendChild(o)}e.appendChild(l)}s?(E("skipme-empty"),B("skipme-content-container")):(E("skipme-content-container"),B("skipme-empty"))}function De(e,t,s){var m;const n=t.get(e.Id),i=s.get((e.Name??"").toLowerCase()),a=n??i;if(!a)return!0;const l=new Set((((m=a.LibraryOptions)==null?void 0:m.DisabledMediaSegmentProviders)??[]).map(p=>p==null?void 0:p.toLowerCase()).filter(p=>!!p));return!l.has(Se)&&!l.has(ge)}function Fe(){O||(O=!0,B("skipme-loading"),E("skipme-error"),E("skipme-empty"),E("skipme-content-container"),Promise.resolve().then(async()=>{const[e,t,s]=await Promise.all([ne(),fe().catch(()=>[]),be().catch(()=>[])]);D=new Set(e.DisabledSeriesIds??[]),F=new Set(e.DisabledSeasonIds??[]),$=new Set(e.DisabledMovieIds??[]),R=new Set(e.EnabledSpecialsSeasonIds??[]),_=null,M=null,U="";const n=f("skipme-search");n&&(n.value="");const i=new Map,a=new Map;for(const r of s){const u=r.ItemId;u&&i.set(u,r);const c=(r.Name??"").toLowerCase();c&&a.set(c,r)}const m=t.filter(r=>De(r,i,a)).map(async r=>{const u=r.CollectionType??null;let c=[],v=0,z=[],le=0;if(u==="tvshows"||u===null){const P=await me(r.Id).catch(()=>({items:[],total:0}));c=P.items,v=P.total}if(u==="movies"||u===null){const P=await ue(r.Id).catch(()=>({items:[],total:0}));z=P.items,le=P.total}return{lib:r,seriesItems:c,seriesTotalCount:v,movieItems:z,moviesTotalCount:le}});S=(await Promise.all(m)).filter(({seriesItems:r,movieItems:u})=>r.length>0||u.length>0).map(({lib:r,seriesItems:u,seriesTotalCount:c,movieItems:v,moviesTotalCount:z})=>({libraryId:r.Id,libraryName:r.Name??"Library",collectionType:r.CollectionType??null,seriesItems:u,seriesTotalCount:c,movieItems:v,moviesTotalCount:z})),S.sort((r,u)=>{const c=v=>v==="tvshows"?0:v==="movies"?1:2;return c(r.collectionType)-c(u.collectionType)}),A=new Set(S.flatMap(r=>r.seriesItems.map(u=>u.Id))),x=new Set(S.flatMap(r=>r.movieItems.map(u=>u.Id)));const h=S.some(r=>r.seriesTotalCount>r.seriesItems.length),b=S.some(r=>r.moviesTotalCount>r.movieItems.length),o=f("skipme-truncation-note");o&&(h?(o.textContent="Some TV series libraries are very large; not all series could be loaded. Use the search bar to find hidden series.",o.style.display=""):o.style.display="none");const d=f("skipme-movie-truncation-note");d&&(b?(d.textContent="Some movie libraries are very large; not all movies could be loaded. Use the search bar to find hidden movies.",d.style.display=""):d.style.display="none"),I(),$e()}).catch(e=>{console.error("[SkipMe.db] Failed to initialise settings page:",e),B("skipme-error")}).finally(()=>{O=!1,E("skipme-loading")}))}function $e(){ye().then(e=>{_=e,y==="sync"&&I()}).catch(e=>{console.error("[SkipMe.db] Failed to load synced segment counts:",e)}),ie().then(e=>{M=e,y==="share"&&I()}).catch(e=>{console.error("[SkipMe.db] Failed to load shareable segment counts:",e)})}function Re(){const e=f("skipme-save-btn");e&&(e.disabled=!0,w("Saving…",""),ne().then(t=>(t.DisabledSeriesIds=Array.from(D),t.DisabledSeasonIds=Array.from(F),t.DisabledMovieIds=Array.from($),t.EnabledSpecialsSeasonIds=Array.from(R),ce(t))).then(()=>w("Settings saved.","ok")).catch(t=>{console.error("[SkipMe.db] Failed to save settings:",t),w("Failed to save β€” please try again.","err")}).finally(()=>{e&&(e.disabled=!1)}))}function Ue(){const e=f("skipme-share-btn");if(!e)return;e.disabled=!0,e.classList.add("is-loading"),w("Sharing…","");const t={FilteredSeriesIds:Array.from(N),FilteredMovieIds:Array.from(L),DisabledSeriesIds:Array.from(A),DisabledSeasonIds:Array.from(G),DisabledMovieIds:Array.from(x),EnabledSpecialsSeasonIds:Array.from(J)};he(t).then(async s=>{if(!s.Ok&&!s.SharedSegments){const i=s.Error?` ${s.Error}`:"";w(`Share failed.${i}`,"err");return}for(const i of N)A.add(i);for(const i of L)x.add(i);I();const n=`Shared ${s.SharedSegments} segment(s). Skipped ${s.SkippedAlreadyShared} already shared, ${s.SkippedMissingMetadata} missing metadata, ${s.SkippedNoSegments} without Intro Skipper timestamps.`;w(n,"ok"),Ce()}).catch(s=>{console.error("[SkipMe.db] Failed to share segments:",s),w("Failed to share β€” please try again.","err")}).finally(()=>{e.disabled=!1,e.classList.remove("is-loading")})}function He(){return` +(function(){"use strict";const ae="b2a63e62-0ac5-4575-9ad2-2c7534ccb83d";async function v(e){const t=window.ApiClient.serverAddress().replace(/\/+$/,""),s=window.ApiClient.accessToken();return fetch(`${t}/${e}`,{headers:{Authorization:`MediaBrowser Token=${s}`}})}let W=null;async function J(){if(W)return W;const e=await v("Users/Me");if(!e.ok)throw new Error(`Failed to get current user (HTTP ${e.status})`);const t=await e.json();return W=t.Id,t.Id}function oe(){return window.ApiClient.getPluginConfiguration(ae)}function ue(e){return window.ApiClient.updatePluginConfiguration(ae,e)}async function fe(e){const t=await J(),s=new URLSearchParams({ParentId:e,IncludeItemTypes:"Series",Recursive:"true",Fields:"ImageTags",SortBy:"SortName",SortOrder:"Ascending",UserId:t}),n=await v(`Items?${s.toString()}`);if(!n.ok)throw new Error(`Failed to fetch series for library (HTTP ${n.status})`);const i=await n.json();return{items:i.Items??[],total:i.TotalRecordCount??0}}async function be(e){const t=new URLSearchParams({ParentId:e,IncludeItemTypes:"Season",Fields:"ImageTags",SortBy:"IndexNumber",SortOrder:"Ascending"}),s=await v(`Items?${t.toString()}`);if(!s.ok)throw new Error(`Failed to fetch seasons (HTTP ${s.status})`);return(await s.json()).Items??[]}async function he(e){const t=await J(),s=new URLSearchParams({ParentId:e,IncludeItemTypes:"Movie",Recursive:"true",Fields:"ImageTags",SortBy:"SortName",SortOrder:"Ascending",UserId:t}),n=await v(`Items?${s.toString()}`);if(!n.ok)throw new Error(`Failed to fetch movies for library (HTTP ${n.status})`);const i=await n.json();return{items:i.Items??[],total:i.TotalRecordCount??0}}async function ke(){const e=await J(),t=await v(`Users/${encodeURIComponent(e)}/Views`);if(!t.ok)throw new Error(`Failed to fetch libraries (HTTP ${t.status})`);return(await t.json()).Items??[]}async function ye(){const e=await v("Library/VirtualFolders");if(!e.ok)throw new Error(`Failed to fetch virtual folders (HTTP ${e.status})`);return await e.json()??[]}async function ge(e){const t=window.ApiClient.serverAddress().replace(/\/+$/,""),s=window.ApiClient.accessToken(),n=new AbortController,i=window.setTimeout(()=>n.abort(),3e5);try{const a=await fetch(`${t}/SkipMeDb/Share`,{method:"POST",headers:{Authorization:`MediaBrowser Token=${s}`,"Content-Type":"application/json"},body:JSON.stringify(e),signal:n.signal});if(!a.ok)throw new Error(`Failed to share segments (HTTP ${a.status})`);return await a.json()}finally{window.clearTimeout(i)}}async function ve(){const e=await v("SkipMeDb/Segments/Counts");if(!e.ok)throw new Error(`Failed to fetch segment counts (HTTP ${e.status})`);return await e.json()}async function re(){const e=await v("SkipMeDb/Share/Counts");if(!e.ok)throw new Error(`Failed to fetch shareable segment counts (HTTP ${e.status})`);return await e.json()}function K(e,t=136){return`${window.ApiClient.serverAddress().replace(/\/+$/,"")}/Items/${encodeURIComponent(e)}/Images/Primary?fillHeight=${t}&quality=90`}const Se="#skipme-root",Ie=3e4,we="skipme.db",Ce="4dbabcc18d37fdc81c1dd513a47b70cb",Ee="Toggle crowd-sourced segment data on or off for individual libraries, series, seasons, or movies. Segments remain in the local database but will not be surfaced to Jellyfin when disabled.",Te="Toggle local segment data on or off for individual libraries, series, seasons, or movies that will be shared with SkipMe.db. Each unique segment type can only be shared once per episode.";let R=new Set,U=new Set,O=new Set,H=new Set,S=[],_="";const B=new Map;let Q=0,j=!1,G=!1,Y=!1,k="sync",L=new Set,A=new Set,z=null,x=null;function de(e,t){const s=k==="share"?x:z;if(!s)return null;if(k==="share"){const i=t==="series"?s.Series:s.Movies;return{count:le(i,e),label:"Segments available to share"}}const n=t==="series"?s.Series:s.Movies;return{count:le(n,e),label:"Currently synced segments"}}function le(e,t){const s=e[t];if(s!==void 0)return s;const n=t.replaceAll("-","").toLowerCase(),i=Object.keys(e).find(a=>a.replaceAll("-","").toLowerCase()===n);return i===void 0?0:e[i]}let P=new Set,X=new Set,D=new Set,Z=new Set;function p(e){return document.getElementById(e)}function F(e){const t=p(e);t&&(t.style.display="")}function w(e){const t=p(e);t&&(t.style.display="none")}function C(e,t){const s=p("skipme-status");s&&(s.textContent=e,s.className="skipme-status"+(t?" "+t:""),t==="ok"&&window.setTimeout(()=>{s.textContent="",s.className="skipme-status"},3e3))}function Ne(){re().then(e=>{x=e,k==="share"&&I()}).catch(e=>{console.error("[SkipMe.db] Failed to refresh shareable segment counts:",e)})}function Me(){const e=p("skipme-description");e&&(e.textContent=k==="share"?Te:Ee)}function ee(e){k=e;const t=p("skipme-tab-sync"),s=p("skipme-tab-share"),n=p("skipme-save-btn"),i=p("skipme-share-btn"),a=e==="sync",d=e==="share";t==null||t.classList.toggle("is-active",a),s==null||s.classList.toggle("is-active",d),t==null||t.setAttribute("aria-selected",a?"true":"false"),s==null||s.setAttribute("aria-selected",d?"true":"false"),n&&(n.style.display=a?"":"none"),i&&(i.style.display=a?"none":""),a?F("skipme-integration"):w("skipme-integration"),Me(),I()}function V(e,t,s,n){const i=document.createElement("label");i.className="skipme-toggle",i.title=n;const a=document.createElement("input");a.type="checkbox",a.id=e,a.checked=t;const d=document.createElement("span");d.className="skipme-toggle-track";const c=document.createElement("span");return c.className="skipme-toggle-thumb",d.appendChild(c),i.appendChild(a),i.appendChild(d),a.addEventListener("change",()=>{i.title=a.checked?"Enabled – click to disable":"Disabled – click to enable",s(a.checked)}),i.addEventListener("click",m=>m.stopPropagation()),{element:i,input:a}}function Le(){const e="http://www.w3.org/2000/svg",t=document.createElementNS(e,"svg");t.setAttribute("viewBox","0 0 24 24"),t.setAttribute("fill","none"),t.setAttribute("stroke","currentColor"),t.setAttribute("stroke-width","2.5"),t.setAttribute("stroke-linecap","round"),t.setAttribute("stroke-linejoin","round"),t.setAttribute("class","skipme-chevron"),t.setAttribute("aria-hidden","true");const s=document.createElementNS(e,"polyline");return s.setAttribute("points","6 9 12 15 18 9"),t.appendChild(s),t}function E(){return k==="sync"?R:P}function te(){return k==="sync"?U:X}function M(){return k==="sync"?O:D}function se(){return k==="sync"?H:Z}function Ae(e,t){var r;const s=E().has(t),n=e.IndexNumber===0,i=n?!se().has(e.Id):te().has(e.Id),a=document.createElement("div");a.className="skipme-season-card",i&&(a.style.opacity="0.6");const d=document.createElement("div");if(d.className="skipme-season-poster-wrap",(r=e.ImageTags)!=null&&r.Primary){const l=document.createElement("img");l.className="skipme-season-poster",l.alt="",l.loading="lazy",l.decoding="async",l.src=K(e.Id,200),l.onerror=()=>{l.style.display="none";const h=document.createElement("div");h.className="skipme-season-no-image",h.textContent="πŸ“Ί",d.insertBefore(h,d.firstChild)},d.appendChild(l)}else{const l=document.createElement("div");l.className="skipme-season-no-image",l.textContent="πŸ“Ί",d.appendChild(l)}if(s){const l=document.createElement("div");l.className="skipme-season-overlay",l.textContent="Disabled via series",d.appendChild(l)}a.appendChild(d);const c=document.createElement("div");c.className="skipme-season-footer";const m=document.createElement("div");m.className="skipme-season-name";const f=e.Name??"Season "+(e.IndexNumber??"?");m.textContent=f,m.title=f;const b=V("skipme-season-"+e.Id,!i,l=>{n?l?se().add(e.Id):se().delete(e.Id):l?te().delete(e.Id):te().add(e.Id),a.style.opacity=l?"":"0.6"},i?"Season disabled – click to enable":"Season enabled – click to disable");return s&&(b.input.disabled=!0,b.element.title="Enable the series to manage seasons individually"),c.appendChild(m),c.appendChild(b.element),a.appendChild(c),a}function ne(e,t,s){if(e.innerHTML="",!t.length){const a=document.createElement("p");a.style.cssText="opacity:0.45;font-size:0.85em;margin:4px 0",a.textContent="No seasons found.",e.appendChild(a);return}const n=[...t].sort((a,d)=>{const c=a.IndexNumber===0,m=d.IndexNumber===0;return c!==m?c?1:-1:(a.IndexNumber??0)-(d.IndexNumber??0)}),i=document.createElement("div");i.className="skipme-seasons-grid";for(const a of n)i.appendChild(Ae(a,s));e.appendChild(i)}function xe(e,t){const s=B.get(t);if(s){ne(e,s,t);return}e.innerHTML="";const n=document.createElement("div");n.className="skipme-seasons-loading",n.innerHTML='
Loading seasons…',e.appendChild(n),be(t).then(i=>{B.set(t,i),ne(e,i,t)}).catch(()=>{e.innerHTML='

Failed to load seasons.

'})}function Pe(e){var T;const t=E().has(e.Id),s=document.createElement("div");s.className="skipme-series-card";const n=document.createElement("div");n.className="skipme-series-header",n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-expanded","false");let i;if((T=e.ImageTags)!=null&&T.Primary){const o=document.createElement("img");o.className="skipme-series-poster",o.alt="",o.loading="lazy",o.decoding="async",o.src=K(e.Id,96),o.onerror=()=>{o.style.visibility="hidden"},i=o}else i=document.createElement("div"),i.className="skipme-series-poster-placeholder",i.setAttribute("aria-hidden","true"),i.textContent="πŸ“Ί";const a=document.createElement("div");a.className="skipme-series-info";const d=document.createElement("div");d.className="skipme-series-name",d.textContent=e.Name??"Unknown Series";const c=document.createElement("div");c.className="skipme-series-hint"+(t?" is-off":""),c.textContent=t?"Segments disabled for all episodes":"Expand to manage individual seasons",a.appendChild(d),a.appendChild(c);const m=document.createElement("div");m.className="skipme-series-controls";const f=Le(),b=de(e.Id,"series");if(b){const o=document.createElement("span");o.className="skipme-segment-count",o.textContent=String(b.count),o.title=b.label,o.setAttribute("aria-label",`${b.count} ${b.label.toLowerCase()}`),m.appendChild(o)}const r=document.createElement("div");r.className="skipme-seasons-panel";const l=V("skipme-series-"+e.Id,!t,o=>{if(o?(E().delete(e.Id),c.textContent="Expand to manage individual seasons",c.className="skipme-series-hint"):(E().add(e.Id),c.textContent="Segments disabled for all episodes",c.className="skipme-series-hint is-off"),s.classList.contains("is-expanded")){const u=B.get(e.Id);u&&ne(r,u,e.Id)}},t?"Series disabled – click to enable":"Series enabled – click to disable");m.appendChild(f),m.appendChild(l.element),n.appendChild(i),n.appendChild(a),n.appendChild(m);const h=()=>{const o=s.classList.toggle("is-expanded");n.setAttribute("aria-expanded",o?"true":"false"),o&&!r.dataset.loaded&&(r.dataset.loaded="true",xe(r,e.Id))};return n.addEventListener("click",h),n.addEventListener("keydown",o=>{(o.key==="Enter"||o.key===" ")&&(o.preventDefault(),h())}),s.appendChild(n),s.appendChild(r),s}function De(e){var b;const t=M().has(e.Id),s=document.createElement("div");s.className="skipme-movie-card",t&&(s.style.opacity="0.6");const n=document.createElement("div");if(n.className="skipme-movie-poster-wrap",(b=e.ImageTags)!=null&&b.Primary){const r=document.createElement("img");r.className="skipme-movie-poster",r.alt="",r.loading="lazy",r.decoding="async",r.src=K(e.Id,200),r.onerror=()=>{r.style.display="none";const l=document.createElement("div");l.className="skipme-movie-no-image",l.textContent="🎬",n.insertBefore(l,n.firstChild)},n.appendChild(r)}else{const r=document.createElement("div");r.className="skipme-movie-no-image",r.textContent="🎬",n.appendChild(r)}s.appendChild(n);const i=document.createElement("div");i.className="skipme-movie-footer";const a=document.createElement("div");a.className="skipme-movie-name";const d=e.Name??"Unknown Movie";a.textContent=d,a.title=d;const c=V("skipme-movie-"+e.Id,!t,r=>{r?(M().delete(e.Id),s.style.opacity=""):(M().add(e.Id),s.style.opacity="0.6")},t?"Movie disabled – click to enable":"Movie enabled – click to disable"),m=document.createElement("div");m.className="skipme-movie-controls";const f=de(e.Id,"movie");if(f){const r=document.createElement("span");r.className="skipme-segment-count",r.textContent=String(f.count),r.title=f.label,r.setAttribute("aria-label",`${f.count} ${f.label.toLowerCase()}`),m.appendChild(r)}return m.appendChild(c.element),i.appendChild(a),i.appendChild(m),s.appendChild(i),s}function Fe(e){const t=e.seriesItems.every(n=>!E().has(n.Id)),s=e.movieItems.every(n=>!M().has(n.Id));return t&&s}function $e(e,t){for(const s of e.seriesItems)t?E().delete(s.Id):E().add(s.Id);for(const s of e.movieItems)t?M().delete(s.Id):M().add(s.Id)}function I(){const e=p("skipme-library-sections");if(!e)return;e.innerHTML="",L=new Set,A=new Set;const t=_;let s=!1;for(const n of S){const i=t?n.seriesItems.filter(r=>(r.Name??"").toLowerCase().includes(t)):n.seriesItems,a=t?n.movieItems.filter(r=>(r.Name??"").toLowerCase().includes(t)):n.movieItems;if(!i.length&&!a.length)continue;s=!0;const d=document.createElement("div");d.className="skipme-library-section";const c=document.createElement("div");c.className="skipme-library-header";const m=document.createElement("h4");m.className="skipme-library-title",m.textContent=n.libraryName,c.appendChild(m);const f=Fe(n),b=V("skipme-library-"+n.libraryId,f,r=>{$e(n,r),I()},f?"Library enabled – click to disable":"Library disabled – click to enable");if(c.appendChild(b.element),d.appendChild(c),i.length){const r=document.createElement("div");r.className="skipme-series-list";const l=document.createDocumentFragment();for(const h of i)L.add(h.Id),l.appendChild(Pe(h));r.appendChild(l),d.appendChild(r)}if(a.length){const r=document.createElement("div");r.className="skipme-movies-grid";for(const l of a)A.add(l.Id),r.appendChild(De(l));d.appendChild(r)}e.appendChild(d)}s?(w("skipme-empty"),F("skipme-content-container")):(w("skipme-content-container"),F("skipme-empty"))}function Re(e,t,s){var c;const n=t.get(e.Id),i=s.get((e.Name??"").toLowerCase()),a=n??i;if(!a)return!0;const d=new Set((((c=a.LibraryOptions)==null?void 0:c.DisabledMediaSegmentProviders)??[]).map(m=>m==null?void 0:m.toLowerCase()).filter(m=>!!m));return!d.has(Ce)&&!d.has(we)}function Ue(){j||(j=!0,F("skipme-loading"),w("skipme-error"),w("skipme-empty"),w("skipme-content-container"),Promise.resolve().then(async()=>{const[e,t,s]=await Promise.all([oe(),ke().catch(()=>[]),ye().catch(()=>[])]);R=new Set(e.DisabledSeriesIds??[]),U=new Set(e.DisabledSeasonIds??[]),O=new Set(e.DisabledMovieIds??[]),H=new Set(e.EnabledSpecialsSeasonIds??[]);const n=p("skipme-intro-skipper-integration");n&&(n.checked=e.EnableIntroSkipperIntegration===!0,n.disabled=!1),G=!0;const i=p("skipme-save-btn");i&&(i.disabled=!1),z=null,x=null,_="";const a=p("skipme-search");a&&(a.value="");const d=new Map,c=new Map;for(const o of s){const u=o.ItemId;u&&d.set(u,o);const y=(o.Name??"").toLowerCase();y&&c.set(y,o)}const f=t.filter(o=>Re(o,d,c)).map(async o=>{const u=o.CollectionType??null;let y=[],N=0,q=[],pe=0;if(u==="tvshows"||u===null){const $=await fe(o.Id).catch(()=>({items:[],total:0}));y=$.items,N=$.total}if(u==="movies"||u===null){const $=await he(o.Id).catch(()=>({items:[],total:0}));q=$.items,pe=$.total}return{lib:o,seriesItems:y,seriesTotalCount:N,movieItems:q,moviesTotalCount:pe}});S=(await Promise.all(f)).filter(({seriesItems:o,movieItems:u})=>o.length>0||u.length>0).map(({lib:o,seriesItems:u,seriesTotalCount:y,movieItems:N,moviesTotalCount:q})=>({libraryId:o.Id,libraryName:o.Name??"Library",collectionType:o.CollectionType??null,seriesItems:u,seriesTotalCount:y,movieItems:N,moviesTotalCount:q})),S.sort((o,u)=>{const y=N=>N==="tvshows"?0:N==="movies"?1:2;return y(o.collectionType)-y(u.collectionType)}),P=new Set(S.flatMap(o=>o.seriesItems.map(u=>u.Id))),D=new Set(S.flatMap(o=>o.movieItems.map(u=>u.Id)));const r=S.some(o=>o.seriesTotalCount>o.seriesItems.length),l=S.some(o=>o.moviesTotalCount>o.movieItems.length),h=p("skipme-truncation-note");h&&(r?(h.textContent="Some TV series libraries are very large; not all series could be loaded. Use the search bar to find hidden series.",h.style.display=""):h.style.display="none");const T=p("skipme-movie-truncation-note");T&&(l?(T.textContent="Some movie libraries are very large; not all movies could be loaded. Use the search bar to find hidden movies.",T.style.display=""):T.style.display="none"),I(),Oe()}).catch(e=>{console.error("[SkipMe.db] Failed to initialise settings page:",e),F("skipme-error")}).finally(()=>{j=!1,w("skipme-loading")}))}function Oe(){ve().then(e=>{z=e,k==="sync"&&I()}).catch(e=>{console.error("[SkipMe.db] Failed to load synced segment counts:",e)}),re().then(e=>{x=e,k==="share"&&I()}).catch(e=>{console.error("[SkipMe.db] Failed to load shareable segment counts:",e)})}function He(){const e=p("skipme-save-btn"),t=p("skipme-intro-skipper-integration");if(!e||!t||!G||e.disabled)return;const s=t.checked;let n=!1;e.disabled=!0,t.disabled=!0,C("Saving…",""),Promise.resolve().then(()=>oe()).then(i=>(n=i.EnableIntroSkipperIntegration===!0!==s,i.EnableIntroSkipperIntegration=s,i.DisabledSeriesIds=Array.from(R),i.DisabledSeasonIds=Array.from(U),i.DisabledMovieIds=Array.from(O),i.EnabledSpecialsSeasonIds=Array.from(H),ue(i))).then(()=>C(n?"Settings saved. Restart Jellyfin to apply the Intro Skipper integration change.":"Settings saved.","ok")).catch(i=>{console.error("[SkipMe.db] Failed to save settings:",i),C("Failed to save β€” please try again.","err")}).finally(()=>{e.disabled=!1,t.disabled=!1})}function _e(){const e=p("skipme-share-btn");if(!e)return;e.disabled=!0,e.classList.add("is-loading"),C("Sharing…","");const t={FilteredSeriesIds:Array.from(L),FilteredMovieIds:Array.from(A),DisabledSeriesIds:Array.from(P),DisabledSeasonIds:Array.from(X),DisabledMovieIds:Array.from(D),EnabledSpecialsSeasonIds:Array.from(Z)};ge(t).then(async s=>{if(!s.Ok&&!s.SharedSegments){const i=s.Error?` ${s.Error}`:"";C(`Share failed.${i}`,"err");return}for(const i of L)P.add(i);for(const i of A)D.add(i);I();const n=`Shared ${s.SharedSegments} segment(s). Skipped ${s.SkippedAlreadyShared} already shared, ${s.SkippedMissingMetadata} missing metadata, ${s.SkippedNoSegments} without Intro Skipper timestamps.`;C(n,"ok"),Ne()}).catch(s=>{console.error("[SkipMe.db] Failed to share segments:",s),C("Failed to share β€” please try again.","err")}).finally(()=>{e.disabled=!1,e.classList.remove("is-loading")})}function Be(){return`

SkipMe.db – Settings

@@ -14,6 +14,21 @@
+
+ +

+ Off by default. When a compatible Intro Skipper is installed, use authoritative SkipMe matches + in Intro Skipper's segment analysis instead of the standalone SkipMe provider. +

+

+ Restart Jellyfin after changing and saving this setting. Changes do not take effect until restart. +

+
+ @@ -45,9 +60,9 @@ -
`}function Oe(){if(Q)return;const e=f("skipme-search"),t=f("skipme-save-btn"),s=f("skipme-share-btn"),n=f("skipme-tab-sync"),i=f("skipme-tab-share");!e||!t||!s||!n||!i||(Q=!0,e.addEventListener("input",a=>{window.clearTimeout(K);const l=a.target.value.trim().toLowerCase();K=window.setTimeout(()=>{U=l,I()},150)}),t.addEventListener("click",Re),s.addEventListener("click",Ue),n.addEventListener("click",()=>Y("sync")),i.addEventListener("click",()=>Y("share")))}let k=null;function te(e){k==null||k(),k=null,Q=!1,O=!1,D=new Set,F=new Set,$=new Set,R=new Set,A=new Set,G=new Set,x=new Set,J=new Set,S=[],U="",y="sync",N=new Set,L=new Set,_=null,M=null,H.clear(),e.innerHTML=He(),Y(y),Oe(),Fe(),k=()=>{window.clearTimeout(K)}}function re(){k==null||k(),k=null}function _e(e){if(e.dataset.skipmebound==="true")return;e.dataset.skipmebound="true";const t=e.closest(".page")??e.closest("[data-role='page']")??e;t.addEventListener("pageshow",()=>te(e)),t.addEventListener("viewshow",()=>te(e)),t.addEventListener("pagehide",re),t.addEventListener("viewhide",re),te(e)}function de(){const e=document.querySelector(ve);return e?(_e(e),!0):!1}if(!de()){const e=new MutationObserver(()=>{de()&&(e.disconnect(),window.clearTimeout(t))});e.observe(document.body??document.documentElement,{childList:!0,subtree:!0});const t=window.setTimeout(()=>e.disconnect(),ke)}})(); + `}function je(){if(Y)return;const e=p("skipme-search"),t=p("skipme-save-btn"),s=p("skipme-share-btn"),n=p("skipme-tab-sync"),i=p("skipme-tab-share");!e||!t||!s||!n||!i||(Y=!0,e.addEventListener("input",a=>{window.clearTimeout(Q);const d=a.target.value.trim().toLowerCase();Q=window.setTimeout(()=>{_=d,I()},150)}),t.addEventListener("click",He),s.addEventListener("click",_e),n.addEventListener("click",()=>ee("sync")),i.addEventListener("click",()=>ee("share")))}let g=null;function ie(e){g==null||g(),g=null,Y=!1,j=!1,G=!1,R=new Set,U=new Set,O=new Set,H=new Set,P=new Set,X=new Set,D=new Set,Z=new Set,S=[],_="",k="sync",L=new Set,A=new Set,z=null,x=null,B.clear(),e.innerHTML=Be(),ee(k),je(),Ue(),g=()=>{window.clearTimeout(Q)}}function ce(){g==null||g(),g=null}function ze(e){if(e.dataset.skipmebound==="true")return;e.dataset.skipmebound="true";const t=e.closest(".page")??e.closest("[data-role='page']")??e;t.addEventListener("pageshow",()=>ie(e)),t.addEventListener("viewshow",()=>ie(e)),t.addEventListener("pagehide",ce),t.addEventListener("viewhide",ce),ie(e)}function me(){const e=document.querySelector(Se);return e?(ze(e),!0):!1}if(!me()){const e=new MutationObserver(()=>{me()&&(e.disconnect(),window.clearTimeout(t))});e.observe(document.body??document.documentElement,{childList:!0,subtree:!0});const t=window.setTimeout(()=>e.disconnect(),Ie)}})(); diff --git a/SkipMe.Db.Plugin/Services/IntroSkipperRegistration.cs b/SkipMe.Db.Plugin/Services/IntroSkipperRegistration.cs index 65c42ea..ae4cfa5 100644 --- a/SkipMe.Db.Plugin/Services/IntroSkipperRegistration.cs +++ b/SkipMe.Db.Plugin/Services/IntroSkipperRegistration.cs @@ -2,8 +2,11 @@ // SPDX-License-Identifier: GPL-3.0-only using System.Reflection; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.MediaSegments; +using MediaBrowser.Model.Serialization; using Microsoft.Extensions.DependencyInjection; +using SkipMe.Db.Plugin.Configuration; using SkipMe.Db.Plugin.Providers; namespace SkipMe.Db.Plugin.Services; @@ -12,6 +15,11 @@ internal static class IntroSkipperRegistration { internal static bool TryRegister(IServiceCollection services, IEnumerable assemblies) { + if (!IsEnabled(services)) + { + return false; + } + foreach (var assembly in assemblies) { if (!string.Equals(assembly.GetName().Name, "IntroSkipper", StringComparison.Ordinal)) @@ -54,4 +62,25 @@ internal static bool TryRegister(IServiceCollection services, IEnumerable !descriptor.IsKeyedService && descriptor.ServiceType == typeof(IApplicationPaths))?.ImplementationInstance as IApplicationPaths; + var serializer = services.LastOrDefault(descriptor => !descriptor.IsKeyedService && descriptor.ServiceType == typeof(IXmlSerializer))?.ImplementationInstance as IXmlSerializer; + if (paths is null || serializer is null) + { + return false; + } + + try + { + var fileName = Path.ChangeExtension(Path.GetFileName(typeof(Plugin).Assembly.Location), ".xml"); + var path = Path.Combine(paths.PluginConfigurationsPath, fileName); + return serializer.DeserializeFromFile(typeof(PluginConfiguration), path) is PluginConfiguration { EnableIntroSkipperIntegration: true }; + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + return false; + } + } } diff --git a/web/src/main.ts b/web/src/main.ts index 90485d7..d69b875 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -52,6 +52,7 @@ let filterQuery = ""; const seasonCache = new Map(); let searchDebounce = 0; let initRunning = false; +let configLoaded = false; let eventsWired = false; let activeTab: "sync" | "share" = "sync"; let filteredSeriesIds = new Set(); @@ -156,6 +157,8 @@ function setActiveTab(tab: "sync" | "share"): void { if (saveBtn) saveBtn.style.display = syncActive ? "" : "none"; if (shareBtn) shareBtn.style.display = syncActive ? "none" : ""; + if (syncActive) show("skipme-integration"); + else hide("skipme-integration"); updateTopDescription(); renderLibrarySections(); @@ -738,6 +741,14 @@ function init(): void { disabledSeasonIds = new Set(config.DisabledSeasonIds ?? []); disabledMovieIds = new Set(config.DisabledMovieIds ?? []); enabledSpecialsSeasonIds = new Set(config.EnabledSpecialsSeasonIds ?? []); + const integrationCheckbox = byId("skipme-intro-skipper-integration"); + if (integrationCheckbox) { + integrationCheckbox.checked = config.EnableIntroSkipperIntegration === true; + integrationCheckbox.disabled = false; + } + configLoaded = true; + const saveBtn = byId("skipme-save-btn"); + if (saveBtn) saveBtn.disabled = false; syncedSegmentCounts = null; shareableSegmentCounts = null; filterQuery = ""; @@ -881,25 +892,38 @@ function loadBadgeCounts(): void { // ── Save ─────────────────────────────────────────────────────────────────────── function save(): void { const btn = byId("skipme-save-btn"); - if (!btn) return; + const integrationCheckbox = byId("skipme-intro-skipper-integration"); + if (!btn || !integrationCheckbox || !configLoaded || btn.disabled) return; + const integrationEnabled = integrationCheckbox.checked; + let integrationChanged = false; btn.disabled = true; + integrationCheckbox.disabled = true; setStatus("Saving…", ""); - loadConfig() + Promise.resolve() + .then(() => loadConfig()) .then((config) => { + integrationChanged = (config.EnableIntroSkipperIntegration === true) !== integrationEnabled; + config.EnableIntroSkipperIntegration = integrationEnabled; config.DisabledSeriesIds = Array.from(disabledSeriesIds); config.DisabledSeasonIds = Array.from(disabledSeasonIds); config.DisabledMovieIds = Array.from(disabledMovieIds); config.EnabledSpecialsSeasonIds = Array.from(enabledSpecialsSeasonIds); return saveConfig(config); }) - .then(() => setStatus("Settings saved.", "ok")) + .then(() => setStatus( + integrationChanged + ? "Settings saved. Restart Jellyfin to apply the Intro Skipper integration change." + : "Settings saved.", + "ok", + )) .catch((err: unknown) => { console.error("[SkipMe.db] Failed to save settings:", err); setStatus("Failed to save β€” please try again.", "err"); }) .finally(() => { - if (btn) btn.disabled = false; + btn.disabled = false; + integrationCheckbox.disabled = false; }); } @@ -975,6 +999,21 @@ function buildPageHTML(): string { +
+ +

+ Off by default. When a compatible Intro Skipper is installed, use authoritative SkipMe matches + in Intro Skipper's segment analysis instead of the standalone SkipMe provider. +

+

+ Restart Jellyfin after changing and saving this setting. Changes do not take effect until restart. +

+
+ @@ -1006,10 +1045,10 @@ function buildPageHTML(): string { `; } @@ -1052,6 +1091,7 @@ function mountPage(rootEl: HTMLElement): void { // Reset all page-level state so a back-navigation starts fresh. eventsWired = false; initRunning = false; + configLoaded = false; disabledSeriesIds = new Set(); disabledSeasonIds = new Set(); disabledMovieIds = new Set(); diff --git a/web/src/styles/main.css b/web/src/styles/main.css index c3be7be..c955591 100644 --- a/web/src/styles/main.css +++ b/web/src/styles/main.css @@ -381,6 +381,52 @@ opacity: 1; } +.skipme-integration { + margin: 0.5em 0 1.5em; + padding: 16px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); +} + +.skipme-integration-label { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + cursor: pointer; +} + +.skipme-integration-label input { + width: 18px; + height: 18px; + margin: 0; + flex-shrink: 0; + accent-color: #00a4dc; +} + +.skipme-integration-label input:focus-visible { + outline: 2px solid #00a4dc; + outline-offset: 3px; +} + +.skipme-integration-label input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.skipme-integration-help { + margin: 8px 0 0 28px; + font-size: 0.85em; + line-height: 1.5; + opacity: 0.7; +} + +.skipme-integration-restart { + color: #f5a623; + opacity: 1; +} + .skipme-section-title { font-size: 1em; font-weight: 600; diff --git a/web/src/types.ts b/web/src/types.ts index 4c1cc4b..53e5a75 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,5 +1,6 @@ // ── Plugin configuration (mirrors PluginConfiguration.cs) ───────────────────── export interface PluginConfig { + EnableIntroSkipperIntegration?: boolean; DisabledSeriesIds: string[]; DisabledSeasonIds: string[]; DisabledMovieIds: string[];