add preferred language (#109)

* add explicit warning for zero/invalid duration media items

* set dateadded on plex media versions

* add media stream table

* save local media streams to db

* save plex media streams to db

* add preferred language settings (no validation)

* use preferred language if possible

* code cleanup

* proper language code validation

* force scan of all libraries to pull in media streams
This commit is contained in:
Jason Dove
2021-03-28 21:54:48 +00:00
committed by GitHub
parent 51b671dec7
commit d303bc0158
52 changed files with 6265 additions and 181 deletions
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MediaStreamConfiguration : IEntityTypeConfiguration<MediaStream>
{
public void Configure(EntityTypeBuilder<MediaStream> builder) => builder.ToTable("MediaStream");
}
}
@@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.WithOne(f => f.MediaVersion)
.HasForeignKey(f => f.MediaVersionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(v => v.Streams)
.WithOne(s => s.MediaVersion)
.HasForeignKey(s => s.MediaVersionId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -1,11 +1,13 @@
using System;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
@@ -44,15 +46,66 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return await dbContext.SaveChangesAsync() > 0;
}
public async Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion)
public async Task<bool> UpdateLocalStatistics(
int mediaVersionId,
MediaVersion incoming,
bool updateVersion = true)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
dbContext.Entry(mediaVersion).State = EntityState.Modified;
return await dbContext.SaveChangesAsync() > 0;
Option<MediaVersion> maybeVersion = await dbContext.MediaVersions
.Include(v => v.Streams)
.OrderBy(v => v.Id)
.SingleOrDefaultAsync(v => v.Id == mediaVersionId)
.Map(Optional);
return await maybeVersion.Match(
async existing =>
{
if (updateVersion)
{
existing.DateUpdated = incoming.DateUpdated;
existing.Duration = incoming.Duration;
existing.SampleAspectRatio = incoming.SampleAspectRatio;
existing.DisplayAspectRatio = incoming.DisplayAspectRatio;
existing.Width = incoming.Width;
existing.Height = incoming.Height;
existing.VideoScanKind = incoming.VideoScanKind;
}
var toAdd = incoming.Streams.Filter(s => existing.Streams.All(es => es.Index != s.Index)).ToList();
var toRemove = existing.Streams.Filter(es => incoming.Streams.All(s => s.Index != es.Index))
.ToList();
var toUpdate = incoming.Streams.Except(toAdd).ToList();
// add
existing.Streams.AddRange(toAdd);
// remove
existing.Streams.RemoveAll(s => toRemove.Contains(s));
// update
foreach (MediaStream incomingStream in toUpdate)
{
MediaStream existingStream = existing.Streams.First(s => s.Index == incomingStream.Index);
existingStream.Codec = incomingStream.Codec;
existingStream.Profile = incomingStream.Profile;
existingStream.MediaStreamKind = incomingStream.MediaStreamKind;
existingStream.Language = incomingStream.Language;
existingStream.Channels = incomingStream.Channels;
existingStream.Title = incomingStream.Title;
existingStream.Default = incomingStream.Default;
existingStream.Forced = incomingStream.Forced;
}
return await dbContext.SaveChangesAsync() > 0;
},
() => Task.FromResult(false));
}
public Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion) =>
_dbConnection.ExecuteAsync(
public async Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming)
{
bool updatedVersion = await _dbConnection.ExecuteAsync(
@"UPDATE MediaVersion SET
SampleAspectRatio = @SampleAspectRatio,
VideoScanKind = @VideoScanKind,
@@ -60,12 +113,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE Id = @MediaVersionId",
new
{
mediaVersion.SampleAspectRatio,
mediaVersion.VideoScanKind,
mediaVersion.DateUpdated,
MediaVersionId = mediaVersion.Id
incoming.SampleAspectRatio,
incoming.VideoScanKind,
incoming.DateUpdated,
MediaVersionId = mediaVersionId
}).Map(result => result > 0);
return await UpdateLocalStatistics(mediaVersionId, incoming, false) || updatedVersion;
}
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
_dbConnection.ExecuteAsync(
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
@@ -64,6 +64,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(lp => lp.Library)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -91,6 +93,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(i => i.Key)
@@ -223,7 +227,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
},
Streams = new List<MediaStream>()
}
}
};
@@ -57,8 +57,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.AsNoTracking()
.SingleOrDefaultAsync()
.Map(Optional);
@@ -283,6 +283,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(em => em.Artwork)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@@ -414,6 +416,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@@ -582,7 +586,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
},
Streams = new List<MediaStream>()
}
}
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaStream : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"MediaStream",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Index = table.Column<int>("INTEGER", nullable: false),
Codec = table.Column<string>("TEXT", nullable: true),
Profile = table.Column<string>("TEXT", nullable: true),
MediaStreamKind = table.Column<int>("INTEGER", nullable: false),
Language = table.Column<string>("TEXT", nullable: true),
Channels = table.Column<int>("INTEGER", nullable: false),
Title = table.Column<string>("TEXT", nullable: true),
Default = table.Column<bool>("INTEGER", nullable: false),
Forced = table.Column<bool>("INTEGER", nullable: false),
MediaVersionId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MediaStream", x => x.Id);
table.ForeignKey(
"FK_MediaStream_MediaVersion_MediaVersionId",
x => x.MediaVersionId,
"MediaVersion",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_MediaStream_MediaVersionId",
"MediaStream",
"MediaVersionId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"MediaStream");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ChannelPreferredLanguageCode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<string>(
"PreferredLanguageCode",
"Channel",
"TEXT",
nullable: true);
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"PreferredLanguageCode",
"Channel");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_LibraryLastScan_MediaStream : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -83,6 +83,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<string>("PreferredLanguageCode")
.HasColumnType("TEXT");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
@@ -419,6 +422,51 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaSource");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaStream",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasColumnType("TEXT");
b.Property<bool>("Default")
.HasColumnType("INTEGER");
b.Property<bool>("Forced")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("MediaStreamKind")
.HasColumnType("INTEGER");
b.Property<int>("MediaVersionId")
.HasColumnType("INTEGER");
b.Property<string>("Profile")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaVersionId");
b.ToTable("MediaStream");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaVersion",
b =>
@@ -1299,6 +1347,19 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("LibraryPath");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaStream",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion")
.WithMany("Streams")
.HasForeignKey("MediaVersionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaVersion");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaVersion",
b =>
@@ -1822,7 +1883,14 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => { b.Navigation("Libraries"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => { b.Navigation("MediaFiles"); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaVersion",
b =>
{
b.Navigation("MediaFiles");
b.Navigation("Streams");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
@@ -3,7 +3,14 @@
public class PlexStreamResponse
{
public int Id { get; set; }
public int Index { get; set; }
public bool Default { get; set; }
public bool Forced { get; set; }
public string LanguageCode { get; set; }
public int StreamType { get; set; }
public string Codec { get; set; }
public string Profile { get; set; }
public int Channels { get; set; }
public bool Anamorphic { get; set; }
public string PixelAspectRatio { get; set; }
public string ScanType { get; set; }
@@ -227,10 +227,8 @@ namespace ErsatzTV.Infrastructure.Plex
Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width,
Height = media.Height,
AudioCodec = media.AudioCodec,
VideoCodec = media.VideoCodec,
VideoProfile = media.VideoProfile,
// specifically omit sample aspect ratio
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile>
{
@@ -240,7 +238,8 @@ namespace ErsatzTV.Infrastructure.Plex
Key = part.Key,
Path = part.File
}
}
},
Streams = new List<MediaStream>()
};
var movie = new PlexMovie
@@ -255,18 +254,72 @@ namespace ErsatzTV.Infrastructure.Plex
private Option<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response)
{
Option<PlexStreamResponse> maybeStream =
response.Media.Head().Part.Head().Stream.Find(s => s.StreamType == 1);
return maybeStream.Map(
stream => new MediaVersion
List<PlexStreamResponse> streams = response.Media.Head().Part.Head().Stream;
DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
Option<PlexStreamResponse> maybeVideoStream = streams.Find(s => s.StreamType == 1);
return maybeVideoStream.Map(
videoStream =>
{
SampleAspectRatio = stream.PixelAspectRatio,
VideoScanKind = stream.ScanType switch
var version = new MediaVersion
{
"interlaced" => VideoScanKind.Interlaced,
"progressive" => VideoScanKind.Progressive,
_ => VideoScanKind.Unknown
SampleAspectRatio = videoStream.PixelAspectRatio ?? "1:1",
VideoScanKind = videoStream.ScanType switch
{
"interlaced" => VideoScanKind.Interlaced,
"progressive" => VideoScanKind.Progressive,
_ => VideoScanKind.Unknown
},
Streams = new List<MediaStream>(),
DateUpdated = dateUpdated
};
version.Streams.Add(
new MediaStream
{
MediaStreamKind = MediaStreamKind.Video,
Index = videoStream.Index,
Codec = videoStream.Codec,
Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(),
Default = videoStream.Default,
Language = videoStream.LanguageCode,
Forced = videoStream.Forced
});
foreach (PlexStreamResponse audioStream in streams.Filter(s => s.StreamType == 2))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Audio,
Index = audioStream.Index,
Codec = audioStream.Codec,
Profile = (audioStream.Profile ?? string.Empty).ToLowerInvariant(),
Channels = audioStream.Channels,
Default = audioStream.Default,
Forced = audioStream.Forced,
Language = audioStream.LanguageCode
};
version.Streams.Add(stream);
}
foreach (PlexStreamResponse subtitleStream in streams.Filter(s => s.StreamType == 3))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Subtitle,
Index = subtitleStream.Index,
Codec = subtitleStream.Codec,
Default = subtitleStream.Default,
Forced = subtitleStream.Forced,
Language = subtitleStream.LanguageCode
};
version.Streams.Add(stream);
}
return version;
});
}
@@ -436,10 +489,7 @@ namespace ErsatzTV.Infrastructure.Plex
Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width,
Height = media.Height,
AudioCodec = media.AudioCodec,
VideoCodec = media.VideoCodec,
VideoProfile = media.VideoProfile,
// specifically omit sample aspect ratio
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile>
{
@@ -449,7 +499,9 @@ namespace ErsatzTV.Infrastructure.Plex
Key = part.Key,
Path = part.File
}
}
},
// specifically omit stream details
Streams = new List<MediaStream>()
};
var episode = new PlexEpisode