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