diff --git a/Jellyfin.Plugin.Themerr.Tests/BootstrapJellyfinServer.cs b/Jellyfin.Plugin.Themerr.Tests/BootstrapJellyfinServer.cs new file mode 100644 index 00000000..c843718d --- /dev/null +++ b/Jellyfin.Plugin.Themerr.Tests/BootstrapJellyfinServer.cs @@ -0,0 +1,115 @@ +using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; +using Movie = MediaBrowser.Controller.Entities.Movies.Movie; + +namespace Jellyfin.Plugin.Themerr.Tests; + +[CollectionDefinition("Bootstrapped Collection")] +public class BootstrappedCollection : ICollectionFixture +{ + // This class doesn't need to have any code, or even be long-lived. + // All it needs is to just exist, and be annotated with CollectionDefinition. +} + +/// +/// This class is used to bootstrap a Jellyfin server with mock movies +/// +public class BootstrapJellyfinServer +{ + /// + /// Mock movies to use for testing + /// + /// + public static List MockMovies() + { + return new List + { + new() + { + Name = "Elephants Dream", + ProductionYear = 2006, + ProviderIds = new Dictionary + { + { MetadataProvider.Imdb.ToString(), "tt0807840"}, + { MetadataProvider.Tmdb.ToString(), "9761"}, + } + }, + new() + { + Name = "Sita Sings the Blues", + ProductionYear = 2008, + ProviderIds = new Dictionary + { + { MetadataProvider.Imdb.ToString(), "tt1172203"}, + { MetadataProvider.Tmdb.ToString(), "20529"}, + } + }, + new() + { + Name = "Big Buck Bunny", + ProductionYear = 2008, + ProviderIds = new Dictionary + { + { MetadataProvider.Imdb.ToString(), "tt1254207"}, + { MetadataProvider.Tmdb.ToString(), "10378"}, + } + }, + new() + { + Name = "Sintel", + ProductionYear = 2010, + ProviderIds = new Dictionary + { + { MetadataProvider.Imdb.ToString(), "tt1727587"}, + { MetadataProvider.Tmdb.ToString(), "45745"}, + } + }, + }; + } + + + /// + /// Create mock movies from stub video + /// + [Fact] + [Trait("Category", "Init")] + private void CreateMockMovies() + { + var mockMovies = MockMovies(); + + // get the stub video path based on the directory of this file + var stubVideoPath = Path.Combine( + Directory.GetCurrentDirectory(), + "data", + "video_stub.mp4" + ); + + Assert.True(File.Exists(stubVideoPath), "Could not find ./data/video_stub.mp4"); + + foreach (var movie in mockMovies) + { + // copy the ./data/video_stub.mp4 to the movie folder "movie.Name (movie.ProductionYear)" + var movieFolder = Path.Combine( + "themerr_jellyfin_tests", + $"{movie.Name} ({movie.ProductionYear})" + ); + + // create the movie folder + Directory.CreateDirectory(movieFolder); + + // copy the video_stub.mp4 to the movie folder, renaming it based on movie name + var movieVideoPath = Path.Combine( + movieFolder, + $"{movie.Name} ({movie.ProductionYear}).mp4" + ); + + // if file does not exist + if (!File.Exists(movieVideoPath)) + { + // copy the stub video to the movie folder + File.Copy(stubVideoPath, movieVideoPath); + } + + Assert.True(File.Exists(movieVideoPath), $"Could not find {movieVideoPath}"); + } + } +} diff --git a/Jellyfin.Plugin.Themerr.Tests/Jellyfin.Plugin.Themerr.Tests.csproj b/Jellyfin.Plugin.Themerr.Tests/Jellyfin.Plugin.Themerr.Tests.csproj index e9c050a4..b9de0f76 100644 --- a/Jellyfin.Plugin.Themerr.Tests/Jellyfin.Plugin.Themerr.Tests.csproj +++ b/Jellyfin.Plugin.Themerr.Tests/Jellyfin.Plugin.Themerr.Tests.csproj @@ -29,4 +29,10 @@ + + + PreserveNewest + + + diff --git a/Jellyfin.Plugin.Themerr.Tests/TestLogger.cs b/Jellyfin.Plugin.Themerr.Tests/TestLogger.cs new file mode 100644 index 00000000..64c3110e --- /dev/null +++ b/Jellyfin.Plugin.Themerr.Tests/TestLogger.cs @@ -0,0 +1,71 @@ +namespace Jellyfin.Plugin.Themerr.Tests +{ + /// + /// A simple logger for tests + /// + public static class TestLogger + { + // log a message to console + private static ITestOutputHelper? _output; + + public static void Initialize(ITestOutputHelper output) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + } + + /// + /// Logs a message to the test output + /// + /// + /// + public static void Log(string message, string type = "INFO") + { + _output?.WriteLine($"[{type}] {message}"); + } + + /// + /// Logs a critical message to the test output + /// + /// + public static void Critical(string message) + { + Log(message, "CRITICAL"); + } + + /// + /// Logs a debug message to the test output + /// + /// + public static void Debug(string message) + { + Log(message, "DEBUG"); + } + + /// + /// Logs an error message to the test output + /// + /// + public static void Error(string message) + { + Log(message, "ERROR"); + } + + /// + /// Logs an info message to the test output + /// + /// + public static void Info(string message) + { + Log(message, "INFO"); + } + + /// + /// Logs a warning message to the test output + /// + /// + public static void Warn(string message) + { + Log(message, "WARN"); + } + } +} diff --git a/Jellyfin.Plugin.Themerr.Tests/TestThemerrManager.cs b/Jellyfin.Plugin.Themerr.Tests/TestThemerrManager.cs index b25f53bb..b73469f9 100644 --- a/Jellyfin.Plugin.Themerr.Tests/TestThemerrManager.cs +++ b/Jellyfin.Plugin.Themerr.Tests/TestThemerrManager.cs @@ -1,17 +1,49 @@ -using Xunit.Abstractions; +using Newtonsoft.Json; +using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; namespace Jellyfin.Plugin.Themerr.Tests; +[Collection("Bootstrapped Collection")] public class TestThemerrManager { - private readonly ITestOutputHelper _testOutputHelper; - - public TestThemerrManager(ITestOutputHelper testOutputHelper) + public TestThemerrManager(ITestOutputHelper output) { - _testOutputHelper = testOutputHelper; + TestLogger.Initialize(output); } - + + private static List FixtureYoutubeUrls() + { + // create a list and return it + var youtubeUrls = new List() + { + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/watch?v=yPYZpwSpKmA", + "https://www.youtube.com/watch?v=Ghmd4QzT9YY", + "https://www.youtube.com/watch?v=LVEWkghDh9A" + }; + + // return the list + return youtubeUrls; + } + + private static List FixtureThemerrDbUrls() + { + // make list of youtubeUrls to populate + var youtubeUrls = new List(); + + foreach (var movie in BootstrapJellyfinServer.MockMovies()) + { + var tmdbId = movie.ProviderIds[MetadataProvider.Tmdb.ToString()]; + var themerrDbLink = ThemerrManager.CreateThemerrDbLink(tmdbId); + youtubeUrls.Add(themerrDbLink); + } + + // return the list + return youtubeUrls; + } + [Fact] + [Trait("Category", "Unit")] public void TestSaveMp3() { // set destination with themerr_jellyfin_tests as the folder name @@ -20,18 +52,11 @@ public void TestSaveMp3() "theme.mp3" ); - // create a list of youtube urls - var videoUrls = new List - { - "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - "https://www.youtube.com/watch?v=yPYZpwSpKmA", - "https://www.youtube.com/watch?v=Ghmd4QzT9YY", - "https://www.youtube.com/watch?v=LVEWkghDh9A" - }; - foreach (var videoUrl in videoUrls) + + foreach (var videoUrl in FixtureYoutubeUrls()) { // log - _testOutputHelper.WriteLine($"Attempting to download {videoUrl}"); + TestLogger.Info($"Attempting to download {videoUrl}"); // run and wait ThemerrManager.SaveMp3(destinationFile, videoUrl); @@ -40,16 +65,16 @@ public void TestSaveMp3() Thread.Sleep(5000); // 5 seconds // check if file exists - Assert.True(File.Exists(destinationFile)); + Assert.True(File.Exists(destinationFile), $"File {destinationFile} does not exist"); // check if the file is an actual mp3 // https://en.wikipedia.org/wiki/List_of_file_signatures var fileBytes = File.ReadAllBytes(destinationFile); var fileBytesHex = BitConverter.ToString(fileBytes); - // make sure the file does is not WebM, starts with `1A 45 DF A3` - var isNotWebM = !fileBytesHex.StartsWith("1A-45-DF-A3"); - Assert.True(isNotWebM); + // make sure the file is not WebM, starts with `1A 45 DF A3` + var isWebM = fileBytesHex.StartsWith("1A-45-DF-A3"); + Assert.False(isWebM, $"File {destinationFile} is WebM"); // valid mp3 signatures dictionary with offsets var validMp3Signatures = new Dictionary @@ -63,7 +88,7 @@ public void TestSaveMp3() }; // log beginning of fileBytesHex - _testOutputHelper.WriteLine($"Beginning of fileBytesHex: {fileBytesHex.Substring(0, 40)}"); + TestLogger.Debug($"Beginning of fileBytesHex: {fileBytesHex.Substring(0, 40)}"); // check if the file is an actual mp3 var isMp3 = false; @@ -72,7 +97,7 @@ public void TestSaveMp3() foreach (var (signature, offset) in validMp3Signatures) { // log - _testOutputHelper.WriteLine($"Checking for {signature} at offset of {offset} bytes"); + TestLogger.Debug($"Checking for {signature} at offset of {offset} bytes"); // remove the offset bytes var fileBytesHexWithoutOffset = fileBytesHex.Substring(offset * 3); @@ -82,7 +107,7 @@ public void TestSaveMp3() if (isSignature) { // log - _testOutputHelper.WriteLine($"Found {signature} at offset {offset}"); + TestLogger.Info($"Found {signature} at offset {offset}"); // set isMp3 to true isMp3 = true; @@ -92,12 +117,81 @@ public void TestSaveMp3() } // log - _testOutputHelper.WriteLine($"Did not find {signature} at offset {offset}"); + TestLogger.Debug($"Did not find {signature} at offset {offset}"); } - Assert.True(isMp3); + Assert.True(isMp3, $"File {destinationFile} is not an mp3"); // delete file File.Delete(destinationFile); } } + + [Fact] + [Trait("Category", "Unit")] + public void TestCreateThemerrDbLink() + { + // get bootstrapped movies + var mockMovies = BootstrapJellyfinServer.MockMovies(); + + Assert.True(mockMovies.Count > 0, "mockMovies.Count is not greater than 0"); + + foreach (var movie in mockMovies) + { + var tmdbId = movie.ProviderIds[MetadataProvider.Tmdb.ToString()]; + var themerrDbLink = ThemerrManager.CreateThemerrDbLink(tmdbId); + + TestLogger.Info($"themerrDbLink: {themerrDbLink}"); + + Assert.EndsWith($"themoviedb/{tmdbId}.json", themerrDbLink); + } + } + + [Fact] + [Trait("Category", "Unit")] + public void TestGetNewYoutubeThemeUrl() + { + + // loop over each themerrDbLink + foreach (var themerrDbLink in FixtureThemerrDbUrls()) + { + // get the new youtube theme url + var youtubeThemeUrl = ThemerrManager.GetNewYoutubeThemeUrl(themerrDbLink); + + // log + TestLogger.Info($"youtubeThemeUrl: {youtubeThemeUrl}"); + + Assert.NotEmpty(youtubeThemeUrl); + } + } + + [Fact] + [Trait("Category", "Unit")] + public void TestSaveThemerrData() + { + + // set mock themerrDataPath using a random number + var mockThemerrDataPath = $"themerr_{new Random().Next()}.json"; + + // loop over each themerrDbLink + foreach (var youtubeThemeUrl in FixtureYoutubeUrls()) + { + + // save themerr data + ThemerrManager.SaveThemerrData(mockThemerrDataPath, youtubeThemeUrl); + + // wait 1 second + Thread.Sleep(1000); + + // check if file exists + Assert.True(File.Exists(mockThemerrDataPath), $"File {mockThemerrDataPath} does not exist"); + + // make sure the saved json file contains a key named "youtube_theme_url", and value is correct + var jsonString = File.ReadAllText(mockThemerrDataPath); + File.Delete(mockThemerrDataPath); // delete the file + dynamic jsonData = JsonConvert.DeserializeObject(jsonString) ?? throw new InvalidOperationException(); + var savedYoutubeThemeUrl = jsonData.youtube_theme_url.ToString(); + Assert.True(youtubeThemeUrl == savedYoutubeThemeUrl, + $"youtubeThemeUrl {youtubeThemeUrl} does not match savedYoutubeThemeUrl {savedYoutubeThemeUrl}"); + } + } } diff --git a/Jellyfin.Plugin.Themerr.Tests/Usings.cs b/Jellyfin.Plugin.Themerr.Tests/Usings.cs index c802f448..89702116 100644 --- a/Jellyfin.Plugin.Themerr.Tests/Usings.cs +++ b/Jellyfin.Plugin.Themerr.Tests/Usings.cs @@ -1 +1,2 @@ global using Xunit; +global using Xunit.Abstractions; diff --git a/Jellyfin.Plugin.Themerr.Tests/data/video_stub.mp4 b/Jellyfin.Plugin.Themerr.Tests/data/video_stub.mp4 new file mode 100644 index 00000000..d9a10e31 Binary files /dev/null and b/Jellyfin.Plugin.Themerr.Tests/data/video_stub.mp4 differ diff --git a/Jellyfin.Plugin.Themerr/Jellyfin.Plugin.Themerr.csproj b/Jellyfin.Plugin.Themerr/Jellyfin.Plugin.Themerr.csproj index 3b37043e..e0f9694a 100644 --- a/Jellyfin.Plugin.Themerr/Jellyfin.Plugin.Themerr.csproj +++ b/Jellyfin.Plugin.Themerr/Jellyfin.Plugin.Themerr.csproj @@ -25,8 +25,4 @@ - - - - diff --git a/Jellyfin.Plugin.Themerr/ScheduledTasks/ThemerrTasks.cs b/Jellyfin.Plugin.Themerr/ScheduledTasks/ThemerrTasks.cs index 837496a5..9c95ec01 100644 --- a/Jellyfin.Plugin.Themerr/ScheduledTasks/ThemerrTasks.cs +++ b/Jellyfin.Plugin.Themerr/ScheduledTasks/ThemerrTasks.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; diff --git a/Jellyfin.Plugin.Themerr/ThemerrManager.cs b/Jellyfin.Plugin.Themerr/ThemerrManager.cs index 90980226..98c8af46 100644 --- a/Jellyfin.Plugin.Themerr/ThemerrManager.cs +++ b/Jellyfin.Plugin.Themerr/ThemerrManager.cs @@ -20,20 +20,32 @@ namespace Jellyfin.Plugin.Themerr { + /// + /// The main entry point for the plugin + /// public class ThemerrManager : IServerEntryPoint { private readonly ILibraryManager _libraryManager; private readonly Timer _timer; private readonly ILogger _logger; + /// + /// Constructor + /// + /// + /// public ThemerrManager(ILibraryManager libraryManager, ILogger logger) { _libraryManager = libraryManager; _logger = logger; _timer = new Timer(_ => OnTimerElapsed(), null, Timeout.Infinite, Timeout.Infinite); } - + /// + /// Saves a mp3 file from a youtube video url + /// + /// + /// public static void SaveMp3(string destination, string videoUrl) { Task.Run(async () => @@ -52,8 +64,11 @@ public static void SaveMp3(string destination, string videoUrl) }); } - - private IEnumerable GetMoviesFromLibrary() + /// + /// Gets all movies from the library that have a tmdb id + /// + /// + public IEnumerable GetMoviesFromLibrary() { return _libraryManager.GetItemList(new InternalItemsQuery { @@ -63,115 +78,175 @@ private IEnumerable GetMoviesFromLibrary() HasTmdbId = true }).Select(m => m as Movie); } - - + + /// + /// Enumerates through all movies in the library and downloads their theme songs as required + /// + /// public Task DownloadAllThemerr() { var movies = GetMoviesFromLibrary(); foreach (var movie in movies) { - // set paths - var themePath = $"{movie.ContainingFolderPath}/theme.mp3"; - var themerrDataPath = $"{movie.ContainingFolderPath}/themerr.json"; - - // if theme.mp3 exists and themerr.json does not exist then skip - // don't overwrite user supplied theme files - if (System.IO.File.Exists(themePath) && !System.IO.File.Exists(themerrDataPath)) - { - continue; - } - - // open themerr.json and check if theme song is already downloaded - var existingYoutubeThemeUrl = ""; - if (System.IO.File.Exists(themerrDataPath)) - { - var jsonString = System.IO.File.ReadAllText(themerrDataPath); - dynamic jsonData = JsonConvert.DeserializeObject(jsonString); - if (jsonData != null) - { - existingYoutubeThemeUrl = jsonData.youtube_theme_url; - } - } - - // get tmdb id - var tmdb = movie.GetProviderId(MetadataProvider.Tmdb); - // create themerrdb_link - var themerrDbLink = $"https://app.lizardbyte.dev/ThemerrDB/movies/themoviedb/{tmdb}.json"; - - // download themerrdb_link as a json object - var client = new HttpClient(); - try - { - var jsonString = client.GetStringAsync(themerrDbLink).Result; - // serialize the json object - dynamic jsonData = JsonConvert.DeserializeObject(jsonString); - if (jsonData != null) - { - // extract the youtube_theme_url key (string) - string youtubeThemeUrl = jsonData.youtube_theme_url; - - // if youtubeThemeUrl is not equal to existingYoutubeThemeUrl then download - if (youtubeThemeUrl == existingYoutubeThemeUrl) - { - continue; - } - - _logger.LogDebug("Trying to download {movieName}, {youtubeThemeUrl}", - movie.Name, youtubeThemeUrl); - - try - { - SaveMp3(themePath, youtubeThemeUrl); - _logger.LogInformation("{movieName} theme song successfully downloaded", - movie.Name); - // create themerr.json (json object) with these keys, youtube_theme_url, downloaded_timestamp - var themerrData = new - { - downloaded_timestamp = DateTime.UtcNow, - youtube_theme_url = youtubeThemeUrl - }; - // write themerr.json to disk - System.IO.File.WriteAllText(themerrDataPath, JsonConvert.SerializeObject(themerrData)); - - // update the metadata - movie.RefreshMetadata(CancellationToken.None); - } - catch (Exception e) - { - _logger.LogError("Unable to download {movieName} theme song: {error}", - movie.Name, e); - } - } - else - { - _logger.LogInformation("{movieName} theme song not in database, or no internet connection", - movie.Name); - } - - } - catch (Exception) - { - _logger.LogInformation("{movieName} theme song not in database, or no internet connection", - movie.Name); - } + ProcessMovieTheme(movie); } + return Task.CompletedTask; } + /// + /// Downloads the theme song for a movie if it doesn't already exist + /// + /// + public void ProcessMovieTheme(Movie movie) + { + var themePath = GetThemePath(movie); + var themerrDataPath = GetThemerrDataPath(movie); + + if (ShouldSkipDownload(themePath, themerrDataPath)) + { + return; + } + + var existingYoutubeThemeUrl = GetExistingYoutubeThemeUrl(themerrDataPath); + + var tmdb = movie.GetProviderId(MetadataProvider.Tmdb); + var themerrDbLink = CreateThemerrDbLink(tmdb); + + var newYoutubeThemeUrl = GetNewYoutubeThemeUrl(themerrDbLink); + + if (string.IsNullOrEmpty(newYoutubeThemeUrl) || newYoutubeThemeUrl == existingYoutubeThemeUrl) + { + return; + } + + try + { + SaveMp3(themePath, newYoutubeThemeUrl); + SaveThemerrData(themerrDataPath, newYoutubeThemeUrl); + movie.RefreshMetadata(CancellationToken.None); + } + catch (Exception e) + { + _logger.LogError("Unable to download {MovieName} theme song: {Error}", movie.Name, e); + } + } + + /// + /// Checks if the theme song should be downloaded + /// + /// + /// + /// + public static bool ShouldSkipDownload(string themePath, string themerrDataPath) + { + return System.IO.File.Exists(themePath) && !System.IO.File.Exists(themerrDataPath); + } + /// + /// Gets the path to the theme song + /// + /// + /// + public static string GetThemePath(Movie movie) + { + return $"{movie.ContainingFolderPath}/theme.mp3"; + } + + /// + /// Gets the path to the themerr data file + /// + /// + /// + public static string GetThemerrDataPath(Movie movie) + { + return $"{movie.ContainingFolderPath}/themerr.json"; + } + + /// + /// Gets the existing youtube theme url from the themerr data file if it exists + /// + /// + /// + public static string GetExistingYoutubeThemeUrl(string themerrDataPath) + { + if (!System.IO.File.Exists(themerrDataPath)) + return string.Empty; + + var jsonString = System.IO.File.ReadAllText(themerrDataPath); + dynamic jsonData = JsonConvert.DeserializeObject(jsonString); + return jsonData?.youtube_theme_url; + } + + /// + /// Creates a link to the themerr database + /// + /// + /// + public static string CreateThemerrDbLink(string tmdb) + { + return $"https://app.lizardbyte.dev/ThemerrDB/movies/themoviedb/{tmdb}.json"; + } + + /// + /// Gets the youtube theme url from the themerr database + /// + /// + /// + public static string GetNewYoutubeThemeUrl(string themerrDbLink) + { + var client = new HttpClient(); + + try + { + var jsonString = client.GetStringAsync(themerrDbLink).Result; + dynamic jsonData = JsonConvert.DeserializeObject(jsonString); + return jsonData?.youtube_theme_url; + } + catch + { + // todo - fix logging + // _logger.LogInformation("Theme song not in database, or no internet connection"); + return string.Empty; + } + } + + /// + /// Saves the themerr data file + /// + /// + /// + public static void SaveThemerrData(string themerrDataPath, string youtubeThemeUrl) + { + var themerrData = new + { + downloaded_timestamp = DateTime.UtcNow, + youtube_theme_url = youtubeThemeUrl + }; + System.IO.File.WriteAllText(themerrDataPath, JsonConvert.SerializeObject(themerrData)); + } + + /// + /// Called when the plugin is loaded + /// private void OnTimerElapsed() { // Stop the timer until next update _timer.Change(Timeout.Infinite, Timeout.Infinite); } - + /// + /// Todo + /// + /// public Task RunAsync() { return Task.CompletedTask; } - + /// + /// Todo + /// public void Dispose() { } diff --git a/docs/source/contributing/testing.rst b/docs/source/contributing/testing.rst index 19632d04..52c36875 100644 --- a/docs/source/contributing/testing.rst +++ b/docs/source/contributing/testing.rst @@ -50,4 +50,4 @@ Themerr-jellyfin uses `xUnit `__ for unit Test with xUnit .. code-block:: bash - dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover + dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover --logger "console;verbosity=detailed"