Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
875010bbf4 | ||
|
|
c5692ef5f1 | ||
|
|
147ab6143d | ||
|
|
aca441074e | ||
|
|
ef6adf9cbb | ||
|
|
ddb7e1887f | ||
|
|
4997699b4d | ||
|
|
c27b906cd5 | ||
|
|
bec3cb864d | ||
|
|
03df2a6c8a | ||
|
|
6142dcf153 | ||
|
|
b287f791e6 | ||
|
|
2ccba9e476 | ||
|
|
e215807e56 | ||
|
|
b0333e89cd | ||
|
|
bc240a40e0 | ||
|
|
ed816e4b06 | ||
|
|
7c0f26ed3e | ||
|
|
a54e37a648 | ||
|
|
c0656114b8 | ||
|
|
7628ec7921 | ||
|
|
30850329f3 | ||
|
|
6bb1c4299f | ||
|
|
73c6758537 | ||
|
|
ef1400d3f8 | ||
|
|
494142f026 | ||
|
|
b89deffda3 | ||
|
|
ab55978732 | ||
|
|
b8dcd26e3a | ||
|
|
24f7544c9f | ||
|
|
afb2caa95d | ||
|
|
a684dcced9 | ||
|
|
cf1552910a | ||
|
|
e53e2b36cf | ||
|
|
793e85f889 | ||
|
|
126304bb8a | ||
|
|
7b3b9b4aad | ||
|
|
cf3b8d90e3 | ||
|
|
109b244676 | ||
|
|
9f42333465 | ||
|
|
c9141b0d86 | ||
|
|
d2c4a58528 | ||
|
|
e93d678b97 | ||
|
|
307940d732 | ||
|
|
721f0df82a | ||
|
|
aa87abc53d | ||
|
|
83d4aa0cb1 | ||
|
|
46034aff54 | ||
|
|
3e447ac7e4 | ||
|
|
bda27faaa3 | ||
|
|
80d89a2530 | ||
|
|
e849ef5dfa | ||
|
|
a26ecb91b8 | ||
|
|
2853e13edc | ||
|
|
9ba0b844a1 | ||
|
|
fdab54a055 | ||
|
|
7e0801119e | ||
|
|
b2f7bcaf1e | ||
|
|
71b8be37da | ||
|
|
f7d19e3747 | ||
|
|
17dcbfc344 | ||
|
|
78745de0ca | ||
|
|
35445e2b3d | ||
|
|
bd2f0f6236 | ||
|
|
4c67965b50 | ||
|
|
234e93349b | ||
|
|
e7e20de502 | ||
|
|
dfc36b4581 | ||
|
|
c56e2526c4 | ||
|
|
8ff6bf652c |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2022.1.0",
|
||||
"version": "2023.1.1",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
|
||||
+118
-1
@@ -5,6 +5,120 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.9-beta] - 2023-06-10
|
||||
### Added
|
||||
- Synchronize actor metadata from Jellyfin and Emby television libraries
|
||||
- New libraries and new episodes will get actor data automatically
|
||||
- Existing libraries can deep scan (one time) to retrieve actor data for existing episodes
|
||||
- `HLS Direct` streaming mode
|
||||
- Use `MP4` container/output format by default, with new global option to use `MKV` container/output format
|
||||
- `MP4` output format: stream copy dvd subtitles
|
||||
- `MKV` output format: stream copy any embedded subtitles
|
||||
|
||||
### Fixed
|
||||
- Fix extracting embedded text subtitles that had been incompletely extracted in the past
|
||||
- Fix fallback filler looping by forcing software mode for this content
|
||||
- Other content will still use hardware acceleration as configured
|
||||
- Hardware-accelerated fallback filler may be re-enabled in the future
|
||||
- Fix playout building when shuffle in order is used with a single media item
|
||||
- Fix pgs subtitle burn in from media server libraries
|
||||
- Fix subtitle and watermark overlays with RadeonSI VAAPI driver
|
||||
- Fix NVIDIA pipeline to use hardware-accelerated decoder with 8-bit h264 content
|
||||
|
||||
### Changed
|
||||
- Timeout playout builds after 2 minutes; this should prevent playout bugs from blocking other functionality
|
||||
|
||||
## [0.7.8-beta] - 2023-04-29
|
||||
### Added
|
||||
- Add `Season, Episode` playback order
|
||||
- This is currently *only* available when a show is added directly to a schedule
|
||||
- This will ignore release date and sort exclusively by season number and then by episode number
|
||||
- Add `Show Media Info` button to movie and episode detail pages for troubleshooting
|
||||
|
||||
### Fixed
|
||||
- Limit `HLS Direct` streams to realtime speed
|
||||
- Fix `Reset Playout` button to use worker thread instead of UI thread
|
||||
- This fixes potential UI hangs and database concurrency bugs
|
||||
- Maintain watermark alpha channel (built-in transparency) using QSV acceleration
|
||||
- Properly extract and burn in embedded text subtitles using Jellyfin, Emby and Plex libraries
|
||||
- Fix bug where deleting a channel would not remove its data from XMLTV
|
||||
- Fix colorspace filter for some files with invalid color metadata
|
||||
- Fix playback of external subtitles on Windows
|
||||
- Fix vobsub subtitle burn in from media server libraries
|
||||
|
||||
### Changed
|
||||
- Remove duplicate items from smart collections before scheduling
|
||||
- i.e. shows no longer need to be filtered out if search results also include episodes
|
||||
- Certain multi-collection scenarios may still include duplicates across multiple collections
|
||||
- Use autocomplete fields for collection searching in schedule items editor
|
||||
- This greatly improves the editor performance
|
||||
- Ignore dot underscore files
|
||||
|
||||
## [0.7.7-beta] - 2023-04-07
|
||||
### Added
|
||||
- Use `plot` field from Other Video NFO metadata as XMLTV description
|
||||
- Add detailed warning log when a file is added to ErsatzTV more than once
|
||||
|
||||
### Fixed
|
||||
- Fix updating (re-adding) Trakt lists to properly use new metadata ids that were not present when originally added
|
||||
- Fix local show library scanning with non-english season folder names, e.g. `Staffel 02`
|
||||
- Fix bug where local libraries would merge with media server libraries when the same file was added to both libraries
|
||||
- Fix transcoding some 10-bit content from media servers using VAAPI acceleration
|
||||
- Fix decoding of MPEG-4 Part 2 (e.g. DivX) content using NVIDIA acceleration
|
||||
- Fix color normalization from `bt470bg` to `bt709` using QSV acceleration
|
||||
- Fix adding files to search index with unknown video codec
|
||||
- Fix subtitle burn-in (embedded or external) using Jellyfin, Emby and Plex libraries
|
||||
- **This requires a one-time full library scan, which may take a long time with large libraries.**
|
||||
|
||||
### Changed
|
||||
- Use Poster artwork for XMLTV if available
|
||||
- If Poster artwork is unavailable, use Thumbnail
|
||||
- Improve XMLTV response time by caching data as playouts are updated
|
||||
|
||||
## [0.7.6-beta] - 2023-03-24
|
||||
### Added
|
||||
- Add `Troubleshooting` page with aggregated settings/hardware accel info for easy reference
|
||||
- Read `director` fields from music video NFO metadata
|
||||
- Pass `directors` and `studios` to music video credit templates
|
||||
- Add optional JSON Web Token (JWT) query string auth for streaming endpoints (everything under `/iptv`)
|
||||
- This can be configured using the following env var (note the double underscore separator `__`)
|
||||
- `JWT__ISSUERSIGNINGKEY`
|
||||
- When configured, a JWT signed with the configured signing key is required to be passed in the query string as `access_token`, for example:
|
||||
- `http://localhost:8409/iptv/channels.m3u?access_token=ABCDEF`
|
||||
- `http://localhost:8409/iptv/xmltv.xml?access_token=ABCDEF`
|
||||
- When channels are retrieved this way, the access token will automatically be passed through to all necessary urls
|
||||
- Note that ONLY the `/iptv` endpoints will require auth when JWT is configured
|
||||
|
||||
### Fixed
|
||||
- Fix scaling anamorphic content from non-local libraries
|
||||
- Fix direct streaming content from Jellyfin that has external subtitles
|
||||
- Note that these subtitles are not currently supported in ETV, but they did cause a playback issue
|
||||
- Fix Jellyfin, Emby and Plex library scans that wouldn't work in certain timezones
|
||||
- Fix song normalization to match FFmpeg Profile bit depth
|
||||
- Fix bug playing some external subtitle files (e.g. with an apostrophe in the file name)
|
||||
- Fix bug detecting VAAPI capabilities when no device is selected in active FFmpeg Profile
|
||||
- Fix playout mode duration bugs in XMLTV
|
||||
- Tail mode filler will properly include filler duration in XMLTV
|
||||
- Duration that wraps across midnight will no longer have overlapping items in XMLTV
|
||||
- Maintain collection progress across all alternate schedules on a playout
|
||||
- Fix color normalization from `bt470bg` to `bt709`
|
||||
|
||||
### Changed
|
||||
- Ignore case of video and audio file extensions in local folder scanner
|
||||
- For example, the scanner will now find `movie.MKV` as well as `movie.mkv` on case-sensitive filesystems
|
||||
- Include multiple `display-name` entries in generated XMLTV
|
||||
- Plex should now display the channel number instead of the channel id (e.g. `1.2` instead of `1.2.etv`)
|
||||
- Rework concurrency a bit
|
||||
- Playout builds are no longer blocked by library scans
|
||||
- Adding Trakt lists is no longer blocked by library scans
|
||||
- All library scans (local and media servers) run sequentially
|
||||
- Emby collection scanning will no longer happen after every (automatic or forced) library scan
|
||||
- Automatic/periodic scans will check collections one time after all libraries have been scanned
|
||||
- There is a new table in the `Media` > `Libraries` page with a button to manually re-scan Emby collections as needed
|
||||
- For performance reasons, limit console log output to errors on Windows
|
||||
- Other platforms are unchanged
|
||||
- Log file behavior is unchanged
|
||||
|
||||
## [0.7.5-beta] - 2023-03-05
|
||||
### Added
|
||||
- Use AV1 hardware-accelerated decoder with VAAPI, QSV, NVIDIA when available
|
||||
@@ -1551,7 +1665,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.7.5-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.8-beta...HEAD
|
||||
[0.7.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.7-beta...v0.7.8-beta
|
||||
[0.7.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.6-beta...v0.7.7-beta
|
||||
[0.7.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.5-beta...v0.7.6-beta
|
||||
[0.7.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.4-beta...v0.7.5-beta
|
||||
[0.7.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.3-beta...v0.7.4-beta
|
||||
[0.7.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.2-beta...v0.7.3-beta
|
||||
|
||||
@@ -10,10 +10,7 @@ public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMed
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
GetAllArtists request,
|
||||
@@ -24,8 +21,8 @@ public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMed
|
||||
List<Artist> allArtists = await dbContext.Artists
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArtistMetadata)
|
||||
.ToListAsync(cancellationToken: cancellationToken);
|
||||
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return allArtists.Bind(a => ProjectArtist(a)).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Task>>;
|
||||
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -1,23 +1,61 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Task>>
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeleteChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
public DeleteChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Task>> Handle(DeleteChannel request, CancellationToken cancellationToken) =>
|
||||
(await ChannelMustExist(request))
|
||||
.Map(DoDeletion)
|
||||
.ToEither<Task>();
|
||||
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request);
|
||||
|
||||
private Task DoDeletion(int channelId) => _channelRepository.Delete(channelId);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, int>> ChannelMustExist(DeleteChannel deleteChannel) =>
|
||||
(await _channelRepository.Get(deleteChannel.ChannelId))
|
||||
.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.")
|
||||
.Map(c => c.Id);
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.Channels.Remove(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteChannel deleteChannel)
|
||||
{
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
|
||||
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,521 @@
|
||||
using System.Xml;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RefreshChannelDataHandler> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public RefreshChannelDataHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshChannelDataHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlayoutItem> sorted = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(em => em.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList());
|
||||
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.Tail or FillerKind.Fallback))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
int customShowId = -1;
|
||||
if (displayItem.MediaItem is Episode ep)
|
||||
{
|
||||
customShowId = ep.Season.ShowId;
|
||||
}
|
||||
|
||||
bool isSameCustomShow = hasCustomTitle;
|
||||
for (int x = j; x <= finishIndex; x++)
|
||||
{
|
||||
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
|
||||
customShowId == e.Season.ShowId;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||
string stop = displayItem.GuideFinishOffset.HasValue
|
||||
? displayItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty)
|
||||
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
|
||||
|
||||
string title = GetTitle(displayItem);
|
||||
string subtitle = GetSubtitle(displayItem);
|
||||
string description = GetDescription(displayItem);
|
||||
Option<ContentRating> contentRating = GetContentRating(displayItem);
|
||||
|
||||
await xml.WriteStartElementAsync(null, "programme", null);
|
||||
await xml.WriteAttributeStringAsync(null, "start", null, start);
|
||||
await xml.WriteAttributeStringAsync(null, "stop", null, stop);
|
||||
await xml.WriteAttributeStringAsync(null, "channel", null, $"{request.ChannelNumber}.etv");
|
||||
|
||||
await xml.WriteStartElementAsync(null, "title", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(title);
|
||||
await xml.WriteEndElementAsync(); // title
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "sub-title", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(subtitle);
|
||||
await xml.WriteEndElementAsync(); // subtitle
|
||||
}
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "desc", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(description);
|
||||
await xml.WriteEndElementAsync(); // desc
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is Movie movie)
|
||||
{
|
||||
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
|
||||
{
|
||||
if (metadata.Year.HasValue)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "date", null);
|
||||
await xml.WriteStringAsync(metadata.Year.Value.ToString());
|
||||
await xml.WriteEndElementAsync(); // date
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Movie");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
string poster = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, poster);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is MusicVideo musicVideo)
|
||||
{
|
||||
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone())
|
||||
{
|
||||
if (metadata.Year.HasValue)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "date", null);
|
||||
await xml.WriteStringAsync(metadata.Year.Value.ToString());
|
||||
await xml.WriteEndElementAsync(); // date
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Music");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
// music video genres
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
// artist genres
|
||||
Option<ArtistMetadata> maybeMetadata =
|
||||
Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
|
||||
foreach (ArtistMetadata artistMetadata in maybeMetadata)
|
||||
{
|
||||
foreach (Genre genre in Optional(artistMetadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
}
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is Song song)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Music");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (SongMetadata metadata in song.SongMetadata.HeadOrNone())
|
||||
{
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (displayItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
|
||||
{
|
||||
Option<ShowMetadata> maybeMetadata =
|
||||
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
|
||||
foreach (ShowMetadata metadata in maybeMetadata)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync("Series");
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten().OrderBy(g => g.Name))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(genre.Name);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
if (!string.IsNullOrWhiteSpace(artworkPath))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, artworkPath);
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
int s = await Optional(episode.Season?.SeasonNumber).IfNoneAsync(-1);
|
||||
// TODO: multi-episode?
|
||||
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
|
||||
if (s >= 0 && e > 0)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "episode-num", null);
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, "onscreen");
|
||||
await xml.WriteStringAsync($"S{s:00}E{e:00}");
|
||||
await xml.WriteEndElementAsync(); // episode-num
|
||||
|
||||
await xml.WriteStartElementAsync(null, "episode-num", null);
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, "xmltv_ns");
|
||||
await xml.WriteStringAsync($"{s - 1}.{e - 1}.0/1");
|
||||
await xml.WriteEndElementAsync(); // episode-num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "previously-shown", null);
|
||||
await xml.WriteEndElementAsync(); // previously-shown
|
||||
|
||||
foreach (ContentRating rating in contentRating)
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "rating", null);
|
||||
foreach (string system in rating.System)
|
||||
{
|
||||
await xml.WriteAttributeStringAsync(null, "system", null, system);
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "value", null);
|
||||
await xml.WriteStringAsync(rating.Value);
|
||||
await xml.WriteEndElementAsync(); // value
|
||||
await xml.WriteEndElementAsync(); // rating
|
||||
}
|
||||
|
||||
await xml.WriteEndElementAsync(); // programme
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
|
||||
{
|
||||
string artworkPath = artwork.Path;
|
||||
|
||||
int height = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => 220,
|
||||
_ => 440
|
||||
};
|
||||
|
||||
if (artworkPath.StartsWith("jellyfin://"))
|
||||
{
|
||||
artworkPath = JellyfinUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||
}
|
||||
else if (artworkPath.StartsWith("emby://"))
|
||||
{
|
||||
artworkPath = EmbyUrl.PlaceholderProxyForArtwork(artworkPath, artworkKind, height);
|
||||
}
|
||||
else
|
||||
{
|
||||
string artworkFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => "thumbnails",
|
||||
_ => "posters"
|
||||
};
|
||||
|
||||
artworkPath = $"{{RequestBase}}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg{{AccessTokenUri}}";
|
||||
}
|
||||
|
||||
return artworkPath;
|
||||
}
|
||||
|
||||
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]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
Song s => s.SongMetadata.HeadOrNone().Map(sm => sm.Artist ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
_ => "[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),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.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),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Plot ?? string.Empty)
|
||||
.IfNone(string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata
|
||||
.HeadOrNone()
|
||||
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
|
||||
Episode e => e.Season.Show.ShowMetadata
|
||||
.HeadOrNone()
|
||||
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
|
||||
_ => None
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
|
||||
{
|
||||
Option<string> maybeFirst = (contentRating ?? string.Empty).Split('/').HeadOrNone();
|
||||
return maybeFirst.Map(
|
||||
first =>
|
||||
{
|
||||
string[] split = first.Split(':');
|
||||
if (split.Length == 2)
|
||||
{
|
||||
return split[0].ToLowerInvariant() == "us"
|
||||
? new ContentRating(system, split[1].ToUpperInvariant())
|
||||
: new ContentRating(None, split[1].ToUpperInvariant());
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(first)
|
||||
? Option<ContentRating>.None
|
||||
: new ContentRating(None, first);
|
||||
}).Flatten();
|
||||
}
|
||||
|
||||
private string GetPrioritizedArtworkPath(Metadata metadata)
|
||||
{
|
||||
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Map(a => GetArtworkUrl(a, ArtworkKind.Poster));
|
||||
|
||||
if (maybeArtwork.IsNone)
|
||||
{
|
||||
maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
|
||||
.HeadOrNone()
|
||||
.Map(a => GetArtworkUrl(a, ArtworkKind.Thumbnail));
|
||||
}
|
||||
|
||||
return maybeArtwork.IfNone(string.Empty);
|
||||
}
|
||||
|
||||
private record ContentRating(Option<string> System, string Value);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record RefreshChannelList : IRequest, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Xml;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public RefreshChannelListHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "channel", null);
|
||||
await xml.WriteAttributeStringAsync(null, "id", null, $"{channel.Number}.etv");
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync($"{channel.Number} {channel.Name}");
|
||||
await xml.WriteEndElementAsync(); // display-name (number and name)
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync(channel.Number);
|
||||
await xml.WriteEndElementAsync(); // display-name (number)
|
||||
|
||||
await xml.WriteStartElementAsync(null, "display-name", null);
|
||||
await xml.WriteStringAsync(channel.Name);
|
||||
await xml.WriteEndElementAsync(); // display-name (name)
|
||||
|
||||
foreach (string category in GetCategories(channel.Categories))
|
||||
{
|
||||
await xml.WriteStartElementAsync(null, "category", null);
|
||||
await xml.WriteAttributeStringAsync(null, "lang", null, "en");
|
||||
await xml.WriteStringAsync(category);
|
||||
await xml.WriteEndElementAsync(); // category
|
||||
}
|
||||
|
||||
await xml.WriteStartElementAsync(null, "icon", null);
|
||||
await xml.WriteAttributeStringAsync(null, "src", null, GetIconUrl(channel));
|
||||
await xml.WriteEndElementAsync(); // icon
|
||||
|
||||
await xml.WriteEndElementAsync(); // channel
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChannelResult> GetChannels(TvContext dbContext)
|
||||
{
|
||||
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
|
||||
from Channel C
|
||||
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
|
||||
where C.Id in (select ChannelId from Playout)
|
||||
order by CAST(C.Number as real)";
|
||||
|
||||
await using var reader = (DbDataReader)await dbContext.Connection.ExecuteReaderAsync(QUERY);
|
||||
Func<IDataReader, ChannelResult> rowParser = reader.GetRowParser<ChannelResult>();
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
yield return rowParser(reader);
|
||||
}
|
||||
|
||||
while (await reader.NextResultAsync())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetCategories(string categories) =>
|
||||
(categories ?? string.Empty).Split(',')
|
||||
.Map(s => s.Trim())
|
||||
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
private static string GetIconUrl(ChannelResult channel) =>
|
||||
string.IsNullOrWhiteSpace(channel.ArtworkPath)
|
||||
? "{RequestBase}/iptv/images/ersatztv-500.png{AccessTokenUri}"
|
||||
: $"{{RequestBase}}/iptv/logos/{channel.ArtworkPath}.jpg{{AccessTokenUri}}";
|
||||
|
||||
// ReSharper disable once ClassNeverInstantiated.Local
|
||||
private record ChannelResult(string Number, string Name, string Categories, string ArtworkPath);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||
return await LanguageExtensions.Apply(validation, c => ApplyUpdateRequest(dbContext, c, request));
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
|
||||
|
||||
@@ -86,10 +86,20 @@ public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, O
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
if (distinct.Any())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelGuide(string Scheme, string Host, string BaseUrl) : IRequest<ChannelGuide>;
|
||||
public record GetChannelGuide
|
||||
(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<Either<BaseError, ChannelGuide>>;
|
||||
|
||||
@@ -1,29 +1,72 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IChannelRepository channelRepository,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_channelRepository = channelRepository;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAllForGuide()
|
||||
.Map(
|
||||
channels => new ChannelGuide(
|
||||
_recyclableMemoryStreamManager,
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels));
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_localFileSystem.FileExists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
string accessTokenUri = string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
accessTokenUri = $"?access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
}
|
||||
|
||||
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameBy
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaylist(string Scheme, string Host, string BaseUrl, string Mode) : IRequest<ChannelPlaylist>;
|
||||
public record GetChannelPlaylist
|
||||
(string Scheme, string Host, string BaseUrl, string Mode, string AccessToken) : IRequest<ChannelPlaylist>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
@@ -14,7 +14,13 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, request.BaseUrl, channels));
|
||||
.Map(
|
||||
channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels,
|
||||
request.AccessToken));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
|
||||
@@ -9,8 +9,6 @@ public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementBy
|
||||
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
|
||||
{
|
||||
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
|
||||
await _configElementRepository.Upsert(request.Key, request.Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
|
||||
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
public CallEmbyCollectionScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.EmbyMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
|
||||
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizeEmbyCollections request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
SynchronizeEmbyCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby-collections", request.EmbyMediaSourceId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,10 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ForceSynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>.Handle(
|
||||
SynchronizeEmbyLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>.
|
||||
Handle(
|
||||
SynchronizeEmbyLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
@@ -77,9 +78,11 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
{
|
||||
return await dbContext.EmbyLibraries
|
||||
DateTime minDateTime = await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -93,6 +96,6 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyLibraries(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IEmbyBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, IEmbyBackgroundServiceRequest
|
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
|
||||
{
|
||||
int EmbyLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
|
||||
@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Emby;
|
||||
public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmbyMediaSources,
|
||||
Either<BaseError, List<EmbyMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public SynchronizeEmbyMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<EmbyMediaSource>>> Handle(
|
||||
@@ -27,7 +27,7 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
|
||||
foreach (EmbyMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
return mediaSources;
|
||||
|
||||
@@ -10,8 +10,8 @@ namespace ErsatzTV.Application.Emby;
|
||||
public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnectionParameters,
|
||||
Either<BaseError, EmbyConnectionParametersViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetEmbyConnectionParametersHandler(
|
||||
@@ -65,7 +65,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection))
|
||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
||||
}
|
||||
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.3" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="12.0.0" />
|
||||
<PackageReference Include="MediatR" Version="12.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.5.22">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.6.40">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="2.0.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, E
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, FFmpegProfile> validation = await FFmpegProfileMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, p => DoDeletion(dbContext, p));
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
}
|
||||
|
||||
private static async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
|
||||
|
||||
@@ -20,7 +20,7 @@ public class
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
|
||||
return await LanguageExtensions.Apply(validation, p => ApplyUpdateRequest(dbContext, p, request));
|
||||
}
|
||||
|
||||
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
|
||||
@@ -36,12 +36,12 @@ public class
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
p.BitDepth = update.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: update.BitDepth;
|
||||
|
||||
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
|
||||
@@ -75,6 +75,9 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegSaveReports,
|
||||
request.Settings.SaveReports.ToString());
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegHlsDirectOutputFormat,
|
||||
request.Settings.HlsDirectOutputFormat);
|
||||
|
||||
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -12,4 +14,5 @@ public class FFmpegSettingsViewModel
|
||||
public int HlsSegmenterIdleTimeout { get; set; }
|
||||
public int WorkAheadSegmenterLimit { get; set; }
|
||||
public int InitialSegmentCount { get; set; }
|
||||
public OutputFormatKind HlsDirectOutputFormat { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
@@ -32,6 +33,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
|
||||
Option<int> initialSegmentCount =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount);
|
||||
Option<OutputFormatKind> outputFormatKind =
|
||||
await _configElementRepository.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -42,7 +45,8 @@ public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpe
|
||||
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
|
||||
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
|
||||
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
|
||||
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1)
|
||||
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1),
|
||||
HlsDirectOutputFormat = await outputFormatKind.IfNoneAsync(OutputFormatKind.Mp4)
|
||||
};
|
||||
|
||||
foreach (int watermarkId in watermark)
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, ps => DoDeletion(dbContext, ps));
|
||||
return await validation.Apply(ps => DoDeletion(dbContext, ps));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset)
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllFillerPresetsHandler : IRequestHandler<GetAllFillerPresets, L
|
||||
GetAllFillerPresets request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FillerPresets.ToListAsync(cancellationToken)
|
||||
.Map(presets => presets.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface IScannerBackgroundServiceRequest
|
||||
{
|
||||
}
|
||||
@@ -24,13 +24,15 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.Handle(
|
||||
ForceSynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.
|
||||
Handle(
|
||||
ForceSynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>.Handle(
|
||||
SynchronizeJellyfinLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>.
|
||||
Handle(
|
||||
SynchronizeJellyfinLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
@@ -64,7 +66,7 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
@@ -77,9 +79,11 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
TvContext dbContext,
|
||||
ISynchronizeJellyfinLibraryById request)
|
||||
{
|
||||
return await dbContext.JellyfinLibraries
|
||||
DateTime minDateTime = await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -93,6 +97,6 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISync
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinAdminUserId(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinLibraries(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public interface ISynchronizeJellyfinLibraryById : IRequest<Either<BaseError, string>>,
|
||||
IJellyfinBackgroundServiceRequest
|
||||
IScannerBackgroundServiceRequest
|
||||
{
|
||||
int JellyfinLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
|
||||
@@ -8,15 +8,15 @@ namespace ErsatzTV.Application.Jellyfin;
|
||||
public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<SynchronizeJellyfinMediaSources,
|
||||
Either<BaseError, List<JellyfinMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public SynchronizeJellyfinMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinMediaSource>>> Handle(
|
||||
@@ -26,8 +26,10 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
|
||||
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
|
||||
foreach (JellyfinMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinAdminUserId(mediaSource.Id),
|
||||
cancellationToken);
|
||||
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
return mediaSources;
|
||||
|
||||
@@ -20,9 +20,9 @@ namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler<TRequest>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private string _libraryName;
|
||||
@@ -66,7 +66,7 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
@@ -152,9 +152,9 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
.IfNoneAsync(0);
|
||||
|
||||
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
||||
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
|
||||
DateTimeOffset lastScan = await GetLastScan(dbContext, request);
|
||||
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
||||
{
|
||||
@@ -164,7 +164,7 @@ public abstract class CallLibraryScannerHandler<TRequest>
|
||||
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ErsatzTV.Scanner.exe"
|
||||
: "ErsatzTV.Scanner";
|
||||
|
||||
|
||||
string processFileName = Environment.ProcessPath ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(processFileName))
|
||||
{
|
||||
|
||||
@@ -14,14 +14,14 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public CreateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
return await LanguageExtensions.Apply(validation, localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
|
||||
@@ -44,7 +44,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(localLibrary.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(localLibrary);
|
||||
|
||||
@@ -39,7 +39,7 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters));
|
||||
return await LanguageExtensions.Apply(validation, parameters => MovePath(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
|
||||
|
||||
@@ -16,16 +16,16 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_searchIndex = searchIndex;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -70,7 +70,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(existing);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record GetExternalCollections : IRequest<List<LibraryViewModel>>;
|
||||
@@ -0,0 +1,33 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollections, List<LibraryViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetExternalCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<LibraryViewModel>> Handle(
|
||||
GetExternalCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<int> mediaSourceIds = await dbContext.EmbyMediaSources
|
||||
.Filter(ems => ems.Libraries.Any(l => ((EmbyLibrary)l).ShouldSyncItems))
|
||||
.Map(ems => ems.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return mediaSourceIds.Map(
|
||||
id => new LibraryViewModel(
|
||||
"Emby",
|
||||
0,
|
||||
"Collections",
|
||||
0,
|
||||
id))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ internal partial class Mapper
|
||||
{
|
||||
[GeneratedRegex(@"(.*)\[(DBG|INF|WRN|ERR|FTL)\](.*)")]
|
||||
private static partial Regex LogEntryRegex();
|
||||
|
||||
|
||||
internal static Option<LogEntryViewModel> ProjectToViewModel(string line)
|
||||
{
|
||||
Match match = LogEntryRegex().Match(line);
|
||||
|
||||
@@ -8,10 +8,7 @@ public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, P
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public GetRecentLogEntriesHandler(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
public GetRecentLogEntriesHandler(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
|
||||
|
||||
public Task<PagedLogEntriesViewModel> Handle(
|
||||
GetRecentLogEntries request,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public record DeleteOrphanedSubtitles : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,42 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class DeleteOrphanedSubtitlesHandler : IRequestHandler<DeleteOrphanedSubtitles, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public DeleteOrphanedSubtitlesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteOrphanedSubtitles request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
IEnumerable<int> toDelete = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT S.Id FROM Subtitle S
|
||||
WHERE S.ArtistMetadataId IS NULL AND S.EpisodeMetadataId IS NULL
|
||||
AND S.MovieMetadataId IS NULL AND S.MusicVideoMetadataId IS NULL
|
||||
AND S.OtherVideoMetadataId IS NULL AND S.SeasonMetadataId IS NULL
|
||||
AND S.ShowMetadataId IS NULL AND s.SongMetadataId IS NULL");
|
||||
|
||||
foreach (int id in toDelete)
|
||||
{
|
||||
await dbContext.Connection.ExecuteAsync("DELETE FROM Subtitle WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.Maintenance;
|
||||
public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
|
||||
{
|
||||
private static long _lastRelease;
|
||||
|
||||
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILogger<ReleaseMemoryHandler> _logger;
|
||||
|
||||
@@ -31,12 +31,12 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
|
||||
if (request.ForceAggressive || !hasActiveWorkers)
|
||||
{
|
||||
_logger.LogDebug("Starting aggressive garbage collection");
|
||||
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true, compacting: true);
|
||||
GC.Collect(2, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Starting garbage collection");
|
||||
GC.Collect(2, GCCollectionMode.Forced, blocking: false);
|
||||
GC.Collect(2, GCCollectionMode.Forced, false);
|
||||
}
|
||||
|
||||
GC.WaitForPendingFinalizers();
|
||||
|
||||
@@ -61,7 +61,7 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
private async Task<Either<BaseError, Unit>> DoAdd(Parameters parameters)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
|
||||
.BindT(list => SaveList(dbContext, list))
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CreateCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => PersistCollection(dbContext, c));
|
||||
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<MediaCollectionViewModel> PersistCollection(
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CreateMultiCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => PersistCollection(dbContext, c));
|
||||
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<MultiCollectionViewModel> PersistCollection(
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CreateSmartCollectionHandler :
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => PersistCollection(dbContext, c));
|
||||
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<SmartCollectionViewModel> PersistCollection(
|
||||
|
||||
@@ -20,7 +20,7 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, Collection collection)
|
||||
|
||||
@@ -20,7 +20,7 @@ public class DeleteMultiCollectionHandler : IRequestHandler<DeleteMultiCollectio
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, MultiCollection> validation = await MultiCollectionMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection)
|
||||
|
||||
@@ -20,7 +20,7 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => DoDeletion(dbContext, c));
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
|
||||
|
||||
@@ -87,7 +87,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (TraktListItem existing in toUpdate)
|
||||
{
|
||||
Option<TraktListItem> maybeIncoming = list.Items.Find(i => i.TraktId == existing.TraktId);
|
||||
Option<TraktListItem> maybeIncoming = items
|
||||
.Filter(i => i.TraktId == existing.TraktId)
|
||||
.Map(i => ProjectItem(list, i))
|
||||
.HeadOrNone();
|
||||
|
||||
foreach (TraktListItem incoming in maybeIncoming)
|
||||
{
|
||||
existing.Kind = incoming.Kind;
|
||||
@@ -208,7 +212,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int movieId in maybeMovieByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
@@ -221,11 +225,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int movieId in maybeMovieByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||
return movieId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -243,7 +247,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int showId in maybeShowByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||
return showId;
|
||||
}
|
||||
|
||||
@@ -256,11 +260,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int showId in maybeShowByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||
// _logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||
return showId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -278,7 +282,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int seasonId in maybeSeasonByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
@@ -292,11 +296,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int seasonId in maybeSeasonByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||
return seasonId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
@@ -314,7 +318,7 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByGuid)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
@@ -329,11 +333,11 @@ public abstract class TraktCommandBase
|
||||
|
||||
foreach (int episodeId in maybeEpisodeByTitleYear)
|
||||
{
|
||||
_logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||
// _logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||
return episodeId;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||
// _logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllCollectionsHandler : IRequestHandler<GetAllCollections, List<
|
||||
GetAllCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Collections
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllMultiCollectionsHandler : IRequestHandler<GetAllMultiCollecti
|
||||
GetAllMultiCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.MultiCollections
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollecti
|
||||
GetAllSmartCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.SmartCollections
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems;
|
||||
|
||||
public record MediaItemInfo(
|
||||
int Id,
|
||||
string Kind,
|
||||
string LibraryKind,
|
||||
string ServerName,
|
||||
string LibraryName,
|
||||
MediaItemState State,
|
||||
TimeSpan Duration,
|
||||
string SampleAspectRatio,
|
||||
string DisplayAspectRatio,
|
||||
string RFrameRate,
|
||||
VideoScanKind VideoScanKind,
|
||||
int Width,
|
||||
int Height,
|
||||
List<MediaItemInfoStream> Streams);
|
||||
@@ -0,0 +1,23 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems;
|
||||
|
||||
public record MediaItemInfoStream(
|
||||
int Index,
|
||||
MediaStreamKind Kind,
|
||||
string Title,
|
||||
string Codec,
|
||||
string Profile,
|
||||
string Language,
|
||||
int? Channels,
|
||||
bool? Default,
|
||||
bool? Forced,
|
||||
bool? AttachedPic,
|
||||
string PixelFormat,
|
||||
string ColorRange,
|
||||
string ColorSpace,
|
||||
string ColorTransfer,
|
||||
string ColorPrimaries,
|
||||
int? BitsPerRawSample,
|
||||
string FileName,
|
||||
string MimeType);
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems;
|
||||
|
||||
public record GetMediaItemInfo(int Id) : IRequest<Either<BaseError, MediaItemInfo>>;
|
||||
@@ -0,0 +1,95 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems;
|
||||
|
||||
public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<BaseError, MediaItemInfo>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetMediaItemInfoHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, MediaItemInfo>> Handle(
|
||||
GetMediaItemInfo request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<MediaItemInfo> mediaItem = await dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ThenInclude(l => l.MediaSource)
|
||||
// TODO: support all media types here
|
||||
.Include(i => (i as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => (i as Episode).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.SelectOneAsync(i => i.Id, i => i.Id == request.Id)
|
||||
.MapT(Project);
|
||||
|
||||
return mediaItem.ToEither(BaseError.New("Unable to locate media item"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static MediaItemInfo Project(MediaItem mediaItem)
|
||||
{
|
||||
MediaVersion version = mediaItem.GetHeadVersion();
|
||||
|
||||
string serverName = mediaItem.LibraryPath.Library.MediaSource switch
|
||||
{
|
||||
PlexMediaSource plexMediaSource => plexMediaSource.ServerName,
|
||||
EmbyMediaSource embyMediaSource => embyMediaSource.ServerName,
|
||||
JellyfinMediaSource jellyfinMediaSource => jellyfinMediaSource.ServerName,
|
||||
_ => null
|
||||
};
|
||||
|
||||
return new MediaItemInfo(
|
||||
mediaItem.Id,
|
||||
mediaItem.GetType().Name,
|
||||
mediaItem.LibraryPath.Library.GetType().Name,
|
||||
serverName,
|
||||
mediaItem.LibraryPath.Library.Name,
|
||||
mediaItem.State,
|
||||
version.Duration,
|
||||
version.SampleAspectRatio,
|
||||
version.DisplayAspectRatio,
|
||||
version.RFrameRate,
|
||||
version.VideoScanKind,
|
||||
version.Width,
|
||||
version.Height,
|
||||
version.Streams.OrderBy(s => s.Index).Map(Project).ToList());
|
||||
}
|
||||
|
||||
private static MediaItemInfoStream Project(MediaStream mediaStream) =>
|
||||
new(
|
||||
mediaStream.Index,
|
||||
mediaStream.MediaStreamKind,
|
||||
mediaStream.Title,
|
||||
mediaStream.Codec,
|
||||
mediaStream.Profile,
|
||||
mediaStream.Language,
|
||||
mediaStream.Channels > 0 ? mediaStream.Channels : null,
|
||||
mediaStream.Default ? true : null,
|
||||
mediaStream.Forced ? true : null,
|
||||
mediaStream.AttachedPic ? true : null,
|
||||
mediaStream.PixelFormat,
|
||||
mediaStream.ColorRange,
|
||||
mediaStream.ColorSpace,
|
||||
mediaStream.ColorTransfer,
|
||||
mediaStream.ColorPrimaries,
|
||||
mediaStream.BitsPerRawSample > 0 ? mediaStream.BitsPerRawSample : null,
|
||||
mediaStream.FileName,
|
||||
mediaStream.MimeType);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
@@ -67,11 +68,11 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, IScanLocalLibrary request)
|
||||
{
|
||||
var libraryPaths = await dbContext.LibraryPaths
|
||||
List<LibraryPath> libraryPaths = await dbContext.LibraryPaths
|
||||
.Filter(lp => lp.LibraryId == request.LibraryId)
|
||||
.ToListAsync();
|
||||
|
||||
var minDateTime = libraryPaths.Any()
|
||||
|
||||
DateTime minDateTime = libraryPaths.Any()
|
||||
? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc)
|
||||
: SystemTime.MaxValueUtc;
|
||||
|
||||
@@ -89,6 +90,6 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLoc
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public interface IScanLocalLibrary : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
|
||||
public interface IScanLocalLibrary : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
|
||||
{
|
||||
int LibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using Bugsnag;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -17,8 +19,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
private readonly IClient _client;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IPlayoutBuilder _playoutBuilder;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public BuildPlayoutHandler(
|
||||
IClient client,
|
||||
@@ -38,29 +40,60 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
return await validation.Match(
|
||||
playout => ApplyUpdateRequest(dbContext, request, playout, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, BuildPlayout request, Playout playout)
|
||||
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
BuildPlayout request,
|
||||
Playout playout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _playoutBuilder.Build(playout, request.Mode);
|
||||
await _playoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
|
||||
// let any active segmenter processes know that the playout has been modified
|
||||
// and therefore the segmenter may need to seek into the next item instead of
|
||||
// starting at the beginning (if already working ahead)
|
||||
bool hasChanges = await dbContext.SaveChangesAsync() > 0;
|
||||
bool hasChanges = await dbContext.SaveChangesAsync(cancellationToken) > 0;
|
||||
if (request.Mode != PlayoutBuildMode.Continue && hasChanges)
|
||||
{
|
||||
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
|
||||
}
|
||||
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
Option<string> maybeChannelNumber = await dbContext.Connection
|
||||
.QuerySingleOrDefaultAsync<string>(
|
||||
@"select C.Number from Channel C
|
||||
inner join Playout P on C.Id = P.ChannelId
|
||||
where P.Id = @PlayoutId",
|
||||
new { request.PlayoutId })
|
||||
.Map(Optional);
|
||||
|
||||
foreach (string channelNumber in maybeChannelNumber)
|
||||
{
|
||||
string fileName = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml");
|
||||
if (hasChanges || !File.Exists(fileName))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
return BaseError.New(
|
||||
$"Timeout building playout for channel {playout.Channel.Name}; this may be a bug!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_client.Notify(ex);
|
||||
return BaseError.New(
|
||||
$"Unexpected error building playout for channel {playout.Channel.Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
@@ -75,7 +108,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
@@ -104,12 +136,10 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(psa => psa.EnumeratorState)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
@@ -131,7 +161,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == buildPlayout.PlayoutId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -36,6 +37,7 @@ public class CreatePlayoutHandler : IRequestHandler<CreatePlayout, Either<BaseEr
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
|
||||
await _channel.WriteAsync(new RefreshChannelList());
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
@@ -8,24 +12,44 @@ namespace ErsatzTV.Application.Playouts;
|
||||
public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeletePlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
public DeletePlayoutHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeletePlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.OrderBy(p => p.Id)
|
||||
.FirstOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
dbContext.Playouts.Remove(playout);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
|
||||
if (_localFileSystem.FileExists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
}
|
||||
|
||||
return maybePlayout
|
||||
|
||||
+3
-3
@@ -12,8 +12,8 @@ namespace ErsatzTV.Application.Playouts;
|
||||
public class ReplacePlayoutAlternateScheduleItemsHandler :
|
||||
IRequestHandler<ReplacePlayoutAlternateScheduleItems, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILogger<ReplacePlayoutAlternateScheduleItemsHandler> _logger;
|
||||
|
||||
public ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
@@ -46,7 +46,7 @@ public class ReplacePlayoutAlternateScheduleItemsHandler :
|
||||
{
|
||||
var existingScheduleMap = new Dictionary<DateTimeOffset, ProgramSchedule>();
|
||||
var daysToCheck = new List<DateTimeOffset>();
|
||||
|
||||
|
||||
Option<PlayoutItem> maybeLastPlayoutItem = await dbContext.PlayoutItems
|
||||
.Filter(pi => pi.PlayoutId == request.PlayoutId)
|
||||
.OrderByDescending(pi => pi.Start)
|
||||
@@ -146,7 +146,7 @@ public class ReplacePlayoutAlternateScheduleItemsHandler :
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -19,7 +19,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
}
|
||||
|
||||
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||
|
||||
@@ -28,9 +28,10 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
ForceSynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>.Handle(
|
||||
SynchronizePlexLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>.
|
||||
Handle(
|
||||
SynchronizePlexLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
ISynchronizePlexLibraryById request,
|
||||
@@ -77,9 +78,11 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
TvContext dbContext,
|
||||
ISynchronizePlexLibraryById request)
|
||||
{
|
||||
return await dbContext.PlexLibraries
|
||||
DateTime minDateTime = await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
@@ -93,6 +96,6 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchron
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IPlexBackgroundServiceRequest
|
||||
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest
|
||||
{
|
||||
int PlexLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
|
||||
@@ -23,7 +23,9 @@ public class
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken));
|
||||
return await LanguageExtensions.Apply(
|
||||
validation,
|
||||
p => PerformCopy(dbContext, p, request, cancellationToken));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DeleteProgramScheduleHandler : IRequestHandler<DeleteProgramSchedul
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, ProgramSchedule> validation = await ProgramScheduleMustExist(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, ps => DoDeletion(dbContext, ps));
|
||||
return await validation.Apply(ps => DoDeletion(dbContext, ps));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, ProgramSchedule programSchedule)
|
||||
|
||||
@@ -56,6 +56,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
case PlaybackOrder.Chronological:
|
||||
case PlaybackOrder.Random:
|
||||
case PlaybackOrder.MultiEpisodeShuffle:
|
||||
case PlaybackOrder.SeasonEpisode:
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
||||
case PlaybackOrder.Shuffle:
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record RebuildSearchIndex : IRequest, IBackgroundServiceRequest;
|
||||
public record RebuildSearchIndex : IRequest, IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -19,13 +19,13 @@ namespace ErsatzTV.Application.Search;
|
||||
public class
|
||||
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes, TelevisionEpisodeCardResultsViewModel>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMoviesHandler(
|
||||
|
||||
@@ -15,11 +15,11 @@ namespace ErsatzTV.Application.Search;
|
||||
public class
|
||||
QuerySearchIndexMusicVideosHandler : IRequestHandler<QuerySearchIndexMusicVideos, MusicVideoCardResultsViewModel>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMusicVideosHandler(
|
||||
|
||||
@@ -11,8 +11,8 @@ public class
|
||||
QuerySearchIndexOtherVideosHandler : IRequestHandler<QuerySearchIndexOtherVideos,
|
||||
OtherVideoCardResultsViewModel>
|
||||
{
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexOtherVideosHandler(
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace ErsatzTV.Application.Search;
|
||||
public class
|
||||
QuerySearchIndexSeasonsHandler : IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace ErsatzTV.Application.Search;
|
||||
public class
|
||||
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchArtists(string Query) : IRequest<List<NamedMediaItemViewModel>>;
|
||||
@@ -0,0 +1,45 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchArtistsHandler : IRequestHandler<SearchArtists, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public SearchArtistsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(SearchArtists request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Connection.QueryAsync<Artist>(
|
||||
@"SELECT Artist.Id, AM.Title FROM Artist
|
||||
INNER JOIN ArtistMetadata AM on AM.ArtistId = Artist.Id
|
||||
WHERE AM.Title LIKE @Title
|
||||
ORDER BY AM.Title
|
||||
LIMIT 10
|
||||
COLLATE NOCASE",
|
||||
new { Title = $"%{request.Query}%" })
|
||||
.Map(list => list.Bind(a => ToNamedMediaItem(a)).ToList());
|
||||
}
|
||||
|
||||
private static Option<NamedMediaItemViewModel> ToNamedMediaItem(Artist artist)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artist.Title))
|
||||
{
|
||||
return Option<NamedMediaItemViewModel>.None;
|
||||
}
|
||||
|
||||
return new NamedMediaItemViewModel(artist.Id, artist.Title);
|
||||
}
|
||||
|
||||
public record Artist(int Id, string Title)
|
||||
{
|
||||
public Artist() : this(default, default)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchCollections(string Query) : IRequest<List<MediaCollectionViewModel>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchCollectionsHandler : IRequestHandler<SearchCollections, List<MediaCollectionViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public SearchCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<MediaCollectionViewModel>> Handle(
|
||||
SearchCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Collections.FromSqlRaw(
|
||||
@"SELECT * FROM Collection
|
||||
WHERE Name LIKE {0}
|
||||
ORDER BY Name
|
||||
LIMIT 10
|
||||
COLLATE NOCASE",
|
||||
$"%{request.Query}%")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchMultiCollections(string Query) : IRequest<List<MultiCollectionViewModel>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchMultiCollectionsHandler : IRequestHandler<SearchMultiCollections, List<MultiCollectionViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public SearchMultiCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<MultiCollectionViewModel>> Handle(
|
||||
SearchMultiCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.MultiCollections.FromSqlRaw(
|
||||
@"SELECT * FROM MultiCollection
|
||||
WHERE Name LIKE {0}
|
||||
ORDER BY Name
|
||||
LIMIT 10
|
||||
COLLATE NOCASE",
|
||||
$"%{request.Query}%")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchSmartCollections(string Query) : IRequest<List<SmartCollectionViewModel>>;
|
||||
@@ -0,0 +1,31 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchSmartCollectionsHandler : IRequestHandler<SearchSmartCollections, List<SmartCollectionViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public SearchSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<SmartCollectionViewModel>> Handle(
|
||||
SearchSmartCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.SmartCollections.FromSqlRaw(
|
||||
@"SELECT * FROM SmartCollection
|
||||
WHERE Name LIKE {0}
|
||||
ORDER BY Name
|
||||
LIMIT 10
|
||||
COLLATE NOCASE",
|
||||
$"%{request.Query}%")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchTelevisionSeasons(string Query) : IRequest<List<NamedMediaItemViewModel>>;
|
||||
@@ -0,0 +1,48 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchTelevisionSeasonsHandler : IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public SearchTelevisionSeasonsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
SearchTelevisionSeasons request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Connection.QueryAsync<TelevisionSeason>(
|
||||
@"SELECT Season.Id, SM2.Title, Season.SeasonNumber FROM Season
|
||||
INNER JOIN SeasonMetadata SM on Season.Id = SM.SeasonId
|
||||
INNER JOIN ShowMetadata SM2 on SM2.ShowId = Season.ShowId
|
||||
WHERE (SM2.Title || ' ' || SM.Title) LIKE @Title
|
||||
ORDER BY SM2.Title, Season.SeasonNumber
|
||||
LIMIT 20
|
||||
COLLATE NOCASE",
|
||||
new { Title = $"%{request.Query}%" })
|
||||
.Map(list => list.Map(ToNamedMediaItem).ToList());
|
||||
}
|
||||
|
||||
private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionSeason season) => new(
|
||||
season.Id,
|
||||
$"{ShowTitle(season)} ({SeasonTitle(season)})");
|
||||
|
||||
private static string ShowTitle(TelevisionSeason season) => $"{season.Title ?? "???"}";
|
||||
|
||||
private static string SeasonTitle(TelevisionSeason season) => season.SeasonNumber == 0
|
||||
? "Specials"
|
||||
: $"Season {season.SeasonNumber}";
|
||||
|
||||
public record TelevisionSeason(int Id, string Title, int SeasonNumber)
|
||||
{
|
||||
public TelevisionSeason() : this(default, default, default)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchTelevisionShows(string Query) : IRequest<List<NamedMediaItemViewModel>>;
|
||||
@@ -0,0 +1,41 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchTelevisionShowsHandler : IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public SearchTelevisionShowsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
SearchTelevisionShows request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Connection.QueryAsync<TelevisionShow>(
|
||||
@"SELECT Show.Id, SM.Title, SM.Year FROM Show
|
||||
INNER JOIN ShowMetadata SM on SM.ShowId = Show.Id
|
||||
WHERE (SM.Title || ' ' || SM.Year) LIKE @Title
|
||||
ORDER BY SM.Title, SM.Year
|
||||
LIMIT 10
|
||||
COLLATE NOCASE",
|
||||
new { Title = $"%{request.Query}%" })
|
||||
.Map(list => list.Map(ToNamedMediaItem).ToList());
|
||||
}
|
||||
|
||||
private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionShow show) => new(
|
||||
show.Id,
|
||||
$"{show.Title} ({(show.Year.HasValue ? show.Year.Value.ToString() : "???")})");
|
||||
|
||||
public record TelevisionShow(int Id, string Title, int? Year)
|
||||
{
|
||||
public TelevisionShow() : this(default, default, default)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,11 @@ namespace ErsatzTV.Application.Streaming;
|
||||
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public StartFFmpegSessionHandler(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -104,7 +104,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
_logger.LogDebug("Playlist exists");
|
||||
|
||||
var segmentCount = 0;
|
||||
var lastSegmentCount = -1;
|
||||
int lastSegmentCount = -1;
|
||||
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
|
||||
{
|
||||
if (segmentCount != lastSegmentCount)
|
||||
|
||||
@@ -54,8 +54,8 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_logger.LogDebug("Keep alive - session worker for channel {ChannelNumber}", _channelNumber);
|
||||
|
||||
// _logger.LogDebug("Keep alive - session worker for channel {ChannelNumber}", _channelNumber);
|
||||
|
||||
_lastAccess = DateTimeOffset.Now;
|
||||
|
||||
_timer?.Stop();
|
||||
@@ -67,31 +67,38 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
DateTimeOffset filterBefore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
foreach (string[] input in maybeLines)
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
|
||||
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
|
||||
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
|
||||
foreach (string[] input in maybeLines)
|
||||
{
|
||||
DeleteOldSegments(trimResult);
|
||||
_lastDelete = DateTimeOffset.Now;
|
||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
|
||||
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
|
||||
{
|
||||
DeleteOldSegments(trimResult);
|
||||
_lastDelete = DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
return trimResult;
|
||||
}
|
||||
|
||||
return trimResult;
|
||||
}
|
||||
|
||||
return None;
|
||||
finally
|
||||
{
|
||||
Slim.Release();
|
||||
sw.Stop();
|
||||
// _logger.LogDebug("TrimPlaylist took {Duration}", sw.Elapsed);
|
||||
}
|
||||
}
|
||||
finally
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
Slim.Release();
|
||||
sw.Stop();
|
||||
// _logger.LogDebug("TrimPlaylist took {Duration}", sw.Elapsed);
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
public void PlayoutUpdated()
|
||||
|
||||
+10
-26
@@ -98,6 +98,12 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaItem)
|
||||
@@ -296,30 +302,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
if (isMediaServer)
|
||||
{
|
||||
string mediaItemFolder = Path.GetDirectoryName(playoutItemWithPath.Path);
|
||||
|
||||
allSubtitles = allSubtitles.Map<Subtitle, Option<Subtitle>>(
|
||||
subtitle =>
|
||||
{
|
||||
if (subtitle.SubtitleKind == SubtitleKind.Sidecar)
|
||||
{
|
||||
// need to prepend path with movie/episode folder
|
||||
if (!string.IsNullOrWhiteSpace(mediaItemFolder))
|
||||
{
|
||||
subtitle.Path = Path.Combine(mediaItemFolder, subtitle.Path);
|
||||
|
||||
// skip subtitles that don't exist
|
||||
if (!File.Exists(subtitle.Path))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return subtitle;
|
||||
})
|
||||
.Somes()
|
||||
.ToList();
|
||||
// closed captions are currently unsupported
|
||||
allSubtitles.RemoveAll(s => s.Codec == "eia_608");
|
||||
}
|
||||
|
||||
return allSubtitles;
|
||||
@@ -481,7 +465,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// attempt to remotely stream jellyfin
|
||||
Option<string> jellyfinItemId = playoutItem.MediaItem switch
|
||||
{
|
||||
@@ -496,7 +480,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{itemId}");
|
||||
}
|
||||
|
||||
|
||||
// attempt to remotely stream emby
|
||||
Option<string> embyItemId = playoutItem.MediaItem switch
|
||||
{
|
||||
|
||||
@@ -4,14 +4,12 @@ using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using CliWrap.Builders;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -22,27 +20,18 @@ namespace ErsatzTV.Application.Subtitles;
|
||||
public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSubtitles, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public ExtractEmbeddedSubtitlesHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<ExtractEmbeddedSubtitlesHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_localFileSystem = localFileSystem;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_workerChannel = workerChannel;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -188,7 +177,8 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(
|
||||
em => em.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" &&
|
||||
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
|
||||
.Map(em => em.EpisodeId)
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(episodeIds);
|
||||
@@ -199,7 +189,8 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" &&
|
||||
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
|
||||
.Map(mm => mm.MovieId)
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(movieIds);
|
||||
@@ -210,7 +201,8 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" &&
|
||||
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
|
||||
.Map(mm => mm.MusicVideoId)
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(musicVideoIds);
|
||||
@@ -221,7 +213,8 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(
|
||||
ovm => ovm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" &&
|
||||
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
|
||||
.Map(ovm => ovm.OtherVideoId)
|
||||
.ToListAsync(cancellationToken);
|
||||
result.AddRange(otherVideoIds);
|
||||
@@ -248,9 +241,11 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
// find each subtitle that needs extraction
|
||||
IEnumerable<Subtitle> subtitles = allSubtitles
|
||||
.Filter(s => s.SubtitleKind == SubtitleKind.Embedded)
|
||||
.Filter(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle");
|
||||
s => s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" &&
|
||||
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")
|
||||
.Filter(s => s.IsExtracted == false || string.IsNullOrWhiteSpace(s.Path));
|
||||
|
||||
// find cache paths for each subtitle
|
||||
foreach (Subtitle subtitle in subtitles)
|
||||
@@ -267,7 +262,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
continue;
|
||||
}
|
||||
|
||||
string mediaItemPath = await GetMediaItemPath(mediaItem);
|
||||
string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem);
|
||||
|
||||
ArgumentsBuilder args = new ArgumentsBuilder()
|
||||
.Add("-nostdin")
|
||||
@@ -382,7 +377,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
continue;
|
||||
}
|
||||
|
||||
string mediaItemPath = await GetMediaItemPath(mediaItem);
|
||||
string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem);
|
||||
|
||||
var arguments =
|
||||
$"-nostdin -hide_banner -dump_attachment:t:{attachmentIndex} \"\" -i \"{mediaItemPath}\" -y";
|
||||
@@ -423,7 +418,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
string nameWithExtension = subtitle.Codec switch
|
||||
{
|
||||
"subrip" => $"{name}.srt",
|
||||
"subrip" or "srt" => $"{name}.srt",
|
||||
"ass" => $"{name}.ass",
|
||||
"webvtt" => $"{name}.vtt",
|
||||
"mov_text" => $"{name}.srt",
|
||||
@@ -438,33 +433,39 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
return Path.Combine(subfolder, subfolder2, nameWithExtension);
|
||||
}
|
||||
|
||||
private async Task<string> GetMediaItemPath(MediaItem mediaItem)
|
||||
private static async Task<string> GetMediaItemPath(TvContext dbContext, MediaItem mediaItem)
|
||||
{
|
||||
MediaVersion version = mediaItem.GetHeadVersion();
|
||||
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
string path = file.Path;
|
||||
switch (file)
|
||||
{
|
||||
case PlexMediaFile pmf:
|
||||
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
|
||||
@"SELECT PMS.Id FROM PlexMediaSource PMS
|
||||
INNER JOIN Library L on PMS.Id = L.MediaSourceId
|
||||
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
|
||||
WHERE LP.Id = @LibraryPathId",
|
||||
new { mediaItem.LibraryPathId })
|
||||
.Map(Optional);
|
||||
|
||||
foreach (int plexMediaSourceId in maybeId)
|
||||
{
|
||||
return $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return mediaItem switch
|
||||
{
|
||||
PlexMovie plexMovie => await _plexPathReplacementService.GetReplacementPlexPath(
|
||||
plexMovie.LibraryPathId,
|
||||
path),
|
||||
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
|
||||
plexEpisode.LibraryPathId,
|
||||
path),
|
||||
JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinMovie.LibraryPathId,
|
||||
path),
|
||||
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinEpisode.LibraryPathId,
|
||||
path),
|
||||
EmbyMovie embyMovie => await _embyPathReplacementService.GetReplacementEmbyPath(
|
||||
embyMovie.LibraryPathId,
|
||||
path),
|
||||
EmbyEpisode embyEpisode => await _embyPathReplacementService.GetReplacementEmbyPath(
|
||||
embyEpisode.LibraryPathId,
|
||||
path),
|
||||
_ => path
|
||||
JellyfinMovie jellyfinMovie =>
|
||||
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{jellyfinMovie.ItemId}",
|
||||
JellyfinEpisode jellyfinEpisode =>
|
||||
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{jellyfinEpisode.ItemId}",
|
||||
EmbyMovie embyMovie => $"http://localhost:{Settings.ListenPort}/media/emby/{embyMovie.ItemId}",
|
||||
EmbyEpisode embyEpisode => $"http://localhost:{Settings.ListenPort}/media/emby/{embyEpisode.ItemId}",
|
||||
_ => file.Path
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Subtitles.Queries;
|
||||
|
||||
public record GetSubtitlePathById(int Id) : IRequest<Either<BaseError, string>>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user