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:
@@ -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>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+1811
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");
|
||||
}
|
||||
}
|
||||
Generated
+1814
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");
|
||||
}
|
||||
}
|
||||
Generated
+1814
File diff suppressed because it is too large
Load Diff
+14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user