Files
ersatztv/ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
T
timothy 64decd492e fix(496): give music videos a per-library server identity; itemId diff + soft trash
Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to
reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served
by two libraries with overlapping local paths is a single row owned by whichever library
scanned it first, so that owner's sweep destroyed a row another library still served —
taking collection membership and playout references with it, irreversibly.

This is #494's deferred "option 2":

- New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table,
  varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo.
- New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed
  existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per
  library via LibraryPath.LibraryId.
- New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds
  onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim.
- The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible
  and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep.
- Pre-identity rows are ADOPTED in place: the identity row is inserted against the same
  MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership
  survives and a local/second-library row is never hijacked.
- AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection
  fills them from the server-reported path, which would break every later PathHash lookup.

Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as
superseded; new active record scan.musicvideo-server-identity.

fixes #496
2026-07-25 17:20:53 +02:00

1359 lines
51 KiB
C#

using System.Globalization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Jellyfin.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Refit;
namespace ErsatzTV.Infrastructure.Jellyfin;
public class JellyfinApiClient : IJellyfinApiClient
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILogger<JellyfinApiClient> _logger;
private readonly IMemoryCache _memoryCache;
public JellyfinApiClient(
IMemoryCache memoryCache,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IFallbackMetadataProvider fallbackMetadataProvider,
IHttpClientFactory httpClientFactory,
ILogger<JellyfinApiClient> logger)
{
_memoryCache = memoryCache;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_fallbackMetadataProvider = fallbackMetadataProvider;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(
string address,
string authorizationHeader)
{
try
{
IJellyfinApi service = ServiceForAddress(address);
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5));
return await service.GetSystemInformation(authorizationHeader, cts.Token)
.Map(response => new JellyfinServerInformation(response.ServerName, response.OperatingSystem));
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
_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<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string authorizationHeader)
{
try
{
IJellyfinApi service = ServiceForAddress(address);
List<JellyfinLibraryResponse> libraries = await service.GetLibraries(authorizationHeader);
return libraries
.Map(Project)
.Somes()
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting jellyfin libraries");
return BaseError.New(ex.Message);
}
}
public IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null) =>
GetPagedLibraryItems(
"JF Movies",
address,
library,
library.MediaSourceId,
library.ItemId,
(service, itemId, skip, pageSize) => service.GetMovieLibraryItems(
authorizationHeader,
itemId,
startIndex: skip,
limit: pageSize),
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToMovie(lib, item)),
projectionFailures);
public IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> GetMusicVideoLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null) =>
GetPagedLibraryItems(
"JF Music Videos",
address,
library,
library.MediaSourceId,
library.ItemId,
(service, itemId, skip, pageSize) => service.GetMusicVideoLibraryItems(
authorizationHeader,
itemId,
startIndex: skip,
limit: pageSize),
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToMusicVideo(lib, item)),
projectionFailures);
public IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
string address,
string authorizationHeader,
JellyfinLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null) =>
GetPagedLibraryItems(
"JF Shows",
address,
library,
library.MediaSourceId,
library.ItemId,
(service, itemId, skip, pageSize) => service.GetShowLibraryItemsWithoutPeople(
authorizationHeader,
itemId,
startIndex: skip,
limit: pageSize),
(_, item) => ProjectToShow(item),
projectionFailures);
public IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library,
string showId,
MediaServerProjectionFailureCounter projectionFailures = null) =>
GetPagedLibraryItems(
"JF Seasons",
address,
library,
library.MediaSourceId,
showId,
(service, _, skip, pageSize) => service.GetSeasonLibraryItems(
authorizationHeader,
showId,
startIndex: skip,
limit: pageSize),
(_, item) => ProjectToSeason(item),
projectionFailures);
public IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library,
string seasonId,
MediaServerProjectionFailureCounter projectionFailures = null) =>
GetPagedLibraryItems(
"JF Episodes*",
address,
library,
library.MediaSourceId,
seasonId,
(service, _, skip, pageSize) => service.GetEpisodeLibraryItems(
authorizationHeader,
seasonId,
startIndex: skip,
limit: pageSize),
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToEpisode(lib, item)),
projectionFailures);
public IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
string address,
string authorizationHeader,
JellyfinLibrary library,
string seasonId,
MediaServerProjectionFailureCounter projectionFailures = null) =>
GetPagedLibraryItems(
"JF Episodes",
address,
library,
library.MediaSourceId,
seasonId,
(service, _, skip, pageSize) => service.GetEpisodeLibraryItemsWithoutPeople(
authorizationHeader,
seasonId,
startIndex: skip,
limit: pageSize),
(maybeLibrary, item) => WithLibrary(maybeLibrary, lib => ProjectToEpisode(lib, item)),
projectionFailures);
public IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
string address,
string authorizationHeader,
int mediaSourceId)
{
// TODO: should we enumerate collection libraries here?
if (_memoryCache.TryGetValue("jellyfin_collections_library_item_id", out string itemId))
{
return GetPagedLibraryItems(
"JF Collections",
address,
None,
mediaSourceId,
itemId,
(service, _, skip, pageSize) => service.GetCollectionLibraryItems(
authorizationHeader,
itemId,
startIndex: skip,
limit: pageSize),
(_, item) => ProjectToCollection(item));
}
return AsyncEnumerable.Empty<Tuple<JellyfinCollection, int>>();
}
public IAsyncEnumerable<Tuple<MediaItem, int>> GetCollectionItems(
string address,
string authorizationHeader,
int mediaSourceId,
string collectionId) =>
GetPagedLibraryItems(
"JF Collection Items",
address,
None,
mediaSourceId,
collectionId,
(service, _, skip, pageSize) => service.GetCollectionItems(
authorizationHeader,
collectionId,
startIndex: skip,
limit: pageSize),
(_, item) => ProjectToCollectionMediaItem(item));
public async Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
string address,
string authorizationHeader,
JellyfinLibrary library,
string itemId)
{
try
{
using (ScanProfiler.Measure("JF Playback Info"))
{
IJellyfinApi service = ServiceForAddress(address);
JellyfinPlaybackInfoResponse playbackInfo = await service.GetPlaybackInfo(authorizationHeader, itemId);
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(playbackInfo);
return maybeVersion.ToEither(() => BaseError.New("Unable to locate Jellyfin statistics"));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting jellyfin playback info");
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, Option<JellyfinShow>>> GetSingleShow(
string address,
string authorizationHeader,
JellyfinLibrary library,
string showId)
{
try
{
using (ScanProfiler.Measure("JF Single Show"))
{
IJellyfinApi service = ServiceForAddress(address);
JellyfinLibraryItemsResponse itemsResponse = await service.GetShowLibraryItems(
authorizationHeader,
parentId: library.ItemId,
recursive: false,
startIndex: 0,
limit: 1,
ids: showId);
foreach (JellyfinLibraryItemResponse item in itemsResponse.Items)
{
return ProjectToShow(item).ToOption();
}
return BaseError.New($"Unable to locate show with id {showId}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching Jellyfin shows by id");
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, List<JellyfinShow>>> SearchShowsByTitle(
string address,
string authorizationHeader,
JellyfinLibrary library,
string showTitle)
{
try
{
IJellyfinApi service = ServiceForAddress(address);
JellyfinSearchHintsResponse searchResponse = await service.SearchHints(
authorizationHeader,
showTitle,
"Series",
library.ItemId);
var shows = new List<JellyfinShow>();
foreach (JellyfinSearchHintResponse hint in searchResponse.SearchHints)
{
if (hint.Type == "Series" &&
string.Equals(hint.Name, showTitle, StringComparison.OrdinalIgnoreCase))
{
JellyfinLibraryItemsResponse detailResponse = await service.GetShowLibraryItems(
authorizationHeader,
hint.Id,
recursive: false,
startIndex: 0,
limit: 1);
foreach (JellyfinLibraryItemResponse item in detailResponse.Items)
{
Option<JellyfinShow> maybeShow = ProjectToShow(item).ToOption();
foreach (JellyfinShow show in maybeShow)
{
shows.Add(show);
}
}
}
}
return shows;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching Jellyfin shows by title");
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, Option<JellyfinEpisode>>> GetSingleEpisode(
string address,
string authorizationHeader,
JellyfinLibrary library,
string seasonId,
string episodeId)
{
try
{
using (ScanProfiler.Measure("JF Single Episode"))
{
IJellyfinApi service = ServiceForAddress(address);
JellyfinLibraryItemsResponse itemsResponse = await service.GetEpisodeLibraryItems(
authorizationHeader,
parentId: seasonId,
recursive: false,
startIndex: 0,
limit: 1,
ids: episodeId);
foreach (JellyfinLibraryItemResponse item in itemsResponse.Items)
{
return ProjectToEpisode(library, item).ToOption();
}
return BaseError.New($"Unable to locate episode with id {episodeId}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching Jellyfin episodes by id");
return BaseError.New(ex.Message);
}
}
// #484: defensive, and currently UNREACHABLE — every counted path passes a concrete library, and the
// only None caller (collections) does not route through here. Kept because the classification would
// otherwise be wrong by default if a future caller does pass None: a library we could not resolve is
// not a deliberate per-item skip, so fail closed rather than look like a clean upstream deletion.
private static MediaServerProjectionResult<TItem> WithLibrary<TItem>(
Option<JellyfinLibrary> maybeLibrary,
Func<JellyfinLibrary, MediaServerProjectionResult<TItem>> project) =>
maybeLibrary.Match(project, MediaServerProjectionResult<TItem>.Failed);
private async IAsyncEnumerable<Tuple<TItem, int>> GetPagedLibraryItems<TItem>(
string pageDescription,
string address,
Option<JellyfinLibrary> maybeLibrary,
int mediaSourceId,
string parentId,
Func<IJellyfinApi, string, int, int, Task<JellyfinLibraryItemsResponse>> getItems,
Func<Option<JellyfinLibrary>, JellyfinLibraryItemResponse, MediaServerProjectionResult<TItem>> mapper,
MediaServerProjectionFailureCounter projectionFailures = null)
{
IJellyfinApi service = ServiceForAddress(address);
int pages = int.MaxValue;
for (var i = 0; i < pages; i++)
{
int skip = i * SystemEnvironment.JellyfinPageSize;
JellyfinLibraryItemsResponse result;
using (ScanProfiler.Measure(pageDescription))
{
result = await getItems(
service,
parentId,
skip,
SystemEnvironment.JellyfinPageSize);
}
// update page count
pages = Math.Min(pages, (result.TotalRecordCount - 1) / SystemEnvironment.JellyfinPageSize + 1);
foreach (JellyfinLibraryItemResponse response in result.Items)
{
MediaServerProjectionResult<TItem> projection = mapper(maybeLibrary, response);
// #484: a swallowed projection failure silently drops an item the server DID return, which
// is indistinguishable from a deletion downstream. Report it so the sweep can refuse.
// Deliberate skips (STRM, virtual, unsupported type) are NOT counted here.
if (projection.IsFailure)
{
projectionFailures?.RecordFailure();
}
foreach (TItem item in projection.Item)
{
yield return new Tuple<TItem, int>(item, result.TotalRecordCount);
}
}
}
}
private MediaServerProjectionResult<MediaItem> ProjectToCollectionMediaItem(JellyfinLibraryItemResponse item)
{
try
{
if (item.LocationType != "FileSystem")
{
return MediaServerProjectionResult<MediaItem>.Skipped();
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path);
return MediaServerProjectionResult<MediaItem>.Skipped();
}
return item.Type switch
{
"Movie" => MediaServerProjectionResult<MediaItem>.Projected(new JellyfinMovie { ItemId = item.Id }),
"Series" => MediaServerProjectionResult<MediaItem>.Projected(new JellyfinShow { ItemId = item.Id }),
"Season" => MediaServerProjectionResult<MediaItem>.Projected(new JellyfinSeason { ItemId = item.Id }),
"Episode" => MediaServerProjectionResult<MediaItem>.Projected(
new JellyfinEpisode { ItemId = item.Id }),
_ => MediaServerProjectionResult<MediaItem>.Skipped()
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin collection media item");
return MediaServerProjectionResult<MediaItem>.Failed();
}
}
private Option<JellyfinLibrary> Project(JellyfinLibraryResponse response) =>
// normalize "no content type" to null: Jellyfin serializes a mixed library's collection type
// as absent, empty or whitespace depending on server version, and all three mean the same thing
(string.IsNullOrWhiteSpace(response.CollectionType) ? null : response.CollectionType.ToLowerInvariant()) switch
{
"tvshows" => new JellyfinLibrary
{
ItemId = response.ItemId,
Name = response.Name,
MediaKind = LibraryMediaKind.Shows,
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
PathInfos = GetPathInfos(response)
},
"movies" => new JellyfinLibrary
{
ItemId = response.ItemId,
Name = response.Name,
MediaKind = LibraryMediaKind.Movies,
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
PathInfos = GetPathInfos(response)
},
"musicvideos" => new JellyfinLibrary
{
ItemId = response.ItemId,
Name = response.Name,
MediaKind = LibraryMediaKind.MusicVideos,
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
PathInfos = GetPathInfos(response)
},
// TODO: ??? for music libraries
"boxsets" => CacheCollectionLibraryId(response.ItemId),
// A "mixed content" library. Jellyfin reports these as either the literal "mixed" or with
// no collection type at all, depending on server version. Its items are read per type via
// includeItemTypes, so the mix is resolved authoritatively by Jellyfin rather than guessed.
"mixed" or null => new JellyfinLibrary
{
ItemId = response.ItemId,
Name = response.Name,
MediaKind = LibraryMediaKind.Mixed,
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
PathInfos = GetPathInfos(response)
},
// anything else (notably "music" audio libraries) stays unsupported
_ => None
};
private static List<JellyfinPathInfo> GetPathInfos(JellyfinLibraryResponse response)
{
var result = new List<JellyfinPathInfo>();
if (response.LibraryOptions?.PathInfos is not null)
{
result.AddRange(
response.LibraryOptions.PathInfos
.Filter(pi => !string.IsNullOrWhiteSpace(pi.NetworkPath))
.Map(pi => new JellyfinPathInfo
{
Path = pi.Path,
NetworkPath = pi.NetworkPath
}));
}
return result;
}
private Option<JellyfinLibrary> CacheCollectionLibraryId(string itemId)
{
_memoryCache.Set("jellyfin_collections_library_item_id", itemId);
return None;
}
private MediaServerProjectionResult<JellyfinMovie> ProjectToMovie(
JellyfinLibrary library,
JellyfinLibraryItemResponse item)
{
try
{
if (item.LocationType != "FileSystem")
{
return MediaServerProjectionResult<JellyfinMovie>.Skipped();
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
return MediaServerProjectionResult<JellyfinMovie>.Skipped();
}
string path = item.Path ?? string.Empty;
foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi =>
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
{
path = _jellyfinPathReplacementService.ReplaceNetworkPath(
(JellyfinMediaSource)library.MediaSource,
path,
pathInfo.NetworkPath,
pathInfo.Path);
}
}
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
var version = new MediaVersion
{
Name = "Main",
Duration = duration,
DateAdded = item.DateCreated.UtcDateTime,
MediaFiles =
[
new MediaFile
{
Path = path,
PathHash = PathUtils.GetPathHash(path)
}
],
Streams = [],
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
};
MovieMetadata metadata = ProjectToMovieMetadata(item);
var movie = new JellyfinMovie
{
ItemId = item.Id,
Etag = item.Etag,
MediaVersions = [version],
MovieMetadata = [metadata],
TraktListItems = []
};
return movie;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin movie");
return MediaServerProjectionResult<JellyfinMovie>.Failed();
}
}
private static List<MediaChapter> ProjectToModel(
IEnumerable<JellyfinChapterResponse> jellyfinChapters,
TimeSpan duration)
{
var models = jellyfinChapters.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(JellyfinChapterResponse chapterResponse) =>
new()
{
Title = chapterResponse.Name,
StartTime = TimeSpan.FromTicks(chapterResponse.StartPositionTicks)
};
private static 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 = SortTitle.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<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Subtitles = new List<Subtitle>()
};
// 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.Count != 0)
{
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 MediaServerProjectionResult<JellyfinMusicVideo> ProjectToMusicVideo(
JellyfinLibrary library,
JellyfinLibraryItemResponse item)
{
try
{
if (item.LocationType != "FileSystem")
{
return MediaServerProjectionResult<JellyfinMusicVideo>.Skipped();
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
return MediaServerProjectionResult<JellyfinMusicVideo>.Skipped();
}
string path = item.Path ?? string.Empty;
foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi =>
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
{
path = _jellyfinPathReplacementService.ReplaceNetworkPath(
(JellyfinMediaSource)library.MediaSource,
path,
pathInfo.NetworkPath,
pathInfo.Path);
}
}
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
var version = new MediaVersion
{
Name = "Main",
Duration = duration,
DateAdded = item.DateCreated.UtcDateTime,
MediaFiles =
[
new MediaFile
{
Path = path,
PathHash = PathUtils.GetPathHash(path)
}
],
Streams = [],
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
};
MusicVideoMetadata metadata = ProjectToMusicVideoMetadata(item);
var musicVideo = new JellyfinMusicVideo
{
ItemId = item.Id,
Etag = item.Etag,
MediaVersions = [version],
MusicVideoMetadata = [metadata],
TraktListItems = []
};
return musicVideo;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin music video");
return MediaServerProjectionResult<JellyfinMusicVideo>.Failed();
}
}
private static MusicVideoMetadata ProjectToMusicVideoMetadata(JellyfinLibraryItemResponse item)
{
DateTime dateAdded = item.DateCreated.UtcDateTime;
var metadata = new MusicVideoMetadata
{
MetadataKind = MetadataKind.External,
Title = item.Name,
SortTitle = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview,
Year = item.ProductionYear,
DateAdded = dateAdded,
// ersatztv#177: Album/Track drive the MTV-style credits overlay (Scriban templates expose both)
// and MusicVideo ordering in ChronologicalMediaComparer. Local NFO libraries have always mapped
// them; the Jellyfin projection did not, so Jellyfin-sourced music videos rendered weaker credits.
Album = item.Album,
Track = item.IndexNumber,
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(),
Artists = MusicVideoArtists(item)
.ToList(),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Subtitles = new List<Subtitle>()
};
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?.Count > 0)
{
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 IEnumerable<MusicVideoArtist> MusicVideoArtists(JellyfinLibraryItemResponse item)
{
List<string> artists = Optional(item.Artists).Flatten().ToList();
if (artists.Count == 0)
{
artists = Optional(item.People).Flatten()
.Filter(p => p.Type is "Artist")
.Map(p => p.Name)
.ToList();
}
return artists.Filter(name => !string.IsNullOrWhiteSpace(name))
.Distinct()
.Map(name => new MusicVideoArtist { Name = name });
}
private static Option<Actor> 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<Director> ProjectToDirector(JellyfinPersonResponse person)
{
if (person.Type?.ToLowerInvariant() != "director")
{
return None;
}
return new Director { Name = person.Name };
}
private static Option<Writer> ProjectToWriter(JellyfinPersonResponse person)
{
if (person.Type?.ToLowerInvariant() != "writer")
{
return None;
}
return new Writer { Name = person.Name };
}
// NOTE (#484): no deliberate-skip guard clause here — every drop from this projection is a failure.
private MediaServerProjectionResult<JellyfinShow> ProjectToShow(JellyfinLibraryItemResponse item)
{
try
{
ShowMetadata metadata = ProjectToShowMetadata(item);
var show = new JellyfinShow
{
ItemId = item.Id,
Etag = item.Etag,
ShowMetadata = new List<ShowMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
};
return show;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin show");
return MediaServerProjectionResult<JellyfinShow>.Failed();
}
}
private static 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 = SortTitle.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<Artwork>(),
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.Count != 0)
{
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;
}
// NOTE (#484): no deliberate-skip guard clause here — every drop from this projection is a failure.
private MediaServerProjectionResult<JellyfinSeason> ProjectToSeason(JellyfinLibraryItemResponse item)
{
try
{
DateTime dateAdded = item.DateCreated.UtcDateTime;
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new SeasonMetadata
{
MetadataKind = MetadataKind.External,
Title = item.Name,
SortTitle = SortTitle.GetSortTitle(item.Name),
Year = item.ProductionYear,
DateAdded = dateAdded,
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Tags = new List<Tag>()
};
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.Count != 0)
{
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<SeasonMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
};
if (item.IndexNumber.HasValue)
{
season.SeasonNumber = item.IndexNumber.Value;
}
else
{
Option<int> maybeSeasonNumber =
_fallbackMetadataProvider.GetSeasonNumberForFolder(item.Path ?? string.Empty);
foreach (int seasonNumber in maybeSeasonNumber)
{
season.SeasonNumber = seasonNumber;
}
}
return season;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin season");
return MediaServerProjectionResult<JellyfinSeason>.Failed();
}
}
// NOTE (#484): no deliberate-skip guard clause here — every drop from this projection is a failure.
private MediaServerProjectionResult<JellyfinCollection> ProjectToCollection(JellyfinLibraryItemResponse item)
{
try
{
return new JellyfinCollection
{
ItemId = item.Id,
Etag = item.Etag,
Name = item.Name
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin collection");
return MediaServerProjectionResult<JellyfinCollection>.Failed();
}
}
private MediaServerProjectionResult<JellyfinEpisode> ProjectToEpisode(
JellyfinLibrary library,
JellyfinLibraryItemResponse item)
{
try
{
if (item.LocationType != "FileSystem")
{
return MediaServerProjectionResult<JellyfinEpisode>.Skipped();
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path);
return MediaServerProjectionResult<JellyfinEpisode>.Skipped();
}
string path = item.Path ?? string.Empty;
foreach (JellyfinPathInfo pathInfo in library.PathInfos.Filter(pi =>
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
{
path = _jellyfinPathReplacementService.ReplaceNetworkPath(
(JellyfinMediaSource)library.MediaSource,
path,
pathInfo.NetworkPath,
pathInfo.Path);
}
}
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
var version = new MediaVersion
{
Name = "Main",
Duration = duration,
DateAdded = item.DateCreated.UtcDateTime,
MediaFiles =
[
new MediaFile
{
Path = path,
PathHash = PathUtils.GetPathHash(path)
}
],
Streams = [],
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
};
EpisodeMetadata metadata = ProjectToEpisodeMetadata(item);
var episode = new JellyfinEpisode
{
ItemId = item.Id,
Etag = item.Etag,
MediaVersions = [version],
EpisodeMetadata = [metadata],
TraktListItems = []
};
return episode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin episode");
return MediaServerProjectionResult<JellyfinEpisode>.Failed();
}
}
private static 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 = SortTitle.GetSortTitle(item.Name),
Plot = item.Overview,
Year = item.ProductionYear,
DateAdded = dateAdded,
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 = new List<Studio>(),
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(),
Subtitles = new List<Subtitle>()
};
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 static List<MetadataGuid> GuidsFromProviderIds(JellyfinProviderIdsResponse providerIds)
{
var result = new List<MetadataGuid>();
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;
}
private Option<MediaVersion> ProjectToMediaVersion(JellyfinPlaybackInfoResponse response)
{
if (response.MediaSources is null || response.MediaSources.Count == 0)
{
_logger.LogWarning("Received empty playback info from Jellyfin");
return None;
}
JellyfinMediaSourceResponse mediaSource = response.MediaSources.Head();
// jellyfin includes external streams first, obscuring real stream indexes
// from the source file
int streamIndexOffset = mediaSource.MediaStreams
.Filter(s => s.IsExternal)
.Map(s => s.Index + 1)
.OrderByDescending(i => i)
.FirstOrDefault();
IList<JellyfinMediaStreamResponse> streams = mediaSource.MediaStreams;
Option<JellyfinMediaStreamResponse> maybeVideoStream =
streams.Find(s => s.Type == JellyfinMediaStreamType.Video);
return maybeVideoStream.Map(videoStream =>
{
int width = videoStream.Width ?? 1;
int height = videoStream.Height ?? 1;
var isAnamorphic = false;
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(":");
if (double.TryParse(split[0], NumberStyles.Any, CultureInfo.InvariantCulture, out double num) &&
double.TryParse(split[1], NumberStyles.Any, CultureInfo.InvariantCulture, out double den) &&
den != 0)
{
double displayRatio = num / den;
isAnamorphic = Math.Abs(resolutionRatio - displayRatio) > 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 - streamIndexOffset,
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 (JellyfinMediaStreamResponse audioStream in streams.Filter(s =>
s.Type == JellyfinMediaStreamType.Audio))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Audio,
Index = audioStream.Index - streamIndexOffset,
Codec = audioStream.Codec,
Profile = (audioStream.Profile ?? string.Empty).ToLowerInvariant(),
Channels = audioStream.Channels ?? 2,
Default = audioStream.IsDefault,
Forced = audioStream.IsForced,
Language = audioStream.Language,
Title = audioStream.Title ?? string.Empty
};
version.Streams.Add(stream);
}
foreach (JellyfinMediaStreamResponse subtitleStream in streams.Filter(s =>
s.Type == JellyfinMediaStreamType.Subtitle))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
Title = subtitleStream.Title,
Codec = (subtitleStream.Codec ?? string.Empty).ToLowerInvariant(),
Default = subtitleStream.IsDefault,
Forced = subtitleStream.IsForced,
Language = subtitleStream.Language
};
if (subtitleStream.IsExternal)
{
stream.MediaStreamKind = MediaStreamKind.ExternalSubtitle;
// ensure these don't collide with real indexes from the source file
stream.Index = subtitleStream.Index + JellyfinStream.ExternalStreamOffset;
}
else
{
stream.MediaStreamKind = MediaStreamKind.Subtitle;
stream.Index = subtitleStream.Index - streamIndexOffset;
}
version.Streams.Add(stream);
}
return version;
});
}
private IJellyfinApi ServiceForAddress(string address)
{
var client = _httpClientFactory.CreateClient("RefitCustomClient");
client.BaseAddress = new Uri(address);
return RestService.For<IJellyfinApi>(client);
}
}