rework television media (#26)

* rework television media

* refactor poster saving

* television and movie views are working again

* remove dead code

* use paper styling for all cards

* add show poster, plot to seasons page

* remove missing shows; cleanup interfaces

* fix split show display (same show in different folders/sources)

* add placeholder "add to schedule" button

* no more duplicate television shows, even with the same show split across sources

* stop releasing CLI for now

* use season number as season placeholder

* add television shows to collections

* add television seasons to collections

* add television episodes to collections

* add movies to collections

* remove movies, shows, seasons, episodes from collections

* fix page width and menus

* fix buffer size defaults

* fix chronological episode ordering

* allow deleting media collections

* don't get stuck building a playout with an empty collection

* schedule editing and playouts work again

* minor cleanup

* remove dead code

* fix bugs with viewing movies as they are loading

* add scanner tests; support nested movie folders

* update collections docs

* rearrange order of schedule items

* add show and season to schedule

* delete schedules that use legacy collections, reset all posters

* move cleanup to new migration

* load fallback metadata when nfo fails; don't require metadata in ui

* update readme and screenshots
This commit is contained in:
Jason Dove
2021-02-22 00:54:41 +00:00
committed by GitHub
parent 98cf922b3c
commit 871a031467
259 changed files with 13493 additions and 4771 deletions
+2 -2
View File
@@ -33,9 +33,9 @@
VideoCodec = "libx264",
AudioCodec = "ac3",
VideoBitrate = 2000,
VideoBufferSize = 2000,
VideoBufferSize = 4000,
AudioBitrate = 192,
AudioBufferSize = 50,
AudioBufferSize = 384,
AudioVolume = 100,
AudioChannels = 2,
AudioSampleRate = 48,
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public class LocalTelevisionShowSource : TelevisionShowSource
{
public int MediaSourceId { get; set; }
public LocalMediaSource MediaSource { get; set; }
public string Path { get; set; }
}
}
+4 -5
View File
@@ -1,18 +1,17 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Interfaces.Domain;
namespace ErsatzTV.Core.Domain
{
public class MediaItem
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; }
public MediaMetadata Metadata { get; set; }
public DateTime? LastWriteTime { get; set; }
public IList<SimpleMediaCollection> SimpleMediaCollections { get; set; }
}
}
+12
View File
@@ -0,0 +1,12 @@
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; }
}
}
@@ -3,24 +3,14 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.Domain
{
public record MediaMetadata : IDisplaySize
public record MediaItemStatistics : IDisplaySize
{
public MetadataSource Source { get; set; }
public DateTime? LastWriteTime { 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 MediaType MediaType { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
public string Subtitle { get; set; }
public string Description { get; set; }
public int? SeasonNumber { get; set; }
public int? EpisodeNumber { get; set; }
public string ContentRating { get; set; }
public DateTime? Aired { get; set; }
public VideoScanType VideoScanType { get; set; }
public int Width { get; set; }
public int Height { get; set; }
+11
View File
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,17 @@
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; }
}
}
@@ -2,12 +2,13 @@
{
public class PlayoutProgramScheduleAnchor
{
public int Id { get; set; }
public int PlayoutId { get; set; }
public Playout Playout { get; set; }
public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
public int MediaCollectionId { get; set; }
public MediaCollection MediaCollection { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int CollectionId { get; set; }
public MediaCollectionEnumeratorState EnumeratorState { get; set; }
}
}
+6 -1
View File
@@ -8,8 +8,13 @@ namespace ErsatzTV.Core.Domain
public int Index { get; set; }
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
public TimeSpan? StartTime { get; set; }
public int MediaCollectionId { 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; }
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public enum ProgramScheduleItemCollectionType
{
Collection = 0,
TelevisionShow = 1,
TelevisionSeason = 2
}
}
-11
View File
@@ -1,11 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public enum ResolutionKey
{
None = 0,
W720H480 = 1,
W1280H720 = 2,
W1920H1080 = 3,
W3840H2160 = 4
}
}
@@ -4,6 +4,9 @@ namespace ErsatzTV.Core.Domain
{
public class SimpleMediaCollection : MediaCollection
{
public IList<MediaItem> Items { get; set; }
public List<MovieMediaItem> Movies { get; set; }
public List<TelevisionShow> TelevisionShows { get; set; }
public List<TelevisionSeason> TelevisionSeasons { get; set; }
public List<TelevisionEpisodeMediaItem> TelevisionEpisodes { get; set; }
}
}
@@ -0,0 +1,12 @@
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; }
}
}
@@ -0,0 +1,15 @@
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; }
}
}
@@ -1,8 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public class TelevisionMediaCollection : MediaCollection
{
public string ShowTitle { get; set; }
public int? SeasonNumber { get; set; }
}
}
+19
View File
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,16 @@
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; }
}
}
@@ -0,0 +1,17 @@
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; }
}
}
@@ -0,0 +1,9 @@
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,10 @@
namespace ErsatzTV.Core.Errors
{
public class MediaSourceInaccessible : BaseError
{
public MediaSourceInaccessible()
: base("Media source is not accessible or missing")
{
}
}
}
@@ -66,24 +66,24 @@ namespace ErsatzTV.Core.FFmpeg
result.Deinterlace = false;
break;
case StreamingMode.TransportStream:
if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Metadata))
if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Statistics))
{
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata);
if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Metadata))
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Statistics);
if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Statistics))
{
result.ScaledSize = Some(
CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata));
CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Statistics));
}
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Metadata);
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Statistics);
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{
result.PadToDesiredResolution = true;
}
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Metadata))
NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Statistics))
{
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
@@ -94,7 +94,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy";
}
if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Metadata))
if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Statistics))
{
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.Metadata.Duration;
result.AudioDuration = playoutItem.MediaItem.Statistics.Duration;
}
}
else
@@ -112,7 +112,7 @@ namespace ErsatzTV.Core.FFmpeg
result.AudioCodec = "copy";
}
if (playoutItem.MediaItem.Metadata.VideoScanType == VideoScanType.Interlaced)
if (playoutItem.MediaItem.Statistics.VideoScanType == VideoScanType.Interlaced)
{
result.Deinterlace = true;
}
@@ -132,16 +132,16 @@ namespace ErsatzTV.Core.FFmpeg
AudioCodec = ffmpegProfile.AudioCodec
};
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
ffmpegProfile.NormalizeResolution &&
IsIncorrectSize(ffmpegProfile.Resolution, mediaMetadata) ||
IsTooLarge(ffmpegProfile.Resolution, mediaMetadata) ||
IsOddSize(mediaMetadata);
IsIncorrectSize(ffmpegProfile.Resolution, statistics) ||
IsTooLarge(ffmpegProfile.Resolution, statistics) ||
IsOddSize(statistics);
private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaMetadata mediaMetadata) =>
IsAnamorphic(mediaMetadata) ||
mediaMetadata.Width != desiredResolution.Width ||
mediaMetadata.Height != desiredResolution.Height;
private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaItemStatistics statistics) =>
IsAnamorphic(statistics) ||
statistics.Width != desiredResolution.Width ||
statistics.Height != desiredResolution.Height;
private static bool IsTooLarge(IDisplaySize desiredResolution, IDisplaySize mediaSize) =>
mediaSize.Height > desiredResolution.Height ||
@@ -150,17 +150,17 @@ namespace ErsatzTV.Core.FFmpeg
private static bool IsOddSize(IDisplaySize displaySize) =>
displaySize.Height % 2 == 1 || displaySize.Width % 2 == 1;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != mediaMetadata.VideoCodec;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != statistics.VideoCodec;
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != mediaMetadata.AudioCodec;
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != statistics.AudioCodec;
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata)
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics)
{
IDisplaySize sarSize = SARSize(mediaMetadata);
int p = mediaMetadata.Width * sarSize.Width;
int q = mediaMetadata.Height * sarSize.Height;
IDisplaySize sarSize = SARSize(statistics);
int p = statistics.Width * sarSize.Width;
int q = statistics.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(MediaMetadata mediaMetadata)
private static bool IsAnamorphic(MediaItemStatistics statistics)
{
if (mediaMetadata.SampleAspectRatio == "1:1")
if (statistics.SampleAspectRatio == "1:1")
{
return false;
}
if (mediaMetadata.SampleAspectRatio != "0:1")
if (statistics.SampleAspectRatio != "0:1")
{
return true;
}
if (mediaMetadata.DisplayAspectRatio == "0:1")
if (statistics.DisplayAspectRatio == "0:1")
{
return false;
}
return mediaMetadata.DisplayAspectRatio != $"{mediaMetadata.Width}:{mediaMetadata.Height}";
return statistics.DisplayAspectRatio != $"{statistics.Width}:{statistics.Height}";
}
private static IDisplaySize SARSize(MediaMetadata mediaMetadata)
private static IDisplaySize SARSize(MediaItemStatistics statistics)
{
string[] split = mediaMetadata.SampleAspectRatio.Split(":");
string[] split = statistics.SampleAspectRatio.Split(":");
return new DisplaySize(int.Parse(split[0]), int.Parse(split[1]));
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ namespace ErsatzTV.Core.FFmpeg
return builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel)
.WithFormat("mpegts")
.WithDuration(item.Start + item.MediaItem.Metadata.Duration - now)
.WithDuration(item.Start + item.MediaItem.Statistics.Duration - now)
.WithPipe()
.Build();
}
@@ -0,0 +1,11 @@
using System;
namespace ErsatzTV.Core.Interfaces.Domain
{
public interface IHasAPoster
{
string Path { get; set; }
string Poster { get; set; }
DateTime? PosterLastWriteTime { get; set; }
}
}
@@ -2,7 +2,7 @@
{
public interface IDisplaySize
{
public int Width { get; }
public int Height { get; }
int Width { get; }
int Height { get; }
}
}
@@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegLocator
{
public Task<Option<string>> ValidatePath(string executableBase, ConfigElementKey key);
Task<Option<string>> ValidatePath(string executableBase, ConfigElementKey key);
}
}
@@ -4,9 +4,9 @@ namespace ErsatzTV.Core.Interfaces.Locking
{
public interface IEntityLocker
{
public event EventHandler OnMediaSourceChanged;
public bool LockMediaSource(int mediaSourceId);
public bool UnlockMediaSource(int mediaSourceId);
public bool IsMediaSourceLocked(int mediaSourceId);
event EventHandler OnMediaSourceChanged;
bool LockMediaSource(int mediaSourceId);
bool UnlockMediaSource(int mediaSourceId);
bool IsMediaSourceLocked(int mediaSourceId);
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IFallbackMetadataProvider
{
TelevisionShowMetadata GetFallbackMetadataForShow(string showFolder);
}
}
@@ -1,15 +1,17 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalFileSystem
{
public DateTime GetLastWriteTime(string path);
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource);
public Seq<string> FindRelevantVideos(LocalMediaSource localMediaSource);
public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem);
public bool ShouldRefreshPoster(MediaItem mediaItem);
DateTime GetLastWriteTime(string path);
bool IsMediaSourceAccessible(LocalMediaSource localMediaSource);
IEnumerable<string> ListSubdirectories(string folder);
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
Task<byte[]> ReadAllBytes(string path);
}
}
@@ -1,15 +0,0 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMediaScanner
{
Task<Unit> ScanLocalMediaSource(
LocalMediaSource localMediaSource,
string ffprobePath,
ScanningMode scanningMode);
}
}
@@ -1,14 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMediaSourcePlanner
{
public Seq<LocalMediaSourcePlan> DetermineActions(
MediaType mediaType,
Seq<MediaItem> mediaItems,
Seq<string> files);
}
}
@@ -1,11 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMetadataProvider
{
Task RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task RefreshFallbackMetadata(MediaItem mediaItem);
Task<TelevisionShowMetadata> GetMetadataForShow(string showFolder);
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task<Unit> RefreshSidecarMetadata(TelevisionShow televisionShow, string showFolder);
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem);
Task<Unit> RefreshFallbackMetadata(TelevisionShow televisionShow, string showFolder);
}
}
@@ -1,10 +0,0 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalPosterProvider
{
Task RefreshPoster(MediaItem mediaItem);
}
}
@@ -0,0 +1,11 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IMovieFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath);
}
}
@@ -1,10 +0,0 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ISmartCollectionBuilder
{
Task<bool> RefreshSmartCollections(MediaItem mediaItem);
}
}
@@ -0,0 +1,11 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ITelevisionFolderScanner
{
Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath);
}
}
@@ -7,11 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Plex
{
public interface IPlexSecretStore
{
public Task<string> GetClientIdentifier();
public Task<List<PlexUserAuthToken>> GetUserAuthTokens();
public Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken);
public Task<List<PlexServerAuthToken>> GetServerAuthTokens();
public Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier);
public Task<Unit> UpsertServerAuthToken(PlexServerAuthToken serverAuthToken);
Task<string> GetClientIdentifier();
Task<List<PlexUserAuthToken>> GetUserAuthTokens();
Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken);
Task<List<PlexServerAuthToken>> GetServerAuthTokens();
Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier);
Task<Unit> UpsertServerAuthToken(PlexServerAuthToken serverAuthToken);
}
}
@@ -7,12 +7,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IChannelRepository
{
public Task<Channel> Add(Channel channel);
public Task<Option<Channel>> Get(int id);
public Task<Option<Channel>> GetByNumber(int number);
public Task<List<Channel>> GetAll();
public Task<List<Channel>> GetAllForGuide();
public Task Update(Channel channel);
public Task Delete(int channelId);
Task<Channel> Add(Channel channel);
Task<Option<Channel>> Get(int id);
Task<Option<Channel>> GetByNumber(int number);
Task<List<Channel>> GetAll();
Task<List<Channel>> GetAllForGuide();
Task Update(Channel channel);
Task Delete(int channelId);
}
}
@@ -6,10 +6,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IConfigElementRepository
{
public Task<ConfigElement> Add(ConfigElement configElement);
public Task<Option<ConfigElement>> Get(ConfigElementKey key);
public Task<Option<T>> GetValue<T>(ConfigElementKey key);
public Task Update(ConfigElement configElement);
public Task Delete(ConfigElement configElement);
Task<ConfigElement> Add(ConfigElement configElement);
Task<Option<ConfigElement>> Get(ConfigElementKey key);
Task<Option<T>> GetValue<T>(ConfigElementKey key);
Task Update(ConfigElement configElement);
Task Delete(ConfigElement configElement);
}
}
@@ -7,10 +7,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IFFmpegProfileRepository
{
public Task<FFmpegProfile> Add(FFmpegProfile ffmpegProfile);
public Task<Option<FFmpegProfile>> Get(int id);
public Task<List<FFmpegProfile>> GetAll();
public Task Update(FFmpegProfile ffmpegProfile);
public Task Delete(int ffmpegProfileId);
Task<FFmpegProfile> Add(FFmpegProfile ffmpegProfile);
Task<Option<FFmpegProfile>> Get(int id);
Task<List<FFmpegProfile>> GetAll();
Task Update(FFmpegProfile ffmpegProfile);
Task Delete(int ffmpegProfileId);
}
}
@@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ILogRepository
{
public Task<List<LogEntry>> GetRecentLogEntries();
Task<List<LogEntry>> GetRecentLogEntries();
}
}
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Domain;
using LanguageExt;
@@ -8,21 +7,16 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMediaCollectionRepository
{
public Task<SimpleMediaCollection> Add(SimpleMediaCollection collection);
public Task<Option<MediaCollection>> Get(int id);
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollection(int id);
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollectionWithItems(int id);
public Task<Option<TelevisionMediaCollection>> GetTelevisionMediaCollection(int id);
public Task<List<SimpleMediaCollection>> GetSimpleMediaCollections();
public Task<List<MediaCollection>> GetAll();
public Task<List<MediaCollectionSummary>> GetSummaries(string searchString);
public Task<Option<List<MediaItem>>> GetItems(int id);
public Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id);
public Task<Option<List<MediaItem>>> GetTelevisionMediaCollectionItems(int id);
public Task Update(SimpleMediaCollection collection);
public Task<bool> InsertOrIgnore(TelevisionMediaCollection collection);
public Task<Unit> ReplaceItems(int collectionId, List<MediaItem> mediaItems);
public Task Delete(int mediaCollectionId);
public Task DeleteEmptyTelevisionCollections();
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<Option<List<MediaItem>>> GetItems(int id);
Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id);
Task Update(SimpleMediaCollection collection);
Task Delete(int mediaCollectionId);
}
}
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Domain;
using LanguageExt;
@@ -8,14 +7,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMediaItemRepository
{
public Task<int> Add(MediaItem mediaItem);
public Task<Option<MediaItem>> Get(int id);
public Task<List<MediaItem>> GetAll();
public Task<List<MediaItem>> Search(string searchString);
public Task<List<MediaItemSummary>> GetPageByType(MediaType mediaType, int pageNumber, int pageSize);
public Task<int> GetCountByType(MediaType mediaType);
public Task<List<MediaItem>> GetAllByMediaSourceId(int mediaSourceId);
public Task<bool> Update(MediaItem mediaItem);
public Task Delete(int mediaItemId);
Task<Option<MediaItem>> Get(int id);
Task<List<MediaItem>> GetAll();
Task<List<MediaItem>> Search(string searchString);
Task<bool> Update(MediaItem mediaItem);
}
}
@@ -7,14 +7,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMediaSourceRepository
{
public Task<LocalMediaSource> Add(LocalMediaSource localMediaSource);
public Task<PlexMediaSource> Add(PlexMediaSource plexMediaSource);
public Task<List<MediaSource>> GetAll();
public Task<List<PlexMediaSource>> GetAllPlex();
public Task<Option<MediaSource>> Get(int id);
public Task<Option<PlexMediaSource>> GetPlex(int id);
public Task<int> CountMediaItems(int id);
public Task Update(PlexMediaSource plexMediaSource);
public Task Delete(int id);
Task<LocalMediaSource> Add(LocalMediaSource localMediaSource);
Task<PlexMediaSource> Add(PlexMediaSource plexMediaSource);
Task<List<MediaSource>> GetAll();
Task<List<PlexMediaSource>> GetAllPlex();
Task<Option<MediaSource>> Get(int id);
Task<Option<PlexMediaSource>> GetPlex(int id);
Task<int> CountMediaItems(int id);
Task Update(PlexMediaSource plexMediaSource);
Task Delete(int id);
}
}
@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
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<int> GetMovieCount();
Task<List<MovieMediaItem>> GetPagedMovies(int pageNumber, int pageSize);
}
}
@@ -8,14 +8,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IPlayoutRepository
{
public Task<Playout> Add(Playout playout);
public Task<Option<Playout>> Get(int id);
public Task<Option<Playout>> GetFull(int id);
public Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now);
public Task<List<PlayoutItem>> GetPlayoutItems(int playoutId);
public Task<List<int>> GetPlayoutIdsForMediaItems(Seq<MediaItem> mediaItems);
public Task<List<Playout>> GetAll();
public Task Update(Playout playout);
public Task Delete(int playoutId);
Task<Playout> Add(Playout playout);
Task<Option<Playout>> Get(int id);
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,12 +7,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IProgramScheduleRepository
{
public Task<ProgramSchedule> Add(ProgramSchedule programSchedule);
public Task<Option<ProgramSchedule>> Get(int id);
public Task<Option<ProgramSchedule>> GetWithPlayouts(int id);
public Task<List<ProgramSchedule>> GetAll();
public Task Update(ProgramSchedule programSchedule);
public Task Delete(int programScheduleId);
public Task<Option<List<ProgramScheduleItem>>> GetItems(int programScheduleId);
Task<ProgramSchedule> Add(ProgramSchedule programSchedule);
Task<Option<ProgramSchedule>> Get(int id);
Task<Option<ProgramSchedule>> GetWithPlayouts(int id);
Task<List<ProgramSchedule>> GetAll();
Task Update(ProgramSchedule programSchedule);
Task Delete(int programScheduleId);
Task<Option<List<ProgramScheduleItem>>> GetItems(int programScheduleId);
}
}
@@ -7,7 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IResolutionRepository
{
public Task<Option<Resolution>> Get(int id);
public Task<List<Resolution>> GetAll();
Task<Option<Resolution>> Get(int id);
Task<List<Resolution>> GetAll();
}
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
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<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<Unit> DeleteEmptyShows();
}
}
@@ -6,7 +6,7 @@ namespace ErsatzTV.Core.Interfaces.Scheduling
public interface IMediaCollectionEnumerator
{
MediaCollectionEnumeratorState State { get; }
public Option<MediaItem> Current { get; }
public void MoveNext();
Option<MediaItem> Current { get; }
void MoveNext();
}
}
@@ -6,9 +6,9 @@ namespace ErsatzTV.Core.Interfaces.Scheduling
{
public interface IPlayoutBuilder
{
public Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false);
Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false);
public Task<Playout> BuildPlayoutItems(
Task<Playout> BuildPlayoutItems(
Playout playout,
DateTimeOffset playoutStart,
DateTimeOffset playoutFinish,
+41 -19
View File
@@ -57,11 +57,26 @@ namespace ErsatzTV.Core.Iptv
{
string start = playoutItem.Start.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = playoutItem.Finish.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
MediaMetadata metadata = Optional(playoutItem.MediaItem.Metadata).IfNone(
new MediaMetadata
{
Title = Path.GetFileName(playoutItem.MediaItem.Path)
});
string title = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.Title ?? m.Path,
TelevisionEpisodeMediaItem e => e.Metadata?.Title ?? e.Path,
_ => "[unknown]"
};
string description = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.Plot,
TelevisionEpisodeMediaItem e => e.Metadata?.Plot,
_ => string.Empty
};
string contentRating = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.ContentRating,
_ => string.Empty
};
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
@@ -70,7 +85,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(metadata.Title);
xml.WriteString(title);
xml.WriteEndElement(); // title
xml.WriteStartElement("previously-shown");
@@ -80,28 +95,35 @@ namespace ErsatzTV.Core.Iptv
xml.WriteAttributeString("lang", "en");
xml.WriteEndElement(); // sub-title
int season = Optional(metadata.SeasonNumber).IfNone(0);
int episode = Optional(metadata.EpisodeNumber).IfNone(0);
if (season > 0 && episode > 0)
if (playoutItem.MediaItem is TelevisionEpisodeMediaItem episode)
{
xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "xmltv_ns");
xml.WriteString($"{season - 1}.{episode - 1}.0/1");
xml.WriteEndElement(); // episode-num
int s = Optional(episode.Metadata?.Season).IfNone(0);
int e = Optional(episode.Metadata?.Episode).IfNone(0);
if (s > 0 && e > 0)
{
xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "xmltv_ns");
xml.WriteString($"{s - 1}.{e - 1}.0/1");
xml.WriteEndElement(); // episode-num
}
}
// sb.AppendLine("<icon src=\"\"/>");
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(metadata.Description);
xml.WriteEndElement(); // desc
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
if (!string.IsNullOrWhiteSpace(description))
{
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(description);
xml.WriteEndElement(); // desc
}
if (!string.IsNullOrWhiteSpace(contentRating))
{
xml.WriteStartElement("rating");
xml.WriteAttributeString("system", "MPAA");
xml.WriteStartElement("value");
xml.WriteString(metadata.ContentRating);
xml.WriteString(contentRating);
xml.WriteEndElement(); // value
xml.WriteEndElement(); // rating
}
+20
View File
@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core
{
@@ -12,5 +13,24 @@ namespace ErsatzTV.Core
validation.ToEither()
.MapLeft(errors => errors.Join())
.MapAsync<BaseError, Task<TR>, TR>(e => e);
public static Task<T> LogFailure<T>(
this TryAsync<T> tryAsync,
T defaultValue,
ILogger logger,
string message,
params object[] args) =>
tryAsync.IfFail(
ex =>
{
logger.LogError(ex, message, args);
return defaultValue;
});
public static Task<Unit> LogFailure(
this TryAsync<Unit> tryAsync,
ILogger logger,
string message,
params object[] args) => LogFailure(tryAsync, Unit.Default, logger, message, args);
}
}
-4
View File
@@ -1,4 +0,0 @@
namespace ErsatzTV.Core.Metadata
{
public record ActionPlan(string TargetPath, ScanningAction TargetAction);
}
@@ -2,35 +2,58 @@
using System.IO;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Core.Metadata
{
public static class FallbackMetadataProvider
public class FallbackMetadataProvider : IFallbackMetadataProvider
{
public static MediaMetadata GetFallbackMetadata(MediaItem mediaItem)
public TelevisionShowMetadata GetFallbackMetadataForShow(string showFolder)
{
string fileName = Path.GetFileName(showFolder);
var metadata = new TelevisionShowMetadata
{ Source = MetadataSource.Fallback, Title = fileName ?? showFolder };
return GetTelevisionShowMetadata(fileName, metadata);
}
public static TelevisionEpisodeMetadata GetFallbackMetadata(TelevisionEpisodeMediaItem mediaItem)
{
string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new MediaMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
var metadata = new TelevisionEpisodeMetadata
{ Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
if (fileName != null)
{
if (!(mediaItem.Source is LocalMediaSource localMediaSource))
if (!(mediaItem.Source is LocalMediaSource))
{
return metadata;
}
return localMediaSource.MediaType switch
{
MediaType.TvShow => GetTvShowMetadata(fileName, metadata),
MediaType.Movie => GetMovieMetadata(fileName, metadata),
_ => metadata
};
return GetEpisodeMetadata(fileName, metadata);
}
return metadata;
}
private static MediaMetadata GetTvShowMetadata(string fileName, MediaMetadata metadata)
public static MovieMetadata GetFallbackMetadata(MovieMediaItem mediaItem)
{
string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new MovieMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
if (fileName != null)
{
if (!(mediaItem.Source is LocalMediaSource))
{
return metadata;
}
return GetMovieMetadata(fileName, metadata);
}
return metadata;
}
private static TelevisionEpisodeMetadata GetEpisodeMetadata(string fileName, TelevisionEpisodeMetadata metadata)
{
try
{
@@ -38,10 +61,9 @@ namespace ErsatzTV.Core.Metadata
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.MediaType = MediaType.TvShow;
metadata.Title = match.Groups[1].Value;
metadata.SeasonNumber = int.Parse(match.Groups[2].Value);
metadata.EpisodeNumber = int.Parse(match.Groups[3].Value);
metadata.Season = int.Parse(match.Groups[2].Value);
metadata.Episode = int.Parse(match.Groups[3].Value);
}
}
catch (Exception)
@@ -52,7 +74,7 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private static MediaMetadata GetMovieMetadata(string fileName, MediaMetadata metadata)
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
{
try
{
@@ -60,9 +82,30 @@ namespace ErsatzTV.Core.Metadata
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.MediaType = MediaType.Movie;
metadata.Title = match.Groups[1].Value;
metadata.Aired = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.Year = int.Parse(match.Groups[2].Value);
}
}
catch (Exception)
{
// ignored
}
return metadata;
}
private static TelevisionShowMetadata GetTelevisionShowMetadata(
string fileName,
TelevisionShowMetadata metadata)
{
try
{
const string PATTERN = @"^(.*?)[\s.]+?[.\(](\d{4})[.\)].*$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.Title = match.Groups[1].Value;
metadata.Year = int.Parse(match.Groups[2].Value);
}
}
catch (Exception)
+8 -47
View File
@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
@@ -16,52 +16,13 @@ namespace ErsatzTV.Core.Metadata
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) =>
Directory.Exists(localMediaSource.Folder);
public Seq<string> FindRelevantVideos(LocalMediaSource localMediaSource)
{
Seq<string> allDirectories = Directory
.GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories)
.ToSeq()
.Add(localMediaSource.Folder);
public IEnumerable<string> ListSubdirectories(string folder) =>
Try(Directory.EnumerateDirectories(folder)).IfFail(new List<string>());
// remove any directories with an .etvignore file locally, or in any parent directory
Seq<string> excluded = allDirectories.Filter(ShouldExcludeDirectory);
Seq<string> relevantDirectories = allDirectories
.Filter(d => !excluded.Any(d.StartsWith))
.Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d));
public IEnumerable<string> ListFiles(string folder) =>
Try(Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly)).IfFail(new List<string>());
return relevantDirectories
.Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly))
.Filter(file => KnownExtensions.Contains(Path.GetExtension(file)))
.OrderBy(identity)
.ToSeq();
}
public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem)
{
DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path);
bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue);
return modified // media item has been modified
|| mediaItem.Metadata == null // media item has no metadata
|| mediaItem.Metadata.MediaType != localMediaSource.MediaType; // media item is typed incorrectly
}
public bool ShouldRefreshPoster(MediaItem mediaItem) =>
string.IsNullOrWhiteSpace(mediaItem.Poster);
private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore"));
// see https://support.emby.media/support/solutions/articles/44001159102-movie-naming
private static bool IsExtrasFolder(string path) =>
ExtraFolderNames.Contains(Path.GetFileName(path)?.ToLowerInvariant());
// @formatter:off
private static readonly Seq<string> KnownExtensions = Seq(
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts");
private static readonly Seq<string> ExtraFolderNames = Seq(
"extras", "specials", "shorts", "scenes", "featurettes",
"behind the scenes", "deleted scenes", "interviews", "trailers");
// @formatter:on
public bool FileExists(string path) => File.Exists(path);
public Task<byte[]> ReadAllBytes(string path) => File.ReadAllBytesAsync(path);
}
}
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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;
namespace ErsatzTV.Core.Metadata
{
public abstract class LocalFolderScanner
{
public static readonly List<string> VideoFileExtensions = new()
{
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"
};
public static readonly List<string> ImageFileExtensions = new()
{
"jpg", "jpeg", "png", "gif", "tbn"
};
public static readonly List<string> ExtraFiles = new()
{
"behindthescenes", "deleted", "featurette",
"interview", "scene", "short", "trailer", "other"
};
public static readonly List<string> ExtraDirectories = new List<string>
{
"behind the scenes", "deleted scenes", "featurettes",
"interviews", "scenes", "shorts", "trailers", "other",
"extras", "specials"
}
.Map(s => $"{Path.DirectorySeparatorChar}{s}{Path.DirectorySeparatorChar}")
.ToList();
private readonly IImageCache _imageCache;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger _logger;
protected LocalFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
IImageCache imageCache,
ILogger logger)
{
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_imageCache = imageCache;
_logger = logger;
}
protected async Task<Either<BaseError, T>> UpdateStatistics<T>(T mediaItem, string ffprobePath)
where T : MediaItem
{
try
{
if (mediaItem.Statistics is null ||
(mediaItem.Statistics.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(mediaItem.Path))
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", mediaItem.Path);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
}
return mediaItem;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
protected async Task SavePosterToDisk<T>(
T show,
string posterPath,
Func<T, Task<bool>> update,
int height = 220) where T : IHasAPoster
{
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;
});
}
protected Task<Either<BaseError, string>> SavePosterToDisk(string posterPath, int height = 220) =>
_localFileSystem.ReadAllBytes(posterPath)
.Bind(bytes => _imageCache.ResizeAndSaveImage(bytes, height, null));
}
}
-289
View File
@@ -1,289 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Seq = LanguageExt.Seq;
namespace ErsatzTV.Core.Metadata
{
public class LocalMediaScanner : ILocalMediaScanner
{
private readonly IImageCache _imageCache;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMediaSourcePlanner _localMediaSourcePlanner;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMediaScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IPlayoutBuilder _playoutBuilder;
private readonly IPlayoutRepository _playoutRepository;
private readonly ISmartCollectionBuilder _smartCollectionBuilder;
public LocalMediaScanner(
IMediaItemRepository mediaItemRepository,
IPlayoutRepository playoutRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
ISmartCollectionBuilder smartCollectionBuilder,
IPlayoutBuilder playoutBuilder,
ILocalMediaSourcePlanner localMediaSourcePlanner,
ILocalFileSystem localFileSystem,
IImageCache imageCache,
ILogger<LocalMediaScanner> logger)
{
_mediaItemRepository = mediaItemRepository;
_playoutRepository = playoutRepository;
_localStatisticsProvider = localStatisticsProvider;
_localMetadataProvider = localMetadataProvider;
_smartCollectionBuilder = smartCollectionBuilder;
_playoutBuilder = playoutBuilder;
_localMediaSourcePlanner = localMediaSourcePlanner;
_localFileSystem = localFileSystem;
_imageCache = imageCache;
_logger = logger;
}
public async Task<Unit> ScanLocalMediaSource(
LocalMediaSource localMediaSource,
string ffprobePath,
ScanningMode scanningMode)
{
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
{
_logger.LogWarning(
"Media source folder {Folder} does not exist or is inaccessible; skipping scan",
localMediaSource.Folder);
return unit;
}
List<MediaItem> knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id);
var modifiedPlayoutIds = new List<int>();
Seq<LocalMediaSourcePlan> actions = _localMediaSourcePlanner.DetermineActions(
localMediaSource.MediaType,
knownMediaItems.ToSeq(),
FindAllFiles(localMediaSource));
foreach (LocalMediaSourcePlan action in actions)
{
Option<ActionPlan> maybeAddPlan =
action.ActionPlans.SingleOrDefault(plan => plan.TargetAction == ScanningAction.Add);
await maybeAddPlan.IfSomeAsync(
async plan =>
{
Option<MediaItem> maybeMediaItem = await AddMediaItem(localMediaSource, plan.TargetPath);
// any actions other than "add" need to operate on a media item
maybeMediaItem.IfSome(mediaItem => action.Source = mediaItem);
});
foreach (ActionPlan plan in action.ActionPlans.OrderBy(plan => (int) plan.TargetAction))
{
string sourcePath = action.Source.Match(
mediaItem => mediaItem.Path,
path => path);
_logger.LogDebug(
"{Source}: {Action} with {File}",
Path.GetFileName(sourcePath),
plan.TargetAction,
Path.GetRelativePath(Path.GetDirectoryName(sourcePath) ?? string.Empty, plan.TargetPath));
await action.Source.Match(
async mediaItem =>
{
var changed = false;
switch (plan.TargetAction)
{
case ScanningAction.Remove:
await RemoveMissingItem(mediaItem);
break;
case ScanningAction.Poster:
await SavePosterForItem(mediaItem, plan.TargetPath);
break;
case ScanningAction.FallbackMetadata:
await RefreshFallbackMetadataForItem(mediaItem);
break;
case ScanningAction.SidecarMetadata:
await RefreshSidecarMetadataForItem(mediaItem, plan.TargetPath);
break;
case ScanningAction.Statistics:
changed = await RefreshStatisticsForItem(mediaItem, ffprobePath);
break;
case ScanningAction.Collections:
changed = await RefreshCollectionsForItem(mediaItem);
break;
}
if (changed)
{
List<int> ids =
await _playoutRepository.GetPlayoutIdsForMediaItems(Seq.create(mediaItem));
modifiedPlayoutIds.AddRange(ids);
}
},
path =>
{
_logger.LogError("This is a bug, something went wrong processing {Path}", path);
return Task.CompletedTask;
});
}
}
foreach (int playoutId in modifiedPlayoutIds.Distinct())
{
Option<Playout> maybePlayout = await _playoutRepository.GetFull(playoutId);
await maybePlayout.Match(
async playout =>
{
Playout result = await _playoutBuilder.BuildPlayoutItems(playout, true);
await _playoutRepository.Update(result);
},
Task.CompletedTask);
}
return unit;
}
private Seq<string> FindAllFiles(LocalMediaSource localMediaSource)
{
Seq<string> allDirectories = Directory
.GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories)
.ToSeq()
.Add(localMediaSource.Folder);
// remove any directories with an .etvignore file locally, or in any parent directory
Seq<string> excluded = allDirectories.Filter(path => File.Exists(Path.Combine(path, ".etvignore")));
Seq<string> relevantDirectories = allDirectories
.Filter(d => !excluded.Any(d.StartsWith));
// .Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d));
return relevantDirectories
.Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly))
.OrderBy(identity)
.ToSeq();
}
private async Task<Option<MediaItem>> AddMediaItem(MediaSource mediaSource, string path)
{
try
{
var mediaItem = new MediaItem
{
MediaSourceId = mediaSource.Id,
Path = path,
LastWriteTime = File.GetLastWriteTimeUtc(path)
};
await _mediaItemRepository.Add(mediaItem);
return mediaItem;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add media item for {Path}", path);
return None;
}
}
private async Task RemoveMissingItem(MediaItem mediaItem)
{
try
{
await _mediaItemRepository.Delete(mediaItem.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove missing local media item {MediaItem}", mediaItem.Path);
}
}
private async Task SavePosterForItem(MediaItem mediaItem, string posterPath)
{
try
{
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
await maybeHash.Match(
hash =>
{
mediaItem.Poster = hash;
mediaItem.PosterLastWriteTime = File.GetLastWriteTimeUtc(posterPath);
return _mediaItemRepository.Update(mediaItem);
},
error =>
{
_logger.LogWarning(
"Unable to save poster to disk from {Path}: {Error}",
posterPath,
error.Value);
return Task.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh poster for media item {MediaItem}", mediaItem.Path);
}
}
private async Task<bool> RefreshStatisticsForItem(MediaItem mediaItem, string ffprobePath)
{
try
{
return await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh statistics for media item {MediaItem}", mediaItem.Path);
return false;
}
}
private async Task<bool> RefreshCollectionsForItem(MediaItem mediaItem)
{
try
{
return await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh collections for media item {MediaItem}", mediaItem.Path);
return false;
}
}
private async Task RefreshSidecarMetadataForItem(MediaItem mediaItem, string path)
{
try
{
await _localMetadataProvider.RefreshSidecarMetadata(mediaItem, path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh nfo metadata for media item {MediaItem}", mediaItem.Path);
}
}
private async Task RefreshFallbackMetadataForItem(MediaItem mediaItem)
{
try
{
await _localMetadataProvider.RefreshFallbackMetadata(mediaItem);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh fallback metadata for media item {MediaItem}", mediaItem.Path);
}
}
}
}
@@ -1,11 +0,0 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Metadata
{
public record LocalMediaSourcePlan(Either<string, MediaItem> Source, List<ActionPlan> ActionPlans)
{
public Either<string, MediaItem> Source { get; set; } = Source;
}
}
@@ -1,179 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
// TODO: this needs a better name
public class LocalMediaSourcePlanner : ILocalMediaSourcePlanner
{
private static readonly Seq<string> ImageFileExtensions = Seq("jpg", "jpeg", "png", "gif", "tbn");
private readonly ILocalFileSystem _localFileSystem;
public LocalMediaSourcePlanner(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
public Seq<LocalMediaSourcePlan> DetermineActions(
MediaType mediaType,
Seq<MediaItem> mediaItems,
Seq<string> files)
{
var results = new IntermediateResults();
Seq<string> videoFiles = files.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => !IsExtra(f));
(Seq<string> newFiles, Seq<MediaItem> existingMediaItems) = videoFiles.Map(
s => mediaItems.Find(i => i.Path == s).ToEither(s))
.Partition();
// new files
foreach (string file in newFiles)
{
results.Add(file, new ActionPlan(file, ScanningAction.Add));
results.Add(file, new ActionPlan(file, ScanningAction.Statistics));
Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, file);
maybeNfoFile.BiIter(
nfoFile =>
{
results.Add(file, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata));
results.Add(file, new ActionPlan(nfoFile, ScanningAction.Collections));
},
() =>
{
results.Add(file, new ActionPlan(file, ScanningAction.FallbackMetadata));
results.Add(file, new ActionPlan(file, ScanningAction.Collections));
});
Option<string> maybePoster = LocatePoster(mediaType, files, file);
maybePoster.IfSome(
posterFile => results.Add(file, new ActionPlan(posterFile, ScanningAction.Poster)));
}
// existing media items
foreach (MediaItem mediaItem in existingMediaItems)
{
if ((mediaItem.LastWriteTime ?? DateTime.MinValue) < _localFileSystem.GetLastWriteTime(mediaItem.Path))
{
results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Statistics));
}
Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, mediaItem.Path);
maybeNfoFile.IfSome(
nfoFile =>
{
if (mediaItem.Metadata == null || mediaItem.Metadata.Source == MetadataSource.Fallback ||
(mediaItem.Metadata.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(nfoFile))
{
results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata));
results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.Collections));
}
});
Option<string> maybePoster = LocatePoster(mediaType, files, mediaItem.Path);
maybePoster.IfSome(
posterFile =>
{
if (string.IsNullOrWhiteSpace(mediaItem.Poster) ||
(mediaItem.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
{
results.Add(mediaItem, new ActionPlan(posterFile, ScanningAction.Poster));
}
});
}
// missing media items
foreach (MediaItem mediaItem in mediaItems.Where(i => !files.Contains(i.Path)))
{
results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Remove));
}
return results.Summarize();
}
private static bool IsExtra(string path)
{
string folder = Path.GetFileName(Path.GetDirectoryName(path) ?? string.Empty);
string file = Path.GetFileNameWithoutExtension(path);
return ExtraDirectories.Contains(folder, StringComparer.OrdinalIgnoreCase)
|| ExtraFiles.Any(f => file.EndsWith(f, StringComparison.OrdinalIgnoreCase));
}
private static Option<string> LocateNfoFile(MediaType mediaType, Seq<string> files, string file)
{
switch (mediaType)
{
case MediaType.Movie:
string movieAsNfo = Path.ChangeExtension(file, "nfo");
string movieNfo = Path.Combine(Path.GetDirectoryName(file) ?? string.Empty, "movie.nfo");
return Seq(movieAsNfo, movieNfo)
.Filter(s => files.Contains(s))
.HeadOrNone();
case MediaType.TvShow:
string episodeAsNfo = Path.ChangeExtension(file, "nfo");
return Optional(episodeAsNfo)
.Filter(s => files.Contains(s))
.HeadOrNone();
}
return None;
}
private static Option<string> LocatePoster(MediaType mediaType, Seq<string> files, string file)
{
string folder = Path.GetDirectoryName(file) ?? string.Empty;
switch (mediaType)
{
case MediaType.Movie:
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(file) + $"-poster.{ext}" })
.Map(f => Path.Combine(folder, f));
return possibleMoviePosters.Filter(p => files.Contains(p)).HeadOrNone();
case MediaType.TvShow:
string parentFolder = Directory.GetParent(folder)?.FullName ?? string.Empty;
IEnumerable<string> possibleTvPosters = ImageFileExtensions
.Collect(ext => new[] { $"poster.{ext}" })
.Map(f => Path.Combine(parentFolder, f));
return possibleTvPosters.Filter(p => files.Contains(p)).HeadOrNone();
}
return None;
}
private class IntermediateResults
{
private readonly List<Tuple<Either<string, MediaItem>, ActionPlan>> _rawResults = new();
public void Add(Either<string, MediaItem> source, ActionPlan plan) =>
_rawResults.Add(Tuple(source, plan));
public Seq<LocalMediaSourcePlan> Summarize() =>
_rawResults
.GroupBy(t => t.Item1)
.Select(g => new LocalMediaSourcePlan(g.Key, g.Select(g2 => g2.Item2).ToList()))
.ToSeq();
}
// @formatter:off
private static readonly Seq<string> VideoFileExtensions = Seq(
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts");
private static readonly Seq<string> ExtraDirectories = Seq(
"behind the scenes", "deleted scenes", "featurettes",
"interviews", "scenes", "shorts", "trailers", "other",
"extras", "specials");
private static readonly Seq<string> ExtraFiles = Seq(
"behindthescenes", "deleted", "featurette",
"interview", "scene", "short", "trailer", "other");
// @formatter:on
}
}
+208 -62
View File
@@ -14,64 +14,117 @@ namespace ErsatzTV.Core.Metadata
public class LocalMetadataProvider : ILocalMetadataProvider
{
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider(IMediaItemRepository mediaItemRepository, ILogger<LocalMetadataProvider> logger)
public LocalMetadataProvider(
IMediaItemRepository mediaItemRepository,
ITelevisionRepository televisionRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
ILogger<LocalMetadataProvider> logger)
{
_mediaItemRepository = mediaItemRepository;
_televisionRepository = televisionRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_logger = logger;
}
public async Task RefreshSidecarMetadata(MediaItem mediaItem, string path)
public Task<TelevisionShowMetadata> GetMetadataForShow(string showFolder)
{
Option<MediaMetadata> maybeMetadata = await LoadMetadata(mediaItem, path);
await maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(mediaItem, metadata));
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;
});
}
public Task RefreshFallbackMetadata(MediaItem mediaItem) =>
ApplyMetadataUpdate(mediaItem, FallbackMetadataProvider.GetFallbackMetadata(mediaItem));
private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata)
{
if (mediaItem.Metadata == null)
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
mediaItem switch
{
mediaItem.Metadata = new MediaMetadata();
}
TelevisionEpisodeMediaItem e => LoadMetadata(e, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))),
MovieMediaItem m => LoadMetadata(m, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
_ => Task.FromResult(Unit.Default)
};
public Task<Unit> RefreshSidecarMetadata(TelevisionShow 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))
.ToUnit(),
MovieMediaItem m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
_ => Task.FromResult(Unit.Default)
};
public Task<Unit> RefreshFallbackMetadata(TelevisionShow televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder))
.ToUnit();
private async Task ApplyMetadataUpdate(TelevisionEpisodeMediaItem mediaItem, TelevisionEpisodeMetadata metadata)
{
mediaItem.Metadata ??= new TelevisionEpisodeMetadata { TelevisionEpisodeId = mediaItem.Id };
mediaItem.Metadata.Source = metadata.Source;
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
mediaItem.Metadata.MediaType = metadata.MediaType;
mediaItem.Metadata.Title = metadata.Title;
mediaItem.Metadata.Subtitle = metadata.Subtitle;
mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title ?? string.Empty);
mediaItem.Metadata.Description = metadata.Description;
mediaItem.Metadata.EpisodeNumber = metadata.EpisodeNumber;
mediaItem.Metadata.SeasonNumber = metadata.SeasonNumber;
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;
await _televisionRepository.Update(mediaItem);
}
private async Task ApplyMetadataUpdate(MovieMediaItem mediaItem, 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;
await _mediaItemRepository.Update(mediaItem);
}
private static string GetSortTitle(string title)
private async Task ApplyMetadataUpdate(TelevisionShow televisionShow, TelevisionShowMetadata metadata)
{
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(4);
}
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);
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
}
return title;
await _televisionRepository.Update(televisionShow);
}
private async Task<Option<MediaMetadata>> LoadMetadata(MediaItem mediaItem, string nfoFileName)
private async Task<Option<MovieMetadata>> LoadMetadata(MovieMediaItem mediaItem, string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
@@ -79,71 +132,122 @@ namespace ErsatzTV.Core.Metadata
return None;
}
if (!(mediaItem.Source is LocalMediaSource localMediaSource))
if (!(mediaItem.Source is LocalMediaSource))
{
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
return None;
}
return localMediaSource.MediaType switch
{
MediaType.Movie => await LoadMovieMetadata(nfoFileName),
MediaType.TvShow => await LoadTvShowMetadata(nfoFileName),
_ => None
};
return await LoadMovieMetadata(mediaItem, nfoFileName);
}
private async Task<Option<MediaMetadata>> LoadTvShowMetadata(string nfoFileName)
private async Task<Option<TelevisionEpisodeMetadata>> LoadMetadata(
TelevisionEpisodeMediaItem mediaItem,
string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
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)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
return None;
}
return await LoadTelevisionShowMetadata(nfoFileName);
}
private async Task<Option<TelevisionShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
return maybeNfo.Match<Option<MediaMetadata>>(
nfo => new MediaMetadata
Option<TvShowNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo;
return maybeNfo.Match<Option<TelevisionShowMetadata>>(
nfo => new TelevisionShowMetadata
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
MediaType = MediaType.TvShow,
Title = nfo.ShowTitle,
Subtitle = nfo.Title,
Description = nfo.Outline,
EpisodeNumber = nfo.Episode,
SeasonNumber = nfo.Season,
Aired = GetAired(nfo.Aired)
Title = nfo.Title,
Plot = nfo.Plot,
Year = nfo.Year
},
None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read TV nfo metadata from {Path}", nfoFileName);
_logger.LogDebug(ex, "Failed to read TV show nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<Option<MediaMetadata>> LoadMovieMetadata(string nfoFileName)
private async Task<Option<TelevisionEpisodeMetadata>> LoadEpisodeMetadata(TelevisionEpisodeMediaItem mediaItem, 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
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
Aired = GetAired(nfo.Aired),
Episode = nfo.Episode,
Season = nfo.Season,
Plot = nfo.Plot
},
None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName);
return FallbackMetadataProvider.GetFallbackMetadata(mediaItem);
}
}
private async Task<Option<MovieMetadata>> LoadMovieMetadata(MovieMediaItem mediaItem, string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<MovieNfo> maybeNfo = MovieSerializer.Deserialize(fileStream) as MovieNfo;
return maybeNfo.Match<Option<MediaMetadata>>(
nfo => new MediaMetadata
return maybeNfo.Match<Option<MovieMetadata>>(
nfo => new MovieMetadata
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
MediaType = MediaType.Movie,
Title = nfo.Title,
Description = nfo.Outline,
ContentRating = nfo.ContentRating,
Aired = GetAired(nfo.Premiered)
Year = nfo.Year,
Premiered = nfo.Premiered,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
ContentRating = nfo.ContentRating
},
None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName);
return None;
return FallbackMetadataProvider.GetFallbackMetadata(mediaItem);
}
}
@@ -162,6 +266,26 @@ namespace ErsatzTV.Core.Metadata
return null;
}
private static string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return title;
}
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(4);
}
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
}
return title;
}
[XmlRoot("movie")]
public class MovieNfo
{
@@ -171,11 +295,33 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("mpaa")]
public string ContentRating { get; set; }
[XmlElement("premiered")]
public string Premiered { get; set; }
public DateTime Premiered { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("tagline")]
public string Tagline { get; set; }
}
[XmlRoot("tvshow")]
public class TvShowNfo
{
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
}
[XmlRoot("episodedetails")]
@@ -187,9 +333,6 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("episode")]
public int Episode { get; set; }
@@ -201,6 +344,9 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("aired")]
public string Aired { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
}
}
}
@@ -1,93 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class LocalPosterProvider : ILocalPosterProvider
{
private static readonly string[] SupportedExtensions = { "jpg", "jpeg", "png", "gif", "tbn" };
private readonly IImageCache _imageCache;
private readonly ILogger<LocalPosterProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
public LocalPosterProvider(
IMediaItemRepository mediaItemRepository,
IImageCache imageCache,
ILogger<LocalPosterProvider> logger)
{
_mediaItemRepository = mediaItemRepository;
_imageCache = imageCache;
_logger = logger;
}
public async Task RefreshPoster(MediaItem mediaItem)
{
Option<string> maybePosterPath = mediaItem.Metadata.MediaType switch
{
MediaType.Movie => RefreshMoviePoster(mediaItem),
MediaType.TvShow => RefreshTelevisionPoster(mediaItem),
_ => None
};
await maybePosterPath.Match(
path => SavePosterToDisk(mediaItem, path),
Task.CompletedTask);
}
private static Option<string> RefreshMoviePoster(MediaItem mediaItem)
{
string folder = Path.GetDirectoryName(mediaItem.Path);
if (folder != null)
{
IEnumerable<string> possiblePaths = SupportedExtensions.Collect(
e => new[] { $"poster.{e}", Path.GetFileNameWithoutExtension(mediaItem.Path) + $"-poster.{e}" });
Option<string> maybePoster =
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
return maybePoster;
}
return None;
}
private Option<string> RefreshTelevisionPoster(MediaItem mediaItem)
{
string folder = Directory.GetParent(Path.GetDirectoryName(mediaItem.Path) ?? string.Empty)?.FullName;
if (folder != null)
{
IEnumerable<string> possiblePaths = SupportedExtensions.Collect(e => new[] { $"poster.{e}" });
Option<string> maybePoster =
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
return maybePoster;
}
return None;
}
public async Task SavePosterToDisk(MediaItem mediaItem, string posterPath)
{
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
await maybeHash.Match(
hash =>
{
mediaItem.Poster = hash;
return _mediaItemRepository.Update(mediaItem);
},
error =>
{
_logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value);
return Task.CompletedTask;
});
}
}
}
@@ -15,14 +15,17 @@ namespace ErsatzTV.Core.Metadata
{
public class LocalStatisticsProvider : ILocalStatisticsProvider
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalStatisticsProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
public LocalStatisticsProvider(
IMediaItemRepository mediaItemRepository,
ILocalFileSystem localFileSystem,
ILogger<LocalStatisticsProvider> logger)
{
_mediaItemRepository = mediaItemRepository;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -31,8 +34,8 @@ namespace ErsatzTV.Core.Metadata
try
{
FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem);
MediaMetadata metadata = ProjectToMediaMetadata(ffprobe);
return await ApplyStatisticsUpdate(mediaItem, metadata);
MediaItemStatistics statistics = ProjectToMediaItemStatistics(ffprobe);
return await ApplyStatisticsUpdate(mediaItem, statistics);
}
catch (Exception ex)
{
@@ -43,23 +46,24 @@ namespace ErsatzTV.Core.Metadata
private async Task<bool> ApplyStatisticsUpdate(
MediaItem mediaItem,
MediaMetadata metadata)
MediaItemStatistics statistics)
{
if (mediaItem.Metadata == null)
if (mediaItem.Statistics == null)
{
mediaItem.Metadata = new MediaMetadata();
mediaItem.Statistics = new MediaItemStatistics();
}
bool durationChange = mediaItem.Metadata.Duration != metadata.Duration;
bool durationChange = mediaItem.Statistics.Duration != statistics.Duration;
mediaItem.Metadata.Duration = metadata.Duration;
mediaItem.Metadata.AudioCodec = metadata.AudioCodec;
mediaItem.Metadata.SampleAspectRatio = metadata.SampleAspectRatio;
mediaItem.Metadata.DisplayAspectRatio = metadata.DisplayAspectRatio;
mediaItem.Metadata.Width = metadata.Width;
mediaItem.Metadata.Height = metadata.Height;
mediaItem.Metadata.VideoCodec = metadata.VideoCodec;
mediaItem.Metadata.VideoScanType = metadata.VideoScanType;
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;
return await _mediaItemRepository.Update(mediaItem) && durationChange;
}
@@ -97,7 +101,7 @@ namespace ErsatzTV.Core.Metadata
});
}
private MediaMetadata ProjectToMediaMetadata(FFprobe probeOutput) =>
private MediaItemStatistics ProjectToMediaItemStatistics(FFprobe probeOutput) =>
Optional(probeOutput)
.Filter(json => json?.format != null && json.streams != null)
.ToValidation<BaseError>("Unable to parse ffprobe output")
@@ -107,12 +111,12 @@ namespace ErsatzTV.Core.Metadata
{
var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration));
var metadata = new MediaMetadata { Duration = duration };
var statistics = new MediaItemStatistics { Duration = duration };
FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio");
if (audioStream != null)
{
metadata = metadata with
statistics = statistics with
{
AudioCodec = audioStream.codec_name
};
@@ -121,7 +125,7 @@ namespace ErsatzTV.Core.Metadata
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
if (videoStream != null)
{
metadata = metadata with
statistics = statistics with
{
SampleAspectRatio = videoStream.sample_aspect_ratio,
DisplayAspectRatio = videoStream.display_aspect_ratio,
@@ -132,9 +136,9 @@ namespace ErsatzTV.Core.Metadata
};
}
return metadata;
return statistics;
},
_ => new MediaMetadata());
_ => new MediaItemStatistics());
private VideoScanType ScanTypeFromFieldOrder(string fieldOrder) =>
fieldOrder?.ToLowerInvariant() switch
@@ -0,0 +1,167 @@
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;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Seq = LanguageExt.Seq;
namespace ErsatzTV.Core.Metadata
{
public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MovieFolderScanner> _logger;
private readonly IMovieRepository _movieRepository;
public MovieFolderScanner(
ILocalFileSystem localFileSystem,
IMovieRepository movieRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IImageCache imageCache,
ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, imageCache, logger)
{
_localFileSystem = localFileSystem;
_movieRepository = movieRepository;
_localMetadataProvider = localMetadataProvider;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
{
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
{
return new MediaSourceInaccessible();
}
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(localMediaSource.Folder).OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{
string movieFolder = folderQueue.Dequeue();
var allFiles = _localFileSystem.ListFiles(movieFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(
f => !ExtraFiles.Any(
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (allFiles.Count == 0)
{
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
continue;
}
foreach (string file in allFiles.OrderBy(identity))
{
// TODO: figure out how to rebuild playlists
Either<BaseError, MovieMediaItem> x = await _movieRepository.GetOrAdd(localMediaSource.Id, file);
Either<BaseError, MovieMediaItem> maybeMovie = await x.AsTask()
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
.BindT(UpdateMetadata)
.BindT(UpdatePoster);
maybeMovie.IfLeft(
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
}
}
return Unit.Default;
}
private async Task<Either<BaseError, MovieMediaItem>> UpdateMetadata(MovieMediaItem 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))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile);
}
},
async () =>
{
if (movie.Metadata == null)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", movie.Path);
await _localMetadataProvider.RefreshFallbackMetadata(movie);
}
});
return movie;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, MovieMediaItem>> UpdatePoster(MovieMediaItem movie)
{
try
{
await LocatePoster(movie).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(movie.Poster) ||
(movie.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
await SavePosterToDisk(movie, posterFile, _movieRepository.Update, 440);
}
});
return movie;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private Option<string> LocateNfoFile(MovieMediaItem movie)
{
string movieAsNfo = Path.ChangeExtension(movie.Path, "nfo");
string movieNfo = Path.Combine(Path.GetDirectoryName(movie.Path) ?? string.Empty, "movie.nfo");
return Seq.create(movieAsNfo, movieNfo)
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocatePoster(MovieMediaItem movie)
{
string folder = Path.GetDirectoryName(movie.Path) ?? string.Empty;
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(movie.Path) + $"-poster.{ext}" })
.Map(f => Path.Combine(folder, f));
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
return result;
}
}
}
-14
View File
@@ -1,14 +0,0 @@
namespace ErsatzTV.Core.Metadata
{
public enum ScanningAction
{
None = 0,
Add = 1,
Remove = 2,
Statistics = 3,
SidecarMetadata = 4,
FallbackMetadata = 5,
Collections = 6,
Poster = 7
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace ErsatzTV.Core.Metadata
{
public enum ScanningMode
{
Default = 0,
RescanAll = 1
}
}
@@ -1,65 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class SmartCollectionBuilder : ISmartCollectionBuilder
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<bool> RefreshSmartCollections(MediaItem mediaItem)
{
var results = new List<bool>();
foreach (TelevisionMediaCollection collection in GetTelevisionCollections(mediaItem))
{
results.Add(await _mediaCollectionRepository.InsertOrIgnore(collection));
}
return results.Any(identity);
}
private IEnumerable<TelevisionMediaCollection> GetTelevisionCollections(MediaItem mediaItem)
{
IList<MediaItem> televisionMediaItems = new[] { mediaItem }
.Where(c => c.Metadata.MediaType == MediaType.TvShow)
.ToList();
IEnumerable<TelevisionMediaCollection> televisionShowCollections = televisionMediaItems
.Map(c => c.Metadata.Title)
.Distinct().Map(
t => new TelevisionMediaCollection
{
Name = $"{t} - All Seasons",
ShowTitle = t,
SeasonNumber = null
});
IEnumerable<TelevisionMediaCollection> televisionShowSeasonCollections = televisionMediaItems
.Map(c => new { c.Metadata.Title, c.Metadata.SeasonNumber }).Distinct()
.Map(
ts =>
{
return Optional(ts.SeasonNumber).Map(
sn => new TelevisionMediaCollection
{
Name = $"{ts.Title} - Season {sn:00}",
ShowTitle = ts.Title,
SeasonNumber = sn
});
})
.Sequence().Flatten();
return Seq(televisionShowCollections, televisionShowSeasonCollections).Flatten();
}
}
}
@@ -0,0 +1,361 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly ITelevisionRepository _televisionRepository;
public TelevisionFolderScanner(
ILocalFileSystem localFileSystem,
ITelevisionRepository televisionRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IImageCache imageCache,
ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
imageCache,
logger)
{
_localFileSystem = localFileSystem;
_televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider;
_logger = logger;
}
public async Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
{
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
{
_logger.LogWarning(
"Media source is not accessible or missing; skipping scan of {Folder}",
localMediaSource.Folder);
return Unit.Default;
}
var allShowFolders = _localFileSystem.ListSubdirectories(localMediaSource.Folder)
.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)
.BindT(show => UpdateMetadataForShow(show, showFolder))
.BindT(show => UpdatePosterForShow(show, showFolder));
await maybeShow.Match(
show => ScanSeasons(localMediaSource, 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,
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));
});
}
private async Task<Unit> ScanSeasons(
LocalMediaSource localMediaSource,
string ffprobePath,
TelevisionShow show,
string showPath)
{
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showPath).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);
await maybeSeason.Match(
season => ScanEpisodes(localMediaSource, ffprobePath, season),
_ => Task.FromResult(Unit.Default));
});
}
return Unit.Default;
}
private async Task<Unit> ScanEpisodes(
LocalMediaSource localMediaSource,
string ffprobePath,
TelevisionSeason season)
{
foreach (string file in _localFileSystem.ListFiles(season.Path)
.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)
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
maybeEpisode.IfLeft(
error => _logger.LogWarning("Error processing episode at {Path}: {Error}", file, error.Value));
}
return Unit.Default;
}
private async Task<Either<BaseError, TelevisionShow>> UpdateMetadataForShow(
TelevisionShow show,
string showFolder)
{
try
{
await LocateNfoFileForShow(showFolder).Match(
async nfoFile =>
{
if (show.Metadata == null || show.Metadata.Source == MetadataSource.Fallback ||
(show.Metadata.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(nfoFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile);
}
},
async () =>
{
if (show.Metadata == null)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder);
await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder);
}
});
return show;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateMetadata(
TelevisionEpisodeMediaItem 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))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(episode, nfoFile);
}
},
async () =>
{
if (episode.Metadata == null)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", episode.Path);
await _localMetadataProvider.RefreshFallbackMetadata(episode);
}
});
return episode;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionShow>> UpdatePosterForShow(
TelevisionShow show,
string showFolder)
{
try
{
await LocatePosterForShow(showFolder).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(show.Poster) ||
(show.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
{
_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;
});
}
});
return show;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionSeason>> UpdatePoster(TelevisionSeason season)
{
try
{
await LocatePoster(season).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(season.Poster) ||
(season.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
await SavePosterToDisk(season, posterFile, _televisionRepository.Update, 440);
}
});
return season;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateThumbnail(
TelevisionEpisodeMediaItem episode)
{
try
{
await LocateThumbnail(episode).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(episode.Poster) ||
(episode.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Thumbnail", posterFile);
await SavePosterToDisk(episode, posterFile, _televisionRepository.Update);
}
});
return episode;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private Option<string> LocateNfoFileForShow(string showFolder) =>
Optional(Path.Combine(showFolder, "tvshow.nfo"))
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateNfoFile(TelevisionEpisodeMediaItem episode) =>
Optional(Path.ChangeExtension(episode.Path, "nfo"))
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocatePosterForShow(string showFolder) =>
ImageFileExtensions
.Map(ext => $"poster.{ext}")
.Map(f => Path.Combine(showFolder, f))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
private Option<string> LocatePoster(TelevisionSeason season)
{
string folder = Path.GetDirectoryName(season.Path) ?? string.Empty;
return ImageFileExtensions
.Map(ext => Path.Combine(folder, $"season{season.Number:00}-poster.{ext}"))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocateThumbnail(TelevisionEpisodeMediaItem episode)
{
string folder = Path.GetDirectoryName(episode.Path) ?? string.Empty;
return ImageFileExtensions
.Map(ext => Path.GetFileNameWithoutExtension(episode.Path) + $"-thumb.{ext}")
.Map(f => Path.Combine(folder, f))
.Filter(f => _localFileSystem.FileExists(f))
.HeadOrNone();
}
private bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
private static Option<int> SeasonNumberForFolder(string folder)
{
if (int.TryParse(folder.Split(" ").Last(), out int seasonNumber))
{
return seasonNumber;
}
if (folder.EndsWith("specials", StringComparison.OrdinalIgnoreCase))
{
return 0;
}
return None;
}
}
}
@@ -16,10 +16,7 @@ namespace ErsatzTV.Core.Scheduling
IEnumerable<MediaItem> mediaItems,
MediaCollectionEnumeratorState state)
{
_sortedMediaItems = mediaItems.OrderBy(c => c.Metadata.Aired ?? DateTime.MaxValue)
.ThenBy(c => c.Metadata.SeasonNumber)
.ThenBy(c => c.Metadata.EpisodeNumber)
.ToList();
_sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalComparer()).ToList();
State = new MediaCollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
@@ -33,5 +30,71 @@ namespace ErsatzTV.Core.Scheduling
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
private class ChronologicalComparer : IComparer<MediaItem>
{
public int Compare(MediaItem x, MediaItem y)
{
if (x == null || y == null)
{
return 0;
}
DateTime date1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Aired ?? DateTime.MaxValue,
MovieMediaItem m => m.Metadata?.Premiered ?? DateTime.MaxValue,
_ => DateTime.MaxValue
};
DateTime date2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Aired ?? DateTime.MaxValue,
MovieMediaItem m => m.Metadata?.Premiered ?? DateTime.MaxValue,
_ => DateTime.MaxValue
};
if (date1 != date2)
{
return date1.CompareTo(date2);
}
int season1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Season ?? int.MaxValue,
_ => int.MaxValue
};
int season2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Season ?? int.MaxValue,
_ => int.MaxValue
};
if (season1 != season2)
{
return season1.CompareTo(season2);
}
int episode1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Episode ?? int.MaxValue,
_ => int.MaxValue
};
int episode2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Episode ?? int.MaxValue,
_ => int.MaxValue
};
if (episode1 != episode2)
{
return episode1.CompareTo(episode2);
}
return x.Id.CompareTo(y.Id);
}
}
}
}
+93 -26
View File
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Map = LanguageExt.Map;
@@ -17,12 +19,15 @@ namespace ErsatzTV.Core.Scheduling
private static readonly Random Random = new();
private readonly ILogger<PlayoutBuilder> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public PlayoutBuilder(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
ILogger<PlayoutBuilder> logger)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_logger = logger;
}
@@ -38,13 +43,31 @@ namespace ErsatzTV.Core.Scheduling
DateTimeOffset playoutFinish,
bool rebuild = false)
{
var collections = playout.ProgramSchedule.Items.Map(i => i.MediaCollection).Distinct().ToList();
var collectionKeys = playout.ProgramSchedule.Items
.Map(CollectionKeyForItem)
.Distinct()
.ToList();
IEnumerable<Tuple<MediaCollection, List<MediaItem>>> tuples = await collections.Map(
async collection =>
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map(
async collectionKey =>
{
Option<List<MediaItem>> maybeItems = await _mediaCollectionRepository.GetItems(collection.Id);
return Tuple(collection, maybeItems.IfNone(new List<MediaItem>()));
switch (collectionKey.CollectionType)
{
case ProgramScheduleItemCollectionType.Collection:
Option<List<MediaItem>> maybeItems =
await _mediaCollectionRepository.GetItems(collectionKey.Id);
return Tuple(collectionKey, maybeItems.IfNone(new List<MediaItem>()));
case ProgramScheduleItemCollectionType.TelevisionShow:
List<TelevisionEpisodeMediaItem> showItems =
await _televisionRepository.GetShowItems(collectionKey.Id);
return Tuple(collectionKey, showItems.Cast<MediaItem>().ToList());
case ProgramScheduleItemCollectionType.TelevisionSeason:
List<TelevisionEpisodeMediaItem> seasonItems =
await _televisionRepository.GetSeasonItems(collectionKey.Id);
return Tuple(collectionKey, seasonItems.Cast<MediaItem>().ToList());
default:
return Tuple(collectionKey, new List<MediaItem>());
}
}).Sequence();
var collectionMediaItems = Map.createRange(tuples);
@@ -56,6 +79,16 @@ namespace ErsatzTV.Core.Scheduling
playout.Channel.Number,
playout.Channel.Name);
Option<CollectionKey> emptyCollection = collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
if (emptyCollection.IsSome)
{
_logger.LogError(
"Unable to rebuild playout; collection {@CollectionKey} has no items!",
emptyCollection.ValueUnsafe());
return playout;
}
playout.Items ??= new List<PlayoutItem>();
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>();
@@ -67,7 +100,7 @@ namespace ErsatzTV.Core.Scheduling
}
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
Map<MediaCollection, IMediaCollectionEnumerator> collectionEnumerators =
Map<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators =
MapExtensions.Map(collectionMediaItems, (c, i) => GetMediaCollectionEnumerator(playout, c, i));
// find start anchor
@@ -100,15 +133,14 @@ namespace ErsatzTV.Core.Scheduling
multipleRemaining.IsSome,
durationFinish.IsSome);
IMediaCollectionEnumerator enumerator = collectionEnumerators[scheduleItem.MediaCollection];
IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)];
enumerator.Current.IfSome(
mediaItem =>
{
_logger.LogDebug(
"Scheduling media item: {ScheduleItemNumber} / {MediaCollectionId} - {MediaCollectionName} / {MediaItemId} - {MediaItemTitle} / {StartTime}",
"Scheduling media item: {ScheduleItemNumber} / {CollectionType} / {MediaItemId} - {MediaItemTitle} / {StartTime}",
scheduleItem.Index,
scheduleItem.MediaCollection.Id,
scheduleItem.MediaCollection.Name,
scheduleItem.CollectionType,
mediaItem.Id,
DisplayTitle(mediaItem),
itemStartTime);
@@ -117,10 +149,10 @@ namespace ErsatzTV.Core.Scheduling
{
MediaItemId = mediaItem.Id,
Start = itemStartTime,
Finish = itemStartTime + mediaItem.Metadata.Duration
Finish = itemStartTime + mediaItem.Statistics.Duration
};
currentTime = itemStartTime + mediaItem.Metadata.Duration;
currentTime = itemStartTime + mediaItem.Statistics.Duration;
enumerator.MoveNext();
playout.Items.Add(playoutItem);
@@ -166,7 +198,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.Metadata.Duration >
currentTime + peekMediaItem.Statistics.Duration >
peekScheduleItemStart;
if (willNotFinishInTime)
{
@@ -189,7 +221,7 @@ namespace ErsatzTV.Core.Scheduling
bool willNotFinishInTime =
currentTime <= durationFinish.IfNone(DateTime.MinValue) &&
currentTime + peekMediaItem.Metadata.Duration >
currentTime + peekMediaItem.Statistics.Duration >
durationFinish.IfNone(DateTime.MinValue);
if (willNotFinishInTime)
{
@@ -285,14 +317,15 @@ namespace ErsatzTV.Core.Scheduling
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors(
Playout playout,
Map<MediaCollection, IMediaCollectionEnumerator> collectionEnumerators)
Map<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
{
var result = new List<PlayoutProgramScheduleAnchor>();
foreach (MediaCollection collection in collectionEnumerators.Keys)
foreach (CollectionKey collectionKey in collectionEnumerators.Keys)
{
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors
.FirstOrDefault(a => a.MediaCollection == collection);
.FirstOrDefault(
a => a.CollectionType == collectionKey.CollectionType && a.CollectionId == collectionKey.Id);
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State)
.ToDictionary(mcs => mcs.Key, mcs => mcs.Head());
@@ -300,7 +333,7 @@ namespace ErsatzTV.Core.Scheduling
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
existing =>
{
existing.EnumeratorState = maybeEnumeratorState[collection];
existing.EnumeratorState = maybeEnumeratorState[collectionKey];
return existing;
},
() => new PlayoutProgramScheduleAnchor
@@ -309,9 +342,9 @@ namespace ErsatzTV.Core.Scheduling
PlayoutId = playout.Id,
ProgramSchedule = playout.ProgramSchedule,
ProgramScheduleId = playout.ProgramScheduleId,
MediaCollection = collection,
MediaCollectionId = collection.Id,
EnumeratorState = maybeEnumeratorState[collection]
CollectionType = collectionKey.CollectionType,
CollectionId = collectionKey.Id,
EnumeratorState = maybeEnumeratorState[collectionKey]
});
result.Add(scheduleAnchor);
@@ -322,12 +355,14 @@ namespace ErsatzTV.Core.Scheduling
private static IMediaCollectionEnumerator GetMediaCollectionEnumerator(
Playout playout,
MediaCollection mediaCollection,
CollectionKey collectionKey,
List<MediaItem> mediaItems)
{
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
.FirstOrDefault(
a => a.ProgramScheduleId == playout.ProgramScheduleId && a.MediaCollectionId == mediaCollection.Id);
a => a.ProgramScheduleId == playout.ProgramScheduleId && a.CollectionType ==
collectionKey.CollectionType
&& a.CollectionId == collectionKey.Id);
MediaCollectionEnumeratorState state = maybeAnchor.Match(
anchor => anchor.EnumeratorState,
@@ -348,8 +383,40 @@ namespace ErsatzTV.Core.Scheduling
}
private static string DisplayTitle(MediaItem mediaItem) =>
mediaItem.Metadata.MediaType == MediaType.TvShow
? $"{mediaItem.Metadata.Title} - s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}"
: mediaItem.Metadata.Title;
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),
_ => string.Empty
};
private static CollectionKey CollectionKeyForItem(ProgramScheduleItem item) =>
item.CollectionType switch
{
ProgramScheduleItemCollectionType.Collection => new CollectionKey
{
CollectionType = item.CollectionType,
Id = item.MediaCollectionId.Value
},
ProgramScheduleItemCollectionType.TelevisionShow => new CollectionKey
{
CollectionType = item.CollectionType,
Id = item.TelevisionShowId.Value
},
ProgramScheduleItemCollectionType.TelevisionSeason => new CollectionKey
{
CollectionType = item.CollectionType,
Id = item.TelevisionSeasonId.Value
},
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
private class CollectionKey : Record<CollectionKey>
{
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int Id { get; set; }
}
}
}