using System.Globalization; using System.Xml.Serialization; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; using ErsatzTV.Infrastructure.Plex.Models; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Refit; namespace ErsatzTV.Infrastructure.Plex; public class PlexServerApiClient : IPlexServerApiClient { private readonly ILogger _logger; private readonly PlexEtag _plexEtag; public PlexServerApiClient(PlexEtag plexEtag, ILogger logger) { _plexEtag = plexEtag; _logger = logger; } public async Task Ping(PlexConnection connection, PlexServerAuthToken token) { try { IPlexServerApi service = XmlServiceFor(connection.Uri, TimeSpan.FromSeconds(5)); PlexXmlMediaContainerPingResponse pingResult = await service.Ping(token.AuthToken); return token.ClientIdentifier == pingResult.MachineIdentifier; } catch (Exception) { return false; } } public async Task>> GetLibraries( PlexConnection connection, PlexServerAuthToken token) { try { IPlexServerApi service = RestService.For( new HttpClient { BaseAddress = new Uri(connection.Uri), Timeout = TimeSpan.FromSeconds(10) }); List directory = await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory); List response = directory .Filter(l => l.Type.ToLowerInvariant() is "movie" or "show") .Map(Project) .Somes() .ToList(); return response; } catch (Exception ex) { return BaseError.New(ex.ToString()); } } public IAsyncEnumerable> GetMovieLibraryContents( PlexLibrary library, PlexConnection connection, PlexServerAuthToken token) { Task CountItems(IPlexServerApi service) { return service.GetLibrarySection(library.Key, token.AuthToken); } Task> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize) { return jsonService .GetLibrarySectionContents(library.Key, skip, pageSize, token.AuthToken) .Map( r => r.MediaContainer.Metadata.Filter( m => m.Media.Count > 0 && m.Media.Any(media => media.Part.Count > 0))) .Map(list => list.Map(metadata => ProjectToMovie(metadata, library.MediaSourceId))); } return GetPagedLibraryContents(connection, CountItems, GetItems); } public IAsyncEnumerable> GetShowLibraryContents( PlexLibrary library, PlexConnection connection, PlexServerAuthToken token) { Task CountItems(IPlexServerApi service) { return service.GetLibrarySection(library.Key, token.AuthToken); } Task> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize) { return jsonService .GetLibrarySectionContents(library.Key, skip, pageSize, token.AuthToken) .Map(r => r.MediaContainer.Metadata ?? new List()) .Map(list => list.Map(metadata => ProjectToShow(metadata, library.MediaSourceId))); } return GetPagedLibraryContents(connection, CountItems, GetItems); } public IAsyncEnumerable> GetOtherVideoLibraryContents( PlexLibrary library, PlexConnection connection, PlexServerAuthToken token) { Task CountItems(IPlexServerApi service) { return service.GetLibrarySection(library.Key, token.AuthToken); } Task> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize) { return jsonService .GetLibrarySectionContents(library.Key, skip, pageSize, token.AuthToken) .Map( r => r.MediaContainer.Metadata.Filter( m => m.Media.Count > 0 && m.Media.Any(media => media.Part.Count > 0))) .Map(list => list.Map(metadata => ProjectToOtherVideo(metadata, library.MediaSourceId, library))); } return GetPagedLibraryContents(connection, CountItems, GetItems); } public IAsyncEnumerable> GetShowSeasons( PlexLibrary library, PlexShow show, PlexConnection connection, PlexServerAuthToken token) { string showMetadataKey = show.Key.Split("/").Reverse().Skip(1).Head(); Task CountItems(IPlexServerApi service) { return service.CountShowChildren(showMetadataKey, token.AuthToken); } Task> GetItems(IPlexServerApi xmlService, IPlexServerApi _, int skip, int pageSize) { return xmlService.GetShowChildren(showMetadataKey, skip, pageSize, token.AuthToken) .Map(r => r.Metadata.Filter(m => !m.Key.Contains("allLeaves"))) .Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId))); } return GetPagedLibraryContents(connection, CountItems, GetItems); } public IAsyncEnumerable> GetSeasonEpisodes( PlexLibrary library, PlexSeason season, PlexConnection connection, PlexServerAuthToken token) { string seasonMetadataKey = season.Key.Split("/").Reverse().Skip(1).Head(); Task CountItems(IPlexServerApi service) { return service.CountSeasonChildren(seasonMetadataKey, token.AuthToken); } Task> GetItems(IPlexServerApi xmlService, IPlexServerApi _, int skip, int pageSize) { return xmlService.GetSeasonChildren(seasonMetadataKey, skip, pageSize, token.AuthToken) .Map(r => r.Metadata.Filter(m => m.Media.Count > 0 && m.Media.Any(media => media.Part.Count > 0))) .Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId))); } return GetPagedLibraryContents(connection, CountItems, GetItems); } public async Task> GetShowMetadata( PlexLibrary library, string key, PlexConnection connection, PlexServerAuthToken token) { try { IPlexServerApi service = XmlServiceFor(connection.Uri); return await service.GetDirectoryMetadata(key, token.AuthToken) .Map(Optional) .MapT(response => ProjectToShowMetadata(response.Metadata, library.MediaSourceId)) .Map(o => o.ToEither("Unable to locate metadata")); } catch (Exception ex) { return BaseError.New(ex.ToString()); } } public async Task>> GetMovieMetadataAndStatistics( int plexMediaSourceId, string key, PlexConnection connection, PlexServerAuthToken token) { try { IPlexServerApi service = XmlServiceFor(connection.Uri); Option maybeResponse = await service .GetVideoMetadata(key, token.AuthToken) .Map(Optional) .Map( r => r.Filter( m => m.Metadata.Media.Count > 0 && m.Metadata.Media.Any(media => media.Part.Count > 0))); return maybeResponse.Match( response => { Option maybeVersion = ProjectToMediaVersion(response.Metadata); return maybeVersion.Match>>( version => Tuple( ProjectToMovieMetadata(version, response.Metadata, plexMediaSourceId), version), () => BaseError.New("Unable to locate metadata")); }, () => BaseError.New("Unable to locate metadata")); } catch (Exception ex) { return BaseError.New(ex.ToString()); } } public async Task>> GetOtherVideoMetadataAndStatistics( int plexMediaSourceId, string key, PlexConnection connection, PlexServerAuthToken token, PlexLibrary library) { try { IPlexServerApi service = XmlServiceFor(connection.Uri); Option maybeResponse = await service .GetVideoMetadata(key, token.AuthToken) .Map(Optional) .Map( r => r.Filter( m => m.Metadata.Media.Count > 0 && m.Metadata.Media.Any(media => media.Part.Count > 0))); return maybeResponse.Match( response => { Option maybeVersion = ProjectToMediaVersion(response.Metadata); return maybeVersion.Match>>( version => Tuple( ProjectToOtherVideoMetadata(version, response.Metadata, plexMediaSourceId, library), version), () => BaseError.New("Unable to locate metadata")); }, () => BaseError.New("Unable to locate metadata")); } catch (Exception ex) { return BaseError.New(ex.ToString()); } } public async Task>> GetEpisodeMetadataAndStatistics( int plexMediaSourceId, string key, PlexConnection connection, PlexServerAuthToken token) { try { IPlexServerApi service = XmlServiceFor(connection.Uri); Option maybeResponse = await service .GetVideoMetadata(key, token.AuthToken) .Map(Optional) .Map( r => r.Filter( m => m.Metadata.Media.Count > 0 && m.Metadata.Media.Any(media => media.Part.Count > 0))); return maybeResponse.Match( response => { Option maybeVersion = ProjectToMediaVersion(response.Metadata); return maybeVersion.Match>>( version => Tuple( ProjectToEpisodeMetadata(version, response.Metadata, plexMediaSourceId), version), () => BaseError.New("Unable to locate metadata")); }, () => BaseError.New("Unable to locate metadata")); } catch (Exception ex) { return BaseError.New(ex.ToString()); } } public IAsyncEnumerable> GetAllCollections( PlexConnection connection, PlexServerAuthToken token, CancellationToken cancellationToken) { return GetPagedLibraryContents(connection, CountItems, GetItems); Task CountItems(IPlexServerApi service) { return service.GetCollectionCount(token.AuthToken); } Task> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize) { return jsonService .GetCollections(skip, pageSize, token.AuthToken) .Map(r => r.MediaContainer.Metadata) .Map(list => list.Map(m => ProjectToCollection(connection.PlexMediaSource, m)).Somes()); } } public IAsyncEnumerable> GetCollectionItems( PlexConnection connection, PlexServerAuthToken token, string key, CancellationToken cancellationToken) { return GetPagedLibraryContents(connection, CountItems, GetItems); Task CountItems(IPlexServerApi service) { return service.GetCollectionItemsCount(key, token.AuthToken); } Task> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize) { return jsonService .GetCollectionItems(key, skip, pageSize, token.AuthToken) .Map(r => Optional(r.MediaContainer.Metadata).Flatten()) .Map(list => list.Map(ProjectToCollectionMediaItem).Somes()); } } private static async IAsyncEnumerable> GetPagedLibraryContents( PlexConnection connection, Func> countItems, Func>> getItems) { IPlexServerApi xmlService = XmlServiceFor(connection.Uri); int size = await countItems(xmlService).Map(r => r.TotalSize); if (size == 0) { yield break; } const int PAGE_SIZE = 10; IPlexServerApi jsonService = RestService.For(connection.Uri); int pages = (size - 1) / PAGE_SIZE + 1; for (var i = 0; i < pages; i++) { int skip = i * PAGE_SIZE; Task> result = getItems(xmlService, jsonService, skip, PAGE_SIZE); foreach (TItem item in await result) { yield return new Tuple(item, size); } } } private static IPlexServerApi XmlServiceFor(string uri, TimeSpan? timeout = null) { var overrides = new XmlAttributeOverrides(); var attrs = new XmlAttributes { XmlIgnore = true }; overrides.Add(typeof(PlexMetadataResponse), "Media", attrs); TimeSpan httpClientTimeout = timeout ?? TimeSpan.FromSeconds(30); return RestService.For( new HttpClient { BaseAddress = new Uri(uri), Timeout = httpClientTimeout }, new RefitSettings { ContentSerializer = new XmlContentSerializer( new XmlContentSerializerSettings { XmlAttributeOverrides = overrides }) }); } private static Option Project(PlexLibraryResponse response) { List paths = [ new LibraryPath { Path = JsonConvert.SerializeObject( new LibraryPaths { Paths = response.Location.Map(l => l.Path).ToList() }) } ]; return response.Type switch { "show" => new PlexLibrary { Key = response.Key, Name = response.Title, MediaKind = LibraryMediaKind.Shows, ShouldSyncItems = false, Paths = paths }, "movie" => new PlexLibrary { Key = response.Key, Name = response.Title, MediaKind = response.Agent == "com.plexapp.agents.none" && response.Language == "xn" ? LibraryMediaKind.OtherVideos : LibraryMediaKind.Movies, ShouldSyncItems = false, Paths = paths }, // TODO: "artist" for music libraries _ => None }; } private Option ProjectToCollection( PlexMediaSource plexMediaSource, PlexCollectionMetadataResponse item) { try { // skip collections in libraries that are not synchronized if (plexMediaSource.Libraries.OfType().Any( l => l.Key == item.LibrarySectionId.ToString(CultureInfo.InvariantCulture) && l.ShouldSyncItems == false)) { return Option.None; } return new PlexCollection { Key = item.RatingKey, Etag = _plexEtag.ForCollection(item), Name = item.Title }; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Plex collection"); return None; } } private Option ProjectToCollectionMediaItem(PlexCollectionItemMetadataResponse item) { try { return item.Type switch { "movie" => new PlexMovie { Key = item.Key }, "show" => new PlexShow { Key = item.Key }, "season" => new PlexSeason { Key = item.Key }, "episode" => new PlexEpisode { Key = item.Key }, _ => None }; } catch (Exception ex) { _logger.LogWarning(ex, "Error projecting Plex collection media item"); return None; } } private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId) { PlexMediaResponse media = response.Media .Filter(media => media.Part.Count != 0) .MaxBy(media => media.Id); PlexPartResponse part = media.Part.Head(); DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var version = new MediaVersion { Name = "Main", Duration = TimeSpan.FromMilliseconds(media.Duration), Width = media.Width, Height = media.Height, // specifically omit sample aspect ratio DateAdded = dateAdded, DateUpdated = lastWriteTime, MediaFiles = new List { new PlexMediaFile { PlexId = part.Id, Key = part.Key, Path = part.File } }, Streams = new List() }; MovieMetadata metadata = ProjectToMovieMetadata(version, response, mediaSourceId); var movie = new PlexMovie { Etag = _plexEtag.ForMovie(response), Key = response.Key, MovieMetadata = new List { metadata }, MediaVersions = new List { version }, TraktListItems = new List() }; return movie; } private MovieMetadata ProjectToMovieMetadata(MediaVersion version, PlexMetadataResponse response, int mediaSourceId) { DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var metadata = new MovieMetadata { MetadataKind = MetadataKind.External, Title = response.Title, SortTitle = SortTitle.GetSortTitle(response.Title), Plot = response.Summary, Year = response.Year, Tagline = response.Tagline, ContentRating = response.ContentRating, DateAdded = dateAdded, DateUpdated = lastWriteTime, Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(), Tags = new List(), Studios = new List(), Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime)) .ToList(), Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(), Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList(), Subtitles = new List() }; var subtitleStreams = version.Streams .Filter(s => s.MediaStreamKind is MediaStreamKind.Subtitle or MediaStreamKind.ExternalSubtitle) .ToList(); metadata.Subtitles.AddRange(subtitleStreams.Map(Subtitle.FromMediaStream)); if (response is PlexXmlMetadataResponse xml) { metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList(); if (!string.IsNullOrWhiteSpace(xml.PlexGuid)) { Option normalized = NormalizeGuid(xml.PlexGuid); foreach (string guid in normalized) { if (metadata.Guids.All(g => g.Guid != guid)) { metadata.Guids.Add(new MetadataGuid { Guid = guid }); } } } } else { metadata.Guids = new List(); } foreach (PlexLabelResponse label in Optional(response.Label).Flatten()) { metadata.Tags.Add( new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture) }); } if (!string.IsNullOrWhiteSpace(response.Studio)) { metadata.Studios.Add(new Studio { Name = response.Studio }); } if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(response.Thumb)) { var path = $"plex/{mediaSourceId}{response.Thumb}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } if (!string.IsNullOrWhiteSpace(response.Art)) { var path = $"plex/{mediaSourceId}{response.Art}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } return metadata; } private static Option ProjectToMediaVersion(PlexXmlMetadataResponse response) { PlexMediaResponse media = response.Media .Filter(media => media.Part.Count != 0) .MaxBy(media => media.Id); List streams = media.Part.Head().Stream; DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; Option maybeVideoStream = streams.Find(s => s.StreamType == 1); return maybeVideoStream.Map( videoStream => { var version = new MediaVersion { Duration = TimeSpan.FromMilliseconds(media.Duration), SampleAspectRatio = string.IsNullOrWhiteSpace(videoStream.PixelAspectRatio) ? "1:1" : videoStream.PixelAspectRatio, VideoScanKind = videoStream.ScanType switch { "interlaced" => VideoScanKind.Interlaced, "progressive" => VideoScanKind.Progressive, _ => VideoScanKind.Unknown }, Streams = new List(), DateUpdated = dateUpdated, Width = videoStream.Width, Height = videoStream.Height, RFrameRate = videoStream.FrameRate, DisplayAspectRatio = media.AspectRatio == 0 ? string.Empty : media.AspectRatio.ToString("0.00###", CultureInfo.InvariantCulture), Chapters = Optional(response.Chapters).Flatten().Map(ProjectToModel).ToList() }; version.Streams.Add( new MediaStream { MediaVersionId = version.Id, MediaStreamKind = MediaStreamKind.Video, Index = videoStream.Index!.Value, Codec = videoStream.Codec, Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(), Default = videoStream.Default, Language = videoStream.LanguageCode, Forced = videoStream.Forced, BitsPerRawSample = videoStream.BitDepth, ColorRange = (videoStream.ColorRange ?? string.Empty).ToLowerInvariant(), ColorSpace = (videoStream.ColorSpace ?? string.Empty).ToLowerInvariant(), ColorTransfer = (videoStream.ColorTrc ?? string.Empty).ToLowerInvariant(), ColorPrimaries = (videoStream.ColorPrimaries ?? string.Empty).ToLowerInvariant() }); foreach (PlexStreamResponse audioStream in streams.Filter(s => s.StreamType == 2 && s.Index.HasValue)) { var stream = new MediaStream { MediaVersionId = version.Id, MediaStreamKind = MediaStreamKind.Audio, Index = audioStream.Index.Value, Codec = audioStream.Codec, Profile = (audioStream.Profile ?? string.Empty).ToLowerInvariant(), Channels = audioStream.Channels, Default = audioStream.Default, Forced = audioStream.Forced, Language = audioStream.LanguageCode, Title = audioStream.Title ?? string.Empty }; version.Streams.Add(stream); } // filter to embedded subtitles, but ignore "embedded in video" closed-caption streams foreach (PlexStreamResponse subtitleStream in streams.Filter(s => s.StreamType == 3 && s.Index.HasValue && !s.EmbeddedInVideo)) { var stream = new MediaStream { MediaVersionId = version.Id, MediaStreamKind = MediaStreamKind.Subtitle, Index = subtitleStream.Index.Value, Codec = subtitleStream.Codec, Default = subtitleStream.Default, Forced = subtitleStream.Forced, Language = subtitleStream.LanguageCode }; version.Streams.Add(stream); } // also include external subtitles foreach (PlexStreamResponse subtitleStream in streams.Filter( s => s.StreamType == 3 && !s.Index.HasValue && !string.IsNullOrWhiteSpace(s.Key))) { var stream = new MediaStream { MediaVersionId = version.Id, MediaStreamKind = MediaStreamKind.ExternalSubtitle, // hacky? maybe... FileName = subtitleStream.Key, Index = subtitleStream.Id, Codec = subtitleStream.Codec, Default = subtitleStream.Default, Forced = subtitleStream.Forced, Language = subtitleStream.LanguageCode }; version.Streams.Add(stream); } return version; }); } private PlexShow ProjectToShow(PlexMetadataResponse response, int mediaSourceId) { ShowMetadata metadata = ProjectToShowMetadata(response, mediaSourceId); var show = new PlexShow { Key = response.Key, Etag = _plexEtag.ForShow(response), ShowMetadata = new List { metadata }, TraktListItems = new List() }; return show; } private ShowMetadata ProjectToShowMetadata(PlexMetadataResponse response, int mediaSourceId) { DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var metadata = new ShowMetadata { MetadataKind = MetadataKind.External, Title = response.Title, SortTitle = SortTitle.GetSortTitle(response.Title), Plot = response.Summary, Year = response.Year, Tagline = response.Tagline, ContentRating = response.ContentRating, DateAdded = dateAdded, DateUpdated = lastWriteTime, Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(), Tags = new List(), Studios = new List(), Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime)) .ToList() }; if (response is PlexXmlMetadataResponse xml) { metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList(); if (!string.IsNullOrWhiteSpace(xml.PlexGuid)) { Option normalized = NormalizeGuid(xml.PlexGuid); foreach (string guid in normalized) { if (metadata.Guids.All(g => g.Guid != guid)) { metadata.Guids.Add(new MetadataGuid { Guid = guid }); } } } } else { metadata.Guids = new List(); } if (!string.IsNullOrWhiteSpace(response.Studio)) { metadata.Studios.Add(new Studio { Name = response.Studio }); } foreach (PlexLabelResponse label in Optional(response.Label).Flatten()) { metadata.Tags.Add( new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture) }); } if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(response.Thumb)) { var path = $"plex/{mediaSourceId}{response.Thumb}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } if (!string.IsNullOrWhiteSpace(response.Art)) { var path = $"plex/{mediaSourceId}{response.Art}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } return metadata; } private PlexSeason ProjectToSeason(PlexXmlMetadataResponse response, int mediaSourceId) { DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var metadata = new SeasonMetadata { MetadataKind = MetadataKind.External, Title = response.Title, SortTitle = SortTitle.GetSortTitle(response.Title), Year = response.Year, DateAdded = dateAdded, DateUpdated = lastWriteTime, Tags = new List() }; metadata.Guids = Optional(response.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList(); if (!string.IsNullOrWhiteSpace(response.PlexGuid)) { Option normalized = NormalizeGuid(response.PlexGuid); foreach (string guid in normalized) { if (metadata.Guids.All(g => g.Guid != guid)) { metadata.Guids.Add(new MetadataGuid { Guid = guid }); } } } if (!string.IsNullOrWhiteSpace(response.Thumb)) { var path = $"plex/{mediaSourceId}{response.Thumb}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } if (!string.IsNullOrWhiteSpace(response.Art)) { var path = $"plex/{mediaSourceId}{response.Art}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } var season = new PlexSeason { Key = response.Key, Etag = _plexEtag.ForSeason(response), SeasonNumber = response.Index, SeasonMetadata = new List { metadata }, TraktListItems = new List() }; return season; } private PlexEpisode ProjectToEpisode(PlexXmlMetadataResponse response, int mediaSourceId) { PlexMediaResponse media = response.Media .Filter(media => media.Part.Count != 0) .MaxBy(media => media.Id); PlexXmlPartResponse part = media.Part.Head(); DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var version = new MediaVersion { Name = "Main", Duration = TimeSpan.FromMilliseconds(media.Duration), Width = media.Width, Height = media.Height, DateAdded = dateAdded, DateUpdated = lastWriteTime, MediaFiles = new List { new PlexMediaFile { PlexId = part.Id, Key = part.Key, Path = part.File } }, // specifically omit stream details Streams = new List() }; EpisodeMetadata metadata = ProjectToEpisodeMetadata(version, response, mediaSourceId); var episode = new PlexEpisode { Key = response.Key, Etag = _plexEtag.ForEpisode(response), EpisodeMetadata = new List { metadata }, MediaVersions = new List { version }, TraktListItems = new List() }; return episode; } private EpisodeMetadata ProjectToEpisodeMetadata( MediaVersion version, PlexMetadataResponse response, int mediaSourceId) { DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var metadata = new EpisodeMetadata { MetadataKind = MetadataKind.External, Title = response.Title, SortTitle = SortTitle.GetSortTitle(response.Title), EpisodeNumber = response.Index, Plot = response.Summary, Year = response.Year, Tagline = response.Tagline, DateAdded = dateAdded, DateUpdated = lastWriteTime, Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime)) .ToList(), Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(), Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList(), Tags = new List(), Subtitles = new List() }; var subtitleStreams = version.Streams .Filter(s => s.MediaStreamKind is MediaStreamKind.Subtitle or MediaStreamKind.ExternalSubtitle) .ToList(); metadata.Subtitles.AddRange(subtitleStreams.Map(Subtitle.FromMediaStream)); if (response is PlexXmlMetadataResponse xml) { metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList(); if (!string.IsNullOrWhiteSpace(xml.PlexGuid)) { Option normalized = NormalizeGuid(xml.PlexGuid); foreach (string guid in normalized) { if (metadata.Guids.All(g => g.Guid != guid)) { metadata.Guids.Add(new MetadataGuid { Guid = guid }); } } } } else { metadata.Guids = new List(); } if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(response.Thumb)) { var path = $"plex/{mediaSourceId}{response.Thumb}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.Thumbnail, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } return metadata; } private static Actor ProjectToModel(PlexRoleResponse role, DateTime dateAdded, DateTime lastWriteTime) { var actor = new Actor { Name = role.Tag, Role = role.Role }; if (!string.IsNullOrWhiteSpace(role.Thumb)) { actor.Artwork = new Artwork { Path = role.Thumb, ArtworkKind = ArtworkKind.Thumbnail, DateAdded = dateAdded, DateUpdated = lastWriteTime }; } return actor; } private static MediaChapter ProjectToModel(PlexChapterResponse chapter) => new() { ChapterId = chapter.Index, StartTime = TimeSpan.FromMilliseconds(chapter.StartTimeOffset), EndTime = TimeSpan.FromMilliseconds(chapter.EndTimeOffset) }; private PlexOtherVideo ProjectToOtherVideo(PlexMetadataResponse response, int mediaSourceId, PlexLibrary library) { PlexMediaResponse media = response.Media .Filter(media => media.Part.Count != 0) .MaxBy(media => media.Id); PlexPartResponse part = media.Part.Head(); DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var version = new MediaVersion { Name = "Main", Duration = TimeSpan.FromMilliseconds(media.Duration), Width = media.Width, Height = media.Height, // specifically omit sample aspect ratio DateAdded = dateAdded, DateUpdated = lastWriteTime, MediaFiles = new List { new PlexMediaFile { PlexId = part.Id, Key = part.Key, Path = part.File } }, Streams = new List() }; OtherVideoMetadata metadata = ProjectToOtherVideoMetadata(version, response, mediaSourceId, library); var otherVideo = new PlexOtherVideo { Etag = _plexEtag.ForMovie(response), Key = response.Key, OtherVideoMetadata = new List { metadata }, MediaVersions = new List { version }, TraktListItems = new List() }; return otherVideo; } private OtherVideoMetadata ProjectToOtherVideoMetadata(MediaVersion version, PlexMetadataResponse response, int mediaSourceId, PlexLibrary library) { DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime; DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime; var metadata = new OtherVideoMetadata { MetadataKind = MetadataKind.External, Title = response.Title, SortTitle = SortTitle.GetSortTitle(response.Title), Plot = response.Summary, Year = response.Year, Tagline = response.Tagline, ContentRating = response.ContentRating, DateAdded = dateAdded, DateUpdated = lastWriteTime, Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(), Tags = new List(), Studios = new List(), Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime)) .ToList(), Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(), Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList(), Subtitles = new List() }; var subtitleStreams = version.Streams .Filter(s => s.MediaStreamKind is MediaStreamKind.Subtitle or MediaStreamKind.ExternalSubtitle) .ToList(); metadata.Subtitles.AddRange(subtitleStreams.Map(Subtitle.FromMediaStream)); if (response is PlexXmlMetadataResponse xml) { metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList(); if (!string.IsNullOrWhiteSpace(xml.PlexGuid)) { Option normalized = NormalizeGuid(xml.PlexGuid); foreach (string guid in normalized) { if (metadata.Guids.All(g => g.Guid != guid)) { metadata.Guids.Add(new MetadataGuid { Guid = guid }); } } } PlexMediaResponse media = xml.Media .Filter(media => media.Part.Count != 0) .MaxBy(media => media.Id); PlexXmlPartResponse part = media.Part.Head(); string folder = Path.GetDirectoryName(part.File); if (!string.IsNullOrWhiteSpace(folder)) { IEnumerable libraryPaths = library.Paths .HeadOrNone() .Map(p => p.Path) .Map(JsonConvert.DeserializeObject) .Map(lp => lp.Paths) .Flatten(); // check each library path from plex foreach (string libraryPath in libraryPaths) { // if the media file belongs to this library path if (folder.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase)) { // try to get a parent directory of the library path string parent = Optional(Directory.GetParent(libraryPath)).Match( di => di.FullName, () => libraryPath); // get all folders between parent and media file string diff = Path.GetRelativePath(parent, folder); // each folder becomes a tag IEnumerable tags = diff.Split(Path.DirectorySeparatorChar) .Map(t => new Tag { Name = t }); metadata.Tags.AddRange(tags); break; } } } } else { metadata.Guids = new List(); } foreach (PlexLabelResponse label in Optional(response.Label).Flatten()) { metadata.Tags.Add( new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture) }); } if (!string.IsNullOrWhiteSpace(response.Studio)) { metadata.Studios.Add(new Studio { Name = response.Studio }); } if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) { metadata.ReleaseDate = releaseDate; } if (!string.IsNullOrWhiteSpace(response.Thumb)) { var path = $"plex/{mediaSourceId}{response.Thumb}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.Poster, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } if (!string.IsNullOrWhiteSpace(response.Art)) { var path = $"plex/{mediaSourceId}{response.Art}"; var artwork = new Artwork { ArtworkKind = ArtworkKind.FanArt, Path = path, DateAdded = dateAdded, DateUpdated = lastWriteTime }; metadata.Artwork ??= new List(); metadata.Artwork.Add(artwork); } return metadata; } private Option NormalizeGuid(string guid) { if (guid.StartsWith("plex://show", StringComparison.OrdinalIgnoreCase) || guid.StartsWith("plex://season", StringComparison.OrdinalIgnoreCase) || guid.StartsWith("plex://episode", StringComparison.OrdinalIgnoreCase) || guid.StartsWith("plex://movie", StringComparison.OrdinalIgnoreCase)) { return guid; } if (guid.StartsWith("com.plexapp.agents.imdb", StringComparison.OrdinalIgnoreCase)) { string strip1 = guid.Replace("com.plexapp.agents.imdb://", string.Empty); string strip2 = strip1.Split("?").Head(); return $"imdb://{strip2}"; } if (guid.StartsWith("com.plexapp.agents.thetvdb", StringComparison.OrdinalIgnoreCase)) { string strip1 = guid.Replace("com.plexapp.agents.thetvdb://", string.Empty); string strip2 = strip1.Split("?").Head(); return $"tvdb://{strip2}"; } if (guid.StartsWith("com.plexapp.agents.themoviedb", StringComparison.OrdinalIgnoreCase)) { string strip1 = guid.Replace("com.plexapp.agents.themoviedb://", string.Empty); string strip2 = strip1.Split("?").Head(); return $"tmdb://{strip2}"; } if (guid.StartsWith("local://", StringComparison.OrdinalIgnoreCase) || guid.StartsWith("com.plexapp.agents.none://", StringComparison.OrdinalIgnoreCase)) { // _logger.LogDebug("Ignoring local Plex guid: {Guid}", guid); } else { _logger.LogWarning("Unsupported guid format from Plex; ignoring: {Guid}", guid); } return None; } private sealed class LibraryPaths { [JsonProperty("paths")] public List Paths { get; set; } = []; } }