From 3e0ca2136013a9ad8c425f6c3fc42c606eca5415 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Tue, 17 Mar 2026 21:43:13 -0400 Subject: [PATCH 1/5] feat: adding schema migration --- .../20260316031943_Chapters.Designer.cs | 423 ++++++++++++++++++ .../Migrations/20260316031943_Chapters.cs | 40 ++ .../20260316031955_Chapters.Designer.cs | 423 ++++++++++++++++++ .../Migrations/20260316031955_Chapters.cs | 39 ++ .../20260316032003_Chapters.Designer.cs | 423 ++++++++++++++++++ .../Migrations/20260316032003_Chapters.cs | 39 ++ .../20260316031929_Chapters.Designer.cs | 408 +++++++++++++++++ .../Migrations/20260316031929_Chapters.cs | 39 ++ 8 files changed, 1834 insertions(+) create mode 100644 src/Migrations/MediaBrowser.MySql/Migrations/20260316031943_Chapters.Designer.cs create mode 100644 src/Migrations/MediaBrowser.MySql/Migrations/20260316031943_Chapters.cs create mode 100644 src/Migrations/MediaBrowser.Postgres/Migrations/20260316031955_Chapters.Designer.cs create mode 100644 src/Migrations/MediaBrowser.Postgres/Migrations/20260316031955_Chapters.cs create mode 100644 src/Migrations/MediaBrowser.SqlServer/Migrations/20260316032003_Chapters.Designer.cs create mode 100644 src/Migrations/MediaBrowser.SqlServer/Migrations/20260316032003_Chapters.cs create mode 100644 src/Migrations/MediaBrowser.Sqlite/Migrations/20260316031929_Chapters.Designer.cs create mode 100644 src/Migrations/MediaBrowser.Sqlite/Migrations/20260316031929_Chapters.cs diff --git a/src/Migrations/MediaBrowser.MySql/Migrations/20260316031943_Chapters.Designer.cs b/src/Migrations/MediaBrowser.MySql/Migrations/20260316031943_Chapters.Designer.cs new file mode 100644 index 0000000..b6e991b --- /dev/null +++ b/src/Migrations/MediaBrowser.MySql/Migrations/20260316031943_Chapters.Designer.cs @@ -0,0 +1,423 @@ +// +using System; +using MediaBrowser; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MediaBrowser.Migrations +{ + [DbContext(typeof(MediaMySqlDbContext))] + [Migration("20260316031943_Chapters")] + partial class Chapters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("MediaBrowser.Media.Cast.CastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("char(36)") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("cast_member") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_cast"); + + b.HasAnnotation("Relational:JsonPropertyName", "cast"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Directors.DirectorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("char(36)") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("director") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_directors"); + + b.HasAnnotation("Relational:JsonPropertyName", "directors"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Genres.GenreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("char(36)") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("genre") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_genres"); + + b.HasAnnotation("Relational:JsonPropertyName", "genres"); + }); + + modelBuilder.Entity("MediaBrowser.Media.MediaEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)") + .HasColumnName("created_on") + .HasAnnotation("Relational:JsonPropertyName", "createdOn"); + + b.Property("CtimeMs") + .HasColumnType("bigint") + .HasColumnName("ctime_ms") + .HasAnnotation("Relational:JsonPropertyName", "ctimeMs"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)") + .HasColumnName("description") + .HasAnnotation("Relational:JsonPropertyName", "description"); + + b.Property("Duration") + .HasColumnType("double") + .HasColumnName("duration") + .HasAnnotation("Relational:JsonPropertyName", "duration"); + + b.Property("Ffprobe") + .IsRequired() + .HasColumnType("JSON") + .HasColumnName("ffprobe") + .HasAnnotation("Relational:JsonPropertyName", "ffprobe"); + + b.Property("Height") + .HasColumnType("int") + .HasColumnName("height") + .HasAnnotation("Relational:JsonPropertyName", "height"); + + b.Property("Md5") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("md5") + .HasAnnotation("Relational:JsonPropertyName", "md5"); + + b.Property("Mime") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("mime") + .HasAnnotation("Relational:JsonPropertyName", "mime"); + + b.Property("MtimeMs") + .HasColumnType("bigint") + .HasColumnName("mtime_ms") + .HasAnnotation("Relational:JsonPropertyName", "mtimeMs"); + + b.Property("OriginalTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("original_title") + .HasAnnotation("Relational:JsonPropertyName", "originalTitle"); + + b.Property("ParentId") + .HasColumnType("char(36)") + .HasColumnName("parent_id") + .HasAnnotation("Relational:JsonPropertyName", "parentId"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)") + .HasColumnName("path") + .HasAnnotation("Relational:JsonPropertyName", "path"); + + b.Property("Published") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("published") + .HasAnnotation("Relational:JsonPropertyName", "published"); + + b.Property("Rating") + .HasColumnType("double") + .HasColumnName("rating") + .HasAnnotation("Relational:JsonPropertyName", "rating"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size") + .HasAnnotation("Relational:JsonPropertyName", "size"); + + b.Property("Start") + .HasColumnType("double") + .HasColumnName("start") + .HasAnnotation("Relational:JsonPropertyName", "start"); + + b.Property("Thumbnail") + .HasColumnType("double") + .HasColumnName("thumbnail") + .HasAnnotation("Relational:JsonPropertyName", "thumbnail"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("title") + .HasAnnotation("Relational:JsonPropertyName", "title"); + + b.Property("UpdatedOn") + .HasColumnType("datetime(6)") + .HasColumnName("updated_on") + .HasAnnotation("Relational:JsonPropertyName", "updatedOn"); + + b.Property("UserStarRating") + .HasColumnType("int") + .HasColumnName("user_star_rating") + .HasAnnotation("Relational:JsonPropertyName", "userStarRating"); + + b.Property("Width") + .HasColumnType("int") + .HasColumnName("width") + .HasAnnotation("Relational:JsonPropertyName", "width"); + + b.HasKey("Id"); + + b.ToTable("media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Producers.ProducerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("char(36)") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("producer") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_producers"); + + b.HasAnnotation("Relational:JsonPropertyName", "producers"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Writers.WriterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("char(36)") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("writer") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_writers"); + + b.HasAnnotation("Relational:JsonPropertyName", "writers"); + }); + + modelBuilder.Entity("MediaBrowser.Users.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)") + .HasColumnName("id"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(125) + .HasColumnType("varchar(125)") + .HasColumnName("password_hash"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("user_name"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Cast.CastEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Cast") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Directors.DirectorEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Directors") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Genres.GenreEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Genres") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Producers.ProducerEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Producers") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Writers.WriterEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Writers") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.MediaEntity", b => + { + b.Navigation("Cast"); + + b.Navigation("Directors"); + + b.Navigation("Genres"); + + b.Navigation("Producers"); + + b.Navigation("Writers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/MediaBrowser.MySql/Migrations/20260316031943_Chapters.cs b/src/Migrations/MediaBrowser.MySql/Migrations/20260316031943_Chapters.cs new file mode 100644 index 0000000..56a5fa9 --- /dev/null +++ b/src/Migrations/MediaBrowser.MySql/Migrations/20260316031943_Chapters.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MediaBrowser.Migrations +{ + /// + public partial class Chapters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "parent_id", + table: "media", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + + migrationBuilder.AddColumn( + name: "start", + table: "media", + type: "double", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "parent_id", + table: "media"); + + migrationBuilder.DropColumn( + name: "start", + table: "media"); + } + } +} diff --git a/src/Migrations/MediaBrowser.Postgres/Migrations/20260316031955_Chapters.Designer.cs b/src/Migrations/MediaBrowser.Postgres/Migrations/20260316031955_Chapters.Designer.cs new file mode 100644 index 0000000..79fc50b --- /dev/null +++ b/src/Migrations/MediaBrowser.Postgres/Migrations/20260316031955_Chapters.Designer.cs @@ -0,0 +1,423 @@ +// +using System; +using MediaBrowser; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MediaBrowser.Migrations +{ + [DbContext(typeof(MediaPostgresDbContext))] + [Migration("20260316031955_Chapters")] + partial class Chapters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MediaBrowser.Media.Cast.CastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uuid") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("cast_member") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_cast"); + + b.HasAnnotation("Relational:JsonPropertyName", "cast"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Directors.DirectorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uuid") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("director") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_directors"); + + b.HasAnnotation("Relational:JsonPropertyName", "directors"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Genres.GenreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uuid") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("genre") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_genres"); + + b.HasAnnotation("Relational:JsonPropertyName", "genres"); + }); + + modelBuilder.Entity("MediaBrowser.Media.MediaEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on") + .HasAnnotation("Relational:JsonPropertyName", "createdOn"); + + b.Property("CtimeMs") + .HasColumnType("bigint") + .HasColumnName("ctime_ms") + .HasAnnotation("Relational:JsonPropertyName", "ctimeMs"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description") + .HasAnnotation("Relational:JsonPropertyName", "description"); + + b.Property("Duration") + .HasColumnType("double precision") + .HasColumnName("duration") + .HasAnnotation("Relational:JsonPropertyName", "duration"); + + b.Property("Ffprobe") + .IsRequired() + .HasColumnType("JSONB") + .HasColumnName("ffprobe") + .HasAnnotation("Relational:JsonPropertyName", "ffprobe"); + + b.Property("Height") + .HasColumnType("integer") + .HasColumnName("height") + .HasAnnotation("Relational:JsonPropertyName", "height"); + + b.Property("Md5") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("md5") + .HasAnnotation("Relational:JsonPropertyName", "md5"); + + b.Property("Mime") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("mime") + .HasAnnotation("Relational:JsonPropertyName", "mime"); + + b.Property("MtimeMs") + .HasColumnType("bigint") + .HasColumnName("mtime_ms") + .HasAnnotation("Relational:JsonPropertyName", "mtimeMs"); + + b.Property("OriginalTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("original_title") + .HasAnnotation("Relational:JsonPropertyName", "originalTitle"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id") + .HasAnnotation("Relational:JsonPropertyName", "parentId"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("path") + .HasAnnotation("Relational:JsonPropertyName", "path"); + + b.Property("Published") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("published") + .HasAnnotation("Relational:JsonPropertyName", "published"); + + b.Property("Rating") + .HasColumnType("double precision") + .HasColumnName("rating") + .HasAnnotation("Relational:JsonPropertyName", "rating"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size") + .HasAnnotation("Relational:JsonPropertyName", "size"); + + b.Property("Start") + .HasColumnType("double precision") + .HasColumnName("start") + .HasAnnotation("Relational:JsonPropertyName", "start"); + + b.Property("Thumbnail") + .HasColumnType("double precision") + .HasColumnName("thumbnail") + .HasAnnotation("Relational:JsonPropertyName", "thumbnail"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title") + .HasAnnotation("Relational:JsonPropertyName", "title"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on") + .HasAnnotation("Relational:JsonPropertyName", "updatedOn"); + + b.Property("UserStarRating") + .HasColumnType("integer") + .HasColumnName("user_star_rating") + .HasAnnotation("Relational:JsonPropertyName", "userStarRating"); + + b.Property("Width") + .HasColumnType("integer") + .HasColumnName("width") + .HasAnnotation("Relational:JsonPropertyName", "width"); + + b.HasKey("Id"); + + b.ToTable("media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Producers.ProducerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uuid") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("producer") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_producers"); + + b.HasAnnotation("Relational:JsonPropertyName", "producers"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Writers.WriterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uuid") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("writer") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_writers"); + + b.HasAnnotation("Relational:JsonPropertyName", "writers"); + }); + + modelBuilder.Entity("MediaBrowser.Users.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(125) + .HasColumnType("character varying(125)") + .HasColumnName("password_hash"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("user_name"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Cast.CastEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Cast") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Directors.DirectorEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Directors") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Genres.GenreEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Genres") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Producers.ProducerEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Producers") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Writers.WriterEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Writers") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.MediaEntity", b => + { + b.Navigation("Cast"); + + b.Navigation("Directors"); + + b.Navigation("Genres"); + + b.Navigation("Producers"); + + b.Navigation("Writers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/MediaBrowser.Postgres/Migrations/20260316031955_Chapters.cs b/src/Migrations/MediaBrowser.Postgres/Migrations/20260316031955_Chapters.cs new file mode 100644 index 0000000..9ba1dee --- /dev/null +++ b/src/Migrations/MediaBrowser.Postgres/Migrations/20260316031955_Chapters.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MediaBrowser.Migrations +{ + /// + public partial class Chapters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "parent_id", + table: "media", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "start", + table: "media", + type: "double precision", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "parent_id", + table: "media"); + + migrationBuilder.DropColumn( + name: "start", + table: "media"); + } + } +} diff --git a/src/Migrations/MediaBrowser.SqlServer/Migrations/20260316032003_Chapters.Designer.cs b/src/Migrations/MediaBrowser.SqlServer/Migrations/20260316032003_Chapters.Designer.cs new file mode 100644 index 0000000..423f0bb --- /dev/null +++ b/src/Migrations/MediaBrowser.SqlServer/Migrations/20260316032003_Chapters.Designer.cs @@ -0,0 +1,423 @@ +// +using System; +using MediaBrowser; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MediaBrowser.Migrations +{ + [DbContext(typeof(MediaSqlServerDbContext))] + [Migration("20260316032003_Chapters")] + partial class Chapters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MediaBrowser.Media.Cast.CastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uniqueidentifier") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("cast_member") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_cast"); + + b.HasAnnotation("Relational:JsonPropertyName", "cast"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Directors.DirectorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uniqueidentifier") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("director") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_directors"); + + b.HasAnnotation("Relational:JsonPropertyName", "directors"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Genres.GenreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uniqueidentifier") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("genre") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_genres"); + + b.HasAnnotation("Relational:JsonPropertyName", "genres"); + }); + + modelBuilder.Entity("MediaBrowser.Media.MediaEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("CreatedOn") + .HasColumnType("datetime2") + .HasColumnName("created_on") + .HasAnnotation("Relational:JsonPropertyName", "createdOn"); + + b.Property("CtimeMs") + .HasColumnType("bigint") + .HasColumnName("ctime_ms") + .HasAnnotation("Relational:JsonPropertyName", "ctimeMs"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasColumnName("description") + .HasAnnotation("Relational:JsonPropertyName", "description"); + + b.Property("Duration") + .HasColumnType("float") + .HasColumnName("duration") + .HasAnnotation("Relational:JsonPropertyName", "duration"); + + b.Property("Ffprobe") + .IsRequired() + .HasColumnType("NVARCHAR(MAX)") + .HasColumnName("ffprobe") + .HasAnnotation("Relational:JsonPropertyName", "ffprobe"); + + b.Property("Height") + .HasColumnType("int") + .HasColumnName("height") + .HasAnnotation("Relational:JsonPropertyName", "height"); + + b.Property("Md5") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)") + .HasColumnName("md5") + .HasAnnotation("Relational:JsonPropertyName", "md5"); + + b.Property("Mime") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("mime") + .HasAnnotation("Relational:JsonPropertyName", "mime"); + + b.Property("MtimeMs") + .HasColumnType("bigint") + .HasColumnName("mtime_ms") + .HasAnnotation("Relational:JsonPropertyName", "mtimeMs"); + + b.Property("OriginalTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("original_title") + .HasAnnotation("Relational:JsonPropertyName", "originalTitle"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id") + .HasAnnotation("Relational:JsonPropertyName", "parentId"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasColumnName("path") + .HasAnnotation("Relational:JsonPropertyName", "path"); + + b.Property("Published") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("published") + .HasAnnotation("Relational:JsonPropertyName", "published"); + + b.Property("Rating") + .HasColumnType("float") + .HasColumnName("rating") + .HasAnnotation("Relational:JsonPropertyName", "rating"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size") + .HasAnnotation("Relational:JsonPropertyName", "size"); + + b.Property("Start") + .HasColumnType("float") + .HasColumnName("start") + .HasAnnotation("Relational:JsonPropertyName", "start"); + + b.Property("Thumbnail") + .HasColumnType("float") + .HasColumnName("thumbnail") + .HasAnnotation("Relational:JsonPropertyName", "thumbnail"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)") + .HasColumnName("title") + .HasAnnotation("Relational:JsonPropertyName", "title"); + + b.Property("UpdatedOn") + .HasColumnType("datetime2") + .HasColumnName("updated_on") + .HasAnnotation("Relational:JsonPropertyName", "updatedOn"); + + b.Property("UserStarRating") + .HasColumnType("int") + .HasColumnName("user_star_rating") + .HasAnnotation("Relational:JsonPropertyName", "userStarRating"); + + b.Property("Width") + .HasColumnType("int") + .HasColumnName("width") + .HasAnnotation("Relational:JsonPropertyName", "width"); + + b.HasKey("Id"); + + b.ToTable("media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Producers.ProducerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uniqueidentifier") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("producer") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_producers"); + + b.HasAnnotation("Relational:JsonPropertyName", "producers"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Writers.WriterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("uniqueidentifier") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("writer") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_writers"); + + b.HasAnnotation("Relational:JsonPropertyName", "writers"); + }); + + modelBuilder.Entity("MediaBrowser.Users.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("id"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(125) + .HasColumnType("nvarchar(125)") + .HasColumnName("password_hash"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("user_name"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Cast.CastEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Cast") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Directors.DirectorEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Directors") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Genres.GenreEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Genres") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Producers.ProducerEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Producers") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Writers.WriterEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Writers") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.MediaEntity", b => + { + b.Navigation("Cast"); + + b.Navigation("Directors"); + + b.Navigation("Genres"); + + b.Navigation("Producers"); + + b.Navigation("Writers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/MediaBrowser.SqlServer/Migrations/20260316032003_Chapters.cs b/src/Migrations/MediaBrowser.SqlServer/Migrations/20260316032003_Chapters.cs new file mode 100644 index 0000000..1d8a0da --- /dev/null +++ b/src/Migrations/MediaBrowser.SqlServer/Migrations/20260316032003_Chapters.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MediaBrowser.Migrations +{ + /// + public partial class Chapters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "parent_id", + table: "media", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "start", + table: "media", + type: "float", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "parent_id", + table: "media"); + + migrationBuilder.DropColumn( + name: "start", + table: "media"); + } + } +} diff --git a/src/Migrations/MediaBrowser.Sqlite/Migrations/20260316031929_Chapters.Designer.cs b/src/Migrations/MediaBrowser.Sqlite/Migrations/20260316031929_Chapters.Designer.cs new file mode 100644 index 0000000..0c274d3 --- /dev/null +++ b/src/Migrations/MediaBrowser.Sqlite/Migrations/20260316031929_Chapters.Designer.cs @@ -0,0 +1,408 @@ +// +using System; +using MediaBrowser; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MediaBrowser.Migrations +{ + [DbContext(typeof(MediaSqliteDbContext))] + [Migration("20260316031929_Chapters")] + partial class Chapters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + modelBuilder.Entity("MediaBrowser.Media.Cast.CastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("TEXT") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("cast_member") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_cast"); + + b.HasAnnotation("Relational:JsonPropertyName", "cast"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Directors.DirectorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("TEXT") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("director") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_directors"); + + b.HasAnnotation("Relational:JsonPropertyName", "directors"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Genres.GenreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("TEXT") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("genre") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_genres"); + + b.HasAnnotation("Relational:JsonPropertyName", "genres"); + }); + + modelBuilder.Entity("MediaBrowser.Media.MediaEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("CreatedOn") + .HasColumnType("TEXT") + .HasColumnName("created_on") + .HasAnnotation("Relational:JsonPropertyName", "createdOn"); + + b.Property("CtimeMs") + .HasColumnType("INTEGER") + .HasColumnName("ctime_ms") + .HasAnnotation("Relational:JsonPropertyName", "ctimeMs"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("description") + .HasAnnotation("Relational:JsonPropertyName", "description"); + + b.Property("Duration") + .HasColumnType("REAL") + .HasColumnName("duration") + .HasAnnotation("Relational:JsonPropertyName", "duration"); + + b.Property("Ffprobe") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ffprobe") + .HasAnnotation("Relational:JsonPropertyName", "ffprobe"); + + b.Property("Height") + .HasColumnType("INTEGER") + .HasColumnName("height") + .HasAnnotation("Relational:JsonPropertyName", "height"); + + b.Property("Md5") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasColumnName("md5") + .HasAnnotation("Relational:JsonPropertyName", "md5"); + + b.Property("Mime") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("mime") + .HasAnnotation("Relational:JsonPropertyName", "mime"); + + b.Property("MtimeMs") + .HasColumnType("INTEGER") + .HasColumnName("mtime_ms") + .HasAnnotation("Relational:JsonPropertyName", "mtimeMs"); + + b.Property("OriginalTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("original_title") + .HasAnnotation("Relational:JsonPropertyName", "originalTitle"); + + b.Property("ParentId") + .HasColumnType("TEXT") + .HasColumnName("parent_id") + .HasAnnotation("Relational:JsonPropertyName", "parentId"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("path") + .HasAnnotation("Relational:JsonPropertyName", "path"); + + b.Property("Published") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("published") + .HasAnnotation("Relational:JsonPropertyName", "published"); + + b.Property("Rating") + .HasColumnType("REAL") + .HasColumnName("rating") + .HasAnnotation("Relational:JsonPropertyName", "rating"); + + b.Property("Size") + .HasColumnType("INTEGER") + .HasColumnName("size") + .HasAnnotation("Relational:JsonPropertyName", "size"); + + b.Property("Start") + .HasColumnType("REAL") + .HasColumnName("start") + .HasAnnotation("Relational:JsonPropertyName", "start"); + + b.Property("Thumbnail") + .HasColumnType("REAL") + .HasColumnName("thumbnail") + .HasAnnotation("Relational:JsonPropertyName", "thumbnail"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("title") + .HasAnnotation("Relational:JsonPropertyName", "title"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT") + .HasColumnName("updated_on") + .HasAnnotation("Relational:JsonPropertyName", "updatedOn"); + + b.Property("UserStarRating") + .HasColumnType("INTEGER") + .HasColumnName("user_star_rating") + .HasAnnotation("Relational:JsonPropertyName", "userStarRating"); + + b.Property("Width") + .HasColumnType("INTEGER") + .HasColumnName("width") + .HasAnnotation("Relational:JsonPropertyName", "width"); + + b.HasKey("Id"); + + b.ToTable("media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Producers.ProducerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("TEXT") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("producer") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_producers"); + + b.HasAnnotation("Relational:JsonPropertyName", "producers"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Writers.WriterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Relational:JsonPropertyName", "id"); + + b.Property("MediaId") + .HasMaxLength(36) + .HasColumnType("TEXT") + .HasColumnName("media_id") + .HasAnnotation("Relational:JsonPropertyName", "mediaId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("writer") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b.HasKey("Id"); + + b.HasIndex("MediaId", "Name") + .IsUnique(); + + b.ToTable("media_writers"); + + b.HasAnnotation("Relational:JsonPropertyName", "writers"); + }); + + modelBuilder.Entity("MediaBrowser.Users.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(125) + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_name"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Cast.CastEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Cast") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Directors.DirectorEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Directors") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Genres.GenreEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Genres") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Producers.ProducerEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Producers") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.Writers.WriterEntity", b => + { + b.HasOne("MediaBrowser.Media.MediaEntity", "Media") + .WithMany("Writers") + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("MediaBrowser.Media.MediaEntity", b => + { + b.Navigation("Cast"); + + b.Navigation("Directors"); + + b.Navigation("Genres"); + + b.Navigation("Producers"); + + b.Navigation("Writers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/MediaBrowser.Sqlite/Migrations/20260316031929_Chapters.cs b/src/Migrations/MediaBrowser.Sqlite/Migrations/20260316031929_Chapters.cs new file mode 100644 index 0000000..a3877df --- /dev/null +++ b/src/Migrations/MediaBrowser.Sqlite/Migrations/20260316031929_Chapters.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MediaBrowser.Migrations +{ + /// + public partial class Chapters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "parent_id", + table: "media", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "start", + table: "media", + type: "REAL", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "parent_id", + table: "media"); + + migrationBuilder.DropColumn( + name: "start", + table: "media"); + } + } +} From 5cc23c17676e869561881a5c9d51d675a671f61c Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Tue, 17 Mar 2026 21:48:57 -0400 Subject: [PATCH 2/5] feat: adding API to add chapters --- .../Media/AddChapterRequest.cs | 13 +++ .../Media/Import/ImportController.cs | 9 +- src/MediaBrowser.Common/Media/Import/Nfo.cs | 7 +- src/MediaBrowser.Common/Media/Import/Nfo.xml | 2 + src/MediaBrowser.Common/Media/Media.cs | 64 ++++++++++- src/MediaBrowser.Common/Media/MediaConfig.cs | 8 ++ .../Media/MediaController.cs | 87 +++++++++++---- .../Media/MediaReadModel.cs | 6 + src/MediaBrowser.Tests/Media/MediaClient.cs | 3 + .../Media/MediaControllerTests.cs | 103 ++++++++++++++++++ .../MediaMySqlDbContextModelSnapshot.cs | 10 ++ src/Migrations/MediaBrowser.MySql/schema.sql | 43 ++++++++ .../MediaPostgresDbContextModelSnapshot.cs | 10 ++ .../MediaBrowser.Postgres/schema.sql | 22 ++++ .../MediaSqlServerDbContextModelSnapshot.cs | 10 ++ .../MediaBrowser.SqlServer/schema.sql | 25 +++++ .../MediaSqliteDbContextModelSnapshot.cs | 10 ++ src/Migrations/MediaBrowser.Sqlite/schema.sql | 7 ++ 18 files changed, 408 insertions(+), 31 deletions(-) create mode 100644 src/MediaBrowser.Common/Media/AddChapterRequest.cs diff --git a/src/MediaBrowser.Common/Media/AddChapterRequest.cs b/src/MediaBrowser.Common/Media/AddChapterRequest.cs new file mode 100644 index 0000000..15859be --- /dev/null +++ b/src/MediaBrowser.Common/Media/AddChapterRequest.cs @@ -0,0 +1,13 @@ +namespace MediaBrowser.Media; + +public class AddChapterRequest : UpdateMediaRequest +{ + [JsonPropertyName("thumbnail"), Range(0, double.MaxValue)] + public double? Thumbnail { get; init; } + + [Range(0, double.MaxValue)] + public required double Duration { get; init; } + + [Range(0, double.MaxValue)] + public required double Start { get; init; } +} \ No newline at end of file diff --git a/src/MediaBrowser.Common/Media/Import/ImportController.cs b/src/MediaBrowser.Common/Media/Import/ImportController.cs index 67822e7..04fdc04 100644 --- a/src/MediaBrowser.Common/Media/Import/ImportController.cs +++ b/src/MediaBrowser.Common/Media/Import/ImportController.cs @@ -118,11 +118,11 @@ public async Task> Import(string name, [FromBody] I thumbnail: request.Thumbnail); await context.Media.AddAsync(media); - await nfo.Save(media, Path.Combine(mediaConfig.MediaDirectory, $"{hash}.nfo")); + await nfo.Save(media, mediaConfig.MediaFileLocation(media, ".nfo")); if (request.Thumbnail != null && ffprobe.Value.mime.StartsWith("video/", StringComparison.InvariantCulture)) { - var thumbnailLocation = Path.Combine(mediaConfig.MediaDirectory, $"{hash}.jpg"); + var thumbnailLocation = mediaConfig.MediaFileLocation(media, ".jpg"); if (!await ffmpeg.TryExtractThumbnail(file.Path, outputPath: thumbnailLocation, at: TimeSpan.FromSeconds(request.Thumbnail.Value))) @@ -131,13 +131,12 @@ public async Task> Import(string name, [FromBody] I } System.IO.File.Copy(thumbnailLocation, - destFileName: Path.Combine(mediaConfig.MediaDirectory, $"{hash}-fanart.jpg"), true); + destFileName: mediaConfig.MediaFileLocation(media, "-fanart.jpg"), true); } await context.SaveChangesAsync(); - var newFilePath = Path.Combine(mediaConfig.MediaDirectory, - $"{hash}.{mediaConfig.GetExtensionFromMime(ffprobe.Value.mime)}"); + var newFilePath = mediaConfig.MediaFileLocation(media); System.IO.File.Move(file.Path, newFilePath); diff --git a/src/MediaBrowser.Common/Media/Import/Nfo.cs b/src/MediaBrowser.Common/Media/Import/Nfo.cs index f8b6766..9f24053 100644 --- a/src/MediaBrowser.Common/Media/Import/Nfo.cs +++ b/src/MediaBrowser.Common/Media/Import/Nfo.cs @@ -41,6 +41,8 @@ public MediaEntity Read(string rawXml) throw new ParseNfoException(1, "Invalid or missing media ID"); } + var parentId = Guid.TryParse(xmlDoc.SelectSingleNode("//parentId")?.InnerText, out var parentIdValue) ? parentIdValue : (Guid?)null; + var hash = xmlDoc.SelectSingleNode("//md5")?.InnerText; if (string.IsNullOrWhiteSpace(hash) || hash.Length != 32) { @@ -99,7 +101,7 @@ public MediaEntity Read(string rawXml) }; var media = MediaEntity.Create( - mediaId: mediaId, + mediaId: mediaId, parentId: parentId, fileInfo: fileInfo, ffprobe: ffprobeResponse, request: request, @@ -113,7 +115,8 @@ public MediaEntity Read(string rawXml) height: int.TryParse(xmlDoc.SelectSingleNode("//height")?.InnerText ?? string.Empty, out var height) ? height : null, width: int.TryParse(xmlDoc.SelectSingleNode("//width")?.InnerText ?? string.Empty, out var width) ? width : null, ctimeMs: long.TryParse(xmlDoc.SelectSingleNode("//ctime")?.InnerText ?? string.Empty, out var ctimeMs) ? ctimeMs : null, - mtimeMs: long.TryParse(xmlDoc.SelectSingleNode("//mtimeMs")?.InnerText ?? string.Empty, out var mtimeMs) ? mtimeMs : null); + mtimeMs: long.TryParse(xmlDoc.SelectSingleNode("//mtimeMs")?.InnerText ?? string.Empty, out var mtimeMs) ? mtimeMs : null, + start: double.TryParse(xmlDoc.SelectSingleNode("//start")?.InnerText ?? string.Empty, out var start) ? start : null); return media; } diff --git a/src/MediaBrowser.Common/Media/Import/Nfo.xml b/src/MediaBrowser.Common/Media/Import/Nfo.xml index 23f0572..118a5ae 100644 --- a/src/MediaBrowser.Common/Media/Import/Nfo.xml +++ b/src/MediaBrowser.Common/Media/Import/Nfo.xml @@ -32,6 +32,8 @@ {{/each}} + {{parentId}} + {{start}} {{ctimeMs}} {{duration}} {{ffprobe}} diff --git a/src/MediaBrowser.Common/Media/Media.cs b/src/MediaBrowser.Common/Media/Media.cs index 8b79ff4..a9a13aa 100644 --- a/src/MediaBrowser.Common/Media/Media.cs +++ b/src/MediaBrowser.Common/Media/Media.cs @@ -20,6 +20,7 @@ public partial class MediaEntity [Column("mime"), JsonPropertyName("mime")] public required string Mime { get; init; } + public bool IsImage() => Mime.StartsWith("image/", StringComparison.InvariantCulture); [Column("size"), JsonPropertyName("size")] public required long? Size { get; init; } @@ -33,6 +34,9 @@ public partial class MediaEntity [Column("duration"), JsonPropertyName("duration")] public required double? Duration { get; set; } + [Column("start"), JsonPropertyName("start")] + public double? Start { get; set; } + [Column("md5"), JsonPropertyName("md5"), MaxLength(32)] public required string Md5 { get; init; } @@ -63,6 +67,9 @@ public partial class MediaEntity [Column("thumbnail"), JsonPropertyName("thumbnail")] public double? Thumbnail { get; set; } + [Column("parent_id"), JsonPropertyName("parentId")] + public Guid? ParentId { get; init; } + [JsonPropertyName("cast"), UnorderedEquality] public ICollection Cast { get; set; } = []; @@ -106,10 +113,12 @@ public partial class MediaEntity Writers = Writers.Select(it => it.Name).ToList(), Url = $"/api/Media/{Id}/file", Thumbnail = Thumbnail, - ThumbnailUrl = Mime.StartsWith("image/", StringComparison.InvariantCulture) || File.Exists(System.IO.Path.Combine(config.MediaDirectory, $"{Md5}.jpg")) + ThumbnailUrl = IsImage() || File.Exists(config.MediaFileLocation(this, ".jpg")) ? $"/api/Media/{Id}/file/thumbnail" : null, - FanartThumbnailUrl = Mime.StartsWith("image/", StringComparison.InvariantCulture) || File.Exists(System.IO.Path.Combine(config.MediaDirectory, $"{Md5}-fanart.jpg")) - ? $"/api/Media/{Id}/file/thumbnail-fanart" : null + FanartThumbnailUrl = IsImage() || File.Exists(config.MediaFileLocation(this, "-fanart.jpg")) + ? $"/api/Media/{Id}/file/thumbnail-fanart" : null, + Start = Start, + ParentId = ParentId }; public static MediaEntity Create( @@ -125,7 +134,8 @@ public static MediaEntity Create( Guid? mediaId = null, double? thumbnail = null, int? height = null, int? width = null, - long? ctimeMs = null, long? mtimeMs = null) + long? ctimeMs = null, long? mtimeMs = null, + Guid? parentId = null, double? start = null, double? duration = null) { var castEntities = new List(); var directorEntities = new List(); @@ -149,7 +159,7 @@ public static MediaEntity Create( Size = fileInfo.Length, Width = width ?? ffprobe.Streams?.Select(it => it.Width).OfType().Cast().FirstOrDefault(), Height = height ?? ffprobe.Streams?.Select(it => it.Height).OfType().Cast().FirstOrDefault(), - Duration = double.Parse(ffprobe.Format?.Duration ?? "0", CultureInfo.InvariantCulture), + Duration = duration ?? double.Parse(ffprobe.Format?.Duration ?? "0", CultureInfo.InvariantCulture), Md5 = hash, Rating = request.Rating, UserStarRating = request.UserStarRating, @@ -157,6 +167,8 @@ public static MediaEntity Create( MtimeMs = mtimeMs ?? Convert.ToInt64((fileInfo.LastWriteTimeUtc - DateTime.UnixEpoch).TotalMilliseconds), Ffprobe = ffprobe, Thumbnail = thumbnail, + Start = start, + ParentId = parentId, Cast = castEntities, Directors = directorEntities, @@ -208,6 +220,48 @@ public void Update(UpdateMediaRequest request) .Where(name => !string.IsNullOrWhiteSpace(name)).Distinct() .Select(name => new WriterEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(); } + + public MediaEntity CreateChapter(AddChapterRequest request) => new() + { + Id = Guid.CreateVersion7(), + CtimeMs = CtimeMs, + CreatedOn = CreatedOn, + UpdatedOn = UpdatedOn, + Path = Path, + Title = request.Title, + OriginalTitle = request.OriginalTitle, + Description = request.Description, + Mime = Mime, + Size = Size, + Width = Width, + Height = Height, + Duration = request.Duration, + Md5 = Md5, + Rating = request.Rating, + UserStarRating = request.UserStarRating, + Published = Published, + MtimeMs = MtimeMs, + Ffprobe = Ffprobe, + Thumbnail = request.Thumbnail, + Start = request.Start, + ParentId = Id, + + Cast = request.Cast + .Where(name => !string.IsNullOrWhiteSpace(name)).Distinct() + .Select(name => new CastEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(), + Directors = request.Directors + .Where(name => !string.IsNullOrWhiteSpace(name)).Distinct() + .Select(name => new DirectorEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(), + Genres = request.Genres + .Where(name => !string.IsNullOrWhiteSpace(name)).Distinct() + .Select(name => new GenreEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(), + Producers = request.Producers + .Where(name => !string.IsNullOrWhiteSpace(name)).Distinct() + .Select(name => new ProducerEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList(), + Writers = request.Writers + .Where(name => !string.IsNullOrWhiteSpace(name)).Distinct() + .Select(name => new WriterEntity {Id = 0, Media = this, MediaId = Id, Name = name}).ToList() + }; } public class MediaEntityConfiguration(DbType type) : IEntityTypeConfiguration diff --git a/src/MediaBrowser.Common/Media/MediaConfig.cs b/src/MediaBrowser.Common/Media/MediaConfig.cs index 9689bd1..e2f97b9 100644 --- a/src/MediaBrowser.Common/Media/MediaConfig.cs +++ b/src/MediaBrowser.Common/Media/MediaConfig.cs @@ -27,6 +27,14 @@ public string GetExtensionFromMime(string mime) return ext; } + /// + /// Creates a file location for a media file based on its MD5 hash and optional parent ID. + /// If the media has a parent ID, it appends it to the file name to allow for multiple files with the same hash (e.g., different chapters). + /// The extension is determined from the media's MIME type, or can be overridden with the optional extension parameter. + /// + public string MediaFileLocation(MediaEntity media, string? extension = null) => + Path.Combine(MediaDirectory, $"{media.Md5}{(media.ParentId == null || extension == null ? "" : $".{media.Id}")}{extension ?? $".{GetExtensionFromMime(media.Mime)}"}"); + public string MediaDirectory { get; } = configuration["media:mediaDirectory"]!; public string ProducersDirectory { get; } = configuration["media:producersDirectory"]!; public string WritersDirectory { get; } = configuration["media:writersDirectory"]!; diff --git a/src/MediaBrowser.Common/Media/MediaController.cs b/src/MediaBrowser.Common/Media/MediaController.cs index 451b164..aeb387b 100644 --- a/src/MediaBrowser.Common/Media/MediaController.cs +++ b/src/MediaBrowser.Common/Media/MediaController.cs @@ -35,13 +35,62 @@ public async Task> Update(Guid id, [FromBody] Updat media.Update(request); - await nfo.Save(media, Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}.nfo")); + await nfo.Save(media, mediaConfig.MediaFileLocation(media, ".nfo")); await context.SaveChangesAsync(); return media.ToReadModel(mediaConfig); } + [HttpPost("{id:guid}/chapters")] + public async Task> AddChapter(Guid id, [FromBody] AddChapterRequest request) + { + var media = await context.MediaJoined.Where(m => m.Id == id && m.ParentId == null).FirstOrDefaultAsync(); + if (media == null || media.IsImage()) + { + return NotFound(); + } + + if (request.Cast + .Concat(request.Directors) + .Concat(request.Genres) + .Concat(request.Producers) + .Concat(request.Writers) + .Any(it => !IsNameValid(it))) + { + return StatusCode(StatusCodes.Status417ExpectationFailed); + } + + if (request.Start + request.Duration > media.Duration) + { + return StatusCode(StatusCodes.Status416RangeNotSatisfiable); + } + + var chapter = media.CreateChapter(request); + + await nfo.Save(chapter, mediaConfig.MediaFileLocation(chapter, ".nfo")); + + await context.AddAsync(chapter); + + await context.SaveChangesAsync(); + + if (request.Thumbnail != null && chapter.Mime.StartsWith("video/", StringComparison.InvariantCulture)) + { + var thumbnailLocation = mediaConfig.MediaFileLocation(chapter, ".jpg"); + if (!await ffmpeg.TryExtractThumbnail(mediaConfig.MediaFileLocation(chapter), + outputPath: thumbnailLocation, + at: TimeSpan.FromSeconds(request.Thumbnail.Value))) + { + return StatusCode(StatusCodes.Status406NotAcceptable); + } + + System.IO.File.Copy(thumbnailLocation, + destFileName: mediaConfig.MediaFileLocation(chapter, "-fanart.jpg"), true); + } + + return chapter.ToReadModel(mediaConfig); + } + [HttpGet("search")] public async Task Search([FromQuery] SearchRequest request) { @@ -62,13 +111,13 @@ public async Task Search([FromQuery] SearchRequest request) [HttpGet("{id:guid}/file")] public Task Stream(Guid id) => - ReadFile(id, media => $".{mediaConfig.GetExtensionFromMime(media.Mime)}", media => media.Mime, true); + ReadFile(id, media => mediaConfig.MediaFileLocation(media), etag: true); [HttpGet("{id:guid}/file/thumbnail-fanart")] public Task StreamFanartThumbnail(Guid id) => ReadFile(id, - media => media.Mime.StartsWith("image/", StringComparison.InvariantCulture) ? $".{mediaConfig.GetExtensionFromMime(media.Mime)}" : "-fanart.jpg", - media => media.Mime.StartsWith("image/", StringComparison.InvariantCulture) ? media.Mime : "image/jpeg"); + media => media.IsImage() ? mediaConfig.MediaFileLocation(media) : mediaConfig.MediaFileLocation(media, "-fanart.jpg"), + media => media.IsImage() ? media.Mime : "image/jpeg"); [HttpPost("{id:guid}/file/thumbnail-fanart")] public Task UpdateFanartThumbnailWithTimestamp(Guid id, [FromBody] UpdateThumbnailRequest request) => @@ -77,8 +126,8 @@ public Task UpdateFanartThumbnailWithTimestamp(Guid id, [FromBody] [HttpGet("{id:guid}/file/thumbnail")] public Task StreamThumbnail(Guid id) => ReadFile(id, - media => media.Mime.StartsWith("image/", StringComparison.InvariantCulture) ? $".{mediaConfig.GetExtensionFromMime(media.Mime)}" : ".jpg", - media => media.Mime.StartsWith("image/", StringComparison.InvariantCulture) ? media.Mime : "image/jpeg"); + media => media.IsImage() ? mediaConfig.MediaFileLocation(media) : mediaConfig.MediaFileLocation(media, ".jpg"), + media => media.IsImage() ? media.Mime : "image/jpeg"); [HttpPost("{id:guid}/file/thumbnail")] public Task UpdateThumbnailWithTimestamp(Guid id, [FromBody] UpdateThumbnailRequest request) => @@ -93,13 +142,12 @@ public async Task UpdateThumbnailWithFile(Guid id, [FromForm] Uplo return NotFound(); } - if (media.Mime.StartsWith("image/", StringComparison.InvariantCulture)) + if (media.IsImage()) { return StatusCode(StatusCodes.Status406NotAcceptable); } - var extension = request.IsPrimary ? ".jpg" : "-fanart.jpg"; - var thumbnailLocation = Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}{extension}"); + var thumbnailLocation = mediaConfig.MediaFileLocation(media, request.IsPrimary ? ".jpg" : "-fanart.jpg"); var (result, success) = await UpdateThumbnail(request.Thumbnail, thumbnailLocation); @@ -109,16 +157,15 @@ public async Task UpdateThumbnailWithFile(Guid id, [FromForm] Uplo } media.Thumbnail = null; - await nfo.Save(media, Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}.nfo")); + await nfo.Save(media, mediaConfig.MediaFileLocation(media, ".nfo")); await context.SaveChangesAsync(); return result; } async Task ReadFile(Guid id, - Func extension, - Func mime, - bool etag = false) + Func filePathFactory, + Func? mimeFactory = null, bool etag = false) { var media = await context.Media.Where(m => m.Id == id).FirstOrDefaultAsync(); if (media == null) @@ -126,7 +173,7 @@ async Task ReadFile(Guid id, return NotFound(); } - var filePath = Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}{extension(media)}"); + var filePath = filePathFactory(media); if (!System.IO.File.Exists(filePath)) { return NotFound(); @@ -149,7 +196,9 @@ async Task ReadFile(Guid id, } Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); - return File(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read), mime(media), + mimeFactory ??= it => it.Mime; + + return File(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read), mimeFactory(media), enableRangeProcessing: true); } @@ -187,18 +236,18 @@ async Task UpdateThumbnailWithTimestamp(Guid id, string extension, return NotFound(); } - if (!media.Mime.StartsWith("video/", StringComparison.InvariantCulture)) + if (media.IsImage()) { return StatusCode(StatusCodes.Status406NotAcceptable); } - var filePath = Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}.{mediaConfig.GetExtensionFromMime(media.Mime)}"); + var filePath = mediaConfig.MediaFileLocation(media); if (!System.IO.File.Exists(filePath)) { return NotFound(); } - var thumbnailLocation = Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}{extension}"); + var thumbnailLocation = mediaConfig.MediaFileLocation(media, extension); if (!await ffmpeg.TryExtractThumbnail(filePath, outputPath: thumbnailLocation, at: TimeSpan.FromSeconds(at))) @@ -210,7 +259,7 @@ async Task UpdateThumbnailWithTimestamp(Guid id, string extension, if (isPrimary) { media.Thumbnail = at; - await nfo.Save(media, Path.Combine(mediaConfig.MediaDirectory, $"{media.Md5}.nfo")); + await nfo.Save(media, mediaConfig.MediaFileLocation(media, ".nfo")); await context.SaveChangesAsync(); } diff --git a/src/MediaBrowser.Common/Media/MediaReadModel.cs b/src/MediaBrowser.Common/Media/MediaReadModel.cs index 6ee8389..360183d 100644 --- a/src/MediaBrowser.Common/Media/MediaReadModel.cs +++ b/src/MediaBrowser.Common/Media/MediaReadModel.cs @@ -33,6 +33,9 @@ public partial class MediaReadModel [JsonPropertyName("duration")] public required double? Duration { get; init; } + [JsonPropertyName("start")] + public double? Start { get; set; } + [JsonPropertyName("md5")] public required string Md5 { get; init; } @@ -86,4 +89,7 @@ public partial class MediaReadModel [JsonPropertyName("fanartThumbnail")] public required string? FanartThumbnailUrl { get; init; } + + [JsonPropertyName("parentId")] + public Guid? ParentId { get; init; } } \ No newline at end of file diff --git a/src/MediaBrowser.Tests/Media/MediaClient.cs b/src/MediaBrowser.Tests/Media/MediaClient.cs index d789b8c..6ccfb2f 100644 --- a/src/MediaBrowser.Tests/Media/MediaClient.cs +++ b/src/MediaBrowser.Tests/Media/MediaClient.cs @@ -8,6 +8,9 @@ public Task> Get(Guid id) => public Task> Update(Guid id, UpdateMediaRequest request) => client.PutAsync($"/api/media/{id}", request); + public Task> AddChapter(Guid id, AddChapterRequest request) => + client.PostAsync($"/api/media/{id}/chapters", request); + public Task> Search(SearchRequest request) { var query = new Dictionary diff --git a/src/MediaBrowser.Tests/Media/MediaControllerTests.cs b/src/MediaBrowser.Tests/Media/MediaControllerTests.cs index 9da9b99..d5fd5f1 100644 --- a/src/MediaBrowser.Tests/Media/MediaControllerTests.cs +++ b/src/MediaBrowser.Tests/Media/MediaControllerTests.cs @@ -179,6 +179,8 @@ async Task UpdateSuccessTest() await UpdateThumbnailWithFileTests(mediaConfig, mediaClient, testVideoFile, testImageFile); await UpdateThumbnailWithTimestampTests(mediaConfig, mediaClient, testVideoFile, testImageFile); + + await AddChapterTests(mediaClient, testVideoFile, testImageFile); } async Task StreamTests(MediaConfig mediaConfig, MediaClient mediaClient, MediaReadModel testFile) @@ -499,6 +501,107 @@ async Task ValidFanartThumbnailTest() } } + async Task AddChapterTests(MediaClient mediaClient, MediaReadModel testVideoFile, MediaReadModel testImageFile) + { + var chapterRequest = new AddChapterRequest + { + Title = "Chapter 1", + OriginalTitle = "Chapter 1 Original", + Description = "A test chapter", + Cast = ["cast1"], + Directors = ["director1"], + Genres = ["genre1"], + Producers = ["producer1"], + Writers = ["writer1"], + Start = 0, + Duration = testVideoFile.Duration!.Value / 2, + Thumbnail = 0.1 + }; + using (var response = await mediaClient.AddChapter(testVideoFile.Id, chapterRequest)) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK, "Should add chapter to video successfully"); + response.Content.ShouldNotBeNull(); + response.Content.Title.ShouldBe("Chapter 1"); + response.Content.ParentId.ShouldBe(testVideoFile.Id); + } + + // Should fail for image (NotFound) + using (var response = await mediaClient.AddChapter(testImageFile.Id, chapterRequest)) + { + response.StatusCode.ShouldBe(HttpStatusCode.NotFound, "Should not allow adding chapter to image"); + } + + // Should fail with invalid tag (ExpectationFailed) + var invalidTagRequest = new AddChapterRequest + { + Title = "Invalid Tag Chapter", + OriginalTitle = "Invalid Tag Chapter", + Description = "Invalid tag test", + Cast = ["_invalid_"], + Directors = [], + Genres = [], + Producers = [], + Writers = [], + Start = 0, + Duration = testVideoFile.Duration!.Value / 2, + Thumbnail = 0.1 + }; + using (var response = await mediaClient.AddChapter(testVideoFile.Id, invalidTagRequest)) + { + response.StatusCode.ShouldBe(HttpStatusCode.ExpectationFailed, "Should fail with invalid tag"); + } + + // Should fail with invalid range (RangeNotSatisfiable) + var invalidRangeRequest = new AddChapterRequest + { + Title = "Invalid Range Chapter", + OriginalTitle = "Invalid Range Chapter", + Description = "Invalid range test", + Cast = [], + Directors = [], + Genres = [], + Producers = [], + Writers = [], + Start = testVideoFile.Duration!.Value, + Duration = 10, + Thumbnail = 0.1 + }; + using (var response = await mediaClient.AddChapter(testVideoFile.Id, invalidRangeRequest)) + { + response.StatusCode.ShouldBe(HttpStatusCode.RequestedRangeNotSatisfiable, "Should fail with invalid range"); + } + + // Should succeed for video with thumbnail + var chapterWithThumbRequest = new AddChapterRequest + { + Title = "Chapter with Thumb", + OriginalTitle = "Chapter with Thumb", + Description = "Chapter with thumbnail", + Cast = [], + Directors = [], + Genres = [], + Producers = [], + Writers = [], + Start = 0.5, + Duration = 0.25, + Thumbnail = 0.5 + }; + using (var response = await mediaClient.AddChapter(testVideoFile.Id, chapterWithThumbRequest)) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK, "Should add chapter with thumbnail to video successfully"); + response.Content.ShouldNotBeNull(); + response.Content.Title.ShouldBe("Chapter with Thumb"); + response.Content.ParentId.ShouldBe(testVideoFile.Id); + + using var thumbnailResponse = await mediaClient.StreamThumbnail(response.Content.Id); + const string message = "This should return the thumbnail successfully since we are using a valid chapter ID and the chapter has a thumbnail."; + thumbnailResponse.StatusCode.ShouldBe(HttpStatusCode.OK, message); + thumbnailResponse.Content.ShouldNotBeNull(message); + thumbnailResponse.Content.Headers.ContentLength.ShouldNotBeNull(message).ShouldNotBe(0, message); + thumbnailResponse.Content.Headers.ContentType.ShouldNotBeNull(message).MediaType.ShouldBe("image/jpeg", message); + } + } + async Task AddTestImageFile(MediaBrowserWebApplicationFactory factory, HttpClient client) { var importClient = new ImportClient(client); diff --git a/src/Migrations/MediaBrowser.MySql/Migrations/MediaMySqlDbContextModelSnapshot.cs b/src/Migrations/MediaBrowser.MySql/Migrations/MediaMySqlDbContextModelSnapshot.cs index dfe1162..ffe8fd5 100644 --- a/src/Migrations/MediaBrowser.MySql/Migrations/MediaMySqlDbContextModelSnapshot.cs +++ b/src/Migrations/MediaBrowser.MySql/Migrations/MediaMySqlDbContextModelSnapshot.cs @@ -188,6 +188,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("original_title") .HasAnnotation("Relational:JsonPropertyName", "originalTitle"); + b.Property("ParentId") + .HasColumnType("char(36)") + .HasColumnName("parent_id") + .HasAnnotation("Relational:JsonPropertyName", "parentId"); + b.Property("Path") .IsRequired() .HasMaxLength(500) @@ -212,6 +217,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("size") .HasAnnotation("Relational:JsonPropertyName", "size"); + b.Property("Start") + .HasColumnType("double") + .HasColumnName("start") + .HasAnnotation("Relational:JsonPropertyName", "start"); + b.Property("Thumbnail") .HasColumnType("double") .HasColumnName("thumbnail") diff --git a/src/Migrations/MediaBrowser.MySql/schema.sql b/src/Migrations/MediaBrowser.MySql/schema.sql index fa53124..fed5291 100644 --- a/src/Migrations/MediaBrowser.MySql/schema.sql +++ b/src/Migrations/MediaBrowser.MySql/schema.sql @@ -330,5 +330,48 @@ DELIMITER ; CALL MigrationsScript(); DROP PROCEDURE MigrationsScript; +DROP PROCEDURE IF EXISTS MigrationsScript; +DELIMITER // +CREATE PROCEDURE MigrationsScript() +BEGIN + IF NOT EXISTS(SELECT 1 FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20260316031943_Chapters') THEN + + ALTER TABLE `media` ADD `parent_id` char(36) COLLATE ascii_general_ci NULL; + + END IF; +END // +DELIMITER ; +CALL MigrationsScript(); +DROP PROCEDURE MigrationsScript; + +DROP PROCEDURE IF EXISTS MigrationsScript; +DELIMITER // +CREATE PROCEDURE MigrationsScript() +BEGIN + IF NOT EXISTS(SELECT 1 FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20260316031943_Chapters') THEN + + ALTER TABLE `media` ADD `start` double NULL; + + END IF; +END // +DELIMITER ; +CALL MigrationsScript(); +DROP PROCEDURE MigrationsScript; + +DROP PROCEDURE IF EXISTS MigrationsScript; +DELIMITER // +CREATE PROCEDURE MigrationsScript() +BEGIN + IF NOT EXISTS(SELECT 1 FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20260316031943_Chapters') THEN + + INSERT INTO `__EFMigrationsHistory` (`MigrationId`, `ProductVersion`) + VALUES ('20260316031943_Chapters', '9.0.9'); + + END IF; +END // +DELIMITER ; +CALL MigrationsScript(); +DROP PROCEDURE MigrationsScript; + COMMIT; diff --git a/src/Migrations/MediaBrowser.Postgres/Migrations/MediaPostgresDbContextModelSnapshot.cs b/src/Migrations/MediaBrowser.Postgres/Migrations/MediaPostgresDbContextModelSnapshot.cs index 41eca51..dcb0d2d 100644 --- a/src/Migrations/MediaBrowser.Postgres/Migrations/MediaPostgresDbContextModelSnapshot.cs +++ b/src/Migrations/MediaBrowser.Postgres/Migrations/MediaPostgresDbContextModelSnapshot.cs @@ -188,6 +188,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("original_title") .HasAnnotation("Relational:JsonPropertyName", "originalTitle"); + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id") + .HasAnnotation("Relational:JsonPropertyName", "parentId"); + b.Property("Path") .IsRequired() .HasMaxLength(500) @@ -212,6 +217,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("size") .HasAnnotation("Relational:JsonPropertyName", "size"); + b.Property("Start") + .HasColumnType("double precision") + .HasColumnName("start") + .HasAnnotation("Relational:JsonPropertyName", "start"); + b.Property("Thumbnail") .HasColumnType("double precision") .HasColumnName("thumbnail") diff --git a/src/Migrations/MediaBrowser.Postgres/schema.sql b/src/Migrations/MediaBrowser.Postgres/schema.sql index ec77acb..21b083d 100644 --- a/src/Migrations/MediaBrowser.Postgres/schema.sql +++ b/src/Migrations/MediaBrowser.Postgres/schema.sql @@ -190,5 +190,27 @@ BEGIN VALUES ('20260306204654_ExpandPasswordLength', '9.0.9'); END IF; END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260316031955_Chapters') THEN + ALTER TABLE media ADD parent_id uuid; + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260316031955_Chapters') THEN + ALTER TABLE media ADD start double precision; + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260316031955_Chapters') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260316031955_Chapters', '9.0.9'); + END IF; +END $EF$; COMMIT; diff --git a/src/Migrations/MediaBrowser.SqlServer/Migrations/MediaSqlServerDbContextModelSnapshot.cs b/src/Migrations/MediaBrowser.SqlServer/Migrations/MediaSqlServerDbContextModelSnapshot.cs index 7e0995f..90f2f79 100644 --- a/src/Migrations/MediaBrowser.SqlServer/Migrations/MediaSqlServerDbContextModelSnapshot.cs +++ b/src/Migrations/MediaBrowser.SqlServer/Migrations/MediaSqlServerDbContextModelSnapshot.cs @@ -188,6 +188,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("original_title") .HasAnnotation("Relational:JsonPropertyName", "originalTitle"); + b.Property("ParentId") + .HasColumnType("uniqueidentifier") + .HasColumnName("parent_id") + .HasAnnotation("Relational:JsonPropertyName", "parentId"); + b.Property("Path") .IsRequired() .HasMaxLength(500) @@ -212,6 +217,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("size") .HasAnnotation("Relational:JsonPropertyName", "size"); + b.Property("Start") + .HasColumnType("float") + .HasColumnName("start") + .HasAnnotation("Relational:JsonPropertyName", "start"); + b.Property("Thumbnail") .HasColumnType("float") .HasColumnName("thumbnail") diff --git a/src/Migrations/MediaBrowser.SqlServer/schema.sql b/src/Migrations/MediaBrowser.SqlServer/schema.sql index cc9c64e..d453045 100644 --- a/src/Migrations/MediaBrowser.SqlServer/schema.sql +++ b/src/Migrations/MediaBrowser.SqlServer/schema.sql @@ -218,6 +218,31 @@ BEGIN VALUES (N'20260306204659_ExpandPasswordLength', N'9.0.9'); END; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260316032003_Chapters' +) +BEGIN + ALTER TABLE [media] ADD [parent_id] uniqueidentifier NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260316032003_Chapters' +) +BEGIN + ALTER TABLE [media] ADD [start] float NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260316032003_Chapters' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260316032003_Chapters', N'9.0.9'); +END; + COMMIT; GO diff --git a/src/Migrations/MediaBrowser.Sqlite/Migrations/MediaSqliteDbContextModelSnapshot.cs b/src/Migrations/MediaBrowser.Sqlite/Migrations/MediaSqliteDbContextModelSnapshot.cs index f81468d..766f9ea 100644 --- a/src/Migrations/MediaBrowser.Sqlite/Migrations/MediaSqliteDbContextModelSnapshot.cs +++ b/src/Migrations/MediaBrowser.Sqlite/Migrations/MediaSqliteDbContextModelSnapshot.cs @@ -177,6 +177,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("original_title") .HasAnnotation("Relational:JsonPropertyName", "originalTitle"); + b.Property("ParentId") + .HasColumnType("TEXT") + .HasColumnName("parent_id") + .HasAnnotation("Relational:JsonPropertyName", "parentId"); + b.Property("Path") .IsRequired() .HasMaxLength(500) @@ -201,6 +206,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("size") .HasAnnotation("Relational:JsonPropertyName", "size"); + b.Property("Start") + .HasColumnType("REAL") + .HasColumnName("start") + .HasAnnotation("Relational:JsonPropertyName", "start"); + b.Property("Thumbnail") .HasColumnType("REAL") .HasColumnName("thumbnail") diff --git a/src/Migrations/MediaBrowser.Sqlite/schema.sql b/src/Migrations/MediaBrowser.Sqlite/schema.sql index c1da3f4..19d4344 100644 --- a/src/Migrations/MediaBrowser.Sqlite/schema.sql +++ b/src/Migrations/MediaBrowser.Sqlite/schema.sql @@ -90,5 +90,12 @@ VALUES ('20251017193532_Thumbnail', '9.0.9'); INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('20260306204644_ExpandPasswordLength', '9.0.9'); +ALTER TABLE "media" ADD "parent_id" TEXT NULL; + +ALTER TABLE "media" ADD "start" REAL NULL; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20260316031929_Chapters', '9.0.9'); + COMMIT; From c4aa9d1e13663ef869e2fb16f812c5ec9d578072 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Tue, 17 Mar 2026 21:49:43 -0400 Subject: [PATCH 3/5] feat: updating the media service --- .../src/app/services/media.service.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/MediaBrowser.Frontend/src/app/services/media.service.ts b/src/MediaBrowser.Frontend/src/app/services/media.service.ts index ce1b0e2..cf978f4 100644 --- a/src/MediaBrowser.Frontend/src/app/services/media.service.ts +++ b/src/MediaBrowser.Frontend/src/app/services/media.service.ts @@ -45,6 +45,8 @@ export interface MediaReadModel { thumbnail?: number; thumbnailUrl?: string; fanartUrl?: string; + start?: number; + parentId?: number; } export interface SearchResponse { @@ -65,6 +67,12 @@ export interface UpdateMediaRequest { writers: string[]; } +export interface AddChapterRequest extends UpdateMediaRequest { + thumbnail?: number; + duration: number; + start: number; +}; + export interface UpdateThumbnailRequest { at: number; } @@ -86,6 +94,10 @@ export class MediaService { return this.apiService.put(`/media/${id}`, request); } + addChapter(id: string, request: AddChapterRequest): Observable { + return this.apiService.post(`/media/${id}/chapters`, request); + } + private readonly seed: number = (Math.random() * 0x100000000 | 0); search(request: SearchMediaRequest): Observable { request.seed ??= this.seed; From a12ddeac6ec41ab5d3cc063f2c7bab711097226b Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Tue, 17 Mar 2026 22:06:27 -0400 Subject: [PATCH 4/5] feat: adding time indexing --- .../src/app/player/player.html | 4 ++-- src/MediaBrowser.Frontend/src/app/player/player.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/MediaBrowser.Frontend/src/app/player/player.html b/src/MediaBrowser.Frontend/src/app/player/player.html index ed99f38..4825153 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.html +++ b/src/MediaBrowser.Frontend/src/app/player/player.html @@ -27,7 +27,7 @@

{{ mediaData.title }}

autoplay class="video-element" controls> - + Your browser does not support the video tag. } @@ -41,7 +41,7 @@

{{ mediaData.title }}

autoplay class="audio-element" controls> - + Your browser does not support the audio element. } diff --git a/src/MediaBrowser.Frontend/src/app/player/player.ts b/src/MediaBrowser.Frontend/src/app/player/player.ts index ee01078..5ed9f1f 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.ts +++ b/src/MediaBrowser.Frontend/src/app/player/player.ts @@ -132,4 +132,17 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { const element = event.target as HTMLVideoElement | HTMLAudioElement; this.saveVolume(element.volume); } + + getMediaSourceUrl(): string { + if (!this.mediaData) { + return ''; + } + + if (this.mediaData.start == null || this.mediaData.duration == null) { + return this.mediaData.url; + } + + const end = this.mediaData.start + this.mediaData.duration; + return `${this.mediaData.url}#t=${this.mediaData.start},${end}`; + } } \ No newline at end of file From 42d1a4feecd3f5db82a846c4b02d66355e800ea4 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Sun, 22 Mar 2026 22:32:24 -0400 Subject: [PATCH 5/5] feat: adding chapter support --- .../src/app/app.routes.ts | 6 +- .../src/app/media-editor/media-editor.html | 76 ++--- .../src/app/media-editor/media-editor.spec.ts | 109 ++++--- .../src/app/media-editor/media-editor.ts | 273 +++++++++++------- .../people-section.component.ts | 48 +-- .../typeahead-input.component.ts | 8 +- .../readonly-info-section.component.ts | 31 +- .../readonly-info-section.html | 17 +- .../thumbnail-section.component.spec.ts | 51 ++-- .../thumbnail-section.component.ts | 95 +++--- .../thumbnail-section/thumbnail-section.html | 2 +- .../src/app/player/player.css | 162 ++++++++++- .../src/app/player/player.html | 58 +++- .../src/app/player/player.spec.ts | 106 ++++++- .../src/app/player/player.ts | 235 ++++++++++----- .../src/app/services/media.service.ts | 2 +- 16 files changed, 894 insertions(+), 385 deletions(-) diff --git a/src/MediaBrowser.Frontend/src/app/app.routes.ts b/src/MediaBrowser.Frontend/src/app/app.routes.ts index 36ced8a..f6f4eb9 100644 --- a/src/MediaBrowser.Frontend/src/app/app.routes.ts +++ b/src/MediaBrowser.Frontend/src/app/app.routes.ts @@ -28,13 +28,13 @@ export const routes: Routes = [ loadComponent: () => import('./media-editor/media-editor').then(m => m.MediaEditorComponent), canActivate: [authGuard] }, - { - path: 'import/:fileName', + { + path: 'edit/:id/:start/:end/chapter', loadComponent: () => import('./media-editor/media-editor').then(m => m.MediaEditorComponent), canActivate: [authGuard] }, { - path: 'edit', + path: 'import/:fileName', loadComponent: () => import('./media-editor/media-editor').then(m => m.MediaEditorComponent), canActivate: [authGuard] }, diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html index 3b71212..dea78a4 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html @@ -2,65 +2,65 @@
@if (hasNavigationHistory) { - } -

{{ mediaId === null ? 'Import Media' : 'Edit Media' }}

+

{{ headerTitle }}

- -
- @if (mediaData) { + @if (mediaData && !isLoading) {
-
- - +
+ + - - + + - - + + - - + + - - -
+ + +
} - - -
-} \ No newline at end of file +} + + + \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.spec.ts b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.spec.ts index 42406c1..4a7f39e 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.spec.ts @@ -9,7 +9,7 @@ import { ImportComponent } from '../import/import'; import { SearchComponent } from '../search/search'; import { PeopleData, PeopleSectionComponent } from './people-section/people-section.component'; import { ThumbnailData } from './thumbnail-section/thumbnail-section.component'; -import { MediaEditorComponent } from './media-editor'; +import { MediaEditorComponent, MediaEditorMode } from './media-editor'; function createMedia(overrides: Partial = {}): MediaReadModel { return { @@ -47,12 +47,15 @@ function createMedia(overrides: Partial = {}): MediaReadModel { describe('MediaEditorComponent', () => { let routeParams: Record; + let queryParams: Record; + let routePath: string | undefined; let currentNavigation: { extras?: { state?: Record }; previousNavigation?: unknown } | null; let mediaServiceMock: { get: ReturnType; getAllTags: ReturnType; update: ReturnType; + addChapter: ReturnType; updateThumbnail: ReturnType; }; @@ -67,6 +70,8 @@ describe('MediaEditorComponent', () => { beforeEach(async () => { routeParams = {}; + queryParams = {}; + routePath = undefined; currentNavigation = { extras: { state: {} }, previousNavigation: {} @@ -76,6 +81,7 @@ describe('MediaEditorComponent', () => { get: vi.fn().mockReturnValue(of(createMedia())), getAllTags: vi.fn().mockReturnValue(of([])), update: vi.fn().mockReturnValue(of(createMedia())), + addChapter: vi.fn().mockReturnValue(of(createMedia({ id: 'chapter-id' }))), updateThumbnail: vi.fn().mockReturnValue(of(void 0)) }; @@ -114,8 +120,22 @@ describe('MediaEditorComponent', () => { provide: ActivatedRoute, useValue: { snapshot: { + routeConfig: { + get path() { + return routePath; + } + }, paramMap: { - get: (key: string) => routeParams[key] ?? null + get: (key: string) => routeParams[key] ?? null, + has: (key: string) => Object.prototype.hasOwnProperty.call(routeParams, key), + getAll: (key: string) => routeParams[key] ? [routeParams[key]!] : [], + keys: Object.keys(routeParams) + }, + queryParamMap: { + get: (key: string) => queryParams[key] ?? null, + has: (key: string) => Object.prototype.hasOwnProperty.call(queryParams, key), + getAll: (key: string) => queryParams[key] ? [queryParams[key]!] : [], + keys: Object.keys(queryParams) } } } @@ -140,7 +160,7 @@ describe('MediaEditorComponent', () => { expect(mediaServiceMock.get).toHaveBeenCalledWith('media-1'); expect(component.mediaId).toBe('media-1'); - expect(component.filename).toBeNull(); + expect(component.filename).toBeUndefined(); expect(component.editableData.title).toBe('Title One'); expect(component.thumbnail).toEqual({ selectedImageFile: null, @@ -164,7 +184,9 @@ describe('MediaEditorComponent', () => { const fixture = TestBed.createComponent(MediaEditorComponent); const component = fixture.componentInstance; - await component.loadMediaById('media-1'); + component.mediaId = 'media-1'; + await component.loadMedia(); + component.setThumbnail(); expect(mediaServiceMock.get).not.toHaveBeenCalled(); expect(component.mediaData?.title).toBe('State Title'); @@ -184,21 +206,16 @@ describe('MediaEditorComponent', () => { expect(importServiceMock.readFileInfo).toHaveBeenCalledWith('movie.mp4'); expect(convertSpy).toHaveBeenCalledTimes(1); expect(component.filename).toBe('movie.mp4'); - expect(component.thumbnail).toBeNull(); + expect(component.thumbnail).toEqual({ + selectedImageFile: null, + thumbnail: 0, + thumbnailPreviewUrl: '' + }); expect(component.mediaData?.title).toBe('import-file.mp4'); convertSpy.mockRestore(); }); - it('throws when loadMediaToImport has no filename or media data', async () => { - routeParams['fileName'] = null; - - const fixture = TestBed.createComponent(MediaEditorComponent); - const component = fixture.componentInstance; - - await expect(component.loadMediaToImport()).rejects.toThrow('No media data or filename found'); - }); - it('logs initialization errors and exits loading state', async () => { routeParams['fileName'] = 'movie.mp4'; importServiceMock.readFileInfo.mockReturnValue(throwError(() => new Error('read failed'))); @@ -222,7 +239,7 @@ describe('MediaEditorComponent', () => { const fixture = TestBed.createComponent(MediaEditorComponent); const component = fixture.componentInstance; - component.mediaData = null; + component.mediaData = undefined; await component.saveChanges(); @@ -238,7 +255,9 @@ describe('MediaEditorComponent', () => { const fixture = TestBed.createComponent(MediaEditorComponent); const component = fixture.componentInstance; + routeParams['fileName'] = 'movie.mp4'; component.filename = 'movie.mp4'; + component.mode = MediaEditorMode.Import; component.mediaData = createMedia({ mime: 'video/mp4' }); component.thumbnail = { selectedImageFile: null, @@ -254,7 +273,7 @@ describe('MediaEditorComponent', () => { }); expect(mediaServiceMock.updateThumbnail).not.toHaveBeenCalled(); expect(searchCacheSpy).toHaveBeenCalledTimes(1); - expect(peopleCacheSpy).toHaveBeenCalledWith(component.getPeopleData()); + expect(peopleCacheSpy).toHaveBeenCalledWith(component.peopleData); expect(locationMock.back).toHaveBeenCalledTimes(1); searchCacheSpy.mockRestore(); @@ -265,7 +284,9 @@ describe('MediaEditorComponent', () => { const fixture = TestBed.createComponent(MediaEditorComponent); const component = fixture.componentInstance; + routeParams['fileName'] = 'movie.mp4'; component.filename = 'movie.mp4'; + component.mode = MediaEditorMode.Import; component.mediaData = createMedia(); component.thumbnail = { selectedImageFile: { name: 'thumb.jpg' } as File, @@ -285,6 +306,7 @@ describe('MediaEditorComponent', () => { component.mediaId = 'media-77'; component.mediaData = createMedia({ id: 'media-77' }); + component.mode = MediaEditorMode.Edit; await component.saveChanges(); @@ -295,6 +317,30 @@ describe('MediaEditorComponent', () => { expect(locationMock.back).toHaveBeenCalledTimes(1); }); + it('adds chapter when route is chapter mode and id exists', async () => { + routePath = 'edit/:id/chapter'; + routeParams['id'] = 'media-parent'; + routeParams['start'] = '12.5'; + routeParams['end'] = '40.5'; + + const fixture = TestBed.createComponent(MediaEditorComponent); + const component = fixture.componentInstance; + + await component.ngOnInit(); + await component.saveChanges(); + + expect(component.mode).toBe(MediaEditorMode.AddChapter); + expect(component.chapterStart).toBe(12.5); + expect(component.chapterDuration).toBe(28); + expect(mediaServiceMock.addChapter).toHaveBeenCalledWith('media-parent', { + ...component.editableData, + duration: 28, + start: 12.5, + thumbnail: 12.5 + }); + expect(mediaServiceMock.update).not.toHaveBeenCalled(); + }); + it('handles saveChanges errors and always resets saving flag', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); @@ -315,32 +361,23 @@ describe('MediaEditorComponent', () => { errorSpy.mockRestore(); }); - it('supports cancel, formatting, getters and event handlers', () => { + it('supports cancel, getters and event handlers', () => { const fixture = TestBed.createComponent(MediaEditorComponent); const component = fixture.componentInstance; component.mediaData = createMedia(); - component.setEditableData(component.mediaData); + component.setEditableData(); component.cancel(); expect(locationMock.back).toHaveBeenCalledTimes(1); - expect(component.formatDuration()).toBe('00:00:0.000'); - expect(component.formatDuration(3661.5)).toBe('01:01:1.500'); - - const localeSpy = vi.spyOn(Date.prototype, 'toLocaleString').mockReturnValue('formatted date'); - expect(component.formatDateTime('12345')).toBe('formatted date'); - localeSpy.mockRestore(); - - expect(component.formatFileSize(1048576)).toBe('1.00 MB'); - - expect(component.getTitleData()).toEqual({ + expect(component.titleData).toEqual({ title: component.editableData.title, originalTitle: component.editableData.originalTitle, description: component.editableData.description }); - expect(component.getReadOnlyData()).toEqual({ + expect(component.readOnlyData).toEqual({ id: component.mediaData.id, duration: component.mediaData.duration, size: component.mediaData.size, @@ -350,13 +387,15 @@ describe('MediaEditorComponent', () => { width: component.mediaData.width, height: component.mediaData.height, mime: component.mediaData.mime, - rating: component.mediaData.rating, - published: component.mediaData.published + published: component.mediaData.published, + start: component.mediaData.start, + parentId: component.mediaData.parentId }); - expect(component.getThumbnailMediaData()).toEqual({ + expect(component.thumbnailMediaData ).toEqual({ mime: component.mediaData.mime, - url: component.mediaData.url + url: component.mediaData.url, + start: component.mediaData.start }); component.onTitleDataChange({ title: 'T2', originalTitle: 'O2', description: 'D2' }); @@ -371,7 +410,7 @@ describe('MediaEditorComponent', () => { }; component.onPeopleDataChange(people); - expect(component.getPeopleData()).toEqual(people); + expect(component.peopleData).toEqual(people); const thumbnail: ThumbnailData = { selectedImageFile: null, @@ -391,7 +430,7 @@ describe('MediaEditorComponent', () => { const fixture = TestBed.createComponent(MediaEditorComponent); const component = fixture.componentInstance; - component.mediaId = null; + component.mediaId = undefined; await component.onSaveThumbnail(); diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts index 41e630a..cad3346 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts @@ -3,7 +3,7 @@ import { ChangeDetectorRef, Component, inject, OnInit, ViewChild } from '@angula import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Navigation, Router } from '@angular/router'; import { Location } from '@angular/common'; -import { MediaReadModel, MediaService, UpdateMediaRequest } from '../services'; +import { AddChapterRequest, MediaReadModel, MediaService, UpdateMediaRequest } from '../services'; import { firstValueFrom } from 'rxjs'; import { ImportService } from '../services/import.service'; import { SpinnerComponent } from '../spinner/spinner'; @@ -15,6 +15,12 @@ import { ThumbnailSectionComponent, ThumbnailData, MediaThumbnailData } from './ import { ImportComponent } from '../import/import'; import { SearchComponent } from '../search/search'; +export enum MediaEditorMode { + Edit = 'Edit', + Import = 'Import', + AddChapter = 'AddChapter' +} + @Component({ selector: 'app-media-editor', imports: [ @@ -45,16 +51,12 @@ export class MediaEditorComponent implements OnInit { get hasNavigationHistory(): boolean { return this.navigation?.previousNavigation != null; } + get readWriteInProgress(): boolean { + return this.isCreatingThumbnail || this.isLoading || this.isSaving; + } - filename: string | null = null; - isCreatingThumbnail: boolean = false; - isLoading: boolean = false; - isSaving: boolean = false; - mediaData: MediaReadModel | null = null; - mediaId: string | null = null; - thumbnail: ThumbnailData | null = null; - - // Form fields for editable properties + chapterDuration?: number; + chapterStart?: number; editableData: UpdateMediaRequest = { cast: [], directors: [], @@ -65,17 +67,51 @@ export class MediaEditorComponent implements OnInit { title: '', writers: [], }; + filename?: string; + isCreatingThumbnail: boolean = false; + isLoading: boolean = false; + isSaving: boolean = false; + mediaData?: MediaReadModel; + mediaId?: string; + mode: MediaEditorMode = MediaEditorMode.Edit; + thumbnail?: ThumbnailData; + // init component based on route params and load media data if needed async ngOnInit(): Promise { try { this.isLoading = true; - this.mediaId = this.route.snapshot.paramMap.get('id'); - if (this.mediaId) { - await this.loadMediaById(this.mediaId); + + if (this.route.snapshot.paramMap.has('fileName')) { + this.mode = MediaEditorMode.Import; + + // set route params + this.chapterStart = undefined; + this.chapterDuration = undefined; + this.filename = this.route.snapshot.paramMap.get('fileName')!; + this.mediaId = undefined; + } else if (this.route.snapshot.paramMap.has('start') && this.route.snapshot.paramMap.has('end')) { + this.mode = MediaEditorMode.AddChapter; + + // set route params + this.chapterStart = Number(this.route.snapshot.paramMap.get('start')); + this.chapterDuration = Number(this.route.snapshot.paramMap.get('end')) - this.chapterStart; + this.filename = undefined; + this.mediaId = this.route.snapshot.paramMap.get('id')!; } else { - await this.loadMediaToImport(); + this.mode = MediaEditorMode.Edit; + + // set route params + this.chapterStart = undefined; + this.chapterDuration = undefined; + this.filename = undefined; + this.mediaId = this.route.snapshot.paramMap.get('id')!; } - this.setEditableData(this.mediaData!); + + // read the media data based on the mode and route params + await this.loadMedia(); + + this.setEditableData(); + this.setThumbnail(); } catch (error) { console.error('Error during initialization:', error); } finally { @@ -83,50 +119,83 @@ export class MediaEditorComponent implements OnInit { this.cdr.detectChanges(); } } + async loadMedia(): Promise { + this.mediaData = this.navigation?.extras.state?.['mediaData']; - async loadMediaById(id: string): Promise { - this.filename = null; - this.mediaData = this.navigation?.extras.state?.['mediaData'] ?? await firstValueFrom(this.mediaService.get(id)); - this.thumbnail = { - selectedImageFile: null, - thumbnail: this.mediaData!.thumbnail!, - thumbnailPreviewUrl: this.mediaData!.thumbnailUrl || '' - }; + if (this.mode === MediaEditorMode.Import) { + this.mediaData ??= ImportComponent.convertToMediaReadModel(await firstValueFrom(this.importService.readFileInfo(this.filename!))); + } else if (this.mediaData?.id !== this.mediaId) { + this.mediaData = await firstValueFrom(this.mediaService.get(this.mediaId!)); + } } - - async loadMediaToImport(): Promise { - this.filename = this.route.snapshot.paramMap.get('fileName'); - this.mediaData = this.navigation?.extras.state?.['mediaData'] ?? - ImportComponent.convertToMediaReadModel(await firstValueFrom(this.importService.readFileInfo(this.filename!))); - this.thumbnail = null; - - if (!this.mediaData || !this.filename) { - throw new Error('No media data or filename found'); + setThumbnail(): void { + if (this.mode === MediaEditorMode.AddChapter) { + this.thumbnail = { + selectedImageFile: null, + thumbnail: this.chapterStart!, + thumbnailPreviewUrl: '' + }; + } else if (this.mode === MediaEditorMode.Edit) { + this.thumbnail = { + selectedImageFile: null, + thumbnail: this.mediaData!.thumbnail!, + thumbnailPreviewUrl: this.mediaData!.thumbnailUrl || '' + }; + } else { + this.thumbnail = { + selectedImageFile: null, + thumbnail: 0, + thumbnailPreviewUrl: '' + }; } + console.log('Initial thumbnail set:', this.thumbnail); } - - setEditableData(mediaData: MediaReadModel): void { + setEditableData(): void { this.editableData = { - cast: [...mediaData.cast], - description: mediaData.description, - directors: [...mediaData.directors], - genres: [...mediaData.genres], - originalTitle: mediaData.originalTitle, - producers: [...mediaData.producers], - title: mediaData.title, - userStarRating: mediaData.userStarRating, - writers: [...mediaData.writers], + cast: [...this.mediaData!.cast], + description: this.mediaData!.description, + directors: [...this.mediaData!.directors], + genres: [...this.mediaData!.genres], + originalTitle: this.mediaData!.originalTitle, + producers: [...this.mediaData!.producers], + title: this.mediaData!.title, + userStarRating: this.mediaData!.userStarRating, + writers: [...this.mediaData!.writers], }; } + // save and cancel methods async saveChanges(): Promise { if (!this.mediaData) { return; } this.isSaving = true; + try { - if (this.filename) { + if (this.mode === MediaEditorMode.AddChapter) { + + let thumbnailData: number | undefined; + + if (typeof this.thumbnail?.thumbnail === 'number') { + thumbnailData = this.thumbnail.thumbnail; + } else if (this.mediaData?.mime.startsWith('video/')) { + thumbnailData = this.chapterStart!; + } + + const chapterRequest: AddChapterRequest = { + ...this.editableData, + duration: this.chapterDuration!, + start: this.chapterStart!, + thumbnail: thumbnailData + }; + + await firstValueFrom(this.mediaService.addChapter(this.mediaId!, chapterRequest)); + } else if (this.mode === MediaEditorMode.Edit) { + this.mediaData = await firstValueFrom(this.mediaService.update(this.mediaId!, { + ...this.editableData + })); + } else if (this.mode === MediaEditorMode.Import) { let thumbnail = this.thumbnail?.thumbnail ?? undefined; if (!this.thumbnail?.selectedImageFile @@ -135,7 +204,7 @@ export class MediaEditorComponent implements OnInit { thumbnail = 0; } - const media = await firstValueFrom(this.importService.import(this.filename, { + const media = await firstValueFrom(this.importService.import(this.filename!, { ...this.editableData, thumbnail: thumbnail })); @@ -143,18 +212,12 @@ export class MediaEditorComponent implements OnInit { if (this.thumbnail?.selectedImageFile) { await firstValueFrom(this.mediaService.updateThumbnail(media.id, this.thumbnail.selectedImageFile)); } - } else if (this.mediaId) { - await firstValueFrom(this.mediaService.update(this.mediaId, { - ...this.editableData - })); } + SearchComponent.clearCachedResults(); - PeopleSectionComponent.clearCacheIfStale(this.getPeopleData()); - if (this.hasNavigationHistory) { - this.location.back(); - } else if (this.filename) { - this.router.navigate(['/search'], { queryParams: { sort: SearchComponent.DEFAULT_SORT } }); - } + PeopleSectionComponent.clearCacheIfStale(this.peopleData); + + this.cancel(); } catch (error) { console.error('Error saving media changes:', error); } finally { @@ -162,7 +225,6 @@ export class MediaEditorComponent implements OnInit { this.cdr.detectChanges(); } } - cancel(): void { if (this.hasNavigationHistory) { this.location.back(); @@ -171,37 +233,15 @@ export class MediaEditorComponent implements OnInit { } } - // Helper methods for formatted display - formatDuration(seconds?: number): string { - seconds ??= 0; - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = (seconds % 60).toFixed(3); - - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; - } - - formatDateTime(epochMs: string): string { - const date = new Date(parseInt(epochMs)); - return date.toLocaleString(); - } - - formatFileSize(bytes?: number): string { - const mb = (bytes ?? 0) / (1024 * 1024); - return `${mb.toFixed(2)} MB`; - } - // Component data getters - getTitleData(): TitleData { + get titleData(): TitleData { return { title: this.editableData.title, originalTitle: this.editableData.originalTitle, description: this.editableData.description }; } - - getPeopleData(): PeopleData { + get peopleData(): PeopleData { return { cast: this.editableData.cast, directors: this.editableData.directors, @@ -210,41 +250,49 @@ export class MediaEditorComponent implements OnInit { writers: this.editableData.writers }; } - - getReadOnlyData(): MediaReadOnlyData { + get readOnlyData(): MediaReadOnlyData { return { - id: this.mediaData!.id, + ctimeMs: this.mediaData!.ctimeMs, duration: this.mediaData!.duration, - size: this.mediaData!.size, + height: this.mediaData!.height, + id: this.mediaData!.id, md5: this.mediaData!.md5, - ctimeMs: this.mediaData!.ctimeMs, + mime: this.mediaData!.mime, mtimeMs: this.mediaData!.mtimeMs, + parentId: this.mediaData!.parentId, + published: this.mediaData!.published, + size: this.mediaData!.size, + start: this.mediaData!.start, width: this.mediaData!.width, - height: this.mediaData!.height, - mime: this.mediaData!.mime, - rating: this.mediaData!.rating, - published: this.mediaData!.published }; } - - getThumbnailMediaData(): MediaThumbnailData { + get saveButtonText(): string { + if (this.mode === MediaEditorMode.AddChapter) { + return 'Add Chapter'; + } + if (this.mode === MediaEditorMode.Import) { + return 'Import File'; + } + return 'Save Changes'; + } + get thumbnailMediaData(): MediaThumbnailData { return { mime: this.mediaData!.mime, + start: this.chapterStart ?? this.mediaData!.start, url: this.mediaData!.url }; } - - // Component event handlers - onTitleDataChange(titleData: TitleData): void { - this.editableData.title = titleData.title; - this.editableData.originalTitle = titleData.originalTitle; - this.editableData.description = titleData.description; - } - - onRatingChange(rating: number): void { - this.editableData.userStarRating = rating; + get headerTitle(): string { + if (this.mode === MediaEditorMode.AddChapter) { + return 'Add Chapter'; + } + if (this.mode === MediaEditorMode.Import) { + return 'Import Media'; + } + return 'Edit Media'; } + // Component event handlers onPeopleDataChange(peopleData: PeopleData): void { this.editableData.cast = peopleData.cast; this.editableData.directors = peopleData.directors; @@ -252,16 +300,9 @@ export class MediaEditorComponent implements OnInit { this.editableData.producers = peopleData.producers; this.editableData.writers = peopleData.writers; } - - onThumbnailChange(thumbnail: ThumbnailData): void { - this.thumbnail = thumbnail; - } - - onSetThumbnailPreview(): void { - // The thumbnail component handles the preview generation internally - // This method can be used for any additional logic if needed + onRatingChange(rating: number): void { + this.editableData.userStarRating = rating; } - async onSaveThumbnail(): Promise { if (!this.mediaId) { return; @@ -282,4 +323,16 @@ export class MediaEditorComponent implements OnInit { this.cdr.detectChanges(); } } + onSetThumbnailPreview(): void { + // The thumbnail component handles the preview generation internally + // This method can be used for any additional logic if needed + } + onThumbnailChange(thumbnail: ThumbnailData): void { + this.thumbnail = thumbnail; + } + onTitleDataChange(titleData: TitleData): void { + this.editableData.title = titleData.title; + this.editableData.originalTitle = titleData.originalTitle; + this.editableData.description = titleData.description; + } } \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/people-section/people-section.component.ts b/src/MediaBrowser.Frontend/src/app/media-editor/people-section/people-section.component.ts index a90cd0b..32e1c0a 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/people-section/people-section.component.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/people-section/people-section.component.ts @@ -56,13 +56,31 @@ export class PeopleSectionComponent implements OnInit { } } + // initialize allPeople from cache or API on component init + + async ngOnInit(): Promise { + const cachedPeopleValue = localStorage.getItem(PeopleSectionComponent.CACHE_KEY); + if (cachedPeopleValue) { + PeopleSectionComponent.allPeople = JSON.parse(cachedPeopleValue); + } else { + PeopleSectionComponent.allPeople = { + cast: await firstValueFrom(this.mediaService.getAllTags('cast')), + directors: await firstValueFrom(this.mediaService.getAllTags('directors')), + genres: await firstValueFrom(this.mediaService.getAllTags('genres')), + producers: await firstValueFrom(this.mediaService.getAllTags('producers')), + writers: await firstValueFrom(this.mediaService.getAllTags('writers')), + }; + localStorage.setItem(PeopleSectionComponent.CACHE_KEY, JSON.stringify(PeopleSectionComponent.allPeople)); + } + } + + // Add/remove items from each array and emit changes + addArrayItem(arrayName: keyof PeopleData): void { this.peopleData[arrayName].push(''); this.onPeopleChange(); - this.focusNewlyAddedInput(arrayName); - } - private focusNewlyAddedInput(arrayName: keyof PeopleData): void { + // Focus the newly added input after it appears in the DOM setTimeout(() => { const inputsByType: Record> = { cast: this.castInputs, @@ -82,26 +100,6 @@ export class PeopleSectionComponent implements OnInit { }); } - async ngOnInit(): Promise { - const cachedPeopleValue = localStorage.getItem(PeopleSectionComponent.CACHE_KEY); - if (cachedPeopleValue) { - PeopleSectionComponent.allPeople = JSON.parse(cachedPeopleValue); - } else { - PeopleSectionComponent.allPeople = { - cast: await firstValueFrom(this.mediaService.getAllTags('cast')), - directors: await firstValueFrom(this.mediaService.getAllTags('directors')), - genres: await firstValueFrom(this.mediaService.getAllTags('genres')), - producers: await firstValueFrom(this.mediaService.getAllTags('producers')), - writers: await firstValueFrom(this.mediaService.getAllTags('writers')), - }; - localStorage.setItem(PeopleSectionComponent.CACHE_KEY, JSON.stringify(PeopleSectionComponent.allPeople)); - } - } - - onPeopleChange(): void { - this.peopleDataChange.emit(this.peopleData); - } - removeArrayItem(arrayName: keyof PeopleData, index: number): void { this.peopleData[arrayName].splice(index, 1); this.onPeopleChange(); @@ -133,6 +131,10 @@ export class PeopleSectionComponent implements OnInit { } // Handle suggestion selection for updating array values + onPeopleChange(): void { + this.peopleDataChange.emit(this.peopleData); + } + onSuggestionSelected(arrayName: keyof PeopleData, index: number, selectedValue: string): void { this.peopleData[arrayName][index] = selectedValue; this.onPeopleChange(); diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/people-section/typeahead-input.component.ts b/src/MediaBrowser.Frontend/src/app/media-editor/people-section/typeahead-input.component.ts index d533083..3593bda 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/people-section/typeahead-input.component.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/people-section/typeahead-input.component.ts @@ -27,15 +27,15 @@ export class TypeaheadInputComponent implements ControlValueAccessor, OnInit, On showDropdown: boolean = false; highlightedIndex: number = -1; - private onChange = (value: string) => {}; - private onDocumentClick(event: Event): void { + onChange = (value: string) => {}; + onDocumentClick(event: Event): void { const target = event.target as HTMLElement; if (!target.closest('.typeahead-container')) { this.showDropdown = false; } } - private onTouched = () => {}; - private updateFilteredSuggestions(): void { + onTouched = () => {}; + updateFilteredSuggestions(): void { if (!this.displayValue) { this.filteredSuggestions = []; return; diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/readonly-info-section/readonly-info-section.component.ts b/src/MediaBrowser.Frontend/src/app/media-editor/readonly-info-section/readonly-info-section.component.ts index 061f7f2..42fc812 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/readonly-info-section/readonly-info-section.component.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/readonly-info-section/readonly-info-section.component.ts @@ -2,17 +2,18 @@ import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; export interface MediaReadOnlyData { - id: string; - duration?: number; - size?: number; - md5: string; ctimeMs: string; - mtimeMs: string; - width?: number; + duration?: number; height?: number; + id: string; + md5: string; mime: string; - rating?: number; + mtimeMs: string; + parentId?: string; published?: string; + size?: number; + start?: number; + width?: number; } @Component({ @@ -25,7 +26,7 @@ export class ReadonlyInfoSectionComponent { @Input() mediaData!: MediaReadOnlyData; @Input() showSection: boolean = true; - formatDateTime(epochMs: string): string { + static formatDateTime(epochMs: string): string { const date = new Date(parseInt(epochMs)); return date.toLocaleString(); } @@ -40,12 +41,22 @@ export class ReadonlyInfoSectionComponent { return `${hours ? `${hours}:` : ''}${minutes.toString().padStart(hours ? 2 : 1, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; } + static formatFileSize(bytes?: number): string { + bytes ??= 0; + + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(2)} MB`; + } + + formatDateTime(epochMs: string): string { + return ReadonlyInfoSectionComponent.formatDateTime(epochMs); + } + formatDuration(seconds?: number): string { return ReadonlyInfoSectionComponent.formatDuration(seconds); } formatFileSize(bytes?: number): string { - const mb = (bytes ?? 0) / (1024 * 1024); - return `${mb.toFixed(2)} MB`; + return ReadonlyInfoSectionComponent.formatFileSize(bytes); } } \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/readonly-info-section/readonly-info-section.html b/src/MediaBrowser.Frontend/src/app/media-editor/readonly-info-section/readonly-info-section.html index 8465954..eb73f39 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/readonly-info-section/readonly-info-section.html +++ b/src/MediaBrowser.Frontend/src/app/media-editor/readonly-info-section/readonly-info-section.html @@ -8,9 +8,19 @@

Media Information (Read Only)

{{ mediaData.id }} +
+ + {{ mediaData.start ? formatDuration(mediaData.start) : 'N/A' }} +
+
- {{ formatDuration(mediaData.duration) }} + {{ formatDuration(mediaData.duration ?? 0) }} +
+ +
+ + {{ mediaData.parentId ?? 'N/A' }}
@@ -43,11 +53,6 @@

Media Information (Read Only)

{{ mediaData.mime }}
-
- - {{ mediaData.rating }} -
-
{{ mediaData.published }} diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.component.spec.ts b/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.component.spec.ts index 5ad2e5a..31a833a 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.component.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.component.spec.ts @@ -25,27 +25,28 @@ describe('ThumbnailSectionComponent', () => { expect(component.getVideoUrl()).toBe(''); component.mediaData = { mime: 'video/mp4', url: 'https://video.test/file.mp4' }; - expect(component.getVideoUrl()).toBe('https://video.test/file.mp4'); - }); + expect(component.getVideoUrl()).toBe('https://video.test/file.mp4#t=0'); - it('loads initial thumbnail in ngAfterViewInit and emits the selection', () => { - vi.useFakeTimers(); + component.mediaData = { mime: 'video/mp4', url: 'https://video.test/file.mp4', start: 10 }; + expect(component.getVideoUrl()).toBe('https://video.test/file.mp4#t=10'); + }); + it('loads initial thumbnail in onVideoMetadataLoaded and emits the selection', () => { const fixture = TestBed.createComponent(ThumbnailSectionComponent); const component = fixture.componentInstance; const emitSpy = vi.spyOn(component.thumbnailChange, 'emit'); component.initialThumbnail = initialThumbnail; - component.ngAfterViewInit(); + component.ngOnInit(); // Sets isLoading = true - vi.runAllTimers(); + // Simulate video metadata loaded event + component.onVideoMetadataLoaded(); expect(component.thumbnails).toEqual([initialThumbnail]); expect(component.selectedThumbnail).toEqual(initialThumbnail); expect(component.selectedThumbnailIndex).toBe(0); expect(emitSpy).toHaveBeenCalledWith(initialThumbnail); - - vi.useRealTimers(); + expect(component.isLoading).toBe(false); }); it('handles drag over/leave and drop states', () => { @@ -87,6 +88,9 @@ describe('ThumbnailSectionComponent', () => { } as unknown as DragEvent; component.onDrop(dropWrongFile); + // Based on implementation, this logs "Please select an image file" via handleImageFile + // But handleImageFile is called. We need to spy on console.error OR handleImageFile. + // The previous test expected errorSpy to be called. expect(errorSpy).toHaveBeenCalledWith('Please select an image file'); errorSpy.mockRestore(); }); @@ -133,37 +137,44 @@ describe('ThumbnailSectionComponent', () => { errorSpy.mockRestore(); }); - it('mutes/focuses video and applies selected timestamp when metadata loads', () => { + it('focuses video and generates preview if missing when metadata loads', () => { const fixture = TestBed.createComponent(ThumbnailSectionComponent); const component = fixture.componentInstance; + // Mock CDR to prevent ViewChild overwrite + (component as any).cdr = { detectChanges: vi.fn() }; + const video = document.createElement('video'); - video.currentTime = 2; (video as HTMLVideoElement).focus = vi.fn(); + // Helper to bypass canvas errors if generateThumbnailPreview is called + vi.spyOn(component as any, 'generateThumbnailPreview').mockReturnValue('mock-preview'); component.videoPlayer = new ElementRef(video); - component.selectedThumbnail = { + component.initialThumbnail = { thumbnail: 14, - thumbnailPreviewUrl: 'preview', + thumbnailPreviewUrl: '', selectedImageFile: null }; + component.ngOnInit(); component.onVideoMetadataLoaded(); - expect(video.muted).toBe(true); - expect(video.volume).toBe(0); - expect(video.currentTime).toBe(14); expect(video.focus).toHaveBeenCalledTimes(1); + expect(component.initialThumbnail.thumbnailPreviewUrl).toBe('mock-preview'); - component.selectedThumbnail = { - thumbnail: null, - thumbnailPreviewUrl: 'preview', + // Check scenario where thumbnail does not need generation + component.initialThumbnail = { + thumbnail: 14, + thumbnailPreviewUrl: 'existing-preview', selectedImageFile: null }; - video.currentTime = 7; + (component as any).generateThumbnailPreview.mockClear(); + // Reset loading state + component.ngOnInit(); component.onVideoMetadataLoaded(); - expect(video.currentTime).toBe(7); + + expect((component as any).generateThumbnailPreview).not.toHaveBeenCalled(); }); it('opens file browser and navigates thumbnails while emitting updates', () => { diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.component.ts b/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.component.ts index 9322093..c58675c 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.component.ts +++ b/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, EventEmitter, inject, Input, OnInit, Output, ViewChild } from '@angular/core'; export interface ThumbnailData { thumbnail: number | null; @@ -9,6 +9,7 @@ export interface ThumbnailData { export interface MediaThumbnailData { mime: string; + start?: number; url?: string; } @@ -18,8 +19,8 @@ export interface MediaThumbnailData { templateUrl: './thumbnail-section.html', styleUrls: ['../media-editor.css', './thumbnail-section.css'] }) -export class ThumbnailSectionComponent implements AfterViewInit { - @Input() initialThumbnail: ThumbnailData | null = null; +export class ThumbnailSectionComponent implements OnInit { + @Input() initialThumbnail: ThumbnailData | undefined = undefined; @Input() isCreatingThumbnail: boolean = false; @Input() mediaData!: MediaThumbnailData; @Input() showSaveThumbnail: boolean = false; @@ -33,7 +34,14 @@ export class ThumbnailSectionComponent implements AfterViewInit { @ViewChild('videoPlayer') videoPlayer?: ElementRef; private cdr = inject(ChangeDetectorRef); - private generateThumbnailPreview(videoElement: HTMLVideoElement): string { + + isDragOver: boolean = false; + isLoading: boolean = true; + selectedThumbnail: ThumbnailData | null = null; + selectedThumbnailIndex: number = -1; + thumbnails: ThumbnailData[] = []; + + generateThumbnailPreview(videoElement: HTMLVideoElement): string { try { // Create a canvas element to capture the video frame const canvas = document.createElement('canvas'); @@ -58,7 +66,12 @@ export class ThumbnailSectionComponent implements AfterViewInit { return ''; } } - private handleImageFile(file: File): void { + + getVideoUrl(): string { + return this.mediaData?.url ? `${this.mediaData.url}#t=${this.mediaData.start ?? 0}` : ''; + } + + handleImageFile(file: File): void { if (!file.type.startsWith('image/')) { console.error('Please select an image file'); return; @@ -85,31 +98,16 @@ export class ThumbnailSectionComponent implements AfterViewInit { }; reader.readAsDataURL(file); } - private onThumbnailChange(): void { - if (this.selectedThumbnail) { - this.thumbnailChange.emit(this.selectedThumbnail); - } - } - isDragOver: boolean = false; - selectedThumbnail: ThumbnailData | null = null; - selectedThumbnailIndex: number = -1; - thumbnails: ThumbnailData[] = []; - - getVideoUrl(): string { - return this.mediaData?.url || ''; + nextThumbnail(): void { + this.selectedThumbnailIndex++; + this.selectedThumbnail = this.thumbnails[this.selectedThumbnailIndex]; + this.onThumbnailChange(); + this.videoPlayer?.nativeElement.focus(); } - - ngAfterViewInit(): void { - // Ensure video is muted when view initializes - setTimeout(() => { - if (this.initialThumbnail) { - this.thumbnails = [this.initialThumbnail]; - this.selectedThumbnail = this.initialThumbnail; - this.selectedThumbnailIndex = 0; - this.onThumbnailChange(); - } - }, 0); + + ngOnInit(): void { + this.isLoading = true; } onDragLeave(event: DragEvent): void { @@ -136,6 +134,12 @@ export class ThumbnailSectionComponent implements AfterViewInit { } } + onThumbnailChange(): void { + if (this.selectedThumbnail) { + this.thumbnailChange.emit(this.selectedThumbnail); + } + } + onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { @@ -145,16 +149,28 @@ export class ThumbnailSectionComponent implements AfterViewInit { } onVideoMetadataLoaded(): void { - if (this.videoPlayer) { - // Ensure video is muted - this.videoPlayer.nativeElement.muted = true; - this.videoPlayer.nativeElement.volume = 0; + if (!this.isLoading) { + return; + } - if (typeof this.selectedThumbnail?.thumbnail === 'number') { - this.videoPlayer.nativeElement.currentTime = this.selectedThumbnail!.thumbnail!; - } + this.isLoading = false; + + let thumbnail = this.initialThumbnail; + if (thumbnail) { + this.thumbnails = [thumbnail]; + this.selectedThumbnail = thumbnail; + this.selectedThumbnailIndex = 0; + this.onThumbnailChange(); + this.cdr.detectChanges(); + } + + if (this.videoPlayer) { this.videoPlayer.nativeElement.focus(); + if (thumbnail?.thumbnailPreviewUrl === '' && typeof thumbnail?.thumbnail === 'number') { + thumbnail.thumbnailPreviewUrl = this.generateThumbnailPreview(this.videoPlayer!.nativeElement); + this.cdr.detectChanges(); + } } } @@ -162,13 +178,6 @@ export class ThumbnailSectionComponent implements AfterViewInit { this.fileInput?.nativeElement.click(); } - nextThumbnail(): void { - this.selectedThumbnailIndex++; - this.selectedThumbnail = this.thumbnails[this.selectedThumbnailIndex]; - this.onThumbnailChange(); - this.videoPlayer?.nativeElement.focus(); - } - previousThumbnail(): void { this.selectedThumbnailIndex--; this.selectedThumbnail = this.thumbnails[this.selectedThumbnailIndex]; @@ -188,7 +197,7 @@ export class ThumbnailSectionComponent implements AfterViewInit { } if (this.videoPlayer) { - const currentTimestamp = this.videoPlayer.nativeElement.currentTime; + const currentTimestamp = this.videoPlayer.nativeElement.currentTime; const existingThumbnailIndex = this.thumbnails.findIndex((thumbnail) => thumbnail.thumbnail === currentTimestamp); if (existingThumbnailIndex !== -1) { diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.html b/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.html index fd1b544..2d09059 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.html +++ b/src/MediaBrowser.Frontend/src/app/media-editor/thumbnail-section/thumbnail-section.html @@ -9,7 +9,7 @@

Thumbnail Creation

@if (mediaData.mime.startsWith('video/')) {
}
@if (mediaData && mediaData.mime.startsWith('video/')) { } @if (mediaData && mediaData.mime.startsWith('audio/')) { } @@ -53,6 +60,43 @@

{{ mediaData.title }}

class="image-element"/> }
+ + @if (mediaData && mediaData.duration && !mediaData.mime.startsWith('image/') && chapterPanelVisible) { +
+
+
+
+ + + Start + Stop +
+ +
+ Start {{ formatTimestamp(chapterStart) }} + Stop {{ formatTimestamp(chapterEnd) }} +
+ + +
+ } @if (!mediaData) {
diff --git a/src/MediaBrowser.Frontend/src/app/player/player.spec.ts b/src/MediaBrowser.Frontend/src/app/player/player.spec.ts index c9dd1f5..cae251a 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/player/player.spec.ts @@ -48,6 +48,7 @@ function createMediaReadModel(id = 'media-1', mime = 'video/mp4'): MediaReadMode producers: [], writers: [], url: `https://example.com/${id}`, + duration: 120, thumbnailUrl: `https://example.com/${id}.jpg` }; } @@ -97,7 +98,23 @@ async function createComponent(overrides?: { describe('PlayerComponent', () => { beforeEach(() => { - localStorage.clear(); + let store: Record = {}; + const localStorageMock = { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + key: vi.fn(), + length: 0, + }; + vi.stubGlobal('localStorage', localStorageMock); + vi.useFakeTimers(); vi.restoreAllMocks(); }); @@ -119,7 +136,8 @@ describe('PlayerComponent', () => { const fromNavigation = createMediaReadModel('from-nav'); const { component, mocks } = await createComponent({ navigationMediaData: fromNavigation }); - await component.loadMediaById('ignored-id'); + component.mediaId = 'from-nav'; + await component.loadMedia(); expect(mocks.mediaService.get).not.toHaveBeenCalled(); expect(component.mediaData?.id).toBe('from-nav'); @@ -129,7 +147,8 @@ describe('PlayerComponent', () => { const fromService = createMediaReadModel('from-service'); const { component, mocks } = await createComponent({ serviceMediaData: fromService }); - await component.loadMediaById('from-service'); + component.mediaId = 'from-service'; + await component.loadMedia(); expect(mocks.mediaService.get).toHaveBeenCalledWith('from-service'); expect(component.mediaData?.id).toBe('from-service'); @@ -140,9 +159,10 @@ describe('PlayerComponent', () => { const { component, mocks } = await createComponent(); mocks.mediaService.get.mockReturnValueOnce(throwError(() => new Error('network failed'))); - await component.loadMediaById('broken'); + component.mediaId = 'broken'; + await component.loadMedia(); - expect(component.mediaData).toBeNull(); + expect(component.mediaData).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalled(); }); @@ -175,6 +195,76 @@ describe('PlayerComponent', () => { expect(mocks.router.navigate).not.toHaveBeenCalled(); }); + it('toggles chapter panel visibility', async () => { + const { component } = await createComponent(); + + component.mediaData = createMediaReadModel(); + const video = document.createElement('video'); + Object.defineProperty(video, 'duration', { value: 120 }); + // @ts-ignore + video.pause = vi.fn(); + component.videoElement = new ElementRef(video); + + expect(component.chapterPanelVisible).toBe(false); + + component.toggleChapterPanel(); + expect(component.chapterPanelVisible).toBe(true); + + component.toggleChapterPanel(); + expect(component.chapterPanelVisible).toBe(false); + }); + + it('updates chapter range and seeks media element while dragging handles', async () => { + const { component } = await createComponent(); + component.chapterStart = 10; + component.chapterEnd = 40; + + const video = document.createElement('video'); + component.videoElement = new ElementRef(video); + + component.onRangeInput('start', { + target: { value: '15' } + } as unknown as Event); + + expect(component.chapterStart).toBe(15); + expect(video.currentTime).toBe(15); + + component.onRangeInput('end', { + target: { value: '32' } + } as unknown as Event); + + expect(component.chapterEnd).toBe(32); + expect(video.currentTime).toBe(32); + }); + + it('navigates to add chapter route with range in query params and state', async () => { + const mediaData = createMediaReadModel('chapter-parent'); + const { component, mocks } = await createComponent(); + component.mediaId = 'chapter-parent'; + component.mediaData = mediaData; + component.chapterStart = 12.5; + component.chapterEnd = 45.2; + + component.goToAddChapter(); + + expect(mocks.router.navigate).toHaveBeenCalledWith(['/edit', 'chapter-parent', 12.5, 45.2, 'chapter'], { + state: { + mediaData + } + }); + }); + + it('does not navigate to add chapter route when range is invalid', async () => { + const { component, mocks } = await createComponent(); + component.mediaId = 'chapter-parent'; + component.chapterStart = 10; + component.chapterEnd = 10; + + component.goToAddChapter(); + + expect(mocks.router.navigate).not.toHaveBeenCalled(); + }); + it('initializes with route id, loads media, and hides header after timeout', async () => { const fromService = createMediaReadModel('route-media'); const { component, mocks } = await createComponent({ @@ -256,7 +346,7 @@ describe('PlayerComponent', () => { const audio = document.createElement('audio'); audio.volume = 0.6; - component.onVolumeChange({ target: audio } as Event); + component.onVolumeChange({ target: audio } as unknown as Event); expect(localStorage.getItem('mediaBrowser_volume')).toBe('0.6'); }); @@ -276,10 +366,10 @@ describe('PlayerComponent', () => { const { component } = await createComponent(); component.onMouseLeave(); - expect((component as any).hideTimeout).not.toBeNull(); + expect((component as any).hideTimeout).toBeDefined(); component.ngOnDestroy(); - expect((component as any).hideTimeout).toBeNull(); + expect((component as any).hideTimeout).toBeUndefined(); }); }); diff --git a/src/MediaBrowser.Frontend/src/app/player/player.ts b/src/MediaBrowser.Frontend/src/app/player/player.ts index 5ed9f1f..b4cd975 100644 --- a/src/MediaBrowser.Frontend/src/app/player/player.ts +++ b/src/MediaBrowser.Frontend/src/app/player/player.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Navigation, Router } from '@angular/router'; import { Location } from '@angular/common'; import { MediaReadModel, MediaService } from '../services'; import { firstValueFrom } from 'rxjs/internal/firstValueFrom'; +import { ReadonlyInfoSectionComponent } from '../media-editor/readonly-info-section/readonly-info-section.component'; @Component({ selector: 'app-player', @@ -25,40 +26,97 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { @ViewChild('videoElement', { static: false }) videoElement!: ElementRef; @ViewChild('audioElement', { static: false }) audioElement!: ElementRef; - mediaData: MediaReadModel | null = null; - mediaId: string | null = null; + get hasHistory(): boolean { + return window.history.length > 1; + } + get mediaSourceUrl(): string { + if (!this.mediaData) { + return ''; + } + + if (this.mediaData.start == null || this.mediaData.duration == null) { + return this.mediaData.url; + } + + const end = this.mediaData.start + this.mediaData.duration; + return `${this.mediaData.url}#t=${this.mediaData.start},${end}`; + } + + chapterEnd?: number; + chapterPanelVisible: boolean = false; + chapterStart?: number; headerVisible: boolean = true; - hasHistory: boolean = false; + hideTimeout?: number; + isLoading: boolean = true; + mediaData?: MediaReadModel; + mediaId?: string; - private hideTimeout: number | null = null; - private readonly VOLUME_STORAGE_KEY = 'mediaBrowser_volume'; + async loadMedia(): Promise { + try { + let mediaData = this.navigation?.extras.state?.['mediaData']; + if (!mediaData || mediaData.id !== this.mediaId) { + mediaData = await firstValueFrom(this.mediaService.get(this.mediaId!)); + } + this.mediaData = mediaData; + this.cdr.detectChanges(); + } catch (error) { + console.error('Error loading media by ID:', error); + } + } + ngAfterViewInit(): void { + this.applySavedVolume(); + } + ngOnDestroy(): void { + this.clearHideTimer(); + } + async ngOnInit(): Promise { + this.isLoading = true; + this.mediaId = this.route.snapshot.paramMap.get('id')!; + await this.loadMedia(); + this.startHideTimer(); + } + onMediaMetadataLoaded(): void { + this.isLoading = false; + this.applySavedVolume(); + } - private clearHideTimer(): void { - if (this.hideTimeout !== null) { + // header visibility management + readonly HEADER_HIDE_TIMEOUT = 5000; // Hide after 5 seconds + clearHideTimer(): void { + if (this.hideTimeout !== undefined) { window.clearTimeout(this.hideTimeout); - this.hideTimeout = null; + this.hideTimeout = undefined; } } - - private getSavedVolume(): number { - const savedVolume = localStorage.getItem(this.VOLUME_STORAGE_KEY); - return savedVolume ? parseFloat(savedVolume) : 1.0; + onMouseLeave(): void { + this.startHideTimer(); } - - private saveVolume(volume: number): void { - localStorage.setItem(this.VOLUME_STORAGE_KEY, volume.toString()); + onMouseMove(): void { + if (!this.headerVisible) { + this.headerVisible = true; + this.cdr.detectChanges(); + } + this.startHideTimer(); } - - private startHideTimer(): void { + startHideTimer(): void { this.clearHideTimer(); this.hideTimeout = window.setTimeout(() => { this.headerVisible = false; this.cdr.detectChanges(); - }, 5000); // Hide after 5 seconds + }, this.HEADER_HIDE_TIMEOUT); } + // volume management + readonly VOLUME_STORAGE_KEY = 'mediaBrowser_volume'; + get savedVolume(): number { + const savedVolume = localStorage.getItem(this.VOLUME_STORAGE_KEY); + return savedVolume ? parseFloat(savedVolume) : 1.0; + } + set savedVolume(volume: number) { + localStorage.setItem(this.VOLUME_STORAGE_KEY, volume.toString()); + } applySavedVolume(): void { - const savedVolume = this.getSavedVolume(); + const savedVolume = this.savedVolume; // Apply to video element if it exists if (this.videoElement?.nativeElement) { @@ -70,23 +128,12 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { this.audioElement.nativeElement.volume = savedVolume; } } - - async loadMediaById(id: string): Promise { - try { - console.log('Loading media by ID:', id); - console.log('Navigation state:', this.navigation); - const response = this.navigation?.extras.state?.['mediaData'] ?? await firstValueFrom(this.mediaService.get(id)); - this.mediaData = response; - this.cdr.detectChanges(); - } catch (error) { - console.error('Error loading media by ID:', error); - } - } - - goBack(): void { - this.location.back(); + onVolumeChange(event: Event): void { + const element = event.target as HTMLVideoElement | HTMLAudioElement; + this.savedVolume = element.volume; } + // navigation editMedia(): void { if (this.mediaId) { this.router.navigate(['/edit', this.mediaId], @@ -94,55 +141,101 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit { ); } } - - async ngOnInit(): Promise { - const mediaId = this.route.snapshot.paramMap.get('id'); - if (mediaId) { - this.mediaId = mediaId; - this.loadMediaById(mediaId); - } - - // Check if there's history available - this.hasHistory = window.history.length > 1; - - this.startHideTimer(); + goBack(): void { + this.location.back(); } - ngAfterViewInit(): void { - this.applySavedVolume(); + // chapter management + readonly RANGE_STEP: number = 1; // 1 second step for chapter range selection + get canAddChapter(): boolean { + const start = this.chapterStart; + const end = this.chapterEnd; + return end !== undefined && start !== undefined && end - start >= this.RANGE_STEP; } - - ngOnDestroy(): void { - this.clearHideTimer(); + get endPercent(): number { + const duration = this.mediaDuration; + const end = this.chapterEnd; + return end === undefined || duration === undefined || duration <= 0 ? 100 : Math.min(100, (end / duration) * 100); } - - onMouseLeave(): void { - this.startHideTimer(); + get mediaDuration(): number | undefined { + const ffprobeDuration = this.mediaData?.ffprobe?.format?.duration; + if (ffprobeDuration) { + return parseFloat(ffprobeDuration); + } + const mediaElement = this.videoElement?.nativeElement ?? this.audioElement?.nativeElement; + return mediaElement?.duration ?? this.mediaData?.duration; + } + get selectedRangeWidth(): number { + const duration = this.mediaDuration; + const start = this.chapterStart; + const end = this.chapterEnd; + return start === undefined || end === undefined || duration === undefined || duration <= 0 + ? 0 : Math.min(100, ((end - start) / duration) * 100); + } + get startPercent(): number { + const duration = this.mediaDuration; + const start = this.chapterStart; + return start === undefined || duration === undefined || duration <= 0 ? 0 : Math.min(100, (start / duration) * 100); + } + async goToAddChapter(): Promise { + if (this.mediaData && this.canAddChapter) { + const start = this.chapterStart!; + const end = this.chapterEnd!; + + this.router.navigate(['/edit', this.mediaData.parentId ?? this.mediaData.id, start, end, 'chapter'], { + state: { + mediaData: this.mediaData.parentId ? undefined : this.mediaData + } + }); + } } + onRangeInput(type: 'start' | 'end', event: Event): void { + const value = Number((event.target as HTMLInputElement).value); - onMouseMove(): void { - if (!this.headerVisible) { - this.headerVisible = true; - this.cdr.detectChanges(); + if (type === 'start') { + this.chapterStart = Math.min(value, this.chapterEnd ?? 0); + this.seekTo(this.chapterStart); + } else { + this.chapterEnd = Math.max(value, this.chapterStart ?? 0); + this.seekTo(this.chapterEnd); } - this.startHideTimer(); } - - onVolumeChange(event: Event): void { - const element = event.target as HTMLVideoElement | HTMLAudioElement; - this.saveVolume(element.volume); + seekTo(position: number): void { + const mediaElement = this.videoElement?.nativeElement ?? this.audioElement?.nativeElement; + if (mediaElement && !Number.isNaN(position)) { + mediaElement.currentTime = Math.max(position, 0); + } } - - getMediaSourceUrl(): string { - if (!this.mediaData) { - return ''; + setupChapterRange(): void { + if (!this.chapterPanelVisible) { + this.chapterEnd = undefined; + this.chapterStart = undefined; + return; } - if (this.mediaData.start == null || this.mediaData.duration == null) { - return this.mediaData.url; + const mediaElement = this.videoElement?.nativeElement ?? this.audioElement?.nativeElement; + mediaElement!.pause(); + + this.chapterEnd = this.mediaDuration!; + + if (typeof this.mediaData?.start === 'number') { + // set the start to end of the current chapter so the user can easily add consecutive chapters + this.chapterStart = this.mediaData.start! + this.mediaData.duration!; + } else { + // else set the start to the current position in the media so the user can easily create a chapter starting from the current position + this.chapterStart = mediaElement?.currentTime ?? 0; + } + } + toggleChapterPanel(): void { + const duration = this.mediaDuration; + if (this.mediaData && duration !== undefined && duration > 0) { + this.chapterPanelVisible = !this.chapterPanelVisible; + this.setupChapterRange(); } + } - const end = this.mediaData.start + this.mediaData.duration; - return `${this.mediaData.url}#t=${this.mediaData.start},${end}`; + // helper methods + formatTimestamp(seconds?: number): string { + return ReadonlyInfoSectionComponent.formatDuration(seconds ?? 0); } } \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/services/media.service.ts b/src/MediaBrowser.Frontend/src/app/services/media.service.ts index cf978f4..83d66ba 100644 --- a/src/MediaBrowser.Frontend/src/app/services/media.service.ts +++ b/src/MediaBrowser.Frontend/src/app/services/media.service.ts @@ -46,7 +46,7 @@ export interface MediaReadModel { thumbnailUrl?: string; fanartUrl?: string; start?: number; - parentId?: number; + parentId?: string; } export interface SearchResponse {