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:
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
+1
-11
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user