Compare commits

...
37 changed files with 4211 additions and 128 deletions
@@ -73,13 +73,12 @@ namespace ErsatzTV.Application.Channels.Commands
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
private Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredLanguageCode)
Optional(createChannel.PreferredLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel)
{
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number);
@@ -97,7 +97,7 @@ namespace ErsatzTV.Application.Channels.Commands
}
private Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredLanguageCode)
Optional(updateChannel.PreferredLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
@@ -16,5 +16,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MediaItemId,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
bool? OfflineTail,
string CustomTitle) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
}
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; }
string CustomTitle { get; }
}
}
@@ -100,7 +100,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId
MediaItemId = item.MediaItemId,
CustomTitle = item.CustomTitle
},
PlayoutMode.One => new ProgramScheduleItemOne
{
@@ -109,7 +110,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId
MediaItemId = item.MediaItemId,
CustomTitle = item.CustomTitle
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
{
@@ -119,7 +121,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId,
Count = item.MultipleCount.GetValueOrDefault()
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
PlayoutMode.Duration => new ProgramScheduleItemDuration
{
@@ -130,7 +133,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),
OfflineTail = item.OfflineTail.GetValueOrDefault()
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
@@ -17,7 +17,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MediaItemId,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail) : IProgramScheduleItemRequest;
bool? OfflineTail,
string CustomTitle) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems
(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
@@ -28,7 +28,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
duration.PlayoutDuration,
duration.OfflineTail),
duration.OfflineTail,
duration.CustomTitle),
ProgramScheduleItemFlood flood =>
new ProgramScheduleItemFloodViewModel(
flood.Id,
@@ -44,7 +45,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
}),
},
flood.CustomTitle),
ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel(
multiple.Id,
@@ -61,7 +63,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
},
multiple.Count),
multiple.Count,
multiple.CustomTitle),
ProgramScheduleItemOne one =>
new ProgramScheduleItemOneViewModel(
one.Id,
@@ -77,7 +80,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
}),
},
one.CustomTitle),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
};
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.ProgramSchedules
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem,
TimeSpan playoutDuration,
bool offlineTail) : base(
bool offlineTail,
string customTitle) : base(
id,
index,
startType,
@@ -24,7 +25,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Duration,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
PlayoutDuration = playoutDuration;
OfflineTail = offlineTail;
@@ -14,7 +14,8 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem) : base(
NamedMediaItemViewModel mediaItem,
string customTitle) : base(
id,
index,
startType,
@@ -22,7 +23,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Flood,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
}
}
@@ -15,7 +15,8 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem,
int count) : base(
int count,
string customTitle) : base(
id,
index,
startType,
@@ -23,7 +24,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Multiple,
collectionType,
collection,
mediaItem) =>
mediaItem,
customTitle) =>
Count = count;
public int Count { get; }
@@ -14,7 +14,8 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem) : base(
NamedMediaItemViewModel mediaItem,
string customTitle) : base(
id,
index,
startType,
@@ -22,7 +23,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.One,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
}
}
@@ -13,7 +13,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
NamedMediaItemViewModel MediaItem)
NamedMediaItemViewModel MediaItem,
string CustomTitle)
{
public string Name => CollectionType switch
{
@@ -56,8 +56,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
public Unit CopyFile(string source, string destination) =>
Unit.Default;
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
@@ -610,6 +610,190 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Items[5].MediaItemId.Should().Be(4);
}
[Test]
public async Task Alternating_MultipleContent_Should_Maintain_Counts()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Multiple Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Multiple Items 2",
MediaItems = new List<MediaItem>
{
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 3
},
new ProgramScheduleItemMultiple
{
Id = 2,
Index = 2,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 3
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
{
NextStart = HoursAfterMidnight(1).UtcDateTime,
NextScheduleItem = items[0],
NextScheduleItemId = 1,
MultipleRemaining = 2
}
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(4);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[1].MediaItemId.Should().Be(1);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[2].MediaItemId.Should().Be(2);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
result.Items[3].MediaItemId.Should().Be(2);
result.Anchor.NextScheduleItem.Should().Be(items[1]);
result.Anchor.MultipleRemaining.Should().Be(1);
}
[Test]
public async Task Alternating_Duration_Should_Maintain_Duration()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Duration Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Duration Items 2",
MediaItems = new List<MediaItem>
{
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false
},
new ProgramScheduleItemDuration
{
Id = 2,
Index = 2,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
{
NextStart = HoursAfterMidnight(1).UtcDateTime,
NextScheduleItem = items[0],
NextScheduleItemId = 1,
DurationFinish = HoursAfterMidnight(3).UtcDateTime
}
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(4);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[1].MediaItemId.Should().Be(1);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[2].MediaItemId.Should().Be(2);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
result.Items[3].MediaItemId.Should().Be(2);
result.Anchor.NextScheduleItem.Should().Be(items[1]);
result.Anchor.DurationFinish.Should().Be(HoursAfterMidnight(6).UtcDateTime);
}
private static DateTimeOffset HoursAfterMidnight(int hours)
{
DateTimeOffset now = DateTimeOffset.Now;
+8
View File
@@ -1,4 +1,6 @@
using System;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Domain
{
@@ -9,7 +11,13 @@ namespace ErsatzTV.Core.Domain
public ProgramScheduleItem NextScheduleItem { get; set; }
public DateTime NextStart { get; set; }
public int? MultipleRemaining { get; set; }
public DateTime? DurationFinish { get; set; }
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
public Option<DateTimeOffset> DurationFinishOffset =>
Optional(DurationFinish)
.Map(durationFinish => new DateTimeOffset(durationFinish, TimeSpan.Zero).ToLocalTime());
}
}
+2
View File
@@ -9,6 +9,8 @@ namespace ErsatzTV.Core.Domain
public MediaItem MediaItem { get; set; }
public DateTime Start { get; set; }
public DateTime Finish { get; set; }
public string CustomTitle { get; set; }
public bool CustomGroup { get; set; }
public int PlayoutId { get; set; }
public Playout Playout { get; set; }
@@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
public TimeSpan? StartTime { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public string CustomTitle { get; set; }
public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
public int? CollectionId { get; set; }
+2 -2
View File
@@ -331,8 +331,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithFilterComplex(int videoStreamIndex, int audioStreamIndex)
{
var videoLabel = $"0:v:{videoStreamIndex}";
var audioLabel = $"0:a:{audioStreamIndex}";
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{audioStreamIndex}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, audioStreamIndex);
maybeFilter.IfSome(
@@ -8,6 +8,6 @@ namespace ErsatzTV.Core.Interfaces.Images
{
Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height);
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
string CopyArtworkToCache(string path, ArtworkKind artworkKind);
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
}
}
@@ -15,6 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
Task<byte[]> ReadAllBytes(string path);
Unit CopyFile(string source, string destination);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
}
}
+70 -32
View File
@@ -57,49 +57,36 @@ namespace ErsatzTV.Core.Iptv
foreach (Channel channel in _channels.OrderBy(c => c.Number))
{
foreach (PlayoutItem playoutItem in channel.Playouts.Collect(p => p.Items).OrderBy(i => i.Start))
var sorted = channel.Playouts.Collect(p => p.Items).OrderBy(x => x.Start).ToList();
var i = 0;
while (i < sorted.Count)
{
string start = playoutItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = playoutItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
PlayoutItem startItem = sorted[i];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
string title = playoutItem.MediaItem switch
int finishIndex = i;
while (hasCustomTitle && finishIndex + 1 < sorted.Count && sorted[finishIndex + 1].CustomGroup)
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
_ => "[unknown]"
};
finishIndex++;
}
string subtitle = playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
string description = playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
.IfNone(string.Empty),
_ => string.Empty
};
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string contentRating = playoutItem.MediaItem switch
{
// TODO: re-implement content rating
// Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.ContentRating).IfNone(string.Empty),
_ => string.Empty
};
string title = GetTitle(startItem);
string subtitle = GetSubtitle(startItem);
string description = GetDescription(startItem);
string contentRating = string.Empty;
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
xml.WriteAttributeString("stop", stop);
xml.WriteAttributeString("channel", channel.Number);
if (playoutItem.MediaItem is Movie movie)
if (!hasCustomTitle && startItem.MediaItem is Movie movie)
{
xml.WriteStartElement("category");
xml.WriteAttributeString("lang", "en");
@@ -150,7 +137,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("previously-shown");
xml.WriteEndElement(); // previously-shown
if (playoutItem.MediaItem is Episode episode)
if (!hasCustomTitle && startItem.MediaItem is Episode episode)
{
Option<ShowMetadata> maybeMetadata =
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
@@ -209,6 +196,8 @@ namespace ErsatzTV.Core.Iptv
}
xml.WriteEndElement(); // programme
i++;
}
}
@@ -218,5 +207,54 @@ namespace ErsatzTV.Core.Iptv
xml.Flush();
return Encoding.UTF8.GetString(ms.ToArray());
}
private static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
_ => "[unknown]"
};
}
private static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
private static string GetDescription(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
.IfNone(string.Empty),
_ => string.Empty
};
}
}
}
+17 -8
View File
@@ -36,17 +36,26 @@ 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)
public async Task<Either<BaseError, Unit>> CopyFile(string source, string destination)
{
string directory = Path.GetDirectoryName(destination) ?? string.Empty;
if (!Directory.Exists(directory))
try
{
Directory.CreateDirectory(directory);
string directory = Path.GetDirectoryName(destination) ?? string.Empty;
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await using FileStream sourceStream = File.OpenRead(source);
await using FileStream destinationStream = File.Create(destination);
await sourceStream.CopyToAsync(destinationStream);
return Unit.Default;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
File.Copy(source, destination, true);
return Unit.Default;
}
}
}
+38 -21
View File
@@ -120,30 +120,47 @@ namespace ErsatzTV.Core.Metadata
if (shouldRefresh)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
string cacheName = _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
try
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
Either<BaseError, string> maybeCacheName =
await _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
await maybeArtwork.Match(
async artwork =>
{
artwork.Path = cacheName;
artwork.DateUpdated = lastWriteTime;
await _metadataRepository.UpdateArtworkPath(artwork);
},
async () =>
{
var artwork = new Artwork
return await maybeCacheName.Match(
async cacheName =>
{
Path = cacheName,
DateAdded = DateTime.UtcNow,
DateUpdated = lastWriteTime,
ArtworkKind = artworkKind
};
metadata.Artwork.Add(artwork);
await _metadataRepository.AddArtwork(metadata, artwork);
});
await maybeArtwork.Match(
async artwork =>
{
artwork.Path = cacheName;
artwork.DateUpdated = lastWriteTime;
await _metadataRepository.UpdateArtworkPath(artwork);
},
async () =>
{
var artwork = new Artwork
{
Path = cacheName,
DateAdded = DateTime.UtcNow,
DateUpdated = lastWriteTime,
ArtworkKind = artworkKind
};
metadata.Artwork.Add(artwork);
await _metadataRepository.AddArtwork(metadata, artwork);
});
return true;
return true;
},
error =>
{
_logger.LogDebug("Failed to cache artwork from {Path}: {Error}", artworkFile, error.Value);
return Task.FromResult(false);
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error refreshing artwork");
}
}
return false;
@@ -113,7 +113,7 @@ namespace ErsatzTV.Core.Metadata
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
@@ -149,17 +149,17 @@ namespace ErsatzTV.Core.Metadata
async existing =>
{
var updated = false;
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
@@ -254,7 +254,7 @@ namespace ErsatzTV.Core.Metadata
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
@@ -80,9 +80,22 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
}
await ScanSeasons(libraryPath, ffprobePath, result.Item, showFolder, lastScan);
await ScanSeasons(
libraryPath,
ffprobePath,
result.Item,
showFolder,
// force scanning all folders if we're adding a new show
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
},
_ => Task.FromResult(Unit.Default));
error =>
{
_logger.LogWarning(
"Error processing show in folder {Folder}: {Error}",
showFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
}
foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
@@ -132,7 +145,14 @@ namespace ErsatzTV.Core.Metadata
await maybeSeason.Match(
season => ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder, lastScan),
_ => Task.FromResult(Unit.Default));
error =>
{
_logger.LogWarning(
"Error processing season in folder {Folder}: {Error}",
seasonFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
});
}
+24 -4
View File
@@ -145,8 +145,12 @@ namespace ErsatzTV.Core.Scheduling
// start with the previously-decided schedule item
int index = sortedScheduleItems.IndexOf(startAnchor.NextScheduleItem);
Option<int> multipleRemaining = None;
Option<DateTimeOffset> durationFinish = None;
// start with the previous multiple/duration states
Option<int> multipleRemaining = Optional(startAnchor.MultipleRemaining);
Option<DateTimeOffset> durationFinish = startAnchor.DurationFinishOffset;
bool customGroup = multipleRemaining.IsSome || durationFinish.IsSome;
// loop until we're done filling the desired amount of time
while (currentTime < playoutFinish)
{
@@ -183,9 +187,15 @@ namespace ErsatzTV.Core.Scheduling
{
MediaItemId = mediaItem.Id,
Start = itemStartTime.UtcDateTime,
Finish = itemStartTime.UtcDateTime + version.Duration
Finish = itemStartTime.UtcDateTime + version.Duration,
CustomGroup = customGroup
};
if (!string.IsNullOrWhiteSpace(scheduleItem.CustomTitle))
{
playoutItem.CustomTitle = scheduleItem.CustomTitle;
}
currentTime = itemStartTime + version.Duration;
enumerator.MoveNext();
@@ -199,11 +209,13 @@ namespace ErsatzTV.Core.Scheduling
"Advancing to next schedule item after playout mode {PlayoutMode}",
"One");
index++;
customGroup = false;
break;
case ProgramScheduleItemMultiple multiple:
if (multipleRemaining.IsNone)
{
multipleRemaining = multiple.Count;
customGroup = true;
}
multipleRemaining = multipleRemaining.Map(i => i - 1);
@@ -214,6 +226,7 @@ namespace ErsatzTV.Core.Scheduling
"Multiple");
index++;
multipleRemaining = None;
customGroup = false;
}
break;
@@ -221,6 +234,8 @@ namespace ErsatzTV.Core.Scheduling
enumerator.Current.Do(
peekMediaItem =>
{
customGroup = true;
MediaVersion peekVersion = peekMediaItem switch
{
Movie m => m.MediaVersions.Head(),
@@ -247,6 +262,7 @@ namespace ErsatzTV.Core.Scheduling
"Advancing to next schedule item after playout mode {PlayoutMode}",
"Flood");
index++;
customGroup = false;
}
});
break;
@@ -265,6 +281,7 @@ namespace ErsatzTV.Core.Scheduling
if (durationFinish.IsNone)
{
durationFinish = itemStartTime + duration.PlayoutDuration;
customGroup = true;
}
bool willNotFinishInTime =
@@ -277,6 +294,7 @@ namespace ErsatzTV.Core.Scheduling
"Advancing to next schedule item after playout mode {PlayoutMode}",
"Duration");
index++;
customGroup = false;
if (duration.OfflineTail)
{
@@ -298,7 +316,9 @@ namespace ErsatzTV.Core.Scheduling
{
NextScheduleItem = nextScheduleItem,
NextScheduleItemId = nextScheduleItem.Id,
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime,
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null
};
// build program schedule anchors
+31 -17
View File
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
@@ -18,10 +19,15 @@ namespace ErsatzTV.Infrastructure.Images
{
private static readonly SHA1CryptoServiceProvider Crypto;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<ImageCache> _logger;
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
public ImageCache(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
public ImageCache(ILocalFileSystem localFileSystem, ILogger<ImageCache> logger)
{
_localFileSystem = localFileSystem;
_logger = logger;
}
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
{
@@ -75,24 +81,32 @@ namespace ErsatzTV.Infrastructure.Images
}
}
public string CopyArtworkToCache(string path, ArtworkKind artworkKind)
public async Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind)
{
var filenameKey = $"{path}:{_localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}";
byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey));
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string subfolder = hex.Substring(0, 2);
string baseFolder = artworkKind switch
try
{
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
string target = Path.Combine(baseFolder, hex);
_localFileSystem.CopyFile(path, target);
return hex;
var filenameKey = $"{path}:{_localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}";
byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey));
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string subfolder = hex.Substring(0, 2);
string baseFolder = artworkKind switch
{
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
_ => FileSystemLayout.LegacyImageCacheFolder
};
string target = Path.Combine(baseFolder, hex);
Either<BaseError, Unit> maybeResult = await _localFileSystem.CopyFile(path, target);
return maybeResult.Match<Either<BaseError, string>>(
_ => hex,
error => error);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}
}
@@ -0,0 +1,34 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlayoutAnchor_DurationMultiple : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
"Anchor_DurationFinish",
"Playout",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
"Anchor_MultipleRemaining",
"Playout",
"INTEGER",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"Anchor_DurationFinish",
"Playout");
migrationBuilder.DropColumn(
"Anchor_MultipleRemaining",
"Playout");
}
}
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ProgramScheduleItem_CustomTitle : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
"CustomTitle",
"ProgramScheduleItem",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
"CustomGroup",
"PlayoutItem",
"INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
"CustomTitle",
"PlayoutItem",
"TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"CustomTitle",
"ProgramScheduleItem");
migrationBuilder.DropColumn(
"CustomGroup",
"PlayoutItem");
migrationBuilder.DropColumn(
"CustomTitle",
"PlayoutItem");
}
}
}
@@ -611,6 +611,12 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CustomGroup")
.HasColumnType("INTEGER");
b.Property<string>("CustomTitle")
.HasColumnType("TEXT");
b.Property<DateTime>("Finish")
.HasColumnType("TEXT");
@@ -752,6 +758,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("CollectionType")
.HasColumnType("INTEGER");
b.Property<string>("CustomTitle")
.HasColumnType("TEXT");
b.Property<int>("Index")
.HasColumnType("INTEGER");
@@ -1412,6 +1421,12 @@ namespace ErsatzTV.Infrastructure.Migrations
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("DurationFinish")
.HasColumnType("TEXT");
b1.Property<int?>("MultipleRemaining")
.HasColumnType("INTEGER");
b1.Property<int>("NextScheduleItemId")
.HasColumnType("INTEGER");
+10 -6
View File
@@ -76,17 +76,21 @@
_messageStore.Clear();
if (_editContext.Validate())
{
Seq<BaseError> errorMessage = IsEdit ?
(await Mediator.Send(_model.ToUpdate())).LeftToSeq() :
(await Mediator.Send(_model.ToCreate())).LeftToSeq();
Either<BaseError, ProgramScheduleViewModel> result = IsEdit ?
await Mediator.Send(_model.ToUpdate()) :
await Mediator.Send(_model.ToCreate());
errorMessage.HeadOrNone().Match(
result.Match(
programSchedule =>
{
string destination = IsEdit ? "/schedules" : $"/schedules/{programSchedule.Id}/items";
NavigationManager.NavigateTo(destination);
},
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving schedule: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("/schedules"));
});
}
}
+5 -2
View File
@@ -140,6 +140,7 @@
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch Label="Offline Tail" @bind-Checked="@_selectedItem.OfflineTail" For="@(() => _selectedItem.OfflineTail)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
</MudElement>
<MudTextField Class="mt-3" Label="Custom Title" @bind-Value="@_selectedItem.CustomTitle" For="@(() => _selectedItem.CustomTitle)"/>
</MudCardContent>
</MudCard>
</EditForm>
@@ -210,7 +211,8 @@
PlayoutMode = item.PlayoutMode,
CollectionType = item.CollectionType,
Collection = item.Collection,
MediaItem = item.MediaItem
MediaItem = item.MediaItem,
CustomTitle = item.CustomTitle
};
switch (item)
@@ -286,7 +288,8 @@
item.MediaItem?.MediaItemId,
item.MultipleCount,
item.PlayoutDuration,
item.PlayoutMode == PlayoutMode.Duration ? item.OfflineTail.IfNone(false) : null)).ToList();
item.PlayoutMode == PlayoutMode.Duration ? item.OfflineTail.IfNone(false) : null,
item.CustomTitle)).ToList();
Seq<BaseError> errorMessages = await Mediator.Send(new ReplaceProgramScheduleItems(Id, items)).Map(e => e.LeftToSeq());
+1 -1
View File
@@ -139,7 +139,7 @@
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, SeasonId, null, null, null));
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, SeasonId, null, null, null, null));
NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
+1 -1
View File
@@ -143,7 +143,7 @@
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, ShowId, null, null, null));
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, ShowId, null, null, null, null));
NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
@@ -75,6 +75,8 @@ namespace ErsatzTV.ViewModels
set => _offlineTail = value;
}
public string CustomTitle { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
[NotifyPropertyChangedInvocator]