Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c1c1e7812 | ||
|
|
5f802c7484 | ||
|
|
7a06ac71e2 | ||
|
|
3b9b8796b9 | ||
|
|
a72d91507e | ||
|
|
45bfbfc179 | ||
|
|
7d24701a82 | ||
|
|
286580d5aa | ||
|
|
d9457c01e5 | ||
|
|
22cf759a29 | ||
|
|
0733a3d8d7 | ||
|
|
5f28707cce | ||
|
|
45f1c6b22a | ||
|
|
3bed81aee9 | ||
|
|
f2eda3033c | ||
|
|
8ce989c3c9 | ||
|
|
b5ba0dff27 | ||
|
|
de3e2ea754 | ||
|
|
2ac840b4bd | ||
|
|
c8ccb5b0a0 | ||
|
|
23c4fcf42c | ||
|
|
e2f3e86fd6 | ||
|
|
fd9f4a8f4e | ||
|
|
d5a0951a9b | ||
|
|
56d9724efd | ||
|
|
f91b5ab3b5 | ||
|
|
4b8e81ff06 | ||
|
|
1a7e6dda54 |
@@ -0,0 +1,8 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
@@ -11,6 +11,9 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
|
||||
+41
-1
@@ -5,6 +5,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.50-alpha] - 2021-07-13
|
||||
### Added
|
||||
- Add Linux ARM release artifacts which can be used on Raspberry Pi devices
|
||||
|
||||
### Fixed
|
||||
- Fix bug preventing ingestion of local movies with fallback metadata (without NFO files)
|
||||
- Fix extra spaces in titles of local movies with fallback metadata (without NFO files)
|
||||
|
||||
## [0.0.49-prealpha] - 2021-07-11
|
||||
### Added
|
||||
- Include audio language metadata in all streaming modes
|
||||
- Add special zero-count case to `Multiple` playout mode
|
||||
- This configuration will automatically maintain the multiple count so that it is equal to the number of items in the collection
|
||||
- This configuration should be used if you want to play every item in a collection exactly once before advancing
|
||||
|
||||
### Changed
|
||||
- Use case-insensitive sorting for collections page and `Add to Collection` dialog
|
||||
- Use case-insensitive sorting for all collection lists in schedule items editor
|
||||
- Use natural sorting for schedules page and `Add to Schedule` dialog
|
||||
|
||||
### Fixed
|
||||
- Fix flooding schedule items that have a fixed start time
|
||||
|
||||
## [0.0.48-prealpha] - 2021-06-22
|
||||
### Added
|
||||
- Store pixel format with media statistics; this is needed to support normalization of 10-bit media items
|
||||
- This requires re-ingesting statistics for all media items the first time this version is launched
|
||||
|
||||
### Changed
|
||||
- Use ffprobe to retrieve statistics for Plex media items (Local, Emby and Jellyfin libraries already use ffprobe)
|
||||
|
||||
### Fixed
|
||||
- Fix playback of transcoded 10-bit media items (pixel format `yuv420p10le`) on Nvidia hardware
|
||||
- Emby and Jellyfin scanners now respect library refresh interval setting
|
||||
- Fix adding new seasons to existing Emby and Jellyfin shows
|
||||
- Fix adding new episodes to existing Emby and Jellyfin seasons
|
||||
|
||||
## [0.0.47-prealpha] - 2021-06-15
|
||||
### Added
|
||||
- Add warning during playout rebuild when schedule has been emptied
|
||||
@@ -468,7 +505,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...HEAD
|
||||
[0.0.50-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
|
||||
[0.0.49-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
|
||||
[0.0.48-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
|
||||
[0.0.47-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-prealpha
|
||||
[0.0.46-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.45-prealpha...v0.0.46-prealpha
|
||||
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
|
||||
|
||||
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
switch (parameters.Library.MediaKind)
|
||||
{
|
||||
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
ISynchronizeEmbyLibraryById request) =>
|
||||
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateFFprobePath())
|
||||
(await ValidateConnection(request), await EmbyLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, embyLibrary, ffprobePath) => new RequestParameters(
|
||||
(connectionParameters, embyLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
embyLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId)
|
||||
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
ConnectionParameters ConnectionParameters,
|
||||
EmbyLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
switch (parameters.Library.MediaKind)
|
||||
{
|
||||
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateFFprobePath())
|
||||
(await ValidateConnection(request), await JellyfinLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, jellyfinLibrary, ffprobePath) => new RequestParameters(
|
||||
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
jellyfinLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
|
||||
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
ConnectionParameters ConnectionParameters,
|
||||
JellyfinLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
List<MediaCollectionViewModel> page = await dbContext.Collections.FromSqlRaw(
|
||||
@"SELECT * FROM Collection
|
||||
ORDER BY Name
|
||||
COLLATE NOCASE
|
||||
LIMIT {0} OFFSET {1}",
|
||||
request.PageSize,
|
||||
request.PageNum * request.PageSize)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -74,13 +75,15 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
await _plexMovieLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library);
|
||||
parameters.Library,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
await _plexTelevisionLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library);
|
||||
parameters.Library,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -100,13 +103,14 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>
|
||||
(await ValidateConnection(request), await PlexLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval())
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, plexLibrary, libraryRefreshInterval) => new RequestParameters(
|
||||
(connectionParameters, plexLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
plexLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
||||
@@ -149,12 +153,20 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffprobePath =>
|
||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
|
||||
private record RequestParameters(
|
||||
ConnectionParameters ConnectionParameters,
|
||||
PlexLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
int LibraryRefreshInterval,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
|
||||
{
|
||||
|
||||
@@ -30,9 +30,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
case PlayoutMode.One:
|
||||
break;
|
||||
case PlayoutMode.Multiple:
|
||||
if (item.MultipleCount.GetValueOrDefault() <= 0)
|
||||
if (item.MultipleCount.GetValueOrDefault() < 0)
|
||||
{
|
||||
return BaseError.New("[MultipleCount] must be greater than 0 for playout mode 'multiple'");
|
||||
return BaseError.New("[MultipleCount] must be greater than or equal to 0 for playout mode 'multiple'");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
<PackageReference Include="FluentAssertions" Version="5.10.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
|
||||
@@ -93,5 +93,30 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
metadata.Count.Should().Be(2);
|
||||
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(episode1, episode2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("Something (2021).mkv", "Something")]
|
||||
[TestCase("Something Else (2021).mkv", "Something Else")]
|
||||
public void GetFallbackMetadata_Should_Set_Proper_Movie_Title(string path, string expectedTitle)
|
||||
{
|
||||
MovieMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
new Movie
|
||||
{
|
||||
LibraryPath = new LibraryPath(),
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
metadata.Should().NotBeNull();
|
||||
metadata.Title.Should().Be(expectedTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +458,182 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
result.Items[5].MediaItemId.Should().Be(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodWithFixedStartTime()
|
||||
{
|
||||
var floodCollection = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Flood Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fixedCollection = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Fixed Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
|
||||
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
|
||||
}
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(floodCollection.Id, floodCollection.MediaItems.ToList()),
|
||||
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(7)
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(12)
|
||||
}
|
||||
};
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(24);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(6);
|
||||
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(8));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
|
||||
result.Items[2].MediaItemId.Should().Be(1);
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
|
||||
result.Items[3].MediaItemId.Should().Be(2);
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
|
||||
result.Items[4].MediaItemId.Should().Be(1);
|
||||
|
||||
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
|
||||
result.Items[5].MediaItemId.Should().Be(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
|
||||
{
|
||||
var floodCollection = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Flood Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fixedCollection = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Fixed Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
|
||||
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
|
||||
}
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(floodCollection.Id, floodCollection.MediaItems.ToList()),
|
||||
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(7)
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(12)
|
||||
}
|
||||
};
|
||||
|
||||
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(9).UtcDateTime,
|
||||
NextScheduleItem = items[0],
|
||||
NextScheduleItemId = 1,
|
||||
InFlood = true
|
||||
}
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(32);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(5);
|
||||
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
|
||||
result.Items[2].MediaItemId.Should().Be(1);
|
||||
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
|
||||
result.Items[3].MediaItemId.Should().Be(3);
|
||||
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
|
||||
result.Items[4].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.InFlood.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodAroundFixedContent_DurationWithoutOfflineTail()
|
||||
{
|
||||
@@ -729,6 +905,96 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
result.Anchor.MultipleRemaining.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Auto_Zero_MultipleCount()
|
||||
{
|
||||
var collectionOne = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Multiple Items 1",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(3, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var collectionTwo = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Multiple Items 2",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(5, 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 = 0
|
||||
},
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
Id = 2,
|
||||
Index = 2,
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
Count = 0
|
||||
}
|
||||
};
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(5);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(5);
|
||||
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(0));
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
|
||||
result.Items[2].MediaItemId.Should().Be(3);
|
||||
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
|
||||
result.Items[3].MediaItemId.Should().Be(4);
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
|
||||
result.Items[4].MediaItemId.Should().Be(5);
|
||||
|
||||
result.Anchor.NextScheduleItem.Should().Be(items[0]);
|
||||
result.Anchor.MultipleRemaining.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Alternating_Duration_Should_Maintain_Duration()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
public string Title { get; set; }
|
||||
public bool Default { get; set; }
|
||||
public bool Forced { get; set; }
|
||||
public string PixelFormat { get; set; }
|
||||
public int BitsPerRawSample { get; set; }
|
||||
public int MediaVersionId { get; set; }
|
||||
public MediaVersion MediaVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public DateTime NextStart { get; set; }
|
||||
public int? MultipleRemaining { get; set; }
|
||||
public DateTime? DurationFinish { get; set; }
|
||||
public bool InFlood { get; set; }
|
||||
|
||||
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
|
||||
|
||||
|
||||
@@ -231,7 +231,6 @@ namespace ErsatzTV.Core.Emby
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -239,7 +238,7 @@ namespace ErsatzTV.Core.Emby
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(incoming);
|
||||
await _televisionRepository.AddSeason(show, incoming);
|
||||
});
|
||||
|
||||
List<EmbyItemEtag> existingEpisodes =
|
||||
@@ -364,7 +363,6 @@ namespace ErsatzTV.Core.Emby
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -373,7 +371,7 @@ namespace ErsatzTV.Core.Emby
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
if (await _televisionRepository.AddEpisode(incoming))
|
||||
if (await _televisionRepository.AddEpisode(season, incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private IDisplaySize _resolution;
|
||||
private Option<IDisplaySize> _scaleToSize = None;
|
||||
private Option<ChannelWatermark> _watermark;
|
||||
private string _pixelFormat;
|
||||
|
||||
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
|
||||
{
|
||||
@@ -63,6 +64,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat)
|
||||
{
|
||||
_pixelFormat = pixelFormat;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
|
||||
{
|
||||
_watermark = watermark;
|
||||
@@ -128,6 +135,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string filter = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
@@ -150,6 +159,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string format = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
|
||||
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _pixelFormat == "yuv420p10le" =>
|
||||
"format=p010le,format=nv12",
|
||||
_ => "format=nv12"
|
||||
};
|
||||
videoFilterQueue.Add(format);
|
||||
|
||||
@@ -175,7 +175,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec)
|
||||
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec, string pixelFormat)
|
||||
{
|
||||
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))
|
||||
{
|
||||
@@ -183,7 +183,9 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.Add(qsvCodec);
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithInputCodec(codec);
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithInputCodec(codec)
|
||||
.WithInputPixelFormat(pixelFormat);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add($"{input}");
|
||||
@@ -228,14 +230,29 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithMetadata(Channel channel)
|
||||
public FFmpegProcessBuilder WithMetadata(Channel channel, Option<MediaStream> maybeAudioStream)
|
||||
{
|
||||
if (channel.StreamingMode == StreamingMode.TransportStream)
|
||||
{
|
||||
_arguments.AddRange(new[] { "-map_metadata", "-1" });
|
||||
}
|
||||
|
||||
foreach (MediaStream audioStream in maybeAudioStream)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(audioStream.Language))
|
||||
{
|
||||
_arguments.AddRange(new[] { "-metadata:s:a:0", $"language={audioStream.Language}" });
|
||||
}
|
||||
}
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-metadata", "service_provider=\"ErsatzTV\"",
|
||||
"-metadata", $"service_name=\"{channel.Name}\""
|
||||
};
|
||||
|
||||
_arguments.AddRange(arguments);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -321,7 +338,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
new[]
|
||||
{
|
||||
"-c:a", playbackSettings.AudioCodec,
|
||||
"-map_metadata", "-1",
|
||||
"-movflags", "+faststart",
|
||||
"-muxdelay", "0",
|
||||
"-muxpreload", "0"
|
||||
|
||||
@@ -62,7 +62,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSeek(playbackSettings.StreamSeek)
|
||||
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
|
||||
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat)
|
||||
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
|
||||
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
@@ -105,7 +105,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
});
|
||||
|
||||
return builder.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel)
|
||||
.WithMetadata(channel, maybeAudioStream)
|
||||
.WithFormat("mpegts")
|
||||
.WithDuration(start + version.Duration - now)
|
||||
.WithPipe()
|
||||
@@ -130,7 +130,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithErrorText(desiredResolution, errorMessage)
|
||||
.WithPixfmt("yuv420p")
|
||||
.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel)
|
||||
.WithMetadata(channel, None)
|
||||
.WithFormat("mpegts");
|
||||
|
||||
duration.IfSome(d => builder = builder.WithDuration(d));
|
||||
@@ -149,7 +149,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithInfiniteLoop()
|
||||
.WithConcat($"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}")
|
||||
.WithMetadata(channel)
|
||||
.WithMetadata(channel, None)
|
||||
.WithFormat("mpegts")
|
||||
.WithPipe()
|
||||
.Build();
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library);
|
||||
PlexLibrary library,
|
||||
string ffprobePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library);
|
||||
PlexLibrary library,
|
||||
string ffprobePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, string seasonItemId);
|
||||
Task<bool> AddShow(EmbyShow show);
|
||||
Task<Option<EmbyShow>> Update(EmbyShow show);
|
||||
Task<bool> AddSeason(EmbySeason season);
|
||||
Task<bool> AddSeason(EmbyShow show, EmbySeason season);
|
||||
Task<Unit> Update(EmbySeason season);
|
||||
Task<bool> AddEpisode(EmbyEpisode episode);
|
||||
Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
|
||||
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
|
||||
Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds);
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId);
|
||||
Task<bool> AddShow(JellyfinShow show);
|
||||
Task<Option<JellyfinShow>> Update(JellyfinShow show);
|
||||
Task<bool> AddSeason(JellyfinSeason season);
|
||||
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season);
|
||||
Task<Unit> Update(JellyfinSeason season);
|
||||
Task<bool> AddEpisode(JellyfinEpisode episode);
|
||||
Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
|
||||
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
|
||||
Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds);
|
||||
|
||||
@@ -231,7 +231,6 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -239,7 +238,7 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(incoming);
|
||||
await _televisionRepository.AddSeason(show, incoming);
|
||||
});
|
||||
|
||||
List<JellyfinItemEtag> existingEpisodes =
|
||||
@@ -365,7 +364,6 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -374,7 +372,7 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
if (await _televisionRepository.AddEpisode(incoming))
|
||||
if (await _televisionRepository.AddEpisode(season, incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
|
||||
@@ -161,13 +161,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Title = match.Groups[1].Value.Trim();
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Directors = new List<Director>();
|
||||
metadata.Writers = new List<Writer>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,9 +194,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
MediaStreamKind = MediaStreamKind.Video,
|
||||
Index = videoStream.index,
|
||||
Codec = videoStream.codec_name,
|
||||
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant()
|
||||
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant(),
|
||||
PixelFormat = (videoStream.pix_fmt ?? string.Empty).ToLowerInvariant(),
|
||||
};
|
||||
|
||||
if (int.TryParse(videoStream.bits_per_raw_sample, out int bitsPerRawSample))
|
||||
{
|
||||
stream.BitsPerRawSample = bitsPerRawSample;
|
||||
}
|
||||
|
||||
if (videoStream.disposition is not null)
|
||||
{
|
||||
stream.Default = videoStream.disposition.@default == 1;
|
||||
@@ -262,8 +268,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
int height,
|
||||
string sample_aspect_ratio,
|
||||
string display_aspect_ratio,
|
||||
string pix_fmt,
|
||||
string field_order,
|
||||
string r_frame_rate,
|
||||
string bits_per_raw_sample,
|
||||
FFprobeDisposition disposition,
|
||||
FFProbeTags tags);
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -18,6 +17,7 @@ namespace ErsatzTV.Core.Plex
|
||||
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
@@ -38,6 +38,7 @@ namespace ErsatzTV.Core.Plex
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILogger<PlexMovieLibraryScanner> logger)
|
||||
: base(metadataRepository, logger)
|
||||
{
|
||||
@@ -50,13 +51,15 @@ namespace ErsatzTV.Core.Plex
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library)
|
||||
PlexLibrary library,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
@@ -95,7 +98,7 @@ namespace ErsatzTV.Core.Plex
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.BindT(existing => UpdateStatistics(existing, incoming, library, connection, token))
|
||||
.BindT(existing => UpdateStatistics(pathReplacements, existing, incoming, ffprobePath))
|
||||
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
@@ -144,11 +147,10 @@ namespace ErsatzTV.Core.Plex
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming,
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
string ffprobePath)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
@@ -156,30 +158,37 @@ namespace ErsatzTV.Core.Plex
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeStatistics =
|
||||
await _plexServerApiClient.GetMovieMetadataAndStatistics(
|
||||
library,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connection,
|
||||
token);
|
||||
|
||||
await maybeStatistics.Match(
|
||||
async tuple =>
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
(MovieMetadata _, MediaVersion mediaVersion) = tuple;
|
||||
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated });
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Refreshing {Attribute} from {Path}",
|
||||
"Plex Statistics",
|
||||
existingVersion.MediaFiles.Head().Path);
|
||||
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Plex
|
||||
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
@@ -40,6 +41,7 @@ namespace ErsatzTV.Core.Plex
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILogger<PlexTelevisionLibraryScanner> logger)
|
||||
: base(metadataRepository, logger)
|
||||
{
|
||||
@@ -52,13 +54,15 @@ namespace ErsatzTV.Core.Plex
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library)
|
||||
PlexLibrary library,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
@@ -85,7 +89,7 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeShow.Match(
|
||||
async result =>
|
||||
{
|
||||
await ScanSeasons(library, pathReplacements, result.Item, connection, token);
|
||||
await ScanSeasons(library, pathReplacements, result.Item, connection, token, ffprobePath);
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -288,7 +292,8 @@ namespace ErsatzTV.Core.Plex
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
PlexServerAuthToken token,
|
||||
string ffprobePath)
|
||||
{
|
||||
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
|
||||
library,
|
||||
@@ -309,7 +314,13 @@ namespace ErsatzTV.Core.Plex
|
||||
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
|
||||
|
||||
await maybeSeason.Match(
|
||||
async season => await ScanEpisodes(library, pathReplacements, season, connection, token),
|
||||
async season => await ScanEpisodes(
|
||||
library,
|
||||
pathReplacements,
|
||||
season,
|
||||
connection,
|
||||
token,
|
||||
ffprobePath),
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -373,7 +384,8 @@ namespace ErsatzTV.Core.Plex
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
PlexServerAuthToken token,
|
||||
string ffprobePath)
|
||||
{
|
||||
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
|
||||
library,
|
||||
@@ -414,11 +426,13 @@ namespace ErsatzTV.Core.Plex
|
||||
.BindT(existing => UpdateMetadata(existing, incoming))
|
||||
.BindT(
|
||||
existing => UpdateStatistics(
|
||||
pathReplacements,
|
||||
existing,
|
||||
incoming,
|
||||
library,
|
||||
connection,
|
||||
token))
|
||||
token,
|
||||
ffprobePath))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
await maybeEpisode.Match(
|
||||
@@ -484,57 +498,89 @@ namespace ErsatzTV.Core.Plex
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
PlexEpisode existing,
|
||||
PlexEpisode incoming,
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
PlexServerAuthToken token,
|
||||
string ffprobePath)
|
||||
{
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
|
||||
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
|
||||
library,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connection,
|
||||
token);
|
||||
|
||||
await maybeStatistics.Match(
|
||||
async tuple =>
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
|
||||
|
||||
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
|
||||
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
|
||||
foreach (EpisodeMetadata existingMetadata in maybeExisting)
|
||||
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
|
||||
{
|
||||
foreach (MetadataGuid guid in existingMetadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Remove(guid);
|
||||
await _metadataRepository.RemoveGuid(guid);
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in incomingMetadata.Guids
|
||||
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Add(guid);
|
||||
await _metadataRepository.AddGuid(existingMetadata, guid);
|
||||
}
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated });
|
||||
}
|
||||
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
|
||||
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
|
||||
library,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connection,
|
||||
token);
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
|
||||
await maybeStatistics.Match(
|
||||
async tuple =>
|
||||
{
|
||||
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
|
||||
|
||||
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
|
||||
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
|
||||
foreach (EpisodeMetadata existingMetadata in maybeExisting)
|
||||
{
|
||||
foreach (MetadataGuid guid in existingMetadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Remove(guid);
|
||||
await _metadataRepository.RemoveGuid(guid);
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in incomingMetadata.Guids
|
||||
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Add(guid);
|
||||
await _metadataRepository.AddGuid(existingMetadata, guid);
|
||||
}
|
||||
}
|
||||
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
return Right<BaseError, PlexEpisode>(existing);
|
||||
|
||||
@@ -201,6 +201,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
// start with the previous multiple/duration states
|
||||
Option<int> multipleRemaining = Optional(startAnchor.MultipleRemaining);
|
||||
Option<DateTimeOffset> durationFinish = startAnchor.DurationFinishOffset;
|
||||
bool inFlood = startAnchor.InFlood;
|
||||
|
||||
bool customGroup = multipleRemaining.IsSome || durationFinish.IsSome;
|
||||
|
||||
@@ -215,7 +216,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
scheduleItem,
|
||||
currentTime,
|
||||
multipleRemaining.IsSome,
|
||||
durationFinish.IsSome);
|
||||
durationFinish.IsSome,
|
||||
inFlood);
|
||||
|
||||
IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)];
|
||||
await enumerator.Current.IfSomeAsync(
|
||||
@@ -268,7 +270,16 @@ namespace ErsatzTV.Core.Scheduling
|
||||
case ProgramScheduleItemMultiple multiple:
|
||||
if (multipleRemaining.IsNone)
|
||||
{
|
||||
multipleRemaining = multiple.Count;
|
||||
if (multiple.Count == 0)
|
||||
{
|
||||
multipleRemaining = collectionMediaItems[CollectionKeyForItem(scheduleItem)]
|
||||
.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
multipleRemaining = multiple.Count;
|
||||
}
|
||||
|
||||
customGroup = true;
|
||||
}
|
||||
|
||||
@@ -318,6 +329,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
"Flood");
|
||||
index++;
|
||||
customGroup = false;
|
||||
inFlood = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
inFlood = true;
|
||||
}
|
||||
});
|
||||
break;
|
||||
@@ -374,7 +390,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
NextScheduleItemId = nextScheduleItem.Id,
|
||||
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime,
|
||||
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
|
||||
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null
|
||||
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null,
|
||||
InFlood = inFlood
|
||||
};
|
||||
|
||||
// build program schedule anchors
|
||||
@@ -419,13 +436,15 @@ namespace ErsatzTV.Core.Scheduling
|
||||
ProgramScheduleItem item,
|
||||
DateTimeOffset start,
|
||||
bool inMultiple = false,
|
||||
bool inDuration = false)
|
||||
bool inDuration = false,
|
||||
bool inFlood = false)
|
||||
{
|
||||
switch (item.StartType)
|
||||
{
|
||||
case StartType.Fixed:
|
||||
if (item is ProgramScheduleItemMultiple && inMultiple ||
|
||||
item is ProgramScheduleItemDuration && inDuration)
|
||||
item is ProgramScheduleItemDuration && inDuration ||
|
||||
item is ProgramScheduleItemFlood && inFlood)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
@@ -242,18 +242,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddSeason(EmbySeason season)
|
||||
public async Task<bool> AddSeason(EmbyShow show, EmbySeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
try
|
||||
{
|
||||
season.ShowId = await _dbConnection.ExecuteScalarAsync<int>(
|
||||
@"SELECT Id FROM EmbyShow WHERE ItemId = @ItemId",
|
||||
new { show.ItemId });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(EmbySeason season)
|
||||
@@ -368,19 +379,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEpisode(EmbyEpisode episode)
|
||||
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
try
|
||||
{
|
||||
episode.SeasonId = await _dbConnection.ExecuteScalarAsync<int>(
|
||||
@"SELECT Id FROM EmbySeason WHERE ItemId = @ItemId",
|
||||
new { season.ItemId });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Option<EmbyEpisode>> Update(EmbyEpisode episode)
|
||||
|
||||
@@ -259,18 +259,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddSeason(JellyfinSeason season)
|
||||
public async Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
try
|
||||
{
|
||||
season.ShowId = await _dbConnection.ExecuteScalarAsync<int>(
|
||||
@"SELECT Id FROM JellyfinShow WHERE ItemId = @ItemId",
|
||||
new { show.ItemId });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(JellyfinSeason season)
|
||||
@@ -368,19 +379,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEpisode(JellyfinEpisode episode)
|
||||
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
try
|
||||
{
|
||||
episode.SeasonId = await _dbConnection.ExecuteScalarAsync<int>(
|
||||
@"SELECT Id FROM JellyfinSeason WHERE ItemId = @ItemId",
|
||||
new { season.ItemId });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode)
|
||||
|
||||
@@ -166,25 +166,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
() => Task.FromResult(false));
|
||||
}
|
||||
|
||||
public async Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming)
|
||||
{
|
||||
bool updatedVersion = await _dbConnection.ExecuteAsync(
|
||||
public Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE MediaVersion SET
|
||||
SampleAspectRatio = @SampleAspectRatio,
|
||||
VideoScanKind = @VideoScanKind,
|
||||
DateUpdated = @DateUpdated
|
||||
WHERE Id = @MediaVersionId",
|
||||
new
|
||||
{
|
||||
incoming.SampleAspectRatio,
|
||||
incoming.VideoScanKind,
|
||||
incoming.DateUpdated,
|
||||
MediaVersionId = mediaVersionId
|
||||
}).Map(result => result > 0);
|
||||
|
||||
return await UpdateLocalStatistics(mediaVersionId, incoming, false) || updatedVersion;
|
||||
}
|
||||
|
||||
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Dapper" Version="2.0.90" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.6">
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.6" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.7" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
+2976
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MediaStreamPixelFormat : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BitsPerRawSample",
|
||||
table: "MediaStream",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PixelFormat",
|
||||
table: "MediaStream",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BitsPerRawSample",
|
||||
table: "MediaStream");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PixelFormat",
|
||||
table: "MediaStream");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2976
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_AllStatistics : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE LibraryFolder SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+2979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_PlayoutAnchorInFlood : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Fix_PlayoutAnchorInFlood : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE Playout SET Anchor_InFlood = 0 WHERE Anchor_InFlood IS NULL");
|
||||
|
||||
migrationBuilder.AlterColumn<bool>(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Fix_MovieFallbackMetadataTitle : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE MovieMetadata SET Title = TRIM(Title), SortTitle = TRIM(SortTitle) WHERE MetadataKind = 0");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.6");
|
||||
.HasAnnotation("ProductVersion", "5.0.7");
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
|
||||
{
|
||||
@@ -733,6 +733,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BitsPerRawSample")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channels")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -757,6 +760,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<int>("MediaVersionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PixelFormat")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Profile")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -2183,6 +2189,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b1.Property<DateTime?>("DurationFinish")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<bool>("InFlood")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int?>("MultipleRemaining")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -17,24 +17,25 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.1.1" />
|
||||
<PackageReference Include="FluentValidation" Version="10.1.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.1.0" />
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.1.2" />
|
||||
<PackageReference Include="FluentValidation" Version="10.3.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.0" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="5.0.404" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Markdig" Version="0.24.0" />
|
||||
<PackageReference Include="Markdig" Version="0.25.0" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.6">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="5.0.10" />
|
||||
<PackageReference Include="MudBlazor" Version="5.0.15" />
|
||||
<PackageReference Include="NaturalSort.Extension" Version="3.1.0" />
|
||||
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.38" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<MudText Class="mt-3 mb-2" Typo="Typo.h6">Playout Mode</MudText>
|
||||
<ul class="mud-typography-body1">
|
||||
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item. A count of <b>0</b> is a special case that will automatically and always equal the number of items in the collection, so each item from the collection will be played once before advancing.</li>
|
||||
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
|
||||
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
|
||||
</ul>
|
||||
@@ -193,10 +193,14 @@
|
||||
private async Task LoadScheduleItems()
|
||||
{
|
||||
// TODO: fix performance
|
||||
_mediaCollections = await _mediator.Send(new GetAllCollections());
|
||||
_televisionShows = await _mediator.Send(new GetAllTelevisionShows());
|
||||
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons());
|
||||
_artists = await _mediator.Send(new GetAllArtists());
|
||||
_mediaCollections = await _mediator.Send(new GetAllCollections())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
_televisionShows = await _mediator.Send(new GetAllTelevisionShows())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
_artists = await _mediator.Send(new GetAllArtists())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
|
||||
string name = string.Empty;
|
||||
Option<ProgramScheduleViewModel> maybeSchedule = await _mediator.Send(new GetProgramScheduleById(Id));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using ErsatzTV.Application.ProgramSchedules.Queries
|
||||
@using ErsatzTV.Application.Configuration.Queries
|
||||
@using ErsatzTV.Application.Configuration.Commands
|
||||
@using NaturalSort.Extension
|
||||
@inject IDialogService _dialog
|
||||
@inject IMediator _mediator
|
||||
|
||||
@@ -135,7 +136,7 @@
|
||||
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesPageSize, state.PageSize.ToString()));
|
||||
|
||||
List<ProgramScheduleViewModel> schedules = await _mediator.Send(new GetAllProgramSchedules());
|
||||
IOrderedEnumerable<ProgramScheduleViewModel> sorted = schedules.OrderBy(s => s.Name);
|
||||
IOrderedEnumerable<ProgramScheduleViewModel> sorted = schedules.OrderBy(s => s.Name, new NaturalSortComparer(StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
// TODO: properly page this data
|
||||
return new TableData<ProgramScheduleViewModel>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_collections = await _mediator.Send(new GetAllCollections())
|
||||
.Map(list => new[] { _newCollection }.Append(list).ToList());
|
||||
.Map(list => new[] { _newCollection }.Append(list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase)).ToList());
|
||||
|
||||
if (_memoryCache.TryGetValue("AddToCollectionDialog.SelectedCollectionId", out int id))
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Application.ProgramSchedules.Queries
|
||||
@using NaturalSort.Extension
|
||||
@inject IMediator _mediator
|
||||
|
||||
<div @onkeydown="@OnKeyDown">
|
||||
@@ -49,7 +50,8 @@
|
||||
private ProgramScheduleViewModel _selectedSchedule;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_schedules = await _mediator.Send(new GetAllProgramSchedules());
|
||||
_schedules = await _mediator.Send(new GetAllProgramSchedules())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, new NaturalSortComparer(StringComparison.CurrentCultureIgnoreCase)).ToList());
|
||||
|
||||
private string FormatText() => $"Select the schedule to add the {EntityType} {EntityName}";
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Validators
|
||||
() => RuleFor(i => i.StartTime).NotNull());
|
||||
When(
|
||||
i => i.PlayoutMode == PlayoutMode.Multiple,
|
||||
() => RuleFor(i => i.MultipleCount).NotNull().GreaterThan(0));
|
||||
() => RuleFor(i => i.MultipleCount).NotNull().GreaterThanOrEqualTo(0));
|
||||
When(
|
||||
i => i.PlayoutMode == PlayoutMode.Duration,
|
||||
() => RuleFor(i => i.PlayoutDuration).NotNull());
|
||||
|
||||
Reference in New Issue
Block a user