diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b966d32c..4ea65946f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Fix adding episodes with no title to the search index - This behavior was preventing some items from being removed from the trash -- Support combination NFO metadata for movies +- Support combination NFO metadata for movies, shows, artists and music videos - Note that ErsatzTV does not scrape any metadata; any URLs after the XML will be ignored - Fix bug causing some Jellyfin and Emby content to incorrectly show as unavailable diff --git a/ErsatzTV.Core.Tests/Metadata/Nfo/ArtistNfoReaderTests.cs b/ErsatzTV.Core.Tests/Metadata/Nfo/ArtistNfoReaderTests.cs new file mode 100644 index 000000000..7c09d2925 --- /dev/null +++ b/ErsatzTV.Core.Tests/Metadata/Nfo/ArtistNfoReaderTests.cs @@ -0,0 +1,153 @@ +using System.Text; +using Bugsnag; +using ErsatzTV.Core.Metadata.Nfo; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ErsatzTV.Core.Tests.Metadata.Nfo; + +[TestFixture] +public class ArtistNfoReaderTests +{ + [SetUp] + public void SetUp() => _artistNfoReader = new ArtistNfoReader(new Mock().Object); + + private ArtistNfoReader _artistNfoReader; + + [Test] + public async Task ParsingNfo_Should_Return_Error() + { + await using var stream = + new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars")); + + Either result = await _artistNfoReader.Read(stream); + + result.IsLeft.Should().BeTrue(); + } + + [Test] + public async Task MetadataNfo_Should_Return_Nfo() + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"")); + + Either result = await _artistNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + } + + [Test] + public async Task CombinationNfo_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes( + @" +https://www.themoviedb.org/movie/11-star-wars")); + + Either result = await _artistNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + } + + [Test] + public async Task FullSample_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes( + @" + + Billy Joel + 64b94289-9474-4d43-8c93-918ccc1920d1 + Joel, Billy + Person + Male + + Pop/Rock + + + + + + Amiable/Good-Natured + Autumnal + Nostalgic + Refined + Acerbic + Bittersweet + Brash + Cynical/Sarcastic + Earnest + 1960s - 2010s + 1949-05-09 + 1964 + William Martin "Billy" Joel (born May 9, 1949, New York, USA) is an American pianist, singer-songwriter, and composer. Since releasing his first hit song, "Piano Man", in 1973, Joel has become the sixth-best-selling recording artist and the third-best-selling solo artist in the United States, according to the RIAA. His compilation album Greatest Hits Vol. 1 & 2 is the third-best-selling album in the United States by discs shipped. Joel had Top 40 hits in the 1970s, 1980s, and 1990s, achieving 33 Top 40 hits in the United States, all of which he wrote himself. He is also a six-time Grammy Award winner, a 23-time Grammy nominee and one of the world's best-selling artists of all time, having sold over 150 million records worldwide. He was inducted into the Songwriter's Hall of Fame (1992), the Rock and Roll Hall of Fame (1999), and the Long Island Music Hall of Fame (2006). In 2008, Billboard magazine released a list of the Hot 100 All-Time Top Artists to celebrate the US singles chart's 50th anniversary, with Billy Joel positioned at No. 23. With the exception of the 2007 songs "All My Life" and "Christmas in Fallujah", Joel stopped writing and recording popular music after 1993's River of Dreams, but he continued to tour extensively until 2010. Joel was born in the Bronx, May 9, 1949 and raised in Hicksville, New York in a Levitt home. His father, Howard (born Helmuth), was born in Germany, the son of German merchant and manufacturer Karl Amson Joel, who, after the advent of the Nazi regime, emigrated to Switzerland and later to the United States. Billy Joel's mother, Rosalind Nyman, was born in England to Philip and Rebecca Nyman. Both of Joel's parents were Jewish. They divorced in 1960, and his father moved to Vienna, Austria. Billy has a sister, Judith Joel, and a half-brother, Alexander Joel, who is an acclaimed classical conductor in Europe and currently chief musical director of the Staatstheater Braunschweig. Joel's father was an accomplished classical pianist. Billy reluctantly began piano lessons at an early age, at his mother's insistence; his teachers included the noted American pianist Morton Estrin and musician/songwriter Timothy Ford. His interest in music, rather than sports, was a source of teasing and bullying in his early years. (He has said in interviews that his piano instructor also taught ballet. Her name was Frances Neiman, and she was a Juilliard trained musician. She gave both classical piano and ballet lessons in the studio attached to the rear of her house, leading neighborhood bullies to mistakenly assume that he was learning to dance.) As a teenager, Joel took up boxing so that he would be able to defend himself. He boxed successfully on the amateur Golden Gloves circuit for a short time, winning twenty-two bouts, but abandoned the sport shortly after breaking his nose in his twenty-fourth boxing match. Joel attended Hicksville High School in 1967, but he did not graduate with his class. He had been helping his single mother make ends meet by playing at a piano bar, which interfered with his school attendance. At the end of his senior year, Joel did not have enough credits to graduate. Rather than attend summer school to earn his diploma, however, Joel decided to immediately begin a career in music. Joel recounted, "I told them, 'To hell with it. If I'm not going to Columbia University, I'm going to Columbia Records, and you don't need a high school diploma over there'." Columbia did, in fact, become the label that eventually signed him. In 1992, he submitted essays to the school board and was awarded his diploma at Hicksville High's annual graduation ceremony, 25 years after he had left. + + + https://assets.fanart.tv/fanart/music/64b94289-9474-4d43-8c93-918ccc1920d1/artistthumb/joel-billy-541603848114c.jpg + https://www.theaudiodb.com/images/media/artist/thumb/ttsxwr1425765041.jpg + https://rovimusic.rovicorp.com/image.jpg?c=73pC-Gp0OovlmiQL7Wp5Yd_M69_UI9rrJSVvWL2-yAg=&f=0 + https://img.discogs.com/u7cfC3lZo9JGRdukSttJTZKr9Go=/350x255/smart/filters:strip_icc():format(jpeg):mode_rgb():quality(90)/discogs-images/A-137418-1143052539.jpeg.jpg + https://www.theaudiodb.com/images/media/artist/logo/tvqpys1367246337.png + https://www.theaudiodb.com/images/media/artist/clearart/yqpsuq1523892204.png + https://www.theaudiodb.com/images/media/artist/widethumb/tywpqx1530815867.jpg + https://assets.fanart.tv/fanart/music/64b94289-9474-4d43-8c93-918ccc1920d1/musicbanner/joel-billy-5914e7759bfcd.jpg + https://assets.fanart.tv/fanart/music/64b94289-9474-4d43-8c93-918ccc1920d1/hdmusiclogo/joel-billy-550b259604412.png + https://assets.fanart.tv/fanart/music/64b94289-9474-4d43-8c93-918ccc1920d1/artistbackground/joel-billy-4fc0c2dad9ab7.jpg + https://www.theaudiodb.com/images/media/artist/fanart/uwqtup1521206367.jpg + F:\Music\ArtistInfoKodi\Billy Joel +")); + + Either result = await _artistNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + + foreach (ArtistNfo nfo in result.RightToSeq()) + { + nfo.Name.Should().Be("Billy Joel"); + nfo.Disambiguation.Should().BeNullOrEmpty(); + nfo.Genres.Should().BeEquivalentTo(new List { "Pop/Rock" }); + nfo.Styles.Should().BeEquivalentTo( + new List + { + "Album Rock", + "Contemporary Pop/Rock", + "Singer/Songwriter", + "Soft Rock", + "Keyboard" + }); + nfo.Moods.Should().BeEquivalentTo( + new List + { + "Amiable/Good-Natured", + "Autumnal", + "Nostalgic", + "Refined", + "Acerbic", + "Bittersweet", + "Brash", + "Cynical/Sarcastic", + "Earnest" + }); + nfo.Biography.Should().Be( + @"William Martin ""Billy"" Joel (born May 9, 1949, New York, USA) is an American pianist, singer-songwriter, and composer. Since releasing his first hit song, ""Piano Man"", in 1973, Joel has become the sixth-best-selling recording artist and the third-best-selling solo artist in the United States, according to the RIAA. His compilation album Greatest Hits Vol. 1 & 2 is the third-best-selling album in the United States by discs shipped. +Joel had Top 40 hits in the 1970s, 1980s, and 1990s, achieving 33 Top 40 hits in the United States, all of which he wrote himself. He is also a six-time Grammy Award winner, a 23-time Grammy nominee and one of the world's best-selling artists of all time, having sold over 150 million records worldwide. He was inducted into the Songwriter's Hall of Fame (1992), the Rock and Roll Hall of Fame (1999), and the Long Island Music Hall of Fame (2006). In 2008, Billboard magazine released a list of the Hot 100 All-Time Top Artists to celebrate the US singles chart's 50th anniversary, with Billy Joel positioned at No. 23. With the exception of the 2007 songs ""All My Life"" and ""Christmas in Fallujah"", Joel stopped writing and recording popular music after 1993's River of Dreams, but he continued to tour extensively until 2010. Joel was born in the Bronx, May 9, 1949 and raised in Hicksville, New York in a Levitt home. His father, Howard (born Helmuth), was born in Germany, the son of German merchant and manufacturer Karl Amson Joel, who, after the advent of the Nazi regime, emigrated to Switzerland and later to the United States. Billy Joel's mother, Rosalind Nyman, was born in England to Philip and Rebecca Nyman. Both of Joel's parents were Jewish. They divorced in 1960, and his father moved to Vienna, Austria. Billy has a sister, Judith Joel, and a half-brother, Alexander Joel, who is an acclaimed classical conductor in Europe and currently chief musical director of the Staatstheater Braunschweig. +Joel's father was an accomplished classical pianist. Billy reluctantly began piano lessons at an early age, at his mother's insistence; his teachers included the noted American pianist Morton Estrin and musician/songwriter Timothy Ford. His interest in music, rather than sports, was a source of teasing and bullying in his early years. (He has said in interviews that his piano instructor also taught ballet. Her name was Frances Neiman, and she was a Juilliard trained musician. She gave both classical piano and ballet lessons in the studio attached to the rear of her house, leading neighborhood bullies to mistakenly assume that he was learning to dance.) As a teenager, Joel took up boxing so that he would be able to defend himself. He boxed successfully on the amateur Golden Gloves circuit for a short time, winning twenty-two bouts, but abandoned the sport shortly after breaking his nose in his twenty-fourth boxing match. +Joel attended Hicksville High School in 1967, but he did not graduate with his class. He had been helping his single mother make ends meet by playing at a piano bar, which interfered with his school attendance. At the end of his senior year, Joel did not have enough credits to graduate. Rather than attend summer school to earn his diploma, however, Joel decided to immediately begin a career in music. Joel recounted, ""I told them, 'To hell with it. If I'm not going to Columbia University, I'm going to Columbia Records, and you don't need a high school diploma over there'."" Columbia did, in fact, become the label that eventually signed him. In 1992, he submitted essays to the school board and was awarded his diploma at Hicksville High's annual graduation ceremony, 25 years after he had left."); + } + } + + [Test] + public async Task MetadataNfo_With_Disambiguation_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(@"Test Disambiguation")); + + Either result = await _artistNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + foreach (ArtistNfo nfo in result.RightToSeq()) + { + nfo.Disambiguation.Should().Be("Test Disambiguation"); + } + } +} diff --git a/ErsatzTV.Core.Tests/Metadata/Nfo/EpisodeNfoReaderTests.cs b/ErsatzTV.Core.Tests/Metadata/Nfo/EpisodeNfoReaderTests.cs index f387759f5..b616f9092 100644 --- a/ErsatzTV.Core.Tests/Metadata/Nfo/EpisodeNfoReaderTests.cs +++ b/ErsatzTV.Core.Tests/Metadata/Nfo/EpisodeNfoReaderTests.cs @@ -1,6 +1,8 @@ using System.Text; +using Bugsnag; using ErsatzTV.Core.Metadata.Nfo; using FluentAssertions; +using Moq; using NUnit.Framework; namespace ErsatzTV.Core.Tests.Metadata.Nfo; @@ -8,10 +10,14 @@ namespace ErsatzTV.Core.Tests.Metadata.Nfo; [TestFixture] public class EpisodeNfoReaderTests { + [SetUp] + public void SetUp() => _episodeNfoReader = new EpisodeNfoReader(new Mock().Object); + + private EpisodeNfoReader _episodeNfoReader; + [Test] public async Task One() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -19,15 +25,18 @@ public class EpisodeNfoReaderTests ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(1); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(1); + } } [Test] public async Task Two() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -45,19 +54,22 @@ public class EpisodeNfoReaderTests 1 ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(2); - result.All(nfo => nfo.ShowTitle == "show").Should().BeTrue(); - result.All(nfo => nfo.Season == 1).Should().BeTrue(); - result.Count(nfo => nfo.Title == "episode-one" && nfo.Episode == 1).Should().Be(1); - result.Count(nfo => nfo.Title == "episode-two" && nfo.Episode == 2).Should().Be(1); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(2); + list.All(nfo => nfo.ShowTitle == "show").Should().BeTrue(); + list.All(nfo => nfo.Season == 1).Should().BeTrue(); + list.Count(nfo => nfo.Title == "episode-one" && nfo.Episode == 1).Should().Be(1); + list.Count(nfo => nfo.Title == "episode-two" && nfo.Episode == 2).Should().Be(1); + } } [Test] public async Task UniqueIds() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -67,18 +79,21 @@ public class EpisodeNfoReaderTests tt54321 ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(1); - result[0].UniqueIds.Count.Should().Be(2); - result[0].UniqueIds.Count(id => id.Default && id.Type == "tvdb" && id.Guid == "12345").Should().Be(1); - result[0].UniqueIds.Count(id => !id.Default && id.Type == "imdb" && id.Guid == "tt54321").Should().Be(1); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(1); + list[0].UniqueIds.Count.Should().Be(2); + list[0].UniqueIds.Count(id => id.Default && id.Type == "tvdb" && id.Guid == "12345").Should().Be(1); + list[0].UniqueIds.Count(id => !id.Default && id.Type == "imdb" && id.Guid == "tt54321").Should().Be(1); + } } [Test] public async Task No_ContentRating() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -87,16 +102,19 @@ public class EpisodeNfoReaderTests ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(1); - result[0].ContentRating.Should().BeNullOrEmpty(); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(1); + list[0].ContentRating.Should().BeNullOrEmpty(); + } } [Test] public async Task ContentRating() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -108,17 +126,20 @@ public class EpisodeNfoReaderTests US:Something / US:SomethingElse ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(2); - result.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1); - result.Count(nfo => nfo.ContentRating == "US:Something / US:SomethingElse").Should().Be(1); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(2); + list.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1); + list.Count(nfo => nfo.ContentRating == "US:Something / US:SomethingElse").Should().Be(1); + } } [Test] public async Task No_Plot() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -127,16 +148,19 @@ public class EpisodeNfoReaderTests ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(1); - result[0].Plot.Should().BeNullOrEmpty(); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(1); + list[0].Plot.Should().BeNullOrEmpty(); + } } [Test] public async Task Plot() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -145,16 +169,19 @@ public class EpisodeNfoReaderTests Some Plot ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(1); - result[0].Plot.Should().Be("Some Plot"); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(1); + list[0].Plot.Should().Be("Some Plot"); + } } [Test] public async Task Actors() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -172,20 +199,23 @@ public class EpisodeNfoReaderTests ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(1); - result[0].Actors.Count.Should().Be(2); - result[0].Actors.Count(a => a.Name == "Name 1" && a.Role == "Role 1" && a.Thumb == "Thumb 1") - .Should().Be(1); - result[0].Actors.Count(a => a.Name == "Name 2" && a.Role == "Role 2" && a.Thumb == "Thumb 2") - .Should().Be(1); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(1); + list[0].Actors.Count.Should().Be(2); + list[0].Actors.Count(a => a.Name == "Name 1" && a.Role == "Role 1" && a.Thumb == "Thumb 1") + .Should().Be(1); + list[0].Actors.Count(a => a.Name == "Name 2" && a.Role == "Role 2" && a.Thumb == "Thumb 2") + .Should().Be(1); + } } [Test] public async Task Writers() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -198,18 +228,21 @@ public class EpisodeNfoReaderTests Writer 3 ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); - result.Count.Should().Be(2); - result.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1); - result.Count(nfo => nfo.Writers.Count == 2 && nfo.Writers[0] == "Writer 2" && nfo.Writers[1] == "Writer 3") - .Should().Be(1); + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(2); + list.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1); + list.Count(nfo => nfo.Writers.Count == 2 && nfo.Writers[0] == "Writer 2" && nfo.Writers[1] == "Writer 3") + .Should().Be(1); + } } [Test] public async Task Directors() { - var reader = new EpisodeNfoReader(); var stream = new MemoryStream( Encoding.UTF8.GetBytes( @" @@ -222,13 +255,153 @@ public class EpisodeNfoReaderTests Director 3 ")); - List result = await reader.Read(stream); + Either> result = await _episodeNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + foreach (List list in result.RightToSeq()) + { + list.Count.Should().Be(2); + list.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1); + list.Count( + nfo => nfo.Directors.Count == 2 && nfo.Directors[0] == "Director 2" && + nfo.Directors[1] == "Director 3") + .Should().Be(1); + } + } + + [Test] + public async Task FullSample_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes( + @" + + Filmed Before a Live Studio Audience + WandaVision + + + 7.500000 + 18766 + + + 7.500000 + 42 + + + 6.952780 + 3621 + + + 0 + 0 + 1 + 1 + -1 + -1 + + Wanda and Vision struggle to conceal their powers during dinner with Vision’s boss and his wife. + + 26 + https://image.tmdb.org/t/p/original/cbe8l0Hnbvu07ePgoOopyWYrcdL.jpg + https://image.tmdb.org/t/p/original/oNCzeCXFanVEWNpzRzyffhLLfZs.jpg + Australia:TV-14 + 1 + 2021-03-27 + 1830976 + tt9601584 + 1830976 + 8042515 + Sci-Fi & Fantasy + Mystery + Drama + Jac Schaeffer + Matt Shakman + 2021-01-15 + 2021 + + + 2021-01-15 + Disney+ (US) + + + + + + + + + Randall Park + Jimmy Woo + 4 + https://image.tmdb.org/t/p/original/1QJ4cBQZoOaLR8Hc3V2NgBLvB0f.jpg + + + Kat Dennings + Darcy Lewis / The Escape Artist + 5 + https://image.tmdb.org/t/p/original/rrfyo9z1wW5nY9ZsFlj1Ozfj9g2.jpg + + + 0.000000 + 0.000000 + + 2021-02-02 11:57:44 +")); + + Either> result = await _episodeNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + + foreach (TvShowEpisodeNfo nfo in result.RightToSeq().Flatten()) + { + nfo.ShowTitle.Should().Be("WandaVision"); + nfo.Title.Should().Be("Filmed Before a Live Studio Audience"); + nfo.Episode.Should().Be(1); + nfo.Season.Should().Be(1); + nfo.ContentRating.Should().Be("Australia:TV-14"); + + nfo.Aired.IsSome.Should().BeTrue(); + foreach (DateTime aired in nfo.Aired) + { + aired.Should().Be(new DateTime(2021, 01, 15)); + } - result.Count.Should().Be(2); - result.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1); - result.Count( - nfo => nfo.Directors.Count == 2 && nfo.Directors[0] == "Director 2" && - nfo.Directors[1] == "Director 3") - .Should().Be(1); + nfo.Plot.Should().Be( + "Wanda and Vision struggle to conceal their powers during dinner with Vision’s boss and his wife."); + nfo.Actors.Should().BeEquivalentTo( + new List + { + new() + { + Name = "Randall Park", Order = 4, Role = "Jimmy Woo", + Thumb = "https://image.tmdb.org/t/p/original/1QJ4cBQZoOaLR8Hc3V2NgBLvB0f.jpg" + }, + new() + { + Name = "Kat Dennings", Order = 5, Role = "Darcy Lewis / The Escape Artist", + Thumb = "https://image.tmdb.org/t/p/original/rrfyo9z1wW5nY9ZsFlj1Ozfj9g2.jpg" + } + }); + nfo.Writers.Should().BeEquivalentTo(new List { "Jac Schaeffer" }); + nfo.Directors.Should().BeEquivalentTo(new List { "Matt Shakman" }); + nfo.UniqueIds.Should().BeEquivalentTo( + new List + { + new() { Type = "imdb", Guid = "tt9601584", Default = false }, + new() { Type = "tmdb", Guid = "1830976", Default = true }, + new() { Type = "tvdb", Guid = "8042515", Default = false } + }); + } } } diff --git a/ErsatzTV.Core.Tests/Metadata/Nfo/MovieNfoReaderTests.cs b/ErsatzTV.Core.Tests/Metadata/Nfo/MovieNfoReaderTests.cs index 39e6f735a..13c4aa615 100644 --- a/ErsatzTV.Core.Tests/Metadata/Nfo/MovieNfoReaderTests.cs +++ b/ErsatzTV.Core.Tests/Metadata/Nfo/MovieNfoReaderTests.cs @@ -173,7 +173,13 @@ https://www.themoviedb.org/movie/11-star-wars")); nfo.Outline.Should().BeNullOrEmpty(); nfo.Year.Should().Be(2021); nfo.ContentRating.Should().Be("Australia:M"); - nfo.Premiered.Should().Be(new DateTime(2021, 03, 18)); + + nfo.Premiered.IsSome.Should().BeTrue(); + foreach (DateTime premiered in nfo.Premiered) + { + premiered.Should().Be(new DateTime(2021, 03, 18)); + } + nfo.Plot.Should().Be( "Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions."); nfo.Tagline.Should().BeNullOrEmpty(); diff --git a/ErsatzTV.Core.Tests/Metadata/Nfo/MusicVideoNfoReaderTests.cs b/ErsatzTV.Core.Tests/Metadata/Nfo/MusicVideoNfoReaderTests.cs new file mode 100644 index 000000000..d2f8cd0b5 --- /dev/null +++ b/ErsatzTV.Core.Tests/Metadata/Nfo/MusicVideoNfoReaderTests.cs @@ -0,0 +1,156 @@ +using System.Text; +using Bugsnag; +using ErsatzTV.Core.Metadata.Nfo; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ErsatzTV.Core.Tests.Metadata.Nfo; + +[TestFixture] +public class MusicVideoNfoReaderTests +{ + [SetUp] + public void SetUp() => _musicVideoNfoReader = new MusicVideoNfoReader(new Mock().Object); + + private MusicVideoNfoReader _musicVideoNfoReader; + + [Test] + public async Task ParsingNfo_Should_Return_Error() + { + await using var stream = + new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars")); + + Either result = await _musicVideoNfoReader.Read(stream); + + result.IsLeft.Should().BeTrue(); + } + + [Test] + public async Task MetadataNfo_Should_Return_Nfo() + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"")); + + Either result = await _musicVideoNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + } + + [Test] + public async Task CombinationNfo_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes( + @" +https://www.themoviedb.org/movie/11-star-wars")); + + Either result = await _musicVideoNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + } + + [Test] + public async Task FullSample_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes( + @" + + Dancing Queen + 0 + 0 + -1 + Arrival + + Dancing Queen est un des tubes emblématiques de l'ère disco produits par le groupe suédois ABBA en 1976. Ce tube connaît un regain de popularité en 1994 lors de la sortie de Priscilla, folle du désert, et fait « presque » partie de la distribution du film Muriel. Le groupe a également enregistré une version espagnole de ce titre, La reina del baile, pour le marché d'Amérique latine. On peut retrouver ces versions en espagnol des succès de ABBA sur l'abum Oro. Le 18 juin 1976, ABBA a interprété cette chanson lors d'un spectacle télévisé organisé en l'honneur du roi Charles XVI Gustave de Suède, qui venait de se marier. Le titre sera repris en 2011 par Glee dans la saison 2, épisode 20. + + 2 + https://www.theaudiodb.com/images/media/album/thumb/arrival-4ee244732bbde.jpg + https://assets.fanart.tv/fanart/music/d87e52c5-bb8d-4da8-b941-9f4928627dc8/albumcover/arrival-548ab7a698b49.jpg + + 0 + + + Pop + 1976 + + Director 1 + Director 2 + Director 3 + Director 4 + + + + + + + + + + ABBA + + 0.000000 + 0.000000 + + 2018-09-10 09:46:06 +")); + + Either result = await _musicVideoNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + + foreach (MusicVideoNfo nfo in result.RightToSeq()) + { + nfo.Artist.Should().Be("ABBA"); + nfo.Title.Should().Be("Dancing Queen"); + nfo.Album.Should().Be("Arrival"); + nfo.Plot.Should().Be( + @"Dancing Queen est un des tubes emblématiques de l'ère disco produits par le groupe suédois ABBA en 1976. Ce tube connaît un regain de popularité en 1994 lors de la sortie de Priscilla, folle du désert, et fait « presque » partie de la distribution du film Muriel. +Le groupe a également enregistré une version espagnole de ce titre, La reina del baile, pour le marché d'Amérique latine. On peut retrouver ces versions en espagnol des succès de ABBA sur l'abum Oro. Le 18 juin 1976, ABBA a interprété cette chanson lors d'un spectacle télévisé organisé en l'honneur du roi Charles XVI Gustave de Suède, qui venait de se marier. Le titre sera repris en 2011 par Glee dans la saison 2, épisode 20."); + + nfo.Year.Should().Be(1976); + nfo.Genres.Should().BeEquivalentTo(new List { "Pop" }); + } + } + + [Test] + public async Task MetadataNfo_With_Tags_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(@"Test Tag")); + + Either result = await _musicVideoNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + foreach (MusicVideoNfo nfo in result.RightToSeq()) + { + nfo.Tags.Should().BeEquivalentTo(new List { "Test Tag" }); + } + } + + [Test] + public async Task MetadataNfo_With_Studios_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(@"Test Studio")); + + Either result = await _musicVideoNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + foreach (MusicVideoNfo nfo in result.RightToSeq()) + { + nfo.Studios.Should().BeEquivalentTo(new List { "Test Studio" }); + } + } +} diff --git a/ErsatzTV.Core.Tests/Metadata/Nfo/TvShowNfoReaderTests.cs b/ErsatzTV.Core.Tests/Metadata/Nfo/TvShowNfoReaderTests.cs new file mode 100644 index 000000000..3c04bfb81 --- /dev/null +++ b/ErsatzTV.Core.Tests/Metadata/Nfo/TvShowNfoReaderTests.cs @@ -0,0 +1,216 @@ +using System.Text; +using Bugsnag; +using ErsatzTV.Core.Metadata.Nfo; +using FluentAssertions; +using Moq; +using NUnit.Framework; + +namespace ErsatzTV.Core.Tests.Metadata.Nfo; + +[TestFixture] +public class TvShowNfoReaderTests +{ + [SetUp] + public void SetUp() => _tvShowNfoReader = new TvShowNfoReader(new Mock().Object); + + private TvShowNfoReader _tvShowNfoReader; + + [Test] + public async Task ParsingNfo_Should_Return_Error() + { + await using var stream = + new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars")); + + Either result = await _tvShowNfoReader.Read(stream); + + result.IsLeft.Should().BeTrue(); + } + + [Test] + public async Task MetadataNfo_Should_Return_Nfo() + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"")); + + Either result = await _tvShowNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + } + + [Test] + public async Task CombinationNfo_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes( + @" +https://www.themoviedb.org/movie/11-star-wars")); + + Either result = await _tvShowNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + } + + [Test] + public async Task FullSample_Should_Return_Nfo() + { + await using var stream = new MemoryStream( + Encoding.UTF8.GetBytes( + @" + + WandaVision + WandaVision + WandaVision + + + 8.200000 + 105359 + + + 8.500000 + 7230 + + + 8.077950 + 3284 + + + 0 + 0 + 1 + 9 + -1 + -1 + + Wanda Maximoff and Vision—two super-powered beings living idealized suburban lives—begin to suspect that everything is not as it seems. + + 0 + https://image.tmdb.org/t/p/original/dUWto4NaeJFrGx7jm8m3KLymUGf.jpg + https://image.tmdb.org/t/p/original/8UsAB1hgwnd80eI2ociyppB6UXL.jpg + https://assets.fanart.tv/fanart/tv/362392/tvposter/wandavision-6009571d1ed1f.jpg + https://assets.fanart.tv/fanart/tv/362392/hdtvlogo/marvels-wandavision-5f6ac3b1e9458.png + https://assets.fanart.tv/fanart/tv/362392/hdclearart/wandavision-6009b6875a285.png + https://assets.fanart.tv/fanart/tv/362392/tvthumb/wandavision-603032a5349b9.jpg + https://image.tmdb.org/t/p/original/7u443QI5xNIfLgNzEsV43CYZCWX.jpg + + https://image.tmdb.org/t/p/original/57vVjteucIF3bGnZj6PmaoJRScw.jpg + https://assets.fanart.tv/fanart/tv/362392/showbackground/marvels-wandavision-5ff4fef387a43.jpg + + Australia:M + 0 + 2021-03-29 + 85271 + tt9140560 + 85271 + 362392 + SuperHero + 2021-01-15 + 2021 + Ended + + + Disney+ + + Elizabeth Olsen + Wanda Maximoff / The Scarlet Witch + 0 + https://image.tmdb.org/t/p/original/wIU675y4dofIDVuhaNWPizJNtep.jpg + + + Paul Bettany + Vision / The Vision + 1 + https://image.tmdb.org/t/p/original/vcAVrAOZrpqmi37qjFdztRAv1u9.jpg + + Season 1 + + 0.000000 + 0.000000 + + 2021-03-12 06:15:51 +")); + + Either result = await _tvShowNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + + foreach (TvShowNfo nfo in result.RightToSeq()) + { + nfo.Title.Should().Be("WandaVision"); + nfo.Year.Should().Be(2021); + nfo.Plot.Should().Be( + "Wanda Maximoff and Vision—two super-powered beings living idealized suburban lives—begin to suspect that everything is not as it seems."); + nfo.ContentRating.Should().Be("Australia:M"); + nfo.Genres.Should().BeEquivalentTo(new List { "SuperHero" }); + nfo.Studios.Should().BeEquivalentTo(new List { "Disney+" }); + nfo.Actors.Should().BeEquivalentTo( + new List + { + new() + { + Name = "Elizabeth Olsen", Order = 0, Role = "Wanda Maximoff / The Scarlet Witch", + Thumb = "https://image.tmdb.org/t/p/original/wIU675y4dofIDVuhaNWPizJNtep.jpg" + }, + new() + { + Name = "Paul Bettany", Order = 1, Role = "Vision / The Vision", + Thumb = "https://image.tmdb.org/t/p/original/vcAVrAOZrpqmi37qjFdztRAv1u9.jpg" + } + }); + nfo.UniqueIds.Should().BeEquivalentTo( + new List + { + new() { Type = "imdb", Guid = "tt9140560", Default = false }, + new() { Type = "tmdb", Guid = "85271", Default = true }, + new() { Type = "tvdb", Guid = "362392", Default = false } + }); + nfo.Premiered.IsSome.Should().BeTrue(); + foreach (DateTime premiered in nfo.Premiered) + { + premiered.Should().Be(new DateTime(2021, 1, 15)); + } + } + } + + [Test] + public async Task MetadataNfo_With_Outline_Should_Return_Nfo() + { + await using var stream = + new MemoryStream(Encoding.UTF8.GetBytes(@"Test Outline")); + + Either result = await _tvShowNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + foreach (TvShowNfo nfo in result.RightToSeq()) + { + nfo.Outline.Should().Be("Test Outline"); + } + } + + [Test] + public async Task MetadataNfo_With_Tagline_Should_Return_Nfo() + { + await using var stream = + new MemoryStream(Encoding.UTF8.GetBytes(@"Test Tagline")); + + Either result = await _tvShowNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + foreach (TvShowNfo nfo in result.RightToSeq()) + { + nfo.Tagline.Should().Be("Test Tagline"); + } + } + + [Test] + public async Task MetadataNfo_With_Tag_Should_Return_Nfo() + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"Test Tag")); + + Either result = await _tvShowNfoReader.Read(stream); + + result.IsRight.Should().BeTrue(); + foreach (TvShowNfo nfo in result.RightToSeq()) + { + nfo.Tags.Should().BeEquivalentTo(new List { "Test Tag" }); + } + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/Nfo/IArtistNfoReader.cs b/ErsatzTV.Core/Interfaces/Metadata/Nfo/IArtistNfoReader.cs new file mode 100644 index 000000000..695b42b4f --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/Nfo/IArtistNfoReader.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core.Metadata.Nfo; + +namespace ErsatzTV.Core.Interfaces.Metadata.Nfo; + +public interface IArtistNfoReader +{ + Task> Read(Stream input); +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs b/ErsatzTV.Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs index bc7e84f05..840b1643b 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs @@ -4,5 +4,5 @@ namespace ErsatzTV.Core.Interfaces.Metadata.Nfo; public interface IEpisodeNfoReader { - Task> Read(Stream input); + Task>> Read(Stream input); } diff --git a/ErsatzTV.Core/Interfaces/Metadata/Nfo/IMusicVideoNfoReader.cs b/ErsatzTV.Core/Interfaces/Metadata/Nfo/IMusicVideoNfoReader.cs new file mode 100644 index 000000000..ee8e0343e --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/Nfo/IMusicVideoNfoReader.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core.Metadata.Nfo; + +namespace ErsatzTV.Core.Interfaces.Metadata.Nfo; + +public interface IMusicVideoNfoReader +{ + Task> Read(Stream input); +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/Nfo/ITvShowNfoReader.cs b/ErsatzTV.Core/Interfaces/Metadata/Nfo/ITvShowNfoReader.cs new file mode 100644 index 000000000..a2d60f201 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/Nfo/ITvShowNfoReader.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Core.Metadata.Nfo; + +namespace ErsatzTV.Core.Interfaces.Metadata.Nfo; + +public interface ITvShowNfoReader +{ + Task> Read(Stream input); +} diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index ea54442a9..45f117aa9 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -1,5 +1,4 @@ -using System.Xml.Serialization; -using Bugsnag; +using Bugsnag; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Metadata; @@ -12,9 +11,7 @@ namespace ErsatzTV.Core.Metadata; public class LocalMetadataProvider : ILocalMetadataProvider { - private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo)); - private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo)); - private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo)); + private readonly IArtistNfoReader _artistNfoReader; private readonly IArtistRepository _artistRepository; private readonly IClient _client; private readonly IEpisodeNfoReader _episodeNfoReader; @@ -22,14 +19,15 @@ public class LocalMetadataProvider : ILocalMetadataProvider private readonly ILocalFileSystem _localFileSystem; private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILogger _logger; - private readonly IMetadataRepository _metadataRepository; private readonly IMovieNfoReader _movieNfoReader; private readonly IMovieRepository _movieRepository; + private readonly IMusicVideoNfoReader _musicVideoNfoReader; private readonly IMusicVideoRepository _musicVideoRepository; private readonly IOtherVideoRepository _otherVideoRepository; private readonly ISongRepository _songRepository; private readonly ITelevisionRepository _televisionRepository; + private readonly ITvShowNfoReader _tvShowNfoReader; public LocalMetadataProvider( IMetadataRepository metadataRepository, @@ -43,6 +41,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider ILocalFileSystem localFileSystem, IMovieNfoReader movieNfoReader, IEpisodeNfoReader episodeNfoReader, + IArtistNfoReader artistNfoReader, + IMusicVideoNfoReader musicVideoNfoReader, + ITvShowNfoReader tvShowNfoReader, ILocalStatisticsProvider localStatisticsProvider, IClient client, ILogger logger) @@ -58,6 +59,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider _localFileSystem = localFileSystem; _movieNfoReader = movieNfoReader; _episodeNfoReader = episodeNfoReader; + _artistNfoReader = artistNfoReader; + _musicVideoNfoReader = musicVideoNfoReader; + _tvShowNfoReader = tvShowNfoReader; _localStatisticsProvider = localStatisticsProvider; _client = client; _logger = logger; @@ -168,8 +172,16 @@ public class LocalMetadataProvider : ILocalMetadataProvider try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); - Option maybeNfo = MusicVideoSerializer.Deserialize(fileStream) as MusicVideoNfo; - foreach (MusicVideoNfo nfo in maybeNfo) + Either maybeNfo = await _musicVideoNfoReader.Read(fileStream); + foreach (BaseError error in maybeNfo.LeftToSeq()) + { + _logger.LogInformation( + "Failed to read MusicVideo nfo metadata from {Path}: {Error}", + nfoFileName, + error.ToString()); + } + + foreach (MusicVideoNfo nfo in maybeNfo.RightToSeq()) { return new MusicVideoMetadata { @@ -179,8 +191,8 @@ public class LocalMetadataProvider : ILocalMetadataProvider Album = nfo.Album, Title = nfo.Title, Plot = nfo.Plot, - Year = GetYear(nfo.Year, nfo.Premiered), - ReleaseDate = GetAired(nfo.Year, nfo.Premiered), + Year = GetYear(nfo.Year, string.Empty), + ReleaseDate = GetAired(nfo.Year, string.Empty), Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(), Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(), Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList() @@ -815,8 +827,16 @@ public class LocalMetadataProvider : ILocalMetadataProvider try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); - Option maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo; - foreach (TvShowNfo nfo in maybeNfo) + Either maybeNfo = await _tvShowNfoReader.Read(fileStream); + foreach (BaseError error in maybeNfo.LeftToSeq()) + { + _logger.LogInformation( + "Failed to read TvShow nfo metadata from {Path}: {Error}", + nfoFileName, + error.ToString()); + } + + foreach (TvShowNfo nfo in maybeNfo.RightToSeq()) { DateTime dateAdded = DateTime.UtcNow; DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); @@ -858,8 +878,16 @@ public class LocalMetadataProvider : ILocalMetadataProvider try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); - Option maybeNfo = ArtistSerializer.Deserialize(fileStream) as ArtistNfo; - foreach (ArtistNfo nfo in maybeNfo) + Either maybeNfo = await _artistNfoReader.Read(fileStream); + foreach (BaseError error in maybeNfo.LeftToSeq()) + { + _logger.LogInformation( + "Failed to read Artist nfo metadata from {Path}: {Error}", + nfoFileName, + error.ToString()); + } + + foreach (ArtistNfo nfo in maybeNfo.RightToSeq()) { return new ArtistMetadata { @@ -890,9 +918,17 @@ public class LocalMetadataProvider : ILocalMetadataProvider try { await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); - List nfos = await _episodeNfoReader.Read(fileStream); + Either> maybeNfo = await _episodeNfoReader.Read(fileStream); + foreach (BaseError error in maybeNfo.LeftToSeq()) + { + _logger.LogInformation( + "Failed to read Episode nfo metadata from {Path}: {Error}", + nfoFileName, + error.ToString()); + } + var result = new List(); - foreach (TvShowEpisodeNfo nfo in nfos) + foreach (TvShowEpisodeNfo nfo in maybeNfo.RightToSeq().Flatten()) { DateTime dateAdded = DateTime.UtcNow; DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); @@ -954,10 +990,23 @@ public class LocalMetadataProvider : ILocalMetadataProvider DateTime dateAdded = DateTime.UtcNow; DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); - int year = nfo.Year > 0 ? nfo.Year : nfo.Premiered.Year; - DateTime releaseDate = nfo.Premiered > SystemTime.MinValueUtc - ? nfo.Premiered - : new DateTimeOffset(year, 0, 0, 0, 0, 0, TimeSpan.Zero).UtcDateTime; + var year = 0; + if (nfo.Year > 1000) + { + year = nfo.Year; + } + + DateTime releaseDate = new DateTimeOffset(year, 0, 0, 0, 0, 0, TimeSpan.Zero).UtcDateTime; + + foreach (DateTime premiered in nfo.Premiered) + { + if (year == 0) + { + year = premiered.Year; + } + + releaseDate = premiered; + } return new MovieMetadata { @@ -1014,6 +1063,21 @@ public class LocalMetadataProvider : ILocalMetadataProvider return null; } + private static int? GetYear(int? year, Option premiered) + { + if (year is > 1000) + { + return year; + } + + foreach (DateTime p in premiered) + { + return p.Year; + } + + return null; + } + private static DateTime? GetAired(int? year, string aired) { DateTime? fallback = year is > 1000 ? new DateTime(year.Value, 1, 1) : null; @@ -1026,6 +1090,18 @@ public class LocalMetadataProvider : ILocalMetadataProvider return DateTime.TryParse(aired, out DateTime parsed) ? parsed : fallback; } + private static DateTime? GetAired(int? year, Option aired) + { + DateTime? fallback = year is > 1000 ? new DateTime(year.Value, 1, 1) : null; + + foreach (DateTime a in aired) + { + return a; + } + + return fallback; + } + private async Task UpdateMetadataCollections( T existing, T incoming, diff --git a/ErsatzTV.Core/Metadata/Nfo/ArtistNfoReader.cs b/ErsatzTV.Core/Metadata/Nfo/ArtistNfoReader.cs new file mode 100644 index 000000000..9e01a77e8 --- /dev/null +++ b/ErsatzTV.Core/Metadata/Nfo/ArtistNfoReader.cs @@ -0,0 +1,83 @@ +using System.Xml; +using Bugsnag; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Metadata.Nfo; + +namespace ErsatzTV.Core.Metadata.Nfo; + +public class ArtistNfoReader : NfoReader, IArtistNfoReader +{ + private readonly IClient _client; + + public ArtistNfoReader(IClient client) => _client = client; + + public async Task> Read(Stream input) + { + try + { + var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment }; + using var reader = XmlReader.Create(input, settings); + ArtistNfo nfo = null; + var done = false; + + while (!done && await reader.ReadAsync()) + { + switch (reader.NodeType) + { + case XmlNodeType.Element: + switch (reader.Name.ToLowerInvariant()) + { + case "artist": + nfo = new ArtistNfo + { + Genres = new List(), + Styles = new List(), + Moods = new List() + }; + break; + case "name": + await ReadStringContent(reader, nfo, (artist, name) => artist.Name = name); + break; + case "disambiguation": + await ReadStringContent( + reader, + nfo, + (artist, disambiguation) => artist.Disambiguation = disambiguation); + break; + case "genre": + await ReadStringContent(reader, nfo, (artist, genre) => artist.Genres.Add(genre)); + break; + case "style": + await ReadStringContent(reader, nfo, (artist, style) => artist.Styles.Add(style)); + break; + case "mood": + await ReadStringContent(reader, nfo, (artist, mood) => artist.Moods.Add(mood)); + break; + case "biography": + await ReadStringContent( + reader, + nfo, + (artist, biography) => artist.Biography = biography); + break; + } + + break; + case XmlNodeType.EndElement: + if (reader.Name == "artist") + { + done = true; + } + + break; + } + } + + return Optional(nfo).ToEither((BaseError)new FailedToReadNfo()); + } + catch (Exception ex) + { + _client.Notify(ex); + return new FailedToReadNfo(ex.ToString()); + } + } +} diff --git a/ErsatzTV.Core/Metadata/Nfo/EpisodeNfoReader.cs b/ErsatzTV.Core/Metadata/Nfo/EpisodeNfoReader.cs index 0f25f683c..2d4e6918d 100644 --- a/ErsatzTV.Core/Metadata/Nfo/EpisodeNfoReader.cs +++ b/ErsatzTV.Core/Metadata/Nfo/EpisodeNfoReader.cs @@ -1,104 +1,115 @@ using System.Xml; +using Bugsnag; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Metadata.Nfo; namespace ErsatzTV.Core.Metadata.Nfo; public class EpisodeNfoReader : NfoReader, IEpisodeNfoReader { - public async Task> Read(Stream input) - { - var result = new List(); + private readonly IClient _client; - var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment }; - using var reader = XmlReader.Create(input, settings); - TvShowEpisodeNfo nfo = null; + public EpisodeNfoReader(IClient client) => _client = client; - while (await reader.ReadAsync()) + public async Task>> Read(Stream input) + { + try { - switch (reader.NodeType) + var result = new List(); + + var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment }; + using var reader = XmlReader.Create(input, settings); + TvShowEpisodeNfo nfo = null; + + while (await reader.ReadAsync()) { - case XmlNodeType.Element: - switch (reader.Name.ToLowerInvariant()) - { - case "episodedetails": - nfo = new TvShowEpisodeNfo - { - UniqueIds = new List(), - Actors = new List(), - Writers = new List(), - Directors = new List() - }; - break; - case "title": - await ReadStringContent(reader, nfo, (episode, title) => episode.Title = title); - break; - case "showtitle": - await ReadStringContent(reader, nfo, (episode, showTitle) => episode.ShowTitle = showTitle); - break; - case "episode": - await ReadIntContent( - reader, - nfo, - (episode, episodeNumber) => episode.Episode = episodeNumber); - break; - case "season": - await ReadIntContent(reader, nfo, (episode, seasonNumber) => episode.Season = seasonNumber); - break; - case "uniqueid": - await ReadUniqueId(reader, nfo, (episode, uniqueId) => episode.UniqueIds.Add(uniqueId)); - break; - case "mpaa": - await ReadStringContent( - reader, - nfo, - (episode, contentRating) => episode.ContentRating = contentRating); - break; - case "aired": - // TODO: parse the date here - await ReadAired(reader, nfo); - break; - case "plot": - await ReadStringContent(reader, nfo, (episode, plot) => episode.Plot = plot); - break; - case "actor": - ReadActor(reader, nfo, (episode, actor) => episode.Actors.Add(actor)); - break; - case "credits": - await ReadStringContent(reader, nfo, (episode, writer) => episode.Writers.Add(writer)); - break; - case "director": - await ReadStringContent( - reader, - nfo, - (episode, director) => episode.Directors.Add(director)); - break; - } + switch (reader.NodeType) + { + case XmlNodeType.Element: + switch (reader.Name.ToLowerInvariant()) + { + case "episodedetails": + nfo = new TvShowEpisodeNfo + { + UniqueIds = new List(), + Actors = new List(), + Writers = new List(), + Directors = new List() + }; + break; + case "title": + await ReadStringContent(reader, nfo, (episode, title) => episode.Title = title); + break; + case "showtitle": + await ReadStringContent( + reader, + nfo, + (episode, showTitle) => episode.ShowTitle = showTitle); + break; + case "episode": + await ReadIntContent( + reader, + nfo, + (episode, episodeNumber) => episode.Episode = episodeNumber); + break; + case "season": + await ReadIntContent( + reader, + nfo, + (episode, seasonNumber) => episode.Season = seasonNumber); + break; + case "uniqueid": + await ReadUniqueId(reader, nfo, (episode, uniqueId) => episode.UniqueIds.Add(uniqueId)); + break; + case "mpaa": + await ReadStringContent( + reader, + nfo, + (episode, contentRating) => episode.ContentRating = contentRating); + break; + case "aired": + await ReadDateTimeContent(reader, nfo, (episode, aired) => episode.Aired = aired); + break; + case "plot": + await ReadStringContent(reader, nfo, (episode, plot) => episode.Plot = plot); + break; + case "actor": + ReadActor(reader, nfo, (episode, actor) => episode.Actors.Add(actor)); + break; + case "credits": + await ReadStringContent(reader, nfo, (episode, writer) => episode.Writers.Add(writer)); + break; + case "director": + await ReadStringContent( + reader, + nfo, + (episode, director) => episode.Directors.Add(director)); + break; + } - break; - case XmlNodeType.EndElement: - switch (reader.Name.ToLowerInvariant()) - { - case "episodedetails": - if (nfo != null) - { - result.Add(nfo); - } + break; + case XmlNodeType.EndElement: + switch (reader.Name.ToLowerInvariant()) + { + case "episodedetails": + if (nfo != null) + { + result.Add(nfo); + } - break; - } + break; + } - break; + break; + } } - } - return result; - } - - private static async Task ReadAired(XmlReader reader, TvShowEpisodeNfo nfo) - { - if (nfo != null) + return result; + } + catch (Exception ex) { - nfo.Aired = await reader.ReadElementContentAsStringAsync(); + _client.Notify(ex); + return new FailedToReadNfo(ex.ToString()); } } } diff --git a/ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs b/ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs index 2f868cc95..36ba4e3f1 100644 --- a/ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs +++ b/ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs @@ -21,7 +21,7 @@ public class MovieNfo public string ContentRating { get; set; } [XmlElement("premiered")] - public DateTime Premiered { get; set; } + public Option Premiered { get; set; } [XmlElement("plot")] public string Plot { get; set; } diff --git a/ErsatzTV.Core/Metadata/Nfo/MusicVideoNfo.cs b/ErsatzTV.Core/Metadata/Nfo/MusicVideoNfo.cs index dad141a27..9fd49e318 100644 --- a/ErsatzTV.Core/Metadata/Nfo/MusicVideoNfo.cs +++ b/ErsatzTV.Core/Metadata/Nfo/MusicVideoNfo.cs @@ -17,9 +17,6 @@ public class MusicVideoNfo [XmlElement("plot")] public string Plot { get; set; } - [XmlElement("premiered")] - public string Premiered { get; set; } - [XmlElement("year")] public int Year { get; set; } diff --git a/ErsatzTV.Core/Metadata/Nfo/MusicVideoNfoReader.cs b/ErsatzTV.Core/Metadata/Nfo/MusicVideoNfoReader.cs new file mode 100644 index 000000000..87fe12bc0 --- /dev/null +++ b/ErsatzTV.Core/Metadata/Nfo/MusicVideoNfoReader.cs @@ -0,0 +1,92 @@ +using System.Xml; +using Bugsnag; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Metadata.Nfo; + +namespace ErsatzTV.Core.Metadata.Nfo; + +public class MusicVideoNfoReader : NfoReader, IMusicVideoNfoReader +{ + private readonly IClient _client; + + public MusicVideoNfoReader(IClient client) => _client = client; + + public async Task> Read(Stream input) + { + try + { + var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment }; + using var reader = XmlReader.Create(input, settings); + MusicVideoNfo nfo = null; + var done = false; + + while (!done && await reader.ReadAsync()) + { + switch (reader.NodeType) + { + case XmlNodeType.Element: + switch (reader.Name.ToLowerInvariant()) + { + case "musicvideo": + nfo = new MusicVideoNfo + { + Genres = new List(), + Tags = new List(), + Studios = new List() + }; + break; + case "artist": + await ReadStringContent( + reader, + nfo, + (musicVideo, artist) => musicVideo.Artist = artist); + break; + case "title": + await ReadStringContent(reader, nfo, (musicVideo, title) => musicVideo.Title = title); + break; + case "album": + await ReadStringContent(reader, nfo, (musicVideo, album) => musicVideo.Album = album); + break; + case "plot": + await ReadStringContent(reader, nfo, (musicVideo, plot) => musicVideo.Plot = plot); + break; + case "year": + await ReadIntContent(reader, nfo, (musicVideo, year) => musicVideo.Year = year); + break; + case "genre": + await ReadStringContent( + reader, + nfo, + (musicVideo, genre) => musicVideo.Genres.Add(genre)); + break; + case "tag": + await ReadStringContent(reader, nfo, (musicVideo, tag) => musicVideo.Tags.Add(tag)); + break; + case "studio": + await ReadStringContent( + reader, + nfo, + (musicVideo, studio) => musicVideo.Studios.Add(studio)); + break; + } + + break; + case XmlNodeType.EndElement: + if (reader.Name == "musicvideo") + { + done = true; + } + + break; + } + } + + return Optional(nfo).ToEither((BaseError)new FailedToReadNfo()); + } + catch (Exception ex) + { + _client.Notify(ex); + return new FailedToReadNfo(ex.ToString()); + } + } +} diff --git a/ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs b/ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs index ee383c234..579a1ab26 100644 --- a/ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs +++ b/ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs @@ -21,7 +21,7 @@ public class TvShowEpisodeNfo public string ContentRating { get; set; } [XmlElement("aired")] - public string Aired { get; set; } + public Option Aired { get; set; } [XmlElement("plot")] public string Plot { get; set; } diff --git a/ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs b/ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs index 1ef39ec8f..f52ebc265 100644 --- a/ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs +++ b/ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs @@ -31,7 +31,7 @@ public class TvShowNfo public string ContentRating { get; set; } [XmlElement("premiered")] - public string Premiered { get; set; } + public Option Premiered { get; set; } [XmlElement("genre")] public List Genres { get; set; } diff --git a/ErsatzTV.Core/Metadata/Nfo/TvShowNfoReader.cs b/ErsatzTV.Core/Metadata/Nfo/TvShowNfoReader.cs new file mode 100644 index 000000000..f2be295be --- /dev/null +++ b/ErsatzTV.Core/Metadata/Nfo/TvShowNfoReader.cs @@ -0,0 +1,100 @@ +using System.Xml; +using Bugsnag; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Metadata.Nfo; + +namespace ErsatzTV.Core.Metadata.Nfo; + +public class TvShowNfoReader : NfoReader, ITvShowNfoReader +{ + private readonly IClient _client; + + public TvShowNfoReader(IClient client) => _client = client; + + public async Task> Read(Stream input) + { + try + { + var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment }; + using var reader = XmlReader.Create(input, settings); + TvShowNfo nfo = null; + var done = false; + + while (!done && await reader.ReadAsync()) + { + switch (reader.NodeType) + { + case XmlNodeType.Element: + switch (reader.Name.ToLowerInvariant()) + { + case "tvshow": + nfo = new TvShowNfo + { + Genres = new List(), + Tags = new List(), + Studios = new List(), + Actors = new List(), + UniqueIds = new List() + }; + break; + case "title": + await ReadStringContent(reader, nfo, (show, title) => show.Title = title); + break; + case "year": + await ReadIntContent(reader, nfo, (show, year) => show.Year = year); + break; + case "plot": + await ReadStringContent(reader, nfo, (show, plot) => show.Plot = plot); + break; + case "outline": + await ReadStringContent(reader, nfo, (show, outline) => show.Outline = outline); + break; + case "tagline": + await ReadStringContent(reader, nfo, (show, tagline) => show.Tagline = tagline); + break; + case "mpaa": + await ReadStringContent( + reader, + nfo, + (show, contentRating) => show.ContentRating = contentRating); + break; + case "premiered": + await ReadDateTimeContent(reader, nfo, (show, premiered) => show.Premiered = premiered); + break; + case "genre": + await ReadStringContent(reader, nfo, (show, genre) => show.Genres.Add(genre)); + break; + case "tag": + await ReadStringContent(reader, nfo, (show, tag) => show.Tags.Add(tag)); + break; + case "studio": + await ReadStringContent(reader, nfo, (show, studio) => show.Studios.Add(studio)); + break; + case "actor": + ReadActor(reader, nfo, (episode, actor) => episode.Actors.Add(actor)); + break; + case "uniqueid": + await ReadUniqueId(reader, nfo, (episode, uniqueid) => episode.UniqueIds.Add(uniqueid)); + break; + } + + break; + case XmlNodeType.EndElement: + if (reader.Name == "tvshow") + { + done = true; + } + + break; + } + } + + return Optional(nfo).ToEither((BaseError)new FailedToReadNfo()); + } + catch (Exception ex) + { + _client.Notify(ex); + return new FailedToReadNfo(ex.ToString()); + } + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 03503976b..e9e8f2adb 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -412,6 +412,9 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>));