Browse Source

direct stream content from emby as needed (#1168)

pull/1171/head
Jason Dove 2 years ago committed by GitHub
parent
commit
7e3436e68f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Emby/EmbyConnectionParametersViewModel.cs
  3. 27
      ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParametersHandler.cs
  4. 15
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  5. 8
      ErsatzTV.Core/Emby/EmbyMediaStreamType.cs
  6. 2
      ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
  7. 6
      ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs
  8. 3
      ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs
  9. 3
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  10. 1
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  11. 174
      ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs
  12. 11
      ErsatzTV.Infrastructure/Emby/IEmbyApi.cs
  13. 7
      ErsatzTV.Infrastructure/Emby/Models/EmbyChapterResponse.cs
  14. 2
      ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs
  15. 2
      ErsatzTV.Infrastructure/Emby/Models/EmbyMediaSourceResponse.cs
  16. 8
      ErsatzTV.Infrastructure/Emby/Models/EmbyMediaStreamResponse.cs
  17. 6
      ErsatzTV.Infrastructure/Emby/Models/EmbyPlaybackInfoResponse.cs
  18. 33
      ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs
  19. 35
      ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs
  20. 54
      ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
  21. 54
      ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  22. 22
      ErsatzTV/Controllers/InternalController.cs

8
CHANGELOG.md

@ -8,14 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/jasongdove/ErsatzTV-ffmpeg) - Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/jasongdove/ErsatzTV-ffmpeg)
### Changed ### Changed
- Plex libraries now retrieve all metadata and statistics from Plex; ffprobe is no longer used - Plex, Jellyfin and Emby libraries now retrieve all metadata and statistics from the media server; ffprobe is no longer used
- Plex libraries now direct stream content from Plex when files are not found on ErsatzTV's file system - Plex, Jellyfin and Emby libraries now direct stream content when files are not found on ErsatzTV's file system
- Content will still be normalized according to the Channel and FFmpeg Profile settings - Content will still be normalized according to the Channel and FFmpeg Profile settings
- Streaming from disk is preferred, so every playback attempt will first check the local file system - Streaming from disk is preferred, so every playback attempt will first check the local file system
- Jellyfin libraries will retrieve all metadata and statistics from Jellyfin when local files are unavailable
- Jellyfin libraries now direct stream content from Jellyfin when files are not found on ErsatzTV's file system
- Content will still be normalized according to the Channel and FFmpeg Profile settings
- Streaming from disk is preferred, so every playback attempt will first check the local file system
## [0.7.4-beta] - 2023-02-12 ## [0.7.4-beta] - 2023-02-12
### Added ### Added

2
ErsatzTV.Application/Emby/EmbyConnectionParametersViewModel.cs

@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
public record EmbyConnectionParametersViewModel(string Address); public record EmbyConnectionParametersViewModel(string Address, string ApiKey);

27
ErsatzTV.Application/Emby/Queries/GetEmbyConnectionParametersHandler.cs

@ -1,5 +1,7 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@ -9,14 +11,17 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
Either<BaseError, EmbyConnectionParametersViewModel>> Either<BaseError, EmbyConnectionParametersViewModel>>
{ {
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IEmbySecretStore _embySecretStore;
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
public GetEmbyConnectionParametersHandler( public GetEmbyConnectionParametersHandler(
IMemoryCache memoryCache, IMemoryCache memoryCache,
IMediaSourceRepository mediaSourceRepository) IMediaSourceRepository mediaSourceRepository,
IEmbySecretStore embySecretStore)
{ {
_memoryCache = memoryCache; _memoryCache = memoryCache;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_embySecretStore = embySecretStore;
} }
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle( public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
@ -30,7 +35,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters = Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
await Validate() await Validate()
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address)) .MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>()); .Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
return maybeParameters.Match( return maybeParameters.Match(
@ -44,7 +49,8 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
private Task<Validation<BaseError, ConnectionParameters>> Validate() => private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
EmbyMediaSourceMustExist() EmbyMediaSourceMustExist()
.BindT(MediaSourceMustHaveActiveConnection); .BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() => private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone()) _mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
@ -59,8 +65,21 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection)) return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection))
.ToValidation<BaseError>("Emby media source requires an active connection"); .ToValidation<BaseError>("Emby media source requires an active connection");
} }
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
{
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key");
}
private record ConnectionParameters( private record ConnectionParameters(
EmbyMediaSource EmbyMediaSource, EmbyMediaSource EmbyMediaSource,
EmbyConnection ActiveConnection); EmbyConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
} }

15
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -496,6 +496,21 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItem, playoutItem,
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{itemId}"); $"http://localhost:{Settings.ListenPort}/media/jellyfin/{itemId}");
} }
// attempt to remotely stream emby
Option<string> embyItemId = playoutItem.MediaItem switch
{
EmbyEpisode e => e.ItemId,
EmbyMovie m => m.ItemId,
_ => None
};
foreach (string itemId in embyItemId)
{
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.ListenPort}/media/emby/{itemId}");
}
return new PlayoutItemDoesNotExistOnDisk(path); return new PlayoutItemDoesNotExistOnDisk(path);
} }

8
ErsatzTV.Core/Emby/EmbyMediaStreamType.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Emby;
public static class EmbyMediaStreamType
{
public static readonly string Video = "Video";
public static readonly string Audio = "Audio";
public static readonly string Subtitle = "Subtitle";
}

2
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

@ -270,7 +270,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); _logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
if (!_localFileSystem.FileExists(jsScriptPath)) if (!_localFileSystem.FileExists(jsScriptPath))
{ {
_logger.LogWarning("Unable to locate episode audio stream selector script; falling back to built-in logic"); _logger.LogDebug("Unable to locate episode audio stream selector script; falling back to built-in logic");
return Option<MediaStream>.None; return Option<MediaStream>.None;
} }

6
ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs

@ -34,4 +34,10 @@ public interface IEmbyApiClient
string apiKey, string apiKey,
string parentId, string parentId,
string includeItemTypes); string includeItemTypes);
Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
string address,
string apiKey,
EmbyLibrary library,
string itemId);
} }

3
ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs

@ -126,6 +126,8 @@ public class EmbyMovieRepository : IEmbyMovieRepository
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MovieMetadata) .Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(m => m.MovieMetadata) .Include(m => m.MovieMetadata)
@ -366,6 +368,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name; version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded; version.DateAdded = incomingVersion.DateAdded;
version.Chapters = incomingVersion.Chapters;
// media file // media file
MediaFile file = version.MediaFiles.Head(); MediaFile file = version.MediaFiles.Head();

3
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

@ -135,6 +135,8 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.EpisodeMetadata) .Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata) .Include(m => m.EpisodeMetadata)
@ -724,6 +726,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name; version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded; version.DateAdded = incomingVersion.DateAdded;
version.Chapters = incomingVersion.Chapters;
// media file // media file
MediaFile file = version.MediaFiles.Head(); MediaFile file = version.MediaFiles.Head();

1
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

@ -729,6 +729,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
MediaVersion incomingVersion = incoming.MediaVersions.Head(); MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name; version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded; version.DateAdded = incomingVersion.DateAdded;
version.Chapters = incomingVersion.Chapters;
// media file // media file
MediaFile file = version.MediaFiles.Head(); MediaFile file = version.MediaFiles.Head();

174
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core; using System.Globalization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
@ -191,6 +192,26 @@ public class EmbyApiClient : IEmbyApiClient
} }
} }
public async Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
string address,
string apiKey,
EmbyLibrary library,
string itemId)
{
try
{
IEmbyApi service = RestService.For<IEmbyApi>(address);
EmbyPlaybackInfoResponse playbackInfo = await service.GetPlaybackInfo(apiKey, itemId);
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(playbackInfo);
return maybeVersion.ToEither(() => BaseError.New("Unable to locate Emby statistics"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Emby playback info");
return BaseError.New(ex.Message);
}
}
private static async IAsyncEnumerable<TItem> GetPagedLibraryContents<TItem>( private static async IAsyncEnumerable<TItem> GetPagedLibraryContents<TItem>(
string address, string address,
string apiKey, string apiKey,
@ -328,10 +349,11 @@ public class EmbyApiClient : IEmbyApiClient
} }
} }
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
var version = new MediaVersion var version = new MediaVersion
{ {
Name = "Main", Name = "Main",
Duration = TimeSpan.FromTicks(item.RunTimeTicks), Duration = duration,
DateAdded = item.DateCreated.UtcDateTime, DateAdded = item.DateCreated.UtcDateTime,
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
@ -340,7 +362,8 @@ public class EmbyApiClient : IEmbyApiClient
Path = path Path = path
} }
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>(),
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
}; };
MovieMetadata metadata = ProjectToMovieMetadata(item); MovieMetadata metadata = ProjectToMovieMetadata(item);
@ -362,6 +385,29 @@ public class EmbyApiClient : IEmbyApiClient
return None; return None;
} }
} }
private static List<MediaChapter> ProjectToModel(
IEnumerable<EmbyChapterResponse> embyChapters,
TimeSpan duration)
{
var models = embyChapters.Map(ProjectToModel).OrderBy(c => c.StartTime).ToList();
for (var index = 0; index < models.Count; index++)
{
MediaChapter model = models[index];
model.ChapterId = index;
model.EndTime = index == models.Count - 1 ? duration : models[index + 1].StartTime;
}
return models;
}
private static MediaChapter ProjectToModel(EmbyChapterResponse chapterResponse) =>
new()
{
Title = chapterResponse.Name,
StartTime = TimeSpan.FromTicks(chapterResponse.StartPositionTicks)
};
private MovieMetadata ProjectToMovieMetadata(EmbyLibraryItemResponse item) private MovieMetadata ProjectToMovieMetadata(EmbyLibraryItemResponse item)
{ {
@ -644,10 +690,11 @@ public class EmbyApiClient : IEmbyApiClient
} }
} }
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
var version = new MediaVersion var version = new MediaVersion
{ {
Name = "Main", Name = "Main",
Duration = TimeSpan.FromTicks(item.RunTimeTicks), Duration = duration,
DateAdded = item.DateCreated.UtcDateTime, DateAdded = item.DateCreated.UtcDateTime,
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
@ -656,7 +703,8 @@ public class EmbyApiClient : IEmbyApiClient
Path = path Path = path
} }
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>(),
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
}; };
EpisodeMetadata metadata = ProjectToEpisodeMetadata(item); EpisodeMetadata metadata = ProjectToEpisodeMetadata(item);
@ -750,4 +798,120 @@ public class EmbyApiClient : IEmbyApiClient
return result; return result;
} }
private Option<MediaVersion> ProjectToMediaVersion(EmbyPlaybackInfoResponse response)
{
if (response.MediaSources is null || response.MediaSources.Count == 0)
{
_logger.LogWarning("Received empty playback info from Jellyfin");
return None;
}
EmbyMediaSourceResponse mediaSource = response.MediaSources.Head();
IList<EmbyMediaStreamResponse> streams = mediaSource.MediaStreams;
Option<EmbyMediaStreamResponse> maybeVideoStream =
streams.Find(s => s.Type == EmbyMediaStreamType.Video);
return maybeVideoStream.Map(
videoStream =>
{
int width = videoStream.Width ?? 1;
int height = videoStream.Height ?? 1;
var isAnamorphic = false;
if (videoStream.IsAnamorphic.HasValue)
{
isAnamorphic = videoStream.IsAnamorphic.Value;
}
else if (!string.IsNullOrWhiteSpace(videoStream.AspectRatio) && videoStream.AspectRatio.Contains(":"))
{
// if width/height != aspect ratio, is anamorphic
double resolutionRatio = width / (double)height;
string[] split = videoStream.AspectRatio.Split(":");
var num = double.Parse(split[0]);
var den = double.Parse(split[1]);
double aspectRatio = num / den;
isAnamorphic = Math.Abs(resolutionRatio - aspectRatio) > 0.01d;
}
var version = new MediaVersion
{
Duration = TimeSpan.FromTicks(mediaSource.RunTimeTicks),
SampleAspectRatio = isAnamorphic ? "0:0" : "1:1",
DisplayAspectRatio = string.IsNullOrWhiteSpace(videoStream.AspectRatio)
? string.Empty
: videoStream.AspectRatio,
VideoScanKind = videoStream.IsInterlaced switch
{
true => VideoScanKind.Interlaced,
false => VideoScanKind.Progressive
},
Streams = new List<MediaStream>(),
Width = videoStream.Width ?? 1,
Height = videoStream.Height ?? 1,
RFrameRate = videoStream.RealFrameRate.HasValue
? videoStream.RealFrameRate.Value.ToString("0.00###", CultureInfo.InvariantCulture)
: string.Empty,
Chapters = new List<MediaChapter>()
};
version.Streams.Add(
new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Video,
Index = videoStream.Index,
Codec = videoStream.Codec,
Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(),
Default = videoStream.IsDefault,
Language = videoStream.Language,
Forced = videoStream.IsForced,
PixelFormat = videoStream.PixelFormat,
ColorRange = (videoStream.ColorRange ?? string.Empty).ToLowerInvariant(),
ColorSpace = (videoStream.ColorSpace ?? string.Empty).ToLowerInvariant(),
ColorTransfer = (videoStream.ColorTransfer ?? string.Empty).ToLowerInvariant(),
ColorPrimaries = (videoStream.ColorPrimaries ?? string.Empty).ToLowerInvariant()
});
foreach (EmbyMediaStreamResponse audioStream in streams.Filter(
s => s.Type == EmbyMediaStreamType.Audio))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Audio,
Index = audioStream.Index,
Codec = audioStream.Codec,
Profile = (audioStream.Profile ?? string.Empty).ToLowerInvariant(),
Channels = audioStream.Channels ?? 2,
Default = audioStream.IsDefault,
Forced = audioStream.IsForced,
Language = audioStream.Language,
Title = audioStream.DisplayTitle ?? string.Empty
};
version.Streams.Add(stream);
}
foreach (EmbyMediaStreamResponse subtitleStream in streams.Filter(
s => s.Type == EmbyMediaStreamType.Subtitle))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Subtitle,
Index = subtitleStream.Index,
Codec = subtitleStream.Codec,
Default = subtitleStream.IsDefault,
Forced = subtitleStream.IsForced,
Language = subtitleStream.Language
};
version.Streams.Add(stream);
}
return version;
});
}
} }

11
ErsatzTV.Infrastructure/Emby/IEmbyApi.cs

@ -40,7 +40,7 @@ public interface IEmbyApi
string parentId, string parentId,
[Query] [Query]
string fields = string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating,ProviderIds", "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating,ProviderIds,Chapters",
[Query] [Query]
string includeItemTypes = "Movie", string includeItemTypes = "Movie",
[Query] [Query]
@ -89,7 +89,7 @@ public interface IEmbyApi
string seasonId, string seasonId,
[Query] [Query]
string fields = string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType,ProviderIds,People", "Path,Genres,Tags,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType,ProviderIds,People,Chapters",
[Query] [Query]
int startIndex = 0, int startIndex = 0,
[Query] [Query]
@ -130,4 +130,11 @@ public interface IEmbyApi
int startIndex = 0, int startIndex = 0,
[Query] [Query]
int limit = 0); int limit = 0);
[Get("/Items/{itemId}/PlaybackInfo")]
public Task<EmbyPlaybackInfoResponse> GetPlaybackInfo(
[Header("X-Emby-Token")]
string apiKey,
string itemId);
} }

7
ErsatzTV.Infrastructure/Emby/Models/EmbyChapterResponse.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Infrastructure.Emby.Models;
public class EmbyChapterResponse
{
public long StartPositionTicks { get; set; }
public string Name { get; set; }
}

2
ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs

@ -14,7 +14,6 @@ public class EmbyLibraryItemResponse
public int ProductionYear { get; set; } public int ProductionYear { get; set; }
public EmbyProviderIdsResponse ProviderIds { get; set; } public EmbyProviderIdsResponse ProviderIds { get; set; }
public string PremiereDate { get; set; } public string PremiereDate { get; set; }
public List<EmbyMediaStreamResponse> MediaStreams { get; set; }
public List<EmbyMediaSourceResponse> MediaSources { get; set; } public List<EmbyMediaSourceResponse> MediaSources { get; set; }
public string LocationType { get; set; } public string LocationType { get; set; }
public string Overview { get; set; } public string Overview { get; set; }
@ -25,4 +24,5 @@ public class EmbyLibraryItemResponse
public List<string> BackdropImageTags { get; set; } public List<string> BackdropImageTags { get; set; }
public int? IndexNumber { get; set; } public int? IndexNumber { get; set; }
public string Type { get; set; } public string Type { get; set; }
public IList<EmbyChapterResponse> Chapters { get; set; }
} }

2
ErsatzTV.Infrastructure/Emby/Models/EmbyMediaSourceResponse.cs

@ -4,4 +4,6 @@ public class EmbyMediaSourceResponse
{ {
public string Id { get; set; } public string Id { get; set; }
public string Protocol { get; set; } public string Protocol { get; set; }
public long RunTimeTicks { get; set; }
public IList<EmbyMediaStreamResponse> MediaStreams { get; set; }
} }

8
ErsatzTV.Infrastructure/Emby/Models/EmbyMediaStreamResponse.cs

@ -14,4 +14,12 @@ public class EmbyMediaStreamResponse
public string Profile { get; set; } public string Profile { get; set; }
public string AspectRatio { get; set; } public string AspectRatio { get; set; }
public int? Channels { get; set; } public int? Channels { get; set; }
public bool? IsAnamorphic { get; set; }
public string DisplayTitle { get; set; }
public string PixelFormat { get; set; }
public string ColorRange { get; set; }
public string ColorSpace { get; set; }
public string ColorTransfer { get; set; }
public string ColorPrimaries { get; set; }
public double? RealFrameRate { get; set; }
} }

6
ErsatzTV.Infrastructure/Emby/Models/EmbyPlaybackInfoResponse.cs

@ -0,0 +1,6 @@
namespace ErsatzTV.Infrastructure.Emby.Models;
public class EmbyPlaybackInfoResponse
{
public IList<EmbyMediaSourceResponse> MediaSources { get; set; }
}

33
ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs

@ -20,6 +20,7 @@ public class EmbyMovieLibraryScanner :
private readonly IEmbyMovieRepository _embyMovieRepository; private readonly IEmbyMovieRepository _embyMovieRepository;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IEmbyPathReplacementService _pathReplacementService; private readonly IEmbyPathReplacementService _pathReplacementService;
private readonly ILogger<EmbyMovieLibraryScanner> _logger;
public EmbyMovieLibraryScanner( public EmbyMovieLibraryScanner(
IEmbyApiClient embyApiClient, IEmbyApiClient embyApiClient,
@ -44,7 +45,10 @@ public class EmbyMovieLibraryScanner :
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_embyMovieRepository = embyMovieRepository; _embyMovieRepository = embyMovieRepository;
_pathReplacementService = pathReplacementService; _pathReplacementService = pathReplacementService;
_logger = logger;
} }
protected override bool ServerSupportsRemoteStreaming => true;
public async Task<Either<BaseError, Unit>> ScanLibrary( public async Task<Either<BaseError, Unit>> ScanLibrary(
string address, string address,
@ -110,6 +114,35 @@ public class EmbyMovieLibraryScanner :
EmbyLibrary library, EmbyLibrary library,
MediaItemScanResult<EmbyMovie> result, MediaItemScanResult<EmbyMovie> result,
EmbyMovie incoming) => Task.FromResult(Option<Tuple<MovieMetadata, MediaVersion>>.None); EmbyMovie incoming) => Task.FromResult(Option<Tuple<MovieMetadata, MediaVersion>>.None);
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyMovie> result,
EmbyMovie incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Emby Statistics", result.LocalPath);
Either<BaseError, MediaVersion> maybeVersion =
await _embyApiClient.GetPlaybackInfo(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
incoming.ItemId);
foreach (BaseError error in maybeVersion.LeftToSeq())
{
_logger.LogWarning("Failed to get movie statistics from Emby: {Error}", error.ToString());
}
// chapters are pulled with metadata, not with statistics, but we need to save them here
foreach (MediaVersion version in maybeVersion.RightToSeq())
{
version.Chapters = result.Item.GetHeadVersion().Chapters;
}
return maybeVersion.ToOption();
}
protected override Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> UpdateMetadata( protected override Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> UpdateMetadata(
MediaItemScanResult<EmbyMovie> result, MediaItemScanResult<EmbyMovie> result,

35
ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -19,6 +19,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
private readonly IEmbyApiClient _embyApiClient; private readonly IEmbyApiClient _embyApiClient;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IEmbyPathReplacementService _pathReplacementService; private readonly IEmbyPathReplacementService _pathReplacementService;
private readonly ILogger<EmbyTelevisionLibraryScanner> _logger;
private readonly IEmbyTelevisionRepository _televisionRepository; private readonly IEmbyTelevisionRepository _televisionRepository;
public EmbyTelevisionLibraryScanner( public EmbyTelevisionLibraryScanner(
@ -44,8 +45,11 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_pathReplacementService = pathReplacementService; _pathReplacementService = pathReplacementService;
_logger = logger;
} }
protected override bool ServerSupportsRemoteStreaming => true;
public async Task<Either<BaseError, Unit>> ScanLibrary( public async Task<Either<BaseError, Unit>> ScanLibrary(
string address, string address,
string apiKey, string apiKey,
@ -170,6 +174,37 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
EmbyLibrary library, EmbyLibrary library,
MediaItemScanResult<EmbyEpisode> result, MediaItemScanResult<EmbyEpisode> result,
EmbyEpisode incoming) => Task.FromResult(Option<Tuple<EpisodeMetadata, MediaVersion>>.None); EmbyEpisode incoming) => Task.FromResult(Option<Tuple<EpisodeMetadata, MediaVersion>>.None);
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyEpisode> result,
EmbyEpisode incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Emby Statistics", result.LocalPath);
Either<BaseError, MediaVersion> maybeVersion =
await _embyApiClient.GetPlaybackInfo(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
incoming.ItemId);
foreach (BaseError error in maybeVersion.LeftToSeq())
{
_logger.LogWarning("Failed to get episode statistics from Emby: {Error}", error.ToString());
}
// chapters are pulled with metadata, not with statistics, but we need to save them here
foreach (MediaVersion version in maybeVersion.RightToSeq())
{
version.Chapters = result.Item.GetHeadVersion().Chapters;
}
return maybeVersion.ToOption();
}
protected override Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> UpdateMetadata( protected override Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> UpdateMetadata(
MediaItemScanResult<EmbyShow> result, MediaItemScanResult<EmbyShow> result,

54
ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -449,32 +449,32 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) || if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0) existing.MediaVersions.Head().Streams.Count == 0)
{ {
if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath)) // if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath))
{ // {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); // _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath);
Either<BaseError, bool> refreshResult = // Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics( // await _localStatisticsProvider.RefreshStatistics(
ffmpegPath, // ffmpegPath,
ffprobePath, // ffprobePath,
existing, // existing,
result.LocalPath); // result.LocalPath);
//
foreach (BaseError error in refreshResult.LeftToSeq()) // foreach (BaseError error in refreshResult.LeftToSeq())
{ // {
_logger.LogWarning( // _logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}", // "Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics", // "Statistics",
result.LocalPath, // result.LocalPath,
error.Value); // error.Value);
} // }
//
foreach (bool _ in refreshResult.RightToSeq()) // foreach (bool _ in refreshResult.RightToSeq())
{ // {
result.IsUpdated = true; // result.IsUpdated = true;
} // }
} // }
else // else
{ // {
if (maybeMediaVersion.IsNone) if (maybeMediaVersion.IsNone)
{ {
maybeMediaVersion = await GetMediaServerStatistics( maybeMediaVersion = await GetMediaServerStatistics(
@ -491,7 +491,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
result.IsUpdated = true; result.IsUpdated = true;
} }
} }
} // }
} }
return result; return result;

54
ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -783,32 +783,32 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) || if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0) existing.MediaVersions.Head().Streams.Count == 0)
{ {
if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath)) // if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath))
{ // {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); // _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath);
Either<BaseError, bool> refreshResult = // Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics( // await _localStatisticsProvider.RefreshStatistics(
ffmpegPath, // ffmpegPath,
ffprobePath, // ffprobePath,
existing, // existing,
result.LocalPath); // result.LocalPath);
//
foreach (BaseError error in refreshResult.LeftToSeq()) // foreach (BaseError error in refreshResult.LeftToSeq())
{ // {
_logger.LogWarning( // _logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}", // "Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics", // "Statistics",
result.LocalPath, // result.LocalPath,
error.Value); // error.Value);
} // }
//
foreach (bool _ in refreshResult.RightToSeq()) // foreach (bool _ in refreshResult.RightToSeq())
{ // {
result.IsUpdated = true; // result.IsUpdated = true;
} // }
} // }
else // else
{ // {
if (maybeMediaVersion.IsNone) if (maybeMediaVersion.IsNone)
{ {
maybeMediaVersion = await GetMediaServerStatistics( maybeMediaVersion = await GetMediaServerStatistics(
@ -825,7 +825,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
result.IsUpdated = true; result.IsUpdated = true;
} }
} }
} // }
} }
return result; return result;

22
ErsatzTV/Controllers/InternalController.cs

@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using CliWrap; using CliWrap;
using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.Plex; using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming; using ErsatzTV.Application.Streaming;
@ -121,4 +122,25 @@ public class InternalController : ControllerBase
return new RedirectResult(fullPath.ToString()); return new RedirectResult(fullPath.ToString());
}); });
} }
[HttpGet("/media/emby/{*path}")]
public async Task<IActionResult> GetEmbyMedia(string path, CancellationToken cancellationToken)
{
Either<BaseError, EmbyConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetEmbyConnectionParameters(), cancellationToken);
return connectionParameters.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r =>
{
Url fullPath = Flurl.Url.Parse(r.Address)
.AppendPathSegment("Videos")
.AppendPathSegment(path)
.AppendPathSegment("stream")
.SetQueryParam("static", "true")
.SetQueryParam("X-Emby-Token", r.ApiKey);
return new RedirectResult(fullPath.ToString());
});
}
} }

Loading…
Cancel
Save