diff --git a/MetaNodes/Globals.cs b/MetaNodes/Globals.cs index c685312a..5ec4cb70 100644 --- a/MetaNodes/Globals.cs +++ b/MetaNodes/Globals.cs @@ -3,6 +3,9 @@ internal class Globals { public static string MOVIE_INFO = "MovieInfo"; + public static string MOVIE = "Movie"; + public static string MOVIE_CREDITS = "MovieCredits"; public static string TV_SHOW_INFO = "TVShowInfo"; + public static string TV_EPISODE_INFO = "TVEpisodeInfo"; } } diff --git a/MetaNodes/MetaNodes.en.json b/MetaNodes/MetaNodes.en.json index 7c3d6131..ebd977b0 100644 --- a/MetaNodes/MetaNodes.en.json +++ b/MetaNodes/MetaNodes.en.json @@ -26,6 +26,19 @@ "MusicMeta": { "Description": "Loads the metadata of a music file into the flow variables." }, + "NfoFileCreator": { + "Description": "Creates a Kodi NFO file from previously loaded metadata.", + "Fields": { + "DestinationPath": "Destination Folder", + "DestinationPath-Help": "The folder where the NFO file will be created in.\nIf empty will be created in the same directory as the original file.", + "DestinationFile": "Destination File", + "DestinationFile-Help": "The filename of the new NFO file. If empty, the original filename will be used with the extension changed to `.nfo`" + }, + "Outputs": { + "1": "NFO File created", + "2": "NFO failed to be created" + } + }, "TVShowLookup": { "Description": "Performs a search on TheMovieDB.org for a TV Show.\nStores the Metadata inside the variable 'TVShowInfo'.", "Outputs": { diff --git a/MetaNodes/Tests/TheMovieDb/MovieLookupTests.cs b/MetaNodes/Tests/TheMovieDb/MovieLookupTests.cs index 8d04ae16..78180bb2 100644 --- a/MetaNodes/Tests/TheMovieDb/MovieLookupTests.cs +++ b/MetaNodes/Tests/TheMovieDb/MovieLookupTests.cs @@ -2,6 +2,7 @@ using DM.MovieApi; using DM.MovieApi.MovieDb.Movies; +using FileFlows.Plugin; using MetaNodes.TheMovieDb; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -181,7 +182,9 @@ public class MovieLookupTests { MovieDbFactory.RegisterSettings(MovieLookup.MovieDbBearerToken); var movieApi = MovieDbFactory.Create().Value; - var md = MovieLookup.GetVideoMetadata(movieApi, 414906, @"D:\videos\temp"); + var args = new FileFlows.Plugin.NodeParameters(@"/test/Ghostbusters 1984.mkv", new TestLogger(), false, string.Empty, null);; + + var md = MovieLookup.GetVideoMetadata(args, movieApi, 414906, @"D:\videos\temp"); Assert.IsNotNull(md); string json = System.Text.Json.JsonSerializer.Serialize(md, new System.Text.Json.JsonSerializerOptions { @@ -189,6 +192,31 @@ public class MovieLookupTests }); File.WriteAllText(@"D:\videos\metadata.json", json); } + [TestMethod] + public void MovieLookupTests_WonderWoman_Nfo() + { + var logger = new TestLogger(); + var args = new NodeParameters(@"/test/Wonder.Woman.1984.2020.German.DL.AC3.1080p.BluRay.x265-Fun{{fdg$ERGESDG32fesdfgds}}/Wonder.Woman.1984.2020.German.DL.AC3.1080p.BluRay.x265-Fun{{fdg$ERGESDG32fesdfgds}}.mkv", + logger, false, string.Empty, null);; + + MovieLookup ml = new MovieLookup(); + ml.UseFolderName = false; + + var result = ml.Execute(args); + Assert.AreEqual(1, result); + Assert.IsTrue(args.Parameters.ContainsKey(Globals.MOVIE_INFO)); + + var mi = args.Parameters[Globals.MOVIE_INFO] as MovieInfo; + Assert.IsNotNull(mi); + Assert.AreEqual("Wonder Woman 1984", mi.Title); + Assert.AreEqual(2020, mi.ReleaseDate.Year); + + var eleNfo = new NfoFileCreator(); + result = eleNfo.Execute(args); + Assert.AreEqual(1, result); + string nfo = (string)args.Variables["NFO"]; + Assert.IsFalse(string.IsNullOrWhiteSpace(nfo)); + } } #endif \ No newline at end of file diff --git a/MetaNodes/Tests/TheMovieDb/TVEpisodeLookupTests.cs b/MetaNodes/Tests/TheMovieDb/TVEpisodeLookupTests.cs index 5a0e363f..969d8747 100644 --- a/MetaNodes/Tests/TheMovieDb/TVEpisodeLookupTests.cs +++ b/MetaNodes/Tests/TheMovieDb/TVEpisodeLookupTests.cs @@ -1,5 +1,6 @@ #if(DEBUG) +using System.Diagnostics.CodeAnalysis; using DM.MovieApi.MovieDb.Movies; using DM.MovieApi.MovieDb.TV; using MetaNodes.TheMovieDb; @@ -102,6 +103,47 @@ public class TVEpisodeLookupTests Assert.AreEqual("The Batman/Superman Story (1)", args.Variables["tvepisode.Subtitle"]); Assert.IsFalse(string.IsNullOrWhiteSpace(args.Variables["tvepisode.Overview"] as string)); } + + + + [TestMethod] + public void TheBatman_2x03_nfo() + { + var logger = new TestLogger(); + var args = new FileFlows.Plugin.NodeParameters("/test/tv/The Batman/Season 2/The Batman - 2x03.mkv", logger, false, string.Empty, null); + + var element = new TVEpisodeLookup(); + + var result = element.Execute(args); + Assert.AreEqual(1, result); + + var eleNfo = new NfoFileCreator(); + result = eleNfo.Execute(args); + Assert.AreEqual(1, result); + + TVShowInfo tvShowInfo = (TVShowInfo)args.Variables[Globals.TV_SHOW_INFO]; + Episode epInfo = (Episode)args.Variables[Globals.TV_EPISODE_INFO]; + string nfo = eleNfo.CreateTvShowNfo(args, tvShowInfo, epInfo); + Assert.IsNotNull(nfo); + } + + [TestMethod] + public void TheBatman_s5e1_2_3_Nfo() + { + var logger = new TestLogger(); + var args = new FileFlows.Plugin.NodeParameters("/test/tv/The Batman/Season 5/The Batman - s5e1-3.mkv", logger, false, string.Empty, null); + + var element = new TVEpisodeLookup(); + + var result = element.Execute(args); + Assert.AreEqual(1, result); + + var eleNfo = new NfoFileCreator(); + result = eleNfo.Execute(args); + Assert.AreEqual(1, result); + string nfo = (string)args.Variables["NFO"]; + } + } diff --git a/MetaNodes/TheMovieDb/MovieLookup.cs b/MetaNodes/TheMovieDb/MovieLookup.cs index 38697df7..983c1bfd 100644 --- a/MetaNodes/TheMovieDb/MovieLookup.cs +++ b/MetaNodes/TheMovieDb/MovieLookup.cs @@ -81,7 +81,7 @@ public class MovieLookup : Node } // remove double spaces in case they were added when removing the year - while (lookupName.IndexOf(" ") > 0) + while (lookupName.IndexOf(" ", StringComparison.Ordinal) > 0) lookupName = lookupName.Replace(" ", " "); args.Logger?.ILog("Lookup name: " + lookupName); @@ -145,14 +145,18 @@ public class MovieLookup : Node args.SetParameter(Globals.MOVIE_INFO, result); + Variables["movie.Title"] = result.Title; Variables["movie.Year"] = result.ReleaseDate.Year; - var meta = GetVideoMetadata(movieApi, result.Id, args.TempPath); + var meta = GetVideoMetadata(args, movieApi, result.Id, args.TempPath); Variables["VideoMetadata"] = meta; if (string.IsNullOrWhiteSpace(meta.OriginalLanguage) == false) Variables["OriginalLanguage"] = meta.OriginalLanguage; Variables[Globals.MOVIE_INFO] = result; + var movie = movieApi.FindByIdAsync(result.Id).Result.Item; + if(movie != null) + Variables[Globals.MOVIE] = movie; args.UpdateVariables(Variables); return 1; @@ -167,7 +171,7 @@ public class MovieLookup : Node /// the ID of the movie /// the temp path to save any images to /// the VideoMetadata - internal static VideoMetadata GetVideoMetadata(IApiMovieRequest movieApi, int id, string tempPath) + internal static VideoMetadata GetVideoMetadata(NodeParameters args, IApiMovieRequest movieApi, int id, string tempPath) { var movie = movieApi.FindByIdAsync(id).Result?.Item; if (movie == null) @@ -202,8 +206,9 @@ public class MovieLookup : Node if(credits != null) { + args.Variables[Globals.MOVIE_CREDITS] = credits; md.Actors = credits.CastMembers?.Select(x => x.Name)?.ToList(); - md.Writers = credits.CrewMembers?.Where(x => x.Job == "Writer" || x.Job == "Screenplay") ?.Select(x => x.Name)?.ToList(); + md.Writers = credits.CrewMembers?.Where(x => x.Department == "Writing" || x.Job == "Writer" || x.Job == "Screenplay") ?.Select(x => x.Name)?.ToList(); md.Directors = credits.CrewMembers?.Where(x => x.Job == "Director")?.Select(x => x.Name)?.ToList(); md.Producers = credits.CrewMembers?.Where(x => x.Job == "Producer")?.Select(x => x.Name)?.ToList(); } diff --git a/MetaNodes/TheMovieDb/NfoFileCreator.cs b/MetaNodes/TheMovieDb/NfoFileCreator.cs new file mode 100644 index 00000000..6ac3774e --- /dev/null +++ b/MetaNodes/TheMovieDb/NfoFileCreator.cs @@ -0,0 +1,335 @@ +using System.Text; +using System.Xml; +using DM.MovieApi.MovieDb.Movies; +using DM.MovieApi.MovieDb.TV; +using FileFlows.Plugin; +using FileFlows.Plugin.Attributes; +using FileFlows.Plugin.Helpers; + +namespace MetaNodes.TheMovieDb; + +/// +/// A flow element that will create a NFO file for a video once the Movie or TV Show info has been read +/// +public class NfoFileCreator : Node +{ + /// + /// Gets the number of inputs + /// + public override int Inputs => 1; + /// + /// Gets the number of outputs + /// + public override int Outputs => 2; + /// + /// Gets the flow element type + /// + public override FlowElementType Type => FlowElementType.Process; + /// + /// Gets the help URL + /// + public override string HelpUrl => "https://fileflows.com/docs/plugins/meta-nodes/nfo-file-creator"; + /// + /// Gets the icon + /// + public override string Icon => "fas fa-file-code"; + + private const string TAB = " "; + + + private string _DestinationPath = string.Empty; + private string _DestinationFile = string.Empty; + /// + /// Gets or sets the destination path for zipping. + /// + [Folder(1)] + public string DestinationPath + { + get => _DestinationPath; + set { _DestinationPath = value ?? ""; } + } + + /// + /// Gets or sets the destination file name for zipping. + /// + [TextVariable(2)] + public string DestinationFile + { + get => _DestinationFile; + set { _DestinationFile = value ?? ""; } + } + + /// + /// Executes the flow element + /// + /// + /// + public override int Execute(NodeParameters args) + { + string nfoXml = null; + if (args.Variables.TryGetValue(Globals.TV_SHOW_INFO, out object oTvShowInfo) && + oTvShowInfo is TVShowInfo tvShowInfo) + { + if (args.Variables.TryGetValue(Globals.TV_EPISODE_INFO, out object oTvEpisodeInfo) && + oTvEpisodeInfo is Episode epInfo) + { + args.Logger?.ILog("TVEpisodeInfo found"); + nfoXml = CreateTvShowNfo(args, tvShowInfo, epInfo); + + + // look for more episodes + for (int i = 1; 1 <= 100; i++) + { + if (args.Variables.TryGetValue(Globals.TV_EPISODE_INFO + "_" + i, out object oOther) == false) + break; + if (oOther is Episode epOther == false) + break; + + nfoXml += CreateTvShowNfo(args, tvShowInfo, epOther); + } + + } + } + else if (args.Variables.TryGetValue(Globals.MOVIE, out object oMovieInfo) && + oMovieInfo is Movie movie) + { + args.Logger?.ILog("MovieInformation found"); + nfoXml = CreateMovieNfo(args, movie); + } + + + if (string.IsNullOrWhiteSpace(nfoXml)) + { + args.Logger?.ILog("No TV or Movie information found in flow"); + return 2; + } + + // remove any white space + nfoXml = "" + Environment.NewLine + nfoXml.Trim(); + args.Variables["NFO"] = nfoXml; + + string path = args.ReplaceVariables(DestinationPath, stripMissing: true)?.EmptyAsNull() ?? FileHelper.GetDirectory(args.LibraryFileName ?? string.Empty); + string filename = args.ReplaceVariables(DestinationFile, stripMissing: true)?.EmptyAsNull() ?? + FileHelper.ChangeExtension(FileHelper.GetShortFileName(args.LibraryFileName ?? string.Empty), "nfo"); + + if ((path + filename) == ".nfo") + { + // this is extremely likely to be a unit test, the library file would be set otherwise + args.Logger?.ILog("No file set to output to."); + return 1; + } + + string output = FileHelper.Combine(path, filename); + + string tempFile = FileHelper.Combine(args.TempPath, Guid.NewGuid() + ".nfo"); + File.WriteAllText(tempFile, nfoXml); + if (args.FileService.FileMove(tempFile, output).Failed(out string error)) + { + args.FailureReason = error; + return -1; + } + args.Logger.ILog("NFO File Created at: " + output); + return 1; + } + + private string? CreateMovieNfo(NodeParameters args, Movie movie) + { + StringBuilder output = new(); + + output.AppendLine(""); + + WriteXmlEscapedElement(output, "title", movie.Title); + WriteXmlEscapedElement(output, "originaltitle", movie.OriginalTitle); + WriteXmlEscapedElement(output, "plot", movie.Overview); + WriteXmlEscapedElement(output, "tagline", movie.Tagline); + if(movie.Runtime > 0) + WriteXmlEscapedElement(output, "runtime", movie.Runtime.ToString()); + WriteXmlEscapedElement(output, "premiered", movie.ReleaseDate.ToString("yyyy-MM-dd")); + output.AppendLine($"{TAB}{movie.Id}"); + if(string.IsNullOrWhiteSpace(movie.ImdbId) == false) + output.AppendLine($"{TAB}{movie.ImdbId}"); + + // Genres + if (movie.Genres?.Any() == true) + { + string genres = string.Join(",", movie.Genres.Select(g => g.Name)); + WriteXmlEscapedElement(output, "genres", genres); + } + + if(movie.ProductionCompanies?.Any() == true) + WriteXmlEscapedElement(output, "studio", movie.ProductionCompanies.First().Name); + + WriteThumb(output, "poster", movie.PosterPath); + WriteThumb(output, "landscape", movie.BackdropPath); + + object oCredits = null; + if (args.Variables.TryGetValue(Globals.MOVIE_CREDITS, out oCredits) && oCredits is MovieCredit credit) + { + var writers = credit.CrewMembers + .Where(x => x.Department == "Writing" || x.Job == "Writer" || x.Job == "Screenplay") + .Select(crewMember => crewMember.Name).Distinct(); + + var directors = credit.CrewMembers.Where(crewMember => crewMember.Job == "Director") + .Select(crewMember => crewMember.Name).Distinct(); + + foreach (var epWriter in writers) + { + WriteXmlEscapedElement(output, "credits", epWriter); + } + + foreach (var director in directors) + { + WriteXmlEscapedElement(output, "director", director); + } + } + + if (movie.VoteCount > 0) + { + output.AppendLine(TAB + ""); + output.AppendLine(TAB + TAB + ""); + WriteXmlEscapedElement(output, "value", movie.VoteAverage.ToString(), tabs: 3); + WriteXmlEscapedElement(output, "votes", movie.VoteCount.ToString(), tabs: 3); + output.AppendLine(TAB + TAB + ""); + output.AppendLine(TAB + ""); + } + + if (string.IsNullOrWhiteSpace(movie.MovieCollectionInfo?.Name) == false) + { + output.AppendLine(TAB + ""); + WriteXmlEscapedElement(output, "name", movie.MovieCollectionInfo.Name, tabs: 2); + output.AppendLine(TAB + ""); + } + + + if (oCredits is MovieCredit credit2) + { + var cast = credit2.CastMembers?.ToList(); + if(cast?.Any() == true) + { + for (int i = 0; i < cast.Count; i++) + { + var castMember = cast[i]; + WriteActorElement(output, castMember.Name, castMember.Character, i, castMember.ProfilePath); + } + } + + } + output.AppendLine(""); + return output.ToString(); + } + + internal string CreateTvShowNfo(NodeParameters args, TVShowInfo tvShowInfo, Episode episode) + { + StringBuilder output = new(); + + output.AppendLine(""); + + WriteXmlEscapedElement(output, "title", episode.Name); + WriteXmlEscapedElement(output, "originaltitle", tvShowInfo.OriginalName); + WriteXmlEscapedElement(output, "showtitle", tvShowInfo.Name); + WriteXmlEscapedElement(output, "season", episode.SeasonNumber.ToString()); + WriteXmlEscapedElement(output, "episode", episode.EpisodeNumber.ToString()); + WriteXmlEscapedElement(output, "plot", episode.Overview); + WriteXmlEscapedElement(output, "premiered", episode.AirDate.ToString("yyyy-MM-dd")); + + output.AppendLine($"{TAB}{episode.Id}"); + + // Additional episode information from TVShowInfo + //WriteXmlEscapedElement(writer, "tvshow_id", tvShowInfo.Id.ToString()); + //WriteXmlEscapedElement(writer, "tvshow_name", tvShowInfo.Name); + //WriteXmlEscapedElement(writer, "original_name", tvShowInfo.OriginalName); + // WriteXmlEscapedElement(writer, "poster_path", tvShowInfo.PosterPath); + // WriteXmlEscapedElement(writer, "backdrop_path", tvShowInfo.BackdropPath); + + WriteXmlEscapedElement(output, "overview", tvShowInfo.Overview); + WriteXmlEscapedElement(output, "first_air_date", tvShowInfo.FirstAirDate.ToString("yyyy-MM-dd")); + WriteXmlEscapedElement(output, "origin_country", string.Join(",", tvShowInfo.OriginCountry)); + WriteXmlEscapedElement(output, "original_language", tvShowInfo.OriginalLanguage); + + // Genres + if (tvShowInfo.Genres != null && tvShowInfo.Genres.Any()) + { + string genres = string.Join(",", tvShowInfo.Genres.Select(g => g.Name)); + WriteXmlEscapedElement(output, "genres", genres); + } + + // Additional episode information + WriteXmlEscapedElement(output, "episode_id", episode.Id.ToString()); + WriteXmlEscapedElement(output, "still_path", episode.StillPath); + + if (episode.VoteCount > 0) + { + output.AppendLine(TAB + ""); + output.AppendLine(TAB + TAB + ""); + WriteXmlEscapedElement(output, "value", episode.VoteAverage.ToString(), tabs: 3); + WriteXmlEscapedElement(output, "votes", episode.VoteCount.ToString(), tabs: 3); + output.AppendLine(TAB + TAB + ""); + output.AppendLine(TAB + ""); + } + + if (episode.Crew?.Any() == true) + { + var writers = episode.Crew.Where(crewMember => crewMember.Department == "Writing").Select(crewMember => crewMember.Name).Distinct(); + var directors = episode.Crew.Where(crewMember => crewMember.Job == "Director").Select(crewMember => crewMember.Name).Distinct(); + + foreach (var epWriter in writers) + { + WriteXmlEscapedElement(output, "credits", epWriter); + } + + foreach (var director in directors) + { + WriteXmlEscapedElement(output, "director", director); + } + } + + if (episode.GuestStars?.Any() == true) + { + for (int i = 0; i < episode.GuestStars.Count; i++) + { + var guestStar = episode.GuestStars[i]; + WriteActorElement(output, guestStar.Name, guestStar.Character, i, guestStar.ProfilePath); + } + } + + output.AppendLine(""); + + return output.ToString(); + } + + + private void WriteXmlEscapedElement(StringBuilder output, string elementName, string elementValue, int tabs = 1) + { + if (string.IsNullOrEmpty(elementValue)) + return; // Skip writing if the value is null or empty + output.Append($"{string.Join("", Enumerable.Range(1, tabs).Select(x => TAB))}<{elementName}>"); + output.Append(XmlEscape(elementValue)); + output.AppendLine($""); + } + + private string XmlEscape(string unescaped) + { + XmlDocument doc = new XmlDocument(); + var node = doc.CreateElement("root"); + node.InnerText = unescaped; + return node.InnerXml; + } + private void WriteActorElement(StringBuilder output, string name, string role, int order, string thumb) + { + output.AppendLine(TAB +""); + WriteXmlEscapedElement(output, "name", name, tabs: 2); + WriteXmlEscapedElement(output, "role", role, tabs: 2); + WriteXmlEscapedElement(output, "order", order.ToString(), tabs: 2); + if(string.IsNullOrWhiteSpace(thumb) == false) + WriteXmlEscapedElement(output, "thumb", "https://image.tmdb.org/t/p/original" + thumb, tabs: 2); + output.AppendLine(TAB + ""); + } + + private void WriteThumb(StringBuilder output, string aspect, string url) + { + if (string.IsNullOrWhiteSpace(url)) + return; + string preview = "https://image.tmdb.org/t/p/original" + url; + output.AppendLine(TAB +$""); + } +} \ No newline at end of file diff --git a/MetaNodes/TheMovieDb/TVEpisodeLookup.cs b/MetaNodes/TheMovieDb/TVEpisodeLookup.cs index 8a7fffa5..e46b8dd4 100644 --- a/MetaNodes/TheMovieDb/TVEpisodeLookup.cs +++ b/MetaNodes/TheMovieDb/TVEpisodeLookup.cs @@ -145,6 +145,19 @@ public class TVEpisodeLookup : Node args.SetParameter(Globals.TV_SHOW_INFO, result); + if (lastEpisode > episode) + { + for (int i = episode.Value + 1; i <= lastEpisode; i++) + { + + var epInfoExtra = show.Item.Episodes.FirstOrDefault(x => x.EpisodeNumber == i); + if (epInfoExtra == null) + continue; + int diff = i - episode.Value; + args.Variables[Globals.TV_EPISODE_INFO + "_" + diff] = epInfoExtra; + } + } + Variables["tvepisode.Title"] = result.Name; Variables["tvepisode.Subtitle"] = epInfo.Name; Variables["tvepisode.Year"] = epInfo.AirDate.Year; @@ -156,6 +169,7 @@ public class TVEpisodeLookup : Node Variables["tvepisode.Overview"] = epInfo.Overview; //Variables["VideoMetadata"] = GetVideoMetadata(movieApi, result.Id, args.TempPath); Variables[Globals.TV_SHOW_INFO] = result; + Variables[Globals.TV_EPISODE_INFO] = epInfo; if (string.IsNullOrWhiteSpace(result.OriginalLanguage) == false) Variables["OriginalLanguage"] = result.OriginalLanguage;