using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Infrastructure.Jellyfin.Models; using LanguageExt; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Refit; using static LanguageExt.Prelude; namespace ErsatzTV.Infrastructure.Jellyfin { public class JellyfinApiClient : IJellyfinApiClient { private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly ILogger _logger; private readonly IMemoryCache _memoryCache; public JellyfinApiClient( IMemoryCache memoryCache, IFallbackMetadataProvider fallbackMetadataProvider, ILogger logger) { _memoryCache = memoryCache; _fallbackMetadataProvider = fallbackMetadataProvider; _logger = logger; } public async Task> GetServerInformation( string address, string apiKey) { try { IJellyfinApi service = RestService.For(address); var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(5)); return await service.GetSystemInformation(apiKey, cts.Token) .Map(response => new JellyfinServerInformation(response.ServerName, response.OperatingSystem)); } catch (OperationCanceledException ex) { _logger.LogError(ex, "Timeout getting jellyfin server name"); return BaseError.New("Jellyfin did not respond in time"); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin server name"); return BaseError.New(ex.Message); } } public async Task>> GetLibraries(string address, string apiKey) { try { IJellyfinApi service = RestService.For(address); List libraries = await service.GetLibraries(apiKey); return libraries .Map(Project) .Somes() .ToList(); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin libraries"); return BaseError.New(ex.Message); } } public async Task> GetAdminUserId(string address, string apiKey) { try { IJellyfinApi service = RestService.For(address); List users = await service.GetUsers(apiKey); Option maybeUserId = users .Filter(user => user.Policy.IsAdministrator) .Map(user => user.Id) .HeadOrNone(); return maybeUserId.ToEither(BaseError.New("Unable to locate jellyfin admin user")); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin admin user id"); return BaseError.New(ex.Message); } } public async Task>> GetMovieLibraryItems( string address, string apiKey, int mediaSourceId, string libraryId) { try { if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId)) { IJellyfinApi service = RestService.For(address); JellyfinLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, userId, libraryId); return items.Items .Map(ProjectToMovie) .Somes() .ToList(); } return BaseError.New("Jellyfin admin user id is not available"); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin movie library items"); return BaseError.New(ex.Message); } } public async Task>> GetShowLibraryItems( string address, string apiKey, int mediaSourceId, string libraryId) { try { if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId)) { IJellyfinApi service = RestService.For(address); JellyfinLibraryItemsResponse items = await service.GetShowLibraryItems(apiKey, userId, libraryId); return items.Items .Map(ProjectToShow) .Somes() .ToList(); } return BaseError.New("Jellyfin admin user id is not available"); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin show library items"); return BaseError.New(ex.Message); } } public async Task>> GetSeasonLibraryItems( string address, string apiKey, int mediaSourceId, string showId) { try { if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId)) { IJellyfinApi service = RestService.For(address); JellyfinLibraryItemsResponse items = await service.GetSeasonLibraryItems(apiKey, userId, showId); return items.Items .Map(ProjectToSeason) .Somes() .ToList(); } return BaseError.New("Jellyfin admin user id is not available"); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin show library items"); return BaseError.New(ex.Message); } } public async Task>> GetEpisodeLibraryItems( string address, string apiKey, int mediaSourceId, string seasonId) { try { if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId)) { IJellyfinApi service = RestService.For(address); JellyfinLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, userId, seasonId); return items.Items .Map(ProjectToEpisode) .Somes() .ToList(); } return BaseError.New("Jellyfin admin user id is not available"); } catch (Exception ex) { _logger.LogError(ex, "Error getting jellyfin episode library items"); return BaseError.New(ex.Message); } } private static Option Project(JellyfinLibraryResponse response) => response.CollectionType?.ToLowerInvariant() switch { "tvshows" => new JellyfinLibrary { ItemId = response.ItemId, Name = response.Name, MediaKind = LibraryMediaKind.Shows, ShouldSyncItems = false, Paths = new List { new() { Path = $"jellyfin://{response.ItemId}" } } }, "movies" => new JellyfinLibrary { ItemId = response.ItemId, Name = response.Name, MediaKind = LibraryMediaKind.Movies, ShouldSyncItems = false, Paths = new List { new() { Path = $"jellyfin://{response.ItemId}" } } }, // TODO: ??? for music libraries _ => None }; private Option ProjectToMovie(JellyfinLibraryItemResponse item) { try { if (item.LocationType != "FileSystem") { return None; } if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm") { _logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path); return None; } var version = new MediaVersion { Name = "Main", Duration = TimeSpan.FromTicks(item.RunTimeTicks), DateAdded = item.DateCreated.UtcDateTime, MediaFiles = new List { new() { Path = item.Path } }, Streams = new List() }; MovieMetadata metadata = ProjectToMovieMetadata(item); var movie = new JellyfinMovie { ItemId = item.Id, Etag = item.Etag, MediaVersions = new List { version }, MovieMetadata = new List { metadata }, TraktListItems = new List() }; return movie; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin movie"); return None; } } private MovieMetadata ProjectToMovieMetadata(JellyfinLibraryItemResponse item) { DateTime dateAdded = item.DateCreated.UtcDateTime; // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; var metadata = new MovieMetadata { MetadataKind = MetadataKind.External, Title = item.Name, SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), Plot = item.Overview, Year = item.ProductionYear, Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), DateAdded = dateAdded, ContentRating = item.OfficialRating, Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(), Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(), Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(), Artwork = new List(), Guids = GuidsFromProviderIds(item.ProviderIds) }; // set order on actors for (var i = 0; i < metadata.Actors.Count; i++) { metadata.Actors[i].Order = i; } if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) { var poster = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", DateAdded = dateAdded }; metadata.Artwork.Add(poster); } if (item.BackdropImageTags.Any()) { var fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", DateAdded = dateAdded }; metadata.Artwork.Add(fanArt); } return metadata; } private static Option ProjectToActor(JellyfinPersonResponse person, DateTime dateAdded) { if (person.Type?.ToLowerInvariant() != "actor") { return None; } var actor = new Actor { Name = person.Name, Role = person.Role }; if (!string.IsNullOrWhiteSpace(person.Id) && !string.IsNullOrWhiteSpace(person.PrimaryImageTag)) { actor.Artwork = new Artwork { Path = $"jellyfin://Items/{person.Id}/Images/Primary?tag={person.PrimaryImageTag}", ArtworkKind = ArtworkKind.Thumbnail, DateAdded = dateAdded }; } return actor; } private static Option ProjectToDirector(JellyfinPersonResponse person) { if (person.Type?.ToLowerInvariant() != "director") { return None; } return new Director { Name = person.Name }; } private static Option ProjectToWriter(JellyfinPersonResponse person) { if (person.Type?.ToLowerInvariant() != "writer") { return None; } return new Writer { Name = person.Name }; } private Option ProjectToShow(JellyfinLibraryItemResponse item) { try { if (item.LocationType != "FileSystem") { return None; } ShowMetadata metadata = ProjectToShowMetadata(item); var show = new JellyfinShow { ItemId = item.Id, Etag = item.Etag, ShowMetadata = new List { metadata }, TraktListItems = new List() }; return show; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin show"); return None; } } private ShowMetadata ProjectToShowMetadata(JellyfinLibraryItemResponse item) { DateTime dateAdded = item.DateCreated.UtcDateTime; // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; var metadata = new ShowMetadata { MetadataKind = MetadataKind.External, Title = item.Name, SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), Plot = item.Overview, Year = item.ProductionYear, Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), DateAdded = dateAdded, ContentRating = item.OfficialRating, Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(), Artwork = new List(), Guids = GuidsFromProviderIds(item.ProviderIds) }; // set order on actors for (var i = 0; i < metadata.Actors.Count; i++) { metadata.Actors[i].Order = i; } if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) { var poster = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", DateAdded = dateAdded }; metadata.Artwork.Add(poster); } if (!string.IsNullOrWhiteSpace(item.ImageTags.Thumb)) { var thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = $"jellyfin://Items/{item.Id}/Images/Thumb?tag={item.ImageTags.Thumb}", DateAdded = dateAdded }; metadata.Artwork.Add(thumb); } if (item.BackdropImageTags.Any()) { var fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", DateAdded = dateAdded }; metadata.Artwork.Add(fanArt); } return metadata; } private Option ProjectToSeason(JellyfinLibraryItemResponse item) { try { if (item.LocationType != "FileSystem") { return None; } DateTime dateAdded = item.DateCreated.UtcDateTime; // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var metadata = new SeasonMetadata { MetadataKind = MetadataKind.External, Title = item.Name, SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), Year = item.ProductionYear, DateAdded = dateAdded, Artwork = new List(), Guids = GuidsFromProviderIds(item.ProviderIds) }; if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) { var poster = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", DateAdded = dateAdded }; metadata.Artwork.Add(poster); } if (item.BackdropImageTags.Any()) { var fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", DateAdded = dateAdded }; metadata.Artwork.Add(fanArt); } var season = new JellyfinSeason { ItemId = item.Id, Etag = item.Etag, SeasonMetadata = new List { metadata }, TraktListItems = new List() }; if (item.IndexNumber.HasValue) { season.SeasonNumber = item.IndexNumber.Value; } return season; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin season"); return None; } } private Option ProjectToEpisode(JellyfinLibraryItemResponse item) { try { if (item.LocationType != "FileSystem") { return None; } if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm") { _logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path); return None; } var version = new MediaVersion { Name = "Main", Duration = TimeSpan.FromTicks(item.RunTimeTicks), DateAdded = item.DateCreated.UtcDateTime, MediaFiles = new List { new() { Path = item.Path } }, Streams = new List() }; EpisodeMetadata metadata = ProjectToEpisodeMetadata(item); var episode = new JellyfinEpisode { ItemId = item.Id, Etag = item.Etag, MediaVersions = new List { version }, EpisodeMetadata = new List { metadata }, TraktListItems = new List() }; return episode; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Jellyfin episode"); return None; } } private EpisodeMetadata ProjectToEpisodeMetadata(JellyfinLibraryItemResponse item) { DateTime dateAdded = item.DateCreated.UtcDateTime; // DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime; var metadata = new EpisodeMetadata { MetadataKind = MetadataKind.External, Title = item.Name, SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), Plot = item.Overview, Year = item.ProductionYear, DateAdded = dateAdded, Genres = new List(), Tags = new List(), Studios = new List(), Actors = new List(), Artwork = new List(), Guids = GuidsFromProviderIds(item.ProviderIds), Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(), Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList() }; if (item.IndexNumber.HasValue) { metadata.EpisodeNumber = item.IndexNumber.Value; } if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) { var thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", DateAdded = dateAdded }; metadata.Artwork.Add(thumbnail); } return metadata; } private List GuidsFromProviderIds(JellyfinProviderIdsResponse providerIds) { var result = new List(); if (providerIds != null) { if (!string.IsNullOrWhiteSpace(providerIds.Imdb)) { result.Add(new MetadataGuid { Guid = $"imdb://{providerIds.Imdb}" }); } if (!string.IsNullOrWhiteSpace(providerIds.Tmdb)) { result.Add(new MetadataGuid { Guid = $"tmdb://{providerIds.Tmdb}" }); } if (!string.IsNullOrWhiteSpace(providerIds.Tvdb)) { result.Add(new MetadataGuid { Guid = $"tvdb://{providerIds.Tvdb}" }); } } return result; } } }