diff --git a/frontend/src/Components/Form/PlaylistInput.js b/frontend/src/Components/Form/PlaylistInput.js index 022367e81..b8dc903d4 100644 --- a/frontend/src/Components/Form/PlaylistInput.js +++ b/frontend/src/Components/Form/PlaylistInput.js @@ -16,7 +16,7 @@ import styles from './PlaylistInput.css'; const columns = [ { name: 'name', - label: 'Playlist', + label: 'Bookshelf', isSortable: false, isVisible: true } @@ -125,7 +125,7 @@ class PlaylistInput extends Component { { isPopulated && !isFetching && user && !!items.length &&
- Select playlists to import from Goodreads user {user}. + Select bookshelves to import from Goodreads user {user}. public virtual string RequestUrl { get; set; } + public virtual Dictionary Parameters { get; set; } #if !WINRT public string GetAuthorizationHeader(NameValueCollection parameters) diff --git a/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs index c1898e7d5..a819108e1 100644 --- a/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs +++ b/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs @@ -43,7 +43,12 @@ namespace NzbDrone.Core.Test.ImportListTests .Returns(x => Builder .CreateListOfSize(1) .TheFirst(1) - .With(b => b.ForeignBookId = x.ToString()) + .With(b => b.Editions = Builder + .CreateListOfSize(1) + .TheFirst(1) + .With(e => e.ForeignEditionId = x.ToString()) + .With(e => e.Monitored = true) + .BuildList()) .BuildList()); Mocker.GetMock() @@ -74,26 +79,26 @@ namespace NzbDrone.Core.Test.ImportListTests private void WithAuthorId() { - _importListReports.First().ArtistMusicBrainzId = "f59c5520-5f46-4d2c-b2c4-822eabf53419"; + _importListReports.First().AuthorGoodreadsId = "f59c5520-5f46-4d2c-b2c4-822eabf53419"; } private void WithBookId() { - _importListReports.First().AlbumMusicBrainzId = "101"; + _importListReports.First().EditionGoodreadsId = "101"; } private void WithExistingArtist() { Mocker.GetMock() - .Setup(v => v.FindById(_importListReports.First().ArtistMusicBrainzId)) - .Returns(new Author { ForeignAuthorId = _importListReports.First().ArtistMusicBrainzId }); + .Setup(v => v.FindById(_importListReports.First().AuthorGoodreadsId)) + .Returns(new Author { ForeignAuthorId = _importListReports.First().AuthorGoodreadsId }); } private void WithExistingAlbum() { Mocker.GetMock() - .Setup(v => v.FindById(_importListReports.First().AlbumMusicBrainzId)) - .Returns(new Book { ForeignBookId = _importListReports.First().AlbumMusicBrainzId }); + .Setup(v => v.FindById(_importListReports.First().EditionGoodreadsId)) + .Returns(new Book { ForeignBookId = _importListReports.First().EditionGoodreadsId }); } private void WithExcludedArtist() diff --git a/src/NzbDrone.Core.Test/MusicTests/AddAlbumFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AddAlbumFixture.cs index 821b4f4f2..a6d4d2d30 100644 --- a/src/NzbDrone.Core.Test/MusicTests/AddAlbumFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/AddAlbumFixture.cs @@ -23,10 +23,6 @@ namespace NzbDrone.Core.Test.MusicTests [SetUp] public void Setup() { - _fakeAlbum = Builder - .CreateNew() - .Build(); - _fakeArtist = Builder .CreateNew() .With(s => s.Path = null) @@ -36,6 +32,16 @@ namespace NzbDrone.Core.Test.MusicTests private void GivenValidAlbum(string readarrId) { + _fakeAlbum = Builder + .CreateNew() + .With(x => x.Editions = Builder + .CreateListOfSize(1) + .TheFirst(1) + .With(e => e.ForeignEditionId = readarrId) + .With(e => e.Monitored = true) + .BuildList()) + .Build(); + Mocker.GetMock() .Setup(s => s.GetBookInfo(readarrId)) .Returns(Tuple.Create(_fakeArtist.Metadata.Value.ForeignAuthorId, diff --git a/src/NzbDrone.Core/Books/Services/AddBookService.cs b/src/NzbDrone.Core/Books/Services/AddBookService.cs index 6cf37c2a4..a45bf1319 100644 --- a/src/NzbDrone.Core/Books/Services/AddBookService.cs +++ b/src/NzbDrone.Core/Books/Services/AddBookService.cs @@ -62,6 +62,7 @@ namespace NzbDrone.Core.Books // Note it's a manual addition so it's not deleted on next refresh book.AddOptions.AddType = BookAddType.Manual; + book.Editions.Value.Single(x => x.Monitored).ManualAdd = true; // Add the author if necessary var dbAuthor = _authorService.FindById(book.AuthorMetadata.Value.ForeignAuthorId); @@ -105,10 +106,11 @@ namespace NzbDrone.Core.Books private Book AddSkyhookData(Book newBook) { + var editionId = newBook.Editions.Value.Single(x => x.Monitored).ForeignEditionId; Tuple> tuple = null; try { - tuple = _bookInfo.GetBookInfo(newBook.Editions.Value.Single(x => x.Monitored).ForeignEditionId); + tuple = _bookInfo.GetBookInfo(editionId); } catch (BookNotFoundException) { @@ -123,6 +125,10 @@ namespace NzbDrone.Core.Books newBook.UseMetadataFrom(tuple.Item2); newBook.Added = DateTime.UtcNow; + newBook.Editions = tuple.Item2.Editions.Value; + newBook.Editions.Value.ForEach(x => x.Monitored = false); + newBook.Editions.Value.Single(x => x.ForeignEditionId == editionId).Monitored = true; + var metadata = tuple.Item3.Single(x => x.ForeignAuthorId == tuple.Item1); newBook.AuthorMetadata = metadata; diff --git a/src/NzbDrone.Core/Books/Services/RefreshBookService.cs b/src/NzbDrone.Core/Books/Services/RefreshBookService.cs index 3340d1001..3a79b1dc8 100644 --- a/src/NzbDrone.Core/Books/Services/RefreshBookService.cs +++ b/src/NzbDrone.Core/Books/Services/RefreshBookService.cs @@ -255,6 +255,12 @@ namespace NzbDrone.Core.Books monitored = children.Future; } + if (monitored.Count == 0) + { + // there are no future children so nothing to do + return; + } + var toMonitor = monitored.OrderByDescending(x => _mediaFileService.GetFilesByEdition(x.Id).Count) .ThenByDescending(x => x.Ratings.Popularity) .First(); diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelf.cs b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelf.cs index ea576fd3f..68b0b3802 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelf.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsBookshelf.cs @@ -51,7 +51,7 @@ namespace NzbDrone.Core.ImportLists.Goodreads { Author = x.Book.Authors.First().Name.CleanSpaces(), Book = x.Book.TitleWithoutSeries.CleanSpaces(), - AlbumMusicBrainzId = x.Book.Uri.Replace("kca://book/", string.Empty) + EditionGoodreadsId = x.Book.Id.ToString() }).ToList(); } diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsImportListBase.cs b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsImportListBase.cs index 39c03be0d..90f7255ea 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsImportListBase.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsImportListBase.cs @@ -10,6 +10,7 @@ using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.OAuth; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MetadataSource.Goodreads; @@ -37,7 +38,6 @@ namespace NzbDrone.Core.ImportLists.Goodreads public string AccessToken => Settings.AccessToken; protected HttpRequestBuilder RequestBuilder() => new HttpRequestBuilder("https://www.goodreads.com/{route}") - .AddQueryParam("key", "xQh8LhdTztb9u3cL26RqVg", true) .AddQueryParam("_nc", "1") .KeepAlive(); @@ -75,7 +75,7 @@ namespace NzbDrone.Core.ImportLists.Goodreads throw new BadRequestException("QueryParam callbackUrl invalid."); } - var oAuthRequest = OAuthRequest.ForRequestToken(Settings.ConsumerKey, Settings.ConsumerSecret, query["callbackUrl"]); + var oAuthRequest = OAuthRequest.ForRequestToken(null, null, query["callbackUrl"]); oAuthRequest.RequestUrl = Settings.OAuthRequestTokenUrl; var qscoll = OAuthQuery(oAuthRequest); @@ -99,7 +99,7 @@ namespace NzbDrone.Core.ImportLists.Goodreads throw new BadRequestException("Missing requestTokenSecret."); } - var oAuthRequest = OAuthRequest.ForAccessToken(Settings.ConsumerKey, Settings.ConsumerSecret, query["oauth_token"], query["requestTokenSecret"], ""); + var oAuthRequest = OAuthRequest.ForAccessToken(null, null, query["oauth_token"], query["requestTokenSecret"], ""); oAuthRequest.RequestUrl = Settings.OAuthAccessTokenUrl; var qscoll = OAuthQuery(oAuthRequest); @@ -123,22 +123,41 @@ namespace NzbDrone.Core.ImportLists.Goodreads protected Common.Http.HttpResponse OAuthGet(HttpRequestBuilder builder) { - var auth = OAuthRequest.ForProtectedResource(builder.Method.ToString(), Settings.ConsumerKey, Settings.ConsumerSecret, Settings.AccessToken, Settings.AccessTokenSecret); + var auth = OAuthRequest.ForProtectedResource(builder.Method.ToString(), null, null, Settings.AccessToken, Settings.AccessTokenSecret); var request = builder.Build(); request.LogResponseContent = true; // we need the url without the query to sign auth.RequestUrl = request.Url.SetQuery(null).FullUri; + auth.Parameters = builder.QueryParams.ToDictionary(x => x.Key, x => x.Value); - var header = auth.GetAuthorizationHeader(builder.QueryParams.ToDictionary(x => x.Key, x => x.Value)); + var header = GetAuthorizationHeader(auth); request.Headers.Add("Authorization", header); + return _httpClient.Get(request); } + private string GetAuthorizationHeader(OAuthRequest oAuthRequest) + { + var request = new Common.Http.HttpRequest(Settings.SigningUrl) + { + Method = HttpMethod.POST, + }; + request.Headers.Set("Content-Type", "application/json"); + + var payload = oAuthRequest.ToJson(); + _logger.Trace(payload); + request.SetContent(payload); + + var response = _httpClient.Post(request).Resource; + + return response.Authorization; + } + private NameValueCollection OAuthQuery(OAuthRequest oAuthRequest) { - var auth = oAuthRequest.GetAuthorizationHeader(); + var auth = GetAuthorizationHeader(oAuthRequest); var request = new Common.Http.HttpRequest(oAuthRequest.RequestUrl); request.Headers.Add("Authorization", auth); var response = _httpClient.Get(request); @@ -148,9 +167,7 @@ namespace NzbDrone.Core.ImportLists.Goodreads private Tuple GetUser() { - var builder = RequestBuilder() - .SetSegment("route", $"api/auth_user") - .AddQueryParam("key", Settings.ConsumerKey, true); + var builder = RequestBuilder().SetSegment("route", $"api/auth_user"); var httpResponse = OAuthGet(builder); @@ -169,4 +186,9 @@ namespace NzbDrone.Core.ImportLists.Goodreads return Tuple.Create(userId, userName); } } + + public class AuthorizationHeader + { + public string Authorization { get; set; } + } } diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsOwnedBooks.cs b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsOwnedBooks.cs index 5f38d9387..eaf3e7b5e 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsOwnedBooks.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsOwnedBooks.cs @@ -49,9 +49,9 @@ namespace NzbDrone.Core.ImportLists.Goodreads var result = reviews.Select(x => new ImportListItemInfo { Author = x.Book.Authors.First().Name.CleanSpaces(), - ArtistMusicBrainzId = x.Book.Authors.First().Id.ToString(), + AuthorGoodreadsId = x.Book.Authors.First().Id.ToString(), Book = x.Book.TitleWithoutSeries.CleanSpaces(), - AlbumMusicBrainzId = x.Book.Id.ToString() + EditionGoodreadsId = x.Book.Id.ToString() }).ToList(); return CleanupListItems(result); diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsSettingsBase.cs b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsSettingsBase.cs index 87eb32d60..777769f76 100644 --- a/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsSettingsBase.cs +++ b/src/NzbDrone.Core/ImportLists/Goodreads/GoodreadsSettingsBase.cs @@ -24,8 +24,7 @@ namespace NzbDrone.Core.ImportLists.Goodreads public string BaseUrl { get; set; } - public string ConsumerKey => "xQh8LhdTztb9u3cL26RqVg"; - public string ConsumerSecret => "96aDA1lJRcS8KofYbw2jjkRk3wTNKypHAL2GeOgbPZw"; + public string SigningUrl => "https://auth.servarr.com/v1/goodreads/sign"; public string OAuthUrl => "https://www.goodreads.com/oauth/authorize"; public string OAuthRequestTokenUrl => "https://www.goodreads.com/oauth/request_token"; public string OAuthAccessTokenUrl => "https://www.goodreads.com/oauth/access_token"; diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs index 846684800..a100ae2bb 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs @@ -97,18 +97,18 @@ namespace NzbDrone.Core.ImportLists var importList = _importListFactory.Get(report.ImportListId); - if (report.Book.IsNotNullOrWhiteSpace() || report.AlbumMusicBrainzId.IsNotNullOrWhiteSpace()) + if (report.Book.IsNotNullOrWhiteSpace() || report.EditionGoodreadsId.IsNotNullOrWhiteSpace()) { - if (report.AlbumMusicBrainzId.IsNullOrWhiteSpace() || report.ArtistMusicBrainzId.IsNullOrWhiteSpace()) + if (report.EditionGoodreadsId.IsNullOrWhiteSpace() || report.AuthorGoodreadsId.IsNullOrWhiteSpace()) { MapAlbumReport(report); } ProcessAlbumReport(importList, report, listExclusions, albumsToAdd); } - else if (report.Author.IsNotNullOrWhiteSpace() || report.ArtistMusicBrainzId.IsNotNullOrWhiteSpace()) + else if (report.Author.IsNotNullOrWhiteSpace() || report.AuthorGoodreadsId.IsNotNullOrWhiteSpace()) { - if (report.ArtistMusicBrainzId.IsNullOrWhiteSpace()) + if (report.AuthorGoodreadsId.IsNullOrWhiteSpace()) { MapArtistReport(report); } @@ -137,9 +137,10 @@ namespace NzbDrone.Core.ImportLists { Book mappedAlbum; - if (report.AlbumMusicBrainzId.IsNotNullOrWhiteSpace() && int.TryParse(report.AlbumMusicBrainzId, out var goodreadsId)) + if (report.EditionGoodreadsId.IsNotNullOrWhiteSpace() && int.TryParse(report.EditionGoodreadsId, out var goodreadsId)) { - mappedAlbum = _bookSearchService.SearchByGoodreadsId(goodreadsId).FirstOrDefault(x => int.TryParse(x.ForeignBookId, out var bookId) && bookId == goodreadsId); + var search = _bookSearchService.SearchByGoodreadsId(goodreadsId); + mappedAlbum = search.FirstOrDefault(x => x.Editions.Value.Any(e => int.TryParse(e.ForeignEditionId, out var editionId) && editionId == goodreadsId)); } else { @@ -149,62 +150,71 @@ namespace NzbDrone.Core.ImportLists // Break if we are looking for an book and cant find it. This will avoid us from adding the author and possibly getting it wrong. if (mappedAlbum == null) { - _logger.Trace($"Nothing found for {report.AlbumMusicBrainzId}"); - report.AlbumMusicBrainzId = null; + _logger.Trace($"Nothing found for {report.EditionGoodreadsId}"); + report.EditionGoodreadsId = null; return; } - _logger.Trace($"Mapped {report.AlbumMusicBrainzId} to {mappedAlbum}"); + _logger.Trace($"Mapped {report.EditionGoodreadsId} to {mappedAlbum}"); - report.AlbumMusicBrainzId = mappedAlbum.ForeignBookId; + report.EditionGoodreadsId = mappedAlbum.Editions.Value.Single(x => x.Monitored).ForeignEditionId; + report.BookGoodreadsId = mappedAlbum.ForeignBookId; report.Book = mappedAlbum.Title; report.Author = mappedAlbum.AuthorMetadata?.Value?.Name; - report.ArtistMusicBrainzId = mappedAlbum.AuthorMetadata?.Value?.ForeignAuthorId; + report.AuthorGoodreadsId = mappedAlbum.AuthorMetadata?.Value?.ForeignAuthorId; } private void ProcessAlbumReport(ImportListDefinition importList, ImportListItemInfo report, List listExclusions, List albumsToAdd) { - if (report.AlbumMusicBrainzId == null) + if (report.EditionGoodreadsId == null) { return; } // Check to see if book in DB - var existingAlbum = _bookService.FindById(report.AlbumMusicBrainzId); + var existingAlbum = _bookService.FindById(report.EditionGoodreadsId); if (existingAlbum != null) { - _logger.Debug("{0} [{1}] Rejected, Book Exists in DB", report.AlbumMusicBrainzId, report.Book); + _logger.Debug("{0} [{1}] Rejected, Book Exists in DB", report.EditionGoodreadsId, report.Book); return; } // Check to see if book excluded - var excludedAlbum = listExclusions.SingleOrDefault(s => s.ForeignId == report.AlbumMusicBrainzId); + var excludedAlbum = listExclusions.SingleOrDefault(s => s.ForeignId == report.EditionGoodreadsId); if (excludedAlbum != null) { - _logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.AlbumMusicBrainzId, report.Book); + _logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.EditionGoodreadsId, report.Book); return; } // Check to see if author excluded - var excludedArtist = listExclusions.SingleOrDefault(s => s.ForeignId == report.ArtistMusicBrainzId); + var excludedArtist = listExclusions.SingleOrDefault(s => s.ForeignId == report.AuthorGoodreadsId); if (excludedArtist != null) { - _logger.Debug("{0} [{1}] Rejected due to list exlcusion for parent author", report.AlbumMusicBrainzId, report.Book); + _logger.Debug("{0} [{1}] Rejected due to list exlcusion for parent author", report.EditionGoodreadsId, report.Book); return; } // Append Album if not already in DB or already on add list - if (albumsToAdd.All(s => s.ForeignBookId != report.AlbumMusicBrainzId)) + if (albumsToAdd.All(s => s.ForeignBookId != report.EditionGoodreadsId)) { var monitored = importList.ShouldMonitor != ImportListMonitorType.None; var toAdd = new Book { - ForeignBookId = report.AlbumMusicBrainzId, + ForeignBookId = report.BookGoodreadsId, Monitored = monitored, + Editions = new List + { + new Edition + { + ForeignEditionId = report.EditionGoodreadsId, + Monitored = true + } + }, Author = new Author { Monitored = monitored, @@ -234,37 +244,37 @@ namespace NzbDrone.Core.ImportLists { var mappedArtist = _authorSearchService.SearchForNewAuthor(report.Author) .FirstOrDefault(); - report.ArtistMusicBrainzId = mappedArtist?.Metadata.Value?.ForeignAuthorId; + report.AuthorGoodreadsId = mappedArtist?.Metadata.Value?.ForeignAuthorId; report.Author = mappedArtist?.Metadata.Value?.Name; } private void ProcessArtistReport(ImportListDefinition importList, ImportListItemInfo report, List listExclusions, List artistsToAdd) { - if (report.ArtistMusicBrainzId == null) + if (report.AuthorGoodreadsId == null) { return; } // Check to see if author in DB - var existingArtist = _authorService.FindById(report.ArtistMusicBrainzId); + var existingArtist = _authorService.FindById(report.AuthorGoodreadsId); if (existingArtist != null) { - _logger.Debug("{0} [{1}] Rejected, Author Exists in DB", report.ArtistMusicBrainzId, report.Author); + _logger.Debug("{0} [{1}] Rejected, Author Exists in DB", report.AuthorGoodreadsId, report.Author); return; } // Check to see if author excluded - var excludedArtist = listExclusions.Where(s => s.ForeignId == report.ArtistMusicBrainzId).SingleOrDefault(); + var excludedArtist = listExclusions.Where(s => s.ForeignId == report.AuthorGoodreadsId).SingleOrDefault(); if (excludedArtist != null) { - _logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.ArtistMusicBrainzId, report.Author); + _logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.AuthorGoodreadsId, report.Author); return; } // Append Author if not already in DB or already on add list - if (artistsToAdd.All(s => s.Metadata.Value.ForeignAuthorId != report.ArtistMusicBrainzId)) + if (artistsToAdd.All(s => s.Metadata.Value.ForeignAuthorId != report.AuthorGoodreadsId)) { var monitored = importList.ShouldMonitor != ImportListMonitorType.None; @@ -272,7 +282,7 @@ namespace NzbDrone.Core.ImportLists { Metadata = new AuthorMetadata { - ForeignAuthorId = report.ArtistMusicBrainzId, + ForeignAuthorId = report.AuthorGoodreadsId, Name = report.Author }, Monitored = monitored, diff --git a/src/NzbDrone.Core/ImportLists/LazyLibrarian/LazyLibrarianImportParser.cs b/src/NzbDrone.Core/ImportLists/LazyLibrarian/LazyLibrarianImportParser.cs index 9b18580a8..0f5421edb 100644 --- a/src/NzbDrone.Core/ImportLists/LazyLibrarian/LazyLibrarianImportParser.cs +++ b/src/NzbDrone.Core/ImportLists/LazyLibrarian/LazyLibrarianImportParser.cs @@ -36,7 +36,7 @@ namespace NzbDrone.Core.ImportLists.LazyLibrarianImport { Author = item.AuthorName, Book = item.BookName, - AlbumMusicBrainzId = item.BookId + EditionGoodreadsId = item.BookId }); } diff --git a/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs b/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs index 16c0762bc..08b0a7f21 100644 --- a/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs @@ -7,9 +7,10 @@ namespace NzbDrone.Core.Parser.Model public int ImportListId { get; set; } public string ImportList { get; set; } public string Author { get; set; } - public string ArtistMusicBrainzId { get; set; } + public string AuthorGoodreadsId { get; set; } public string Book { get; set; } - public string AlbumMusicBrainzId { get; set; } + public string BookGoodreadsId { get; set; } + public string EditionGoodreadsId { get; set; } public DateTime ReleaseDate { get; set; } public override string ToString()