Database redesign (#31)

* starting database redesign

* set season and episode numbers

* use datetimes in db (utc); update movie metadata

* get movie cards from new table

* copy show/episode metadata

* remove old movie metadata type

* rename new movie metadata type

* code cleanup

* start to remove old television classes

* remove old television tables from database

* fix playout building

* fix collection views

* fix show/season views

* clean up movie metadata table

* fix scanner tests

* add libraries ui

* code cleanup

* fix movie scanning/metadata

* add library scan button to ui

* delete library path from ui

* temp disable movie scanning

* remove orphan media items and prevent duplicate paths

* attach artwork to metadata

* fix split show/season display

* fix television artwork

* store year distinct from release date

* fix collections ui

* code cleanup

* add library paths from ui

* fix adding to collections from ui

* fix schedule items loading

* schedule editing works again

* remove some todos

* more cleanup

* fix unit tests

* fix episode sorting

* fix deleting show library paths

* remove unused class

* fix playout list in ui

* fix log viewer

* start to use version/file instead of statistics

* clean up old columns

* fix playout display (time zone)

* fix playback

* fix channel guide time zone

* cascade more deletes

* fix compiler warnings

* fix adding new seasons

* use artwork for channel logo

* clean cache folder on startup (move channel logos, delete everything else)

* log database migration

* update homepage docs for libraries

* fix adding new channel with logo

* fix episode numbers in epg
This commit is contained in:
Jason Dove
2021-02-28 17:48:01 +00:00
committed by GitHub
parent e25b9edd01
commit f392bab118
392 changed files with 52108 additions and 4079 deletions
+2 -3
View File
@@ -6,17 +6,16 @@ namespace ErsatzTV.Core.Domain
public class Channel
{
public Channel(Guid uniqueId) => UniqueId = uniqueId;
public int Id { get; set; }
public Guid UniqueId { get; init; }
public int Number { get; set; }
public string Name { get; set; }
public string Logo { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
// public SourceMode Mode { get; set; }
}
}
@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Collection
{
public int Id { get; set; }
public string Name { get; set; }
public List<MediaItem> MediaItems { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public class CollectionItem
{
public int CollectionId { get; set; }
public Collection Collection { get; set; }
public int MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
}
}
@@ -1,6 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class MediaCollectionEnumeratorState
public class CollectionEnumeratorState
{
public int Seed { get; set; }
public int Index { get; set; }
+18
View File
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public abstract class Library
{
public int Id { get; set; }
public string Name { get; set; }
public LibraryMediaKind MediaKind { get; set; }
public DateTime? LastScan { get; set; }
public int MediaSourceId { get; set; }
public MediaSource MediaSource { get; set; }
public List<LibraryPath> Paths { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public enum LibraryMediaKind
{
Movies = 1,
Shows = 2
}
}
@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class LibraryPath
{
public int Id { get; set; }
public string Path { get; set; }
public int LibraryId { get; set; }
public Library Library { get; set; }
public List<MediaItem> MediaItems { get; set; }
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class LocalLibrary : Library
{
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class PlexLibrary : Library
{
public string Key { get; set; }
public bool ShouldSyncItems { get; set; }
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public class LocalMediaSource : MediaSource
{
public LocalMediaSource() => SourceType = MediaSourceType.Local;
public MediaType MediaType { get; set; }
public string Folder { get; set; }
}
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public class LocalTelevisionShowSource : TelevisionShowSource
{
public int MediaSourceId { get; set; }
public LocalMediaSource MediaSource { get; set; }
public string Path { get; set; }
}
}
-10
View File
@@ -1,10 +0,0 @@
using LanguageExt;
namespace ErsatzTV.Core.Domain
{
public abstract class MediaCollection : Record<MediaCollection>
{
public int Id { get; set; }
public string Name { get; set; }
}
}
-17
View File
@@ -1,17 +0,0 @@
using System;
using ErsatzTV.Core.Interfaces.Domain;
namespace ErsatzTV.Core.Domain
{
public class MediaItem : IHasAPoster
{
public int Id { get; set; }
public int MediaSourceId { get; set; }
public MediaSource Source { get; set; }
public MediaItemStatistics Statistics { get; set; }
public DateTime? LastWriteTime { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Episode : MediaItem
{
public int EpisodeNumber { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; }
public List<EpisodeMetadata> EpisodeMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class MediaFile
{
public int Id { get; set; }
public string Path { get; set; }
public int MediaVersionId { get; set; }
public MediaVersion MediaVersion { get; set; }
}
}
@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class MediaItem
{
public int Id { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
}
}
@@ -1,17 +1,24 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.Domain
{
public record MediaItemStatistics : IDisplaySize
public class MediaVersion : IDisplaySize
{
public DateTime? LastWriteTime { get; set; }
public int Id { get; set; }
public string Name { get; set; }
public List<MediaFile> MediaFiles { get; set; }
public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; }
public string VideoCodec { get; set; }
public string AudioCodec { get; set; }
public VideoScanType VideoScanType { get; set; }
public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
public int Width { get; set; }
public int Height { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Movie : MediaItem
{
public List<MovieMetadata> MovieMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMediaItemPart
{
public int Id { get; set; }
public int PlexId { get; set; }
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public int Size { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMovie : Movie
{
public string Key { get; set; }
public PlexMediaItemPart Part { get; set; }
}
}
+14
View File
@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Season : MediaItem
{
public int SeasonNumber { get; set; }
public int ShowId { get; set; }
public Show Show { get; set; }
public List<Episode> Episodes { get; set; }
public List<SeasonMetadata> SeasonMetadata { get; set; }
}
}
+10
View File
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Show : MediaItem
{
public List<Season> Seasons { get; set; }
public List<ShowMetadata> ShowMetadata { get; set; }
}
}
@@ -1,6 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public enum VideoScanType
public enum VideoScanKind
{
Unknown = 0,
Progressive = 1,
-12
View File
@@ -1,12 +0,0 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class MediaItemMetadata
{
public MetadataSource Source { get; set; }
public DateTime? LastWriteTime { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
}
}
-12
View File
@@ -1,12 +0,0 @@
using System;
namespace ErsatzTV.Core.Domain
{
public abstract class MediaSource
{
public int Id { get; set; }
public MediaSourceType SourceType { get; set; }
public string Name { get; set; }
public DateTimeOffset? LastScan { get; set; }
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Domain
{
public class LocalMediaSource : MediaSource
{
}
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public abstract class MediaSource
{
public int Id { get; set; }
public List<Library> Libraries { get; set; }
}
}
@@ -1,9 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMediaSourceConnection
public class PlexConnection
{
public int Id { get; set; }
public bool IsActive { get; set; }
public string Uri { get; set; }
public int PlexMediaSourceId { get; set; }
public PlexMediaSource PlexMediaSource { get; set; }
}
}
@@ -4,13 +4,11 @@ namespace ErsatzTV.Core.Domain
{
public class PlexMediaSource : MediaSource
{
public PlexMediaSource() => SourceType = MediaSourceType.Plex;
public string ServerName { get; set; }
public string ProductVersion { get; set; }
public string ClientIdentifier { get; set; }
// public bool IsOwned { get; set; }
public List<PlexMediaSourceConnection> Connections { get; set; }
public List<PlexMediaSourceLibrary> Libraries { get; set; }
public List<PlexConnection> Connections { get; set; }
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public enum MediaSourceType
{
None = 0,
Local = 1,
Plex = 2,
Jellyfin = 3
}
}
-9
View File
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public enum MediaType
{
Other = 0,
TvShow = 1,
Movie = 2
}
}
+13
View File
@@ -0,0 +1,13 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class Artwork
{
public int Id { get; set; }
public string Path { get; set; }
public ArtworkKind ArtworkKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public enum ArtworkKind
{
Poster = 0,
Thumbnail = 1,
Logo = 2
}
}
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class EpisodeMetadata : Metadata
{
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
public int EpisodeId { get; set; }
public Episode Episode { get; set; }
}
}
+19
View File
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Metadata
{
public int Id { get; set; }
public MetadataKind MetadataKind { get; set; }
public string Title { get; set; }
public string OriginalTitle { get; set; }
public string SortTitle { get; set; }
public int? Year { get; set; }
public DateTime? ReleaseDate { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
public List<Artwork> Artwork { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public enum MetadataKind
{
Fallback = 0,
Sidecar = 1,
External
}
}
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class MovieMetadata : Metadata
{
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
public int MovieId { get; set; }
public Movie Movie { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public class SeasonMetadata : Metadata
{
public string Outline { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; }
}
}
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class ShowMetadata : Metadata
{
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
public int ShowId { get; set; }
public Show Show { get; set; }
}
}
-11
View File
@@ -1,11 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class MovieMediaItem : MediaItem
{
public int MetadataId { get; set; }
public MovieMetadata Metadata { get; set; }
public List<SimpleMediaCollection> SimpleMediaCollections { get; set; }
}
}
-17
View File
@@ -1,17 +0,0 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class MovieMetadata : MediaItemMetadata
{
public int Id { get; set; }
public int MovieId { get; set; }
public MovieMediaItem Movie { get; set; }
public int? Year { get; set; }
public DateTime? Premiered { get; set; }
public string Plot { get; set; }
public string Outline { get; set; }
public string Tagline { get; set; }
public string ContentRating { get; set; }
}
}
+3 -1
View File
@@ -8,6 +8,8 @@ namespace ErsatzTV.Core.Domain
public ProgramScheduleItem NextScheduleItem { get; set; }
public DateTimeOffset NextStart { get; set; }
public DateTime NextStart { get; set; }
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
}
}
+5 -2
View File
@@ -7,9 +7,12 @@ namespace ErsatzTV.Core.Domain
public int Id { get; set; }
public int MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public DateTimeOffset Start { get; set; }
public DateTimeOffset Finish { get; set; }
public DateTime Start { get; set; }
public DateTime Finish { get; set; }
public int PlayoutId { get; set; }
public Playout Playout { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
}
}
@@ -8,7 +8,10 @@
public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int CollectionId { get; set; }
public MediaCollectionEnumeratorState EnumeratorState { get; set; }
public int? CollectionId { get; set; }
public Collection Collection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public CollectionEnumeratorState EnumeratorState { get; set; }
}
}
@@ -1,10 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMediaSourceLibrary
{
public int Id { get; set; }
public string Key { get; init; }
public string Name { get; init; }
public MediaType MediaType { get; init; }
}
}
+4 -6
View File
@@ -9,13 +9,11 @@ namespace ErsatzTV.Core.Domain
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
public TimeSpan? StartTime { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? MediaCollectionId { get; set; }
public MediaCollection MediaCollection { get; set; }
public int? TelevisionShowId { get; set; }
public TelevisionShow TelevisionShow { get; set; }
public int? TelevisionSeasonId { get; set; }
public TelevisionSeason TelevisionSeason { get; set; }
public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
public int? CollectionId { get; set; }
public Collection Collection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
}
}
@@ -1,12 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class SimpleMediaCollection : MediaCollection
{
public List<MovieMediaItem> Movies { get; set; }
public List<TelevisionShow> TelevisionShows { get; set; }
public List<TelevisionSeason> TelevisionSeasons { get; set; }
public List<TelevisionEpisodeMediaItem> TelevisionEpisodes { get; set; }
}
}
@@ -1,12 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class TelevisionEpisodeMediaItem : MediaItem
{
public int SeasonId { get; set; }
public TelevisionSeason Season { get; set; }
public TelevisionEpisodeMetadata Metadata { get; set; }
public List<SimpleMediaCollection> SimpleMediaCollections { get; set; }
}
}
@@ -1,15 +0,0 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class TelevisionEpisodeMetadata : MediaItemMetadata
{
public int Id { get; set; }
public int TelevisionEpisodeId { get; set; }
public TelevisionEpisodeMediaItem TelevisionEpisode { get; set; }
public int Season { get; set; }
public int Episode { get; set; }
public string Plot { get; set; }
public DateTime? Aired { get; set; }
}
}
-19
View File
@@ -1,19 +0,0 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Interfaces.Domain;
namespace ErsatzTV.Core.Domain
{
public class TelevisionSeason : IHasAPoster
{
public int Id { get; set; }
public int TelevisionShowId { get; set; }
public TelevisionShow TelevisionShow { get; set; }
public int Number { get; set; }
public List<TelevisionEpisodeMediaItem> Episodes { get; set; }
public List<SimpleMediaCollection> SimpleMediaCollections { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
}
}
-16
View File
@@ -1,16 +0,0 @@
using System;
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class TelevisionShow
{
public int Id { get; set; }
public List<TelevisionShowSource> Sources { get; set; }
public TelevisionShowMetadata Metadata { get; set; }
public List<TelevisionSeason> Seasons { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
public List<SimpleMediaCollection> SimpleMediaCollections { get; set; }
}
}
@@ -1,17 +0,0 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class TelevisionShowMetadata
{
public int Id { get; set; }
public int TelevisionShowId { get; set; }
public TelevisionShow TelevisionShow { get; set; }
public MetadataSource Source { get; set; }
public DateTime? LastWriteTime { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
public int? Year { get; set; }
public string Plot { get; set; }
}
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public class TelevisionShowSource
{
public int Id { get; set; }
public int TelevisionShowId { get; set; }
public TelevisionShow TelevisionShow { get; set; }
}
}
@@ -0,0 +1,7 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Ccollection/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Clibrary/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Cmediaitem/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Cmediasource/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Cmediasources/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=domain_005Cmetadata/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
@@ -44,7 +44,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegPlaybackSettings CalculateSettings(
StreamingMode streamingMode,
FFmpegProfile ffmpegProfile,
PlayoutItem playoutItem,
MediaVersion version,
DateTimeOffset start,
DateTimeOffset now)
{
var result = new FFmpegPlaybackSettings
@@ -53,9 +54,9 @@ namespace ErsatzTV.Core.FFmpeg
FormatFlags = CommonFormatFlags
};
if (now != playoutItem.Start)
if (now != start)
{
result.StreamSeek = now - playoutItem.Start;
result.StreamSeek = now - start;
}
switch (streamingMode)
@@ -66,24 +67,23 @@ namespace ErsatzTV.Core.FFmpeg
result.Deinterlace = false;
break;
case StreamingMode.TransportStream:
if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Statistics))
if (NeedToScale(ffmpegProfile, version))
{
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Statistics);
if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Statistics))
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, version);
if (!scaledSize.IsSameSizeAs(version))
{
result.ScaledSize = Some(
CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Statistics));
result.ScaledSize = Some(CalculateScaledSize(ffmpegProfile, version));
}
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Statistics);
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version);
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{
result.PadToDesiredResolution = true;
}
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Statistics))
NeedToNormalizeVideoCodec(ffmpegProfile, version))
{
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
@@ -94,7 +94,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy";
}
if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Statistics))
if (NeedToNormalizeAudioCodec(ffmpegProfile, version))
{
result.AudioCodec = ffmpegProfile.AudioCodec;
result.AudioBitrate = ffmpegProfile.AudioBitrate;
@@ -104,7 +104,7 @@ namespace ErsatzTV.Core.FFmpeg
{
result.AudioChannels = ffmpegProfile.AudioChannels;
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = playoutItem.MediaItem.Statistics.Duration;
result.AudioDuration = version.Duration;
}
}
else
@@ -112,7 +112,7 @@ namespace ErsatzTV.Core.FFmpeg
result.AudioCodec = "copy";
}
if (playoutItem.MediaItem.Statistics.VideoScanType == VideoScanType.Interlaced)
if (version.VideoScanKind == VideoScanKind.Interlaced)
{
result.Deinterlace = true;
}
@@ -132,35 +132,35 @@ namespace ErsatzTV.Core.FFmpeg
AudioCodec = ffmpegProfile.AudioCodec
};
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
ffmpegProfile.NormalizeResolution &&
IsIncorrectSize(ffmpegProfile.Resolution, statistics) ||
IsTooLarge(ffmpegProfile.Resolution, statistics) ||
IsOddSize(statistics);
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
IsTooLarge(ffmpegProfile.Resolution, version) ||
IsOddSize(version);
private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaItemStatistics statistics) =>
IsAnamorphic(statistics) ||
statistics.Width != desiredResolution.Width ||
statistics.Height != desiredResolution.Height;
private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaVersion version) =>
IsAnamorphic(version) ||
version.Width != desiredResolution.Width ||
version.Height != desiredResolution.Height;
private static bool IsTooLarge(IDisplaySize desiredResolution, IDisplaySize mediaSize) =>
mediaSize.Height > desiredResolution.Height ||
mediaSize.Width > desiredResolution.Width;
private static bool IsTooLarge(IDisplaySize desiredResolution, IDisplaySize displaySize) =>
displaySize.Height > desiredResolution.Height ||
displaySize.Width > desiredResolution.Width;
private static bool IsOddSize(IDisplaySize displaySize) =>
displaySize.Height % 2 == 1 || displaySize.Width % 2 == 1;
private static bool IsOddSize(MediaVersion version) =>
version.Height % 2 == 1 || version.Width % 2 == 1;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != statistics.VideoCodec;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaVersion version) =>
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != version.VideoCodec;
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != statistics.AudioCodec;
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaVersion version) =>
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != version.AudioCodec;
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics)
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
{
IDisplaySize sarSize = SARSize(statistics);
int p = statistics.Width * sarSize.Width;
int q = statistics.Height * sarSize.Height;
IDisplaySize sarSize = SARSize(version);
int p = version.Width * sarSize.Width;
int q = version.Height * sarSize.Height;
int g = Gcd(q, p);
p = p / g;
q = q / g;
@@ -194,29 +194,29 @@ namespace ErsatzTV.Core.FFmpeg
return a | b;
}
private static bool IsAnamorphic(MediaItemStatistics statistics)
private static bool IsAnamorphic(MediaVersion version)
{
if (statistics.SampleAspectRatio == "1:1")
if (version.SampleAspectRatio == "1:1")
{
return false;
}
if (statistics.SampleAspectRatio != "0:1")
if (version.SampleAspectRatio != "0:1")
{
return true;
}
if (statistics.DisplayAspectRatio == "0:1")
if (version.DisplayAspectRatio == "0:1")
{
return false;
}
return statistics.DisplayAspectRatio != $"{statistics.Width}:{statistics.Height}";
return version.DisplayAspectRatio != $"{version.Width}:{version.Height}";
}
private static IDisplaySize SARSize(MediaItemStatistics statistics)
private static IDisplaySize SARSize(MediaVersion version)
{
string[] split = statistics.SampleAspectRatio.Split(":");
string[] split = version.SampleAspectRatio.Split(":");
return new DisplaySize(int.Parse(split[0]), int.Parse(split[1]));
}
}
+13 -3
View File
@@ -18,19 +18,29 @@ namespace ErsatzTV.Core.FFmpeg
PlayoutItem item,
DateTimeOffset now)
{
MediaVersion version = item.MediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
item,
version,
item.StartOffset,
now);
MediaFile file = version.MediaFiles.Head();
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
.WithThreads(playbackSettings.ThreadCount)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInput(item.MediaItem.Path);
.WithInput(file.Path);
playbackSettings.ScaledSize.Match(
scaledSize =>
@@ -76,7 +86,7 @@ namespace ErsatzTV.Core.FFmpeg
return builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel)
.WithFormat("mpegts")
.WithDuration(item.Start + item.MediaItem.Statistics.Duration - now)
.WithDuration(item.Start + version.Duration - now)
.WithPipe()
.Build();
}
+7 -1
View File
@@ -15,8 +15,14 @@ namespace ErsatzTV.Core
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
public static readonly string ImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");
public static readonly string PosterCacheFolder = Path.Combine(ArtworkCacheFolder, "posters");
public static readonly string ThumbnailCacheFolder = Path.Combine(ArtworkCacheFolder, "thumbnails");
public static readonly string LogoCacheFolder = Path.Combine(ArtworkCacheFolder, "logos");
}
}
@@ -1,11 +0,0 @@
using System;
namespace ErsatzTV.Core.Interfaces.Domain
{
public interface IHasAPoster
{
string Path { get; set; }
string Poster { get; set; }
DateTime? PosterLastWriteTime { get; set; }
}
}
@@ -1,11 +1,13 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Images
{
public interface IImageCache
{
Task<Either<BaseError, string>> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width);
Task<Either<BaseError, string>> SaveImage(byte[] imageBuffer);
Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height);
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
string CopyArtworkToCache(string path, ArtworkKind artworkKind);
}
}
@@ -4,9 +4,9 @@ namespace ErsatzTV.Core.Interfaces.Locking
{
public interface IEntityLocker
{
event EventHandler OnMediaSourceChanged;
bool LockMediaSource(int mediaSourceId);
bool UnlockMediaSource(int mediaSourceId);
bool IsMediaSourceLocked(int mediaSourceId);
event EventHandler OnLibraryChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId);
}
}
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IFallbackMetadataProvider
{
TelevisionShowMetadata GetFallbackMetadataForShow(string showFolder);
ShowMetadata GetFallbackMetadataForShow(string showFolder);
}
}
@@ -2,16 +2,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalFileSystem
{
DateTime GetLastWriteTime(string path);
bool IsMediaSourceAccessible(LocalMediaSource localMediaSource);
bool IsLibraryPathAccessible(LibraryPath libraryPath);
IEnumerable<string> ListSubdirectories(string folder);
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
Task<byte[]> ReadAllBytes(string path);
Unit CopyFile(string source, string destination);
}
}
@@ -6,10 +6,10 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMetadataProvider
{
Task<TelevisionShowMetadata> GetMetadataForShow(string showFolder);
Task<ShowMetadata> GetMetadataForShow(string showFolder);
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task<Unit> RefreshSidecarMetadata(TelevisionShow televisionShow, string showFolder);
Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder);
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem);
Task<Unit> RefreshFallbackMetadata(TelevisionShow televisionShow, string showFolder);
Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder);
}
}
@@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IMovieFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath);
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath);
}
}
@@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ITelevisionFolderScanner
{
Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath);
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath);
}
}
@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Plex
{
public interface IPlexMovieLibraryScanner
{
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary);
}
}
@@ -8,8 +8,13 @@ namespace ErsatzTV.Core.Interfaces.Plex
{
public interface IPlexServerApiClient
{
Task<Either<BaseError, List<PlexMediaSourceLibrary>>> GetLibraries(
PlexMediaSourceConnection connection,
Task<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, List<PlexMovie>>> GetLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
}
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ILibraryRepository
{
Task<LibraryPath> Add(LibraryPath libraryPath);
Task<Option<Library>> Get(int libraryId);
Task<Option<LocalLibrary>> GetLocal(int libraryId);
Task<List<LocalLibrary>> GetAllLocal();
Task<Unit> UpdateLastScan(Library library);
Task<List<LibraryPath>> GetLocalPaths(int libraryId);
Task<Option<LibraryPath>> GetPath(int libraryPathId);
Task<int> CountMediaItemsByPath(int libraryPathId);
Task DeleteLocalPath(int libraryPathId);
}
}
@@ -7,16 +7,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMediaCollectionRepository
{
Task<SimpleMediaCollection> Add(SimpleMediaCollection collection);
Task<Option<MediaCollection>> Get(int id);
Task<Option<SimpleMediaCollection>> GetSimpleMediaCollection(int id);
Task<Option<SimpleMediaCollection>> GetSimpleMediaCollectionWithItems(int id);
Task<Option<SimpleMediaCollection>> GetSimpleMediaCollectionWithItemsUntracked(int id);
Task<List<SimpleMediaCollection>> GetSimpleMediaCollections();
Task<List<MediaCollection>> GetAll();
Task<Collection> Add(Collection collection);
Task<Unit> AddMediaItem(int collectionId, int mediaItemId);
Task<Option<Collection>> Get(int id);
Task<Option<Collection>> GetCollectionWithItems(int id);
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);
Task<List<Collection>> GetAll();
Task<Option<List<MediaItem>>> GetItems(int id);
Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id);
Task Update(SimpleMediaCollection collection);
Task Delete(int mediaCollectionId);
Task Update(Collection collection);
Task Delete(int collectionId);
}
}
@@ -11,11 +11,16 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<PlexMediaSource> Add(PlexMediaSource plexMediaSource);
Task<List<MediaSource>> GetAll();
Task<List<PlexMediaSource>> GetAllPlex();
Task<List<PlexLibrary>> GetPlexLibraries(int plexMediaSourceId);
Task<Option<PlexLibrary>> GetPlexLibrary(int plexLibraryId);
Task<Option<MediaSource>> Get(int id);
Task<Option<PlexMediaSource>> GetPlex(int id);
Task<int> CountMediaItems(int id);
Task Update(LocalMediaSource localMediaSource);
Task Update(PlexMediaSource plexMediaSource);
Task Update(PlexLibrary plexMediaSourceLibrary);
Task Delete(int id);
Task DisablePlexLibrarySync(IEnumerable<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
}
}
@@ -7,10 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMovieRepository
{
Task<Option<MovieMediaItem>> GetMovie(int movieId);
Task<Either<BaseError, MovieMediaItem>> GetOrAdd(int mediaSourceId, string path);
Task<bool> Update(MovieMediaItem movie);
Task<Option<Movie>> GetMovie(int movieId);
Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path);
Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item);
Task<bool> Update(Movie movie);
Task<int> GetMovieCount();
Task<List<MovieMediaItem>> GetPagedMovies(int pageNumber, int pageSize);
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
}
}
@@ -13,7 +13,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Playout>> GetFull(int id);
Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now);
Task<List<PlayoutItem>> GetPlayoutItems(int playoutId);
Task<List<int>> GetPlayoutIdsForMediaItems(Seq<MediaItem> mediaItems);
Task<List<Playout>> GetAll();
Task Update(Playout playout);
Task Delete(int playoutId);
@@ -7,41 +7,26 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ITelevisionRepository
{
Task<bool> Update(TelevisionShow show);
Task<bool> Update(TelevisionSeason season);
Task<bool> Update(TelevisionEpisodeMediaItem episode);
Task<List<TelevisionShow>> GetAllShows();
Task<Option<TelevisionShow>> GetShow(int televisionShowId);
Task<bool> Update(Show show);
Task<bool> Update(Season season);
Task<bool> Update(Episode episode);
Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId);
Task<int> GetShowCount();
Task<List<TelevisionShow>> GetPagedShows(int pageNumber, int pageSize);
Task<List<TelevisionEpisodeMediaItem>> GetShowItems(int televisionShowId);
Task<List<TelevisionSeason>> GetAllSeasons();
Task<Option<TelevisionSeason>> GetSeason(int televisionSeasonId);
Task<int> GetSeasonCount(int televisionShowId);
Task<List<TelevisionSeason>> GetPagedSeasons(int televisionShowId, int pageNumber, int pageSize);
Task<List<TelevisionEpisodeMediaItem>> GetSeasonItems(int televisionSeasonId);
Task<Option<TelevisionEpisodeMediaItem>> GetEpisode(int televisionEpisodeId);
Task<int> GetEpisodeCount(int televisionSeasonId);
Task<List<TelevisionEpisodeMediaItem>> GetPagedEpisodes(int televisionSeasonId, int pageNumber, int pageSize);
Task<Option<TelevisionShow>> GetShowByPath(int mediaSourceId, string path);
Task<Option<TelevisionShow>> GetShowByMetadata(TelevisionShowMetadata metadata);
Task<Either<BaseError, TelevisionShow>> AddShow(
int localMediaSourceId,
string showFolder,
TelevisionShowMetadata metadata);
Task<Either<BaseError, TelevisionSeason>> GetOrAddSeason(
TelevisionShow show,
string path,
int seasonNumber);
Task<Either<BaseError, TelevisionEpisodeMediaItem>> GetOrAddEpisode(
TelevisionSeason season,
int mediaSourceId,
string path);
Task<Unit> DeleteMissingSources(int localMediaSourceId, List<string> allFolders);
Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize);
Task<List<Episode>> GetShowItems(int showId);
Task<List<Season>> GetAllSeasons();
Task<Option<Season>> GetSeason(int seasonId);
Task<int> GetSeasonCount(int showId);
Task<List<Season>> GetPagedSeasons(int televisionShowId, int pageNumber, int pageSize);
Task<List<Episode>> GetSeasonItems(int seasonId);
Task<Option<Episode>> GetEpisode(int episodeId);
Task<int> GetEpisodeCount(int seasonId);
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata);
Task<Either<BaseError, Show>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata);
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptyShows();
}
}
@@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Scheduling
{
public interface IMediaCollectionEnumerator
{
MediaCollectionEnumeratorState State { get; }
CollectionEnumeratorState State { get; }
Option<MediaItem> Current { get; }
void MoveNext();
}
+18 -15
View File
@@ -41,11 +41,13 @@ namespace ErsatzTV.Core.Iptv
xml.WriteEndElement(); // display-name
xml.WriteStartElement("icon");
xml.WriteAttributeString(
"src",
!string.IsNullOrWhiteSpace(channel.Logo)
? $"{_scheme}://{_host}/iptv/images/{channel.Logo}"
: $"{_scheme}://{_host}/images/ersatztv-500.png");
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
() => $"{_scheme}://{_host}/images/ersatztv-500.png");
xml.WriteAttributeString("src", logo);
xml.WriteEndElement(); // icon
xml.WriteEndElement(); // channel
@@ -55,26 +57,27 @@ namespace ErsatzTV.Core.Iptv
{
foreach (PlayoutItem playoutItem in channel.Playouts.Collect(p => p.Items).OrderBy(i => i.Start))
{
string start = playoutItem.Start.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = playoutItem.Finish.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string start = playoutItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = playoutItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string title = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.Title ?? m.Path,
TelevisionEpisodeMediaItem e => e.Metadata?.Title ?? e.Path,
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Title).IfNone("[unknown episode]"),
_ => "[unknown]"
};
string description = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.Plot,
TelevisionEpisodeMediaItem e => e.Metadata?.Plot,
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot).IfNone(string.Empty),
_ => string.Empty
};
string contentRating = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.ContentRating,
// TODO: re-implement content rating
// Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.ContentRating).IfNone(string.Empty),
_ => string.Empty
};
@@ -95,10 +98,10 @@ namespace ErsatzTV.Core.Iptv
xml.WriteAttributeString("lang", "en");
xml.WriteEndElement(); // sub-title
if (playoutItem.MediaItem is TelevisionEpisodeMediaItem episode)
if (playoutItem.MediaItem is Episode episode)
{
int s = Optional(episode.Metadata?.Season).IfNone(0);
int e = Optional(episode.Metadata?.Episode).IfNone(0);
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
int e = episode.EpisodeNumber;
if (s > 0 && e > 0)
{
xml.WriteStartElement("episode-num");
+8 -4
View File
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Text;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Iptv
{
@@ -26,9 +27,12 @@ namespace ErsatzTV.Core.Iptv
sb.AppendLine($"#EXTM3U url-tvg=\"{xmltv}\" x-tvg-url=\"{xmltv}\"");
foreach (Channel channel in _channels)
{
string logo = !string.IsNullOrWhiteSpace(channel.Logo)
? $"{_scheme}://{_host}/iptv/images/{channel.Logo}"
: $"{_scheme}://{_host}/images/ersatztv-500.png";
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
() => $"{_scheme}://{_host}/images/ersatztv-500.png");
string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray())
.TrimEnd('=')
@@ -42,7 +46,7 @@ namespace ErsatzTV.Core.Iptv
};
sb.AppendLine(
$"#EXTINF:0 tvg-id=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\", {channel.Name}");
$"#EXTINF:0 tvg-id=\"{channel.Number}\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\", {channel.Name}");
sb.AppendLine($"{_scheme}://{_host}/iptv/channel/{channel.Number}.{format}");
}
@@ -3,57 +3,39 @@ using System.IO;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class FallbackMetadataProvider : IFallbackMetadataProvider
{
public TelevisionShowMetadata GetFallbackMetadataForShow(string showFolder)
public ShowMetadata GetFallbackMetadataForShow(string showFolder)
{
string fileName = Path.GetFileName(showFolder);
var metadata = new TelevisionShowMetadata
{ Source = MetadataSource.Fallback, Title = fileName ?? showFolder };
var metadata = new ShowMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? showFolder };
return GetTelevisionShowMetadata(fileName, metadata);
}
public static TelevisionEpisodeMetadata GetFallbackMetadata(TelevisionEpisodeMediaItem mediaItem)
public static Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
{
string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new TelevisionEpisodeMetadata
{ Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
if (fileName != null)
{
if (!(mediaItem.Source is LocalMediaSource))
{
return metadata;
}
return GetEpisodeMetadata(fileName, metadata);
}
return metadata;
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
var metadata = new EpisodeMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
}
public static MovieMetadata GetFallbackMetadata(MovieMediaItem mediaItem)
public static MovieMetadata GetFallbackMetadata(Movie movie)
{
string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new MovieMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
var metadata = new MovieMetadata { MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
if (fileName != null)
{
if (!(mediaItem.Source is LocalMediaSource))
{
return metadata;
}
return GetMovieMetadata(fileName, metadata);
}
return metadata;
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
private static TelevisionEpisodeMetadata GetEpisodeMetadata(string fileName, TelevisionEpisodeMetadata metadata)
private static Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
{
try
{
@@ -62,8 +44,7 @@ namespace ErsatzTV.Core.Metadata
if (match.Success)
{
metadata.Title = match.Groups[1].Value;
metadata.Season = int.Parse(match.Groups[2].Value);
metadata.Episode = int.Parse(match.Groups[3].Value);
return Tuple(metadata, int.Parse(match.Groups[3].Value));
}
}
catch (Exception)
@@ -71,7 +52,7 @@ namespace ErsatzTV.Core.Metadata
// ignored
}
return metadata;
return Tuple(metadata, 0);
}
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
@@ -84,6 +65,7 @@ namespace ErsatzTV.Core.Metadata
{
metadata.Title = match.Groups[1].Value;
metadata.Year = int.Parse(match.Groups[2].Value);
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
}
}
catch (Exception)
@@ -94,9 +76,9 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private static TelevisionShowMetadata GetTelevisionShowMetadata(
private static ShowMetadata GetTelevisionShowMetadata(
string fileName,
TelevisionShowMetadata metadata)
ShowMetadata metadata)
{
try
{
@@ -106,6 +88,7 @@ namespace ErsatzTV.Core.Metadata
{
metadata.Title = match.Groups[1].Value;
metadata.Year = int.Parse(match.Groups[2].Value);
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
}
}
catch (Exception)
+16 -2
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
@@ -13,8 +14,8 @@ namespace ErsatzTV.Core.Metadata
public DateTime GetLastWriteTime(string path) =>
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) =>
Directory.Exists(localMediaSource.Folder);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
Directory.Exists(libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) =>
Try(Directory.EnumerateDirectories(folder)).IfFail(new List<string>());
@@ -24,5 +25,18 @@ namespace ErsatzTV.Core.Metadata
public bool FileExists(string path) => File.Exists(path);
public Task<byte[]> ReadAllBytes(string path) => File.ReadAllBytesAsync(path);
public Unit CopyFile(string source, string destination)
{
string directory = Path.GetDirectoryName(destination) ?? string.Empty;
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.Copy(source, destination, true);
return Unit.Default;
}
}
}
+57 -28
View File
@@ -2,18 +2,21 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public abstract class LocalFolderScanner
{
private static readonly SHA1CryptoServiceProvider Crypto;
public static readonly List<string> VideoFileExtensions = new()
{
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
@@ -46,6 +49,8 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger _logger;
static LocalFolderScanner() => Crypto = new SHA1CryptoServiceProvider();
protected LocalFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
@@ -63,11 +68,18 @@ namespace ErsatzTV.Core.Metadata
{
try
{
if (mediaItem.Statistics is null ||
(mediaItem.Statistics.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(mediaItem.Path))
MediaVersion version = mediaItem switch
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", mediaItem.Path);
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
string path = version.MediaFiles.Head().Path;
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
}
@@ -79,30 +91,47 @@ namespace ErsatzTV.Core.Metadata
}
}
protected async Task SavePosterToDisk<T>(
T show,
string posterPath,
Func<T, Task<bool>> update,
int height = 220) where T : IHasAPoster
protected bool RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
{
byte[] originalBytes = await _localFileSystem.ReadAllBytes(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, height, null);
await maybeHash.Match(
hash =>
{
show.Poster = hash;
show.PosterLastWriteTime = _localFileSystem.GetLastWriteTime(posterPath);
return update(show);
},
error =>
{
_logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value);
return Task.CompletedTask;
});
}
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
protected Task<Either<BaseError, string>> SavePosterToDisk(string posterPath, int height = 220) =>
_localFileSystem.ReadAllBytes(posterPath)
.Bind(bytes => _imageCache.ResizeAndSaveImage(bytes, height, null));
metadata.Artwork ??= new List<Artwork>();
Option<Artwork> maybePoster =
Optional(metadata.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == artworkKind);
bool shouldRefresh = maybePoster.Match(
artwork => artwork.DateUpdated < lastWriteTime,
true);
if (shouldRefresh)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
string cacheName = _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
maybePoster.Match(
artwork =>
{
artwork.Path = cacheName;
artwork.DateUpdated = lastWriteTime;
},
() =>
{
var artwork = new Artwork
{
Path = cacheName,
DateAdded = DateTime.UtcNow,
DateUpdated = lastWriteTime,
ArtworkKind = artworkKind
};
metadata.Artwork.Add(artwork);
});
return true;
}
return false;
}
}
}
+128 -94
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System.Xml.Serialization;
@@ -37,94 +38,132 @@ namespace ErsatzTV.Core.Metadata
_logger = logger;
}
public Task<TelevisionShowMetadata> GetMetadataForShow(string showFolder)
public async Task<ShowMetadata> GetMetadataForShow(string showFolder)
{
string nfoFileName = Path.Combine(showFolder, "tvshow.nfo");
return Optional(_localFileSystem.FileExists(nfoFileName))
.Filter(identity).AsTask()
.Bind(_ => LoadTelevisionShowMetadata(nfoFileName))
.IfNoneAsync(() => _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder).AsTask())
.Map(
m =>
{
m.SortTitle = GetSortTitle(m.Title);
return m;
});
Option<ShowMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName))
{
maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName);
}
return maybeMetadata.IfNone(
() =>
{
ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder);
metadata.SortTitle = GetSortTitle(metadata.Title);
return metadata;
});
}
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
mediaItem switch
{
TelevisionEpisodeMediaItem e => LoadMetadata(e, path)
Episode e => LoadMetadata(e, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))),
MovieMediaItem m => LoadMetadata(m, path)
Movie m => LoadMetadata(m, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
_ => Task.FromResult(Unit.Default)
};
public Task<Unit> RefreshSidecarMetadata(TelevisionShow televisionShow, string showFolder) =>
public Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder) =>
LoadMetadata(televisionShow, showFolder).Bind(
maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata)));
public Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem) =>
mediaItem switch
{
TelevisionEpisodeMediaItem e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e))
Episode e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e))
.ToUnit(),
MovieMediaItem m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
Movie m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
_ => Task.FromResult(Unit.Default)
};
public Task<Unit> RefreshFallbackMetadata(TelevisionShow televisionShow, string showFolder) =>
public Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder))
.ToUnit();
private async Task ApplyMetadataUpdate(TelevisionEpisodeMediaItem mediaItem, TelevisionEpisodeMetadata metadata)
private async Task ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
{
mediaItem.Metadata ??= new TelevisionEpisodeMetadata { TelevisionEpisodeId = mediaItem.Id };
mediaItem.Metadata.Source = metadata.Source;
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
mediaItem.Metadata.Title = metadata.Title;
mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title);
mediaItem.Metadata.Season = metadata.Season;
mediaItem.Metadata.Episode = metadata.Episode;
mediaItem.Metadata.Plot = metadata.Plot;
mediaItem.Metadata.Aired = metadata.Aired;
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
episode.EpisodeNumber = episodeNumber;
Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
existing.DateAdded = metadata.DateAdded;
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
});
await _televisionRepository.Update(mediaItem);
await _televisionRepository.Update(episode);
}
private async Task ApplyMetadataUpdate(MovieMediaItem mediaItem, MovieMetadata metadata)
private async Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata)
{
mediaItem.Metadata ??= new MovieMetadata();
mediaItem.Metadata.Source = metadata.Source;
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
mediaItem.Metadata.Title = metadata.Title;
mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title);
mediaItem.Metadata.Year = metadata.Year;
mediaItem.Metadata.Premiered = metadata.Premiered;
mediaItem.Metadata.Plot = metadata.Plot;
mediaItem.Metadata.Outline = metadata.Outline;
mediaItem.Metadata.Tagline = metadata.Tagline;
mediaItem.Metadata.ContentRating = metadata.ContentRating;
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
existing.DateAdded = metadata.DateAdded;
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
movie.MovieMetadata = new List<MovieMetadata> { metadata };
});
await _mediaItemRepository.Update(mediaItem);
await _mediaItemRepository.Update(movie);
}
private async Task ApplyMetadataUpdate(TelevisionShow televisionShow, TelevisionShowMetadata metadata)
private async Task ApplyMetadataUpdate(Show show, ShowMetadata metadata)
{
televisionShow.Metadata ??= new TelevisionShowMetadata();
televisionShow.Metadata.Source = metadata.Source;
televisionShow.Metadata.LastWriteTime = metadata.LastWriteTime;
televisionShow.Metadata.Title = metadata.Title;
televisionShow.Metadata.Plot = metadata.Plot;
televisionShow.Metadata.Year = metadata.Year;
televisionShow.Metadata.SortTitle = GetSortTitle(metadata.Title);
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
existing.DateAdded = metadata.DateAdded;
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
show.ShowMetadata = new List<ShowMetadata> { metadata };
});
await _televisionRepository.Update(televisionShow);
await _televisionRepository.Update(show);
}
private async Task<Option<MovieMetadata>> LoadMetadata(MovieMediaItem mediaItem, string nfoFileName)
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
@@ -132,18 +171,10 @@ namespace ErsatzTV.Core.Metadata
return None;
}
if (!(mediaItem.Source is LocalMediaSource))
{
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
return None;
}
return await LoadMovieMetadata(mediaItem, nfoFileName);
}
private async Task<Option<TelevisionEpisodeMetadata>> LoadMetadata(
TelevisionEpisodeMediaItem mediaItem,
string nfoFileName)
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadMetadata(Episode mediaItem, string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
@@ -151,18 +182,10 @@ namespace ErsatzTV.Core.Metadata
return None;
}
if (!(mediaItem.Source is LocalMediaSource))
{
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
return None;
}
return await LoadEpisodeMetadata(mediaItem, nfoFileName);
}
private async Task<Option<TelevisionShowMetadata>> LoadMetadata(
TelevisionShow televisionShow,
string nfoFileName)
private async Task<Option<ShowMetadata>> LoadMetadata(Show televisionShow, string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
@@ -173,20 +196,23 @@ namespace ErsatzTV.Core.Metadata
return await LoadTelevisionShowMetadata(nfoFileName);
}
private async Task<Option<TelevisionShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo;
return maybeNfo.Match<Option<TelevisionShowMetadata>>(
nfo => new TelevisionShowMetadata
return maybeNfo.Match<Option<ShowMetadata>>(
nfo => new ShowMetadata
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
MetadataKind = MetadataKind.Sidecar,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
Plot = nfo.Plot,
Year = nfo.Year
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Year = nfo.Year,
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1)
},
None);
}
@@ -197,35 +223,35 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Option<TelevisionEpisodeMetadata>> LoadEpisodeMetadata(
TelevisionEpisodeMediaItem mediaItem,
string nfoFileName)
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
return maybeNfo.Match<Option<TelevisionEpisodeMetadata>>(
nfo => new TelevisionEpisodeMetadata
return maybeNfo.Match<Option<Tuple<EpisodeMetadata, int>>>(
nfo =>
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
Aired = GetAired(nfo.Aired),
Episode = nfo.Episode,
Season = nfo.Season,
Plot = nfo.Plot
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
ReleaseDate = GetAired(nfo.Aired),
Plot = nfo.Plot
};
return Tuple(metadata, nfo.Episode);
},
None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName);
return FallbackMetadataProvider.GetFallbackMetadata(mediaItem);
return FallbackMetadataProvider.GetFallbackMetadata(episode);
}
}
private async Task<Option<MovieMetadata>> LoadMovieMetadata(MovieMediaItem mediaItem, string nfoFileName)
private async Task<Option<MovieMetadata>> LoadMovieMetadata(Movie mediaItem, string nfoFileName)
{
try
{
@@ -234,15 +260,14 @@ namespace ErsatzTV.Core.Metadata
return maybeNfo.Match<Option<MovieMetadata>>(
nfo => new MovieMetadata
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
MetadataKind = MetadataKind.Sidecar,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
Year = nfo.Year,
Premiered = nfo.Premiered,
ReleaseDate = nfo.Premiered,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
ContentRating = nfo.ContentRating
Tagline = nfo.Tagline
},
None);
}
@@ -324,6 +349,15 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("tagline")]
public string Tagline { get; set; }
[XmlElement("premiered")]
public string Premiered { get; set; }
}
[XmlRoot("episodedetails")]
@@ -33,42 +33,49 @@ namespace ErsatzTV.Core.Metadata
{
try
{
FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem);
MediaItemStatistics statistics = ProjectToMediaItemStatistics(ffprobe);
return await ApplyStatisticsUpdate(mediaItem, statistics);
string filePath = mediaItem switch
{
Movie m => m.MediaVersions.Head().MediaFiles.Head().Path,
Episode e => e.MediaVersions.Head().MediaFiles.Head().Path,
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
FFprobe ffprobe = await GetProbeOutput(ffprobePath, filePath);
MediaVersion version = ProjectToMediaVersion(ffprobe);
return await ApplyVersionUpdate(mediaItem, version, filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh statistics for media item at {Path}", mediaItem.Path);
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
return false;
}
}
private async Task<bool> ApplyStatisticsUpdate(
MediaItem mediaItem,
MediaItemStatistics statistics)
private async Task<bool> ApplyVersionUpdate(MediaItem mediaItem, MediaVersion version, string filePath)
{
if (mediaItem.Statistics == null)
MediaVersion mediaItemVersion = mediaItem switch
{
mediaItem.Statistics = new MediaItemStatistics();
}
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
bool durationChange = mediaItem.Statistics.Duration != statistics.Duration;
bool durationChange = mediaItemVersion.Duration != version.Duration;
mediaItem.Statistics.LastWriteTime = _localFileSystem.GetLastWriteTime(mediaItem.Path);
mediaItem.Statistics.Duration = statistics.Duration;
mediaItem.Statistics.AudioCodec = statistics.AudioCodec;
mediaItem.Statistics.SampleAspectRatio = statistics.SampleAspectRatio;
mediaItem.Statistics.DisplayAspectRatio = statistics.DisplayAspectRatio;
mediaItem.Statistics.Width = statistics.Width;
mediaItem.Statistics.Height = statistics.Height;
mediaItem.Statistics.VideoCodec = statistics.VideoCodec;
mediaItem.Statistics.VideoScanType = statistics.VideoScanType;
mediaItemVersion.DateUpdated = _localFileSystem.GetLastWriteTime(filePath);
mediaItemVersion.Duration = version.Duration;
mediaItemVersion.AudioCodec = version.AudioCodec;
mediaItemVersion.SampleAspectRatio = version.SampleAspectRatio;
mediaItemVersion.DisplayAspectRatio = version.DisplayAspectRatio;
mediaItemVersion.Width = version.Width;
mediaItemVersion.Height = version.Height;
mediaItemVersion.VideoCodec = version.VideoCodec;
mediaItemVersion.VideoScanKind = version.VideoScanKind;
return await _mediaItemRepository.Update(mediaItem) && durationChange;
}
private Task<FFprobe> GetProbeOutput(string ffprobePath, MediaItem mediaItem)
private Task<FFprobe> GetProbeOutput(string ffprobePath, string filePath)
{
var startInfo = new ProcessStartInfo
{
@@ -85,7 +92,7 @@ namespace ErsatzTV.Core.Metadata
startInfo.ArgumentList.Add("-show_format");
startInfo.ArgumentList.Add("-show_streams");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(mediaItem.Path);
startInfo.ArgumentList.Add(filePath);
var probe = new Process
{
@@ -101,7 +108,7 @@ namespace ErsatzTV.Core.Metadata
});
}
private MediaItemStatistics ProjectToMediaItemStatistics(FFprobe probeOutput) =>
private MediaVersion ProjectToMediaVersion(FFprobe probeOutput) =>
Optional(probeOutput)
.Filter(json => json?.format != null && json.streams != null)
.ToValidation<BaseError>("Unable to parse ffprobe output")
@@ -111,41 +118,35 @@ namespace ErsatzTV.Core.Metadata
{
var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration));
var statistics = new MediaItemStatistics { Duration = duration };
var version = new MediaVersion { Name = "Main", Duration = duration };
FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio");
if (audioStream != null)
{
statistics = statistics with
{
AudioCodec = audioStream.codec_name
};
version.AudioCodec = audioStream.codec_name;
}
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
if (videoStream != null)
{
statistics = statistics with
{
SampleAspectRatio = videoStream.sample_aspect_ratio,
DisplayAspectRatio = videoStream.display_aspect_ratio,
Width = videoStream.width,
Height = videoStream.height,
VideoCodec = videoStream.codec_name,
VideoScanType = ScanTypeFromFieldOrder(videoStream.field_order)
};
version.SampleAspectRatio = videoStream.sample_aspect_ratio;
version.DisplayAspectRatio = videoStream.display_aspect_ratio;
version.Width = videoStream.width;
version.Height = videoStream.height;
version.VideoCodec = videoStream.codec_name;
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
}
return statistics;
return version;
},
_ => new MediaItemStatistics());
_ => new MediaVersion { Name = "Main" });
private VideoScanType ScanTypeFromFieldOrder(string fieldOrder) =>
private VideoScanKind ScanKindFromFieldOrder(string fieldOrder) =>
fieldOrder?.ToLowerInvariant() switch
{
var x when x == "tt" || x == "bb" || x == "tb" || x == "bt" => VideoScanType.Interlaced,
"progressive" => VideoScanType.Progressive,
_ => VideoScanType.Unknown
var x when x == "tt" || x == "bb" || x == "tb" || x == "bt" => VideoScanKind.Interlaced,
"progressive" => VideoScanKind.Progressive,
_ => VideoScanKind.Unknown
};
// ReSharper disable InconsistentNaming
+28 -24
View File
@@ -37,15 +37,15 @@ namespace ErsatzTV.Core.Metadata
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
public async Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath)
{
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
return new MediaSourceInaccessible();
}
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(localMediaSource.Folder).OrderBy(identity))
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
@@ -73,10 +73,10 @@ namespace ErsatzTV.Core.Metadata
foreach (string file in allFiles.OrderBy(identity))
{
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
// TODO: figure out how to rebuild playlists
Either<BaseError, MovieMediaItem> x = await _movieRepository.GetOrAdd(localMediaSource.Id, file);
Either<BaseError, MovieMediaItem> maybeMovie = await x.AsTask()
Either<BaseError, Movie> maybeMovie = await _movieRepository
.GetOrAdd(libraryPath, file)
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
.BindT(UpdateMetadata)
.BindT(UpdatePoster);
@@ -89,16 +89,19 @@ namespace ErsatzTV.Core.Metadata
return Unit.Default;
}
private async Task<Either<BaseError, MovieMediaItem>> UpdateMetadata(MovieMediaItem movie)
private async Task<Either<BaseError, Movie>> UpdateMetadata(Movie movie)
{
try
{
await LocateNfoFile(movie).Match(
async nfoFile =>
{
if (movie.Metadata == null || movie.Metadata.Source == MetadataSource.Fallback ||
(movie.Metadata.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(nfoFile))
bool shouldUpdate = Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile);
@@ -106,9 +109,10 @@ namespace ErsatzTV.Core.Metadata
},
async () =>
{
if (movie.Metadata == null)
if (!Optional(movie.MovieMetadata).Flatten().Any())
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", movie.Path);
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
await _localMetadataProvider.RefreshFallbackMetadata(movie);
}
});
@@ -121,19 +125,17 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, MovieMediaItem>> UpdatePoster(MovieMediaItem movie)
private async Task<Either<BaseError, Movie>> UpdatePoster(Movie movie)
{
try
{
await LocatePoster(movie).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(movie.Poster) ||
(movie.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
MovieMetadata metadata = movie.MovieMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
await SavePosterToDisk(movie, posterFile, _movieRepository.Update, 440);
await _movieRepository.Update(movie);
}
});
@@ -145,20 +147,22 @@ namespace ErsatzTV.Core.Metadata
}
}
private Option<string> LocateNfoFile(MovieMediaItem movie)
private Option<string> LocateNfoFile(Movie movie)
{
string movieAsNfo = Path.ChangeExtension(movie.Path, "nfo");
string movieNfo = Path.Combine(Path.GetDirectoryName(movie.Path) ?? string.Empty, "movie.nfo");
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string movieAsNfo = Path.ChangeExtension(path, "nfo");
string movieNfo = Path.Combine(Path.GetDirectoryName(path) ?? string.Empty, "movie.nfo");
return Seq.create(movieAsNfo, movieNfo)
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocatePoster(MovieMediaItem movie)
private Option<string> LocatePoster(Movie movie)
{
string folder = Path.GetDirectoryName(movie.Path) ?? string.Empty;
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string folder = Path.GetDirectoryName(path) ?? string.Empty;
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(movie.Path) + $"-poster.{ext}" })
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(path) + $"-poster.{ext}" })
.Map(f => Path.Combine(folder, f));
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
return result;
+83 -107
View File
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -37,88 +39,65 @@ namespace ErsatzTV.Core.Metadata
_logger = logger;
}
public async Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
public async Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath)
{
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
_logger.LogWarning(
"Media source is not accessible or missing; skipping scan of {Folder}",
localMediaSource.Folder);
return Unit.Default;
return new MediaSourceInaccessible();
}
var allShowFolders = _localFileSystem.ListSubdirectories(localMediaSource.Folder)
var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity)
.ToList();
foreach (string showFolder in allShowFolders)
{
// TODO: check all sources for latest metadata?
Either<BaseError, TelevisionShow> maybeShow =
await FindOrCreateShow(localMediaSource.Id, showFolder)
Either<BaseError, Show> maybeShow =
await FindOrCreateShow(libraryPath.Id, showFolder)
.BindT(show => UpdateMetadataForShow(show, showFolder))
.BindT(show => UpdatePosterForShow(show, showFolder));
await maybeShow.Match(
show => ScanSeasons(localMediaSource, ffprobePath, show, showFolder),
show => ScanSeasons(libraryPath, ffprobePath, show, showFolder),
_ => Task.FromResult(Unit.Default));
}
await _televisionRepository.DeleteMissingSources(localMediaSource.Id, allShowFolders);
await _televisionRepository.DeleteEmptyShows();
return Unit.Default;
}
private async Task<Either<BaseError, TelevisionShow>> FindOrCreateShow(
int localMediaSourceId,
private async Task<Either<BaseError, Show>> FindOrCreateShow(
int libraryPathId,
string showFolder)
{
Option<TelevisionShow> maybeShowByPath =
await _televisionRepository.GetShowByPath(localMediaSourceId, showFolder);
return await maybeShowByPath.Match(
show => Right<BaseError, TelevisionShow>(show).AsTask(),
async () =>
{
TelevisionShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
Option<TelevisionShow> maybeShow = await _televisionRepository.GetShowByMetadata(metadata);
return await maybeShow.Match(
async show =>
{
show.Sources.Add(
new LocalTelevisionShowSource
{
MediaSourceId = localMediaSourceId,
Path = showFolder,
TelevisionShow = show
});
await _televisionRepository.Update(show);
return Right<BaseError, TelevisionShow>(show);
},
async () => await _televisionRepository.AddShow(localMediaSourceId, showFolder, metadata));
});
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
return await maybeShow.Match(
show => Right<BaseError, Show>(show).AsTask(),
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
}
private async Task<Unit> ScanSeasons(
LocalMediaSource localMediaSource,
LibraryPath libraryPath,
string ffprobePath,
TelevisionShow show,
string showPath)
Show show,
string showFolder)
{
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showPath).Filter(ShouldIncludeFolder)
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder);
await maybeSeasonNumber.IfSomeAsync(
async seasonNumber =>
{
Either<BaseError, TelevisionSeason> maybeSeason = await _televisionRepository
.GetOrAddSeason(show, seasonFolder, seasonNumber)
.BindT(UpdatePoster);
Either<BaseError, Season> maybeSeason = await _televisionRepository
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
.BindT(season => UpdatePoster(season, seasonFolder));
await maybeSeason.Match(
season => ScanEpisodes(localMediaSource, ffprobePath, season),
season => ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder),
_ => Task.FromResult(Unit.Default));
});
}
@@ -127,16 +106,17 @@ namespace ErsatzTV.Core.Metadata
}
private async Task<Unit> ScanEpisodes(
LocalMediaSource localMediaSource,
LibraryPath libraryPath,
string ffprobePath,
TelevisionSeason season)
Season season,
string seasonPath)
{
foreach (string file in _localFileSystem.ListFiles(season.Path)
foreach (string file in _localFileSystem.ListFiles(seasonPath)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))).OrderBy(identity))
{
// TODO: figure out how to rebuild playlists
Either<BaseError, TelevisionEpisodeMediaItem> maybeEpisode = await _televisionRepository
.GetOrAddEpisode(season, localMediaSource.Id, file)
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
.GetOrAddEpisode(season, libraryPath, file)
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
@@ -148,8 +128,8 @@ namespace ErsatzTV.Core.Metadata
return Unit.Default;
}
private async Task<Either<BaseError, TelevisionShow>> UpdateMetadataForShow(
TelevisionShow show,
private async Task<Either<BaseError, Show>> UpdateMetadataForShow(
Show show,
string showFolder)
{
try
@@ -157,9 +137,12 @@ namespace ErsatzTV.Core.Metadata
await LocateNfoFileForShow(showFolder).Match(
async nfoFile =>
{
if (show.Metadata == null || show.Metadata.Source == MetadataSource.Fallback ||
(show.Metadata.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(nfoFile))
bool shouldUpdate = Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile);
@@ -167,7 +150,7 @@ namespace ErsatzTV.Core.Metadata
},
async () =>
{
if (show.Metadata == null)
if (!Optional(show.ShowMetadata).Flatten().Any())
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder);
await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder);
@@ -182,17 +165,20 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateMetadata(
TelevisionEpisodeMediaItem episode)
private async Task<Either<BaseError, Episode>> UpdateMetadata(
Episode episode)
{
try
{
await LocateNfoFile(episode).Match(
async nfoFile =>
{
if (episode.Metadata == null || episode.Metadata.Source == MetadataSource.Fallback ||
(episode.Metadata.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(nfoFile))
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(episode, nfoFile);
@@ -200,9 +186,10 @@ namespace ErsatzTV.Core.Metadata
},
async () =>
{
if (episode.Metadata == null)
if (!Optional(episode.EpisodeMetadata).Flatten().Any())
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", episode.Path);
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
await _localMetadataProvider.RefreshFallbackMetadata(episode);
}
});
@@ -215,8 +202,8 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, TelevisionShow>> UpdatePosterForShow(
TelevisionShow show,
private async Task<Either<BaseError, Show>> UpdatePosterForShow(
Show show,
string showFolder)
{
try
@@ -224,27 +211,10 @@ namespace ErsatzTV.Core.Metadata
await LocatePosterForShow(showFolder).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(show.Poster) ||
(show.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
ShowMetadata metadata = show.ShowMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
Either<BaseError, string> maybePoster = await SavePosterToDisk(posterFile, 440);
await maybePoster.Match(
poster =>
{
show.Poster = poster;
show.PosterLastWriteTime = _localFileSystem.GetLastWriteTime(posterFile);
return _televisionRepository.Update(show);
},
error =>
{
_logger.LogWarning(
"Unable to save poster to disk from {Path}: {Error}",
posterFile,
error.Value);
return Task.CompletedTask;
});
await _televisionRepository.Update(show);
}
});
@@ -256,19 +226,24 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, TelevisionSeason>> UpdatePoster(TelevisionSeason season)
private async Task<Either<BaseError, Season>> UpdatePoster(Season season, string seasonFolder)
{
try
{
await LocatePoster(season).IfSomeAsync(
await LocatePoster(season, seasonFolder).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(season.Poster) ||
(season.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
season.SeasonMetadata ??= new List<SeasonMetadata>();
if (!season.SeasonMetadata.Any())
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
await SavePosterToDisk(season, posterFile, _televisionRepository.Update, 440);
season.SeasonMetadata.Add(new SeasonMetadata { SeasonId = season.Id });
}
SeasonMetadata metadata = season.SeasonMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
{
await _televisionRepository.Update(season);
}
});
@@ -280,20 +255,17 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateThumbnail(
TelevisionEpisodeMediaItem episode)
private async Task<Either<BaseError, Episode>> UpdateThumbnail(Episode episode)
{
try
{
await LocateThumbnail(episode).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(episode.Poster) ||
(episode.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
EpisodeMetadata metadata = episode.EpisodeMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Thumbnail", posterFile);
await SavePosterToDisk(episode, posterFile, _televisionRepository.Update);
await _televisionRepository.Update(episode);
}
});
@@ -309,9 +281,12 @@ namespace ErsatzTV.Core.Metadata
Optional(Path.Combine(showFolder, "tvshow.nfo"))
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateNfoFile(TelevisionEpisodeMediaItem episode) =>
Optional(Path.ChangeExtension(episode.Path, "nfo"))
private Option<string> LocateNfoFile(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
return Optional(Path.ChangeExtension(path, "nfo"))
.Filter(s => _localFileSystem.FileExists(s));
}
private Option<string> LocatePosterForShow(string showFolder) =>
ImageFileExtensions
@@ -320,20 +295,21 @@ namespace ErsatzTV.Core.Metadata
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
private Option<string> LocatePoster(TelevisionSeason season)
private Option<string> LocatePoster(Season season, string seasonFolder)
{
string folder = Path.GetDirectoryName(season.Path) ?? string.Empty;
string folder = Path.GetDirectoryName(seasonFolder) ?? string.Empty;
return ImageFileExtensions
.Map(ext => Path.Combine(folder, $"season{season.Number:00}-poster.{ext}"))
.Map(ext => Path.Combine(folder, $"season{season.SeasonNumber:00}-poster.{ext}"))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocateThumbnail(TelevisionEpisodeMediaItem episode)
private Option<string> LocateThumbnail(Episode episode)
{
string folder = Path.GetDirectoryName(episode.Path) ?? string.Empty;
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
string folder = Path.GetDirectoryName(path) ?? string.Empty;
return ImageFileExtensions
.Map(ext => Path.GetFileNameWithoutExtension(episode.Path) + $"-thumb.{ext}")
.Map(ext => Path.GetFileNameWithoutExtension(path) + $"-thumb.{ext}")
.Map(f => Path.Combine(folder, f))
.Filter(f => _localFileSystem.FileExists(f))
.HeadOrNone();
+20
View File
@@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Plex
{
public class PlexMediaEntry
{
public int Id { get; set; }
public int Duration { get; set; }
public int Bitrate { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public double AspectRatio { get; set; }
public int AudioChannels { get; set; }
public string AudioCodec { get; set; }
public string VideoCodec { get; set; }
public string Container { get; set; }
public string VideoFrameRate { get; set; }
public List<PlexPartEntry> Part { get; set; }
}
}
+19
View File
@@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Plex
{
public class PlexMetadataEntry
{
public string Key { get; set; }
public string Title { get; set; }
public string Summary { get; set; }
public int Year { get; set; }
public string Tagline { get; set; }
public string Thumb { get; set; }
public string Art { get; set; }
public string OriginallyAvailableAt { get; set; }
public int AddedAt { get; set; }
public int UpdatedAt { get; set; }
public List<PlexMediaEntry> Media { get; set; }
}
}
@@ -0,0 +1,77 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex
{
public class PlexMovieLibraryScanner : IPlexMovieLibraryScanner
{
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMovieRepository _movieRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
public PlexMovieLibraryScanner(
IPlexServerApiClient plexServerApiClient,
IMovieRepository movieRepository,
ILogger<PlexMovieLibraryScanner> logger)
{
_plexServerApiClient = plexServerApiClient;
_movieRepository = movieRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary)
{
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetLibraryContents(
plexMediaSourceLibrary,
connection,
token);
await entries.Match(
async movieEntries =>
{
foreach (PlexMovie entry in movieEntries)
{
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexMovie> maybeMovie = await _movieRepository
.GetOrAdd(plexMediaSourceLibrary, entry)
.BindT(UpdateIfNeeded);
maybeMovie.IfLeft(
error => _logger.LogWarning(
"Error processing plex movie at {Key}: {Error}",
entry.Key,
error.Value));
}
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
error.Value);
return Task.CompletedTask;
});
// need plex media item model that can be used to lookup by unique id (metadata key?)
return Unit.Default;
}
private Task<Either<BaseError, PlexMovie>> UpdateIfNeeded(PlexMovie plexMovie) =>
// .BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
// .BindT(UpdateMetadata)
// .BindT(UpdatePoster);
Right<BaseError, PlexMovie>(plexMovie).AsTask();
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Plex
{
public class PlexPartEntry
{
public int Id { get; set; }
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public int Size { get; set; }
}
}
@@ -14,18 +14,18 @@ namespace ErsatzTV.Core.Scheduling
public ChronologicalMediaCollectionEnumerator(
IEnumerable<MediaItem> mediaItems,
MediaCollectionEnumeratorState state)
CollectionEnumeratorState state)
{
_sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalComparer()).ToList();
State = new MediaCollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
{
MoveNext();
}
}
public MediaCollectionEnumeratorState State { get; }
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
@@ -42,15 +42,23 @@ namespace ErsatzTV.Core.Scheduling
DateTime date1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Aired ?? DateTime.MaxValue,
MovieMediaItem m => m.Metadata?.Premiered ?? DateTime.MaxValue,
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
_ => DateTime.MaxValue
};
DateTime date2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Aired ?? DateTime.MaxValue,
MovieMediaItem m => m.Metadata?.Premiered ?? DateTime.MaxValue,
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
_ => DateTime.MaxValue
};
@@ -61,13 +69,13 @@ namespace ErsatzTV.Core.Scheduling
int season1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Season ?? int.MaxValue,
Episode e => e.Season?.SeasonNumber ?? int.MaxValue,
_ => int.MaxValue
};
int season2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Season ?? int.MaxValue,
Episode e => e.Season?.SeasonNumber ?? int.MaxValue,
_ => int.MaxValue
};
@@ -78,13 +86,13 @@ namespace ErsatzTV.Core.Scheduling
int episode1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Episode ?? int.MaxValue,
Episode e => e.EpisodeNumber,
_ => int.MaxValue
};
int episode2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Episode ?? int.MaxValue,
Episode e => e.EpisodeNumber,
_ => int.MaxValue
};
+55 -28
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -55,15 +54,15 @@ namespace ErsatzTV.Core.Scheduling
{
case ProgramScheduleItemCollectionType.Collection:
Option<List<MediaItem>> maybeItems =
await _mediaCollectionRepository.GetItems(collectionKey.Id);
await _mediaCollectionRepository.GetItems(collectionKey.CollectionId ?? 0);
return Tuple(collectionKey, maybeItems.IfNone(new List<MediaItem>()));
case ProgramScheduleItemCollectionType.TelevisionShow:
List<TelevisionEpisodeMediaItem> showItems =
await _televisionRepository.GetShowItems(collectionKey.Id);
List<Episode> showItems =
await _televisionRepository.GetShowItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, showItems.Cast<MediaItem>().ToList());
case ProgramScheduleItemCollectionType.TelevisionSeason:
List<TelevisionEpisodeMediaItem> seasonItems =
await _televisionRepository.GetSeasonItems(collectionKey.Id);
List<Episode> seasonItems =
await _televisionRepository.GetSeasonItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, seasonItems.Cast<MediaItem>().ToList());
default:
return Tuple(collectionKey, new List<MediaItem>());
@@ -145,14 +144,21 @@ namespace ErsatzTV.Core.Scheduling
DisplayTitle(mediaItem),
itemStartTime);
MediaVersion version = mediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
var playoutItem = new PlayoutItem
{
MediaItemId = mediaItem.Id,
Start = itemStartTime,
Finish = itemStartTime + mediaItem.Statistics.Duration
Start = itemStartTime.UtcDateTime,
Finish = itemStartTime.UtcDateTime + version.Duration
};
currentTime = itemStartTime + mediaItem.Statistics.Duration;
currentTime = itemStartTime + version.Duration;
enumerator.MoveNext();
playout.Items.Add(playoutItem);
@@ -187,6 +193,13 @@ namespace ErsatzTV.Core.Scheduling
enumerator.Current.Do(
peekMediaItem =>
{
MediaVersion peekVersion = peekMediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
ProgramScheduleItem peekScheduleItem =
sortedScheduleItems[(index + 1) % sortedScheduleItems.Count];
DateTimeOffset peekScheduleItemStart =
@@ -198,7 +211,7 @@ namespace ErsatzTV.Core.Scheduling
// is after, we need to move on to the next schedule item
// eventually, spots probably have to fit in this gap
bool willNotFinishInTime = currentTime <= peekScheduleItemStart &&
currentTime + peekMediaItem.Statistics.Duration >
currentTime + peekVersion.Duration >
peekScheduleItemStart;
if (willNotFinishInTime)
{
@@ -213,6 +226,13 @@ namespace ErsatzTV.Core.Scheduling
enumerator.Current.Do(
peekMediaItem =>
{
MediaVersion peekVersion = peekMediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
// remember when we need to finish this duration item
if (durationFinish.IsNone)
{
@@ -221,7 +241,7 @@ namespace ErsatzTV.Core.Scheduling
bool willNotFinishInTime =
currentTime <= durationFinish.IfNone(DateTime.MinValue) &&
currentTime + peekMediaItem.Statistics.Duration >
currentTime + peekVersion.Duration >
durationFinish.IfNone(DateTime.MinValue);
if (willNotFinishInTime)
{
@@ -250,7 +270,7 @@ namespace ErsatzTV.Core.Scheduling
{
NextScheduleItem = nextScheduleItem,
NextScheduleItemId = nextScheduleItem.Id,
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime)
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime
};
// build program schedule anchors
@@ -306,7 +326,7 @@ namespace ErsatzTV.Core.Scheduling
}
TimeSpan startTime = item.StartTime.GetValueOrDefault();
DateTime result = start.Date + startTime;
DateTimeOffset result = start.Date + startTime;
// need to wrap to the next day if appropriate
return start.TimeOfDay > startTime ? result.AddDays(1) : result;
case StartType.Dynamic:
@@ -325,7 +345,9 @@ namespace ErsatzTV.Core.Scheduling
{
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors
.FirstOrDefault(
a => a.CollectionType == collectionKey.CollectionType && a.CollectionId == collectionKey.Id);
a => a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State)
.ToDictionary(mcs => mcs.Key, mcs => mcs.Head());
@@ -343,7 +365,8 @@ namespace ErsatzTV.Core.Scheduling
ProgramSchedule = playout.ProgramSchedule,
ProgramScheduleId = playout.ProgramScheduleId,
CollectionType = collectionKey.CollectionType,
CollectionId = collectionKey.Id,
CollectionId = collectionKey.CollectionId,
MediaItemId = collectionKey.MediaItemId,
EnumeratorState = maybeEnumeratorState[collectionKey]
});
@@ -360,13 +383,14 @@ namespace ErsatzTV.Core.Scheduling
{
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
.FirstOrDefault(
a => a.ProgramScheduleId == playout.ProgramScheduleId && a.CollectionType ==
collectionKey.CollectionType
&& a.CollectionId == collectionKey.Id);
a => a.ProgramScheduleId == playout.ProgramScheduleId
&& a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
MediaCollectionEnumeratorState state = maybeAnchor.Match(
CollectionEnumeratorState state = maybeAnchor.Match(
anchor => anchor.EnumeratorState,
() => new MediaCollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder)
{
@@ -385,10 +409,12 @@ namespace ErsatzTV.Core.Scheduling
private static string DisplayTitle(MediaItem mediaItem) =>
mediaItem switch
{
TelevisionEpisodeMediaItem e => e.Metadata != null
? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}"
: Path.GetFileName(e.Path),
MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path),
Episode e => e.EpisodeMetadata.Any() && e.Season != null
? $"{e.EpisodeMetadata.Head().Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}"
: "[unknown episode]",
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.Title ?? string.Empty,
() => "[unknown movie]"),
_ => string.Empty
};
@@ -398,17 +424,17 @@ namespace ErsatzTV.Core.Scheduling
ProgramScheduleItemCollectionType.Collection => new CollectionKey
{
CollectionType = item.CollectionType,
Id = item.MediaCollectionId.Value
CollectionId = item.CollectionId
},
ProgramScheduleItemCollectionType.TelevisionShow => new CollectionKey
{
CollectionType = item.CollectionType,
Id = item.TelevisionShowId.Value
MediaItemId = item.MediaItemId
},
ProgramScheduleItemCollectionType.TelevisionSeason => new CollectionKey
{
CollectionType = item.CollectionType,
Id = item.TelevisionSeasonId.Value
MediaItemId = item.MediaItemId
},
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
@@ -416,7 +442,8 @@ namespace ErsatzTV.Core.Scheduling
private class CollectionKey : Record<CollectionKey>
{
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int Id { get; set; }
public int? CollectionId { get; set; }
public int? MediaItemId { get; set; }
}
}
}
@@ -14,12 +14,12 @@ namespace ErsatzTV.Core.Scheduling
private readonly Random _random;
private int _index;
public RandomizedMediaCollectionEnumerator(IList<MediaItem> mediaItems, MediaCollectionEnumeratorState state)
public RandomizedMediaCollectionEnumerator(IList<MediaItem> mediaItems, CollectionEnumeratorState state)
{
_mediaItems = mediaItems;
_random = new Random(state.Seed);
State = new MediaCollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed };
// we want to move at least once so we start with a random item and not the first
// because _index defaults to 0
while (State.Index <= state.Index)
@@ -28,7 +28,7 @@ namespace ErsatzTV.Core.Scheduling
}
}
public MediaCollectionEnumeratorState State { get; }
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _mediaItems.Any() ? _mediaItems[_index] : None;
@@ -15,20 +15,20 @@ namespace ErsatzTV.Core.Scheduling
private IList<MediaItem> _shuffled;
public ShuffledMediaCollectionEnumerator(IList<MediaItem> mediaItems, MediaCollectionEnumeratorState state)
public ShuffledMediaCollectionEnumerator(IList<MediaItem> mediaItems, CollectionEnumeratorState state)
{
_mediaItems = mediaItems;
_random = new Random(state.Seed);
_shuffled = Shuffle(_mediaItems, _random);
State = new MediaCollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
{
MoveNext();
}
}
public MediaCollectionEnumeratorState State { get; }
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItems.Count] : None;