Commit: b9e8ddc
Parent: 1b2c5c3

Clip extractor

Mårten Åsberg committed on 2026-05-06 at 17:14
Directory.Packages.props +2 -0
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 70a4198..b45d39b 100644
@@ -8,6 +8,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</GlobalPackageReference>
<PackageVersion Include="FFMpegCore" Version="5.4.0" />
<PackageVersion Include="Jellyfin.Database.Implementations" Version="10.11.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -15,5 +16,6 @@
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="11.0.0-preview.3.26207.106" />
</ItemGroup>
</Project>
\ No newline at end of file
Slopper.slnx +1 -0
diff --git a/Slopper.slnx b/Slopper.slnx
index 6f6bfaf..cdcc498 100644
@@ -5,5 +5,6 @@
</Folder>
<Folder Name="/src/Infrastructure/">
<Project Path="src/Infrastructure/Database/Database.csproj" />
<Project Path="src/Infrastructure/Ffmpeg/Ffmpeg.csproj" />
</Folder>
</Solution>
src/Cli/Cli.csproj +1 -0
diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj
index afa67b8..e6ec537 100644
@@ -8,6 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
<ProjectReference Include="..\Infrastructure\Database\Database.csproj" />
<ProjectReference Include="..\Infrastructure\Ffmpeg\Ffmpeg.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
src/Cli/Program.cs +11 -25
diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs
index 184f2da..710e5a0 100644
@@ -1,39 +1,25 @@
using System.Linq;
using System;
using System.Reflection;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Slopper.Domain;
using Slopper.Infrastructure.Database;
using Slopper.Infrastructure.Ffmpeg;
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly());
builder.Services.AddJellyfinDatabase();
builder.Services.AddJellyfinDatabase().AddFfmpegServices();
using var app = builder.Build();
var dbContext = app.Services.GetRequiredService<JellyfinDbContext>();
var subs = dbContext
.MediaStreamInfos.AsNoTracking()
.Include(s => s.Item)
.Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle && s.Language == "eng")
.OrderBy(s => s.Item.SortName)
.Take(4);
var clipExtractor = app.Services.GetRequiredService<ClipExtractor>();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
await foreach (var sub in subs.AsAsyncEnumerable())
{
logger.LogInformation(
"{Title}: (IsExternal: {IsExternal}; Format: {Format}) {StreamIndex} {Path}",
string.Join(" ", new[] { sub.Item.SeriesName, sub.Item.SeasonName, sub.Item.Name }.Where(s => s is not null)),
sub.IsExternal,
sub.Codec,
sub.StreamIndex,
sub.Path
);
}
await clipExtractor.Clip(
new("media", args[0], new Subtitles.Embedded(0)),
TimeSpan.FromMinutes(1),
TimeSpan.FromSeconds(5),
args[1]
);
src/Cli/packages.lock.json +36 -9
diff --git a/src/Cli/packages.lock.json b/src/Cli/packages.lock.json
index 7aacb3b..b388142 100644
@@ -38,6 +38,11 @@
"Microsoft.Extensions.Options": "11.0.0-preview.3.26207.106"
}
},
"Instances": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "LfhegDpmA8PuHW58RmgVvCDG/mfVCTU+Vhy4ppmXLJfAer33Xl0NocDy92OwSL6CnkVdx41O/I0+BjNhU1JtMQ=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.7",
@@ -272,14 +277,6 @@
"Microsoft.Extensions.Options": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "0LktkD4eySHjlglnee7jt/I3KPea+MPIxLTYBacH1P/iluOCl7VVKwpG/bciZMkyaNnfslY2E70t6nfvjq51vA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
@@ -414,7 +411,11 @@
"contentHash": "dcvsAEx7YxeuSfzj5QOx8B1npA+3WuI25nLb9IscNyvgGB1cDmliDE9WRlwJvGaHhCBjqQO0G4dyB+3V7BLFzA=="
},
"Slopper.Domain": {
"type": "Project"
"type": "Project",
"dependencies": {
"FFMpegCore": "[5.4.0, )",
"Microsoft.Extensions.Logging.Abstractions": "[11.0.0-preview.3.26207.106, )"
}
},
"Slopper.Infrastructure.Database": {
"type": "Project",
@@ -424,6 +425,23 @@
"Slopper.Domain": "[1.0.0, )"
}
},
"Slopper.Infrastructure.Ffmpeg": {
"type": "Project",
"dependencies": {
"FFMpegCore": "[5.4.0, )",
"Microsoft.Extensions.Logging.Abstractions": "[11.0.0-preview.3.26207.106, )",
"Slopper.Domain": "[1.0.0, )"
}
},
"FFMpegCore": {
"type": "CentralTransitive",
"requested": "[5.4.0, )",
"resolved": "5.4.0",
"contentHash": "nymhPWpwvBaB4fOqf0thxg7Inm8SrruFQLdCMFEDKD8S3hy0/iV2vuKaJvaL9ZnbJx/2rJCHnJXOymo2apXovg==",
"dependencies": {
"Instances": "3.0.2"
}
},
"Jellyfin.Database.Implementations": {
"type": "CentralTransitive",
"requested": "[10.11.8, )",
@@ -448,6 +466,15 @@
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[11.0.0-preview.3.26207.106, )",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "0LktkD4eySHjlglnee7jt/I3KPea+MPIxLTYBacH1P/iluOCl7VVKwpG/bciZMkyaNnfslY2E70t6nfvjq51vA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106"
}
}
},
"net11.0/linux-arm64": {
src/Domain/Domain.csproj +4 -0
diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj
index e9214e7..1e887de 100644
@@ -3,4 +3,8 @@
<RootNamespace>Slopper.Domain</RootNamespace>
<AssemblyName>Slopper.Domain</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FFMpegCore" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
src/Domain/MediaItem.cs +3 -0
diff --git a/src/Domain/MediaItem.cs b/src/Domain/MediaItem.cs
new file mode 100644
index 0000000..e5ff6b9
@@ -0,0 +1,3 @@
namespace Slopper.Domain;
public sealed record MediaItem(string Name, string Path, Subtitles Subtitles);
src/Domain/Subtitles.cs +10 -0
diff --git a/src/Domain/Subtitles.cs b/src/Domain/Subtitles.cs
new file mode 100644
index 0000000..25dc5a8
@@ -0,0 +1,10 @@
namespace Slopper.Domain;
public abstract record Subtitles
{
private Subtitles() { }
public sealed record Embedded(int Index) : Subtitles();
public sealed record External(string Path) : Subtitles();
}
src/Domain/packages.lock.json +28 -0
diff --git a/src/Domain/packages.lock.json b/src/Domain/packages.lock.json
index 7f8a810..c408979 100644
@@ -7,6 +7,34 @@
"requested": "[1.2.6, )",
"resolved": "1.2.6",
"contentHash": "KMSJG+jfk7vjP52QkWB99qWespXCPAzG/IaMCMRHYWumJEAGKQYm2HtyWG6eqnOwDitH96i1cqq5EVesyOtPmg=="
},
"FFMpegCore": {
"type": "Direct",
"requested": "[5.4.0, )",
"resolved": "5.4.0",
"contentHash": "nymhPWpwvBaB4fOqf0thxg7Inm8SrruFQLdCMFEDKD8S3hy0/iV2vuKaJvaL9ZnbJx/2rJCHnJXOymo2apXovg==",
"dependencies": {
"Instances": "3.0.2"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Direct",
"requested": "[11.0.0-preview.3.26207.106, )",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "0LktkD4eySHjlglnee7jt/I3KPea+MPIxLTYBacH1P/iluOCl7VVKwpG/bciZMkyaNnfslY2E70t6nfvjq51vA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106"
}
},
"Instances": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "LfhegDpmA8PuHW58RmgVvCDG/mfVCTU+Vhy4ppmXLJfAer33Xl0NocDy92OwSL6CnkVdx41O/I0+BjNhU1JtMQ=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "+gJnv1/kfXLXPv21R3iluhKqfXdf2zPWUaHBiSvlJurThv2D5HRUfU5z5SpmBII4I0JSpuprX9DlHrKz/1wCXA=="
}
},
"net11.0/linux-arm64": {},
src/Infrastructure/Database/packages.lock.json +30 -11
diff --git a/src/Infrastructure/Database/packages.lock.json b/src/Infrastructure/Database/packages.lock.json
index dee9fbb..1a3c727 100644
@@ -58,6 +58,11 @@
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"Instances": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "LfhegDpmA8PuHW58RmgVvCDG/mfVCTU+Vhy4ppmXLJfAer33Xl0NocDy92OwSL6CnkVdx41O/I0+BjNhU1JtMQ=="
},
"Microsoft.Build.Framework": {
"type": "Transitive",
"resolved": "18.0.2",
@@ -220,8 +225,8 @@
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.7",
"contentHash": "Z6mfFEaFcwCfSboxJwOLfu7/31npCY9q70WUamHW/vRQhDvBKOT4Vf9YkZj5J6hLvJpb0oDEYfHunQZj0xxvKw=="
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "+gJnv1/kfXLXPv21R3iluhKqfXdf2zPWUaHBiSvlJurThv2D5HRUfU5z5SpmBII4I0JSpuprX9DlHrKz/1wCXA=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
@@ -238,14 +243,6 @@
"Microsoft.Extensions.Options": "10.0.7"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.7",
"contentHash": "tIEcQ2gvERrH2KiCjdsVcHGhXt9lIsuDStfOIeZWr7/fP8IXhGiYfx0/80PNI7WPO2IYuFtlZLSlnTS8+/Mchw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.7",
@@ -372,7 +369,29 @@
}
},
"Slopper.Domain": {
"type": "Project"
"type": "Project",
"dependencies": {
"FFMpegCore": "[5.4.0, )",
"Microsoft.Extensions.Logging.Abstractions": "[11.0.0-preview.3.26207.106, )"
}
},
"FFMpegCore": {
"type": "CentralTransitive",
"requested": "[5.4.0, )",
"resolved": "5.4.0",
"contentHash": "nymhPWpwvBaB4fOqf0thxg7Inm8SrruFQLdCMFEDKD8S3hy0/iV2vuKaJvaL9ZnbJx/2rJCHnJXOymo2apXovg==",
"dependencies": {
"Instances": "3.0.2"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[11.0.0-preview.3.26207.106, )",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "0LktkD4eySHjlglnee7jt/I3KPea+MPIxLTYBacH1P/iluOCl7VVKwpG/bciZMkyaNnfslY2E70t6nfvjq51vA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106"
}
}
},
"net11.0/linux-arm64": {
src/Infrastructure/Ffmpeg/ClipExtractor.cs +48 -0
diff --git a/src/Infrastructure/Ffmpeg/ClipExtractor.cs b/src/Infrastructure/Ffmpeg/ClipExtractor.cs
new file mode 100644
index 0000000..1349dcf
@@ -0,0 +1,48 @@
using System;
using System.Threading.Tasks;
using FFMpegCore;
using FFMpegCore.Arguments;
using FFMpegCore.Enums;
using Microsoft.Extensions.Logging;
using Slopper.Domain;
namespace Slopper.Infrastructure.Ffmpeg;
public sealed class ClipExtractor(ILogger<ClipExtractor> logger)
{
public async Task Clip(MediaItem media, TimeSpan start, TimeSpan duration, string outputPath)
{
var args = FFMpegArguments
.FromFileInput(media.Path)
.OutputToFile(
outputPath,
addArguments: options =>
options
.Seek(start)
.EndSeek(start + duration)
.WithVideoFilters(filter => filter.HardBurnSubtitle(CreateSubtitleOptions(media)))
.WithFastStart()
.WithVideoCodec(VideoCodec.LibX264)
.WithAudioCodec(AudioCodec.Aac)
.WithFramerate(30.0d)
.WithVideoBitrate(4000)
.WithAudioBitrate(128)
.ForceFormat("mp4")
);
logger.LogInformation("Running ffmpeg {FfmpegArguments}", args.Arguments);
await args.ProcessAsynchronously();
}
private static SubtitleHardBurnOptions CreateSubtitleOptions(MediaItem media) =>
(
media.Subtitles switch
{
Subtitles.External(var path) => SubtitleHardBurnOptions.Create(path),
Subtitles.Embedded(var index) => SubtitleHardBurnOptions.Create(media.Path).SetSubtitleIndex(index),
_ => throw new ArgumentException("Unknown subtitle type"),
}
).WithParameter(
"force_style",
"FontName=Segoe UI,FontSize=20,PrimaryColour=&HFFFFFF&,BackColour=&H000000&,BorderStyle=1,Alignment=2"
);
}
src/Infrastructure/Ffmpeg/Ffmpeg.csproj +13 -0
diff --git a/src/Infrastructure/Ffmpeg/Ffmpeg.csproj b/src/Infrastructure/Ffmpeg/Ffmpeg.csproj
new file mode 100644
index 0000000..4b5a52f
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Slopper.Infrastructure.Ffmpeg</RootNamespace>
<AssemblyName>Slopper.Infrastructure.Ffmpeg</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FFMpegCore" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs +9 -0
diff --git a/src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs b/src/Infrastructure/Ffmpeg/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..08a10d3
@@ -0,0 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
namespace Slopper.Infrastructure.Ffmpeg;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFfmpegServices(this IServiceCollection services) =>
services.AddTransient<ClipExtractor>();
}
src/Infrastructure/Ffmpeg/packages.lock.json +54 -0
diff --git a/src/Infrastructure/Ffmpeg/packages.lock.json b/src/Infrastructure/Ffmpeg/packages.lock.json
new file mode 100644
index 0000000..fc8fffa
@@ -0,0 +1,54 @@
{
"version": 2,
"dependencies": {
"net11.0": {
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.2.6, )",
"resolved": "1.2.6",
"contentHash": "KMSJG+jfk7vjP52QkWB99qWespXCPAzG/IaMCMRHYWumJEAGKQYm2HtyWG6eqnOwDitH96i1cqq5EVesyOtPmg=="
},
"FFMpegCore": {
"type": "Direct",
"requested": "[5.4.0, )",
"resolved": "5.4.0",
"contentHash": "nymhPWpwvBaB4fOqf0thxg7Inm8SrruFQLdCMFEDKD8S3hy0/iV2vuKaJvaL9ZnbJx/2rJCHnJXOymo2apXovg==",
"dependencies": {
"Instances": "3.0.2"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Direct",
"requested": "[11.0.0-preview.3.26207.106, )",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "0LktkD4eySHjlglnee7jt/I3KPea+MPIxLTYBacH1P/iluOCl7VVKwpG/bciZMkyaNnfslY2E70t6nfvjq51vA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "11.0.0-preview.3.26207.106"
}
},
"Instances": {
"type": "Transitive",
"resolved": "3.0.2",
"contentHash": "LfhegDpmA8PuHW58RmgVvCDG/mfVCTU+Vhy4ppmXLJfAer33Xl0NocDy92OwSL6CnkVdx41O/I0+BjNhU1JtMQ=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "11.0.0-preview.3.26207.106",
"contentHash": "+gJnv1/kfXLXPv21R3iluhKqfXdf2zPWUaHBiSvlJurThv2D5HRUfU5z5SpmBII4I0JSpuprX9DlHrKz/1wCXA=="
},
"Slopper.Domain": {
"type": "Project",
"dependencies": {
"FFMpegCore": "[5.4.0, )",
"Microsoft.Extensions.Logging.Abstractions": "[11.0.0-preview.3.26207.106, )"
}
}
},
"net11.0/linux-arm64": {},
"net11.0/linux-x64": {},
"net11.0/osx-arm64": {},
"net11.0/osx-x64": {},
"net11.0/win-arm64": {},
"net11.0/win-x64": {}
}
}
\ No newline at end of file