diff --git a/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs b/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs index f5f444d..eb1fdd8 100644 --- a/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs +++ b/src/MusicCatalogue.Entities/CommandLine/CommandLineOption.cs @@ -6,6 +6,7 @@ namespace MusicCatalogue.Entities.CommandLine public class CommandLineOption { public CommandLineOptionType OptionType { get; set; } + public bool IsOperation { get; set; } public string Name { get; set; } = ""; public string ShortName { get; set; } = ""; public string Description { get; set; } = ""; diff --git a/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs index 3c0c78b..4c0442e 100644 --- a/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs +++ b/src/MusicCatalogue.Entities/CommandLine/CommandLineOptionType.cs @@ -4,5 +4,6 @@ public enum CommandLineOptionType { Unknown, Lookup, + Import, } } diff --git a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs index d617a4b..30c49ca 100644 --- a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs +++ b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs @@ -51,7 +51,7 @@ public static FlattenedTrack FromCsv(string record) // Split the duration on the ":" separator and convert to milliseconds var durationWords = words[DurationField][..^1].Split(new string[] { ":" }, StringSplitOptions.None); - var durationMs = 1000 * (60 * int.Parse(durationWords[0]) + 1000 * int.Parse(durationWords[1])); + var durationMs = 1000 * (60 * int.Parse(durationWords[0]) + int.Parse(durationWords[1])); // Create a new "flattened" record containing artist, album and track details return new FlattenedTrack diff --git a/src/MusicCatalogue.Entities/Exceptions/MultipleOperationsException.cs b/src/MusicCatalogue.Entities/Exceptions/MultipleOperationsException.cs new file mode 100644 index 0000000..20d777d --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/MultipleOperationsException.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace MusicCatalogue.Entities.Exceptions +{ + [Serializable] + [ExcludeFromCodeCoverage] + public class MultipleOperationsException : Exception + { + public MultipleOperationsException() + { + } + + public MultipleOperationsException(string message) : base(message) + { + } + + public MultipleOperationsException(string message, Exception inner) : base(message, inner) + { + } + + protected MultipleOperationsException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} + diff --git a/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs b/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs index f2a5bdb..e43a5d0 100644 --- a/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs +++ b/src/MusicCatalogue.Entities/Interfaces/ICsvImporter.cs @@ -1,7 +1,10 @@ -namespace MusicCatalogue.Entities.Interfaces +using MusicCatalogue.Entities.DataExchange; + +namespace MusicCatalogue.Entities.Interfaces { public interface ICsvImporter { + event EventHandler? TrackImport; Task Import(string file); } } \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs index cc1e71f..2ac4b72 100644 --- a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs @@ -6,5 +6,6 @@ public interface IMusicCatalogueFactory IArtistManager Artists { get; } ITrackManager Tracks { get; } IUserManager Users { get; } + ICsvImporter Importer { get; } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs b/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs index b8aed6f..5f0cd17 100644 --- a/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs +++ b/src/MusicCatalogue.Logic/CommandLine/CommandLineParser.cs @@ -12,12 +12,13 @@ public class CommandLineParser /// Add an option to the available command line options /// /// + /// /// /// /// /// /// - public void Add(CommandLineOptionType optionType, string name, string shortName, string description, int minimumNumberOfValues, int maximumNumberOfValues) + public void Add(CommandLineOptionType optionType, bool isOperation, string name, string shortName, string description, int minimumNumberOfValues, int maximumNumberOfValues) { // Check the option's not a duplicate if (_options.Select(x => x.OptionType).Contains(optionType)) @@ -41,6 +42,7 @@ public void Add(CommandLineOptionType optionType, string name, string shortName, _options.Add(new CommandLineOption { OptionType = optionType, + IsOperation = isOperation, Name = name, ShortName = shortName, Description = description, @@ -61,6 +63,11 @@ public void Parse(IEnumerable args) // Check that all arguments have the required number of values CheckForMinimumValues(); + + // Check that there's only one argument that defines an operation. For example, + // looking up an album's details is one operation, importing a CSV file is another. + // Both can't be supplied at the same time + CheckForSingleOperation(); } /// @@ -96,6 +103,19 @@ private void CheckForMinimumValues() } } + /// + /// Check there's only one operation specified on the command line + /// + private void CheckForSingleOperation() + { + IEnumerable options = _options.Where(x => _values!.ContainsKey(x.OptionType) && x.IsOperation); + if (options!.Count() > 1) + { + var message = $"Command line specifies multiple operations: {string.Join(", ", options.Select(x => x.ToString()))}"; + throw new MultipleOperationsException(message); + } + } + /// /// Build the value list from the command line /// diff --git a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs index ca960d6..8c770d8 100644 --- a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs +++ b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs @@ -13,7 +13,7 @@ public partial class CsvImporter : ICsvImporter { private readonly MusicCatalogueFactory _factory; - public EventHandler RecordImport; + public event EventHandler? TrackImport; #pragma warning disable CS8618 internal CsvImporter(MusicCatalogueFactory factory) @@ -60,13 +60,15 @@ public async Task Import(string file) throw new InvalidRecordFormatException(message); } - // Inflate the CSV record to a track and store it in the database + // Inflate the CSV record to a track and save the artist FlattenedTrack track = FlattenedTrack.FromCsv(line); var artist = await _factory.Artists.AddAsync(track.ArtistName); + + // See if the album exists var album = await _factory.Albums.AddAsync(artist.Id, track.AlbumTitle, track.Released, track.Genre, track.CoverUrl); await _factory.Tracks.AddAsync(album.Id, track.Title, track.TrackNumber, track.Duration); - RecordImport?.Invoke(this, new TrackDataExchangeEventArgs { RecordCount = count - 1, Track = track }); + TrackImport?.Invoke(this, new TrackDataExchangeEventArgs { RecordCount = count - 1, Track = track }); } } } diff --git a/src/MusicCatalogue.LookupTool/Logic/AlbumLookup.cs b/src/MusicCatalogue.LookupTool/Logic/AlbumLookup.cs new file mode 100644 index 0000000..8f6caa8 --- /dev/null +++ b/src/MusicCatalogue.LookupTool/Logic/AlbumLookup.cs @@ -0,0 +1,81 @@ +using MusicCatalogue.Entities.Config; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Logic.Api; +using MusicCatalogue.Logic.Api.TheAudioDB; +using MusicCatalogue.Logic.Collection; +using MusicCatalogue.Logic.Database; + +namespace MusicCatalogue.LookupTool.Logic +{ + internal class AlbumLookup + { + private readonly IMusicLogger _logger; + private readonly MusicApplicationSettings _settings; + private readonly IMusicCatalogueFactory _factory; + + public AlbumLookup(IMusicLogger logger, IMusicCatalogueFactory factory, MusicApplicationSettings settings) + { + _logger = logger; + _settings = settings; + _factory = factory; + } + + /// + /// Lookup an album given the artist name and album title + /// + /// + /// + public async Task LookupAlbum(string artistName, string albumTitle) + { + // Get the API key and the URLs for the album and track lookup endpoints + var key = _settings!.ApiServiceKeys.Find(x => x.Service == ApiServiceType.TheAudioDB)!.Key; + var albumsEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Albums)!.Url; + var tracksEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Tracks)!.Url; + + // Convert the URL into a URI instance that will expose the host name - this is needed + // to set up the client headers + var uri = new Uri(albumsEndpoint); + + // Configure an HTTP client + var client = MusicHttpClient.Instance; + client.AddHeader("X-RapidAPI-Key", key); + client.AddHeader("X-RapidAPI-Host", uri.Host); + + // Configure the APIs + var albumsApi = new TheAudioDBAlbumsApi(_logger, client, albumsEndpoint); + var tracksApi = new TheAudioDBTracksApi(_logger, client, tracksEndpoint); + var lookupManager = new AlbumLookupManager(_logger, albumsApi, tracksApi, _factory); + + // Lookup the album and its tracks + var album = await lookupManager.LookupAlbum(artistName, albumTitle); + if (album != null) + { + // Dump the album details + Console.WriteLine($"Title: {album.Title}"); + Console.WriteLine($"Artist: {StringCleaner.Clean(artistName)}"); + Console.WriteLine($"Released: {album.Released}"); + Console.WriteLine($"Genre: {album.Genre}"); + Console.WriteLine($"Cover: {album.CoverUrl}"); + Console.WriteLine(); + + // Dump the track list + if ((album.Tracks != null) && (album.Tracks.Count > 0)) + { + foreach (var track in album.Tracks) + { + Console.WriteLine($"{track.Number} : {track.Title}, {track.FormattedDuration()}"); + } + Console.WriteLine(); + } + else + { + Console.WriteLine("No tracks found"); + } + } + else + { + Console.WriteLine("Album details not found"); + } + } + } +} diff --git a/src/MusicCatalogue.LookupTool/Logic/DataImport.cs b/src/MusicCatalogue.LookupTool/Logic/DataImport.cs new file mode 100644 index 0000000..b000318 --- /dev/null +++ b/src/MusicCatalogue.LookupTool/Logic/DataImport.cs @@ -0,0 +1,58 @@ +using MusicCatalogue.Entities.DataExchange; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Entities.Logging; + +namespace MusicCatalogue.LookupTool.Logic +{ + internal class DataImport + { + private readonly IMusicLogger _logger; + private readonly IMusicCatalogueFactory _factory; + + public DataImport(IMusicLogger logger, IMusicCatalogueFactory factory) + { + _logger = logger; + _factory = factory; + } + + /// + /// Import the data held in the specified CSV file + /// + /// + public void Import(string file) + { + _logger.LogMessage(Severity.Info, $"Importing {file} ..."); + + try + { + // Register a handler for the "track imported" event and import the file + _factory.Importer.TrackImport += OnTrackImported; + _factory.Importer.Import(file); + } + catch (Exception ex) + { + Console.WriteLine($"Import error: {ex.Message}"); + _logger.LogMessage(Severity.Info, $"Import error: {ex.Message}"); + _logger.LogException(ex); + } + finally + { + // Un-register the event handler + _factory.Importer.TrackImport -= OnTrackImported; + } + } + + /// + /// Handler called when a track is imported + /// + /// + /// + public void OnTrackImported(object? sender, TrackDataExchangeEventArgs e) + { + if (e.Track != null) + { + Console.WriteLine($"Imported {e.Track.ArtistName} {e.Track.AlbumTitle} {e.Track.TrackNumber} {e.Track.Title}"); + } + } + } +} diff --git a/src/MusicCatalogue.LookupTool/Program.cs b/src/MusicCatalogue.LookupTool/Program.cs index 5257df2..353d5dc 100644 --- a/src/MusicCatalogue.LookupTool/Program.cs +++ b/src/MusicCatalogue.LookupTool/Program.cs @@ -2,14 +2,11 @@ using MusicCatalogue.Entities.CommandLine; using MusicCatalogue.Entities.Config; using MusicCatalogue.Entities.Logging; -using MusicCatalogue.Logic.Api; -using MusicCatalogue.Logic.Api.TheAudioDB; -using MusicCatalogue.Logic.Collection; using MusicCatalogue.Logic.CommandLine; using MusicCatalogue.Logic.Config; -using MusicCatalogue.Logic.Database; using MusicCatalogue.Logic.Factory; using MusicCatalogue.Logic.Logging; +using MusicCatalogue.LookupTool.Logic; using System.Diagnostics; using System.Reflection; @@ -17,11 +14,6 @@ namespace MusicCatalogue.LookupPoC { public static class Program { - private static CommandLineParser? _parser= null; - private static MusicApplicationSettings? _settings = null; - private static FileLogger? _logger = null; - private static MusicCatalogueFactory? _factory = null; - /// /// Application entry point /// @@ -30,16 +22,17 @@ public static class Program public static async Task Main(string[] args) { // Parse the command line - _parser = new(); - _parser.Add(CommandLineOptionType.Lookup, "--lookup", "-l", "Lookup an album and display its details", 2, 2); - _parser.Parse(args); + CommandLineParser parser = new(); + parser.Add(CommandLineOptionType.Lookup, true, "--lookup", "-l", "Lookup an album and display its details", 2, 2); + parser.Add(CommandLineOptionType.Import, true, "--import", "-i", "Import data from a CSV format file", 1, 1); + parser.Parse(args); // Read the application settings - _settings = new MusicCatalogueConfigReader().Read("appsettings.json"); + MusicApplicationSettings? settings = new MusicCatalogueConfigReader().Read("appsettings.json"); // Configure the log file - _logger = new FileLogger(); - _logger.Initialise(_settings!.LogFile, _settings.MinimumLogLevel); + FileLogger logger = new FileLogger(); + logger.Initialise(settings!.LogFile, settings.MinimumLogLevel); // Get the version number and application title Assembly assembly = Assembly.GetExecutingAssembly(); @@ -48,77 +41,27 @@ public static async Task Main(string[] args) // Log the startup messages Console.WriteLine($"{title}\n"); - _logger.LogMessage(Severity.Info, new string('=', 80)); - _logger.LogMessage(Severity.Info, title); + logger.LogMessage(Severity.Info, new string('=', 80)); + logger.LogMessage(Severity.Info, title); // Configure the business logic factory var context = new MusicCatalogueDbContextFactory().CreateDbContext(Array.Empty()); - _factory = new MusicCatalogueFactory(context); + MusicCatalogueFactory factory = new MusicCatalogueFactory(context); // If this is a lookup, look up the album details - var values = _parser.GetValues(CommandLineOptionType.Lookup); + var values = parser.GetValues(CommandLineOptionType.Lookup); if (values != null) { - await LookupAlbum(values[0], values[1]); + await new AlbumLookup(logger, factory, settings!).LookupAlbum(values[0], values[1]); } - } - - /// - /// Lookup an album given the artist name and album title - /// - /// - /// - private static async Task LookupAlbum(string artistName, string albumTitle) - { - // Get the API key and the URLs for the album and track lookup endpoints - var key = _settings!.ApiServiceKeys.Find(x => x.Service == ApiServiceType.TheAudioDB)!.Key; - var albumsEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Albums)!.Url; - var tracksEndpoint = _settings.ApiEndpoints.Find(x => x.EndpointType == ApiEndpointType.Tracks)!.Url; - - // Convert the URL into a URI instance that will expose the host name - this is needed - // to set up the client headers - var uri = new Uri(albumsEndpoint); - // Configure an HTTP client - var client = MusicHttpClient.Instance; - client.AddHeader("X-RapidAPI-Key", key); - client.AddHeader("X-RapidAPI-Host", uri.Host); - - // Configure the APIs - var albumsApi = new TheAudioDBAlbumsApi(_logger, client, albumsEndpoint); - var tracksApi = new TheAudioDBTracksApi(_logger, client, tracksEndpoint); - var lookupManager = new AlbumLookupManager(_logger, albumsApi, tracksApi, _factory); - - // Lookup the album and its tracks - var album = await lookupManager.LookupAlbum(artistName, albumTitle); - if (album != null) - { - // Dump the album details - Console.WriteLine($"Title: {album.Title}"); - Console.WriteLine($"Artist: {StringCleaner.Clean(artistName)}"); - Console.WriteLine($"Released: {album.Released}"); - Console.WriteLine($"Genre: {album.Genre}"); - Console.WriteLine($"Cover: {album.CoverUrl}"); - Console.WriteLine(); - - // Dump the track list - if ((album.Tracks != null) && (album.Tracks.Count > 0)) - { - foreach (var track in album.Tracks) - { - Console.WriteLine($"{track.Number} : {track.Title}, {track.FormattedDuration()}"); - } - Console.WriteLine(); - } - else - { - Console.WriteLine("No tracks found"); - } - } - else + // If this is an import, import data from the specified CSV file + values = parser.GetValues(CommandLineOptionType.Import); + if (values != null) { - Console.WriteLine("Album details not found"); + new DataImport(logger, factory).Import(values[0]); } } + } } \ No newline at end of file diff --git a/src/MusicCatalogue.LookupTool/Properties/launchSettings.json b/src/MusicCatalogue.LookupTool/Properties/launchSettings.json index 784481f..4df230f 100644 --- a/src/MusicCatalogue.LookupTool/Properties/launchSettings.json +++ b/src/MusicCatalogue.LookupTool/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "MusicCatalogue.LookupTool": { "commandName": "Project", - "commandLineArgs": "--lookup \"Dire Straits\" \"Brothers In Arms\"" + "commandLineArgs": "--import \"C:\\Temp\\2023-10-06.csv\"" } } } \ No newline at end of file diff --git a/src/MusicCatalogue.Tests/CommandLineParserTest.cs b/src/MusicCatalogue.Tests/CommandLineParserTest.cs index d8bc26d..186feec 100644 --- a/src/MusicCatalogue.Tests/CommandLineParserTest.cs +++ b/src/MusicCatalogue.Tests/CommandLineParserTest.cs @@ -15,7 +15,8 @@ public class CommandLineParserTest public void TestInitialise() { _parser = new CommandLineParser(); - _parser.Add(CommandLineOptionType.Lookup, "--lookup", "-l", "Lookup an album and display its details", 2, 2); + _parser.Add(CommandLineOptionType.Lookup, true, "--lookup", "-l", "Lookup an album and display its details", 2, 2); + _parser.Add(CommandLineOptionType.Import, true, "--import", "-i", "Import data from a CSV format file", 1, 1); } [TestMethod] @@ -88,21 +89,29 @@ public void MalformedCommandLineFailsTest() [ExpectedException(typeof(DuplicateOptionException))] public void DuplicateOptionTypeFailsTest() { - _parser!.Add(CommandLineOptionType.Lookup, "--other-lookup", "-ol", "Duplicate option type", 2, 2); + _parser!.Add(CommandLineOptionType.Lookup, true, "--other-lookup", "-ol", "Duplicate option type", 2, 2); } [TestMethod] [ExpectedException(typeof(DuplicateOptionException))] public void DuplicateOptionNameFailsTest() { - _parser!.Add(CommandLineOptionType.Unknown, "--lookup", "-ol", "Duplicate option name", 2, 2); + _parser!.Add(CommandLineOptionType.Unknown, true, "--lookup", "-ol", "Duplicate option name", 2, 2); } [TestMethod] [ExpectedException(typeof(DuplicateOptionException))] public void DuplicateOptionShortNameFailsTest() { - _parser!.Add(CommandLineOptionType.Unknown, "--other-lookup", "-l", "Duplicate option shortname", 2, 2); + _parser!.Add(CommandLineOptionType.Unknown, true, "--other-lookup", "-l", "Duplicate option shortname", 2, 2); + } + + [TestMethod] + [ExpectedException(typeof(MultipleOperationsException))] + public void MultipleOperationsFailsTest() + { + string[] args = new string[] { "--lookup", "The Beatles", "Let It Be", "--import", "a_file.csv" }; + _parser!.Parse(args); } } }