From 88d56361c4bce745277f6a3bde33f12fc9b85de1 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 23 Mar 2025 19:55:36 -0700 Subject: [PATCH] Add XML declaration and clean up Kodi metadata generation Closes #7753 --- src/NzbDrone.Common/Utf8StringWriter.cs | 9 + .../Metadata/Consumers/Xbmc/XbmcMetadata.cs | 415 +++++++++--------- 2 files changed, 223 insertions(+), 201 deletions(-) create mode 100644 src/NzbDrone.Common/Utf8StringWriter.cs diff --git a/src/NzbDrone.Common/Utf8StringWriter.cs b/src/NzbDrone.Common/Utf8StringWriter.cs new file mode 100644 index 000000000..f98bb8f2e --- /dev/null +++ b/src/NzbDrone.Common/Utf8StringWriter.cs @@ -0,0 +1,9 @@ +using System.IO; +using System.Text; + +namespace NzbDrone.Common; + +public class Utf8StringWriter : StringWriter +{ + public override Encoding Encoding => Encoding.UTF8; +} diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs index e66cc89b5..fe95859fd 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs @@ -8,6 +8,7 @@ using System.Xml; using System.Xml.Linq; using NLog; +using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; @@ -149,110 +150,116 @@ public override MetadataFileResult SeriesMetadata(Series series, SeriesMetadataR if (Settings.SeriesMetadata) { _logger.Debug("Generating Series Metadata for: {0}", series.Title); - var sb = new StringBuilder(); - var xws = new XmlWriterSettings(); - xws.OmitXmlDeclaration = true; - xws.Indent = false; - using (var xw = XmlWriter.Create(sb, xws)) + var tvShow = new XElement("tvshow"); + + tvShow.Add(new XElement("title", series.Title)); + + if (series.Ratings != null && series.Ratings.Votes > 0) { - var tvShow = new XElement("tvshow"); - - tvShow.Add(new XElement("title", series.Title)); - - if (series.Ratings != null && series.Ratings.Votes > 0) - { - tvShow.Add(new XElement("rating", series.Ratings.Value)); - } - - tvShow.Add(new XElement("plot", series.Overview)); - tvShow.Add(new XElement("mpaa", series.Certification)); - tvShow.Add(new XElement("id", series.TvdbId)); - - var uniqueId = new XElement("uniqueid", series.TvdbId); - uniqueId.SetAttributeValue("type", "tvdb"); - uniqueId.SetAttributeValue("default", true); - tvShow.Add(uniqueId); - - if (series.ImdbId.IsNotNullOrWhiteSpace()) - { - var imdbId = new XElement("uniqueid", series.ImdbId); - imdbId.SetAttributeValue("type", "imdb"); - tvShow.Add(imdbId); - } - - if (series.TmdbId > 0) - { - var tmdbId = new XElement("uniqueid", series.TmdbId); - tmdbId.SetAttributeValue("type", "tmdb"); - tvShow.Add(tmdbId); - } - - if (series.TvMazeId > 0) - { - var tvMazeId = new XElement("uniqueid", series.TvMazeId); - tvMazeId.SetAttributeValue("type", "tvmaze"); - tvShow.Add(tvMazeId); - } - - foreach (var genre in series.Genres) - { - tvShow.Add(new XElement("genre", genre)); - } - - if (series.Tags.Any()) - { - var tags = _tagRepo.GetTags(series.Tags); - - foreach (var tag in tags) - { - tvShow.Add(new XElement("tag", tag.Label)); - } - } - - tvShow.Add(new XElement("status", series.Status)); - - if (series.FirstAired.HasValue) - { - tvShow.Add(new XElement("premiered", series.FirstAired.Value.ToString("yyyy-MM-dd"))); - } - - // Add support for Jellyfin's "enddate" tag - if (series.Status == SeriesStatusType.Ended && series.LastAired.HasValue) - { - tvShow.Add(new XElement("enddate", series.LastAired.Value.ToString("yyyy-MM-dd"))); - } - - tvShow.Add(new XElement("studio", series.Network)); - - foreach (var actor in series.Actors) - { - var xmlActor = new XElement("actor", - new XElement("name", actor.Name), - new XElement("role", actor.Character)); - - if (actor.Images.Any()) - { - xmlActor.Add(new XElement("thumb", actor.Images.First().RemoteUrl)); - } - - tvShow.Add(xmlActor); - } - - if (Settings.SeriesMetadataEpisodeGuide) - { - var episodeGuide = new KodiEpisodeGuide(series); - var serializerSettings = STJson.GetSerializerSettings(); - serializerSettings.WriteIndented = false; - - tvShow.Add(new XElement("episodeguide", JsonSerializer.Serialize(episodeGuide, serializerSettings))); - } - - var doc = new XDocument(tvShow); - doc.Save(xw); - - xmlResult += doc.ToString(); + tvShow.Add(new XElement("rating", series.Ratings.Value)); } + + tvShow.Add(new XElement("plot", series.Overview)); + tvShow.Add(new XElement("mpaa", series.Certification)); + tvShow.Add(new XElement("id", series.TvdbId)); + + var uniqueId = new XElement("uniqueid", series.TvdbId); + uniqueId.SetAttributeValue("type", "tvdb"); + uniqueId.SetAttributeValue("default", true); + tvShow.Add(uniqueId); + + if (series.ImdbId.IsNotNullOrWhiteSpace()) + { + var imdbId = new XElement("uniqueid", series.ImdbId); + imdbId.SetAttributeValue("type", "imdb"); + tvShow.Add(imdbId); + } + + if (series.TmdbId > 0) + { + var tmdbId = new XElement("uniqueid", series.TmdbId); + tmdbId.SetAttributeValue("type", "tmdb"); + tvShow.Add(tmdbId); + } + + if (series.TvMazeId > 0) + { + var tvMazeId = new XElement("uniqueid", series.TvMazeId); + tvMazeId.SetAttributeValue("type", "tvmaze"); + tvShow.Add(tvMazeId); + } + + foreach (var genre in series.Genres) + { + tvShow.Add(new XElement("genre", genre)); + } + + if (series.Tags.Any()) + { + var tags = _tagRepo.GetTags(series.Tags); + + foreach (var tag in tags) + { + tvShow.Add(new XElement("tag", tag.Label)); + } + } + + tvShow.Add(new XElement("status", series.Status)); + + if (series.FirstAired.HasValue) + { + tvShow.Add(new XElement("premiered", series.FirstAired.Value.ToString("yyyy-MM-dd"))); + } + + // Add support for Jellyfin's "enddate" tag + if (series.Status == SeriesStatusType.Ended && series.LastAired.HasValue) + { + tvShow.Add(new XElement("enddate", series.LastAired.Value.ToString("yyyy-MM-dd"))); + } + + tvShow.Add(new XElement("studio", series.Network)); + + foreach (var actor in series.Actors) + { + var xmlActor = new XElement("actor", + new XElement("name", actor.Name), + new XElement("role", actor.Character)); + + if (actor.Images.Any()) + { + xmlActor.Add(new XElement("thumb", actor.Images.First().RemoteUrl)); + } + + tvShow.Add(xmlActor); + } + + if (Settings.SeriesMetadataEpisodeGuide) + { + var episodeGuide = new KodiEpisodeGuide(series); + var serializerSettings = STJson.GetSerializerSettings(); + serializerSettings.WriteIndented = false; + + tvShow.Add(new XElement("episodeguide", JsonSerializer.Serialize(episodeGuide, serializerSettings))); + } + + var doc = new XDocument(tvShow) + { + Declaration = new XDeclaration("1.0", "UTF-8", "yes"), + }; + + var sb = new StringBuilder(); + using var sw = new Utf8StringWriter(); + using var xw = XmlWriter.Create(sw, new XmlWriterSettings + { + Encoding = Encoding.UTF8, + Indent = true + }); + + doc.Save(xw); + xw.Flush(); + + xmlResult += sw.ToString(); } if (Settings.SeriesMetadataUrl) @@ -280,113 +287,119 @@ public override MetadataFileResult EpisodeMetadata(Series series, EpisodeFile ep var watched = GetExistingWatchedStatus(series, episodeFile.RelativePath); var xmlResult = string.Empty; + var xws = new XmlWriterSettings + { + Encoding = Encoding.UTF8, + Indent = true + }; + foreach (var episode in episodeFile.Episodes.Value) { - var sb = new StringBuilder(); - var xws = new XmlWriterSettings(); - xws.OmitXmlDeclaration = true; - xws.Indent = false; - - using (var xw = XmlWriter.Create(sb, xws)) + var doc = new XDocument { - var doc = new XDocument(); - var image = episode.Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); + Declaration = new XDeclaration("1.0", "UTF-8", "yes") + }; - var details = new XElement("episodedetails"); - details.Add(new XElement("title", episode.Title)); - details.Add(new XElement("season", episode.SeasonNumber)); - details.Add(new XElement("episode", episode.EpisodeNumber)); - details.Add(new XElement("aired", episode.AirDate)); - details.Add(new XElement("plot", episode.Overview)); + var image = episode.Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); - if (episode.SeasonNumber == 0 && episode.AiredAfterSeasonNumber.HasValue) - { - details.Add(new XElement("displayafterseason", episode.AiredAfterSeasonNumber)); - } - else if (episode.SeasonNumber == 0 && episode.AiredBeforeSeasonNumber.HasValue) - { - details.Add(new XElement("displayseason", episode.AiredBeforeSeasonNumber)); - details.Add(new XElement("displayepisode", episode.AiredBeforeEpisodeNumber ?? -1)); - } + var details = new XElement("episodedetails"); + details.Add(new XElement("title", episode.Title)); + details.Add(new XElement("season", episode.SeasonNumber)); + details.Add(new XElement("episode", episode.EpisodeNumber)); + details.Add(new XElement("aired", episode.AirDate)); + details.Add(new XElement("plot", episode.Overview)); - var tvdbId = new XElement("uniqueid", episode.TvdbId); - tvdbId.SetAttributeValue("type", "tvdb"); - tvdbId.SetAttributeValue("default", true); - details.Add(tvdbId); - - var sonarrId = new XElement("uniqueid", episode.Id); - sonarrId.SetAttributeValue("type", "sonarr"); - details.Add(sonarrId); - - if (image == null) - { - details.Add(new XElement("thumb")); - } - else if (Settings.EpisodeImageThumb) - { - details.Add(new XElement("thumb", image.RemoteUrl)); - } - - details.Add(new XElement("watched", watched)); - - if (episode.Ratings != null && episode.Ratings.Votes > 0) - { - details.Add(new XElement("rating", episode.Ratings.Value)); - } - - if (episodeFile.MediaInfo != null) - { - var sceneName = episodeFile.GetSceneOrFileName(); - - var fileInfo = new XElement("fileinfo"); - var streamDetails = new XElement("streamdetails"); - - var video = new XElement("video"); - video.Add(new XElement("aspect", (float)episodeFile.MediaInfo.Width / (float)episodeFile.MediaInfo.Height)); - video.Add(new XElement("bitrate", episodeFile.MediaInfo.VideoBitrate)); - video.Add(new XElement("codec", MediaInfoFormatter.FormatVideoCodec(episodeFile.MediaInfo, sceneName))); - video.Add(new XElement("framerate", episodeFile.MediaInfo.VideoFps)); - video.Add(new XElement("height", episodeFile.MediaInfo.Height)); - video.Add(new XElement("scantype", episodeFile.MediaInfo.ScanType)); - video.Add(new XElement("width", episodeFile.MediaInfo.Width)); - - video.Add(new XElement("duration", episodeFile.MediaInfo.RunTime.TotalMinutes)); - video.Add(new XElement("durationinseconds", Math.Round(episodeFile.MediaInfo.RunTime.TotalSeconds))); - - streamDetails.Add(video); - - var audio = new XElement("audio"); - var audioChannelCount = episodeFile.MediaInfo.AudioChannels; - audio.Add(new XElement("bitrate", episodeFile.MediaInfo.AudioBitrate)); - audio.Add(new XElement("channels", audioChannelCount)); - audio.Add(new XElement("codec", MediaInfoFormatter.FormatAudioCodec(episodeFile.MediaInfo, sceneName))); - audio.Add(new XElement("language", episodeFile.MediaInfo.AudioLanguages)); - streamDetails.Add(audio); - - if (episodeFile.MediaInfo.Subtitles != null && episodeFile.MediaInfo.Subtitles.Count > 0) - { - foreach (var s in episodeFile.MediaInfo.Subtitles) - { - var subtitle = new XElement("subtitle"); - subtitle.Add(new XElement("language", s)); - streamDetails.Add(subtitle); - } - } - - fileInfo.Add(streamDetails); - details.Add(fileInfo); - } - - // Todo: get guest stars, writer and director - // details.Add(new XElement("credits", tvdbEpisode.Writer.FirstOrDefault())); - // details.Add(new XElement("director", tvdbEpisode.Directors.FirstOrDefault())); - - doc.Add(details); - doc.Save(xw); - - xmlResult += doc.ToString(); - xmlResult += Environment.NewLine; + if (episode.SeasonNumber == 0 && episode.AiredAfterSeasonNumber.HasValue) + { + details.Add(new XElement("displayafterseason", episode.AiredAfterSeasonNumber)); } + else if (episode.SeasonNumber == 0 && episode.AiredBeforeSeasonNumber.HasValue) + { + details.Add(new XElement("displayseason", episode.AiredBeforeSeasonNumber)); + details.Add(new XElement("displayepisode", episode.AiredBeforeEpisodeNumber ?? -1)); + } + + var tvdbId = new XElement("uniqueid", episode.TvdbId); + tvdbId.SetAttributeValue("type", "tvdb"); + tvdbId.SetAttributeValue("default", true); + details.Add(tvdbId); + + var sonarrId = new XElement("uniqueid", episode.Id); + sonarrId.SetAttributeValue("type", "sonarr"); + details.Add(sonarrId); + + if (image == null) + { + details.Add(new XElement("thumb")); + } + else if (Settings.EpisodeImageThumb) + { + details.Add(new XElement("thumb", image.RemoteUrl)); + } + + details.Add(new XElement("watched", watched)); + + if (episode.Ratings != null && episode.Ratings.Votes > 0) + { + details.Add(new XElement("rating", episode.Ratings.Value)); + } + + if (episodeFile.MediaInfo != null) + { + var sceneName = episodeFile.GetSceneOrFileName(); + + var fileInfo = new XElement("fileinfo"); + var streamDetails = new XElement("streamdetails"); + + var video = new XElement("video"); + video.Add(new XElement("aspect", (float)episodeFile.MediaInfo.Width / (float)episodeFile.MediaInfo.Height)); + video.Add(new XElement("bitrate", episodeFile.MediaInfo.VideoBitrate)); + video.Add(new XElement("codec", MediaInfoFormatter.FormatVideoCodec(episodeFile.MediaInfo, sceneName))); + video.Add(new XElement("framerate", episodeFile.MediaInfo.VideoFps)); + video.Add(new XElement("height", episodeFile.MediaInfo.Height)); + video.Add(new XElement("scantype", episodeFile.MediaInfo.ScanType)); + video.Add(new XElement("width", episodeFile.MediaInfo.Width)); + + video.Add(new XElement("duration", episodeFile.MediaInfo.RunTime.TotalMinutes)); + video.Add(new XElement("durationinseconds", Math.Round(episodeFile.MediaInfo.RunTime.TotalSeconds))); + + streamDetails.Add(video); + + var audio = new XElement("audio"); + var audioChannelCount = episodeFile.MediaInfo.AudioChannels; + audio.Add(new XElement("bitrate", episodeFile.MediaInfo.AudioBitrate)); + audio.Add(new XElement("channels", audioChannelCount)); + audio.Add(new XElement("codec", MediaInfoFormatter.FormatAudioCodec(episodeFile.MediaInfo, sceneName))); + audio.Add(new XElement("language", episodeFile.MediaInfo.AudioLanguages)); + streamDetails.Add(audio); + + if (episodeFile.MediaInfo.Subtitles != null && episodeFile.MediaInfo.Subtitles.Count > 0) + { + foreach (var s in episodeFile.MediaInfo.Subtitles) + { + var subtitle = new XElement("subtitle"); + subtitle.Add(new XElement("language", s)); + streamDetails.Add(subtitle); + } + } + + fileInfo.Add(streamDetails); + details.Add(fileInfo); + } + + // Todo: get guest stars, writer and director + // details.Add(new XElement("credits", tvdbEpisode.Writer.FirstOrDefault())); + // details.Add(new XElement("director", tvdbEpisode.Directors.FirstOrDefault())); + + using var sw = new Utf8StringWriter(); + using var xw = XmlWriter.Create(sw, xws); + + doc.Add(details); + doc.Save(xw); + xw.Flush(); + + xmlResult += sw.ToString(); + xmlResult += Environment.NewLine; } return new MetadataFileResult(GetEpisodeMetadataFilename(episodeFile.RelativePath), xmlResult.Trim(Environment.NewLine.ToCharArray()));