diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 19902b26a0..58746a6d1c 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.IO; using System.Linq; using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.MediaSegment; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common; @@ -24,6 +25,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaSegments; using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; using Book = MediaBrowser.Controller.Entities.Book; @@ -53,6 +55,7 @@ namespace Emby.Server.Implementations.Dto private readonly Lazy _livetvManagerFactory; private readonly ITrickplayManager _trickplayManager; + private readonly IMediaSegmentsManager _mediaSegmentsManager; public DtoService( ILogger logger, @@ -65,7 +68,8 @@ namespace Emby.Server.Implementations.Dto IApplicationHost appHost, IMediaSourceManager mediaSourceManager, Lazy livetvManagerFactory, - ITrickplayManager trickplayManager) + ITrickplayManager trickplayManager, + IMediaSegmentsManager mediaSegmentsManager) { _logger = logger; _libraryManager = libraryManager; @@ -78,6 +82,7 @@ namespace Emby.Server.Implementations.Dto _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; _trickplayManager = trickplayManager; + _mediaSegmentsManager = mediaSegmentsManager; } private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; @@ -1067,6 +1072,19 @@ namespace Emby.Server.Implementations.Dto dto.ExtraType = video.ExtraType; } + // Add MediaSegments for all media sources + if (dto.MediaSources is not null && options.ContainsField(ItemFields.MediaSegments)) + { + var allSegments = new List(); + + foreach (var source in dto.MediaSources) + { + allSegments.AddRange(_mediaSegmentsManager.GetAllMediaSegments(Guid.Parse(source.Id)).GetAwaiter().GetResult()); + } + + dto.MediaSegments = allSegments; + } + if (options.ContainsField(ItemFields.MediaStreams)) { // Add VideoInfo diff --git a/Jellyfin.Api/Controllers/MediaSegmentsController.cs b/Jellyfin.Api/Controllers/MediaSegmentsController.cs new file mode 100644 index 0000000000..493a769de6 --- /dev/null +++ b/Jellyfin.Api/Controllers/MediaSegmentsController.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Models.MediaSegmentsDtos; +using Jellyfin.Data.Enums.MediaSegmentType; +using Jellyfin.Extensions; +using MediaBrowser.Common.Api; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.MediaSegments; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers; + +/// +/// Media Segments controller. +/// +[Authorize] +public class MediaSegmentsController : BaseJellyfinApiController +{ + private readonly IUserManager _userManager; + private readonly IMediaSegmentsManager _mediaSegmentManager; + private readonly ILibraryManager _libraryManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// The library manager. + public MediaSegmentsController( + IMediaSegmentsManager mediaSegmentManager, + IUserManager userManager, + ILibraryManager libraryManager) + { + _userManager = userManager; + _mediaSegmentManager = mediaSegmentManager; + _libraryManager = libraryManager; + } + + /// + /// Get all media segments. + /// + /// The item id. + /// Just segments with MediaStreamIndex. + /// All segments of type. + /// All segments with typeIndex. + /// Segments returned. + /// itemId doesn't exist. + /// User is not authorized to access the requested item. + /// An containing the found segments. + [HttpGet("{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetSegments( + [FromRoute, Required] Guid itemId, + [FromQuery] int? streamIndex, + [FromQuery] MediaSegmentType? type, + [FromQuery] int? typeIndex) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + var user = !isApiKey && !userId.IsEmpty() + ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() + : null; + + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + if (!isApiKey && !item.IsVisible(user)) + { + return Unauthorized(); + } + + var list = await _mediaSegmentManager.GetAllMediaSegments(itemId, streamIndex, typeIndex, type).ConfigureAwait(false); + return Ok(list.ConvertAll(MediaSegmentDto.FromMediaSegment)); + } + + /// + /// Create or update multiple media segments. + /// + /// The item the segments belong to. + /// All segments that should be added. + /// Segments returned. + /// Invalid segments. + /// User is not authorized to access the requested item. + /// An containing the created/updated segments. + [HttpPost("{itemId}")] + [Authorize(Policy = Policies.MediaSegmentsManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> CreateSegments( + [FromRoute, Required] Guid itemId, + [FromBody, Required] IReadOnlyList segments) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + var user = !isApiKey && !userId.IsEmpty() + ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() + : null; + + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + if (!isApiKey && !item.IsVisible(user)) + { + return Unauthorized(); + } + + var segmentsToAdd = segments.ConvertAll(s => s.ToMediaSegment()); + var addedSegments = await _mediaSegmentManager.CreateMediaSegments(itemId, segmentsToAdd).ConfigureAwait(false); + return Ok(addedSegments.ConvertAll(MediaSegmentDto.FromMediaSegment)); + } + + /// + /// Delete media segments. All query parameters can be freely defined. + /// + /// The item id. + /// Segment is associated with MediaStreamIndex. + /// All segments of type. + /// All segments with typeIndex. + /// Segments deleted. + /// Segments not found. + /// User is not authorized to access the requested item. + /// A representing the asynchronous operation. + [HttpDelete("{itemId}")] + [Authorize(Policy = Policies.MediaSegmentsManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task DeleteSegments( + [FromRoute, Required] Guid itemId, + [FromQuery] int? streamIndex, + [FromQuery] MediaSegmentType? type, + [FromQuery] int? typeIndex) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + var user = !isApiKey && !userId.IsEmpty() + ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() + : null; + + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + if (!isApiKey && !item.IsVisible(user)) + { + return Unauthorized(); + } + + await _mediaSegmentManager.DeleteSegments(itemId, streamIndex, typeIndex, type).ConfigureAwait(false); + return Ok(); + } +} diff --git a/Jellyfin.Api/Models/MediaSegmentsDtos/MediaSegmentDto.cs b/Jellyfin.Api/Models/MediaSegmentsDtos/MediaSegmentDto.cs new file mode 100644 index 0000000000..fd14940867 --- /dev/null +++ b/Jellyfin.Api/Models/MediaSegmentsDtos/MediaSegmentDto.cs @@ -0,0 +1,99 @@ +using System; +using Jellyfin.Data.Entities.MediaSegment; +using Jellyfin.Data.Enums.MediaSegmentAction; +using Jellyfin.Data.Enums.MediaSegmentType; + +namespace Jellyfin.Api.Models.MediaSegmentsDtos; + +/// +/// Media Segment dto. +/// +public class MediaSegmentDto +{ + /// + /// Gets or sets the start position in Ticks. + /// + /// The start position. + public long StartTicks { get; set; } + + /// + /// Gets or sets the end position in Ticks. + /// + /// The end position. + public long EndTicks { get; set; } + + /// + /// Gets or sets the Type. + /// + /// The media segment type. + public MediaSegmentType Type { get; set; } + + /// + /// Gets or sets the TypeIndex which relates to the type. + /// + /// The type index. + public int TypeIndex { get; set; } + + /// + /// Gets or sets the associated MediaSourceId. + /// + /// The id. + public Guid ItemId { get; set; } + + /// + /// Gets or sets the associated MediaStreamIndex. + /// + /// The id. + public int StreamIndex { get; set; } + + /// + /// Gets or sets the creator recommended action. Can be overwritten with user defined action. + /// + /// The media segment action. + public MediaSegmentAction Action { get; set; } + + /// + /// Gets or sets a comment. + /// + /// The user provided value to be displayed when the is a . + public string? Comment { get; set; } + + /// + /// Convert the dto to the model. + /// + /// The converted model. + public MediaSegment ToMediaSegment() + { + return new MediaSegment + { + StartTicks = StartTicks, + EndTicks = EndTicks, + Type = Type, + TypeIndex = TypeIndex, + ItemId = ItemId, + StreamIndex = StreamIndex, + Action = Action, + Comment = Comment + }; + } + + /// + /// Convert the to dto model. + /// + /// segment to convert. + /// The converted model. + public static MediaSegmentDto FromMediaSegment(MediaSegment seg) + { + return new MediaSegmentDto + { + StartTicks = seg.StartTicks, + EndTicks = seg.EndTicks, + Type = seg.Type, + TypeIndex = seg.TypeIndex, + ItemId = seg.ItemId, + StreamIndex = seg.StreamIndex, + Action = seg.Action, + Comment = seg.Comment + }; + } +} diff --git a/Jellyfin.Data/Entities/MediaSegment.cs b/Jellyfin.Data/Entities/MediaSegment.cs new file mode 100644 index 0000000000..efaf79bfb0 --- /dev/null +++ b/Jellyfin.Data/Entities/MediaSegment.cs @@ -0,0 +1,60 @@ +using System; +using Jellyfin.Data.Enums.MediaSegmentAction; +using Jellyfin.Data.Enums.MediaSegmentType; + +namespace Jellyfin.Data.Entities.MediaSegment +{ + /// + /// A moment in time of a media stream (ItemId+StreamIndex) with Type and possible Action applicable between StartTicks/Endticks. + /// + public class MediaSegment + { + /// + /// Gets or sets the start position in Ticks. + /// + /// The start position. + public long StartTicks { get; set; } + + /// + /// Gets or sets the end position in Ticks. + /// + /// The end position. + public long EndTicks { get; set; } + + /// + /// Gets or sets the Type. + /// + /// The media segment type. + public MediaSegmentType Type { get; set; } + + /// + /// Gets or sets the TypeIndex which relates to the type. + /// + /// The type index. + public int TypeIndex { get; set; } + + /// + /// Gets or sets the associated MediaSourceId. + /// + /// The id. + public Guid ItemId { get; set; } + + /// + /// Gets or sets the associated MediaStreamIndex. + /// + /// The id. + public int StreamIndex { get; set; } + + /// + /// Gets or sets the creator recommended action. Can be overwritten with user defined action. + /// + /// The media segment action. + public MediaSegmentAction Action { get; set; } + + /// + /// Gets or sets a comment. + /// + /// The user provided value to be displayed when the is a . + public string? Comment { get; set; } + } +} diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs index 2c9cc8d785..4789f2f9dc 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/Jellyfin.Data/Entities/User.cs @@ -507,6 +507,7 @@ namespace Jellyfin.Data.Entities Permissions.Add(new Permission(PermissionKind.EnableCollectionManagement, false)); Permissions.Add(new Permission(PermissionKind.EnableSubtitleManagement, false)); Permissions.Add(new Permission(PermissionKind.EnableLyricManagement, false)); + Permissions.Add(new Permission(PermissionKind.EnableMediaSegmentsManagement, false)); } /// diff --git a/Jellyfin.Data/Enums/MediaSegmentAction.cs b/Jellyfin.Data/Enums/MediaSegmentAction.cs new file mode 100644 index 0000000000..3d38fdab75 --- /dev/null +++ b/Jellyfin.Data/Enums/MediaSegmentAction.cs @@ -0,0 +1,28 @@ +namespace Jellyfin.Data.Enums.MediaSegmentAction +{ + /// + /// An enum representing the Action of MediaSegment. + /// + public enum MediaSegmentAction + { + /// + /// None, do nothing with MediaSegment. + /// + None = 0, + + /// + /// Force skip the MediaSegment. + /// + Skip = 1, + + /// + /// Prompt user to skip the MediaSegment. + /// + PromptToSkip = 2, + + /// + /// Mute the MediaSegment. + /// + Mute = 3, + } +} diff --git a/Jellyfin.Data/Enums/MediaSegmentType.cs b/Jellyfin.Data/Enums/MediaSegmentType.cs new file mode 100644 index 0000000000..843bdbe9c9 --- /dev/null +++ b/Jellyfin.Data/Enums/MediaSegmentType.cs @@ -0,0 +1,38 @@ +namespace Jellyfin.Data.Enums.MediaSegmentType +{ + /// + /// An enum representing the Type of MediaSegment. + /// + public enum MediaSegmentType + { + /// + /// The Intro. + /// + Intro = 0, + + /// + /// The Outro. + /// + Outro = 1, + + /// + /// Recap of last tv show episode(s). + /// + Recap = 2, + + /// + /// The preview for the next tv show episode. + /// + Preview = 3, + + /// + /// Commercial that interrupt the viewer. + /// + Commercial = 4, + + /// + /// A Comment or additional info. + /// + Annotation = 5, + } +} diff --git a/Jellyfin.Data/Enums/PermissionKind.cs b/Jellyfin.Data/Enums/PermissionKind.cs index c3d6705c24..c4f00266eb 100644 --- a/Jellyfin.Data/Enums/PermissionKind.cs +++ b/Jellyfin.Data/Enums/PermissionKind.cs @@ -124,5 +124,10 @@ namespace Jellyfin.Data.Enums /// Whether the user can edit lyrics. /// EnableLyricManagement = 23, + + /// + /// Whether the user can media segments. + /// + EnableMediaSegmentsManagement = 24, } } diff --git a/Jellyfin.Server.Implementations/JellyfinDbContext.cs b/Jellyfin.Server.Implementations/JellyfinDbContext.cs index ea99af0047..2ae6539c43 100644 --- a/Jellyfin.Server.Implementations/JellyfinDbContext.cs +++ b/Jellyfin.Server.Implementations/JellyfinDbContext.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.MediaSegment; using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Interfaces; using Microsoft.EntityFrameworkCore; @@ -83,6 +84,11 @@ public class JellyfinDbContext : DbContext /// public DbSet TrickplayInfos => Set(); + /// + /// Gets the containing the media segments. + /// + public DbSet Segments => Set(); + /*public DbSet Artwork => Set(); public DbSet Books => Set(); diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentsManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentsManager.cs new file mode 100644 index 0000000000..0044a587fd --- /dev/null +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentsManager.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Data.Entities.MediaSegment; +using Jellyfin.Data.Enums.MediaSegmentType; +using Jellyfin.Data.Events; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.MediaSegments; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations.MediaSegments; + +/// +/// Manages the creation and retrieval of instances. +/// +public sealed class MediaSegmentsManager : IMediaSegmentsManager, IDisposable +{ + private readonly ILibraryManager _libraryManager; + private readonly IDbContextFactory _dbProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The library manager. + /// The database provider. + public MediaSegmentsManager( + ILibraryManager libraryManager, + IDbContextFactory dbProvider) + { + _libraryManager = libraryManager; + _libraryManager.ItemRemoved += LibraryManagerItemRemoved; + _dbProvider = dbProvider; + } + + /// + public event EventHandler>? SegmentsAddedOrUpdated; + + /// + public async Task> CreateMediaSegments(Guid itemId, IReadOnlyList segments) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + throw new InvalidOperationException("Item not found"); + } + + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var segment in segments) + { + segment.ItemId = itemId; + ValidateSegment(segment); + + var found = await dbContext.Segments.FirstOrDefaultAsync(s => s.ItemId.Equals(segment.ItemId) + && s.StreamIndex == segment.StreamIndex + && s.Type == segment.Type + && s.TypeIndex == segment.TypeIndex) + .ConfigureAwait(false); + + AddOrUpdateSegment(dbContext, segment, found); + } + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + SegmentsAddedOrUpdated?.Invoke(this, new GenericEventArgs(itemId)); + + return segments; + } + + /// + public async Task> GetAllMediaSegments(Guid itemId, int? streamIndex = null, int? typeIndex = null, MediaSegmentType? type = null) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + throw new InvalidOperationException("Item not found"); + } + + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + IQueryable queryable = dbContext.Segments.Where(s => s.ItemId.Equals(itemId)); + + if (streamIndex is not null) + { + queryable = queryable.Where(s => s.StreamIndex == streamIndex.Value); + } + + if (type is not null) + { + queryable = queryable.Where(s => s.Type == type.Value); + } + + if (!typeIndex.Equals(null)) + { + queryable = queryable.Where(s => s.TypeIndex == typeIndex.Value); + } + + return await queryable.AsNoTracking().ToListAsync().ConfigureAwait(false); + } + } + + /// + /// Add or Update a segment in db. + /// The db context. + /// The segment. + /// The found segment. + /// + private void AddOrUpdateSegment(JellyfinDbContext dbContext, MediaSegment segment, MediaSegment? found) + { + if (found is not null) + { + found.StartTicks = segment.StartTicks; + found.EndTicks = segment.EndTicks; + found.Action = segment.Action; + found.Comment = segment.Comment; + } + else + { + dbContext.Segments.Add(segment); + } + } + + /// + /// Validate a segment: itemId, start >= end and type. + /// + /// The segment to validate. + private void ValidateSegment(MediaSegment segment) + { + if (segment.ItemId.IsEmpty()) + { + throw new ArgumentException($"itemId is default: itemId={segment.ItemId} for segment with type '{segment.Type}.{segment.TypeIndex}'"); + } + + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(segment.StartTicks, segment.EndTicks, $"itemId '{segment.ItemId}' with type '{segment.Type}.{segment.TypeIndex}'"); + } + + /// + /// Delete all segments when itemid is deleted from library. + /// + /// The sending entity. + /// The . + private async void LibraryManagerItemRemoved(object? sender, ItemChangeEventArgs itemChangeEventArgs) + { + await DeleteSegments(itemChangeEventArgs.Item.Id).ConfigureAwait(false); + } + + /// + public async Task DeleteSegments(Guid itemId, int? streamIndex = null, int? typeIndex = null, MediaSegmentType? type = null) + { + if (itemId.IsEmpty()) + { + throw new ArgumentException("Default value provided", nameof(itemId)); + } + + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + IQueryable queryable = dbContext.Segments.Where(s => s.ItemId.Equals(itemId)); + + if (streamIndex is not null) + { + queryable = queryable.Where(s => s.StreamIndex == streamIndex); + } + + if (type is not null) + { + queryable = queryable.Where(s => s.Type == type); + } + + if (typeIndex is not null) + { + queryable = queryable.Where(s => s.TypeIndex == typeIndex); + } + + await queryable.ExecuteDeleteAsync().ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + _libraryManager.ItemRemoved -= LibraryManagerItemRemoved; + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20240224002503_MediaSegments.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20240224002503_MediaSegments.Designer.cs new file mode 100644 index 0000000000..cbac379489 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20240224002503_MediaSegments.Designer.cs @@ -0,0 +1,718 @@ +// +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20240224002503_MediaSegments")] + partial class MediaSegments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.2"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("TypeIndex") + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex", "Type", "TypeIndex"); + + b.HasIndex("ItemId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20240224002503_MediaSegments.cs b/Jellyfin.Server.Implementations/Migrations/20240224002503_MediaSegments.cs new file mode 100644 index 0000000000..dc4867f617 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20240224002503_MediaSegments.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class MediaSegments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Segments", + columns: table => new + { + Type = table.Column(type: "INTEGER", nullable: false), + TypeIndex = table.Column(type: "INTEGER", nullable: false), + ItemId = table.Column(type: "TEXT", nullable: false), + StreamIndex = table.Column(type: "INTEGER", nullable: false), + StartTicks = table.Column(type: "INTEGER", nullable: false), + EndTicks = table.Column(type: "INTEGER", nullable: false), + Action = table.Column(type: "INTEGER", nullable: false), + Comment = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Segments", x => new { x.ItemId, x.StreamIndex, x.Type, x.TypeIndex }); + }); + + migrationBuilder.CreateIndex( + name: "IX_Segments_ItemId", + table: "Segments", + column: "ItemId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Segments"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index f725ababe9..08f3e34a74 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Jellyfin.Server.Implementations; using Microsoft.EntityFrameworkCore; @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.2"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -270,6 +270,39 @@ namespace Jellyfin.Server.Implementations.Migrations b.ToTable("ItemDisplayPreferences"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("TypeIndex") + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex", "Type", "TypeIndex"); + + b.HasIndex("ItemId"); + + b.ToTable("Segments"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => { b.Property("Id") diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/MediaSegmentConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/MediaSegmentConfiguration.cs new file mode 100644 index 0000000000..f9f7e9023e --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/MediaSegmentConfiguration.cs @@ -0,0 +1,42 @@ +using Jellyfin.Data.Entities.MediaSegment; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration.MediaSegmentConfiguration +{ + /// + /// FluentAPI configuration for the MediaSegment entity. + /// + public class MediaSegmentConfiguration : IEntityTypeConfiguration + { + /// + public void Configure(EntityTypeBuilder builder) + { + builder + .Property(s => s.StartTicks) + .IsRequired(); + builder + .Property(s => s.EndTicks) + .IsRequired(); + builder + .Property(s => s.Type) + .IsRequired(); + builder + .Property(s => s.TypeIndex) + .IsRequired(); + builder + .Property(s => s.ItemId) + .IsRequired(); + builder + .Property(s => s.StreamIndex) + .IsRequired(); + builder + .Property(s => s.Action) + .IsRequired(); + builder + .HasKey(s => new { s.ItemId, s.StreamIndex, s.Type, s.TypeIndex }); + builder + .HasIndex(s => s.ItemId); + } + } +} diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 41f1ac3519..f492926c2f 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -689,6 +689,7 @@ namespace Jellyfin.Server.Implementations.Users user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); + user.SetPermission(PermissionKind.EnableMediaSegmentsManagement, policy.EnableMediaSegmentsManagement); user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index d5b6e93b8e..2417aeb2ad 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -11,6 +11,7 @@ using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Activity; using Jellyfin.Server.Implementations.Devices; using Jellyfin.Server.Implementations.Events; +using Jellyfin.Server.Implementations.MediaSegments; using Jellyfin.Server.Implementations.Security; using Jellyfin.Server.Implementations.Trickplay; using Jellyfin.Server.Implementations.Users; @@ -26,6 +27,7 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Activity; +using MediaBrowser.Model.MediaSegments; using MediaBrowser.Providers.Lyric; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -86,6 +88,7 @@ namespace Jellyfin.Server serviceCollection.AddScoped(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // TODO search the assemblies instead of adding them manually? serviceCollection.AddSingleton(); diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 597643ed19..42fc37866e 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -83,6 +83,7 @@ namespace Jellyfin.Server.Extensions options.AddPolicy(Policies.SyncPlayIsInGroup, new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup)); options.AddPolicy(Policies.SubtitleManagement, new UserPermissionRequirement(PermissionKind.EnableSubtitleManagement)); options.AddPolicy(Policies.LyricManagement, new UserPermissionRequirement(PermissionKind.EnableLyricManagement)); + options.AddPolicy(Policies.MediaSegmentsManagement, new UserPermissionRequirement(PermissionKind.EnableMediaSegmentsManagement)); options.AddPolicy( Policies.RequiresElevation, policy => policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication) diff --git a/MediaBrowser.Common/Api/Policies.cs b/MediaBrowser.Common/Api/Policies.cs index 435f4798ff..117a16beeb 100644 --- a/MediaBrowser.Common/Api/Policies.cs +++ b/MediaBrowser.Common/Api/Policies.cs @@ -94,4 +94,9 @@ public static class Policies /// Policy name for accessing lyric management. /// public const string LyricManagement = "LyricManagement"; + + /// + /// Policy name for accessing media segments management. + /// + public const string MediaSegmentsManagement = "MediaSegmentsManagement"; } diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index 7e8949e1fb..699e6ab9fd 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.MediaSegment; using Jellyfin.Data.Enums; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Entities; @@ -570,6 +571,12 @@ namespace MediaBrowser.Model.Dto /// The trickplay manifest. public Dictionary> Trickplay { get; set; } + /// + /// Gets or sets the media segments data. + /// + /// The media segments. + public IReadOnlyList MediaSegments { get; set; } + /// /// Gets or sets the type of the location. /// diff --git a/MediaBrowser.Model/MediaSegments/IMediaSegmentsManager.cs b/MediaBrowser.Model/MediaSegments/IMediaSegmentsManager.cs new file mode 100644 index 0000000000..dd3c2b967f --- /dev/null +++ b/MediaBrowser.Model/MediaSegments/IMediaSegmentsManager.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Jellyfin.Data.Entities.MediaSegment; +using Jellyfin.Data.Enums.MediaSegmentType; +using Jellyfin.Data.Events; + +namespace MediaBrowser.Model.MediaSegments; + +/// +/// Media segments manager definition. +/// +public interface IMediaSegmentsManager +{ + /// + /// Occurs when new or updated segments are available for itemId. + /// + public event EventHandler>? SegmentsAddedOrUpdated; + + /// + /// Create or update multiple media segments. + /// + /// The item to create segments for. + /// List of segments. + /// New or updated MediaSegments. + /// Will be thrown when an non existing item is requested. + Task> CreateMediaSegments(Guid itemId, IReadOnlyList segments); + + /// + /// Get all media segments. + /// + /// Optional: Just segments with itemId. + /// Optional: Just segments with MediaStreamIndex. + /// Optional: The typeIndex. + /// Optional: The segment type. + /// List of MediaSegment. + /// Will be thrown when an non existing item is requested. + public Task> GetAllMediaSegments(Guid itemId, int? streamIndex = null, int? typeIndex = null, MediaSegmentType? type = null); + + /// + /// Delete Media Segments. + /// + /// Required: The itemId. + /// Optional: Just segments with MediaStreamIndex. + /// Optional: The typeIndex. + /// Optional: The segment type. + /// A representing the asynchronous operation. + /// Will be thrown when a empty Guid is requested. + public Task DeleteSegments(Guid itemId, int? streamIndex = null, int? typeIndex = null, MediaSegmentType? type = null); +} diff --git a/MediaBrowser.Model/Querying/ItemFields.cs b/MediaBrowser.Model/Querying/ItemFields.cs index 49d7c0bcb0..b5ec12df0c 100644 --- a/MediaBrowser.Model/Querying/ItemFields.cs +++ b/MediaBrowser.Model/Querying/ItemFields.cs @@ -39,6 +39,11 @@ namespace MediaBrowser.Model.Querying /// Trickplay, + /// + /// The MediaSegments data. + /// + MediaSegments, + ChildCount, /// diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 951e057632..c4a83f49d2 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -98,6 +98,12 @@ namespace MediaBrowser.Model.Users [DefaultValue(false)] public bool EnableLyricManagement { get; set; } + /// + /// Gets or sets a value indicating whether this user can manage media segments. + /// + [DefaultValue(false)] + public bool EnableMediaSegmentsManagement { get; set; } + /// /// Gets or sets a value indicating whether this instance is disabled. /// diff --git a/src/Jellyfin.Extensions/ReadOnlyListExtension.cs b/src/Jellyfin.Extensions/ReadOnlyListExtension.cs index ba99bb534d..47457a59ba 100644 --- a/src/Jellyfin.Extensions/ReadOnlyListExtension.cs +++ b/src/Jellyfin.Extensions/ReadOnlyListExtension.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace Jellyfin.Extensions { @@ -73,5 +74,28 @@ namespace Jellyfin.Extensions return source[0]; } + + /// + /// Converts a ReadOnlyList{TIn} to ReadOnlyList{TOut}. + /// + /// The source list. + /// The converter to use. + /// The input type. + /// The output type. + /// The converted list. + public static IReadOnlyList ConvertAll(this IReadOnlyList? source, Converter converter) + { + if (source is null || source.Count == 0) + { + return Array.Empty(); + } + + return source switch + { + List list => list.ConvertAll(converter), + TIn[] array => Array.ConvertAll(array, converter), + _ => source.Select(s => converter(s)).ToList() + }; + } } }