diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js index 9d67883af..512d87151 100644 --- a/frontend/src/App/AppUpdatedModalContent.js +++ b/frontend/src/App/AppUpdatedModalContent.js @@ -11,9 +11,43 @@ import UpdateChanges from 'System/Updates/UpdateChanges'; import translate from 'Utilities/String/translate'; import styles from './AppUpdatedModalContent.css'; +function mergeUpdates(items, version, prevVersion) { + let installedIndex = items.findIndex((u) => u.version === version); + let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion); + + if (installedIndex === -1) { + installedIndex = 0; + } + + if (installedPreviouslyIndex === -1) { + installedPreviouslyIndex = items.length; + } else if (installedPreviouslyIndex === installedIndex && items.size()) { + installedPreviouslyIndex += 1; + } + + const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); + + if (!appliedUpdates.length) { + return null; + } + + const appliedChanges = { new: [], fixed: [] }; + appliedUpdates.forEach((u) => { + if (u.changes) { + appliedChanges.new.push(... u.changes.new); + appliedChanges.fixed.push(... u.changes.fixed); + } + }); + + const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges }); + + return mergedUpdate; +} + function AppUpdatedModalContent(props) { const { version, + prevVersion, isPopulated, error, items, @@ -21,7 +55,7 @@ function AppUpdatedModalContent(props) { onModalClose } = props; - const update = items[0]; + const update = mergeUpdates(items, version, prevVersion); return ( @@ -89,6 +123,7 @@ function AppUpdatedModalContent(props) { AppUpdatedModalContent.propTypes = { version: PropTypes.string.isRequired, + prevVersion: PropTypes.string, isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js index 81e43cf19..97dd0aeb9 100644 --- a/frontend/src/App/AppUpdatedModalContentConnector.js +++ b/frontend/src/App/AppUpdatedModalContentConnector.js @@ -8,8 +8,9 @@ import AppUpdatedModalContent from './AppUpdatedModalContent'; function createMapStateToProps() { return createSelector( (state) => state.app.version, + (state) => state.app.prevVersion, (state) => state.system.updates, - (version, updates) => { + (version, prevVersion, updates) => { const { isPopulated, error, @@ -18,6 +19,7 @@ function createMapStateToProps() { return { version, + prevVersion, isPopulated, error, items diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js index b774e7a38..e7e86cf6f 100644 --- a/frontend/src/Store/Actions/appActions.js +++ b/frontend/src/Store/Actions/appActions.js @@ -117,6 +117,9 @@ export const reducers = createHandleActions({ }; if (state.version !== version) { + if (!state.prevVersion) { + newState.prevVersion = state.version; + } newState.isUpdated = true; } diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js index d40fee93c..97ed7967c 100644 --- a/frontend/src/System/Updates/Updates.js +++ b/frontend/src/System/Updates/Updates.js @@ -10,6 +10,7 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { icons, kinds } from 'Helpers/Props'; import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; import translate from 'Utilities/String/translate'; import UpdateChanges from './UpdateChanges'; import styles from './Updates.css'; @@ -32,6 +33,8 @@ class Updates extends Component { isDocker, updateMechanismMessage, shortDateFormat, + longDateFormat, + timeFormat, onInstallLatestPress } = this.props; @@ -138,7 +141,12 @@ class Updates extends Component {
{update.version}
-
{formatDate(update.releaseDate, shortDateFormat)}
+
+ {formatDate(update.releaseDate, shortDateFormat)} +
{ update.branch === 'master' ? @@ -155,11 +163,24 @@ class Updates extends Component { : null } + + { + update.version !== currentVersion && update.installedOn ? + : + null + }
{ @@ -222,6 +243,8 @@ Updates.propTypes = { updateMechanism: PropTypes.string, updateMechanismMessage: PropTypes.string, shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, onInstallLatestPress: PropTypes.func.isRequired }; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js index 7b5318991..343106d24 100644 --- a/frontend/src/System/Updates/UpdatesConnector.js +++ b/frontend/src/System/Updates/UpdatesConnector.js @@ -48,7 +48,9 @@ function createMapStateToProps() { isDocker: systemStatus.isDocker, updateMechanism: generalSettings.item.updateMechanism, updateMechanismMessage: status.packageUpdateMechanismMessage, - shortDateFormat: uiSettings.shortDateFormat + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat }; } ); diff --git a/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs b/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs index 3189935ed..7f58693e6 100644 --- a/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs +++ b/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs @@ -47,7 +47,7 @@ namespace NzbDrone.Core.Test.UpdateTests { const string branch = "develop"; UseRealHttp(); - var recent = Subject.GetRecentUpdates(branch, new Version(2, 0)); + var recent = Subject.GetRecentUpdates(branch, new Version(2, 0), null); var recentWithChanges = recent.Where(c => c.Changes != null); recent.Should().NotBeEmpty(); diff --git a/src/NzbDrone.Core/Datastore/Converters/SystemVersionConverter.cs b/src/NzbDrone.Core/Datastore/Converters/SystemVersionConverter.cs new file mode 100644 index 000000000..d532df430 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/SystemVersionConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Data; +using Dapper; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class SystemVersionConverter : SqlMapper.TypeHandler + { + public override Version Parse(object value) + { + if (value is string version) + { + return Version.Parse((string)value); + } + + return null; + } + + public override void SetValue(IDbDataParameter parameter, Version value) + { + parameter.Value = value.ToString(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/004_add_update_history.cs b/src/NzbDrone.Core/Datastore/Migration/004_add_update_history.cs new file mode 100644 index 000000000..4e2e3c751 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/004_add_update_history.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Data; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(004)] + public class add_update_history : NzbDroneMigrationBase + { + protected override void LogDbUpgrade() + { + Create.TableForModel("UpdateHistory") + .WithColumn("Date").AsDateTime().NotNullable().Indexed() + .WithColumn("Version").AsString().NotNullable() + .WithColumn("EventType").AsInt32().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 70e718ed3..f72cef336 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -18,6 +18,7 @@ using NzbDrone.Core.Notifications; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Update.History; using static Dapper.SqlMapper; namespace NzbDrone.Core.Datastore @@ -84,6 +85,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("ApplicationStatus").RegisterModel(); Mapper.Entity("CustomFilters").RegisterModel(); + Mapper.Entity("UpdateHistory").RegisterModel(); } private static void RegisterMappers() @@ -108,6 +110,7 @@ namespace NzbDrone.Core.Datastore SqlMapper.RemoveTypeMap(typeof(Guid?)); SqlMapper.AddTypeHandler(new GuidConverter()); SqlMapper.AddTypeHandler(new CommandConverter()); + SqlMapper.AddTypeHandler(new SystemVersionConverter()); } private static void RegisterProviderSettingConverter() diff --git a/src/NzbDrone.Core/Update/Events/UpdateInstalledEvent.cs b/src/NzbDrone.Core/Update/Events/UpdateInstalledEvent.cs new file mode 100644 index 000000000..29290b17f --- /dev/null +++ b/src/NzbDrone.Core/Update/Events/UpdateInstalledEvent.cs @@ -0,0 +1,17 @@ +using System; +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Update.History.Events +{ + public class UpdateInstalledEvent : IEvent + { + public Version PreviousVerison { get; set; } + public Version NewVersion { get; set; } + + public UpdateInstalledEvent(Version previousVersion, Version newVersion) + { + PreviousVerison = previousVersion; + NewVersion = newVersion; + } + } +} diff --git a/src/NzbDrone.Core/Update/History/UpdateHistory.cs b/src/NzbDrone.Core/Update/History/UpdateHistory.cs new file mode 100644 index 000000000..62a3a6fc0 --- /dev/null +++ b/src/NzbDrone.Core/Update/History/UpdateHistory.cs @@ -0,0 +1,12 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Update.History +{ + public class UpdateHistory : ModelBase + { + public DateTime Date { get; set; } + public Version Version { get; set; } + public UpdateHistoryEventType EventType { get; set; } + } +} diff --git a/src/NzbDrone.Core/Update/History/UpdateHistoryEventType.cs b/src/NzbDrone.Core/Update/History/UpdateHistoryEventType.cs new file mode 100644 index 000000000..2ad99d624 --- /dev/null +++ b/src/NzbDrone.Core/Update/History/UpdateHistoryEventType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Update.History +{ + public enum UpdateHistoryEventType + { + Unknown = 0, + Initiated = 1, + Installed = 2 + } +} diff --git a/src/NzbDrone.Core/Update/History/UpdateHistoryRepository.cs b/src/NzbDrone.Core/Update/History/UpdateHistoryRepository.cs new file mode 100644 index 000000000..527f6d655 --- /dev/null +++ b/src/NzbDrone.Core/Update/History/UpdateHistoryRepository.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Update.History +{ + public interface IUpdateHistoryRepository : IBasicRepository + { + UpdateHistory LastInstalled(); + UpdateHistory PreviouslyInstalled(); + List InstalledSince(DateTime dateTime); + } + + public class UpdateHistoryRepository : BasicRepository, IUpdateHistoryRepository + { + public UpdateHistoryRepository(ILogDatabase logDatabase, IEventAggregator eventAggregator) + : base(logDatabase, eventAggregator) + { + } + + public UpdateHistory LastInstalled() + { + var history = Query(x => x.EventType == UpdateHistoryEventType.Installed) + .OrderBy(v => v.Date) + .Take(1) + .FirstOrDefault(); + + return history; + } + + public UpdateHistory PreviouslyInstalled() + { + var history = Query(x => x.EventType == UpdateHistoryEventType.Installed) + .OrderBy(v => v.Date) + .Skip(1) + .Take(1) + .FirstOrDefault(); + + return history; + } + + public List InstalledSince(DateTime dateTime) + { + var history = Query(v => v.EventType == UpdateHistoryEventType.Installed && v.Date >= dateTime) + .OrderBy(v => v.Date) + .ToList(); + + return history; + } + } +} diff --git a/src/NzbDrone.Core/Update/History/UpdateHistoryService.cs b/src/NzbDrone.Core/Update/History/UpdateHistoryService.cs new file mode 100644 index 000000000..8d11ea054 --- /dev/null +++ b/src/NzbDrone.Core/Update/History/UpdateHistoryService.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Update.History.Events; + +namespace NzbDrone.Core.Update.History +{ + public interface IUpdateHistoryService + { + Version PreviouslyInstalled(); + List InstalledSince(DateTime dateTime); + } + + public class UpdateHistoryService : IUpdateHistoryService, IHandle, IHandleAsync + { + private readonly IUpdateHistoryRepository _repository; + private readonly IEventAggregator _eventAggregator; + private Version _prevVersion; + + public UpdateHistoryService(IUpdateHistoryRepository repository, IEventAggregator eventAggregator) + { + _repository = repository; + _eventAggregator = eventAggregator; + } + + public Version PreviouslyInstalled() + { + var history = _repository.PreviouslyInstalled(); + + return history?.Version; + } + + public List InstalledSince(DateTime dateTime) + { + return _repository.InstalledSince(dateTime); + } + + public void Handle(ApplicationStartedEvent message) + { + if (BuildInfo.Version.Major == 10) + { + // Don't save dev versions, they change constantly + return; + } + + var history = _repository.LastInstalled(); + + if (history == null || history.Version != BuildInfo.Version) + { + _prevVersion = history.Version; + + _repository.Insert(new UpdateHistory + { + Date = DateTime.UtcNow, + Version = BuildInfo.Version, + EventType = UpdateHistoryEventType.Installed + }); + } + } + + public void HandleAsync(ApplicationStartedEvent message) + { + if (_prevVersion != null) + { + _eventAggregator.PublishEvent(new UpdateInstalledEvent(_prevVersion, BuildInfo.Version)); + } + } + } +} diff --git a/src/NzbDrone.Core/Update/RecentUpdateProvider.cs b/src/NzbDrone.Core/Update/RecentUpdateProvider.cs index 6fcaf42c2..42b338f5f 100644 --- a/src/NzbDrone.Core/Update/RecentUpdateProvider.cs +++ b/src/NzbDrone.Core/Update/RecentUpdateProvider.cs @@ -1,6 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Update.History; namespace NzbDrone.Core.Update { @@ -13,18 +15,23 @@ namespace NzbDrone.Core.Update { private readonly IConfigFileProvider _configFileProvider; private readonly IUpdatePackageProvider _updatePackageProvider; + private readonly IUpdateHistoryService _updateHistoryService; public RecentUpdateProvider(IConfigFileProvider configFileProvider, - IUpdatePackageProvider updatePackageProvider) + IUpdatePackageProvider updatePackageProvider, + IUpdateHistoryService updateHistoryService) { _configFileProvider = configFileProvider; _updatePackageProvider = updatePackageProvider; + _updateHistoryService = updateHistoryService; } public List GetRecentUpdatePackages() { var branch = _configFileProvider.Branch; - return _updatePackageProvider.GetRecentUpdates(branch, BuildInfo.Version); + var version = BuildInfo.Version; + var prevVersion = _updateHistoryService.PreviouslyInstalled(); + return _updatePackageProvider.GetRecentUpdates(branch, version, prevVersion); } } } diff --git a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs index ec0c0b4bf..f441d2901 100644 --- a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs +++ b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs @@ -11,7 +11,7 @@ namespace NzbDrone.Core.Update public interface IUpdatePackageProvider { UpdatePackage GetLatestUpdate(string branch, Version currentVersion); - List GetRecentUpdates(string branch, Version currentVersion); + List GetRecentUpdates(string branch, Version currentVersion, Version previousVersion = null); } public class UpdatePackageProvider : IUpdatePackageProvider @@ -56,7 +56,7 @@ namespace NzbDrone.Core.Update return update.UpdatePackage; } - public List GetRecentUpdates(string branch, Version currentVersion) + public List GetRecentUpdates(string branch, Version currentVersion, Version previousVersion) { var request = _requestBuilder.Create() .Resource("/update/{branch}/changes") @@ -67,6 +67,11 @@ namespace NzbDrone.Core.Update .AddQueryParam("runtimeVer", _platformInfo.Version) .SetSegment("branch", branch); + if (previousVersion != null && previousVersion != currentVersion) + { + request.AddQueryParam("prevVersion", previousVersion); + } + if (_analyticsService.IsEnabled) { // Send if the system is active so we know which versions to deprecate/ignore diff --git a/src/Prowlarr.Api.V1/Update/UpdateController.cs b/src/Prowlarr.Api.V1/Update/UpdateController.cs index 168d40679..30d99e549 100644 --- a/src/Prowlarr.Api.V1/Update/UpdateController.cs +++ b/src/Prowlarr.Api.V1/Update/UpdateController.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Update; +using NzbDrone.Core.Update.History; using Prowlarr.Http; namespace Prowlarr.Api.V1.Update @@ -11,10 +13,12 @@ namespace Prowlarr.Api.V1.Update public class UpdateController : Controller { private readonly IRecentUpdateProvider _recentUpdateProvider; + private readonly IUpdateHistoryService _updateHistoryService; - public UpdateController(IRecentUpdateProvider recentUpdateProvider) + public UpdateController(IRecentUpdateProvider recentUpdateProvider, IUpdateHistoryService updateHistoryService) { _recentUpdateProvider = recentUpdateProvider; + _updateHistoryService = updateHistoryService; } [HttpGet] @@ -40,6 +44,18 @@ namespace Prowlarr.Api.V1.Update { installed.Installed = true; } + + var installDates = _updateHistoryService.InstalledSince(resources.Last().ReleaseDate) + .DistinctBy(v => v.Version) + .ToDictionary(v => v.Version); + + foreach (var resource in resources) + { + if (installDates.TryGetValue(resource.Version, out var installDate)) + { + resource.InstalledOn = installDate.Date; + } + } } return resources; diff --git a/src/Prowlarr.Api.V1/Update/UpdateResource.cs b/src/Prowlarr.Api.V1/Update/UpdateResource.cs index e347b21e7..48a0a74c0 100644 --- a/src/Prowlarr.Api.V1/Update/UpdateResource.cs +++ b/src/Prowlarr.Api.V1/Update/UpdateResource.cs @@ -15,6 +15,7 @@ namespace Prowlarr.Api.V1.Update public string FileName { get; set; } public string Url { get; set; } public bool Installed { get; set; } + public DateTime? InstalledOn { get; set; } public bool Installable { get; set; } public bool Latest { get; set; } public UpdateChanges Changes { get; set; } diff --git a/src/Radarr.Api.V3/Update/UpdateResource.cs b/src/Radarr.Api.V3/Update/UpdateResource.cs new file mode 100644 index 000000000..365619a88 --- /dev/null +++ b/src/Radarr.Api.V3/Update/UpdateResource.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NzbDrone.Core.Update; +using Radarr.Http.REST; + +namespace Radarr.Api.V3.Update +{ + public class UpdateResource : RestResource + { + [JsonConverter(typeof(Newtonsoft.Json.Converters.VersionConverter))] + public Version Version { get; set; } + + public string Branch { get; set; } + public DateTime ReleaseDate { get; set; } + public string FileName { get; set; } + public string Url { get; set; } + public bool Installed { get; set; } + public DateTime? InstalledOn { get; set; } + public bool Installable { get; set; } + public bool Latest { get; set; } + public UpdateChanges Changes { get; set; } + public string Hash { get; set; } + } + + public static class UpdateResourceMapper + { + public static UpdateResource ToResource(this UpdatePackage model) + { + if (model == null) + { + return null; + } + + return new UpdateResource + { + Version = model.Version, + + Branch = model.Branch, + ReleaseDate = model.ReleaseDate, + FileName = model.FileName, + Url = model.Url, + + //Installed + //Installable + //Latest + Changes = model.Changes, + Hash = model.Hash, + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +}