From 3f064c94b9b70eeaefc58bdb8b4f7592318e1ae3 Mon Sep 17 00:00:00 2001 From: Qstick Date: Sat, 23 Feb 2019 17:39:11 -0500 Subject: [PATCH] New: Release Profiles, Frontend updates (#580) * New: Release Profiles - UI Updates * New: Release Profiles - API Changes * New: Release Profiles - Test Updates * New: Release Profiles - Backend Updates * New: Interactive Artist Search * New: Change Montiored on Album Details Page * New: Show Duration on Album Details Page * Fixed: Manual Import not working if no albums are Missing * Fixed: Sort search input by sortTitle * Fixed: Queue columnLabel throwing JS error --- frontend/.eslintrc | 9 +- frontend/postcss.config.js | 1 + frontend/src/Activity/Blacklist/Blacklist.js | 15 +- .../Activity/Blacklist/BlacklistConnector.js | 30 ++- .../src/Activity/Blacklist/BlacklistRow.js | 4 +- .../History/Details/HistoryDetails.css | 5 + .../History/Details/HistoryDetails.js | 7 + frontend/src/Activity/History/History.js | 11 + .../src/Activity/History/HistoryConnector.js | 19 +- frontend/src/Activity/History/HistoryRow.js | 4 +- frontend/src/Activity/Queue/Queue.js | 42 +++- frontend/src/Activity/Queue/QueueConnector.js | 29 ++- frontend/src/Activity/Queue/QueueOptions.js | 77 +++++++ .../Activity/Queue/QueueOptionsConnector.js | 19 ++ frontend/src/Activity/Queue/QueueRow.js | 62 +++-- .../src/Activity/Queue/QueueRowConnector.js | 4 - .../Queue/Status/QueueStatusConnector.js | 11 +- .../AddArtist/AddNewArtist/AddNewArtist.js | 2 +- .../AddNewArtist/AddNewArtistModalContent.js | 28 ++- .../AddNewArtist/AddNewArtistSearchResult.js | 32 +-- .../ImportArtist/Import/ImportArtistFooter.js | 15 ++ .../Import/ImportArtistFooterConnector.js | 7 +- .../ImportArtist/Import/ImportArtistRow.js | 1 - .../Import/ImportArtistRowConnector.js | 4 +- .../Import/SelectArtist/ImportArtistName.css | 9 +- .../Import/SelectArtist/ImportArtistName.js | 2 - .../SelectArtist/ImportArtistSelectArtist.css | 15 +- .../SelectArtist/ImportArtistSelectArtist.js | 10 +- .../ImportArtistRootFolderRowConnector.js | 48 ---- .../SelectFolder/ImportArtistSelectFolder.js | 56 +---- .../ImportArtistSelectFolderConnector.js | 9 +- frontend/src/Album/AlbumSearchCell.js | 6 +- frontend/src/Album/Details/AlbumDetails.css | 36 ++- frontend/src/Album/Details/AlbumDetails.js | 60 ++++- .../Album/Details/AlbumDetailsConnector.js | 13 +- .../Search/AlbumInteractiveSearchModal.js | 36 +++ .../AlbumInteractiveSearchModalConnector.js | 15 ++ .../AlbumInteractiveSearchModalContent.js | 47 ++++ .../{EpisodeLanguage.js => TrackLanguage.js} | 8 +- frontend/src/App/AppUpdatedModal.js | 1 + frontend/src/App/ColorImpairedContext.js | 6 + frontend/src/Artist/ArtistBanner.js | 171 +------------- frontend/src/Artist/ArtistImage.js | 199 ++++++++++++++++ frontend/src/Artist/ArtistPoster.js | 171 +------------- frontend/src/Artist/Details/ArtistDetails.css | 9 +- frontend/src/Artist/Details/ArtistDetails.js | 36 ++- .../Artist/Details/ArtistDetailsConnector.js | 74 +++++- .../Artist/Details/ArtistDetailsSeason.css | 2 +- .../src/Artist/Details/ArtistDetailsSeason.js | 11 +- .../Details/ArtistDetailsSeasonConnector.js | 2 - .../src/Artist/Edit/EditArtistModalContent.js | 4 +- .../src/Artist/History/ArtistHistoryRow.js | 6 +- frontend/src/Artist/Index/ArtistIndex.js | 43 +++- .../src/Artist/Index/ArtistIndexConnector.js | 80 +++---- .../src/Artist/Index/ArtistIndexFooter.css | 8 + .../src/Artist/Index/ArtistIndexFooter.js | 174 ++++++++------ .../Index/Posters/ArtistIndexPoster.css | 18 ++ .../Artist/Index/Posters/ArtistIndexPoster.js | 24 ++ .../Artist/Index/Table/ArtistIndexHeader.css | 15 +- .../Artist/Index/Table/ArtistIndexHeader.js | 147 +++++------- .../src/Artist/Index/Table/ArtistIndexRow.css | 77 +++++-- .../src/Artist/Index/Table/ArtistIndexRow.js | 60 ++++- .../Artist/Index/Table/ArtistIndexTable.js | 9 +- .../Index/Table/ArtistIndexTableConnector.js | 3 +- .../Index/Table/ArtistIndexTableOptions.js | 52 +++-- .../Artist/Index/Table/hasGrowableColumns.js | 17 ++ .../Search/ArtistInteractiveSearchModal.js | 33 +++ .../ArtistInteractiveSearchModalConnector.js | 15 ++ .../ArtistInteractiveSearchModalContent.js | 45 ++++ frontend/src/Calendar/Agenda/AgendaEvent.css | 26 ++- frontend/src/Calendar/Agenda/AgendaEvent.js | 8 +- .../Calendar/Agenda/AgendaEventConnector.js | 3 +- frontend/src/Calendar/CalendarConnector.js | 14 +- frontend/src/Calendar/CalendarPage.js | 73 +++++- .../src/Calendar/CalendarPageConnector.js | 79 ++++++- .../src/Calendar/Events/CalendarEvent.css | 8 +- frontend/src/Calendar/Events/CalendarEvent.js | 1 - frontend/src/Calendar/Legend/Legend.js | 35 ++- .../src/Calendar/Legend/LegendConnector.js | 19 ++ .../src/Calendar/Legend/LegendIconItem.css | 10 + .../src/Calendar/Legend/LegendIconItem.js | 37 +++ .../Options/CalendarOptionsModal.js} | 14 +- .../Options/CalendarOptionsModalContent.js | 216 +++++++++++++++++ .../CalendarOptionsModalContentConnector.js | 25 ++ .../Filter/Builder/FilterBuilderRowValue.js | 3 + .../QualityFilterBuilderRowValueConnector.js | 2 +- .../Form/AlbumReleaseSelectInputConnector.js | 2 +- .../src/Components/Form/AutoCompleteInput.css | 58 +++++ .../src/Components/Form/AutoCompleteInput.js | 162 +++++++++++++ frontend/src/Components/Form/Form.css | 3 + frontend/src/Components/Form/Form.js | 57 ++--- .../src/Components/Form/FormInputGroup.js | 8 + .../src/Components/Form/KeyValueListInput.css | 21 ++ .../src/Components/Form/KeyValueListInput.js | 152 ++++++++++++ .../Components/Form/KeyValueListInputItem.css | 14 ++ .../Components/Form/KeyValueListInputItem.js | 117 ++++++++++ .../Form/MonitorAlbumsSelectInput.js | 11 +- frontend/src/Components/Form/NumberInput.js | 85 +++++-- .../RootFolderSelectInputSelectedValue.css | 4 +- frontend/src/Components/Form/TagInputTag.js | 5 +- frontend/src/Components/Form/TextInput.js | 3 + frontend/src/Components/Icon.css | 8 + frontend/src/Components/Label.css | 11 +- frontend/src/Components/Link/IconButton.js | 1 - .../Page/Header/ArtistSearchInput.js | 20 +- .../Page/Header/ArtistSearchInputConnector.js | 4 +- .../src/Components/Page/Header/PageHeader.css | 9 +- .../src/Components/Page/Header/PageHeader.js | 5 +- frontend/src/Components/Page/Page.js | 55 +++-- frontend/src/Components/Page/PageConnector.js | 51 ++-- .../Page/Toolbar/PageToolbarSection.css | 13 ++ .../Page/Toolbar/PageToolbarSection.js | 4 +- frontend/src/Components/ProgressBar.css | 8 + frontend/src/Components/ProgressBar.js | 97 ++++---- frontend/src/Components/SignalRConnector.js | 8 + .../Components/Table/Cells/TableSelectCell.js | 1 - frontend/src/Components/Table/Table.js | 186 +++++++-------- .../src/Components/Table/TableHeaderCell.js | 6 +- .../TableOptionsColumnDragPreview.js | 2 - .../TableOptionsColumnDragSource.js | 2 - .../Table/TableOptions/TableOptionsModal.js | 2 - .../TableOptions/TableOptionsModalWrapper.js | 61 +++++ frontend/src/Components/Tooltip/Popover.css | 1 + frontend/src/Components/Tooltip/Popover.js | 47 ++-- frontend/src/Components/Tooltip/Tooltip.js | 9 +- frontend/src/Components/withCurrentPage.js | 25 ++ frontend/src/Helpers/Props/icons.js | 10 +- frontend/src/Helpers/Props/inputTypes.js | 4 + frontend/src/Helpers/Props/kinds.js | 4 + .../InteractiveImportModalContent.js | 10 +- .../Interactive/InteractiveImportRow.js | 4 +- .../SelectLanguageModalContentConnector.js | 2 +- .../SelectQualityModalContentConnector.js | 2 +- ...ModalContent.css => InteractiveSearch.css} | 4 + .../InteractiveSearch/InteractiveSearch.js | 210 +++++++++++++++++ ...ector.js => InteractiveSearchConnector.js} | 62 +++-- .../InteractiveSearchFilterModalConnector.js | 6 +- .../InteractiveSearchModalContent.js | 217 ------------------ .../InteractiveSearchRow.css | 15 +- .../InteractiveSearch/InteractiveSearchRow.js | 76 +++++- .../RootFolderRow.css} | 0 .../RootFolderRow.js} | 10 +- .../src/RootFolder/RootFolderRowConnector.js | 13 ++ frontend/src/RootFolder/RootFolders.js | 80 +++++++ .../src/RootFolder/RootFoldersConnector.js | 46 ++++ .../AddDownloadClientModalContent.js | 20 +- .../AddDownloadClientModalContentConnector.js | 12 +- .../DownloadClients/DownloadClient.js | 18 +- .../EditDownloadClientModalContent.js | 4 +- .../EditRemotePathMappingModalContent.js | 9 +- ...tRemotePathMappingModalContentConnector.js | 37 ++- .../RemotePathMappings/RemotePathMapping.css | 8 +- .../RemotePathMappings/RemotePathMappings.css | 8 +- .../RemotePathMappingsConnector.js | 12 +- frontend/src/Settings/General/HostSettings.js | 91 ++++---- .../src/Settings/General/UpdateSettings.js | 10 +- .../ImportLists/AddImportListModalContent.js | 18 +- .../AddImportListModalContentConnector.js | 12 +- .../ImportLists/EditImportListModalContent.js | 4 +- .../ImportLists/ImportLists/ImportList.js | 25 +- .../src/Settings/Indexers/IndexerSettings.js | 3 - .../Indexers/AddIndexerModalContent.js | 18 +- .../AddIndexerModalContentConnector.js | 12 +- .../Indexers/EditIndexerModalContent.js | 4 +- .../src/Settings/Indexers/Indexers/Indexer.js | 63 +++-- .../Indexers/Restrictions/Restriction.js | 148 ------------ .../Restrictions/RestrictionsConnector.js | 61 ----- .../MediaManagement/MediaManagement.js | 17 +- .../MediaManagement/Naming/NamingModal.js | 181 +++++++-------- .../MediaManagement/Naming/NamingOption.css | 5 +- .../Metadata/EditMetadataModalContent.js | 4 +- .../Settings/Metadata/Metadata/Metadata.css | 6 +- .../Settings/Metadata/Metadata/Metadata.js | 101 +++++--- .../AddNotificationModalContent.js | 18 +- .../AddNotificationModalContentConnector.js | 12 +- .../EditNotificationModalContent.js | 4 +- .../Notifications/Notification.js | 90 ++++---- .../Delay/EditDelayProfileModalContent.js | 4 +- .../EditDelayProfileModalContentConnector.js | 14 +- .../EditLanguageProfileModalContent.js | 38 ++- .../Profiles/Language/LanguageProfile.js | 6 +- .../EditMetadataProfileModalContent.js | 4 +- frontend/src/Settings/Profiles/Profiles.js | 2 + .../Quality/EditQualityProfileModalContent.js | 36 ++- .../Profiles/Quality/QualityProfile.js | 8 +- .../Release/EditReleaseProfileModal.js} | 10 +- .../EditReleaseProfileModalConnector.js} | 12 +- .../EditReleaseProfileModalContent.css} | 0 .../EditReleaseProfileModalContent.js} | 59 ++++- ...ditReleaseProfileModalContentConnector.js} | 42 ++-- .../Release/ReleaseProfile.css} | 2 +- .../Profiles/Release/ReleaseProfile.js | 168 ++++++++++++++ .../Release/ReleaseProfiles.css} | 6 +- .../Release/ReleaseProfiles.js} | 46 ++-- .../Release/ReleaseProfilesConnector.js | 61 +++++ .../Quality/Definition/QualityDefinition.js | 88 +++++-- .../Definition/QualityDefinitionConnector.js | 8 +- .../Quality/Definition/QualityDefinitions.js | 15 +- .../Definition/QualityDefinitionsConnector.js | 4 +- frontend/src/Settings/Settings.js | 6 +- .../Tags/Details/TagDetailsDelayProfile.js | 47 ++++ .../Tags/Details/TagDetailsModalContent.js | 33 ++- .../TagDetailsModalContentConnector.js | 10 +- frontend/src/Settings/Tags/TagsConnector.js | 10 +- frontend/src/Settings/UI/UISettings.js | 2 +- .../Creators/createFetchSchemaHandler.js | 6 +- .../createFetchServerSideCollectionHandler.js | 6 +- .../createServerSideCollectionHandlers.js | 4 +- .../Store/Actions/Settings/downloadClients.js | 2 +- .../src/Store/Actions/Settings/importLists.js | 2 +- .../src/Store/Actions/Settings/indexers.js | 2 +- .../Actions/Settings/languageProfiles.js | 2 +- .../Actions/Settings/metadataProfiles.js | 2 +- .../Store/Actions/Settings/notifications.js | 2 +- .../Store/Actions/Settings/qualityProfiles.js | 2 +- .../Store/Actions/Settings/releaseProfiles.js | 71 ++++++ .../Store/Actions/Settings/restrictions.js | 71 ------ .../src/Store/Actions/addArtistActions.js | 5 +- frontend/src/Store/Actions/albumActions.js | 8 +- .../src/Store/Actions/albumStudioActions.js | 22 +- .../src/Store/Actions/artistIndexActions.js | 1 + .../src/Store/Actions/blacklistActions.js | 14 +- frontend/src/Store/Actions/calendarActions.js | 66 ++++-- frontend/src/Store/Actions/commandActions.js | 54 +++-- frontend/src/Store/Actions/historyActions.js | 6 +- .../src/Store/Actions/importArtistActions.js | 49 +++- frontend/src/Store/Actions/queueActions.js | 41 +++- frontend/src/Store/Actions/releaseActions.js | 33 ++- frontend/src/Store/Actions/settingsActions.js | 10 +- frontend/src/Store/Actions/systemActions.js | 33 ++- frontend/src/Store/Actions/wantedActions.js | 18 +- ...{persistState.js => createPersistState.js} | 10 +- frontend/src/Store/Middleware/middlewares.js | 4 +- frontend/src/Store/Migrators/migrate.js | 5 + .../Migrators/migrateAddArtistDefaults.js | 14 ++ .../Selectors/createArtistCountSelector.js | 4 +- .../createClientSideCollectionSelector.js | 9 +- .../createProviderSettingsSelector.js | 2 +- .../Selectors/createQueueItemSelector.js | 10 +- .../Selectors/createTrackFileSelector.js | 2 +- frontend/src/Styles/Variables/colors.js | 6 +- frontend/src/Styles/Variables/fonts.js | 1 + frontend/src/Styles/scaffolding.css | 9 + frontend/src/System/Events/LogsTable.js | 12 + .../src/System/Events/LogsTableConnector.js | 18 +- .../Editor/TrackFileEditorModalContent.js | 25 +- .../TrackFileEditorModalContentConnector.js | 48 ++-- .../TrackFile/TrackFileLanguageConnector.js | 4 +- .../Utilities/Artist/getMonitoringOptions.js | 37 --- frontend/src/Utilities/Artist/getNewArtist.js | 9 +- .../src/Utilities/Artist/monitorOptions.js | 11 + frontend/src/Utilities/Number/roundNumber.js | 5 + .../src/Utilities/State/getProviderState.js | 16 +- .../src/Utilities/Table/toggleSelected.js | 1 - frontend/src/Utilities/pagePopulator.js | 1 + .../src/Wanted/CutoffUnmet/CutoffUnmet.js | 1 + .../CutoffUnmet/CutoffUnmetConnector.js | 21 +- frontend/src/Wanted/Missing/Missing.js | 10 +- .../src/Wanted/Missing/MissingConnector.js | 22 +- frontend/src/login.html | 6 + package.json | 1 + src/Lidarr.Api.V1/Albums/AlbumModule.cs | 2 +- .../Albums/AlbumModuleWithSignalR.cs | 2 +- .../Artist/ArtistEditorModule.cs | 2 +- src/Lidarr.Api.V1/Artist/ArtistResource.cs | 4 +- src/Lidarr.Api.V1/Calendar/CalendarModule.cs | 2 +- src/Lidarr.Api.V1/History/HistoryModule.cs | 4 +- src/Lidarr.Api.V1/Indexers/ReleaseModule.cs | 22 ++ .../Indexers/ReleaseModuleBase.cs | 13 +- src/Lidarr.Api.V1/Indexers/ReleaseResource.cs | 16 +- src/Lidarr.Api.V1/Lidarr.Api.V1.csproj | 4 +- .../Language/LanguageProfileResource.cs | 11 +- .../Language/LanguageProfileSchemaModule.cs | 25 +- .../Quality/QualityProfileResource.cs | 17 +- .../Quality/QualityProfileSchemaModule.cs | 2 +- .../Profiles/Release/ReleaseProfileModule.cs | 60 +++++ .../Release/ReleaseProfileResource.cs} | 29 +-- src/Lidarr.Api.V1/Queue/QueueDetailsModule.cs | 4 +- src/Lidarr.Api.V1/Queue/QueueModule.cs | 74 ++++-- src/Lidarr.Api.V1/Queue/QueueResource.cs | 17 +- src/Lidarr.Api.V1/Queue/QueueStatusModule.cs | 1 + .../Queue/QueueStatusResource.cs | 3 +- .../Restrictions/RestrictionModule.cs | 60 ----- .../TrackFiles/MediaInfoResource.cs | 2 +- .../TrackFiles/TrackFileModule.cs | 2 +- .../TrackFiles/TrackFileResource.cs | 4 +- src/Lidarr.Api.V1/Tracks/TrackModule.cs | 2 +- .../Tracks/TrackModuleWithSignalR.cs | 2 +- src/Lidarr.Api.V1/Wanted/CutoffModule.cs | 2 +- src/Lidarr.Api.V1/Wanted/MissingModule.cs | 2 +- src/Lidarr.Http/ClientSchema/Field.cs | 3 +- src/Lidarr.Http/ClientSchema/SchemaBuilder.cs | 3 +- .../Extensions/ReqResExtensions.cs | 6 +- .../DiskTests/DiskTransferServiceFixture.cs | 159 ++++++++++++- .../PathExtensionFixture.cs | 24 +- src/NzbDrone.Common/Disk/DiskProviderBase.cs | 8 + .../Disk/DiskTransferService.cs | 17 ++ src/NzbDrone.Common/Disk/IDiskProvider.cs | 1 + .../Extensions/PathExtensions.cs | 33 ++- .../Datastore/MarrDataLazyLoadingFixture.cs | 12 +- .../CutoffSpecificationFixture.cs | 68 ++++-- .../DownloadDecisionMakerFixture.cs | 1 + .../HistorySpecificationFixture.cs | 24 +- .../PrioritizeDownloadDecisionFixture.cs | 2 +- ...ityAllowedByProfileSpecificationFixture.cs | 6 +- .../QueueSpecificationFixture.cs | 134 ++++++++++- ...ReleaseRestrictionsSpecificationFixture.cs | 18 +- .../RssSync/DelaySpecificationFixture.cs | 18 +- .../DeletedTrackFileSpecificationFixture.cs | 2 +- .../RssSync/ProperSpecificationFixture.cs | 4 +- .../UpgradeDiskSpecificationFixture.cs | 14 +- ...ture.cs => UpgradeSpecificationFixture.cs} | 55 +++-- .../DownloadApprovedFixture.cs | 2 +- .../PendingReleaseServiceTests/AddFixture.cs | 14 +- .../RemoveGrabbedFixture.cs | 14 +- .../RemoveRejectedFixture.cs | 14 +- .../HistoryTests/HistoryServiceFixture.cs | 8 +- .../Housekeepers/CleanupUnusedTagsFixture.cs | 6 +- .../Languages/LanguageFixture.cs | 4 +- .../LanguageProfileRepositoryFixture.cs | 2 +- .../MediaFiles/ImportApprovedTracksFixture.cs | 2 +- .../MoveTrackFileFixture.cs | 2 +- .../TrackImport/ImportDecisionMakerFixture.cs | 2 +- .../UpgradeSpecificationFixture.cs | 2 +- .../AlbumMonitoredServiceFixture.cs | 4 +- .../ArtistRepositoryFixture.cs | 8 +- .../UpdateMultipleArtistFixture.cs | 2 +- .../NzbDrone.Core.Test.csproj | 3 +- .../Profiles/ProfileRepositoryFixture.cs | 4 +- .../Profiles/ProfileServiceFixture.cs | 22 +- .../PreferredWordService/CalculateFixture.cs | 95 ++++++++ .../Qualities/QualityFixture.cs | 4 +- .../Qualities/QualityModelComparerFixture.cs | 20 +- .../Annotations/FieldDefinitionAttribute.cs | 1 + .../Configuration/ConfigFileProvider.cs | 2 + .../Migration/025_rename_release_profiles.cs | 15 ++ ...me_quality_profiles_add_upgrade_allowed.cs | 23 ++ .../Migration/Framework/SqliteSyntaxReader.cs | 5 +- src/NzbDrone.Core/Datastore/TableMapping.cs | 13 +- .../DownloadDecisionComparer.cs | 8 +- .../DecisionEngine/DownloadDecisionMaker.cs | 10 +- .../Specifications/CutoffSpecification.cs | 12 +- .../IDecisionEngineSpecification.cs | 2 +- .../QualityAllowedByProfileSpecification.cs | 2 +- .../Specifications/QueueSpecification.cs | 32 ++- .../ReleaseRestrictionsSpecification.cs | 10 +- .../RssSync/DelaySpecification.cs | 22 +- .../RssSync/HistorySpecification.cs | 28 ++- .../RssSync/MonitoredAlbumSpecification.cs | 18 +- .../SameTracksSpecification.cs | 4 +- .../Search/SeasonMatchSpecification.cs | 25 -- .../Search/SingleEpisodeMatchSpecification.cs | 45 ---- .../UpgradableSpecification.cs | 64 ++++-- .../UpgradeDiskSpecification.cs | 12 +- .../AggregatePreferredWordScore.cs | 22 ++ .../Aggregators/IAggregateRemoteAlbum.cs | 9 + .../RemoteAlbumAggregationService.cs | 44 ++++ .../Download/Pending/PendingReleaseService.cs | 5 +- .../TrackedDownloadService.cs | 4 +- .../Roksbox/RoksboxMetadataSettings.cs | 6 +- .../Consumers/Wdtv/WdtvMetadataSettings.cs | 2 +- .../Consumers/Xbmc/XbmcMetadataSettings.cs | 8 +- .../Extras/Metadata/MetadataSectionType.cs | 8 + .../Housekeepers/CleanupUnusedTags.cs | 2 +- .../EnsureValidLanguageProfileId.cs | 42 ++++ .../ImportLists/ImportListSyncService.cs | 8 +- .../IndexerSearch/AlbumSearchService.cs | 4 +- .../Instrumentation/ReconfigureLogging.cs | 11 +- src/NzbDrone.Core/MediaFiles/TrackFile.cs | 23 +- .../TrackImport/ImportApprovedTracks.cs | 22 +- .../AlbumUpgradeSpecification.cs | 2 +- .../SameTracksImportSpecification.cs | 1 + .../Specifications/UpgradeSpecification.cs | 2 +- src/NzbDrone.Core/Music/AddArtistValidator.cs | 2 +- .../Music/AlbumMonitoredService.cs | 16 +- src/NzbDrone.Core/Music/AlbumRepository.cs | 2 +- src/NzbDrone.Core/Music/Artist.cs | 8 +- src/NzbDrone.Core/Music/MonitoringOptions.cs | 19 +- src/NzbDrone.Core/Music/MoveArtistService.cs | 2 + .../Music/RefreshArtistService.cs | 2 +- src/NzbDrone.Core/NzbDrone.Core.csproj | 41 ++-- .../Organizer/FileNameBuilder.cs | 22 +- .../Organizer/FileNameSampleService.cs | 9 +- src/NzbDrone.Core/Parser/Model/RemoteAlbum.cs | 6 + .../Profiles/Languages/LanguageProfile.cs | 5 +- ...LanguageItem.cs => LanguageProfileItem.cs} | 2 +- .../Languages/LanguageProfileService.cs | 22 +- .../Profiles/Qualities/ProfileRepository.cs | 23 -- .../{Profile.cs => QualityProfile.cs} | 13 +- ...ion.cs => QualityProfileInUseException.cs} | 4 +- ...tyItem.cs => QualityProfileQualityItem.cs} | 8 +- .../Qualities/QualityProfileRepository.cs | 23 ++ ...ileService.cs => QualityProfileService.cs} | 40 ++-- .../Releases}/PerlRegexFactory.cs | 6 +- .../Profiles/Releases/PreferredWordService.cs | 76 ++++++ .../Profiles/Releases/ReleaseProfile.cs | 21 ++ .../Releases/ReleaseProfileRepository.cs | 17 ++ .../Releases/ReleaseProfileService.cs | 65 ++++++ .../Releases}/TermMatcher.cs | 7 +- .../Qualities/QualityModelComparer.cs | 8 +- src/NzbDrone.Core/Queue/Queue.cs | 2 + src/NzbDrone.Core/Queue/QueueService.cs | 43 ++-- src/NzbDrone.Core/Restrictions/Restriction.cs | 18 -- .../Restrictions/RestrictionRepository.cs | 17 -- .../Restrictions/RestrictionService.cs | 65 ------ src/NzbDrone.Core/Tags/TagService.cs | 12 +- .../Validation/ProfileExistsValidator.cs | 6 +- .../Client/ClientBase.cs | 2 +- yarn.lock | 30 +++ 409 files changed, 6911 insertions(+), 3205 deletions(-) create mode 100644 frontend/src/Activity/History/Details/HistoryDetails.css create mode 100644 frontend/src/Activity/Queue/QueueOptions.js create mode 100644 frontend/src/Activity/Queue/QueueOptionsConnector.js delete mode 100644 frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRowConnector.js create mode 100644 frontend/src/Album/Search/AlbumInteractiveSearchModal.js create mode 100644 frontend/src/Album/Search/AlbumInteractiveSearchModalConnector.js create mode 100644 frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js rename frontend/src/Album/{EpisodeLanguage.js => TrackLanguage.js} (80%) create mode 100644 frontend/src/App/ColorImpairedContext.js create mode 100644 frontend/src/Artist/ArtistImage.js create mode 100644 frontend/src/Artist/Index/Table/hasGrowableColumns.js create mode 100644 frontend/src/Artist/Search/ArtistInteractiveSearchModal.js create mode 100644 frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js create mode 100644 frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js create mode 100644 frontend/src/Calendar/Legend/LegendConnector.js create mode 100644 frontend/src/Calendar/Legend/LegendIconItem.css create mode 100644 frontend/src/Calendar/Legend/LegendIconItem.js rename frontend/src/{InteractiveSearch/InteractiveSearchModal.js => Calendar/Options/CalendarOptionsModal.js} (54%) create mode 100644 frontend/src/Calendar/Options/CalendarOptionsModalContent.js create mode 100644 frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js create mode 100644 frontend/src/Components/Form/AutoCompleteInput.css create mode 100644 frontend/src/Components/Form/AutoCompleteInput.js create mode 100644 frontend/src/Components/Form/Form.css create mode 100644 frontend/src/Components/Form/KeyValueListInput.css create mode 100644 frontend/src/Components/Form/KeyValueListInput.js create mode 100644 frontend/src/Components/Form/KeyValueListInputItem.css create mode 100644 frontend/src/Components/Form/KeyValueListInputItem.js create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js create mode 100644 frontend/src/Components/withCurrentPage.js rename frontend/src/InteractiveSearch/{InteractiveSearchModalContent.css => InteractiveSearch.css} (69%) create mode 100644 frontend/src/InteractiveSearch/InteractiveSearch.js rename frontend/src/InteractiveSearch/{InteractiveSearchModalContentConnector.js => InteractiveSearchConnector.js} (52%) delete mode 100644 frontend/src/InteractiveSearch/InteractiveSearchModalContent.js rename frontend/src/{AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css => RootFolder/RootFolderRow.css} (100%) rename frontend/src/{AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js => RootFolder/RootFolderRow.js} (86%) create mode 100644 frontend/src/RootFolder/RootFolderRowConnector.js create mode 100644 frontend/src/RootFolder/RootFolders.js create mode 100644 frontend/src/RootFolder/RootFoldersConnector.js delete mode 100644 frontend/src/Settings/Indexers/Restrictions/Restriction.js delete mode 100644 frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js rename frontend/src/Settings/{Indexers/Restrictions/EditRestrictionModal.js => Profiles/Release/EditReleaseProfileModal.js} (59%) rename frontend/src/Settings/{Indexers/Restrictions/EditRestrictionModalConnector.js => Profiles/Release/EditReleaseProfileModalConnector.js} (60%) rename frontend/src/Settings/{Indexers/Restrictions/EditRestrictionModalContent.css => Profiles/Release/EditReleaseProfileModalContent.css} (100%) rename frontend/src/Settings/{Indexers/Restrictions/EditRestrictionModalContent.js => Profiles/Release/EditReleaseProfileModalContent.js} (62%) rename frontend/src/Settings/{Indexers/Restrictions/EditRestrictionModalContentConnector.js => Profiles/Release/EditReleaseProfileModalContentConnector.js} (62%) rename frontend/src/Settings/{Indexers/Restrictions/Restriction.css => Profiles/Release/ReleaseProfile.css} (88%) create mode 100644 frontend/src/Settings/Profiles/Release/ReleaseProfile.js rename frontend/src/Settings/{Indexers/Restrictions/Restrictions.css => Profiles/Release/ReleaseProfiles.css} (74%) rename frontend/src/Settings/{Indexers/Restrictions/Restrictions.js => Profiles/Release/ReleaseProfiles.js} (54%) create mode 100644 frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js create mode 100644 frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js create mode 100644 frontend/src/Store/Actions/Settings/releaseProfiles.js delete mode 100644 frontend/src/Store/Actions/Settings/restrictions.js rename frontend/src/Store/Middleware/{persistState.js => createPersistState.js} (86%) create mode 100644 frontend/src/Store/Migrators/migrate.js create mode 100644 frontend/src/Store/Migrators/migrateAddArtistDefaults.js delete mode 100644 frontend/src/Utilities/Artist/getMonitoringOptions.js create mode 100644 frontend/src/Utilities/Artist/monitorOptions.js create mode 100644 frontend/src/Utilities/Number/roundNumber.js create mode 100644 src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs rename src/Lidarr.Api.V1/{Restrictions/RestrictionResource.cs => Profiles/Release/ReleaseProfileResource.cs} (54%) delete mode 100644 src/Lidarr.Api.V1/Restrictions/RestrictionModule.cs rename src/NzbDrone.Core.Test/DecisionEngineTests/{QualityUpgradeSpecificationFixture.cs => UpgradeSpecificationFixture.cs} (67%) create mode 100644 src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/CalculateFixture.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/025_rename_release_profiles.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/026_rename_quality_profiles_add_upgrade_allowed.cs rename src/NzbDrone.Core/DecisionEngine/{ => Specifications}/IDecisionEngineSpecification.cs (85%) rename src/NzbDrone.Core/DecisionEngine/{ => Specifications}/SameTracksSpecification.cs (93%) delete mode 100644 src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs delete mode 100644 src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeMatchSpecification.cs rename src/NzbDrone.Core/DecisionEngine/{ => Specifications}/UpgradableSpecification.cs (54%) create mode 100644 src/NzbDrone.Core/Download/Aggregation/Aggregators/AggregatePreferredWordScore.cs create mode 100644 src/NzbDrone.Core/Download/Aggregation/Aggregators/IAggregateRemoteAlbum.cs create mode 100644 src/NzbDrone.Core/Download/Aggregation/RemoteAlbumAggregationService.cs create mode 100644 src/NzbDrone.Core/Extras/Metadata/MetadataSectionType.cs create mode 100644 src/NzbDrone.Core/Housekeeping/Housekeepers/EnsureValidLanguageProfileId.cs rename src/NzbDrone.Core/Profiles/Languages/{ProfileLanguageItem.cs => LanguageProfileItem.cs} (78%) delete mode 100644 src/NzbDrone.Core/Profiles/Qualities/ProfileRepository.cs rename src/NzbDrone.Core/Profiles/Qualities/{Profile.cs => QualityProfile.cs} (76%) rename src/NzbDrone.Core/Profiles/Qualities/{ProfileInUseException.cs => QualityProfileInUseException.cs} (62%) rename src/NzbDrone.Core/Profiles/Qualities/{ProfileQualityItem.cs => QualityProfileQualityItem.cs} (81%) create mode 100644 src/NzbDrone.Core/Profiles/Qualities/QualityProfileRepository.cs rename src/NzbDrone.Core/Profiles/Qualities/{ProfileService.cs => QualityProfileService.cs} (74%) rename src/NzbDrone.Core/{Restrictions => Profiles/Releases}/PerlRegexFactory.cs (93%) create mode 100644 src/NzbDrone.Core/Profiles/Releases/PreferredWordService.cs create mode 100644 src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs create mode 100644 src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs create mode 100644 src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs rename src/NzbDrone.Core/{Restrictions => Profiles/Releases}/TermMatcher.cs (92%) delete mode 100644 src/NzbDrone.Core/Restrictions/Restriction.cs delete mode 100644 src/NzbDrone.Core/Restrictions/RestrictionRepository.cs delete mode 100644 src/NzbDrone.Core/Restrictions/RestrictionService.cs diff --git a/frontend/.eslintrc b/frontend/.eslintrc index b7a5b2137..85a301813 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -177,7 +177,7 @@ "no-undef": "error", "no-undef-init": "off", "no-undefined": "off", - "no-unused-vars": ["warn", { "args": "none" }], + "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }], "no-use-before-define": "error", # Node.js and CommonJS @@ -205,14 +205,13 @@ "func-style": ["error", "declaration"], "indent": ["error", 2, {"SwitchCase": 1}], "key-spacing": ["error", {"beforeColon": false, "afterColon": true}], - "keyword-spacing": ["error", {before: true, after: true}], + "keyword-spacing": ["error", { "before": true, "after": true}], "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }], "max-depth": ["error", {"maximum": 5}], "max-nested-callbacks": ["error", 4], - "max-params": ["error", 7], "max-statements": "off", "max-statements-per-line": ["error", { "max": 1 }], - "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred"]}], + "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}], "new-parens": "error", "newline-after-var": "off", "newline-before-return": "off", @@ -223,7 +222,7 @@ "no-inline-comments": "off", "no-lonely-if": "warn", "no-mixed-spaces-and-tabs": "error", - "no-multiple-empty-lines": ["error", {max: 1}], + "no-multiple-empty-lines": ["error", { "max": 1 }], "no-negated-condition": "warn", "no-nested-ternary": "error", "no-new-object": "error", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index f82554ba8..54a56b172 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -14,6 +14,7 @@ module.exports = (ctx, configPath, options) => { return Object.assign(acc, reload(vars)); }, {}) }, + 'postcss-color-function': {}, 'postcss-nested': {}, autoprefixer: { browsers: [ diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js index e3ecd2ff7..d93bec0bf 100644 --- a/frontend/src/Activity/Blacklist/Blacklist.js +++ b/frontend/src/Activity/Blacklist/Blacklist.js @@ -1,9 +1,10 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; +import { align, icons } from 'Helpers/Props'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; @@ -41,6 +42,18 @@ class Blacklist extends Component { onPress={onClearBlacklistPress} /> + + + + + + diff --git a/frontend/src/Activity/Blacklist/BlacklistConnector.js b/frontend/src/Activity/Blacklist/BlacklistConnector.js index 1f5b6cd05..b182e7bb2 100644 --- a/frontend/src/Activity/Blacklist/BlacklistConnector.js +++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import withCurrentPage from 'Components/withCurrentPage'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import * as blacklistActions from 'Store/Actions/blacklistActions'; import { executeCommand } from 'Store/Actions/commandActions'; @@ -33,8 +34,19 @@ class BlacklistConnector extends Component { // Lifecycle componentDidMount() { + const { + useCurrentPage, + fetchBlacklist, + gotoBlacklistFirstPage + } = this.props; + registerPagePopulator(this.repopulate); - this.props.gotoBlacklistFirstPage(); + + if (useCurrentPage) { + fetchBlacklist(); + } else { + gotoBlacklistFirstPage(); + } } componentDidUpdate(prevProps) { @@ -44,6 +56,7 @@ class BlacklistConnector extends Component { } componentWillUnmount() { + this.props.clearBlacklist(); unregisterPagePopulator(this.repopulate); } @@ -53,7 +66,6 @@ class BlacklistConnector extends Component { repopulate = () => { this.props.fetchBlacklist(); } - // // Listeners @@ -93,6 +105,14 @@ class BlacklistConnector extends Component { this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST }); } + onTableOptionChange = (payload) => { + this.props.setBlacklistTableOption(payload); + + if (payload.pageSize) { + this.props.gotoBlacklistFirstPage(); + } + } + // // Render @@ -114,6 +134,7 @@ class BlacklistConnector extends Component { } BlacklistConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, isClearingBlacklistExecuting: PropTypes.bool.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, fetchBlacklist: PropTypes.func.isRequired, @@ -124,7 +145,10 @@ BlacklistConnector.propTypes = { gotoBlacklistPage: PropTypes.func.isRequired, setBlacklistSort: PropTypes.func.isRequired, setBlacklistTableOption: PropTypes.func.isRequired, + clearBlacklist: PropTypes.func.isRequired, executeCommand: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector); +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector) +); diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js index 47599dbab..25168e4f2 100644 --- a/frontend/src/Activity/Blacklist/BlacklistRow.js +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -5,7 +5,7 @@ import IconButton from 'Components/Link/IconButton'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import EpisodeLanguage from 'Album/EpisodeLanguage'; +import TrackLanguage from 'Album/TrackLanguage'; import TrackQuality from 'Album/TrackQuality'; import ArtistNameLink from 'Artist/ArtistNameLink'; import BlacklistDetailsModal from './BlacklistDetailsModal'; @@ -90,7 +90,7 @@ class BlacklistRow extends Component { key={name} className={styles.language} > - diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css b/frontend/src/Activity/History/Details/HistoryDetails.css new file mode 100644 index 000000000..03f8fd3ce --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.css @@ -0,0 +1,5 @@ +.description { + composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css'; + + overflow-wrap: break-word; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index 9eb94eca3..3dbd2a77d 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -7,6 +7,7 @@ import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import styles from './HistoryDetails.css'; function getDetailedList(statusMessages) { return ( @@ -60,6 +61,7 @@ function HistoryDetails(props) { return ( @@ -75,6 +77,7 @@ function HistoryDetails(props) { { !!releaseGroup && @@ -136,6 +139,7 @@ function HistoryDetails(props) { return ( @@ -160,6 +164,7 @@ function HistoryDetails(props) { return ( @@ -167,6 +172,7 @@ function HistoryDetails(props) { { !!droppedPath && @@ -175,6 +181,7 @@ function HistoryDetails(props) { { !!importedPath && diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js index 8c9548d5d..a525d9988 100644 --- a/frontend/src/Activity/History/History.js +++ b/frontend/src/Activity/History/History.js @@ -5,6 +5,7 @@ import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; @@ -75,6 +76,16 @@ class History extends Component { + + + + - diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js index 0392f7ebb..f04ca1ceb 100644 --- a/frontend/src/Activity/Queue/Queue.js +++ b/frontend/src/Activity/Queue/Queue.js @@ -3,9 +3,10 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; -import { icons } from 'Helpers/Props'; +import { align, icons } from 'Helpers/Props'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; @@ -16,7 +17,9 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import RemoveQueueItemsModal from './RemoveQueueItemsModal'; +import QueueOptionsConnector from './QueueOptionsConnector'; import QueueRowConnector from './QueueRowConnector'; class Queue extends Component { @@ -42,22 +45,27 @@ class Queue extends Component { // before albums start fetching or when albums start fetching. if ( - ( - this.props.isFetching && - nextProps.isPopulated && - hasDifferentItems(this.props.items, nextProps.items) - ) || - (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching) + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) && + nextProps.items.some((e) => e.albumId) ) { return false; } + if (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching) { + return false; + } + return true; } componentDidUpdate(prevProps) { if (hasDifferentItems(prevProps.items, this.props.items)) { - this.setState({ selectedState: {} }); + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + return; } @@ -138,7 +146,7 @@ class Queue extends Component { } = this.state; const isRefreshing = isFetching || isAlbumsFetching || isCheckForFinishedDownloadExecuting; - const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length); + const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length || items.every((e) => !e.albumId)); const hasError = error || albumsError; const selectedCount = this.getSelectedIds().length; const disableSelectedActions = selectedCount === 0; @@ -172,6 +180,21 @@ class Queue extends Component { onPress={this.onRemoveSelectedPress} /> + + + + + + @@ -203,6 +226,7 @@ class Queue extends Component { allSelected={allSelected} allUnselected={allUnselected} {...otherProps} + optionsComponent={QueueOptionsConnector} onSelectAllChange={this.onSelectAllChange} > diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js index 4a55a7e90..f7f853089 100644 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -5,6 +5,7 @@ import { createSelector } from 'reselect'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { executeCommand } from 'Store/Actions/commandActions'; import * as queueActions from 'Store/Actions/queueActions'; @@ -15,14 +16,16 @@ import Queue from './Queue'; function createMapStateToProps() { return createSelector( (state) => state.albums, + (state) => state.queue.options, (state) => state.queue.paged, createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD), - (albums, queue, isCheckForFinishedDownloadExecuting) => { + (albums, options, queue, isCheckForFinishedDownloadExecuting) => { return { isAlbumsFetching: albums.isFetching, isAlbumsPopulated: albums.isPopulated, albumsError: albums.error, isCheckForFinishedDownloadExecuting, + ...options, ...queue }; } @@ -42,19 +45,37 @@ class QueueConnector extends Component { // Lifecycle componentDidMount() { + const { + useCurrentPage, + fetchQueue, + gotoQueueFirstPage + } = this.props; + registerPagePopulator(this.repopulate); - this.props.gotoQueueFirstPage(); + + if (useCurrentPage) { + fetchQueue(); + } else { + gotoQueueFirstPage(); + } } componentDidUpdate(prevProps) { if (hasDifferentItems(prevProps.items, this.props.items)) { const albumIds = selectUniqueIds(this.props.items, 'albumId'); + if (albumIds.length) { this.props.fetchAlbums({ albumIds }); } else { this.props.clearAlbums(); } + } + if ( + this.props.includeUnknownArtistItems !== + prevProps.includeUnknownArtistItems + ) { + this.repopulate(); } } @@ -160,4 +181,6 @@ QueueConnector.propTypes = { executeCommand: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(QueueConnector); +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) +); diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js new file mode 100644 index 000000000..835be52b3 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class QueueOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + includeUnknownArtistItems: props.includeUnknownArtistItems + }; + } + + componentDidUpdate(prevProps) { + const { + includeUnknownArtistItems + } = this.props; + + if (includeUnknownArtistItems !== prevProps.includeUnknownArtistItems) { + this.setState({ + includeUnknownArtistItems + }); + } + } + + // + // Listeners + + onOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onOptionChange({ + [name]: value + }); + }); + } + + // + // Render + + render() { + const { + includeUnknownArtistItems + } = this.state; + + return ( + + + Show Unknown Artist Items + + + + + ); + } +} + +QueueOptions.propTypes = { + includeUnknownArtistItems: PropTypes.bool.isRequired, + onOptionChange: PropTypes.func.isRequired +}; + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js new file mode 100644 index 000000000..b2c99511c --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setQueueOption } from 'Store/Actions/queueActions'; +import QueueOptions from './QueueOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.queue.options, + (options) => { + return options; + } + ); +} + +const mapDispatchToProps = { + onOptionChange: setQueueOption +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index 0cbbe4d6d..076af3dca 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -12,6 +12,7 @@ import Icon from 'Components/Icon'; import Popover from 'Components/Tooltip/Popover'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import AlbumTitleLink from 'Album/AlbumTitleLink'; +import TrackLanguage from 'Album/TrackLanguage'; import TrackQuality from 'Album/TrackQuality'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import ArtistNameLink from 'Artist/ArtistNameLink'; @@ -72,6 +73,7 @@ class QueueRow extends Component { errorMessage, artist, album, + language, quality, protocol, indexer, @@ -137,43 +139,58 @@ class QueueRow extends Component { if (name === 'artist.sortName') { return ( - + { + artist ? + : + title + } ); } - if (name === 'artist') { + if (name === 'album.title') { return ( - + { + album ? + : + '-' + } ); } - if (name === 'album.title') { + if (name === 'album.releaseDate') { + if (album) { + return ( + + ); + } + return ( - + - ); } - if (name === 'album.releaseDate') { + if (name === 'language') { return ( - + + + ); } @@ -326,8 +343,9 @@ QueueRow.propTypes = { trackedDownloadStatus: PropTypes.string, statusMessages: PropTypes.arrayOf(PropTypes.object), errorMessage: PropTypes.string, - artist: PropTypes.object.isRequired, - album: PropTypes.object.isRequired, + artist: PropTypes.object, + album: PropTypes.object, + language: PropTypes.object.isRequired, quality: PropTypes.object.isRequired, protocol: PropTypes.string.isRequired, indexer: PropTypes.string, diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js index 3c89a868b..9d3938b13 100644 --- a/frontend/src/Activity/Queue/QueueRowConnector.js +++ b/frontend/src/Activity/Queue/QueueRowConnector.js @@ -51,10 +51,6 @@ class QueueRowConnector extends Component { // Render render() { - if (!this.props.album) { - return null; - } - return ( state.app, (state) => state.queue.status, - (app, status) => { + (state) => state.queue.options.includeUnknownArtistItems, + (app, status, includeUnknownArtistItems) => { + const { + count, + unknownCount + } = status.item; + return { isConnected: app.isConnected, isReconnecting: app.isReconnecting, isPopulated: status.isPopulated, - ...status.item + ...status.item, + count: includeUnknownArtistItems ? count : count - unknownCount }; } ); diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js index 3b1959982..23affe605 100644 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js @@ -100,8 +100,8 @@ class AddNewArtist extends Component { name="artistLookup" value={term} placeholder="eg. Breaking Benjamin, lidarr:854a1807-025b-42a8-ba8c-2a39717f1d25" - onChange={this.onSearchInputChange} autoFocus={true} + onChange={this.onSearchInputChange} /> } + { + hasUnsearchedItems && + + } + { isLookingUpArtist && { @@ -35,6 +35,7 @@ function createMapStateToProps() { const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId'); const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId'); const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder'); + const hasUnsearchedItems = !isLookingUpArtist && items.some((item) => !item.isPopulated); return { selectedCount: selectedIds.length, @@ -49,13 +50,15 @@ function createMapStateToProps() { isQualityProfileIdMixed, isLanguageProfileIdMixed, isMetadataProfileIdMixed, - isAlbumFolderMixed + isAlbumFolderMixed, + hasUnsearchedItems }; } ); } const mapDispatchToProps = { + onLookupPress: lookupUnsearchedArtist, onCancelLookupPress: cancelLookupArtist }; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js index 0d2064e65..771fc4222 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js @@ -110,7 +110,6 @@ ImportArtistRow.propTypes = { selectedArtist: PropTypes.object, isExistingArtist: PropTypes.bool.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, - queued: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired, showMetadataProfile: PropTypes.bool.isRequired, isSelected: PropTypes.bool, diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js index 219b82e86..2480bfdb6 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; +import { setImportArtistValue } from 'Store/Actions/importArtistActions'; import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; import ImportArtistRow from './ImportArtistRow'; @@ -34,7 +34,6 @@ function createMapStateToProps() { } const mapDispatchToProps = { - queueLookupArtist, setImportArtistValue }; @@ -82,7 +81,6 @@ ImportArtistRowConnector.propTypes = { monitor: PropTypes.string, albumFolder: PropTypes.bool, items: PropTypes.arrayOf(PropTypes.object), - queueLookupArtist: PropTypes.func.isRequired, setImportArtistValue: PropTypes.func.isRequired }; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css index 263e91fda..fc86c41d1 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css @@ -1,10 +1,12 @@ .artistNameContainer { display: flex; align-items: center; + flex: 0 1 auto; + overflow: hidden; } .artistName { - margin-right: 5px; + @add-mixin truncate; } .disambiguation { @@ -12,11 +14,6 @@ color: $disabledColor; } -.year { - margin-left: 5px; - color: $disabledColor; -} - .existing { margin-left: 5px; } diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js index 25d4edd16..1d9fb21b7 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js @@ -8,7 +8,6 @@ function ImportArtistName(props) { const { artistName, disambiguation, - // year, isExistingArtist } = props; @@ -36,7 +35,6 @@ function ImportArtistName(props) { ImportArtistName.propTypes = { artistName: PropTypes.string.isRequired, disambiguation: PropTypes.string, - // year: PropTypes.number.isRequired, isExistingArtist: PropTypes.bool.isRequired }; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css index 1a7f4836e..32ff0489b 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css @@ -30,8 +30,9 @@ } .dropdownArrowContainer { - position: absolute; - right: 16px; + flex: 1 0 auto; + margin-left: 5px; + text-align: right; } .contentContainer { @@ -68,3 +69,13 @@ border-radius: 0; } + +.results { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; + + overflow-x: hidden; + overflow-y: scroll; + max-height: 165px; +} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js index 59a7a2746..8d734dc32 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js @@ -120,7 +120,7 @@ class ImportArtistSelectArtist extends Component { isPopulated, error, items, - queued, + isQueued, isLookingUpArtist } = this.props; @@ -142,7 +142,7 @@ class ImportArtistSelectArtist extends Component { onPress={this.onPress} > { - isLookingUpArtist && queued && !isPopulated && + isLookingUpArtist && isQueued && !isPopulated && +
{ - return { - }; - } - ); -} - -const mapDispatchToProps = { - deleteRootFolder -}; - -class ImportArtistRootFolderRowConnector extends Component { - - // - // Listeners - - onDeletePress = () => { - this.props.deleteRootFolder({ id: this.props.id }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -ImportArtistRootFolderRowConnector.propTypes = { - id: PropTypes.number.isRequired, - deleteRootFolder: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRootFolderRowConnector); diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js index 691ac76b4..9a7253ec7 100644 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js +++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js @@ -8,33 +8,9 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import ImportArtistRootFolderRowConnector from './ImportArtistRootFolderRowConnector'; +import RootFolders from 'RootFolder/RootFolders'; import styles from './ImportArtistSelectFolder.css'; -const rootFolderColumns = [ - { - name: 'path', - label: 'Path', - isVisible: true - }, - { - name: 'freeSpace', - label: 'Free Space', - isVisible: true - }, - { - name: 'unmappedFolders', - label: 'Unmapped Folders', - isVisible: true - }, - { - name: 'actions', - isVisible: true - } -]; - class ImportArtistSelectFolder extends Component { // @@ -107,26 +83,13 @@ class ImportArtistSelectFolder extends Component { { items.length > 0 ?
-
- - - { - items.map((rootFolder) => { - return ( - - ); - }) - } - -
+
+
+ + + ); +} + +AlbumInteractiveSearchModalContent.propTypes = { + albumId: PropTypes.number.isRequired, + albumTitle: PropTypes.string.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AlbumInteractiveSearchModalContent; diff --git a/frontend/src/Album/EpisodeLanguage.js b/frontend/src/Album/TrackLanguage.js similarity index 80% rename from frontend/src/Album/EpisodeLanguage.js rename to frontend/src/Album/TrackLanguage.js index 52c8b3390..666674814 100644 --- a/frontend/src/Album/EpisodeLanguage.js +++ b/frontend/src/Album/TrackLanguage.js @@ -3,7 +3,7 @@ import React from 'react'; import Label from 'Components/Label'; import { kinds } from 'Helpers/Props'; -function EpisodeLanguage(props) { +function TrackLanguage(props) { const { className, language, @@ -24,14 +24,14 @@ function EpisodeLanguage(props) { ); } -EpisodeLanguage.propTypes = { +TrackLanguage.propTypes = { className: PropTypes.string, language: PropTypes.object, isCutoffNotMet: PropTypes.bool }; -EpisodeLanguage.defaultProps = { +TrackLanguage.defaultProps = { isCutoffNotMet: true }; -export default EpisodeLanguage; +export default TrackLanguage; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js index 285b87ec8..abc7f8832 100644 --- a/frontend/src/App/AppUpdatedModal.js +++ b/frontend/src/App/AppUpdatedModal.js @@ -12,6 +12,7 @@ function AppUpdatedModal(props) { return ( { - this.setState({ hasError: true }); - } - - onLoad = () => { - this.setState({ - isLoaded: true, - hasError: false - }); - } - - // - // Render - - render() { - const { - className, - style, - size, - lazy, - overflow - } = this.props; - - const { - bannerUrl, - hasError, - isLoaded - } = this.state; - - if (hasError || !bannerUrl) { - return ( - - ); - } - - if (lazy) { - return ( - - } - > - - - ); - } - - return ( - - ); - } +function ArtistBanner(props) { + return ( + + ); } ArtistBanner.propTypes = { - className: PropTypes.string, - style: PropTypes.object, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - size: PropTypes.number.isRequired, - lazy: PropTypes.bool.isRequired, - overflow: PropTypes.bool.isRequired + size: PropTypes.number.isRequired }; ArtistBanner.defaultProps = { - size: 70, - lazy: true, - overflow: false + size: 70 }; export default ArtistBanner; diff --git a/frontend/src/Artist/ArtistImage.js b/frontend/src/Artist/ArtistImage.js new file mode 100644 index 000000000..576a354a1 --- /dev/null +++ b/frontend/src/Artist/ArtistImage.js @@ -0,0 +1,199 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LazyLoad from 'react-lazyload'; + +function findImage(images, coverType) { + return images.find((image) => image.coverType === coverType); +} + +function getUrl(image, coverType, size) { + if (image) { + // Remove protocol + let url = image.url.replace(/^https?:/, ''); + url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); + + return url; + } +} + +class ArtistImage extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const pixelRatio = Math.floor(window.devicePixelRatio); + + const { + images, + coverType, + size + } = props; + + const image = findImage(images, coverType); + + this.state = { + pixelRatio, + image, + url: getUrl(image, coverType, pixelRatio * size), + isLoaded: false, + hasError: false + }; + } + + componentDidMount() { + if (!this.state.url && this.props.onError) { + this.props.onError(); + } + } + + componentDidUpdate() { + const { + images, + coverType, + placeholder, + size, + onError + } = this.props; + + const { + image, + pixelRatio + } = this.state; + + const nextImage = findImage(images, coverType); + + if (nextImage && (!image || nextImage.url !== image.url)) { + this.setState({ + image: nextImage, + url: getUrl(nextImage, coverType, pixelRatio * size), + hasError: false + // Don't reset isLoaded, as we want to immediately try to + // show the new image, whether an image was shown previously + // or the placeholder was shown. + }); + } else if (!nextImage && image) { + this.setState({ + image: nextImage, + url: placeholder, + hasError: false + }); + + if (onError) { + onError(); + } + } + } + + // + // Listeners + + onError = () => { + this.setState({ + hasError: true + }); + + if (this.props.onError) { + this.props.onError(); + } + } + + onLoad = () => { + this.setState({ + isLoaded: true, + hasError: false + }); + + if (this.props.onLoad) { + this.props.onLoad(); + } + } + + // + // Render + + render() { + const { + className, + style, + placeholder, + size, + lazy, + overflow + } = this.props; + + const { + url, + hasError, + isLoaded + } = this.state; + + if (hasError || !url) { + return ( + + ); + } + + if (lazy) { + return ( + + } + > + + + ); + } + + return ( + + ); + } +} + +ArtistImage.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + coverType: PropTypes.string.isRequired, + placeholder: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + lazy: PropTypes.bool.isRequired, + overflow: PropTypes.bool.isRequired, + onError: PropTypes.func, + onLoad: PropTypes.func +}; + +ArtistImage.defaultProps = { + size: 250, + lazy: true, + overflow: false +}; + +export default ArtistImage; diff --git a/frontend/src/Artist/ArtistPoster.js b/frontend/src/Artist/ArtistPoster.js index 21038a9f6..4eebd9ca4 100644 --- a/frontend/src/Artist/ArtistPoster.js +++ b/frontend/src/Artist/ArtistPoster.js @@ -1,172 +1,25 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LazyLoad from 'react-lazyload'; +import React from 'react'; +import ArtistImage from './ArtistImage'; const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII='; -function findPoster(images) { - return _.find(images, { coverType: 'poster' }); -} - -function getPosterUrl(poster, size) { - if (poster) { - if (poster.url.contains('lastWrite=') || (/^https?:/).test(poster.url)) { - // Remove protocol - let url = poster.url.replace(/^https?:/, ''); - url = url.replace('poster.jpg', `poster-${size}.jpg`); - - return url; - } - } -} - -class ArtistPoster extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const pixelRatio = Math.floor(window.devicePixelRatio); - - const { - images, - size - } = props; - - const poster = findPoster(images); - - this.state = { - pixelRatio, - poster, - posterUrl: getPosterUrl(poster, pixelRatio * size), - isLoaded: false, - hasError: false - }; - } - - componentDidUpdate(prevProps) { - const { - images, - size - } = this.props; - - const { - poster, - pixelRatio - } = this.state; - - const nextPoster = findPoster(images); - - if (nextPoster && (!poster || nextPoster.url !== poster.url)) { - this.setState({ - poster: nextPoster, - posterUrl: getPosterUrl(nextPoster, pixelRatio * size), - hasError: false, - isLoaded: true - }); - } else if (!nextPoster && poster) { - this.setState({ - poster: nextPoster, - posterUrl: posterPlaceholder, - hasError: false - }); - } - } - - // - // Listeners - - onError = () => { - this.setState({ hasError: true }); - } - - onLoad = () => { - this.setState({ - isLoaded: true, - hasError: false - }); - } - - // - // Render - - render() { - const { - className, - style, - size, - lazy, - overflow - } = this.props; - - const { - posterUrl, - hasError, - isLoaded - } = this.state; - - if (hasError || !posterUrl) { - return ( - - ); - } - - if (lazy) { - return ( - - } - > - - - ); - } - - return ( - - ); - } +function ArtistPoster(props) { + return ( + + ); } ArtistPoster.propTypes = { - className: PropTypes.string, - style: PropTypes.object, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - size: PropTypes.number.isRequired, - lazy: PropTypes.bool.isRequired, - overflow: PropTypes.bool.isRequired + size: PropTypes.number.isRequired }; ArtistPoster.defaultProps = { - size: 250, - lazy: true, - overflow: false + size: 250 }; export default ArtistPoster; diff --git a/frontend/src/Artist/Details/ArtistDetails.css b/frontend/src/Artist/Details/ArtistDetails.css index dd1e8be64..6d75e959e 100644 --- a/frontend/src/Artist/Details/ArtistDetails.css +++ b/frontend/src/Artist/Details/ArtistDetails.css @@ -108,6 +108,7 @@ } .details { + margin-bottom: 8px; font-weight: 300; font-size: 20px; } @@ -132,15 +133,11 @@ font-size: 17px; } -.path { - vertical-align: text-top; - font-size: $defaultFontSize; - font-family: $monoSpaceFontFamily; -} - .overview { flex: 1 0 auto; + margin-top: 8px; min-height: 0; + font-size: $intermediateFontSize; } .contentContainer { diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js index 1d2dfe858..316ce692a 100644 --- a/frontend/src/Artist/Details/ArtistDetails.js +++ b/frontend/src/Artist/Details/ArtistDetails.js @@ -11,7 +11,6 @@ import HeartRating from 'Components/HeartRating'; import Icon from 'Components/Icon'; import IconButton from 'Components/Link/IconButton'; import Label from 'Components/Label'; -import Measure from 'Components/Measure'; import MonitorToggleButton from 'Components/MonitorToggleButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; @@ -35,6 +34,7 @@ import ArtistTagsConnector from './ArtistTagsConnector'; import ArtistDetailsLinks from './ArtistDetailsLinks'; import styles from './ArtistDetails.css'; import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; +import ArtistInteractiveSearchModalConnector from 'Artist/Search/ArtistInteractiveSearchModalConnector'; import Link from 'Components/Link/Link'; const defaultFontSize = parseInt(fonts.defaultFontSize); @@ -71,6 +71,7 @@ class ArtistDetails extends Component { isDeleteArtistModalOpen: false, isArtistHistoryModalOpen: false, isInteractiveImportModalOpen: false, + isInteractiveSearchModalOpen: false, allExpanded: false, allCollapsed: false, expandedState: {} @@ -104,6 +105,14 @@ class ArtistDetails extends Component { this.setState({ isInteractiveImportModalOpen: false }); } + onInteractiveSearchPress = () => { + this.setState({ isInteractiveSearchModalOpen: true }); + } + + onInteractiveSearchModalClose = () => { + this.setState({ isInteractiveSearchModalOpen: false }); + } + onEditArtistPress = () => { this.setState({ isEditArtistModalOpen: true }); } @@ -181,7 +190,9 @@ class ArtistDetails extends Component { isPopulated, albumsError, trackFilesError, + hasAlbums, hasMonitoredAlbums, + hasTrackFiles, previousArtist, nextArtist, onMonitorTogglePress, @@ -201,6 +212,7 @@ class ArtistDetails extends Component { isDeleteArtistModalOpen, isArtistHistoryModalOpen, isInteractiveImportModalOpen, + isInteractiveSearchModalOpen, allExpanded, allCollapsed, expandedState @@ -240,29 +252,41 @@ class ArtistDetails extends Component { + + @@ -609,6 +633,12 @@ class ArtistDetails extends Component { showImportMode={false} onModalClose={this.onInteractiveImportModalClose} /> + + ); @@ -638,7 +668,9 @@ ArtistDetails.propTypes = { isPopulated: PropTypes.bool.isRequired, albumsError: PropTypes.object, trackFilesError: PropTypes.object, + hasAlbums: PropTypes.bool.isRequired, hasMonitoredAlbums: PropTypes.bool.isRequired, + hasTrackFiles: PropTypes.bool.isRequired, previousArtist: PropTypes.object.isRequired, nextArtist: PropTypes.object.isRequired, onMonitorTogglePress: PropTypes.func.isRequired, diff --git a/frontend/src/Artist/Details/ArtistDetailsConnector.js b/frontend/src/Artist/Details/ArtistDetailsConnector.js index f7ed9db40..2e5ba1d11 100644 --- a/frontend/src/Artist/Details/ArtistDetailsConnector.js +++ b/frontend/src/Artist/Details/ArtistDetailsConnector.js @@ -16,11 +16,55 @@ import { executeCommand } from 'Store/Actions/commandActions'; import * as commandNames from 'Commands/commandNames'; import ArtistDetails from './ArtistDetails'; +const selectAlbums = createSelector( + (state) => state.albums, + (albums) => { + const { + items, + isFetching, + isPopulated, + error + } = albums; + + const hasAlbums = !!items.length; + const hasMonitoredAlbums = items.some((e) => e.monitored); + + return { + isAlbumsFetching: isFetching, + isAlbumsPopulated: isPopulated, + albumsError: error, + hasAlbums, + hasMonitoredAlbums + }; + } +); + +const selectTrackFiles = createSelector( + (state) => state.trackFiles, + (trackFiles) => { + const { + items, + isFetching, + isPopulated, + error + } = trackFiles; + + const hasTrackFiles = !!items.length; + + return { + isTrackFilesFetching: isFetching, + isTrackFilesPopulated: isPopulated, + trackFilesError: error, + hasTrackFiles + }; + } +); + function createMapStateToProps() { return createSelector( (state, { foreignArtistId }) => foreignArtistId, - (state) => state.albums, - (state) => state.trackFiles, + selectAlbums, + selectTrackFiles, (state) => state.settings.metadataProfiles, createAllArtistSelector(), createCommandsSelector(), @@ -40,6 +84,21 @@ function createMapStateToProps() { return {}; } + const { + isAlbumsFetching, + isAlbumsPopulated, + albumsError, + hasAlbums, + hasMonitoredAlbums + } = albums; + + const { + isTrackFilesFetching, + isTrackFilesPopulated, + trackFilesError, + hasTrackFiles + } = trackFiles; + const sortedAlbumTypes = _.orderBy(albumTypes); const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist); @@ -60,10 +119,9 @@ function createMapStateToProps() { isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1 ); - const isFetching = albums.isFetching || trackFiles.isFetching; - const isPopulated = albums.isPopulated && trackFiles.isPopulated; - const albumsError = albums.error; - const trackFilesError = trackFiles.error; + const isFetching = isAlbumsFetching || isTrackFilesFetching; + const isPopulated = isAlbumsPopulated && isTrackFilesPopulated; + const alternateTitles = _.reduce(artist.alternateTitles, (acc, alternateTitle) => { if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) && (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) { @@ -73,8 +131,6 @@ function createMapStateToProps() { return acc; }, []); - const hasMonitoredAlbums = albums.items.some((e) => e.monitored); - return { ...artist, albumTypes: sortedAlbumTypes, @@ -89,7 +145,9 @@ function createMapStateToProps() { isPopulated, albumsError, trackFilesError, + hasAlbums, hasMonitoredAlbums, + hasTrackFiles, previousArtist, nextArtist }; diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.css b/frontend/src/Artist/Details/ArtistDetailsSeason.css index 5a0840889..0133e85a2 100644 --- a/frontend/src/Artist/Details/ArtistDetailsSeason.css +++ b/frontend/src/Artist/Details/ArtistDetailsSeason.css @@ -62,7 +62,7 @@ composes: menuContent from 'Components/Menu/MenuContent.css'; white-space: nowrap; - font-size: 14px; + font-size: $defaultFontSize; } .actionMenuIcon { diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.js b/frontend/src/Artist/Details/ArtistDetailsSeason.js index 3df7fca77..f3d105dc0 100644 --- a/frontend/src/Artist/Details/ArtistDetailsSeason.js +++ b/frontend/src/Artist/Details/ArtistDetailsSeason.js @@ -34,8 +34,13 @@ class ArtistDetailsSeason extends Component { } componentDidUpdate(prevProps) { - if (prevProps.artistId !== this.props.artistId) { + const { + artistId + } = this.props; + + if (prevProps.artistId !== artistId) { this._expandByDefault(); + return; } } @@ -51,7 +56,7 @@ class ArtistDetailsSeason extends Component { const expand = _.some(items, (item) => { return isAfter(item.releaseDate) || - isAfter(item.releaseDate, { days: -30 }); + isAfter(item.releaseDate, { days: -365 }); }); onExpandPress(name, expand); @@ -113,7 +118,6 @@ class ArtistDetailsSeason extends Component { items, columns, isExpanded, - artistMonitored, sortKey, sortDirection, onSortPress, @@ -235,7 +239,6 @@ ArtistDetailsSeason.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, isExpanded: PropTypes.bool, - artistMonitored: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired, onTableOptionChange: PropTypes.func.isRequired, onExpandPress: PropTypes.func.isRequired, diff --git a/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js index 11202c7ac..21e25a67d 100644 --- a/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js +++ b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js @@ -4,14 +4,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { findCommand, isCommandExecuting } from 'Utilities/Command'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { toggleAlbumsMonitored, setAlbumsTableOption, setAlbumsSort } from 'Store/Actions/albumActions'; import { executeCommand } from 'Store/Actions/commandActions'; -import * as commandNames from 'Commands/commandNames'; import ArtistDetailsSeason from './ArtistDetailsSeason'; function createMapStateToProps() { diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js index 4f3fa9803..ba0e657c4 100644 --- a/frontend/src/Artist/Edit/EditArtistModalContent.js +++ b/frontend/src/Artist/Edit/EditArtistModalContent.js @@ -85,9 +85,7 @@ class EditArtistModalContent extends Component { - + Monitored diff --git a/frontend/src/Artist/History/ArtistHistoryRow.js b/frontend/src/Artist/History/ArtistHistoryRow.js index 8e3ffebdb..48c4f3db2 100644 --- a/frontend/src/Artist/History/ArtistHistoryRow.js +++ b/frontend/src/Artist/History/ArtistHistoryRow.js @@ -8,7 +8,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import Popover from 'Components/Tooltip/Popover'; -import EpisodeLanguage from 'Album/EpisodeLanguage'; +import TrackLanguage from 'Album/TrackLanguage'; import TrackQuality from 'Album/TrackQuality'; import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; @@ -68,8 +68,6 @@ class ArtistHistoryRow extends Component { qualityCutoffNotMet, date, data, - fullArtist, - artist, album } = this.props; @@ -93,7 +91,7 @@ class ArtistHistoryRow extends Component { - diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js index 710d898e0..fcb30fb0b 100644 --- a/frontend/src/Artist/Index/ArtistIndex.js +++ b/frontend/src/Artist/Index/ArtistIndex.js @@ -7,12 +7,14 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageJumpBar from 'Components/Page/PageJumpBar'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import NoArtist from 'Artist/NoArtist'; import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector'; +import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector'; import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector'; import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; @@ -187,6 +189,7 @@ class ArtistIndex extends Component { error, totalItems, items, + columns, selectedFilterKey, filters, customFilters, @@ -245,35 +248,52 @@ class ArtistIndex extends Component { alignContent={align.RIGHT} collapseButtons={false} > + { + view === 'table' ? + + + : + null + } { - view === 'posters' && + view === 'posters' ? + /> : + null } { - view === 'banners' && + view === 'banners' ? + /> : + null } { - view === 'overview' && - + view === 'overview' ? + : + null } { @@ -382,6 +402,7 @@ ArtistIndex.propTypes = { error: PropTypes.object, totalItems: PropTypes.number.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js index 0c22d2556..8d510c62a 100644 --- a/frontend/src/Artist/Index/ArtistIndexConnector.js +++ b/frontend/src/Artist/Index/ArtistIndexConnector.js @@ -9,7 +9,7 @@ import createCommandExecutingSelector from 'Store/Selectors/createCommandExecuti import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import { fetchArtist } from 'Store/Actions/artistActions'; import scrollPositions from 'Store/scrollPositions'; -import { setArtistSort, setArtistFilter, setArtistView } from 'Store/Actions/artistIndexActions'; +import { setArtistSort, setArtistFilter, setArtistView, setArtistTableOption } from 'Store/Actions/artistIndexActions'; import { executeCommand } from 'Store/Actions/commandActions'; import * as commandNames from 'Commands/commandNames'; import withScrollPosition from 'Components/withScrollPosition'; @@ -66,13 +66,41 @@ function createMapStateToProps() { ); } -const mapDispatchToProps = { - fetchArtist, - setArtistSort, - setArtistFilter, - setArtistView, - executeCommand -}; +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchArtist() { + dispatch(fetchArtist); + }, + + onTableOptionChange(payload) { + dispatch(setArtistTableOption(payload)); + }, + + onSortSelect(sortKey) { + dispatch(setArtistSort({ sortKey })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setArtistFilter({ selectedFilterKey })); + }, + + dispatchSetArtistView(view) { + dispatch(setArtistView({ view })); + }, + + onRefreshArtistPress() { + dispatch(executeCommand({ + name: commandNames.REFRESH_ARTIST + })); + }, + + onRssSyncPress() { + dispatch(executeCommand({ + name: commandNames.RSS_SYNC + })); + } + }; +} class ArtistIndexConnector extends Component { @@ -94,24 +122,16 @@ class ArtistIndexConnector extends Component { } componentDidMount() { - this.props.fetchArtist(); + this.props.dispatchFetchArtist(); } // // Listeners - onSortSelect = (sortKey) => { - this.props.setArtistSort({ sortKey }); - } - - onFilterSelect = (selectedFilterKey) => { - this.props.setArtistFilter({ selectedFilterKey }); - } - onViewSelect = (view) => { // Reset the scroll position before changing the view this.setState({ scrollTop: 0 }, () => { - this.props.setArtistView({ view }); + this.props.dispatchSetArtistView(view); }); } @@ -123,18 +143,6 @@ class ArtistIndexConnector extends Component { }); } - onRefreshArtistPress = () => { - this.props.executeCommand({ - name: commandNames.REFRESH_ARTIST - }); - } - - onRssSyncPress = () => { - this.props.executeCommand({ - name: commandNames.RSS_SYNC - }); - } - // // Render @@ -143,12 +151,8 @@ class ArtistIndexConnector extends Component { ); } @@ -158,14 +162,10 @@ ArtistIndexConnector.propTypes = { isSmallScreen: PropTypes.bool.isRequired, view: PropTypes.string.isRequired, scrollTop: PropTypes.number.isRequired, - fetchArtist: PropTypes.func.isRequired, - setArtistSort: PropTypes.func.isRequired, - setArtistFilter: PropTypes.func.isRequired, - setArtistView: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired + dispatchFetchArtist: PropTypes.func.isRequired }; export default withScrollPosition( - connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexConnector), + connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector), 'artistIndex' ); diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.css b/frontend/src/Artist/Index/ArtistIndexFooter.css index 3aa369576..71d0439b6 100644 --- a/frontend/src/Artist/Index/ArtistIndexFooter.css +++ b/frontend/src/Artist/Index/ArtistIndexFooter.css @@ -34,12 +34,20 @@ composes: legendItemColor; background-color: $dangerColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px); + } } .missingUnmonitored { composes: legendItemColor; background-color: $warningColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px); + } } .statistics { diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js index 2e3f1e5d5..f16e36661 100644 --- a/frontend/src/Artist/Index/ArtistIndexFooter.js +++ b/frontend/src/Artist/Index/ArtistIndexFooter.js @@ -1,6 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; +import classNames from 'classnames'; import formatBytes from 'Utilities/Number/formatBytes'; +import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import styles from './ArtistIndexFooter.css'; @@ -40,79 +42,105 @@ function ArtistIndexFooter({ artist }) { }); return ( -
-
-
-
-
Continuing (All tracks downloaded)
-
- -
-
-
Ended (All tracks downloaded)
-
- -
-
-
Missing Tracks (Artist monitored)
-
- -
-
-
Missing Tracks (Artist not monitored)
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
-
+ + {(enableColorImpairedMode) => { + return ( +
+
+
+
+
Continuing (All tracks downloaded)
+
+ +
+
+
Ended (All tracks downloaded)
+
+ +
+
+
Missing Tracks (Artist monitored)
+
+ +
+
+
Missing Tracks (Artist not monitored)
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); + }} + ); } diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css index 9f58a8f21..cfeb7f317 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css @@ -26,10 +26,27 @@ $hoverScale: 1.05; .link { composes: link from 'Components/Link/Link.css'; + position: relative; display: block; + height: 70px; background-color: $defaultColor; } +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + .nextAiring { background-color: #fafbfc; text-align: center; @@ -49,6 +66,7 @@ $hoverScale: 1.05; position: absolute; top: 0; right: 0; + z-index: 1; width: 0; height: 0; border-width: 0 25px 25px 0; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js index 0d89e51b5..101b49f7b 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js @@ -22,6 +22,7 @@ class ArtistIndexPoster extends Component { super(props, context); this.state = { + hasPosterError: false, isEditArtistModalOpen: false, isDeleteArtistModalOpen: false }; @@ -49,6 +50,18 @@ class ArtistIndexPoster extends Component { this.setState({ isDeleteArtistModalOpen: false }); } + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + // // Render @@ -90,6 +103,7 @@ class ArtistIndexPoster extends Component { } = statistics; const { + hasPosterError, isEditArtistModalOpen, isDeleteArtistModalOpen } = this.state; @@ -153,7 +167,17 @@ class ArtistIndexPoster extends Component { size={250} lazy={false} overflow={true} + onError={this.onPosterLoadError} + onLoad={this.onPosterLoad} /> + + { + hasPosterError && +
+ {artistName} +
+ } +
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css index ec7a1dbfd..465f11f91 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css @@ -10,6 +10,20 @@ flex: 4 0 110px; } +.banner { + flex: 0 0 379px; +} + +.bannerGrow { + flex-grow: 1; +} + +.artistType { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 100px; +} + .qualityProfileId, .languageProfileId, .metadataProfileId { @@ -40,7 +54,6 @@ flex: 0 0 150px; } -.artistType, .trackCount { composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js index f46bbde5f..aed47bafa 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js @@ -1,109 +1,86 @@ import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React from 'react'; +import classNames from 'classnames'; import { icons } from 'Helpers/Props'; import IconButton from 'Components/Link/IconButton'; import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; -import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import hasGrowableColumns from './hasGrowableColumns'; import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector'; import styles from './ArtistIndexHeader.css'; -class ArtistIndexHeader extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isTableOptionsModalOpen: false - }; - } - - // - // Listeners - - onTableOptionsPress = () => { - this.setState({ isTableOptionsModalOpen: true }); - } - - onTableOptionsModalClose = () => { - this.setState({ isTableOptionsModalOpen: false }); - } - - // - // Render - - render() { - const { - showSearchAction, - columns, - onTableOptionChange, - ...otherProps - } = this.props; - - return ( - - { - columns.map((column) => { - const { - name, - label, - isSortable, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'actions') { - return ( - - - - ); - } - +function ArtistIndexHeader(props) { + const { + showBanners, + columns, + onTableOptionChange, + ...otherProps + } = props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { return ( - {label} + + + + ); - }) - } - - - - ); - } + } + + return ( + + {label} + + ); + }) + } + + ); } ArtistIndexHeader.propTypes = { columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onTableOptionChange: PropTypes.func.isRequired + onTableOptionChange: PropTypes.func.isRequired, + showBanners: PropTypes.bool.isRequired }; export default ArtistIndexHeader; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css b/frontend/src/Artist/Index/Table/ArtistIndexRow.css index 5de727246..83fb0aa0e 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.css @@ -1,19 +1,69 @@ -.status { +.cell { composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + display: flex; + align-items: center; +} + +.status { + composes: cell; + flex: 0 0 60px; } .sortName { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 4 0 110px; } +.artistType { + composes: cell; + + flex: 0 0 100px; +} + +.banner { + flex: 0 0 379px; +} + +.bannerGrow { + flex-grow: 1; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.bannerImage { + width: 379px; + height: 70px; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + .qualityProfileId, .languageProfileId, .metadataProfileId { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 1 0 125px; } @@ -22,19 +72,19 @@ .lastAlbum, .added, .genres { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 0 0 180px; } .albumCount { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 0 0 100px; } .trackProgress { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; display: flex; justify-content: center; @@ -42,21 +92,20 @@ flex-direction: column; } -.artistType, .trackCount { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 0 0 130px; } .path { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 1 0 150px; } .sizeOnDisk { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 0 0 120px; } @@ -68,21 +117,21 @@ } .tags { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 1 0 60px; } .useSceneNumbering { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; flex: 0 0 145px; } .actions { - composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + composes: cell; - flex: 0 1 90px; + flex: 0 0 90px; } .checkInput { diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js index 787ababb9..b77c499c4 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.js @@ -1,10 +1,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import classNames from 'classnames'; import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; import formatBytes from 'Utilities/Number/formatBytes'; import { icons } from 'Helpers/Props'; import HeartRating from 'Components/HeartRating'; import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import ProgressBar from 'Components/ProgressBar'; import TagListConnector from 'Components/TagListConnector'; @@ -16,6 +18,8 @@ import ArtistNameLink from 'Artist/ArtistNameLink'; import AlbumTitleLink from 'Album/AlbumTitleLink'; import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import ArtistBanner from 'Artist/ArtistBanner'; +import hasGrowableColumns from './hasGrowableColumns'; import ArtistStatusCell from './ArtistStatusCell'; import styles from './ArtistIndexRow.css'; @@ -28,6 +32,7 @@ class ArtistIndexRow extends Component { super(props, context); this.state = { + hasBannerError: false, isEditArtistModalOpen: false, isDeleteArtistModalOpen: false }; @@ -57,6 +62,18 @@ class ArtistIndexRow extends Component { // } + onBannerLoad = () => { + if (this.state.hasBannerError) { + this.setState({ hasBannerError: false }); + } + } + + onBannerLoadError = () => { + if (!this.state.hasBannerError) { + this.setState({ hasBannerError: true }); + } + } + // // Render @@ -80,6 +97,8 @@ class ArtistIndexRow extends Component { ratings, path, tags, + images, + showBanners, showSearchAction, columns, isRefreshingArtist, @@ -97,6 +116,7 @@ class ArtistIndexRow extends Component { } = statistics; const { + hasBannerError, isEditArtistModalOpen, isDeleteArtistModalOpen } = this.state; @@ -130,12 +150,40 @@ class ArtistIndexRow extends Component { return ( - + { + showBanners ? + + + + { + hasBannerError && +
+ {artistName} +
+ } + : + + + }
); } @@ -424,6 +472,8 @@ ArtistIndexRow.propTypes = { genres: PropTypes.arrayOf(PropTypes.string).isRequired, ratings: PropTypes.object.isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + showBanners: PropTypes.bool.isRequired, showSearchAction: PropTypes.bool.isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, isRefreshingArtist: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js index eee92a418..1ed508cc1 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.js @@ -43,7 +43,8 @@ class ArtistIndexTable extends Component { rowRenderer = ({ key, rowIndex, style }) => { const { items, - columns + columns, + showBanners } = this.props; const artist = items[rowIndex]; @@ -58,6 +59,7 @@ class ArtistIndexTable extends Component { languageProfileId={artist.languageProfileId} qualityProfileId={artist.qualityProfileId} metadataProfileId={artist.metadataProfileId} + showBanners={showBanners} /> ); } @@ -72,6 +74,7 @@ class ArtistIndexTable extends Component { filters, sortKey, sortDirection, + showBanners, isSmallScreen, scrollTop, contentBody, @@ -88,11 +91,12 @@ class ArtistIndexTable extends Component { scrollIndex={this.state.scrollIndex} contentBody={contentBody} isSmallScreen={isSmallScreen} - rowHeight={38} + rowHeight={showBanners ? 70 : 38} overscanRowCount={2} rowRenderer={this.rowRenderer} header={ { return { isSmallScreen: dimensions.isSmallScreen, - ...artist + ...artist, + showBanners: artist.tableOptions.showBanners }; } ); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js index ce03ba8df..110a024e4 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { inputTypes } from 'Helpers/Props'; import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; @@ -14,15 +14,23 @@ class ArtistIndexTableOptions extends Component { super(props, context); this.state = { + showBanners: props.showBanners, showSearchAction: props.showSearchAction }; } componentDidUpdate(prevProps) { - const { showSearchAction } = this.props; + const { + showBanners, + showSearchAction + } = this.props; - if (showSearchAction !== prevProps.showSearchAction) { + if ( + showBanners !== prevProps.showBanners || + showSearchAction !== prevProps.showSearchAction + ) { this.setState({ + showBanners, showSearchAction }); } @@ -49,26 +57,42 @@ class ArtistIndexTableOptions extends Component { render() { const { + showBanners, showSearchAction } = this.state; return ( - - Show Search - - - + + + Show Banners + + + + + + Show Search + + + + ); } } ArtistIndexTableOptions.propTypes = { + showBanners: PropTypes.bool.isRequired, showSearchAction: PropTypes.bool.isRequired, onTableOptionChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.js b/frontend/src/Artist/Index/Table/hasGrowableColumns.js new file mode 100644 index 000000000..6af100d1b --- /dev/null +++ b/frontend/src/Artist/Index/Table/hasGrowableColumns.js @@ -0,0 +1,17 @@ +const growableColumns = [ + 'qualityProfileId', + 'languageProfileId', + 'path', + 'tags' +]; + +export default function hasGrowableColumns(columns) { + return columns.some((column) => { + const { + name, + isVisible + } = column; + + return growableColumns.includes(name) && isVisible; + }); +} diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js new file mode 100644 index 000000000..0da3661a8 --- /dev/null +++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistInteractiveSearchModalContent from './ArtistInteractiveSearchModalContent'; + +function ArtistInteractiveSearchModal(props) { + const { + isOpen, + artistId, + onModalClose + } = props; + + return ( + + + + ); +} + +ArtistInteractiveSearchModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + artistId: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistInteractiveSearchModal; diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js new file mode 100644 index 000000000..fe3170570 --- /dev/null +++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; +import ArtistInteractiveSearchModal from './ArtistInteractiveSearchModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(cancelFetchReleases()); + dispatch(clearReleases()); + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(ArtistInteractiveSearchModal); diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js new file mode 100644 index 000000000..9b7f4c6ed --- /dev/null +++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; + +function ArtistInteractiveSearchModalContent(props) { + const { + artistId, + onModalClose + } = props; + + return ( + + + Interactive Search + + + + + + + + + + + ); +} + +ArtistInteractiveSearchModalContent.propTypes = { + artistId: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistInteractiveSearchModalContent; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css index d01be1954..bcac8bf72 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.css +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css @@ -3,15 +3,18 @@ overflow-x: hidden; padding: 5px; border-bottom: 1px solid $borderColor; - font-size: 14px; + font-size: $defaultFontSize; &:hover { background-color: $tableRowHoverBackgroundColor; } } -.status { - width: 10px; +.eventWrapper { + display: flex; + flex: 1 0 1px; + overflow-x: hidden; + padding-left: 6px; border-left-width: 4px; border-left-style: solid; } @@ -24,6 +27,7 @@ .time { flex: 0 0 120px; margin-right: 10px; + border: none !important; } .artistName, @@ -80,16 +84,16 @@ @media only screen and (max-width: $breakpointSmall) { .event { - position: relative; - flex-wrap: wrap; - padding-left: 10px; + flex-direction: column; + } + + .eventWrapper { + display: block; + flex: 0 0 auto; } - .status { - position: absolute; - top: 7%; - left: 0; - height: 86%; + .date { + margin-left: 10px; } .date, diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js index 054206072..f8ce4e0fb 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.js +++ b/frontend/src/Calendar/Agenda/AgendaEvent.js @@ -49,7 +49,8 @@ class AgendaEvent extends Component { queueItem, showDate, timeFormat, - longDateFormat + longDateFormat, + colorImpairedMode } = this.props; const startTime = moment(releaseDate); @@ -74,8 +75,9 @@ class AgendaEvent extends Component {
diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js index 76de94184..b0ab00f1b 100644 --- a/frontend/src/Calendar/Agenda/AgendaEventConnector.js +++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js @@ -15,7 +15,8 @@ function createMapStateToProps() { artist, queueItem, timeFormat: uiSettings.timeFormat, - longDateFormat: uiSettings.longDateFormat + longDateFormat: uiSettings.longDateFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode }; } ); diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js index 97d35671b..c5f3e32e6 100644 --- a/frontend/src/Calendar/CalendarConnector.js +++ b/frontend/src/Calendar/CalendarConnector.js @@ -41,8 +41,20 @@ class CalendarConnector extends Component { } componentDidMount() { + const { + useCurrentPage, + fetchCalendar, + gotoCalendarToday + } = this.props; + registerPagePopulator(this.repopulate); - this.props.gotoCalendarToday(); + + if (useCurrentPage) { + fetchCalendar(); + } else { + gotoCalendarToday(); + } + this.scheduleUpdate(); } diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js index d33b1387b..ea795cc52 100644 --- a/frontend/src/Calendar/CalendarPage.js +++ b/frontend/src/Calendar/CalendarPage.js @@ -10,7 +10,8 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import FilterMenu from 'Components/Menu/FilterMenu'; import NoArtist from 'Artist/NoArtist'; import CalendarLinkModal from './iCal/CalendarLinkModal'; -import Legend from './Legend/Legend'; +import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import LegendConnector from './Legend/LegendConnector'; import CalendarConnector from './CalendarConnector'; import styles from './CalendarPage.css'; @@ -26,6 +27,7 @@ class CalendarPage extends Component { this.state = { isCalendarLinkModalOpen: false, + isOptionsModalOpen: false, width: 0 }; } @@ -48,6 +50,23 @@ class CalendarPage extends Component { this.setState({ isCalendarLinkModalOpen: false }); } + onOptionsPress = () => { + this.setState({ isOptionsModalOpen: true }); + } + + onOptionsModalClose = () => { + this.setState({ isOptionsModalOpen: false }); + } + + onSearchMissingPress = () => { + const { + missingAlbumIds, + onSearchMissingPress + } = this.props; + + onSearchMissingPress(missingAlbumIds); + } + // // Render @@ -56,17 +75,20 @@ class CalendarPage extends Component { selectedFilterKey, filters, hasArtist, - colorImpairedMode, + missingAlbumIds, + isSearchingForMissing, + useCurrentPage, onFilterSelect } = this.props; - const isMeasured = this.state.width > 0; + const { + isCalendarLinkModalOpen, + isOptionsModalOpen + } = this.state; - let PageComponent = 'div'; + const isMeasured = this.state.width > 0; - if (isMeasured) { - PageComponent = hasArtist ? CalendarConnector : NoArtist; - } + const PageComponent = hasArtist ? CalendarConnector : NoArtist; return ( @@ -77,9 +99,23 @@ class CalendarPage extends Component { iconName={icons.CALENDAR} onPress={this.onGetCalendarLinkPress} /> + + + + - + { + isMeasured ? + : +
+ } { hasArtist && - + } + + + ); } @@ -121,7 +169,10 @@ CalendarPage.propTypes = { selectedFilterKey: PropTypes.string.isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, hasArtist: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired, + missingAlbumIds: PropTypes.arrayOf(PropTypes.number).isRequired, + isSearchingForMissing: PropTypes.bool.isRequired, + useCurrentPage: PropTypes.bool.isRequired, + onSearchMissingPress: PropTypes.func.isRequired, onDaysCountChange: PropTypes.func.isRequired, onFilterSelect: PropTypes.func.isRequired }; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js index d03842628..655275b00 100644 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -1,22 +1,80 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; +import moment from 'moment'; +import { isCommandExecuting } from 'Utilities/Command'; +import isBefore from 'Utilities/Date/isBefore'; +import withCurrentPage from 'Components/withCurrentPage'; +import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import CalendarPage from './CalendarPage'; +function createMissingAlbumIdsSelector() { + return createSelector( + (state) => state.calendar.start, + (state) => state.calendar.end, + (state) => state.calendar.items, + (state) => state.queue.details.items, + (start, end, albums, queueDetails) => { + return albums.reduce((acc, album) => { + const releaseDate = album.releaseDate; + + if ( + album.percentOfTracks < 100 && + moment(releaseDate).isAfter(start) && + moment(releaseDate).isBefore(end) && + isBefore(album.releaseDate) && + !queueDetails.some((details) => !!details.album && details.album.id === album.id) + ) { + acc.push(album.id); + } + + return acc; + }, []); + } + ); +} + +function createIsSearchingSelector() { + return createSelector( + (state) => state.calendar.searchMissingCommandId, + createCommandsSelector(), + (searchMissingCommandId, commands) => { + if (searchMissingCommandId == null) { + return false; + } + + return isCommandExecuting(commands.find((command) => { + return command.id === searchMissingCommandId; + })); + } + ); +} + function createMapStateToProps() { return createSelector( - (state) => state.calendar, + (state) => state.calendar.selectedFilterKey, + (state) => state.calendar.filters, createArtistCountSelector(), createUISettingsSelector(), - (calendar, artistCount, uiSettings) => { + createMissingAlbumIdsSelector(), + createIsSearchingSelector(), + ( + selectedFilterKey, + filters, + artistCount, + uiSettings, + missingAlbumIds, + isSearchingForMissing + ) => { return { - selectedFilterKey: calendar.selectedFilterKey, - filters: calendar.filters, - showUpcoming: calendar.showUpcoming, + selectedFilterKey, + filters, colorImpairedMode: uiSettings.enableColorImpairedMode, - hasArtist: !!artistCount + hasArtist: !!artistCount, + missingAlbumIds, + isSearchingForMissing }; } ); @@ -24,6 +82,9 @@ function createMapStateToProps() { function createMapDispatchToProps(dispatch, props) { return { + onSearchMissingPress(albumIds) { + dispatch(searchMissing({ albumIds })); + }, onDaysCountChange(dayCount) { dispatch(setCalendarDaysCount({ dayCount })); }, @@ -34,4 +95,6 @@ function createMapDispatchToProps(dispatch, props) { }; } -export default connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage); +export default withCurrentPage( + connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage) +); diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css index 03ce086b9..c0153156b 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.css +++ b/frontend/src/Calendar/Events/CalendarEvent.css @@ -22,7 +22,7 @@ .artistName { color: #3a3f51; - font-size: 14px; + font-size: $defaultFontSize; } .absoluteEpisodeNumber { @@ -53,7 +53,7 @@ border-left-color: $gray; &:global(.colorImpaired) { - background: repeating-linear-gradient(45deg, transparent, transparent 5px, #eee 5px, #eee 10px); + background: repeating-linear-gradient(45deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); } } @@ -61,7 +61,7 @@ border-left-color: $dangerColor; &:global(.colorImpaired) { - background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px); + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); } } @@ -69,6 +69,6 @@ border-left-color: $blue; &:global(.colorImpaired) { - background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px); + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); } } diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js index b104d5cee..8f04fd670 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.js +++ b/frontend/src/Calendar/Events/CalendarEvent.js @@ -4,7 +4,6 @@ import React, { Component } from 'react'; import classNames from 'classnames'; import { icons } from 'Helpers/Props'; import getStatusStyle from 'Calendar/getStatusStyle'; -import albumEntities from 'Album/albumEntities'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; import CalendarEventQueueDetails from './CalendarEventQueueDetails'; diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js index 8ab94d032..009294930 100644 --- a/frontend/src/Calendar/Legend/Legend.js +++ b/frontend/src/Calendar/Legend/Legend.js @@ -1,9 +1,29 @@ import PropTypes from 'prop-types'; import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; import LegendItem from './LegendItem'; +import LegendIconItem from './LegendIconItem'; import styles from './Legend.css'; -function Legend({ colorImpairedMode }) { +function Legend(props) { + const { + showCutoffUnmetIcon, + colorImpairedMode + } = props; + + const iconsToShow = []; + + if (showCutoffUnmetIcon) { + iconsToShow.push( + + ); + } + return (
@@ -47,11 +67,24 @@ function Legend({ colorImpairedMode }) { colorImpairedMode={colorImpairedMode} />
+ +
+ {iconsToShow[0]} +
+ + { + iconsToShow.length > 1 && +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ }
); } Legend.propTypes = { + showCutoffUnmetIcon: PropTypes.bool.isRequired, colorImpairedMode: PropTypes.bool.isRequired }; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js new file mode 100644 index 000000000..30bbc4adb --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import Legend from './Legend'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createUISettingsSelector(), + (calendarOptions, uiSettings) => { + return { + ...calendarOptions, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css new file mode 100644 index 000000000..01db0ba5a --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.css @@ -0,0 +1,10 @@ +.legendIconItem { + margin: 3px 0; + margin-right: 6px; + width: 150px; + cursor: default; +} + +.icon { + margin-right: 5px; +} diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js new file mode 100644 index 000000000..13e106784 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './LegendIconItem.css'; + +function LegendIconItem(props) { + const { + name, + icon, + kind, + tooltip + } = props; + + return ( +
+ + + {name} +
+ ); +} + +LegendIconItem.propTypes = { + name: PropTypes.string.isRequired, + icon: PropTypes.object.isRequired, + kind: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired +}; + +export default LegendIconItem; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js similarity index 54% rename from frontend/src/InteractiveSearch/InteractiveSearchModal.js rename to frontend/src/Calendar/Options/CalendarOptionsModal.js index 7b4b9ffdb..b68c83f30 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchModal.js +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.js @@ -1,13 +1,12 @@ import PropTypes from 'prop-types'; import React from 'react'; import Modal from 'Components/Modal/Modal'; -import InteractiveSearchModalContentConnector from './InteractiveSearchModalContentConnector'; +import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; -function InteractiveSearchModal(props) { +function CalendarOptionsModal(props) { const { isOpen, - onModalClose, - ...otherProps + onModalClose } = props; return ( @@ -15,17 +14,16 @@ function InteractiveSearchModal(props) { isOpen={isOpen} onModalClose={onModalClose} > - ); } -InteractiveSearchModal.propTypes = { +CalendarOptionsModal.propTypes = { isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; -export default InteractiveSearchModal; +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js new file mode 100644 index 000000000..a25d36f9c --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js @@ -0,0 +1,216 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings'; + +class CalendarOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = props; + + this.state = { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }; + } + + componentDidUpdate(prevProps) { + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.props; + + if ( + prevProps.firstDayOfWeek !== firstDayOfWeek || + prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || + prevProps.timeFormat !== timeFormat || + prevProps.enableColorImpairedMode !== enableColorImpairedMode + ) { + this.setState({ + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }); + } + } + + // + // Listeners + + onOptionInputChange = ({ name, value }) => { + const { + dispatchSetCalendarOption + } = this.props; + + dispatchSetCalendarOption({ [name]: value }); + } + + onGlobalInputChange = ({ name, value }) => { + const { + dispatchSaveUISettings + } = this.props; + + const setting = { [name]: value }; + + this.setState(setting, () => { + dispatchSaveUISettings(setting); + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + collapseMultipleAlbums, + showCutoffUnmetIcon, + onModalClose + } = this.props; + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.state; + + return ( + + + Calendar Options + + + +
+ + + Collapse Multiple Albums + + + + + + Icon for Cutoff Unmet + + + + +
+ +
+
+ + First Day of Week + + + + + + Week Column Header + + + + + + Time Format + + + + Enable Color-Impaired Mode + + + + +
+
+
+ + + + +
+ ); + } +} + +CalendarOptionsModalContent.propTypes = { + collapseMultipleAlbums: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + firstDayOfWeek: PropTypes.number.isRequired, + calendarWeekColumnHeader: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + enableColorImpairedMode: PropTypes.bool.isRequired, + dispatchSetCalendarOption: PropTypes.func.isRequired, + dispatchSaveUISettings: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js new file mode 100644 index 000000000..eb979f74e --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setCalendarOption } from 'Store/Actions/calendarActions'; +import CalendarOptionsModalContent from './CalendarOptionsModalContent'; +import { saveUISettings } from 'Store/Actions/settingsActions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + (state) => state.settings.ui.item, + (options, uiSettings) => { + return { + ...options, + ...uiSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetCalendarOption: setCalendarOption, + dispatchSaveUISettings: saveUISettings +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js index 758e12691..70c496620 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -19,8 +19,10 @@ function getTagDisplayValue(value, selectedFilterBuilderProp) { function getValue(input, selectedFilterBuilderProp) { if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { const match = input.match(/^(\d+)([kmgt](i?b)?)$/i); + if (match && match.length > 1) { const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i); + switch (unit.toLowerCase()) { case 'k': return convertToBytes(value, 1, true); @@ -118,6 +120,7 @@ class FilterBuilderRowValue extends Component { name: tag && tag.name }; } + return { id, name: getTagDisplayValue(id, selectedFilterBuilderProp) diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js index ee1dc732e..0290bcdcb 100644 --- a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js @@ -12,7 +12,7 @@ function createMapStateToProps() { (state) => state.settings.qualityProfiles, (qualityProfiles) => { const { - isFetchingSchema: isFetching, + isSchemaFetching: isFetching, isSchemaPopulated: isPopulated, schemaError: error, schema diff --git a/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js b/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js index 862cc1b63..b79c0db1d 100644 --- a/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js +++ b/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js @@ -44,7 +44,7 @@ class AlbumReleaseSelectInputConnector extends Component { albumReleases } = this.props; - let updatedReleases = _.map(albumReleases.value, (e) => ({ ...e, monitored: false })); + const updatedReleases = _.map(albumReleases.value, (e) => ({ ...e, monitored: false })); _.find(updatedReleases, { foreignReleaseId: value }).monitored = true; this.props.onChange({ name, value: updatedReleases }); diff --git a/frontend/src/Components/Form/AutoCompleteInput.css b/frontend/src/Components/Form/AutoCompleteInput.css new file mode 100644 index 000000000..417a71437 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.css @@ -0,0 +1,58 @@ +.input { + composes: input from 'Components/Form/Input.css'; +} + +.hasError { + composes: hasError from 'Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; +} + +.inputWrapper { + display: flex; +} + +.inputContainer { + position: relative; + flex-grow: 1; +} + +.container { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.inputContainerOpen { + .container { + position: absolute; + z-index: 1; + overflow-y: auto; + max-height: 200px; + width: 100%; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + } +} + +.list { + margin: 5px 0; + padding-left: 0; + list-style-type: none; +} + +.listItem { + padding: 0 16px; +} + +.match { + font-weight: bold; +} + +.highlighted { + background-color: $menuItemHoverBackgroundColor; +} diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js new file mode 100644 index 000000000..740726b36 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.js @@ -0,0 +1,162 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Autosuggest from 'react-autosuggest'; +import classNames from 'classnames'; +import jdu from 'jdu'; +import styles from './AutoCompleteInput.css'; + +class AutoCompleteInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + suggestions: [] + }; + } + + // + // Control + + getSuggestionValue(item) { + return item; + } + + renderSuggestion(item) { + return item; + } + + // + // Listeners + + onInputChange = (event, { newValue }) => { + this.props.onChange({ + name: this.props.name, + value: newValue + }); + } + + onInputKeyDown = (event) => { + const { + name, + value, + onChange + } = this.props; + + const { suggestions } = this.state; + + if ( + event.key === 'Tab' && + suggestions.length && + suggestions[0] !== this.props.value + ) { + event.preventDefault(); + + if (value) { + onChange({ + name, + value: suggestions[0] + }); + } + } + } + + onInputBlur = () => { + this.setState({ suggestions: [] }); + } + + onSuggestionsFetchRequested = ({ value }) => { + const { values } = this.props; + const lowerCaseValue = jdu.replace(value).toLowerCase(); + + const filteredValues = values.filter((v) => { + return jdu.replace(v).toLowerCase().contains(lowerCaseValue); + }); + + this.setState({ suggestions: filteredValues }); + } + + onSuggestionsClearRequested = () => { + this.setState({ suggestions: [] }); + } + + // + // Render + + render() { + const { + className, + inputClassName, + name, + value, + placeholder, + hasError, + hasWarning + } = this.props; + + const { suggestions } = this.state; + + const inputProps = { + className: classNames( + inputClassName, + hasError && styles.hasError, + hasWarning && styles.hasWarning, + ), + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: this.onInputChange, + onKeyDown: this.onInputKeyDown, + onBlur: this.onInputBlur + }; + + const theme = { + container: styles.inputContainer, + containerOpen: styles.inputContainerOpen, + suggestionsContainer: styles.container, + suggestionsList: styles.list, + suggestion: styles.listItem, + suggestionHighlighted: styles.highlighted + }; + + return ( +
+ +
+ ); + } +} + +AutoCompleteInput.propTypes = { + className: PropTypes.string.isRequired, + inputClassName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string, + values: PropTypes.arrayOf(PropTypes.string).isRequired, + placeholder: PropTypes.string, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + onChange: PropTypes.func.isRequired +}; + +AutoCompleteInput.defaultProps = { + className: styles.inputWrapper, + inputClassName: styles.input, + value: '' +}; + +export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/Form.css b/frontend/src/Components/Form/Form.css new file mode 100644 index 000000000..52e79aec4 --- /dev/null +++ b/frontend/src/Components/Form/Form.css @@ -0,0 +1,3 @@ +.validationFailures { + margin-bottom: 20px; +} diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js index 9a605297a..c2c67eddf 100644 --- a/frontend/src/Components/Form/Form.js +++ b/frontend/src/Components/Form/Form.js @@ -2,37 +2,42 @@ import PropTypes from 'prop-types'; import React from 'react'; import { kinds } from 'Helpers/Props'; import Alert from 'Components/Alert'; +import styles from './Form.css'; function Form({ children, validationErrors, validationWarnings, ...otherProps }) { return (
-
- { - validationErrors.map((error, index) => { - return ( - - {error.errorMessage} - - ); - }) - } + { + validationErrors.length || validationWarnings.length ? +
+ { + validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + }) + } - { - validationWarnings.map((warning, index) => { - return ( - - {warning.errorMessage} - - ); - }) - } -
+ { + validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + }) + } +
: + null + } {children}
diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 2a38be6b1..a487d1a0b 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -2,9 +2,11 @@ import PropTypes from 'prop-types'; import React from 'react'; import { inputTypes } from 'Helpers/Props'; import Link from 'Components/Link/Link'; +import AutoCompleteInput from './AutoCompleteInput'; import CaptchaInputConnector from './CaptchaInputConnector'; import CheckInput from './CheckInput'; import DeviceInputConnector from './DeviceInputConnector'; +import KeyValueListInput from './KeyValueListInput'; import MonitorAlbumsSelectInput from './MonitorAlbumsSelectInput'; import NumberInput from './NumberInput'; import OAuthInputConnector from './OAuthInputConnector'; @@ -25,6 +27,9 @@ import styles from './FormInputGroup.css'; function getComponent(type) { switch (type) { + case inputTypes.AUTO_COMPLETE: + return AutoCompleteInput; + case inputTypes.CAPTCHA: return CaptchaInputConnector; @@ -34,6 +39,9 @@ function getComponent(type) { case inputTypes.DEVICE: return DeviceInputConnector; + case inputTypes.KEY_VALUE_LIST: + return KeyValueListInput; + case inputTypes.MONITOR_ALBUMS_SELECT: return MonitorAlbumsSelectInput; diff --git a/frontend/src/Components/Form/KeyValueListInput.css b/frontend/src/Components/Form/KeyValueListInput.css new file mode 100644 index 000000000..59be3a4d7 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInput.css @@ -0,0 +1,21 @@ +.inputContainer { + composes: input from 'Components/Form/Input.css'; + + position: relative; + min-height: 35px; + height: auto; + + &.isFocused { + outline: 0; + border-color: $inputFocusBorderColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor; + } +} + +.hasError { + composes: hasError from 'Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; +} diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js new file mode 100644 index 000000000..a52c76f70 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInput.js @@ -0,0 +1,152 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import KeyValueListInputItem from './KeyValueListInputItem'; +import styles from './KeyValueListInput.css'; + +class KeyValueListInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isFocused: false + }; + } + + // + // Listeners + + onItemChange = (index, itemValue) => { + const { + name, + value, + onChange + } = this.props; + + const newValue = [...value]; + + if (index == null) { + newValue.push(itemValue); + } else { + newValue.splice(index, 1, itemValue); + } + + onChange({ + name, + value: newValue + }); + } + + onRemoveItem = (index) => { + const { + name, + value, + onChange + } = this.props; + + const newValue = [...value]; + newValue.splice(index, 1); + + onChange({ + name, + value: newValue + }); + } + + onFocus = () => { + this.setState({ + isFocused: true + }); + } + + onBlur = () => { + this.setState({ + isFocused: false + }); + + const { + name, + value, + onChange + } = this.props; + + const newValue = value.reduce((acc, v) => { + if (v.key || v.value) { + acc.push(v); + } + + return acc; + }, []); + + if (newValue.length !== value.length) { + onChange({ + name, + value: newValue + }); + } + } + + // + // Render + + render() { + const { + className, + value, + keyPlaceholder, + valuePlaceholder + } = this.props; + + const { isFocused } = this.state; + + return ( +
+ { + [...value, { key: '', value: '' }].map((v, index) => { + return ( + + ); + }) + } +
+ ); + } +} + +KeyValueListInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.object).isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + keyPlaceholder: PropTypes.string, + valuePlaceholder: PropTypes.string, + onChange: PropTypes.func.isRequired +}; + +KeyValueListInput.defaultProps = { + className: styles.inputContainer, + value: [] +}; + +export default KeyValueListInput; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css new file mode 100644 index 000000000..f77ea3470 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInputItem.css @@ -0,0 +1,14 @@ +.itemContainer { + display: flex; + margin-bottom: 3px; + border-bottom: 1px solid $inputBorderColor; + + &:last-child { + margin-bottom: 0; + } +} + +.keyInput, +.valueInput { + border: none; +} diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js new file mode 100644 index 000000000..4e465f3a9 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInputItem.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import TextInput from './TextInput'; +import styles from './KeyValueListInputItem.css'; + +class KeyValueListInputItem extends Component { + + // + // Listeners + + onKeyChange = ({ value: keyValue }) => { + const { + index, + value, + onChange + } = this.props; + + onChange(index, { key: keyValue, value }); + } + + onValueChange = ({ value }) => { + // TODO: Validate here or validate at a lower level component + + const { + index, + keyValue, + onChange + } = this.props; + + onChange(index, { key: keyValue, value }); + } + + onRemovePress = () => { + const { + index, + onRemove + } = this.props; + + onRemove(index); + } + + onFocus = () => { + this.props.onFocus(); + } + + onBlur = () => { + this.props.onBlur(); + } + + // + // Render + + render() { + const { + keyValue, + value, + keyPlaceholder, + valuePlaceholder, + isNew + } = this.props; + + return ( +
+ + + + + { + !isNew && + + } +
+ ); + } +} + +KeyValueListInputItem.propTypes = { + index: PropTypes.number, + keyValue: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + keyPlaceholder: PropTypes.string.isRequired, + valuePlaceholder: PropTypes.string.isRequired, + isNew: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + onBlur: PropTypes.func.isRequired +}; + +KeyValueListInputItem.defaultProps = { + keyPlaceholder: 'Key', + valuePlaceholder: 'Value' +}; + +export default KeyValueListInputItem; diff --git a/frontend/src/Components/Form/MonitorAlbumsSelectInput.js b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js index 71f39a146..a3780de56 100644 --- a/frontend/src/Components/Form/MonitorAlbumsSelectInput.js +++ b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js @@ -1,17 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; +import monitorOptions from 'Utilities/Artist/monitorOptions'; import SelectInput from './SelectInput'; -const monitorOptions = [ - { key: 'all', value: 'All Albums' }, - { key: 'future', value: 'Future Albums' }, - { key: 'missing', value: 'Missing Albums' }, - { key: 'existing', value: 'Existing Albums' }, - { key: 'first', value: 'Only First Album' }, - { key: 'latest', value: 'Only Latest Album' }, - { key: 'none', value: 'None' } -]; - function MonitorAlbumsSelectInput(props) { const { includeNoChange, diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js index 20b6fd0a1..c4ecc7e86 100644 --- a/frontend/src/Components/Form/NumberInput.js +++ b/frontend/src/Components/Form/NumberInput.js @@ -2,44 +2,91 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import TextInput from './TextInput'; +function parseValue(props, value) { + const { + isFloat, + min, + max + } = props; + + if (value == null || value === '') { + return min; + } + + let newValue = isFloat ? parseFloat(value) : parseInt(value); + + if (min != null && newValue != null && newValue < min) { + newValue = min; + } else if (max != null && newValue != null && newValue > max) { + newValue = max; + } + + return newValue; +} + class NumberInput extends Component { // - // Listeners + // Lifecycle - onChange = ({ name, value }) => { - let newValue = null; + constructor(props, context) { + super(props, context); + + this.state = { + value: props.value == null ? '' : props.value.toString(), + isFocused: false + }; + } - if (value) { - newValue = this.props.isFloat ? parseFloat(value) : parseInt(value); + componentDidUpdate(prevProps, prevState) { + const { value } = this.props; + + if (value !== prevProps.value && !this.state.isFocused) { + this.setState({ + value: value == null ? '' : value.toString() + }); } + } + + // + // Listeners + + onChange = ({ name, value }) => { + this.setState({ value }); this.props.onChange({ name, - value: newValue + value: parseValue(this.props, value) }); + + } + + onFocus = () => { + this.setState({ isFocused: true }); } onBlur = () => { const { name, - value, - min, - max, onChange } = this.props; - let newValue = value; + const { value } = this.state; + const parsedValue = parseValue(this.props, value); + const stringValue = parsedValue == null ? '' : parsedValue.toString(); - if (min != null && newValue != null && newValue < min) { - newValue = min; - } else if (max != null && newValue != null && newValue > max) { - newValue = max; + if (stringValue === value) { + this.setState({ isFocused: false }); + } else { + this.setState({ + value: stringValue, + isFocused: false + }); } onChange({ name, - value: newValue + value: parsedValue }); } @@ -47,18 +94,16 @@ class NumberInput extends Component { // Render render() { - const { - value, - ...otherProps - } = this.props; + const value = this.state.value; return ( ); } diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css index d1fdcb08e..0a8fa6ffe 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css +++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css @@ -14,9 +14,7 @@ } .freeSpace { - @add-mixin truncate; - - flex: 1 0 0; + flex: 0 0 auto; margin-left: 15px; color: $gray; text-align: right; diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js index 8cb5486bc..ff1e0e2db 100644 --- a/frontend/src/Components/Form/TagInputTag.js +++ b/frontend/src/Components/Form/TagInputTag.js @@ -33,7 +33,10 @@ class TagInputTag extends Component { } = this.props; return ( - + diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js index 92c0f4baf..9feefa616 100644 --- a/frontend/src/Components/Form/TextInput.js +++ b/frontend/src/Components/Form/TextInput.js @@ -127,6 +127,7 @@ class TextInput extends Component { hasError, hasWarning, hasButton, + step, onBlur } = this.props; @@ -146,6 +147,7 @@ class TextInput extends Component { )} name={name} value={value} + step={step} onChange={this.onChange} onFocus={this.onFocus} onBlur={onBlur} @@ -168,6 +170,7 @@ TextInput.propTypes = { hasError: PropTypes.bool, hasWarning: PropTypes.bool, hasButton: PropTypes.bool, + step: PropTypes.number, onChange: PropTypes.func.isRequired, onFocus: PropTypes.func, onBlur: PropTypes.func, diff --git a/frontend/src/Components/Icon.css b/frontend/src/Components/Icon.css index 8c6c10d0f..df1ff5327 100644 --- a/frontend/src/Components/Icon.css +++ b/frontend/src/Components/Icon.css @@ -6,10 +6,18 @@ color: inherit; } +.disabled { + color: $disabledColor; +} + .info { color: $infoColor; } +.pink { + color: $pink; +} + .success { color: $successColor; } diff --git a/frontend/src/Components/Label.css b/frontend/src/Components/Label.css index b63b760c2..df17427d9 100644 --- a/frontend/src/Components/Label.css +++ b/frontend/src/Components/Label.css @@ -30,6 +30,15 @@ } } +.disabled { + border-color: $disabledColor; + background-color: $disabledColor; + + &.outline { + color: $disabledColor; + } +} + .info { border-color: $infoColor; background-color: $infoColor; @@ -92,7 +101,7 @@ .large { padding: 3px 7px; font-weight: bold; - font-size: 14px; + font-size: $defaultFontSize; } /** Outline **/ diff --git a/frontend/src/Components/Link/IconButton.js b/frontend/src/Components/Link/IconButton.js index 084e57878..26aacb0bf 100644 --- a/frontend/src/Components/Link/IconButton.js +++ b/frontend/src/Components/Link/IconButton.js @@ -24,7 +24,6 @@ function IconButton(props) { isDisabled && styles.isDisabled )} isDisabled={isDisabled} - {...otherProps} > { - if (!newValue) { + onChange = (event, { newValue, method }) => { + if (method === 'up' || method === 'down') { return; } @@ -117,6 +117,7 @@ class ArtistSearchInput extends Component { if (!suggestions.length || highlightedSectionIndex && (event.key !== 'ArrowDown' || event.key !== 'ArrowUp')) { this.props.onGoToAddNewArtist(value); this._autosuggest.input.blur(); + this.reset(); return; } @@ -129,6 +130,9 @@ class ArtistSearchInput extends Component { } else { this.goToArtist(suggestions[highlightedSuggestionIndex]); } + + this._autosuggest.input.blur(); + this.reset(); } onBlur = () => { @@ -142,9 +146,15 @@ class ArtistSearchInput extends Component { // Check the title first and if there isn't a match fallback to // the alternate titles and finally the tags. + if (value.length === 1) { + return ( + artist.cleanName.startsWith(lowerCaseValue) || + artist.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue)) + ); + } + return ( artist.cleanName.contains(lowerCaseValue) || - // artist.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) || artist.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue)) ); }); @@ -153,7 +163,9 @@ class ArtistSearchInput extends Component { } onSuggestionsClearRequested = () => { - this.reset(); + this.setState({ + suggestions: [] + }); } onSuggestionSelected = (event, { suggestion }) => { diff --git a/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js b/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js index 44cc6b2cd..55f1d9a25 100644 --- a/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js +++ b/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js @@ -58,10 +58,10 @@ function createCleanArtistSelector() { }) }; }).sort((a, b) => { - if (a.cleanName < b.cleanName) { + if (a.sortName < b.sortName) { return -1; } - if (a.cleanName > b.cleanName) { + if (a.sortName > b.sortName) { return 1; } diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css index 3bfcbc10b..1974cbcb1 100644 --- a/frontend/src/Components/Page/Header/PageHeader.css +++ b/frontend/src/Components/Page/Header/PageHeader.css @@ -4,14 +4,19 @@ align-items: center; flex: 0 0 auto; height: $headerHeight; - background-color: #00a65b; + background-color: $themeAlternateBlue; color: $white; } .logoContainer { display: flex; - justify-content: center; + align-items: center; flex: 0 0 $sidebarWidth; + padding-left: 20px; +} + +.logoLink { + line-height: 0; } .logo { diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js index add4e1939..87cf317b3 100644 --- a/frontend/src/Components/Page/Header/PageHeader.js +++ b/frontend/src/Components/Page/Header/PageHeader.js @@ -51,7 +51,10 @@ class PageHeader extends Component { return (
- + - - - - -
- +
+ + + - {children} -
+
+ - + {children} +
- -
+ + + +
+ ); } } @@ -118,6 +122,7 @@ Page.propTypes = { isSidebarVisible: PropTypes.bool.isRequired, isUpdated: PropTypes.bool.isRequired, isDisconnected: PropTypes.bool.isRequired, + enableColorImpairedMode: PropTypes.bool.isRequired, onResize: PropTypes.func.isRequired, onSidebarToggle: PropTypes.func.isRequired, onSidebarVisibleChange: PropTypes.func.isRequired diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index bdb00ccec..4fd993002 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -1,4 +1,3 @@ -/* eslint max-params: 0 */ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; @@ -33,30 +32,45 @@ function createMapStateToProps() { (state) => state.artist, (state) => state.customFilters, (state) => state.tags, - (state) => state.settings, + (state) => state.settings.ui, + (state) => state.settings.qualityProfiles, + (state) => state.settings.languageProfiles, + (state) => state.settings.metadataProfiles, + (state) => state.settings.importLists, (state) => state.app, createDimensionsSelector(), - (artist, customFilters, tags, settings, app, dimensions) => { + ( + artist, + customFilters, + tags, + uiSettings, + qualityProfiles, + languageProfiles, + metadataProfiles, + importLists, + app, + dimensions + ) => { const isPopulated = ( artist.isPopulated && customFilters.isPopulated && tags.isPopulated && - settings.qualityProfiles.isPopulated && - settings.languageProfiles.isPopulated && - settings.metadataProfiles.isPopulated && - settings.importLists.isPopulated && - settings.ui.isPopulated + qualityProfiles.isPopulated && + languageProfiles.isPopulated && + metadataProfiles.isPopulated && + importLists.isPopulated && + uiSettings.isPopulated ); const hasError = !!( artist.error || customFilters.error || tags.error || - settings.qualityProfiles.error || - settings.languageProfiles.error || - settings.metadataProfiles.error || - settings.importLists.error || - settings.ui.error + qualityProfiles.error || + languageProfiles.error || + metadataProfiles.error || + importLists.error || + uiSettings.error ); return { @@ -65,13 +79,14 @@ function createMapStateToProps() { artistError: artist.error, customFiltersError: tags.error, tagsError: tags.error, - qualityProfilesError: settings.qualityProfiles.error, - languageProfilesError: settings.languageProfiles.error, - metadataProfilesError: settings.metadataProfiles.error, - importListsError: settings.importLists.error, - uiSettingsError: settings.ui.error, + qualityProfilesError: qualityProfiles.error, + languageProfilesError: languageProfiles.error, + metadataProfilesError: metadataProfiles.error, + importListsError: importLists.error, + uiSettingsError: uiSettings.error, isSmallScreen: dimensions.isSmallScreen, isSidebarVisible: app.isSidebarVisible, + enableColorImpairedMode: uiSettings.item.enableColorImpairedMode, version: app.version, isUpdated: app.isUpdated, isDisconnected: app.isDisconnected diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.css b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css index 5fb56b77c..638636ffb 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSection.css +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css @@ -22,6 +22,19 @@ justify-content: flex-end; } +.overflowMenuButton { + composes: menuButton from 'Components/Menu/ToolbarMenuButton.css'; +} + .overflowMenuItemIcon { margin-right: 8px; } + +@media only screen and (max-width: $breakpointSmall) { + .overflowMenuButton { + &::after { + margin-left: 0; + content: '\25BE'; + } + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js index 57b53ff4e..35ee586ec 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -160,6 +160,7 @@ class PageToolbarSection extends Component { !!overflowItems.length && @@ -179,14 +180,13 @@ class PageToolbarSection extends Component { return ( {label} diff --git a/frontend/src/Components/ProgressBar.css b/frontend/src/Components/ProgressBar.css index 2f0019043..777187eec 100644 --- a/frontend/src/Components/ProgressBar.css +++ b/frontend/src/Components/ProgressBar.css @@ -47,6 +47,10 @@ .danger { background-color: $dangerColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px); + } } .success { @@ -59,6 +63,10 @@ .warning { background-color: $warningColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px); + } } .info { diff --git a/frontend/src/Components/ProgressBar.js b/frontend/src/Components/ProgressBar.js index 112bfb575..3c16792fa 100644 --- a/frontend/src/Components/ProgressBar.js +++ b/frontend/src/Components/ProgressBar.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import classNames from 'classnames'; import { kinds, sizes } from 'Helpers/Props'; +import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; import styles from './ProgressBar.css'; function ProgressBar(props) { @@ -23,55 +24,65 @@ function ProgressBar(props) { const actualWidth = width ? `${width}px` : '100%'; return ( -
- { - showText && !!width && + + {(enableColorImpairedMode) => { + return (
-
-
- {progressText} -
-
-
- } + { + showText && width ? +
+
+
+ {progressText} +
+
+
: + null + } -
- { - showText && -
-
- {progressText} -
-
+ className={classNames( + className, + styles[kind], + enableColorImpairedMode && 'colorImpaired' + )} + aria-valuenow={progress} + aria-valuemin="0" + aria-valuemax="100" + style={{ width: progressPercent }} + /> + + { + showText ? +
+
+
+ {progressText} +
+
+
: + null + }
- } -
+ ); + }} +
); } diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 06c0cc0c5..4231a7728 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -11,6 +11,7 @@ import { setAppValue, setVersion } from 'Store/Actions/appActions'; import { update, updateItem, removeItem } from 'Store/Actions/baseActions'; import { fetchHealth } from 'Store/Actions/systemActions'; import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions'; function getState(status) { @@ -70,6 +71,7 @@ const mapDispatchToProps = { dispatchFetchHealth: fetchHealth, dispatchFetchQueue: fetchQueue, dispatchFetchQueueDetails: fetchQueueDetails, + dispatchFetchRootFolders: fetchRootFolders, dispatchFetchTags: fetchTags, dispatchFetchTagDetails: fetchTagDetails }; @@ -202,6 +204,7 @@ class SignalRConnector extends Component { if (body.action === 'updated') { this.props.dispatchUpdateItem({ section, ...body.resource }); + // Repopulate the page to handle recently imported file repopulatePage('trackFileUpdated'); } else if (body.action === 'deleted') { @@ -278,6 +281,10 @@ class SignalRConnector extends Component { // No-op for now, we may want this later } + handleRootfolder = () => { + this.props.dispatchFetchRootFolders(); + } + handleTag = (body) => { if (body.action === 'sync') { this.props.dispatchFetchTags(); @@ -386,6 +393,7 @@ SignalRConnector.propTypes = { dispatchFetchHealth: PropTypes.func.isRequired, dispatchFetchQueue: PropTypes.func.isRequired, dispatchFetchQueueDetails: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchTagDetails: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.js b/frontend/src/Components/Table/Cells/TableSelectCell.js index d516c43fd..9c10f4444 100644 --- a/frontend/src/Components/Table/Cells/TableSelectCell.js +++ b/frontend/src/Components/Table/Cells/TableSelectCell.js @@ -1,4 +1,3 @@ -/* eslint max-params: 0 */ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import CheckInput from 'Components/Form/CheckInput'; diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js index f66eec49a..612d95b8c 100644 --- a/frontend/src/Components/Table/Table.js +++ b/frontend/src/Components/Table/Table.js @@ -1,10 +1,10 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React from 'react'; import { icons, scrollDirections } from 'Helpers/Props'; import IconButton from 'Components/Link/IconButton'; import Scroller from 'Components/Scroller/Scroller'; -import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableHeader from './TableHeader'; import TableHeaderCell from './TableHeaderCell'; import TableSelectAllHeaderCell from './TableSelectAllHeaderCell'; @@ -25,123 +25,95 @@ function getTableHeaderCellProps(props) { }, {}); } -class Table extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isTableOptionsModalOpen: false - }; - } - - // - // Listeners - - onTableOptionsPress = () => { - this.setState({ isTableOptionsModalOpen: true }); - } - - onTableOptionsModalClose = () => { - this.setState({ isTableOptionsModalOpen: false }); - } - - // - // Render - - render() { - const { - className, - selectAll, - columns, - pageSize, - canModifyColumns, - children, - onSortPress, - onTableOptionChange, - ...otherProps - } = this.props; - - return ( - - - - { - selectAll && - - } - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if ((name === 'actions' || name === 'details') && onTableOptionChange) { - return ( - +
+ + { + selectAll && + + } + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if ( + (name === 'actions' || name === 'details') && + onTableOptionChange + ) { + return ( + + - - ); - } - - return ( - - {column.label} + ); - }) - } - - { - !!onTableOptionChange && - - } - - - {children} -
-
- ); - } + } + + return ( + + {column.label} + + ); + }) + } + + + {children} + + + ); } Table.propTypes = { className: PropTypes.string, selectAll: PropTypes.bool.isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, + optionsComponent: PropTypes.func, pageSize: PropTypes.number, canModifyColumns: PropTypes.bool, children: PropTypes.node, diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js index 73c4b7ec2..e4739e63f 100644 --- a/frontend/src/Components/Table/TableHeaderCell.js +++ b/frontend/src/Components/Table/TableHeaderCell.js @@ -30,6 +30,7 @@ class TableHeaderCell extends Component { const { className, name, + columnLabel, isSortable, isVisible, isModifiable, @@ -49,10 +50,11 @@ class TableHeaderCell extends Component { return ( isSortable ? {children} @@ -75,7 +77,7 @@ class TableHeaderCell extends Component { TableHeaderCell.propTypes = { className: PropTypes.string, name: PropTypes.string.isRequired, - label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + columnLabel: PropTypes.string, isSortable: PropTypes.bool, isVisible: PropTypes.bool, isModifiable: PropTypes.bool, diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js index df268a512..b1d016529 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js @@ -75,6 +75,4 @@ TableOptionsColumnDragPreview.propTypes = { }) }; -/* eslint-disable new-cap */ export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview); -/* eslint-enable new-cap */ diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js index 5d7da30b8..80f03e430 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js @@ -153,7 +153,6 @@ TableOptionsColumnDragSource.propTypes = { onColumnDragEnd: PropTypes.func.isRequired }; -/* eslint-disable new-cap */ export default DropTarget( TABLE_COLUMN, columnDropTarget, @@ -163,4 +162,3 @@ export default DropTarget( columnDragSource, collectDragSource )(TableOptionsColumnDragSource)); -/* eslint-enable new-cap */ diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js index 6f9ddaea5..351d827ca 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js @@ -249,6 +249,4 @@ TableOptionsModal.defaultProps = { canModifyColumns: true }; -/* eslint-disable new-cap */ export default DragDropContext(HTML5Backend)(TableOptionsModal); -/* eslint-enable new-cap */ diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js b/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js new file mode 100644 index 000000000..ff2b8538b --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import TableOptionsModal from './TableOptionsModal'; + +class TableOptionsModalWrapper extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + columns, + children, + ...otherProps + } = this.props; + + return ( + + { + React.cloneElement(children, { onPress: this.onTableOptionsPress }) + } + + + + ); + } +} + +TableOptionsModalWrapper.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + children: PropTypes.node.isRequired +}; + +export default TableOptionsModalWrapper; diff --git a/frontend/src/Components/Tooltip/Popover.css b/frontend/src/Components/Tooltip/Popover.css index 852ea167f..f7b87f0b9 100644 --- a/frontend/src/Components/Tooltip/Popover.css +++ b/frontend/src/Components/Tooltip/Popover.css @@ -100,5 +100,6 @@ } .body { + overflow: auto; padding: 10px; } diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js index adb6c64d6..c958fce1b 100644 --- a/frontend/src/Components/Tooltip/Popover.js +++ b/frontend/src/Components/Tooltip/Popover.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import TetherComponent from 'react-tether'; import classNames from 'classnames'; +import isMobileUtil from 'Utilities/isMobile'; import { tooltipPositions } from 'Helpers/Props'; import styles from './Popover.css'; @@ -67,7 +68,9 @@ class Popover extends Component { // Listeners onClick = () => { - this.setState({ isOpen: !this.state.isOpen }); + if (isMobileUtil()) { + this.setState({ isOpen: !this.state.isOpen }); + } } onMouseEnter = () => { @@ -105,7 +108,7 @@ class Popover extends Component { > @@ -114,28 +117,28 @@ class Popover extends Component { { this.state.isOpen && -
-
-
- -
- {title} -
- -
- {body} +
+
+
+ +
+ {title} +
+ +
+ {body} +
-
} ); diff --git a/frontend/src/Components/Tooltip/Tooltip.js b/frontend/src/Components/Tooltip/Tooltip.js index 019484f5e..43caf87e8 100644 --- a/frontend/src/Components/Tooltip/Tooltip.js +++ b/frontend/src/Components/Tooltip/Tooltip.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import TetherComponent from 'react-tether'; import classNames from 'classnames'; +import isMobileUtil from 'Utilities/isMobile'; import { kinds, tooltipPositions } from 'Helpers/Props'; import styles from './Tooltip.css'; @@ -67,12 +68,14 @@ class Tooltip extends Component { // Listeners onClick = () => { - this.setState({ isOpen: !this.state.isOpen }); + if (isMobileUtil()) { + this.setState({ isOpen: !this.state.isOpen }); + } } onMouseEnter = () => { if (this._closeTimeout) { - clearTimeout(this._closeTimeout); + this._closeTimeout = clearTimeout(this._closeTimeout); } this.setState({ isOpen: true }); @@ -105,7 +108,7 @@ class Tooltip extends Component { > diff --git a/frontend/src/Components/withCurrentPage.js b/frontend/src/Components/withCurrentPage.js new file mode 100644 index 000000000..5e6d9ccf4 --- /dev/null +++ b/frontend/src/Components/withCurrentPage.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +function withCurrentPage(WrappedComponent) { + function CurrentPage(props) { + const { + history + } = props; + + return ( + + ); + } + + CurrentPage.propTypes = { + history: PropTypes.object.isRequired + }; + + return CurrentPage; +} + +export default withCurrentPage; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 6797c7087..1e3311aff 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -31,6 +31,7 @@ import { faBookmark as fasBookmark, faBookReader as fasBookReader, faBug as fasBug, + faBroadcastTower as fasBroadcastTower, faCalendarAlt as fasCalendarAlt, faCaretDown as fasCaretDown, faCheck as fasCheck, @@ -54,6 +55,7 @@ import { faFastBackward as fasFastBackward, faFastForward as fasFastForward, faFileImport as fasFileImport, + faFileInvoice as farFileInvoice, faFilter as fasFilter, faFolderOpen as fasFolderOpen, faForward as fasForward, @@ -77,7 +79,6 @@ import { faRocket as fasRocket, faSave as fasSave, faSearch as fasSearch, - faSignal as fasSignal, faSignOutAlt as fasSignOutAlt, faSitemap as fasSitemap, faSpinner as fasSpinner, @@ -88,12 +89,14 @@ import { faStop as fasStop, faSync as fasSync, faTags as fasTags, + faTable as fasTable, faTh as fasTh, faThList as fasThList, faTrashAlt as fasTrashAlt, faTimes as fasTimes, faTimesCircle as fasTimesCircle, faUser as fasUser, + faUserPlus as fasUserPlus, faVial as fasVial, faWrench as fasWrench } from '@fortawesome/free-solid-svg-icons'; @@ -151,9 +154,10 @@ export const INFO = fasInfoCircle; export const INTERACTIVE = fasUser; export const KEYBOARD = farKeyboard; export const LOGOUT = fasSignOutAlt; +export const MEDIA_INFO = farFileInvoice; export const MISSING = fasExclamationTriangle; export const MONITORED = fasBookmark; -export const NETWORK = fasSignal; +export const NETWORK = fasBroadcastTower; export const NAVBAR_COLLAPSE = fasBars; export const NOT_AIRED = farClock; export const ORGANIZE = fasSitemap; @@ -178,6 +182,7 @@ export const REORDER = fasBars; export const RSS = fasRss; export const SAVE = fasSave; export const SCHEDULED = farClock; +export const SCORE = fasUserPlus; export const SEARCH = fasSearch; export const ARTIST_CONTINUING = fasPlay; export const ARTIST_ENDED = fasStop; @@ -190,6 +195,7 @@ export const SPINNER = fasSpinner; export const STAR_FULL = fasStar; export const SUBTRACT = fasMinus; export const SYSTEM = fasLaptop; +export const TABLE = fasTable; export const TAGS = fasTags; export const TBA = fasQuestionCircle; export const TEST = fasVial; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 9832b7072..deb8dbb7d 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -1,6 +1,8 @@ +export const AUTO_COMPLETE = 'autoComplete'; export const CAPTCHA = 'captcha'; export const CHECK = 'check'; export const DEVICE = 'device'; +export const KEY_VALUE_LIST = 'keyValueList'; export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect'; export const NUMBER = 'number'; export const OAUTH = 'oauth'; @@ -18,9 +20,11 @@ export const TEXT = 'text'; export const TEXT_TAG = 'textTag'; export const all = [ + AUTO_COMPLETE, CAPTCHA, CHECK, DEVICE, + KEY_VALUE_LIST, MONITOR_ALBUMS_SELECT, NUMBER, OAUTH, diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js index cb2d5fabe..fd2c17f7b 100644 --- a/frontend/src/Helpers/Props/kinds.js +++ b/frontend/src/Helpers/Props/kinds.js @@ -1,7 +1,9 @@ export const DANGER = 'danger'; export const DEFAULT = 'default'; +export const DISABLED = 'disabled'; export const INFO = 'info'; export const INVERSE = 'inverse'; +export const PINK = 'pink'; export const PRIMARY = 'primary'; export const PURPLE = 'purple'; export const SUCCESS = 'success'; @@ -10,8 +12,10 @@ export const WARNING = 'warning'; export const all = [ DANGER, DEFAULT, + DISABLED, INFO, INVERSE, + PINK, PRIMARY, PURPLE, SUCCESS, diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index ce6a303fb..ada5c2f38 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -134,9 +134,17 @@ class InteractiveImportModalContent extends Component { } onImportSelectedPress = () => { + const { + downloadId, + showImportMode, + importMode, + onImportSelectedPress + } = this.props; + const selected = this.getSelectedIds(); + const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode; - this.props.onImportSelectedPress(selected, this.props.importMode); + onImportSelectedPress(selected, finalImportMode); } onFilterExistingFilesChange = (value) => { diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js index e88d54ad1..b28e4a2e3 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -10,7 +10,7 @@ import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import Popover from 'Components/Tooltip/Popover'; import TrackQuality from 'Album/TrackQuality'; -import EpisodeLanguage from 'Album/EpisodeLanguage'; +import TrackLanguage from 'Album/TrackLanguage'; import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; import SelectTrackModal from 'InteractiveImport/Track/SelectTrackModal'; @@ -271,7 +271,7 @@ class InteractiveImportRow extends Component { { !showLanguagePlaceholder && !!language && - diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js index 454be7e37..56e95b861 100644 --- a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js @@ -12,7 +12,7 @@ function createMapStateToProps() { (state) => state.settings.languageProfiles, (languageProfiles) => { const { - isFetchingSchema: isFetching, + isSchemaFetching: isFetching, isSchemaPopulated: isPopulated, schemaError: error, schema diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js index fba89b7ff..20a49c768 100644 --- a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js @@ -13,7 +13,7 @@ function createMapStateToProps() { (state) => state.settings.qualityProfiles, (qualityProfiles) => { const { - isFetchingSchema: isFetching, + isSchemaFetching: isFetching, isSchemaPopulated: isPopulated, schemaError: error, schema diff --git a/frontend/src/InteractiveSearch/InteractiveSearchModalContent.css b/frontend/src/InteractiveSearch/InteractiveSearch.css similarity index 69% rename from frontend/src/InteractiveSearch/InteractiveSearchModalContent.css rename to frontend/src/InteractiveSearch/InteractiveSearch.css index 8bd4c0f0d..5e647332f 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchModalContent.css +++ b/frontend/src/InteractiveSearch/InteractiveSearch.css @@ -3,3 +3,7 @@ justify-content: flex-end; margin-bottom: 10px; } + +.filteredMessage { + margin-top: 10px; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js new file mode 100644 index 000000000..6714c8cc6 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -0,0 +1,210 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Icon from 'Components/Icon'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageMenuButton from 'Components/Menu/PageMenuButton'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; +import InteractiveSearchRow from './InteractiveSearchRow'; +import styles from './InteractiveSearch.css'; + +const columns = [ + { + name: 'protocol', + label: 'Source', + isSortable: true, + isVisible: true + }, + { + name: 'age', + label: 'Age', + isSortable: true, + isVisible: true + }, + { + name: 'title', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isSortable: true, + isVisible: true + }, + { + name: 'peers', + label: 'Peers', + isSortable: true, + isVisible: true + }, + { + name: 'languageWeight', + label: 'Language', + isSortable: true, + isVisible: true + }, + { + name: 'qualityWeight', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'preferredWordScore', + label: React.createElement(Icon, { + name: icons.SCORE, + title: 'Preferred word score' + }), + isSortable: true, + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + title: 'Rejections' + }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + }, + { + name: 'releaseWeight', + label: React.createElement(Icon, { name: icons.DOWNLOAD }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + } +]; + +function InteractiveSearch(props) { + const { + searchPayload, + isFetching, + isPopulated, + error, + totalReleasesCount, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + type, + longDateFormat, + timeFormat, + onSortPress, + onFilterSelect, + onGrabPress + } = props; + + return ( +
+
+ +
+ + { + isFetching && + + } + + { + !isFetching && !!error && +
+ Unable to load results for this album search. Try again later +
+ } + + { + !isFetching && isPopulated && !totalReleasesCount && +
+ No results found +
+ } + + { + !!totalReleasesCount && isPopulated && !items.length && +
+ All results are hidden by the applied filter +
+ } + + { + isPopulated && !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + totalReleasesCount !== items.length && !!items.length && +
+ Some results are hidden by the applied filter +
+ } +
+ ); +} + +InteractiveSearch.propTypes = { + searchPayload: PropTypes.object.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalReleasesCount: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + type: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +export default InteractiveSearch; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchModalContentConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js similarity index 52% rename from frontend/src/InteractiveSearch/InteractiveSearchModalContentConnector.js rename to frontend/src/InteractiveSearch/InteractiveSearchConnector.js index 53c906a2f..b8b764aa7 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchModalContentConnector.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js @@ -3,14 +3,14 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import * as releaseActions from 'Store/Actions/releaseActions'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import InteractiveSearchModalContent from './InteractiveSearchModalContent'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import InteractiveSearch from './InteractiveSearch'; -function createMapStateToProps() { +function createMapStateToProps(appState, { type }) { return createSelector( (state) => state.releases.items.length, - createClientSideCollectionSelector('releases'), + createClientSideCollectionSelector('releases', `releases.${type}`), createUISettingsSelector(), (totalReleasesCount, releases, uiSettings) => { return { @@ -25,16 +25,8 @@ function createMapStateToProps() { function createMapDispatchToProps(dispatch, props) { return { - dispatchFetchReleases({ albumId }) { - dispatch(releaseActions.fetchReleases({ albumId })); - }, - - dispatchCancelFetchReleases() { - dispatch(releaseActions.cancelFetchReleases()); - }, - - dispatchClearReleases() { - dispatch(releaseActions.clearReleases()); + dispatchFetchReleases(payload) { + dispatch(releaseActions.fetchReleases(payload)); }, onSortPress(sortKey, sortDirection) { @@ -42,33 +34,37 @@ function createMapDispatchToProps(dispatch, props) { }, onFilterSelect(selectedFilterKey) { - dispatch(releaseActions.setReleasesFilter({ selectedFilterKey })); + const action = props.type === 'album' ? + releaseActions.setAlbumReleasesFilter : + releaseActions.setArtistReleasesFilter; + + dispatch(action({ selectedFilterKey })); }, - onGrabPress(guid, indexerId) { - dispatch(releaseActions.grabRelease({ guid, indexerId })); + onGrabPress(payload) { + dispatch(releaseActions.grabRelease(payload)); } }; } -class InteractiveSearchModalContentConnector extends Component { +class InteractiveSearchConnector extends Component { // // Lifecycle componentDidMount() { const { - albumId + searchPayload, + isPopulated, + dispatchFetchReleases } = this.props; - this.props.dispatchFetchReleases({ - albumId - }); - } + // If search results are not yet isPopulated fetch them, + // otherwise re-show the existing props. - componentWillUnmount() { - this.props.dispatchCancelFetchReleases(); - this.props.dispatchClearReleases(); + if (!isPopulated) { + dispatchFetchReleases(searchPayload); + } } // @@ -81,18 +77,18 @@ class InteractiveSearchModalContentConnector extends Component { } = this.props; return ( - ); } } -InteractiveSearchModalContentConnector.propTypes = { - albumId: PropTypes.number, - dispatchFetchReleases: PropTypes.func.isRequired, - dispatchClearReleases: PropTypes.func.isRequired, - dispatchCancelFetchReleases: PropTypes.func.isRequired +InteractiveSearchConnector.propTypes = { + searchPayload: PropTypes.object.isRequired, + isPopulated: PropTypes.bool.isRequired, + dispatchFetchReleases: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchModalContentConnector); +export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js index 6c40023d1..5f79d0ec1 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { setReleasesFilter } from 'Store/Actions/releaseActions'; +import { setAlbumReleasesFilter, setArtistReleasesFilter } from 'Store/Actions/releaseActions'; import FilterModal from 'Components/Filter/FilterModal'; function createMapStateToProps() { @@ -20,7 +20,9 @@ function createMapStateToProps() { function createMapDispatchToProps(dispatch, props) { return { dispatchSetFilter(payload) { - const action = setReleasesFilter; + const action = props.type === 'album' ? + setAlbumReleasesFilter: + setArtistReleasesFilter; dispatch(action(payload)); } diff --git a/frontend/src/InteractiveSearch/InteractiveSearchModalContent.js b/frontend/src/InteractiveSearch/InteractiveSearchModalContent.js deleted file mode 100644 index 36a6b670a..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchModalContent.js +++ /dev/null @@ -1,217 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { align, icons, sortDirections } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import Icon from 'Components/Icon'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageMenuButton from 'Components/Menu/PageMenuButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; -import InteractiveSearchRow from './InteractiveSearchRow'; -import styles from './InteractiveSearchModalContent.css'; - -const columns = [ - { - name: 'protocol', - label: 'Source', - isSortable: true, - isVisible: true - }, - { - name: 'age', - label: 'Age', - isSortable: true, - isVisible: true - }, - { - name: 'title', - label: 'Title', - isSortable: true, - isVisible: true - }, - { - name: 'indexer', - label: 'Indexer', - isSortable: true, - isVisible: true - }, - { - name: 'size', - label: 'Size', - isSortable: true, - isVisible: true - }, - { - name: 'peers', - label: 'Peers', - isSortable: true, - isVisible: true - }, - { - name: 'qualityWeight', - label: 'Quality', - isSortable: true, - isVisible: true - }, - { - name: 'rejections', - label: React.createElement(Icon, { name: icons.DANGER }), - isSortable: true, - fixedSortDirection: sortDirections.ASCENDING, - isVisible: true - }, - { - name: 'releaseWeight', - label: React.createElement(Icon, { name: icons.DOWNLOAD }), - isSortable: true, - fixedSortDirection: sortDirections.ASCENDING, - isVisible: true - } -]; - -class InteractiveSearchModalContent extends Component { - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - totalReleasesCount, - items, - selectedFilterKey, - filters, - customFilters, - sortKey, - sortDirection, - longDateFormat, - timeFormat, - onSortPress, - onFilterSelect, - onGrabPress, - onModalClose - } = this.props; - - const hasItems = !!items.length; - - return ( - - - Interactive Album Search - - - - { -
-
- -
- - { - isFetching && - - } - - { - !isFetching && !!error && -
- Unable to load results for this album search. Try again later. -
- } - - { - !isFetching && isPopulated && !totalReleasesCount && -
- No results found. -
- } - - { - !!totalReleasesCount && isPopulated && !items.length && -
- All results are hidden by {filters.length > 1 ? 'filters' : 'a filter'}. -
- } - - { - !!items.length && - - - { - items.map((item) => { - return ( - - ); - }) - } - -
- } - - { - totalReleasesCount !== items.length && !!items.length && -
- Some results are hidden by {filters.length > 1 ? 'filters' : 'a filter'}. -
- } -
- } -
- - - - -
- ); - } -} - -InteractiveSearchModalContent.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - totalReleasesCount: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.string, - onSortPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired, - onGrabPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default InteractiveSearchModalContent; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css index c77b73e7d..98503e496 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -4,12 +4,25 @@ word-break: break-all; } -.quality { +.quality, +.language { composes: cell from 'Components/Table/Cells/TableRowCell.css'; text-align: center; } +.language { + width: 100px; +} + +.preferredWordScore { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 55px; + font-weight: bold; + cursor: default; +} + .rejected, .download { composes: cell from 'Components/Table/Cells/TableRowCell.css'; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js index 09908e526..cf4d16a3e 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -7,9 +7,11 @@ import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import Icon from 'Components/Icon'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import Popover from 'Components/Tooltip/Popover'; +import TrackLanguage from 'Album/TrackLanguage'; import TrackQuality from 'Album/TrackQuality'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import Peers from './Peers'; @@ -41,6 +43,17 @@ function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { class InteractiveSearchRow extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmGrabModalOpen: false + }; + } + // // Listeners @@ -49,9 +62,37 @@ class InteractiveSearchRow extends Component { guid, indexerId, onGrabPress - }= this.props; + } = this.props; + + onGrabPress({ + guid, + indexerId + }); + } + + onConfirmGrabPress = () => { + this.setState({ isConfirmGrabModalOpen: true }); + } + + onGrabConfirm = () => { + this.setState({ isConfirmGrabModalOpen: false }); + + const { + guid, + indexerId, + searchPayload, + onGrabPress + } = this.props; + + onGrabPress({ + guid, + indexerId, + ...searchPayload + }); + } - onGrabPress(guid, indexerId); + onGrabCancel = () => { + this.setState({ isConfirmGrabModalOpen: false }); } // @@ -71,6 +112,8 @@ class InteractiveSearchRow extends Component { seeders, leechers, quality, + language, + preferredWordScore, rejections, downloadAllowed, isGrabbing, @@ -119,10 +162,17 @@ class InteractiveSearchRow extends Component { } + + + + - + + + + + {preferredWordScore > 0 && `+${preferredWordScore}`} + {preferredWordScore < 0 && preferredWordScore} @@ -161,10 +211,20 @@ class InteractiveSearchRow extends Component { kind={grabError || !downloadAllowed ? kinds.DANGER : kinds.DEFAULT} title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)} isSpinning={isGrabbing} - onPress={this.onGrabPress} + onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress} /> } + + ); } @@ -185,6 +245,8 @@ InteractiveSearchRow.propTypes = { seeders: PropTypes.number, leechers: PropTypes.number, quality: PropTypes.object.isRequired, + language: PropTypes.object.isRequired, + preferredWordScore: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.string).isRequired, downloadAllowed: PropTypes.bool.isRequired, isGrabbing: PropTypes.bool.isRequired, @@ -192,10 +254,12 @@ InteractiveSearchRow.propTypes = { grabError: PropTypes.string, longDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired, + searchPayload: PropTypes.object.isRequired, onGrabPress: PropTypes.func.isRequired }; InteractiveSearchRow.defaultProps = { + rejections: [], isGrabbing: false, isGrabbed: false }; diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css b/frontend/src/RootFolder/RootFolderRow.css similarity index 100% rename from frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css rename to frontend/src/RootFolder/RootFolderRow.css diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js b/frontend/src/RootFolder/RootFolderRow.js similarity index 86% rename from frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js rename to frontend/src/RootFolder/RootFolderRow.js index 15d2cb7be..2a4038a54 100644 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js +++ b/frontend/src/RootFolder/RootFolderRow.js @@ -6,9 +6,9 @@ import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import styles from './ImportArtistRootFolderRow.css'; +import styles from './RootFolderRow.css'; -function ImportArtistRootFolderRow(props) { +function RootFolderRow(props) { const { id, path, @@ -49,7 +49,7 @@ function ImportArtistRootFolderRow(props) { ); } -ImportArtistRootFolderRow.propTypes = { +RootFolderRow.propTypes = { id: PropTypes.number.isRequired, path: PropTypes.string.isRequired, freeSpace: PropTypes.number.isRequired, @@ -57,9 +57,9 @@ ImportArtistRootFolderRow.propTypes = { onDeletePress: PropTypes.func.isRequired }; -ImportArtistRootFolderRow.defaultProps = { +RootFolderRow.defaultProps = { freeSpace: 0, unmappedFolders: [] }; -export default ImportArtistRootFolderRow; +export default RootFolderRow; diff --git a/frontend/src/RootFolder/RootFolderRowConnector.js b/frontend/src/RootFolder/RootFolderRowConnector.js new file mode 100644 index 000000000..ab0848e87 --- /dev/null +++ b/frontend/src/RootFolder/RootFolderRowConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import RootFolderRow from './RootFolderRow'; + +function createMapDispatchToProps(dispatch, props) { + return { + onDeletePress() { + dispatch(deleteRootFolder({ id: props.id })); + } + }; +} + +export default connect(null, createMapDispatchToProps)(RootFolderRow); diff --git a/frontend/src/RootFolder/RootFolders.js b/frontend/src/RootFolder/RootFolders.js new file mode 100644 index 000000000..57598dbb9 --- /dev/null +++ b/frontend/src/RootFolder/RootFolders.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import RootFolderRowConnector from './RootFolderRowConnector'; + +const rootFolderColumns = [ + { + name: 'path', + label: 'Path', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'unmappedFolders', + label: 'Unmapped Folders', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function RootFolders(props) { + const { + isFetching, + isPopulated, + error, + items + } = props; + + if (isFetching && !isPopulated) { + return ( + + ); + } + + if (!isFetching && !!error) { + return ( +
Unable to load root folders
+ ); + } + + return ( + + + { + items.map((rootFolder) => { + return ( + + ); + }) + } + +
+ ); +} + +RootFolders.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default RootFolders; diff --git a/frontend/src/RootFolder/RootFoldersConnector.js b/frontend/src/RootFolder/RootFoldersConnector.js new file mode 100644 index 000000000..39f140bcc --- /dev/null +++ b/frontend/src/RootFolder/RootFoldersConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import RootFolders from './RootFolders'; + +function createMapStateToProps() { + return createSelector( + (state) => state.rootFolders, + (rootFolders) => { + return rootFolders; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchRootFolders: fetchRootFolders +}; + +class RootFoldersConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRootFolders(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RootFoldersConnector.propTypes = { + dispatchFetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js index 6f1c99432..5da3e34dc 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js @@ -19,9 +19,9 @@ class AddDownloadClientModalContent extends Component { render() { const { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, usenetDownloadClients, torrentDownloadClients, onDownloadClientSelect, @@ -31,22 +31,22 @@ class AddDownloadClientModalContent extends Component { return ( - Add DownloadClient + Add Download Client { - isFetching && + isSchemaFetching && } { - !isFetching && !!error && + !isSchemaFetching && !!schemaError &&
Unable to add a new downloadClient, please try again.
} { - isPopulated && !error && + isSchemaPopulated && !schemaError &&
@@ -103,9 +103,9 @@ class AddDownloadClientModalContent extends Component { } AddDownloadClientModalContent.propTypes = { - isFetching: PropTypes.bool.isRequired, - error: PropTypes.object, - isPopulated: PropTypes.bool.isRequired, + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, onDownloadClientSelect: PropTypes.func.isRequired, diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js index d6015b934..99d5c4f19 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js @@ -11,9 +11,9 @@ function createMapStateToProps() { (state) => state.settings.downloadClients, (downloadClients) => { const { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, schema } = downloadClients; @@ -21,9 +21,9 @@ function createMapStateToProps() { const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' }); return { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, usenetDownloadClients, torrentDownloadClients }; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js index b8fde8bcc..6a86fef16 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -68,12 +68,18 @@ class DownloadClient extends Component {
- + { + enable ? + : + + }
+
{ !!message && + Host @@ -140,6 +140,7 @@ EditRemotePathMappingModalContent.propTypes = { isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, item: PropTypes.shape(remotePathMappingShape).isRequired, + downloadClientHosts: PropTypes.arrayOf(PropTypes.string).isRequired, onInputChange: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired, diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js index 00aa7b8ac..ae0e51bc0 100644 --- a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js @@ -13,11 +13,29 @@ const newRemotePathMapping = { localPath: '' }; +const selectDownloadClientHosts = createSelector( + (state) => state.settings.downloadClients.items, + (downloadClients) => { + return downloadClients.reduce((acc, downloadClient) => { + const host = downloadClient.fields.find((field) => { + return field.name === 'host'; + }); + + if (host && !acc.includes(host.value)) { + acc.push(host.value); + } + + return acc; + }, []); + } +); + function createRemotePathMappingSelector() { return createSelector( (state, { id }) => id, (state) => state.settings.remotePathMappings, - (id, remotePathMappings) => { + selectDownloadClientHosts, + (id, remotePathMappings, downloadClientHosts) => { const { isFetching, error, @@ -37,7 +55,8 @@ function createRemotePathMappingSelector() { isSaving, saveError, item: settings.settings, - ...settings + ...settings, + downloadClientHosts }; } ); @@ -55,8 +74,8 @@ function createMapStateToProps() { } const mapDispatchToProps = { - setRemotePathMappingValue, - saveRemotePathMapping + dispatchSetRemotePathMappingValue: setRemotePathMappingValue, + dispatchSaveRemotePathMapping: saveRemotePathMapping }; class EditRemotePathMappingModalContentConnector extends Component { @@ -67,7 +86,7 @@ class EditRemotePathMappingModalContentConnector extends Component { componentDidMount() { if (!this.props.id) { Object.keys(newRemotePathMapping).forEach((name) => { - this.props.setRemotePathMappingValue({ + this.props.dispatchSetRemotePathMappingValue({ name, value: newRemotePathMapping[name] }); @@ -85,11 +104,11 @@ class EditRemotePathMappingModalContentConnector extends Component { // Listeners onInputChange = ({ name, value }) => { - this.props.setRemotePathMappingValue({ name, value }); + this.props.dispatchSetRemotePathMappingValue({ name, value }); } onSavePress = () => { - this.props.saveRemotePathMapping({ id: this.props.id }); + this.props.dispatchSaveRemotePathMapping({ id: this.props.id }); } // @@ -111,8 +130,8 @@ EditRemotePathMappingModalContentConnector.propTypes = { isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, item: PropTypes.object.isRequired, - setRemotePathMappingValue: PropTypes.func.isRequired, - saveRemotePathMapping: PropTypes.func.isRequired, + dispatchSetRemotePathMappingValue: PropTypes.func.isRequired, + dispatchSaveRemotePathMapping: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css index a79efda26..13f35bed4 100644 --- a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css @@ -8,11 +8,15 @@ } .host { - flex: 0 0 300px; + @add-mixin truncate; + + flex: 0 1 300px; } .path { - flex: 0 0 400px; + @add-mixin truncate; + + flex: 0 1 400px; } .actions { diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css index 4ef9dcb0f..6d0079fd9 100644 --- a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css @@ -5,11 +5,15 @@ } .host { - flex: 0 0 300px; + @add-mixin truncate; + + flex: 0 1 300px; } .path { - flex: 0 0 400px; + @add-mixin truncate; + + flex: 0 1 400px; } .addRemotePathMapping { diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js index 4900119a3..7a029818a 100644 --- a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js @@ -17,8 +17,8 @@ function createMapStateToProps() { } const mapDispatchToProps = { - fetchRemotePathMappings, - deleteRemotePathMapping + dispatchFetchRemotePathMappings: fetchRemotePathMappings, + dispatchDeleteRemotePathMapping: deleteRemotePathMapping }; class RemotePathMappingsConnector extends Component { @@ -27,14 +27,14 @@ class RemotePathMappingsConnector extends Component { // Lifecycle componentDidMount() { - this.props.fetchRemotePathMappings(); + this.props.dispatchFetchRemotePathMappings(); } // // Listeners onConfirmDeleteRemotePathMapping = (id) => { - this.props.deleteRemotePathMapping({ id }); + this.props.dispatchDeleteRemotePathMapping({ id }); } // @@ -52,8 +52,8 @@ class RemotePathMappingsConnector extends Component { } RemotePathMappingsConnector.propTypes = { - fetchRemotePathMappings: PropTypes.func.isRequired, - deleteRemotePathMapping: PropTypes.func.isRequired + dispatchFetchRemotePathMappings: PropTypes.func.isRequired, + dispatchDeleteRemotePathMapping: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector); diff --git a/frontend/src/Settings/General/HostSettings.js b/frontend/src/Settings/General/HostSettings.js index 3e8941555..2f3de8562 100644 --- a/frontend/src/Settings/General/HostSettings.js +++ b/frontend/src/Settings/General/HostSettings.js @@ -87,56 +87,59 @@ function HostSettings(props) { { - enableSsl.value && - - SSL Port - - - + enableSsl.value ? + + SSL Port + + + : + null } { - isWindows && enableSsl.value && - - SSL Cert Hash - - - + isWindows && enableSsl.value ? + + SSL Cert Hash + + + : + null } { - mode !== 'service' && - - Open browser on start - - - + isWindows && mode !== 'service' ? + + Open browser on start + + + : + null }
diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js index ccbc4d787..71ddb0c4a 100644 --- a/frontend/src/Settings/General/UpdateSettings.js +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -6,6 +6,11 @@ import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; import FormInputGroup from 'Components/Form/FormInputGroup'; +const branchValues = [ + 'develop', + 'nightly' +]; + function UpdateSettings(props) { const { advancedSettings, @@ -39,12 +44,13 @@ function UpdateSettings(props) { Branch diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js index 2ade930f6..6c42c5ccd 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js @@ -19,9 +19,9 @@ class AddImportListModalContent extends Component { render() { const { - isFetching, - isPopulated, - error, + isSchemaFetching, + isSchemaPopulated, + schemaError, allLists, onImportListSelect, onModalClose @@ -35,17 +35,17 @@ class AddImportListModalContent extends Component { { - isFetching && + isSchemaFetching && } { - !isFetching && !!error && + !isSchemaFetching && !!schemaError &&
Unable to add a new list, please try again.
} { - isPopulated && !error && + isSchemaPopulated && !schemaError &&
@@ -85,9 +85,9 @@ class AddImportListModalContent extends Component { } AddImportListModalContent.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, allLists: PropTypes.arrayOf(PropTypes.object).isRequired, onImportListSelect: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js index 6985076ab..e3b5b1c08 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js @@ -10,18 +10,18 @@ function createMapStateToProps() { (state) => state.settings.importLists, (importLists) => { const { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, schema } = importLists; const allLists = schema; return { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, allLists }; } diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js index 2c6573c9e..c2996f0f7 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js @@ -67,9 +67,7 @@ function EditImportListModalContent(props) { { !isFetching && !error && - + Name diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportList.js b/frontend/src/Settings/ImportLists/ImportLists/ImportList.js index 407486e3a..f0b1044e7 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/ImportList.js +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.js @@ -7,18 +7,6 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import EditImportListModalConnector from './EditImportListModalConnector'; import styles from './ImportList.css'; -function getLabelKind(supports, enabled) { - if (!supports) { - return kinds.DEFAULT; - } - - if (!enabled) { - return kinds.DANGER; - } - - return kinds.SUCCESS; -} - class ImportList extends Component { // @@ -80,12 +68,13 @@ class ImportList extends Component {
- + { + enableAutomaticAdd && + + } +
- - ); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js index ecf813f80..9bfd9d1fd 100644 --- a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js @@ -19,9 +19,9 @@ class AddIndexerModalContent extends Component { render() { const { - isFetching, - isPopulated, - error, + isSchemaFetching, + isSchemaPopulated, + schemaError, usenetIndexers, torrentIndexers, onIndexerSelect, @@ -36,17 +36,17 @@ class AddIndexerModalContent extends Component { { - isFetching && + isSchemaFetching && } { - !isFetching && !!error && + !isSchemaFetching && !!schemaError &&
Unable to add a new indexer, please try again.
} { - isPopulated && !error && + isSchemaPopulated && !schemaError &&
@@ -103,9 +103,9 @@ class AddIndexerModalContent extends Component { } AddIndexerModalContent.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, onIndexerSelect: PropTypes.func.isRequired, diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js index 986466c09..d79f028da 100644 --- a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js @@ -11,9 +11,9 @@ function createMapStateToProps() { (state) => state.settings.indexers, (indexers) => { const { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, schema } = indexers; @@ -21,9 +21,9 @@ function createMapStateToProps() { const torrentIndexers = _.filter(schema, { protocol: 'torrent' }); return { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, usenetIndexers, torrentIndexers }; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js index 188160e4d..e2621e57f 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -64,9 +64,7 @@ function EditIndexerModalContent(props) { { !isFetching && !error && - + Name diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.js b/frontend/src/Settings/Indexers/Indexers/Indexer.js index 27ce30f44..9269f8532 100644 --- a/frontend/src/Settings/Indexers/Indexers/Indexer.js +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js @@ -7,18 +7,6 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import EditIndexerModalConnector from './EditIndexerModalConnector'; import styles from './Indexer.css'; -function getLabelKind(supports, enabled) { - if (!supports) { - return kinds.DEFAULT; - } - - if (!enabled) { - return kinds.DANGER; - } - - return kinds.SUCCESS; -} - class Indexer extends Component { // @@ -84,26 +72,37 @@ class Indexer extends Component {
- - - - - + + { + supportsRss && enableRss && + + } + + { + supportsSearch && enableAutomaticSearch && + + } + + { + supportsSearch && enableInteractiveSearch && + + } + + { + !enableRss && !enableAutomaticSearch && !enableInteractiveSearch && + + }
{ - this.setState({ isEditRestrictionModalOpen: true }); - } - - onEditRestrictionModalClose = () => { - this.setState({ isEditRestrictionModalOpen: false }); - } - - onDeleteRestrictionPress = () => { - this.setState({ - isEditRestrictionModalOpen: false, - isDeleteRestrictionModalOpen: true - }); - } - - onDeleteRestrictionModalClose= () => { - this.setState({ isDeleteRestrictionModalOpen: false }); - } - - onConfirmDeleteRestriction = () => { - this.props.onConfirmDeleteRestriction(this.props.id); - } - - // - // Render - - render() { - const { - id, - required, - ignored, - tags, - tagList - } = this.props; - - return ( - -
- { - split(required).map((item) => { - if (!item) { - return null; - } - - return ( - - ); - }) - } -
- -
- { - split(ignored).map((item) => { - if (!item) { - return null; - } - - return ( - - ); - }) - } -
- - - - - - -
- ); - } -} - -Restriction.propTypes = { - id: PropTypes.number.isRequired, - required: PropTypes.string.isRequired, - ignored: PropTypes.string.isRequired, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onConfirmDeleteRestriction: PropTypes.func.isRequired -}; - -Restriction.defaultProps = { - required: '', - ignored: '' -}; - -export default Restriction; diff --git a/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js b/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js deleted file mode 100644 index c53c05de2..000000000 --- a/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchRestrictions, deleteRestriction } from 'Store/Actions/settingsActions'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import Restrictions from './Restrictions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.restrictions, - createTagsSelector(), - (restrictions, tagList) => { - return { - ...restrictions, - tagList - }; - } - ); -} - -const mapDispatchToProps = { - fetchRestrictions, - deleteRestriction -}; - -class RestrictionsConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchRestrictions(); - } - - // - // Listeners - - onConfirmDeleteRestriction = (id) => { - this.props.deleteRestriction({ id }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -RestrictionsConnector.propTypes = { - fetchRestrictions: PropTypes.func.isRequired, - deleteRestriction: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(RestrictionsConnector); diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index 4aaf5540a..6ab1074ab 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -10,6 +10,7 @@ import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; import FormInputGroup from 'Components/Form/FormInputGroup'; +import RootFoldersConnector from 'RootFolder/RootFoldersConnector'; import NamingConnector from './Naming/NamingConnector'; const rescanAfterRefreshOptions = [ @@ -56,14 +57,20 @@ class MediaManagement extends Component { /> + + { isFetching && - +
+ +
} { !isFetching && error && -
Unable to load Media Management settings
+
+
Unable to load Media Management settings
+
} { @@ -72,8 +79,6 @@ class MediaManagement extends Component { id="mediaManagementSettings" {...otherProps} > - - { advancedSettings &&
@@ -369,6 +374,10 @@ class MediaManagement extends Component { } } + +
+ +
); diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js index 9570f23d9..102006adf 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -13,6 +13,95 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import NamingOption from './NamingOption'; import styles from './NamingModal.css'; +const separatorOptions = [ + { key: ' ', value: 'Space ( )' }, + { key: '.', value: 'Period (.)' }, + { key: '_', value: 'Underscore (_)' }, + { key: '-', value: 'Dash (-)' } +]; + +const caseOptions = [ + { key: 'title', value: 'Default Case' }, + { key: 'lower', value: 'Lower Case' }, + { key: 'upper', value: 'Upper Case' } +]; + +const fileNameTokens = [ + { + token: '{Artist Name} - {Album Title} - {track:00} - {Track Title} {Quality Full}', + example: 'Artist Name - Album Title - 01 - Track Title MP3-320 Proper' + }, + { + token: '{Artist.Name}.{Album.Title}.{track:00}.{TrackClean.Title}.{Quality.Full}', + example: 'Artist.Name.Album.Title.01.Track.Title.MP3-320' + } +]; + +const artistTokens = [ + { token: '{Artist Name}', example: 'Artist Name' }, + + { token: '{Artist NameThe}', example: 'Artist Name, The' }, + + { token: '{Artist CleanName}', example: 'Artist Name' } +]; + +const albumTokens = [ + { token: '{Album Title}', example: 'Album Title' }, + + { token: '{Album TitleThe}', example: 'Album Title, The' }, + + { token: '{Album CleanTitle}', example: 'Album Title' }, + + { token: '{Album Type}', example: 'Album Type' }, + + { token: '{Album Disambiguation}', example: 'Disambiguation' } +]; + +const mediumTokens = [ + { token: '{medium:0}', example: '1' }, + { token: '{medium:00}', example: '01' } +]; + +const mediumFormatTokens = [ + { token: '{Medium Format}', example: 'CD' } +]; + +const trackTokens = [ + { token: '{track:0}', example: '1' }, + { token: '{track:00}', example: '01' } +]; + +const releaseDateTokens = [ + { token: '{Release Year}', example: '2016' } +]; + +const trackTitleTokens = [ + { token: '{Track Title}', example: 'Track Title' }, + { token: '{Track CleanTitle}', example: 'Track Title' } +]; + +const qualityTokens = [ + { token: '{Quality Full}', example: 'FLAC Proper' }, + { token: '{Quality Title}', example: 'FLAC' } +]; + +const mediaInfoTokens = [ + { token: '{MediaInfo AudioCodec}', example: 'FLAC' }, + { token: '{MediaInfo AudioChannels}', example: '2.0' }, + { token: '{MediaInfo AudioBitsPerSample}', example: '24bit' }, + { token: '{MediaInfo AudioSampleRate}', example: '44.1kHz' } +]; + +const otherTokens = [ + { token: '{Release Group}', example: 'Rls Grp' }, + { token: '{Preferred Words}', example: 'iNTERNAL' } +]; + +const originalTokens = [ + { token: '{Original Title}', example: 'Artist.Name.S01E01.HDTV.x264-EVOLVE' }, + { token: '{Original Filename}', example: 'artist.name.s01e01.hdtv.x264-EVOLVE' } +]; + class NamingModal extends Component { // @@ -95,94 +184,6 @@ class NamingModal extends Component { case: tokenCase } = this.state; - const separatorOptions = [ - { key: ' ', value: 'Space ( )' }, - { key: '.', value: 'Period (.)' }, - { key: '_', value: 'Underscore (_)' }, - { key: '-', value: 'Dash (-)' } - ]; - - const caseOptions = [ - { key: 'title', value: 'Default Case' }, - { key: 'lower', value: 'Lower Case' }, - { key: 'upper', value: 'Upper Case' } - ]; - - const fileNameTokens = [ - { - token: '{Artist Name} - {Album Title} - {track:00} - {Track Title} {Quality Full}', - example: 'Artist Name - Album Title - 01 - Track Title MP3-320 Proper' - }, - { - token: '{Artist.Name}.{Album.Title}.{track:00}.{TrackClean.Title}.{Quality.Full}', - example: 'Artist.Name.Album.Title.01.Track.Title.MP3-320' - } - ]; - - const artistTokens = [ - { token: '{Artist Name}', example: 'Artist Name' }, - - { token: '{Artist NameThe}', example: 'Artist Name, The' }, - - { token: '{Artist CleanName}', example: 'Artist Name' } - ]; - - const albumTokens = [ - { token: '{Album Title}', example: 'Album Title' }, - - { token: '{Album TitleThe}', example: 'Album Title, The' }, - - { token: '{Album CleanTitle}', example: 'Album Title' }, - - { token: '{Album Type}', example: 'Album Type' }, - - { token: '{Album Disambiguation}', example: 'Disambiguation' } - ]; - - const mediumTokens = [ - { token: '{medium:0}', example: '1' }, - { token: '{medium:00}', example: '01' } - ]; - - const mediumFormatTokens = [ - { token: '{Medium Format}', example: 'CD' } - ]; - - const trackTokens = [ - { token: '{track:0}', example: '1' }, - { token: '{track:00}', example: '01' } - ]; - - const releaseDateTokens = [ - { token: '{Release Year}', example: '2016' } - ]; - - const trackTitleTokens = [ - { token: '{Track Title}', example: 'Track Title' }, - { token: '{Track CleanTitle}', example: 'Track Title' } - ]; - - const qualityTokens = [ - { token: '{Quality Full}', example: 'FLAC Proper' }, - { token: '{Quality Title}', example: 'FLAC' } - ]; - - const mediaInfoTokens = [ - { token: '{MediaInfo AudioCodec}', example: 'FLAC' }, - { token: '{MediaInfo AudioChannels}', example: '2.0' }, - { token: '{MediaInfo AudioBitsPerSample}', example: '24bit' }, - { token: '{MediaInfo AudioSampleRate}', example: '44.1kHz' } - ]; - - const releaseGroupTokens = [ - { token: '{Release Group}', example: 'Rls Grp' } - ]; - - const originalTokens = [ - { token: '{Original Title}', example: 'Artist.Name.S01E01.HDTV.x264-EVOLVE' }, - { token: '{Original Filename}', example: 'artist.name.s01e01.hdtv.x264-EVOLVE' } - ]; - return (
-
+
{ - releaseGroupTokens.map(({ token, example }) => { + otherTokens.map(({ token, example }) => { return ( -
+ Enable diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css b/frontend/src/Settings/Metadata/Metadata/Metadata.css index 2de4023de..31507ee23 100644 --- a/frontend/src/Settings/Metadata/Metadata/Metadata.css +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css @@ -10,8 +10,6 @@ font-size: 24px; } -.label { - composes: label from 'Components/Label.css'; - - width: 100%; +.section { + margin-top: 10px; } diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js index 392b4838f..eba01463c 100644 --- a/frontend/src/Settings/Metadata/Metadata/Metadata.js +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js @@ -6,14 +6,6 @@ import Label from 'Components/Label'; import EditMetadataModalConnector from './EditMetadataModalConnector'; import styles from './Metadata.css'; -function getKind(enable) { - if (enable) { - return kinds.SUCCESS; - } - - return kinds.DANGER; -} - class Metadata extends Component { // @@ -49,6 +41,17 @@ class Metadata extends Component { fields } = this.props; + const metadataFields = []; + const imageFields = []; + + fields.forEach((field) => { + if (field.section === 'metadata') { + metadataFields.push(field); + } else { + imageFields.push(field); + } + }); + return ( -
- -
-
{ - fields.map((field) => { - return ( - - ); - }) + enable ? + : + }
+ { + enable && !!metadataFields.length && +
+
+ Metadata +
+ + { + metadataFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + }) + } +
+ } + + { + enable && !!imageFields.length && +
+
+ Images +
+ + { + imageFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + }) + } +
+ } + { - isFetching && + isSchemaFetching && } { - !isFetching && !!error && + !isSchemaFetching && !!schemaError &&
Unable to add a new notification, please try again.
} { - isPopulated && !error && + isSchemaPopulated && !schemaError &&
{ @@ -74,9 +74,9 @@ class AddNotificationModalContent extends Component { } AddNotificationModalContent.propTypes = { - isFetching: PropTypes.bool.isRequired, - error: PropTypes.object, - isPopulated: PropTypes.bool.isRequired, + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, schema: PropTypes.arrayOf(PropTypes.object).isRequired, onNotificationSelect: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js index a65670eca..abeb5e2ac 100644 --- a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js @@ -10,16 +10,16 @@ function createMapStateToProps() { (state) => state.settings.notifications, (notifications) => { const { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, schema } = notifications; return { - isFetching, - error, - isPopulated, + isSchemaFetching, + isSchemaPopulated, + schemaError, schema }; } diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js index 0c1ca6566..ad699532c 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -72,9 +72,7 @@ function EditNotificationModalContent(props) { { !isFetching && !error && - + { !!message && - - - - - - - - - + { + supportsOnGrab && onGrab && + + } + + { + supportsOnAlbumDownload && onAlbumDownload && + + } + + { + supportsOnDownload && onDownload && + + } + + { + supportsOnUpgrade && onDownload && onUpgrade && + + } + + { + supportsOnRename && onRename && + + } + + { + !onGrab && !onAlbumDownload && !onDownload && !onRename && + + } + Protocol diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js index 8cd001950..5b7e036f5 100644 --- a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js @@ -16,6 +16,13 @@ const newDelayProfile = { tags: [] }; +const protocolOptions = [ + { key: 'preferUsenet', value: 'Prefer Usenet' }, + { key: 'preferTorrent', value: 'Prefer Torrent' }, + { key: 'onlyUsenet', value: 'Only Usenet' }, + { key: 'onlyTorrent', value: 'Only Torrent' } +]; + function createDelayProfileSelector() { return createSelector( (state, { id }) => id, @@ -50,13 +57,6 @@ function createMapStateToProps() { return createSelector( createDelayProfileSelector(), (delayProfile) => { - const protocolOptions = [ - { key: 'preferUsenet', value: 'Prefer Usenet' }, - { key: 'preferTorrent', value: 'Prefer Torrent' }, - { key: 'onlyUsenet', value: 'Only Usenet' }, - { key: 'onlyTorrent', value: 'Only Torrent' } - ]; - const enableUsenet = delayProfile.item.enableUsenet.value; const enableTorrent = delayProfile.item.enableTorrent.value; const preferredProtocol = delayProfile.item.preferredProtocol.value; diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js index 72ba7c057..fb3b043bf 100644 --- a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js @@ -35,6 +35,7 @@ function EditLanguageProfileModalContent(props) { const { id, name, + upgradeAllowed, cutoff, languages: itemLanguages } = item; @@ -58,9 +59,7 @@ function EditLanguageProfileModalContent(props) { { !isFetching && !error && - + Name @@ -73,19 +72,36 @@ function EditLanguageProfileModalContent(props) { - Cutoff + + Upgrades Allowed + + { + upgradeAllowed.value && + + Upgrade Until + + + + } + {item.language.name} @@ -135,6 +136,7 @@ class LanguageProfile extends Component { LanguageProfile.propTypes = { id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, + upgradeAllowed: PropTypes.bool.isRequired, cutoff: PropTypes.object.isRequired, languages: PropTypes.arrayOf(PropTypes.object).isRequired, isDeleting: PropTypes.bool.isRequired, diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js index 9846f4e4c..672e2f28c 100644 --- a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js @@ -61,9 +61,7 @@ function EditMetadataProfileModalContent(props) { { !isFetching && !error && - + Name diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js index d89f0b449..98349b32c 100644 --- a/frontend/src/Settings/Profiles/Profiles.js +++ b/frontend/src/Settings/Profiles/Profiles.js @@ -8,6 +8,7 @@ import QualityProfilesConnector from './Quality/QualityProfilesConnector'; import LanguageProfilesConnector from './Language/LanguageProfilesConnector'; import MetadataProfilesConnector from './Metadata/MetadataProfilesConnector'; import DelayProfilesConnector from './Delay/DelayProfilesConnector'; +import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector'; class Profiles extends Component { @@ -26,6 +27,7 @@ class Profiles extends Component { + ); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js index ece3df17c..e1d555b37 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -105,6 +105,7 @@ class EditQualityProfileModalContent extends Component { const { id, name, + upgradeAllowed, cutoff, items } = item; @@ -139,9 +140,7 @@ class EditQualityProfileModalContent extends Component { { !isFetching && !error && - +
@@ -159,18 +158,35 @@ class EditQualityProfileModalContent extends Component { - Cutoff + Upgrades Allowed + + { + upgradeAllowed.value && + + + Upgrade Until + + + + + }
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js index b4d521adb..f4a4ca414 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfile.js +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js @@ -65,6 +65,7 @@ class QualityProfile extends Component { const { id, name, + upgradeAllowed, cutoff, items, isDeleting @@ -97,20 +98,20 @@ class QualityProfile extends Component { } if (item.quality) { - const isCutoff = item.quality.id === cutoff; + const isCutoff = upgradeAllowed && item.quality.id === cutoff; return ( ); } - const isCutoff = item.id === cutoff; + const isCutoff = upgradeAllowed && item.id === cutoff; return ( - @@ -19,9 +19,9 @@ function EditRestrictionModal({ isOpen, onModalClose, ...otherProps }) { ); } -EditRestrictionModal.propTypes = { +EditReleaseProfileModal.propTypes = { isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; -export default EditRestrictionModal; +export default EditReleaseProfileModal; diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js similarity index 60% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js index 0089d153e..89b605652 100644 --- a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js @@ -2,19 +2,19 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { clearPendingChanges } from 'Store/Actions/baseActions'; -import EditRestrictionModal from './EditRestrictionModal'; +import EditReleaseProfileModal from './EditReleaseProfileModal'; const mapDispatchToProps = { clearPendingChanges }; -class EditRestrictionModalConnector extends Component { +class EditReleaseProfileModalConnector extends Component { // // Listeners onModalClose = () => { - this.props.clearPendingChanges({ section: 'settings.restrictions' }); + this.props.clearPendingChanges({ section: 'settings.releaseProfiles' }); this.props.onModalClose(); } @@ -23,7 +23,7 @@ class EditRestrictionModalConnector extends Component { render() { return ( - @@ -31,9 +31,9 @@ class EditRestrictionModalConnector extends Component { } } -EditRestrictionModalConnector.propTypes = { +EditReleaseProfileModalConnector.propTypes = { onModalClose: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; -export default connect(null, mapDispatchToProps)(EditRestrictionModalConnector); +export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector); diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css similarity index 100% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js similarity index 62% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js index eea3abad0..b2423e791 100644 --- a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js @@ -11,9 +11,12 @@ import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; import FormInputGroup from 'Components/Form/FormInputGroup'; -import styles from './EditRestrictionModalContent.css'; +import styles from './EditReleaseProfileModalContent.css'; -function EditRestrictionModalContent(props) { +// Tab, enter, and comma +const tagInputDelimiters = [9, 13, 188]; + +function EditReleaseProfileModalContent(props) { const { isSaving, saveError, @@ -21,7 +24,7 @@ function EditRestrictionModalContent(props) { onInputChange, onModalClose, onSavePress, - onDeleteRestrictionPress, + onDeleteReleaseProfilePress, ...otherProps } = props; @@ -29,19 +32,19 @@ function EditRestrictionModalContent(props) { id, required, ignored, + preferred, + includePreferredWhenRenaming, tags } = item; return ( - {id ? 'Edit Restriction' : 'Add Restriction'} + {id ? 'Edit Release Profile' : 'Add Release Profile'} - + Must Contain @@ -51,6 +54,7 @@ function EditRestrictionModalContent(props) { helpText="The release must contain at least one of these terms (case insensitive)" kind={kinds.SUCCESS} placeholder="Add new restriction" + delimiters={tagInputDelimiters} {...required} onChange={onInputChange} /> @@ -65,18 +69,49 @@ function EditRestrictionModalContent(props) { helpText="The release will be rejected if it contains one or more of terms (case insensitive)" kind={kinds.DANGER} placeholder="Add new restriction" + delimiters={tagInputDelimiters} {...ignored} onChange={onInputChange} /> + + Preferred + + + + + + Include Preferred when Renaming + + + + Tags @@ -89,7 +124,7 @@ function EditRestrictionModalContent(props) { @@ -113,14 +148,14 @@ function EditRestrictionModalContent(props) { ); } -EditRestrictionModalContent.propTypes = { +EditReleaseProfileModalContent.propTypes = { isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, item: PropTypes.object.isRequired, onInputChange: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired, - onDeleteRestrictionPress: PropTypes.func + onDeleteReleaseProfilePress: PropTypes.func }; -export default EditRestrictionModalContent; +export default EditReleaseProfileModalContent; diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js similarity index 62% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js index 322b0a8d9..447bea3c7 100644 --- a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js @@ -4,20 +4,22 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import selectSettings from 'Store/Selectors/selectSettings'; -import { setRestrictionValue, saveRestriction } from 'Store/Actions/settingsActions'; -import EditRestrictionModalContent from './EditRestrictionModalContent'; +import { setReleaseProfileValue, saveReleaseProfile } from 'Store/Actions/settingsActions'; +import EditReleaseProfileModalContent from './EditReleaseProfileModalContent'; -const newRestriction = { +const newReleaseProfile = { required: '', ignored: '', + preferred: [], + includePreferredWhenRenaming: false, tags: [] }; function createMapStateToProps() { return createSelector( (state, { id }) => id, - (state) => state.settings.restrictions, - (id, restrictions) => { + (state) => state.settings.releaseProfiles, + (id, releaseProfiles) => { const { isFetching, error, @@ -25,9 +27,9 @@ function createMapStateToProps() { saveError, pendingChanges, items - } = restrictions; + } = releaseProfiles; - const profile = id ? _.find(items, { id }) : newRestriction; + const profile = id ? _.find(items, { id }) : newReleaseProfile; const settings = selectSettings(profile, pendingChanges, saveError); return { @@ -44,21 +46,21 @@ function createMapStateToProps() { } const mapDispatchToProps = { - setRestrictionValue, - saveRestriction + setReleaseProfileValue, + saveReleaseProfile }; -class EditRestrictionModalContentConnector extends Component { +class EditReleaseProfileModalContentConnector extends Component { // // Lifecycle componentDidMount() { if (!this.props.id) { - Object.keys(newRestriction).forEach((name) => { - this.props.setRestrictionValue({ + Object.keys(newReleaseProfile).forEach((name) => { + this.props.setReleaseProfileValue({ name, - value: newRestriction[name] + value: newReleaseProfile[name] }); }); } @@ -74,11 +76,11 @@ class EditRestrictionModalContentConnector extends Component { // Listeners onInputChange = ({ name, value }) => { - this.props.setRestrictionValue({ name, value }); + this.props.setReleaseProfileValue({ name, value }); } onSavePress = () => { - this.props.saveRestriction({ id: this.props.id }); + this.props.saveReleaseProfile({ id: this.props.id }); } // @@ -86,7 +88,7 @@ class EditRestrictionModalContentConnector extends Component { render() { return ( - { + this.setState({ isEditReleaseProfileModalOpen: true }); + } + + onEditReleaseProfileModalClose = () => { + this.setState({ isEditReleaseProfileModalOpen: false }); + } + + onDeleteReleaseProfilePress = () => { + this.setState({ + isEditReleaseProfileModalOpen: false, + isDeleteReleaseProfileModalOpen: true + }); + } + + onDeleteReleaseProfileModalClose= () => { + this.setState({ isDeleteReleaseProfileModalOpen: false }); + } + + onConfirmDeleteReleaseProfile = () => { + this.props.onConfirmDeleteReleaseProfile(this.props.id); + } + + // + // Render + + render() { + const { + id, + required, + ignored, + preferred, + tags, + tagList + } = this.props; + + return ( + +
+ { + split(required).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + split(ignored).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + preferred.map((item) => { + const isPreferred = item.value >= 0; + + return ( + + ); + }) + } +
+ + + + + + +
+ ); + } +} + +ReleaseProfile.propTypes = { + id: PropTypes.number.isRequired, + required: PropTypes.string.isRequired, + ignored: PropTypes.string.isRequired, + preferred: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired +}; + +ReleaseProfile.defaultProps = { + required: '', + ignored: '', + preferred: [] +}; + +export default ReleaseProfile; diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css similarity index 74% rename from frontend/src/Settings/Indexers/Restrictions/Restrictions.css rename to frontend/src/Settings/Profiles/Release/ReleaseProfiles.css index 904a66a57..e3573452e 100644 --- a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css @@ -1,10 +1,10 @@ -.restrictions { +.releaseProfiles { display: flex; flex-wrap: wrap; } -.addRestriction { - composes: restriction from './Restriction.css'; +.addReleaseProfile { + composes: releaseProfile from './ReleaseProfile.css'; background-color: $cardAlternateBackgroundColor; color: $gray; diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.js b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js similarity index 54% rename from frontend/src/Settings/Indexers/Restrictions/Restrictions.js rename to frontend/src/Settings/Profiles/Release/ReleaseProfiles.js index 3c9493b39..73c648a04 100644 --- a/frontend/src/Settings/Indexers/Restrictions/Restrictions.js +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js @@ -5,11 +5,11 @@ import FieldSet from 'Components/FieldSet'; import Card from 'Components/Card'; import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; -import Restriction from './Restriction'; -import EditRestrictionModalConnector from './EditRestrictionModalConnector'; -import styles from './Restrictions.css'; +import ReleaseProfile from './ReleaseProfile'; +import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; +import styles from './ReleaseProfiles.css'; -class Restrictions extends Component { +class ReleaseProfiles extends Component { // // Lifecycle @@ -18,19 +18,19 @@ class Restrictions extends Component { super(props, context); this.state = { - isAddRestrictionModalOpen: false + isAddReleaseProfileModalOpen: false }; } // // Listeners - onAddRestrictionPress = () => { - this.setState({ isAddRestrictionModalOpen: true }); + onAddReleaseProfilePress = () => { + this.setState({ isAddReleaseProfileModalOpen: true }); } - onAddRestrictionModalClose = () => { - this.setState({ isAddRestrictionModalOpen: false }); + onAddReleaseProfileModalClose = () => { + this.setState({ isAddReleaseProfileModalOpen: false }); } // @@ -40,20 +40,20 @@ class Restrictions extends Component { const { items, tagList, - onConfirmDeleteRestriction, + onConfirmDeleteReleaseProfile, ...otherProps } = this.props; return ( -
+
-
+
{ return ( - ); }) }
-
@@ -87,12 +87,12 @@ class Restrictions extends Component { } } -Restrictions.propTypes = { +ReleaseProfiles.propTypes = { isFetching: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onConfirmDeleteRestriction: PropTypes.func.isRequired + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired }; -export default Restrictions; +export default ReleaseProfiles; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js new file mode 100644 index 000000000..dd4b41171 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchReleaseProfiles, deleteReleaseProfile } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ReleaseProfiles from './ReleaseProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.releaseProfiles, + createTagsSelector(), + (releaseProfiles, tagList) => { + return { + ...releaseProfiles, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + fetchReleaseProfiles, + deleteReleaseProfile +}; + +class ReleaseProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchReleaseProfiles(); + } + + // + // Listeners + + onConfirmDeleteReleaseProfile = (id) => { + this.props.deleteReleaseProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ReleaseProfilesConnector.propTypes = { + fetchReleaseProfiles: PropTypes.func.isRequired, + deleteReleaseProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js index 26b38bab2..e83236069 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinition.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -2,28 +2,38 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactSlider from 'react-slider'; import formatBytes from 'Utilities/Number/formatBytes'; +import roundNumber from 'Utilities/Number/roundNumber'; import { kinds } from 'Helpers/Props'; import Label from 'Components/Label'; import NumberInput from 'Components/Form/NumberInput'; import TextInput from 'Components/Form/TextInput'; import styles from './QualityDefinition.css'; +const MIN = 0; +const MAX = 1500; + const slider = { - min: 0, - max: 1500, - step: 1 + min: MIN, + max: roundNumber(Math.pow(MAX, 1 / 1.1)), + step: 0.1 }; -function getValue(value) { - if (value < slider.min) { - return slider.min; +function getValue(inputValue) { + if (inputValue < MIN) { + return MIN; } - if (value > slider.max) { - return slider.max; + if (inputValue > MAX) { + return MAX; } - return value; + return roundNumber(inputValue); +} + +function getSliderValue(value, defaultValue) { + const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue; + + return roundNumber(sliderValue); } class QualityDefinition extends Component { @@ -35,6 +45,11 @@ class QualityDefinition extends Component { super(props, context); this._forceUpdateTimeout = null; + + this.state = { + sliderMinSize: getSliderValue(props.minSize, slider.min), + sliderMaxSize: getSliderValue(props.maxSize, slider.max) + }; } componentDidMount() { @@ -54,15 +69,37 @@ class QualityDefinition extends Component { // // Listeners - onSizeChange = ([minSize, maxSize]) => { - maxSize = maxSize === slider.max ? null : maxSize; + onSliderChange = ([sliderMinSize, sliderMaxSize]) => { + this.setState({ + sliderMinSize, + sliderMaxSize + }); + + this.props.onSizeChange({ + minSize: roundNumber(Math.pow(sliderMinSize, 1.1)), + maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1)) + }); + } + + onAfterSliderChange = () => { + const { + minSize, + maxSize + } = this.props; - this.props.onSizeChange({ minSize, maxSize }); + this.setState({ + sliderMiSize: getSliderValue(minSize, slider.min), + sliderMaxSize: getSliderValue(maxSize, slider.max) + }); } onMinSizeChange = ({ value }) => { const minSize = getValue(value); + this.setState({ + sliderMinSize: getSliderValue(minSize, slider.min) + }); + this.props.onSizeChange({ minSize, maxSize: this.props.maxSize @@ -70,7 +107,11 @@ class QualityDefinition extends Component { } onMaxSizeChange = ({ value }) => { - const maxSize = value === slider.max ? null : getValue(value); + const maxSize = value === MAX ? null : getValue(value); + + this.setState({ + sliderMaxSize: getSliderValue(maxSize, slider.max) + }); this.props.onSizeChange({ minSize: this.props.minSize, @@ -92,6 +133,11 @@ class QualityDefinition extends Component { onTitleChange } = this.props; + const { + sliderMinSize, + sliderMaxSize + } = this.state; + const minBytes = minSize * 128; const maxBytes = maxSize && maxSize * 128; @@ -123,13 +169,14 @@ class QualityDefinition extends Component { max={slider.max} step={slider.step} minDistance={10} - value={[minSize || slider.min, maxSize || slider.max]} + value={[sliderMinSize, sliderMaxSize]} withBars={true} snapDragDisabled={true} className={styles.slider} barClassName={styles.bar} handleClassName={styles.handle} - onChange={this.onSizeChange} + onChange={this.onSliderChange} + onAfterChange={this.onAfterSliderChange} />
@@ -154,9 +201,10 @@ class QualityDefinition extends Component { @@ -169,7 +217,9 @@ class QualityDefinition extends Component { className={styles.sizeInput} name={`${id}.max`} min={minSize + 10} - value={maxSize || slider.max} + value={maxSize || MAX} + max={MAX} + step={0.1} isFloat={true} onChange={this.onMaxSizeChange} /> diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js index f902f3d02..a76c9440f 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js @@ -5,12 +5,6 @@ import { setQualityDefinitionValue } from 'Store/Actions/settingsActions'; import { clearPendingChanges } from 'Store/Actions/baseActions'; import QualityDefinition from './QualityDefinition'; -function mapStateToProps(state) { - return { - advancedSettings: state.settings.advancedSettings - }; -} - const mapDispatchToProps = { setQualityDefinitionValue, clearPendingChanges @@ -67,4 +61,4 @@ QualityDefinitionConnector.propTypes = { clearPendingChanges: PropTypes.func.isRequired }; -export default connect(mapStateToProps, mapDispatchToProps)(QualityDefinitionConnector); +export default connect(null, mapDispatchToProps)(QualityDefinitionConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js index 79a25f158..e7817de48 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js @@ -12,8 +12,8 @@ class QualityDefinitions extends Component { render() { const { - advancedSettings, items, + advancedSettings, ...otherProps } = this.props; @@ -27,8 +27,12 @@ class QualityDefinitions extends Component {
Quality
Title
Size Limit
- {advancedSettings && -
Kilobits Per Second
+ { + advancedSettings ? +
+ Kilobits Per Second +
: + null }
@@ -39,6 +43,7 @@ class QualityDefinitions extends Component { ); }) @@ -57,11 +62,11 @@ class QualityDefinitions extends Component { } QualityDefinitions.propTypes = { - advancedSettings: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired, error: PropTypes.object, defaultProfile: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired + items: PropTypes.arrayOf(PropTypes.object).isRequired, + advancedSettings: PropTypes.bool.isRequired }; export default QualityDefinitions; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js index a0d9973b6..c2f830afd 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js @@ -18,10 +18,10 @@ function createMapStateToProps() { }); return { - advancedSettings, ...qualityDefinitions, items, - hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges) + hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges), + advancedSettings }; } ); diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js index b340e09b9..744e4f49d 100644 --- a/frontend/src/Settings/Settings.js +++ b/frontend/src/Settings/Settings.js @@ -21,7 +21,7 @@ function Settings() {
- Naming and file management settings + Naming, file management settings and root folders
- Quality, Language, Metadata, and Delay profiles + Quality, Language, Metadata, Delay, and Release profiles
- Indexers and release restrictions + Indexers and indexer options
+
+ Protocol: {titleCase(preferredProtocol)} +
+ +
+ { + enableUsenet ? + `Usenet Delay: ${usenetDelay}` : + 'Usenet disabled' + } +
+ +
+ { + enableTorrent ? + `Torrent Delay: ${torrentDelay}` : + 'Torrents disabled' + } +
+
+ ); +} + +TagDetailsDelayProfile.propTypes = { + preferredProtocol: PropTypes.string.isRequired, + enableUsenet: PropTypes.bool.isRequired, + enableTorrent: PropTypes.bool.isRequired, + usenetDelay: PropTypes.number.isRequired, + torrentDelay: PropTypes.number.isRequired +}; + +export default TagDetailsDelayProfile; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index 3d6ec10a2..d5028ba74 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -9,6 +9,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalHeader from 'Components/Modal/ModalHeader'; import ModalBody from 'Components/Modal/ModalBody'; import ModalFooter from 'Components/Modal/ModalFooter'; +import TagDetailsDelayProfile from './TagDetailsDelayProfile'; import styles from './TagDetailsModalContent.css'; function TagDetailsModalContent(props) { @@ -19,7 +20,7 @@ function TagDetailsModalContent(props) { delayProfiles, importLists, notifications, - restrictions, + releaseProfiles, onModalClose, onDeleteTagPress } = props; @@ -53,13 +54,27 @@ function TagDetailsModalContent(props) { { !!delayProfiles.length && -
+
{ delayProfiles.map((item) => { + const { + id, + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = item; + return ( -
- {item.name} -
+ ); }) } @@ -97,10 +112,10 @@ function TagDetailsModalContent(props) { } { - !!restrictions.length && -
+ !!releaseProfiles.length && +
{ - restrictions.map((item) => { + releaseProfiles.map((item) => { return (
restrictionIds, - (state) => state.settings.restrictions.items, + (state) => state.settings.releaseProfiles.items, findMatchingItems ); } @@ -55,14 +55,14 @@ function createMapStateToProps() { createMatchingDelayProfilesSelector(), createMatchingImportListsSelector(), createMatchingNotificationsSelector(), - createMatchingRestrictionsSelector(), - (artist, delayProfiles, importLists, notifications, restrictions) => { + createMatchingReleaseProfilesSelector(), + (artist, delayProfiles, importLists, notifications, releaseProfiles) => { return { artist, delayProfiles, importLists, notifications, - restrictions + releaseProfiles }; } ); diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js index 46946e1f5..70b727387 100644 --- a/frontend/src/Settings/Tags/TagsConnector.js +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchTagDetails } from 'Store/Actions/tagActions'; -import { fetchDelayProfiles, fetchNotifications, fetchRestrictions, fetchImportLists } from 'Store/Actions/settingsActions'; +import { fetchDelayProfiles, fetchNotifications, fetchReleaseProfiles, fetchImportLists } from 'Store/Actions/settingsActions'; import Tags from './Tags'; function createMapStateToProps() { @@ -29,7 +29,7 @@ const mapDispatchToProps = { dispatchFetchDelayProfiles: fetchDelayProfiles, dispatchFetchImportLists: fetchImportLists, dispatchFetchNotifications: fetchNotifications, - dispatchFetchRestrictions: fetchRestrictions + dispatchFetchReleaseProfiles: fetchReleaseProfiles }; class MetadatasConnector extends Component { @@ -43,14 +43,14 @@ class MetadatasConnector extends Component { dispatchFetchDelayProfiles, dispatchFetchImportLists, dispatchFetchNotifications, - dispatchFetchRestrictions + dispatchFetchReleaseProfiles } = this.props; dispatchFetchTagDetails(); dispatchFetchDelayProfiles(); dispatchFetchImportLists(); dispatchFetchNotifications(); - dispatchFetchRestrictions(); + dispatchFetchReleaseProfiles(); } // @@ -70,7 +70,7 @@ MetadatasConnector.propTypes = { dispatchFetchDelayProfiles: PropTypes.func.isRequired, dispatchFetchImportLists: PropTypes.func.isRequired, dispatchFetchNotifications: PropTypes.func.isRequired, - dispatchFetchRestrictions: PropTypes.func.isRequired + dispatchFetchReleaseProfiles: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js index 319b42d0c..71356a5f0 100644 --- a/frontend/src/Settings/UI/UISettings.js +++ b/frontend/src/Settings/UI/UISettings.js @@ -164,7 +164,7 @@ class UISettings extends Component { legend="Style" > - Enable Color-Impaired mode + Enable Color-Impaired Mode { dispatch(set({ section, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: true, schemaError: null, schema: data @@ -22,7 +22,7 @@ function createFetchSchemaHandler(section, url) { promise.fail((xhr) => { dispatch(set({ section, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: true, schemaError: xhr })); diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js index e30e615b1..e7f1d4b04 100644 --- a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -5,7 +5,7 @@ import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import getSectionState from 'Utilities/State/getSectionState'; import { set, updateServerSideCollection } from '../baseActions'; -function createFetchServerSideCollectionHandler(section, url) { +function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) { return function(getState, payload, dispatch) { dispatch(set({ section, isFetching: true })); @@ -19,6 +19,10 @@ function createFetchServerSideCollectionHandler(section, url) { 'sortKey' ])); + if (fetchDataAugmenter) { + fetchDataAugmenter(getState, payload, data); + } + const { selectedFilterKey, filters, diff --git a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js index d036cb1ae..f81723769 100644 --- a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js +++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js @@ -5,10 +5,10 @@ import createSetServerSideCollectionPageHandler from './createSetServerSideColle import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler'; import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler'; -function createServerSideCollectionHandlers(section, url, fetchThunk, handlers) { +function createServerSideCollectionHandlers(section, url, fetchThunk, handlers, fetchDataAugmenter) { const actionHandlers = {}; const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH]; - const fetchHandler = createFetchServerSideCollectionHandler(section, url); + const fetchHandler = createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter); actionHandlers[fetchHandlerType] = fetchHandler; if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) { diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index 651f062ed..a268053f7 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -70,7 +70,7 @@ export default { isFetching: false, isPopulated: false, error: null, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: [], diff --git a/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js index 7b67c3a77..de57761d4 100644 --- a/frontend/src/Store/Actions/Settings/importLists.js +++ b/frontend/src/Store/Actions/Settings/importLists.js @@ -70,7 +70,7 @@ export default { isFetching: false, isPopulated: false, error: null, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: [], diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js index 622ae685f..ddab7c154 100644 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -70,7 +70,7 @@ export default { isFetching: false, isPopulated: false, error: null, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: [], diff --git a/frontend/src/Store/Actions/Settings/languageProfiles.js b/frontend/src/Store/Actions/Settings/languageProfiles.js index c89b78e5e..49fe9825b 100644 --- a/frontend/src/Store/Actions/Settings/languageProfiles.js +++ b/frontend/src/Store/Actions/Settings/languageProfiles.js @@ -54,7 +54,7 @@ export default { error: null, isDeleting: false, deleteError: null, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: {}, diff --git a/frontend/src/Store/Actions/Settings/metadataProfiles.js b/frontend/src/Store/Actions/Settings/metadataProfiles.js index 8cbf33365..a553068d1 100644 --- a/frontend/src/Store/Actions/Settings/metadataProfiles.js +++ b/frontend/src/Store/Actions/Settings/metadataProfiles.js @@ -54,7 +54,7 @@ export default { error: null, isDeleting: false, deleteError: null, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: {}, diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js index ee53c238f..b2c28dac9 100644 --- a/frontend/src/Store/Actions/Settings/notifications.js +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -67,7 +67,7 @@ export default { isFetching: false, isPopulated: false, error: null, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: [], diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js index 04cbab616..6fdc204a0 100644 --- a/frontend/src/Store/Actions/Settings/qualityProfiles.js +++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js @@ -54,7 +54,7 @@ export default { error: null, isDeleting: false, deleteError: null, - isFetchingSchema: false, + isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: {}, diff --git a/frontend/src/Store/Actions/Settings/releaseProfiles.js b/frontend/src/Store/Actions/Settings/releaseProfiles.js new file mode 100644 index 000000000..339e732f6 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/releaseProfiles.js @@ -0,0 +1,71 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.releaseProfiles'; + +// +// Actions Types + +export const FETCH_RELEASE_PROFILES = 'settings/releaseProfiles/fetchReleaseProfiles'; +export const SAVE_RELEASE_PROFILE = 'settings/releaseProfiles/saveReleaseProfile'; +export const DELETE_RELEASE_PROFILE = 'settings/releaseProfiles/deleteReleaseProfile'; +export const SET_RELEASE_PROFILE_VALUE = 'settings/releaseProfiles/setReleaseProfileValue'; + +// +// Action Creators + +export const fetchReleaseProfiles = createThunk(FETCH_RELEASE_PROFILES); +export const saveReleaseProfile = createThunk(SAVE_RELEASE_PROFILE); +export const deleteReleaseProfile = createThunk(DELETE_RELEASE_PROFILE); + +export const setReleaseProfileValue = createAction(SET_RELEASE_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_RELEASE_PROFILES]: createFetchHandler(section, '/releaseprofile'), + + [SAVE_RELEASE_PROFILE]: createSaveProviderHandler(section, '/releaseprofile'), + + [DELETE_RELEASE_PROFILE]: createRemoveItemHandler(section, '/releaseprofile') + }, + + // + // Reducers + + reducers: { + [SET_RELEASE_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/restrictions.js b/frontend/src/Store/Actions/Settings/restrictions.js deleted file mode 100644 index 190b5124e..000000000 --- a/frontend/src/Store/Actions/Settings/restrictions.js +++ /dev/null @@ -1,71 +0,0 @@ -import { createAction } from 'redux-actions'; -import { createThunk } from 'Store/thunks'; -import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; -import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; - -// -// Variables - -const section = 'settings.restrictions'; - -// -// Actions Types - -export const FETCH_RESTRICTIONS = 'settings/restrictions/fetchRestrictions'; -export const SAVE_RESTRICTION = 'settings/restrictions/saveRestriction'; -export const DELETE_RESTRICTION = 'settings/restrictions/deleteRestriction'; -export const SET_RESTRICTION_VALUE = 'settings/restrictions/setRestrictionValue'; - -// -// Action Creators - -export const fetchRestrictions = createThunk(FETCH_RESTRICTIONS); -export const saveRestriction = createThunk(SAVE_RESTRICTION); -export const deleteRestriction = createThunk(DELETE_RESTRICTION); - -export const setRestrictionValue = createAction(SET_RESTRICTION_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - isSaving: false, - saveError: null, - items: [], - pendingChanges: {} - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_RESTRICTIONS]: createFetchHandler(section, '/restriction'), - - [SAVE_RESTRICTION]: createSaveProviderHandler(section, '/restriction'), - - [DELETE_RESTRICTION]: createRemoveItemHandler(section, '/restriction') - }, - - // - // Reducers - - reducers: { - [SET_RESTRICTION_VALUE]: createSetSettingValueReducer(section) - } - -}; diff --git a/frontend/src/Store/Actions/addArtistActions.js b/frontend/src/Store/Actions/addArtistActions.js index 500709fc6..4e0a927f5 100644 --- a/frontend/src/Store/Actions/addArtistActions.js +++ b/frontend/src/Store/Actions/addArtistActions.js @@ -2,11 +2,12 @@ import _ from 'lodash'; import $ from 'jquery'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import { createThunk, handleThunks } from 'Store/thunks'; +import monitorOptions from 'Utilities/Artist/monitorOptions'; import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import getNewArtist from 'Utilities/Artist/getNewArtist'; +import { createThunk, handleThunks } from 'Store/thunks'; import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; import createHandleActions from './Creators/createHandleActions'; import { set, update, updateItem } from './baseActions'; @@ -31,7 +32,7 @@ export const defaultState = { defaults: { rootFolderPath: '', - monitor: 'allAlbums', + monitor: monitorOptions[0].key, qualityProfileId: 0, languageProfileId: 0, metadataProfileId: 0, diff --git a/frontend/src/Store/Actions/albumActions.js b/frontend/src/Store/Actions/albumActions.js index 937b48da6..fe3bc688d 100644 --- a/frontend/src/Store/Actions/albumActions.js +++ b/frontend/src/Store/Actions/albumActions.js @@ -189,13 +189,11 @@ export const actionHandlers = handleThunks({ monitored } = payload; - const albumSection = _.last(albumEntity.split('.')); - dispatch(batchActions( albumIds.map((albumId) => { return updateItem({ id: albumId, - section: albumSection, + section: albumEntity, isSaving: true }); }) @@ -213,7 +211,7 @@ export const actionHandlers = handleThunks({ albumIds.map((albumId) => { return updateItem({ id: albumId, - section: albumSection, + section: albumEntity, isSaving: false, monitored }); @@ -226,7 +224,7 @@ export const actionHandlers = handleThunks({ albumIds.map((albumId) => { return updateItem({ id: albumId, - section: albumSection, + section: albumEntity, isSaving: false }); }) diff --git a/frontend/src/Store/Actions/albumStudioActions.js b/frontend/src/Store/Actions/albumStudioActions.js index 11302ccbd..cf1ec7840 100644 --- a/frontend/src/Store/Actions/albumStudioActions.js +++ b/frontend/src/Store/Actions/albumStudioActions.js @@ -1,7 +1,5 @@ -import _ from 'lodash'; import $ from 'jquery'; import { createAction } from 'redux-actions'; -import getMonitoringOptions from 'Utilities/Artist/getMonitoringOptions'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; @@ -9,7 +7,7 @@ import createSetClientSideCollectionFilterReducer from './Creators/Reducers/crea import createHandleActions from './Creators/createHandleActions'; import { set } from './baseActions'; import { fetchAlbums } from './albumActions'; -import { fetchArtist, filters, filterPredicates } from './artistActions'; +import { filters, filterPredicates } from './artistActions'; // // Variables @@ -113,31 +111,15 @@ export const actionHandlers = handleThunks({ monitor } = payload; - let monitoringOptions = null; const artist = []; - const allArtists = getState().artist.items; artistIds.forEach((id) => { - const s = _.find(allArtists, { id }); const artistToUpdate = { id }; if (payload.hasOwnProperty('monitored')) { artistToUpdate.monitored = monitored; } - if (monitor) { - const { - albums, - options: artistMonitoringOptions - } = getMonitoringOptions(monitor); - - if (!monitoringOptions) { - monitoringOptions = artistMonitoringOptions; - } - - artistToUpdate.albums = albums; - } - artist.push(artistToUpdate); }); @@ -151,7 +133,7 @@ export const actionHandlers = handleThunks({ method: 'POST', data: JSON.stringify({ artist, - monitoringOptions + monitoringOptions: { monitor } }), dataType: 'json' }); diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index 8c2232c0a..bcbd9dc52 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -54,6 +54,7 @@ export const defaultState = { }, tableOptions: { + showBanners: false, showSearchAction: false }, diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js index f6dbcfe55..a6775e836 100644 --- a/frontend/src/Store/Actions/blacklistActions.js +++ b/frontend/src/Store/Actions/blacklistActions.js @@ -2,6 +2,7 @@ import { createAction } from 'redux-actions'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import { createThunk, handleThunks } from 'Store/thunks'; import { sortDirections } from 'Helpers/Props'; +import createClearReducer from './Creators/Reducers/createClearReducer'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; import createHandleActions from './Creators/createHandleActions'; import createRemoveItemHandler from './Creators/createRemoveItemHandler'; @@ -87,6 +88,7 @@ export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage'; export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort'; export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption'; export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist'; +export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist'; // // Action Creators @@ -100,6 +102,7 @@ export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE); export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT); export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION); export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST); +export const clearBlacklist = createAction(CLEAR_BLACKLIST); // // Action Handlers @@ -127,6 +130,15 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ - [SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(section) + [SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_BLACKLIST]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) }, defaultState, section); diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js index c9fdedf08..b189e0e08 100644 --- a/frontend/src/Store/Actions/calendarActions.js +++ b/frontend/src/Store/Actions/calendarActions.js @@ -6,8 +6,11 @@ import moment from 'moment'; import { filterTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import * as calendarViews from 'Calendar/calendarViews'; +import * as commandNames from 'Commands/commandNames'; +import createClearReducer from './Creators/Reducers/createClearReducer'; import createHandleActions from './Creators/createHandleActions'; import { set, update } from './baseActions'; +import { executeCommandHelper } from './commandActions'; // // Variables @@ -35,6 +38,12 @@ export const defaultState = { showUpcoming: true, error: null, items: [], + searchMissingCommandId: null, + + options: { + collapseMultipleAlbums: false, + showCutoffUnmetIcon: false + }, selectedFilterKey: 'monitored', @@ -67,7 +76,7 @@ export const defaultState = { export const persistState = [ 'calendar.view', 'calendar.selectedFilterKey', - 'calendar.showUpcoming' + 'calendar.options' ]; // @@ -78,9 +87,11 @@ export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount'; export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter'; export const SET_CALENDAR_VIEW = 'calendar/setCalendarView'; export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday'; -export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange'; export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange'; export const CLEAR_CALENDAR = 'calendar/clearCalendar'; +export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption'; +export const SEARCH_MISSING = 'calendar/searchMissing'; +export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange'; // // Helpers @@ -188,6 +199,8 @@ export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY); export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE); export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE); export const clearCalendar = createAction(CLEAR_CALENDAR); +export const setCalendarOption = createAction(SET_CALENDAR_OPTION); +export const searchMissing = createThunk(SEARCH_MISSING); // // Action Handlers @@ -195,11 +208,12 @@ export const clearCalendar = createAction(CLEAR_CALENDAR); export const actionHandlers = handleThunks({ [FETCH_CALENDAR]: function(getState, payload, dispatch) { const state = getState(); - const unmonitored = state.calendar.selectedFilterKey === 'all'; + const calendar = state.calendar; + const unmonitored = calendar.selectedFilterKey === 'all'; const { - time, - view + time = calendar.time, + view = calendar.view } = payload; const dayCount = state.calendar.dayCount; @@ -328,6 +342,22 @@ export const actionHandlers = handleThunks({ const time = moment(state.calendar.time).add(amount, viewRanges[view]); dispatch(fetchCalendar({ time, view })); + }, + + [SEARCH_MISSING]: function(getState, payload, dispatch) { + const { albumIds } = payload; + + const commandPayload = { + name: commandNames.ALBUM_SEARCH, + albumIds + }; + + executeCommandHelper(commandPayload, dispatch).then((data) => { + dispatch(set({ + section, + searchMissingCommandId: data.id + })); + }); } }); @@ -336,15 +366,23 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ - [CLEAR_CALENDAR]: (state) => { - const { - view, - selectedFilterKey, - showUpcoming, - ...otherDefaultState - } = defaultState; - - return Object.assign({}, state, otherDefaultState); + [CLEAR_CALENDAR]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }), + + [SET_CALENDAR_OPTION]: function(state, { payload }) { + const options = state.options; + + return { + ...state, + options: { + ...options, + ...payload + } + }; } }, defaultState, section); diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js index 2122b72a2..5a7baf58a 100644 --- a/frontend/src/Store/Actions/commandActions.js +++ b/frontend/src/Store/Actions/commandActions.js @@ -121,38 +121,42 @@ function scheduleRemoveCommand(command, dispatch) { }, 60000 * 5); } -// -// Action Handlers +export function executeCommandHelper( payload, dispatch) { + // TODO: show a message for the user + if (lastCommand && isSameCommand(lastCommand, payload)) { + console.warn('Please wait at least 5 seconds before running this command again'); + } -export const actionHandlers = handleThunks({ - [FETCH_COMMANDS]: createFetchHandler('commands', '/command'), + lastCommand = payload; - [EXECUTE_COMMAND]: function(getState, payload, dispatch) { - // TODO: show a message for the user - if (lastCommand && isSameCommand(lastCommand, payload)) { - console.warn('Please wait at least 5 seconds before running this command again'); - } + // clear last command after 5 seconds. + if (lastCommandTimeout) { + clearTimeout(lastCommandTimeout); + } - lastCommand = payload; + lastCommandTimeout = setTimeout(() => { + lastCommand = null; + }, 5000); - // clear last command after 5 seconds. - if (lastCommandTimeout) { - clearTimeout(lastCommandTimeout); - } + const promise = $.ajax({ + url: '/command', + method: 'POST', + data: JSON.stringify(payload) + }); - lastCommandTimeout = setTimeout(() => { - lastCommand = null; - }, 5000); + return promise.then((data) => { + dispatch(addCommand(data)); + }); +} - const promise = $.ajax({ - url: '/command', - method: 'POST', - data: JSON.stringify(payload) - }); +// +// Action Handlers - promise.done((data) => { - dispatch(addCommand(data)); - }); +export const actionHandlers = handleThunks({ + [FETCH_COMMANDS]: createFetchHandler('commands', '/command'), + + [EXECUTE_COMMAND]: function(getState, payload, dispatch) { + executeCommandHelper(payload, dispatch); }, [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'), diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index 715e06562..ea14fa47d 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -278,11 +278,13 @@ export const reducers = createHandleActions({ [SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section), - [CLEAR_HISTORY]: createClearReducer('history', { + [CLEAR_HISTORY]: createClearReducer(section, { isFetching: false, isPopulated: false, error: null, - items: [] + items: [], + totalPages: 0, + totalRecords: 0 }) }, defaultState, section); diff --git a/frontend/src/Store/Actions/importArtistActions.js b/frontend/src/Store/Actions/importArtistActions.js index 5d327644d..276b91d03 100644 --- a/frontend/src/Store/Actions/importArtistActions.js +++ b/frontend/src/Store/Actions/importArtistActions.js @@ -36,6 +36,7 @@ export const defaultState = { export const QUEUE_LOOKUP_ARTIST = 'importArtist/queueLookupArtist'; export const START_LOOKUP_ARTIST = 'importArtist/startLookupArtist'; export const CANCEL_LOOKUP_ARTIST = 'importArtist/cancelLookupArtist'; +export const LOOKUP_UNSEARCHED_ARTIST = 'importArtist/lookupUnsearchedArtist'; export const CLEAR_IMPORT_ARTIST = 'importArtist/clearImportArtist'; export const SET_IMPORT_ARTIST_VALUE = 'importArtist/setImportArtistValue'; export const IMPORT_ARTIST = 'importArtist/importArtist'; @@ -46,6 +47,7 @@ export const IMPORT_ARTIST = 'importArtist/importArtist'; export const queueLookupArtist = createThunk(QUEUE_LOOKUP_ARTIST); export const startLookupArtist = createThunk(START_LOOKUP_ARTIST); export const importArtist = createThunk(IMPORT_ARTIST); +export const lookupUnsearchedArtist = createThunk(LOOKUP_UNSEARCHED_ARTIST); export const clearImportArtist = createAction(CLEAR_IMPORT_ARTIST); export const cancelLookupArtist = createAction(CANCEL_LOOKUP_ARTIST); @@ -83,7 +85,7 @@ export const actionHandlers = handleThunks({ section, ...item, term, - queued: true, + isQueued: true, items: [] })); @@ -155,7 +157,7 @@ export const actionHandlers = handleThunks({ isPopulated: true, error: null, items: data, - queued: false, + isQueued: false, selectedArtist: queued.selectedArtist || data[0], updateOnly: true })); @@ -168,7 +170,7 @@ export const actionHandlers = handleThunks({ isFetching: false, isPopulated: false, error: xhr, - queued: false, + isQueued: false, updateOnly: true })); }); @@ -180,6 +182,29 @@ export const actionHandlers = handleThunks({ }); }, + [LOOKUP_UNSEARCHED_ARTIST]: function(getState, payload, dispatch) { + const state = getState().importArtist; + + if (state.isLookingUpArtist) { + return; + } + + state.items.forEach((item) => { + const id = item.id; + + if ( + !item.isPopulated && + !queue.includes(id) + ) { + queue.push(item.id); + } + }); + + if (queue.length) { + dispatch(startLookupArtist({ start: true })); + } + }, + [IMPORT_ARTIST]: function(getState, payload, dispatch) { dispatch(set({ section, isImporting: true })); @@ -251,7 +276,23 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ [CANCEL_LOOKUP_ARTIST]: function(state) { - return Object.assign({}, state, { isLookingUpArtist: false }); + queue.splice(0, queue.length); + + const items = state.items.map((item) => { + if (item.isQueued) { + return { + ...item, + isQueued: false + }; + } + + return item; + }); + + return Object.assign({}, state, { + isLookingUpArtist: false, + items + }); }, [CLEAR_IMPORT_ARTIST]: function(state) { diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js index 32a2aed02..d16b3d372 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -24,6 +24,10 @@ const paged = `${section}.paged`; // State export const defaultState = { + options: { + includeUnknownArtistItems: false + }, + status: { isFetching: false, isPopulated: false, @@ -54,6 +58,7 @@ export const defaultState = { { name: 'status', columnLabel: 'Status', + isSortable: true, isVisible: true, isModifiable: false }, @@ -75,6 +80,12 @@ export const defaultState = { isSortable: true, isVisible: false }, + { + name: 'language', + label: 'Language', + isSortable: true, + isVisible: false + }, { name: 'quality', label: 'Quality', @@ -122,12 +133,20 @@ export const defaultState = { }; export const persistState = [ + 'queue.options', 'queue.paged.pageSize', 'queue.paged.sortKey', 'queue.paged.sortDirection', 'queue.paged.columns' ]; +// +// Helpers + +function fetchDataAugmenter(getState, payload, data) { + data.includeUnknownArtistItems = getState().queue.options.includeUnknownArtistItems; +} + // // Actions Types @@ -144,6 +163,7 @@ export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage'; export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage'; export const SET_QUEUE_SORT = 'queue/setQueueSort'; export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption'; +export const SET_QUEUE_OPTION = 'queue/setQueueOption'; export const CLEAR_QUEUE = 'queue/clearQueue'; export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem'; @@ -167,6 +187,7 @@ export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE); export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE); export const setQueueSort = createThunk(SET_QUEUE_SORT); export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION); +export const setQueueOption = createAction(SET_QUEUE_OPTION); export const clearQueue = createAction(CLEAR_QUEUE); export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM); @@ -217,7 +238,9 @@ export const actionHandlers = handleThunks({ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE, [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE, [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT - }), + }, + fetchDataAugmenter + ), [GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) { const id = payload.id; @@ -392,11 +415,25 @@ export const reducers = createHandleActions({ [SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged), + [SET_QUEUE_OPTION]: function(state, { payload }) { + const queueOptions = state.options; + + return { + ...state, + options: { + ...queueOptions, + ...payload + } + }; + }, + [CLEAR_QUEUE]: createClearReducer(paged, { isFetching: false, isPopulated: false, error: null, - items: [] + items: [], + totalPages: 0, + totalRecords: 0 }) }, defaultState, section); diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js index 9d9ec2161..be36173bd 100644 --- a/frontend/src/Store/Actions/releaseActions.js +++ b/frontend/src/Store/Actions/releaseActions.js @@ -11,6 +11,9 @@ import createHandleActions from './Creators/createHandleActions'; // Variables export const section = 'releases'; +export const albumSection = 'releases.album'; +export const artistSection = 'releases.artist'; + let abortCurrentRequest = null; // @@ -139,12 +142,21 @@ export const defaultState = { label: 'Rejections', type: filterBuilderTypes.NUMBER } - ] + ], + + album: { + selectedFilterKey: 'all' + }, + + artist: { + selectedFilterKey: 'discography-pack' + } }; export const persistState = [ 'releases.selectedFilterKey', - 'releases.customFilters' + 'releases.album.customFilters', + 'releases.artist.customFilters' ]; // @@ -156,7 +168,8 @@ export const SET_RELEASES_SORT = 'releases/setReleasesSort'; export const CLEAR_RELEASES = 'releases/clearReleases'; export const GRAB_RELEASE = 'releases/grabRelease'; export const UPDATE_RELEASE = 'releases/updateRelease'; -export const SET_RELEASES_FILTER = 'releases/setReleasesFilter'; +export const SET_ALBUM_RELEASES_FILTER = 'releases/setAlbumReleasesFilter'; +export const SET_ARTIST_RELEASES_FILTER = 'releases/setArtistReleasesFilter'; // // Action Creators @@ -167,7 +180,8 @@ export const setReleasesSort = createAction(SET_RELEASES_SORT); export const clearReleases = createAction(CLEAR_RELEASES); export const grabRelease = createThunk(GRAB_RELEASE); export const updateRelease = createAction(UPDATE_RELEASE); -export const setReleasesFilter = createAction(SET_RELEASES_FILTER); +export const setAlbumReleasesFilter = createAction(SET_ALBUM_RELEASES_FILTER); +export const setArtistReleasesFilter = createAction(SET_ARTIST_RELEASES_FILTER); // // Helpers @@ -231,7 +245,13 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ [CLEAR_RELEASES]: (state) => { - return Object.assign({}, state, defaultState); + const { + album, + artist, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); }, [UPDATE_RELEASE]: (state, { payload }) => { @@ -254,6 +274,7 @@ export const reducers = createHandleActions({ }, [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section), - [SET_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(section) + [SET_ALBUM_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(albumSection), + [SET_ARTIST_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(artistSection) }, defaultState, section); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 5eaa1b81c..1455a6935 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -18,8 +18,8 @@ import namingExamples from './Settings/namingExamples'; import notifications from './Settings/notifications'; import qualityDefinitions from './Settings/qualityDefinitions'; import qualityProfiles from './Settings/qualityProfiles'; +import releaseProfiles from './Settings/releaseProfiles'; import remotePathMappings from './Settings/remotePathMappings'; -import restrictions from './Settings/restrictions'; import ui from './Settings/ui'; export * from './Settings/delayProfiles'; @@ -39,8 +39,8 @@ export * from './Settings/namingExamples'; export * from './Settings/notifications'; export * from './Settings/qualityDefinitions'; export * from './Settings/qualityProfiles'; +export * from './Settings/releaseProfiles'; export * from './Settings/remotePathMappings'; -export * from './Settings/restrictions'; export * from './Settings/ui'; // @@ -71,8 +71,8 @@ export const defaultState = { notifications: notifications.defaultState, qualityDefinitions: qualityDefinitions.defaultState, qualityProfiles: qualityProfiles.defaultState, + releaseProfiles: releaseProfiles.defaultState, remotePathMappings: remotePathMappings.defaultState, - restrictions: restrictions.defaultState, ui: ui.defaultState }; @@ -111,8 +111,8 @@ export const actionHandlers = handleThunks({ ...notifications.actionHandlers, ...qualityDefinitions.actionHandlers, ...qualityProfiles.actionHandlers, + ...releaseProfiles.actionHandlers, ...remotePathMappings.actionHandlers, - ...restrictions.actionHandlers, ...ui.actionHandlers }); @@ -142,8 +142,8 @@ export const reducers = createHandleActions({ ...notifications.reducers, ...qualityDefinitions.reducers, ...qualityProfiles.reducers, + ...releaseProfiles.reducers, ...remotePathMappings.reducers, - ...restrictions.reducers, ...ui.reducers }, defaultState, section); diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js index de156cde2..a7c3119b7 100644 --- a/frontend/src/Store/Actions/systemActions.js +++ b/frontend/src/Store/Actions/systemActions.js @@ -5,6 +5,7 @@ import { filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import { setAppValue } from 'Store/Actions/appActions'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createClearReducer from './Creators/Reducers/createClearReducer'; import createFetchHandler from './Creators/createFetchHandler'; import createRemoveItemHandler from './Creators/createRemoveItemHandler'; import createHandleActions from './Creators/createHandleActions'; @@ -79,25 +80,30 @@ export const defaultState = { columns: [ { name: 'level', - isSortable: true, - isVisible: true + columnLabel: 'Level', + isSortable: false, + isVisible: true, + isModifiable: false }, { name: 'logger', label: 'Component', - isSortable: true, - isVisible: true + isSortable: false, + isVisible: true, + isModifiable: false }, { name: 'message', label: 'Message', - isVisible: true + isVisible: true, + isModifiable: false }, { name: 'time', label: 'Time', isSortable: true, - isVisible: true + isVisible: true, + isModifiable: false }, { name: 'actions', @@ -199,7 +205,8 @@ export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage'; export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage'; export const SET_LOGS_SORT = 'system/logs/setLogsSort'; export const SET_LOGS_FILTER = 'system/logs/setLogsFilter'; -export const SET_LOGS_TABLE_OPTION = 'system/logs/ssetLogsTableOption'; +export const SET_LOGS_TABLE_OPTION = 'system/logs/setLogsTableOption'; +export const CLEAR_LOGS_TABLE = 'system/logs/clearLogsTable'; export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles'; export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles'; @@ -233,6 +240,7 @@ export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE); export const setLogsSort = createThunk(SET_LOGS_SORT); export const setLogsFilter = createThunk(SET_LOGS_FILTER); export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION); +export const clearLogsTable = createAction(CLEAR_LOGS_TABLE); export const fetchLogFiles = createThunk(FETCH_LOG_FILES); export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES); @@ -370,6 +378,15 @@ export const reducers = createHandleActions({ }; }, - [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs') + [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs'), + + [CLEAR_LOGS_TABLE]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) }, defaultState, section); diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js index 850e5387a..12ccce802 100644 --- a/frontend/src/Store/Actions/wantedActions.js +++ b/frontend/src/Store/Actions/wantedActions.js @@ -33,11 +33,6 @@ export const defaultState = { isSortable: true, isVisible: true }, - // { - // name: 'episode', - // label: 'Episode', - // isVisible: true - // }, { name: 'albumTitle', label: 'Album Title', @@ -112,11 +107,6 @@ export const defaultState = { isSortable: true, isVisible: true }, - // { - // name: 'episode', - // label: 'Episode', - // isVisible: true - // }, { name: 'albumTitle', label: 'Album Title', @@ -310,7 +300,9 @@ export const reducers = createHandleActions({ isFetching: false, isPopulated: false, error: null, - items: [] + items: [], + totalPages: 0, + totalRecords: 0 } ), @@ -320,7 +312,9 @@ export const reducers = createHandleActions({ isFetching: false, isPopulated: false, error: null, - items: [] + items: [], + totalPages: 0, + totalRecords: 0 } ) diff --git a/frontend/src/Store/Middleware/persistState.js b/frontend/src/Store/Middleware/createPersistState.js similarity index 86% rename from frontend/src/Store/Middleware/persistState.js rename to frontend/src/Store/Middleware/createPersistState.js index 88ca8dead..407044d56 100644 --- a/frontend/src/Store/Middleware/persistState.js +++ b/frontend/src/Store/Middleware/createPersistState.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import persistState from 'redux-localstorage'; import actions from 'Store/Actions'; +import migrate from 'Store/Migrators/migrate'; const columnPaths = []; @@ -90,4 +91,11 @@ const config = { key: 'lidarr' }; -export default persistState(paths, config); +export default function createPersistState() { + // Migrate existing local storage before proceeding + const persistedState = JSON.parse(localStorage.getItem(config.key)); + migrate(persistedState); + localStorage.setItem(config.key, serialize(persistedState)); + + return persistState(paths, config); +} diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js index a1c400bfe..59937bc45 100644 --- a/frontend/src/Store/Middleware/middlewares.js +++ b/frontend/src/Store/Middleware/middlewares.js @@ -2,7 +2,7 @@ import { applyMiddleware, compose } from 'redux'; import thunk from 'redux-thunk'; import { routerMiddleware } from 'react-router-redux'; import createSentryMiddleware from './createSentryMiddleware'; -import persistState from './persistState'; +import createPersistState from './createPersistState'; export default function(history) { const middlewares = []; @@ -20,6 +20,6 @@ export default function(history) { return composeEnhancers( applyMiddleware(...middlewares), - persistState + createPersistState() ); } diff --git a/frontend/src/Store/Migrators/migrate.js b/frontend/src/Store/Migrators/migrate.js new file mode 100644 index 000000000..36dbbb8c3 --- /dev/null +++ b/frontend/src/Store/Migrators/migrate.js @@ -0,0 +1,5 @@ +import migrateAddArtistDefaults from './migrateAddArtistDefaults'; + +export default function migrate(persistedState) { + migrateAddArtistDefaults(persistedState); +} diff --git a/frontend/src/Store/Migrators/migrateAddArtistDefaults.js b/frontend/src/Store/Migrators/migrateAddArtistDefaults.js new file mode 100644 index 000000000..731bb00c6 --- /dev/null +++ b/frontend/src/Store/Migrators/migrateAddArtistDefaults.js @@ -0,0 +1,14 @@ +import { get } from 'lodash'; +import monitorOptions from 'Utilities/Artist/monitorOptions'; + +export default function migrateAddArtistDefaults(persistedState) { + const monitor = get(persistedState, 'addArtist.defaults.monitor'); + + if (!monitor) { + return; + } + + if (!monitorOptions.find((option) => option.key === monitor)) { + persistedState.addArtist.defaults.monitor = monitorOptions[0].key; + } +} diff --git a/frontend/src/Store/Selectors/createArtistCountSelector.js b/frontend/src/Store/Selectors/createArtistCountSelector.js index 71910cb44..5920b4099 100644 --- a/frontend/src/Store/Selectors/createArtistCountSelector.js +++ b/frontend/src/Store/Selectors/createArtistCountSelector.js @@ -4,8 +4,8 @@ import createAllArtistSelector from './createAllArtistSelector'; function createArtistCountSelector() { return createSelector( createAllArtistSelector(), - (series) => { - return series.length; + (artists) => { + return artists.length; } ); } diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js index 929b0afe0..36f9d4a56 100644 --- a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -52,7 +52,14 @@ function filter(items, state) { const predicate = filterTypePredicates[type]; if (Array.isArray(value)) { - accepted = value.some((v) => predicate(item[key], v)); + if ( + type === filterTypes.NOT_CONTAINS || + type === filterTypes.NOT_EQUAL + ) { + accepted = value.every((v) => predicate(item[key], v)); + } else { + accepted = value.some((v) => predicate(item[key], v)); + } } else { accepted = predicate(item[key], value); } diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js index 2c4816a17..46659609f 100644 --- a/frontend/src/Store/Selectors/createProviderSettingsSelector.js +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js @@ -12,7 +12,7 @@ function createProviderSettingsSelector(sectionName) { const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); const { - isFetchingSchema: isFetching, + isSchemaFetching: isFetching, isSchemaPopulated: isPopulated, schemaError: error, isSaving, diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.js index fd131cd29..089795ced 100644 --- a/frontend/src/Store/Selectors/createQueueItemSelector.js +++ b/frontend/src/Store/Selectors/createQueueItemSelector.js @@ -3,14 +3,18 @@ import { createSelector } from 'reselect'; function createQueueItemSelector() { return createSelector( (state, { albumId }) => albumId, - (state) => state.queue.details, + (state) => state.queue.details.items, (albumId, details) => { if (!albumId) { return null; } - return details.items.find((item) => { - return item.album.id === albumId; + return details.find((item) => { + if (item.album) { + return item.album.id === albumId; + } + + return false; }); } ); diff --git a/frontend/src/Store/Selectors/createTrackFileSelector.js b/frontend/src/Store/Selectors/createTrackFileSelector.js index 7eced03a8..bcfc5cb0b 100644 --- a/frontend/src/Store/Selectors/createTrackFileSelector.js +++ b/frontend/src/Store/Selectors/createTrackFileSelector.js @@ -6,7 +6,7 @@ function createTrackFileSelector() { (state) => state.trackFiles, (trackFileId, trackFiles) => { if (!trackFileId) { - return null; + return; } return trackFiles.items.find((trackFile) => trackFile.id === trackFileId); diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js index 2592b8a71..8626225cf 100644 --- a/frontend/src/Styles/Variables/colors.js +++ b/frontend/src/Styles/Variables/colors.js @@ -6,6 +6,7 @@ module.exports = { dimColor: '#555', black: '#000', white: '#fff', + offWhite: '#f5f7fa', blue: '#06f', yellow: '#FFA500', primaryColor: '#0b8750', @@ -15,8 +16,7 @@ module.exports = { warningColor: '#ffa500', infoColor: lidarrGreen, purple: '#7a43b6', - nzbdronePurple: '#7932ea', - nzbdronePink: '#f43565', + pink: '#ff69b4', lidarrGreen, helpTextColor: '#909293', darkGray: '#888', @@ -27,6 +27,7 @@ module.exports = { // Theme Colors themeBlue: lidarrGreen, + themeAlternateBlue: '#00a65b', themeRed: '#c4273c', themeDarkColor: '#353535', themeLightColor: '#1d563d', @@ -62,6 +63,7 @@ module.exports = { inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)', inputWarningBorderColor: '#ffa500', inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)', + colorImpairedGradient: '#fcfcfc', // // Buttons diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js index b95c6f640..3b0077c5a 100644 --- a/frontend/src/Styles/Variables/fonts.js +++ b/frontend/src/Styles/Variables/fonts.js @@ -8,6 +8,7 @@ module.exports = { extraSmallFontSize: '11px', smallFontSize: '12px', defaultFontSize: '14px', + intermediateFontSize: '15px', largeFontSize: '16px', lineHeight: '1.528571429' diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css index 8d95f8d12..1810037cf 100644 --- a/frontend/src/Styles/scaffolding.css +++ b/frontend/src/Styles/scaffolding.css @@ -43,3 +43,12 @@ ul { margin: 0; padding-left: 20px; } + +@media only screen and (min-device-width: 375px) and (max-device-width: 812px) { + input, + optgroup, + select, + textarea { + font-size: 16px; + } +} diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js index 1858d483a..ce6d0c995 100644 --- a/frontend/src/System/Events/LogsTable.js +++ b/frontend/src/System/Events/LogsTable.js @@ -4,6 +4,7 @@ import { align, icons } from 'Helpers/Props'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; @@ -51,6 +52,17 @@ function LogsTable(props) { + + + + { - isFetching && - + isFetching && !isPopulated ? + : + null } { - !isFetching && !items.length && + !isFetching && error ? +
{error}
: + null + } + + { + isPopulated && !items.length ?
No track files to manage. -
+
: + null } { - !isFetching && !!items.length && + isPopulated && items.length ? -
+ : + null } @@ -270,6 +281,8 @@ class TrackFileEditorModalContent extends Component { TrackFileEditorModalContent.propTypes = { isDeleting: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, languages: PropTypes.arrayOf(PropTypes.object).isRequired, qualities: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js index 66e8e92d6..37702cb51 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js @@ -11,20 +11,45 @@ import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; import { fetchLanguageProfileSchema, fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; import TrackFileEditorModalContent from './TrackFileEditorModalContent'; +function createSchemaSelector() { + return createSelector( + (state) => state.settings.languageProfiles, + (state) => state.settings.qualityProfiles, + (languageProfiles, qualityProfiles) => { + const languages = _.map(languageProfiles.schema.languages, 'language'); + const qualities = getQualities(qualityProfiles.schema.items); + + let error = null; + + if (languageProfiles.schemaError) { + error = 'Unable to load languages'; + } else if (qualityProfiles.schemaError) { + error = 'Unable to load qualities'; + } + + return { + isFetching: languageProfiles.isSchemaFetching || qualityProfiles.isSchemaFetching, + isPopulated: languageProfiles.isSchemaPopulated && qualityProfiles.isSchemaPopulated, + error, + languages, + qualities + }; + } + ); +} + function createMapStateToProps() { return createSelector( (state, { albumId }) => albumId, (state) => state.tracks, (state) => state.trackFiles, - (state) => state.settings.languageProfiles.schema, - (state) => state.settings.qualityProfiles.schema, + createSchemaSelector(), createArtistSelector(), ( albumId, tracks, trackFiles, - languageProfilesSchema, - qualityProfileSchema, + schema, artist ) => { const filtered = _.filter(tracks.items, (track) => { @@ -52,17 +77,12 @@ function createMapStateToProps() { }; }); - const languages = _.map(languageProfilesSchema.languages, 'language'); - const qualities = getQualities(qualityProfileSchema.items); - return { + ...schema, items, artistType: artist.artistType, isDeleting: trackFiles.isDeleting, - isFetching: tracks.isFetching || trackFiles.isFetching, - isSaving: trackFiles.isSaving, - languages, - qualities + isSaving: trackFiles.isSaving }; } ); @@ -115,9 +135,6 @@ class TrackFileEditorModalContentConnector extends Component { this.props.dispatchClearTracks(); } - // - // Render - // // Listeners @@ -139,6 +156,9 @@ class TrackFileEditorModalContentConnector extends Component { this.props.dispatchUpdateTrackFiles({ trackFileIds, quality }); } + // + // Render + render() { const { dispatchFetchLanguageProfileSchema, diff --git a/frontend/src/TrackFile/TrackFileLanguageConnector.js b/frontend/src/TrackFile/TrackFileLanguageConnector.js index 9a1a3b1bf..3b392d277 100644 --- a/frontend/src/TrackFile/TrackFileLanguageConnector.js +++ b/frontend/src/TrackFile/TrackFileLanguageConnector.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector'; -import EpisodeLanguage from 'Album/EpisodeLanguage'; +import TrackLanguage from 'Album/TrackLanguage'; function createMapStateToProps() { return createSelector( @@ -14,4 +14,4 @@ function createMapStateToProps() { ); } -export default connect(createMapStateToProps)(EpisodeLanguage); +export default connect(createMapStateToProps)(TrackLanguage); diff --git a/frontend/src/Utilities/Artist/getMonitoringOptions.js b/frontend/src/Utilities/Artist/getMonitoringOptions.js deleted file mode 100644 index fc3e79a44..000000000 --- a/frontend/src/Utilities/Artist/getMonitoringOptions.js +++ /dev/null @@ -1,37 +0,0 @@ -function getMonitoringOptions(monitor) { - const monitoringOptions = { - selectedOption: 0, - monitored: true - }; - - switch (monitor) { - case 'future': - monitoringOptions.selectedOption = 1; - break; - case 'missing': - monitoringOptions.selectedOption = 2; - break; - case 'existing': - monitoringOptions.selectedOption = 3; - break; - case 'first': - monitoringOptions.selectedOption = 5; - break; - case 'latest': - monitoringOptions.selectedOption = 4; - break; - case 'none': - monitoringOptions.monitored = false; - monitoringOptions.selectedOption = 6; - break; - default: - monitoringOptions.selectedOption = 0; - break; - } - - return { - options: monitoringOptions - }; -} - -export default getMonitoringOptions; diff --git a/frontend/src/Utilities/Artist/getNewArtist.js b/frontend/src/Utilities/Artist/getNewArtist.js index b100d385c..e46099eb6 100644 --- a/frontend/src/Utilities/Artist/getNewArtist.js +++ b/frontend/src/Utilities/Artist/getNewArtist.js @@ -1,4 +1,3 @@ -import getMonitoringOptions from 'Utilities/Artist/getMonitoringOptions'; function getNewArtist(artist, payload) { const { @@ -13,11 +12,11 @@ function getNewArtist(artist, payload) { searchForMissingAlbums = false } = payload; - const { - options: addOptions - } = getMonitoringOptions(monitor); + const addOptions = { + monitor, + searchForMissingAlbums + }; - addOptions.searchForMissingAlbums = searchForMissingAlbums; artist.addOptions = addOptions; artist.monitored = true; artist.qualityProfileId = qualityProfileId; diff --git a/frontend/src/Utilities/Artist/monitorOptions.js b/frontend/src/Utilities/Artist/monitorOptions.js new file mode 100644 index 000000000..b5e942ae6 --- /dev/null +++ b/frontend/src/Utilities/Artist/monitorOptions.js @@ -0,0 +1,11 @@ +const monitorOptions = [ + { key: 'all', value: 'All Albums' }, + { key: 'future', value: 'Future Albums' }, + { key: 'missing', value: 'Missing Albums' }, + { key: 'existing', value: 'Existing Albums' }, + { key: 'first', value: 'Only First Album' }, + { key: 'latest', value: 'Only Latest Album' }, + { key: 'none', value: 'None' } +]; + +export default monitorOptions; diff --git a/frontend/src/Utilities/Number/roundNumber.js b/frontend/src/Utilities/Number/roundNumber.js new file mode 100644 index 000000000..e1a19018f --- /dev/null +++ b/frontend/src/Utilities/Number/roundNumber.js @@ -0,0 +1,5 @@ +export default function roundNumber(input, decimalPlaces = 1) { + const multiplier = Math.pow(10, decimalPlaces); + + return Math.round(input * multiplier) / multiplier; +} diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js index 039429c10..60923a646 100644 --- a/frontend/src/Utilities/State/getProviderState.js +++ b/frontend/src/Utilities/State/getProviderState.js @@ -6,6 +6,7 @@ function getProviderState(payload, getState, section) { id, ...otherPayload } = payload; + const state = getSectionState(getState(), section, true); const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload); const pendingFields = state.pendingChanges.fields || {}; @@ -15,12 +16,15 @@ function getProviderState(payload, getState, section) { if (item.fields) { pendingChanges.fields = _.reduce(item.fields, (result, field) => { - const value = pendingFields.hasOwnProperty(field.name) ? - pendingFields[field.name] : + const name = field.name; + + const value = pendingFields.hasOwnProperty(name) ? + pendingFields[name] : field.value; + // Only send the name and value to the server result.push({ - ...field, + name, value }); @@ -28,7 +32,11 @@ function getProviderState(payload, getState, section) { }, []); } - return Object.assign({}, item, pendingChanges); + const result = Object.assign({}, item, pendingChanges); + + delete result.presets; + + return result; } export default getProviderState; diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js index ab6ffc3ba..dbc0d6223 100644 --- a/frontend/src/Utilities/Table/toggleSelected.js +++ b/frontend/src/Utilities/Table/toggleSelected.js @@ -1,4 +1,3 @@ -/* eslint max-params: 0 */ import areAllSelected from './areAllSelected'; import getToggledRange from './getToggledRange'; diff --git a/frontend/src/Utilities/pagePopulator.js b/frontend/src/Utilities/pagePopulator.js index 128a1d7a3..f58dbe803 100644 --- a/frontend/src/Utilities/pagePopulator.js +++ b/frontend/src/Utilities/pagePopulator.js @@ -17,6 +17,7 @@ export function repopulatePage(reason) { if (!currentPopulator) { return; } + if (!reason) { currentPopulator(); } diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js index af01ef7a5..eca14e349 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js @@ -26,6 +26,7 @@ function getMonitoredValue(props) { filters, selectedFilterKey } = props; + return getFilterValue(filters, selectedFilterKey, 'monitored', false); } diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js index a61c5258c..8af140999 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js @@ -1,4 +1,3 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; @@ -6,6 +5,7 @@ import { createSelector } from 'reselect'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import * as wantedActions from 'Store/Actions/wantedActions'; import { executeCommand } from 'Store/Actions/commandActions'; @@ -22,7 +22,7 @@ function createMapStateToProps() { return { isSearchingForCutoffUnmetAlbums, - isSaving: _.some(cutoffUnmet.items, { isSaving: true }), + isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1, ...cutoffUnmet }; } @@ -44,8 +44,19 @@ class CutoffUnmetConnector extends Component { // Lifecycle componentDidMount() { + const { + useCurrentPage, + fetchCutoffUnmet, + gotoCutoffUnmetFirstPage + } = this.props; + registerPagePopulator(this.repopulate, ['trackFileUpdated']); - this.props.gotoCutoffUnmetFirstPage(); + + if (useCurrentPage) { + fetchCutoffUnmet(); + } else { + gotoCutoffUnmetFirstPage(); + } } componentDidUpdate(prevProps) { @@ -169,4 +180,6 @@ CutoffUnmetConnector.propTypes = { clearTrackFiles: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector); +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector) +); diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js index 0f8e0a3fd..ec969b314 100644 --- a/frontend/src/Wanted/Missing/Missing.js +++ b/frontend/src/Wanted/Missing/Missing.js @@ -265,13 +265,13 @@ class Missing extends Component { onConfirm={this.onSearchAllMissingConfirmed} onCancel={this.onConfirmSearchAllMissingModalClose} /> - -
} + + ); diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js index ccc5c48e6..ec90e274d 100644 --- a/frontend/src/Wanted/Missing/MissingConnector.js +++ b/frontend/src/Wanted/Missing/MissingConnector.js @@ -1,4 +1,3 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; @@ -6,6 +5,7 @@ import { createSelector } from 'reselect'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import * as wantedActions from 'Store/Actions/wantedActions'; import { executeCommand } from 'Store/Actions/commandActions'; @@ -21,7 +21,7 @@ function createMapStateToProps() { return { isSearchingForMissingAlbums, - isSaving: _.some(missing.items, { isSaving: true }), + isSaving: missing.items.filter((m) => m.isSaving).length > 1, ...missing }; } @@ -41,8 +41,19 @@ class MissingConnector extends Component { // Lifecycle componentDidMount() { + const { + useCurrentPage, + fetchMissing, + gotoMissingFirstPage + } = this.props; + registerPagePopulator(this.repopulate, ['trackFileUpdated']); - this.props.gotoMissingFirstPage(); + + if (useCurrentPage) { + fetchMissing(); + } else { + gotoMissingFirstPage(); + } } componentDidUpdate(prevProps) { @@ -141,6 +152,7 @@ class MissingConnector extends Component { } MissingConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, fetchMissing: PropTypes.func.isRequired, gotoMissingFirstPage: PropTypes.func.isRequired, @@ -157,4 +169,6 @@ MissingConnector.propTypes = { clearQueueDetails: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(MissingConnector); +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(MissingConnector) +); diff --git a/frontend/src/login.html b/frontend/src/login.html index 60505bc23..39df3402f 100644 --- a/frontend/src/login.html +++ b/frontend/src/login.html @@ -158,6 +158,12 @@ .hidden { display: none; } + + @media only screen and (min-device-width: 375px) and (max-device-width: 812px) { + .form-input { + font-size: 16px; + } + } diff --git a/package.json b/package.json index ad0d05c3d..bc7efde84 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "mousetrap": "1.6.2", "normalize.css": "8.0.1", "optimize-css-assets-webpack-plugin": "3.2.0", + "postcss-color-function": "4.0.1", "postcss-loader": "3.0.0", "postcss-mixins": "6.2.1", "postcss-nested": "4.1.1", diff --git a/src/Lidarr.Api.V1/Albums/AlbumModule.cs b/src/Lidarr.Api.V1/Albums/AlbumModule.cs index 1b19913f4..e029d239c 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumModule.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumModule.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using Nancy; using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Music; using NzbDrone.SignalR; using Lidarr.Http.Extensions; diff --git a/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs b/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs index 48ffeb225..3e44d469a 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; using Lidarr.Api.V1.Artist; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Music; using NzbDrone.Core.ArtistStats; using NzbDrone.SignalR; diff --git a/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs b/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs index 81dec4158..665e6f6e9 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs @@ -38,7 +38,7 @@ namespace Lidarr.Api.V1.Artist if (resource.QualityProfileId.HasValue) { - artist.ProfileId = resource.QualityProfileId.Value; + artist.QualityProfileId = resource.QualityProfileId.Value; } if (resource.LanguageProfileId.HasValue) diff --git a/src/Lidarr.Api.V1/Artist/ArtistResource.cs b/src/Lidarr.Api.V1/Artist/ArtistResource.cs index da77685c5..8017acdf7 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistResource.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistResource.cs @@ -84,7 +84,7 @@ namespace Lidarr.Api.V1.Artist Images = model.Metadata.Value.Images.JsonClone(), Path = model.Path, - QualityProfileId = model.ProfileId, + QualityProfileId = model.QualityProfileId, LanguageProfileId = model.LanguageProfileId, MetadataProfileId = model.MetadataProfileId, Links = model.Metadata.Value.Links, @@ -132,7 +132,7 @@ namespace Lidarr.Api.V1.Artist //AlternateTitles SortName = resource.SortName, Path = resource.Path, - ProfileId = resource.QualityProfileId, + QualityProfileId = resource.QualityProfileId, LanguageProfileId = resource.LanguageProfileId, MetadataProfileId = resource.MetadataProfileId, diff --git a/src/Lidarr.Api.V1/Calendar/CalendarModule.cs b/src/Lidarr.Api.V1/Calendar/CalendarModule.cs index e20dc81d0..67214727f 100644 --- a/src/Lidarr.Api.V1/Calendar/CalendarModule.cs +++ b/src/Lidarr.Api.V1/Calendar/CalendarModule.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Music; using NzbDrone.Core.ArtistStats; using NzbDrone.SignalR; diff --git a/src/Lidarr.Api.V1/History/HistoryModule.cs b/src/Lidarr.Api.V1/History/HistoryModule.cs index 779c4362a..5acd53128 100644 --- a/src/Lidarr.Api.V1/History/HistoryModule.cs +++ b/src/Lidarr.Api.V1/History/HistoryModule.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using Nancy; using NzbDrone.Core.Datastore; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Download; using NzbDrone.Core.History; using Lidarr.Api.V1.Albums; @@ -55,7 +55,7 @@ namespace Lidarr.Api.V1.History if (model.Artist != null) { - resource.QualityCutoffNotMet = _upgradableSpecification.QualityCutoffNotMet(model.Artist.Profile.Value, model.Quality); + resource.QualityCutoffNotMet = _upgradableSpecification.QualityCutoffNotMet(model.Artist.QualityProfile.Value, model.Quality); resource.LanguageCutoffNotMet = _upgradableSpecification.LanguageCutoffNotMet(model.Artist.LanguageProfile, model.Language); } diff --git a/src/Lidarr.Api.V1/Indexers/ReleaseModule.cs b/src/Lidarr.Api.V1/Indexers/ReleaseModule.cs index d1b817d94..d7fbc9bf0 100644 --- a/src/Lidarr.Api.V1/Indexers/ReleaseModule.cs +++ b/src/Lidarr.Api.V1/Indexers/ReleaseModule.cs @@ -83,6 +83,11 @@ namespace Lidarr.Api.V1.Indexers return GetAlbumReleases(Request.Query.albumId); } + if (Request.Query.artistId.HasValue) + { + return GetArtistReleases(Request.Query.artistId); + } + return GetRss(); } @@ -103,6 +108,23 @@ namespace Lidarr.Api.V1.Indexers return new List(); } + private List GetArtistReleases(int artistId) + { + try + { + var decisions = _nzbSearchService.ArtistSearch(artistId, false, true, true); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + + return MapDecisions(prioritizedDecisions); + } + catch (Exception ex) + { + _logger.Error(ex, "Artist search failed: " + ex.Message); + } + + return new List(); + } + private List GetRss() { var reports = _rssFetcherAndParser.Fetch(); diff --git a/src/Lidarr.Api.V1/Indexers/ReleaseModuleBase.cs b/src/Lidarr.Api.V1/Indexers/ReleaseModuleBase.cs index 761f05785..994150dc2 100644 --- a/src/Lidarr.Api.V1/Indexers/ReleaseModuleBase.cs +++ b/src/Lidarr.Api.V1/Indexers/ReleaseModuleBase.cs @@ -28,9 +28,16 @@ namespace Lidarr.Api.V1.Indexers if (decision.RemoteAlbum.Artist != null) { - release.QualityWeight = decision.RemoteAlbum.Artist - .Profile.Value - .Items.FindIndex(v => v.Quality == release.Quality.Quality) * 100; + release.QualityWeight = decision.RemoteAlbum + .Artist + .QualityProfile.Value + .Items.FindIndex(v => v.Quality == release.Quality.Quality) * 100; + + release.LanguageWeight = decision.RemoteAlbum + .Artist + .LanguageProfile.Value + .Languages.FindIndex(v => v.Language == release.Language) * 100; + } release.QualityWeight += release.Quality.Revision.Real * 10; diff --git a/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs b/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs index 073bedd90..91d171b51 100644 --- a/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs +++ b/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Indexers; using NzbDrone.Core.Languages; @@ -28,6 +29,7 @@ namespace Lidarr.Api.V1.Indexers public bool Discography { get; set; } public bool SceneSource { get; set; } public Language Language { get; set; } + public int LanguageWeight { get; set; } public string AirDate { get; set; } public string ArtistName { get; set; } public string AlbumTitle { get; set; } @@ -41,6 +43,7 @@ namespace Lidarr.Api.V1.Indexers public string InfoUrl { get; set; } public bool DownloadAllowed { get; set; } public int ReleaseWeight { get; set; } + public int PreferredWordScore { get; set; } public string MagnetUrl { get; set; } public string InfoHash { get; set; } @@ -55,6 +58,16 @@ namespace Lidarr.Api.V1.Indexers //public bool IsAbsoluteNumbering { get; set; } //public bool IsPossibleSpecialEpisode { get; set; } //public bool Special { get; set; } + + // Sent when queuing an unknown release + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + // [JsonIgnore] + public int? ArtistId { get; set; } + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + // [JsonIgnore] + public int? AlbumId { get; set; } } public static class ReleaseResourceMapper @@ -95,6 +108,7 @@ namespace Lidarr.Api.V1.Indexers InfoUrl = releaseInfo.InfoUrl, DownloadAllowed = remoteAlbum.DownloadAllowed, //ReleaseWeight + PreferredWordScore = remoteAlbum.PreferredWordScore, MagnetUrl = torrentInfo.MagnetUrl, @@ -107,7 +121,7 @@ namespace Lidarr.Api.V1.Indexers //IsAbsoluteNumbering = parsedEpisodeInfo.IsAbsoluteNumbering, //IsPossibleSpecialEpisode = parsedEpisodeInfo.IsPossibleSpecialEpisode, //Special = parsedEpisodeInfo.Special, - }; + }; } diff --git a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj index 69a99b169..4a4fd06fd 100644 --- a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj +++ b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj @@ -107,6 +107,8 @@ + + @@ -191,8 +193,6 @@ - - diff --git a/src/Lidarr.Api.V1/Profiles/Language/LanguageProfileResource.cs b/src/Lidarr.Api.V1/Profiles/Language/LanguageProfileResource.cs index c14b17460..3a33757a8 100644 --- a/src/Lidarr.Api.V1/Profiles/Language/LanguageProfileResource.cs +++ b/src/Lidarr.Api.V1/Profiles/Language/LanguageProfileResource.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Profiles.Languages; using Lidarr.Http.REST; @@ -8,6 +8,7 @@ namespace Lidarr.Api.V1.Profiles.Language public class LanguageProfileResource : RestResource { public string Name { get; set; } + public bool UpgradeAllowed { get; set; } public NzbDrone.Core.Languages.Language Cutoff { get; set; } public List Languages { get; set; } } @@ -28,12 +29,13 @@ namespace Lidarr.Api.V1.Profiles.Language { Id = model.Id, Name = model.Name, + UpgradeAllowed = model.UpgradeAllowed, Cutoff = model.Cutoff, Languages = model.Languages.ConvertAll(ToResource) }; } - public static ProfileLanguageItemResource ToResource(this ProfileLanguageItem model) + public static ProfileLanguageItemResource ToResource(this LanguageProfileItem model) { if (model == null) return null; @@ -52,16 +54,17 @@ namespace Lidarr.Api.V1.Profiles.Language { Id = resource.Id, Name = resource.Name, + UpgradeAllowed = resource.UpgradeAllowed, Cutoff = (NzbDrone.Core.Languages.Language)resource.Cutoff.Id, Languages = resource.Languages.ConvertAll(ToModel) }; } - public static ProfileLanguageItem ToModel(this ProfileLanguageItemResource resource) + public static LanguageProfileItem ToModel(this ProfileLanguageItemResource resource) { if (resource == null) return null; - return new ProfileLanguageItem + return new LanguageProfileItem { Language = (NzbDrone.Core.Languages.Language)resource.Language.Id, Allowed = resource.Allowed diff --git a/src/Lidarr.Api.V1/Profiles/Language/LanguageProfileSchemaModule.cs b/src/Lidarr.Api.V1/Profiles/Language/LanguageProfileSchemaModule.cs index 3b2345231..ecc154043 100644 --- a/src/Lidarr.Api.V1/Profiles/Language/LanguageProfileSchemaModule.cs +++ b/src/Lidarr.Api.V1/Profiles/Language/LanguageProfileSchemaModule.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using NzbDrone.Core.Profiles.Languages; using Lidarr.Http; @@ -6,32 +6,19 @@ namespace Lidarr.Api.V1.Profiles.Language { public class LanguageProfileSchemaModule : LidarrRestModule { + private readonly LanguageProfileService _languageProfileService; - public LanguageProfileSchemaModule() + public LanguageProfileSchemaModule(LanguageProfileService languageProfileService) : base("/languageprofile/schema") { + _languageProfileService = languageProfileService; GetResourceSingle = GetAll; } private LanguageProfileResource GetAll() { - var orderedLanguages = NzbDrone.Core.Languages.Language.All - .Where(l => l != NzbDrone.Core.Languages.Language.Unknown) - .OrderByDescending(l => l.Name) - .ToList(); - - orderedLanguages.Insert(0, NzbDrone.Core.Languages.Language.Unknown); - - var languages = orderedLanguages.Select(v => new ProfileLanguageItem {Language = v, Allowed = false}) - .ToList(); - - var profile = new LanguageProfile - { - Cutoff = NzbDrone.Core.Languages.Language.Unknown, - Languages = languages - }; - + var profile = _languageProfileService.GetDefaultProfile(string.Empty); return profile.ToResource(); } } -} \ No newline at end of file +} diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileResource.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileResource.cs index 1e64242a6..a50bc0fd8 100644 --- a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileResource.cs +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileResource.cs @@ -8,6 +8,7 @@ namespace Lidarr.Api.V1.Profiles.Quality public class QualityProfileResource : RestResource { public string Name { get; set; } + public bool UpgradeAllowed { get; set; } public int Cutoff { get; set; } public List Items { get; set; } } @@ -27,7 +28,7 @@ namespace Lidarr.Api.V1.Profiles.Quality public static class ProfileResourceMapper { - public static QualityProfileResource ToResource(this Profile model) + public static QualityProfileResource ToResource(this QualityProfile model) { if (model == null) return null; @@ -35,12 +36,13 @@ namespace Lidarr.Api.V1.Profiles.Quality { Id = model.Id, Name = model.Name, + UpgradeAllowed = model.UpgradeAllowed, Cutoff = model.Cutoff, Items = model.Items.ConvertAll(ToResource) }; } - public static QualityProfileQualityItemResource ToResource(this ProfileQualityItem model) + public static QualityProfileQualityItemResource ToResource(this QualityProfileQualityItem model) { if (model == null) return null; @@ -54,24 +56,25 @@ namespace Lidarr.Api.V1.Profiles.Quality }; } - public static Profile ToModel(this QualityProfileResource resource) + public static QualityProfile ToModel(this QualityProfileResource resource) { if (resource == null) return null; - return new Profile + return new QualityProfile { Id = resource.Id, Name = resource.Name, + UpgradeAllowed = resource.UpgradeAllowed, Cutoff = resource.Cutoff, Items = resource.Items.ConvertAll(ToModel) }; } - public static ProfileQualityItem ToModel(this QualityProfileQualityItemResource resource) + public static QualityProfileQualityItem ToModel(this QualityProfileQualityItemResource resource) { if (resource == null) return null; - return new ProfileQualityItem + return new QualityProfileQualityItem { Id = resource.Id, Name = resource.Name, @@ -81,7 +84,7 @@ namespace Lidarr.Api.V1.Profiles.Quality }; } - public static List ToResource(this IEnumerable models) + public static List ToResource(this IEnumerable models) { return models.Select(ToResource).ToList(); } diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs index b52469674..3436d935b 100644 --- a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs @@ -16,7 +16,7 @@ namespace Lidarr.Api.V1.Profiles.Quality private QualityProfileResource GetSchema() { - Profile qualityProfile = _profileService.GetDefaultProfile(string.Empty); + QualityProfile qualityProfile = _profileService.GetDefaultProfile(string.Empty); return qualityProfile.ToResource(); diff --git a/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs b/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs new file mode 100644 index 000000000..ae3433e41 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Profiles.Releases; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Profiles.Release +{ + public class ReleaseProfileModule : LidarrRestModule + { + private readonly IReleaseProfileService _releaseProfileService; + + + public ReleaseProfileModule(IReleaseProfileService releaseProfileService) + { + _releaseProfileService = releaseProfileService; + + GetResourceById = Get; + GetResourceAll = GetAll; + CreateResource = Create; + UpdateResource = Update; + DeleteResource = Delete; + + SharedValidator.Custom(restriction => + { + if (restriction.Ignored.IsNullOrWhiteSpace() && restriction.Required.IsNullOrWhiteSpace() && restriction.Preferred.Empty()) + { + return new ValidationFailure("", "'Must contain', 'Must not contain' or 'Preferred' is required"); + } + + return null; + }); + } + + private ReleaseProfileResource Get(int id) + { + return _releaseProfileService.Get(id).ToResource(); + } + + private List GetAll() + { + return _releaseProfileService.All().ToResource(); + } + + private int Create(ReleaseProfileResource resource) + { + return _releaseProfileService.Add(resource.ToModel()).Id; + } + + private void Update(ReleaseProfileResource resource) + { + _releaseProfileService.Update(resource.ToModel()); + } + + private void Delete(int id) + { + _releaseProfileService.Delete(id); + } + } +} diff --git a/src/Lidarr.Api.V1/Restrictions/RestrictionResource.cs b/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileResource.cs similarity index 54% rename from src/Lidarr.Api.V1/Restrictions/RestrictionResource.cs rename to src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileResource.cs index aff26e209..e7af27941 100644 --- a/src/Lidarr.Api.V1/Restrictions/RestrictionResource.cs +++ b/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileResource.cs @@ -1,18 +1,19 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; using Lidarr.Http.REST; -namespace Lidarr.Api.V1.Restrictions +namespace Lidarr.Api.V1.Profiles.Release { - public class RestrictionResource : RestResource + public class ReleaseProfileResource : RestResource { public string Required { get; set; } - public string Preferred { get; set; } public string Ignored { get; set; } + public List> Preferred { get; set; } + public bool IncludePreferredWhenRenaming { get; set; } public HashSet Tags { get; set; } - public RestrictionResource() + public ReleaseProfileResource() { Tags = new HashSet(); } @@ -20,37 +21,39 @@ namespace Lidarr.Api.V1.Restrictions public static class RestrictionResourceMapper { - public static RestrictionResource ToResource(this Restriction model) + public static ReleaseProfileResource ToResource(this ReleaseProfile model) { if (model == null) return null; - return new RestrictionResource + return new ReleaseProfileResource { Id = model.Id, Required = model.Required, - Preferred = model.Preferred, Ignored = model.Ignored, + Preferred = model.Preferred, + IncludePreferredWhenRenaming = model.IncludePreferredWhenRenaming, Tags = new HashSet(model.Tags) }; } - public static Restriction ToModel(this RestrictionResource resource) + public static ReleaseProfile ToModel(this ReleaseProfileResource resource) { if (resource == null) return null; - return new Restriction + return new ReleaseProfile { Id = resource.Id, Required = resource.Required, - Preferred = resource.Preferred, Ignored = resource.Ignored, + Preferred = resource.Preferred, + IncludePreferredWhenRenaming = resource.IncludePreferredWhenRenaming, Tags = new HashSet(resource.Tags) }; } - public static List ToResource(this IEnumerable models) + public static List ToResource(this IEnumerable models) { return models.Select(ToResource).ToList(); } diff --git a/src/Lidarr.Api.V1/Queue/QueueDetailsModule.cs b/src/Lidarr.Api.V1/Queue/QueueDetailsModule.cs index e677c65e6..201d9f776 100644 --- a/src/Lidarr.Api.V1/Queue/QueueDetailsModule.cs +++ b/src/Lidarr.Api.V1/Queue/QueueDetailsModule.cs @@ -38,7 +38,7 @@ namespace Lidarr.Api.V1.Queue if (artistIdQuery.HasValue) { - return fullQueue.Where(q => q.Artist.Id == (int)artistIdQuery).ToResource(includeSeries, includeEpisode); + return fullQueue.Where(q => q.Artist?.Id == (int)artistIdQuery).ToResource(includeSeries, includeEpisode); } if (albumIdsQuery.HasValue) @@ -49,7 +49,7 @@ namespace Lidarr.Api.V1.Queue .Select(e => Convert.ToInt32(e)) .ToList(); - return fullQueue.Where(q => albumIds.Contains(q.Album.Id)).ToResource(includeSeries, includeEpisode); + return fullQueue.Where(q => q.Album != null && albumIds.Contains(q.Album.Id)).ToResource(includeSeries, includeEpisode); } return fullQueue.ToResource(includeSeries, includeEpisode); diff --git a/src/Lidarr.Api.V1/Queue/QueueModule.cs b/src/Lidarr.Api.V1/Queue/QueueModule.cs index ba352620d..cb3d113ec 100644 --- a/src/Lidarr.Api.V1/Queue/QueueModule.cs +++ b/src/Lidarr.Api.V1/Queue/QueueModule.cs @@ -1,10 +1,15 @@ using System; using System.Linq; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Languages; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Profiles.Languages; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; using NzbDrone.Core.Queue; using NzbDrone.SignalR; using Lidarr.Http; @@ -18,61 +23,93 @@ namespace Lidarr.Api.V1.Queue private readonly IQueueService _queueService; private readonly IPendingReleaseService _pendingReleaseService; - public QueueModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + private readonly LanguageComparer LANGUAGE_COMPARER; + private readonly QualityModelComparer QUALITY_COMPARER; + + public QueueModule(IBroadcastSignalRMessage broadcastSignalRMessage, + IQueueService queueService, + IPendingReleaseService pendingReleaseService, + ILanguageProfileService languageProfileService, + QualityProfileService qualityProfileService) : base(broadcastSignalRMessage) { _queueService = queueService; _pendingReleaseService = pendingReleaseService; GetResourcePaged = GetQueue; + + LANGUAGE_COMPARER = new LanguageComparer(languageProfileService.GetDefaultProfile(string.Empty)); + QUALITY_COMPARER = new QualityModelComparer(qualityProfileService.GetDefaultProfile(string.Empty)); } private PagingResource GetQueue(PagingResource pagingResource) { var pagingSpec = pagingResource.MapToPagingSpec("timeleft", SortDirection.Ascending); + var includeUnknownArtistItems = Request.GetBooleanQueryParameter("includeUnknownArtistItems"); var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum"); - return ApplyToPage(GetQueue, pagingSpec, (q) => MapToResource(q, includeArtist, includeAlbum)); + return ApplyToPage((spec) => GetQueue(spec, includeUnknownArtistItems), pagingSpec, (q) => MapToResource(q, includeArtist, includeAlbum)); } - private PagingSpec GetQueue(PagingSpec pagingSpec) + private PagingSpec GetQueue(PagingSpec pagingSpec, bool includeUnknownArtistItems) { var ascending = pagingSpec.SortDirection == SortDirection.Ascending; var orderByFunc = GetOrderByFunc(pagingSpec); var queue = _queueService.GetQueue(); + var filteredQueue = includeUnknownArtistItems ? queue : queue.Where(q => q.Artist != null); var pending = _pendingReleaseService.GetPendingQueue(); - var fullQueue = queue.Concat(pending).ToList(); + var fullQueue = filteredQueue.Concat(pending).ToList(); IOrderedEnumerable ordered; if (pagingSpec.SortKey == "timeleft") { - ordered = ascending ? fullQueue.OrderBy(q => q.Timeleft, new TimeleftComparer()) : - fullQueue.OrderByDescending(q => q.Timeleft, new TimeleftComparer()); + ordered = ascending + ? fullQueue.OrderBy(q => q.Timeleft, new TimeleftComparer()) + : fullQueue.OrderByDescending(q => q.Timeleft, new TimeleftComparer()); } else if (pagingSpec.SortKey == "estimatedCompletionTime") { - ordered = ascending ? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new EstimatedCompletionTimeComparer()) : - fullQueue.OrderByDescending(q => q.EstimatedCompletionTime, new EstimatedCompletionTimeComparer()); + ordered = ascending + ? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new EstimatedCompletionTimeComparer()) + : fullQueue.OrderByDescending(q => q.EstimatedCompletionTime, + new EstimatedCompletionTimeComparer()); } else if (pagingSpec.SortKey == "protocol") { - ordered = ascending ? fullQueue.OrderBy(q => q.Protocol) : - fullQueue.OrderByDescending(q => q.Protocol); + ordered = ascending + ? fullQueue.OrderBy(q => q.Protocol) + : fullQueue.OrderByDescending(q => q.Protocol); } else if (pagingSpec.SortKey == "indexer") { - ordered = ascending ? fullQueue.OrderBy(q => q.Indexer, StringComparer.InvariantCultureIgnoreCase) : - fullQueue.OrderByDescending(q => q.Indexer, StringComparer.InvariantCultureIgnoreCase); + ordered = ascending + ? fullQueue.OrderBy(q => q.Indexer, StringComparer.InvariantCultureIgnoreCase) + : fullQueue.OrderByDescending(q => q.Indexer, StringComparer.InvariantCultureIgnoreCase); } else if (pagingSpec.SortKey == "downloadClient") { - ordered = ascending ? fullQueue.OrderBy(q => q.DownloadClient, StringComparer.InvariantCultureIgnoreCase) : - fullQueue.OrderByDescending(q => q.DownloadClient, StringComparer.InvariantCultureIgnoreCase); + ordered = ascending + ? fullQueue.OrderBy(q => q.DownloadClient, StringComparer.InvariantCultureIgnoreCase) + : fullQueue.OrderByDescending(q => q.DownloadClient, StringComparer.InvariantCultureIgnoreCase); + } + + else if (pagingSpec.SortKey == "language") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Language, LANGUAGE_COMPARER) + : fullQueue.OrderByDescending(q => q.Language, LANGUAGE_COMPARER); + } + + else if (pagingSpec.SortKey == "quality") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Quality, QUALITY_COMPARER) + : fullQueue.OrderByDescending(q => q.Quality, QUALITY_COMPARER); } else @@ -98,18 +135,23 @@ namespace Lidarr.Api.V1.Queue { switch (pagingSpec.SortKey) { + case "status": + return q => q.Status; case "artist.sortName": - return q => q.Artist.SortName; + return q => q.Artist?.SortName; case "album": return q => q.Album; case "album.title": return q => q.Album.Title; case "album.releaseDate": return q => q.Album.ReleaseDate; + case "language": + return q => q.Language; case "quality": return q => q.Quality; case "progress": - return q => q.Size == 0 ? 0 : 100 - q.Sizeleft / q.Size * 100; + // Avoid exploding if a download's size is 0 + return q => 100 - q.Sizeleft / Math.Max(q.Size * 100, 1); default: return q => q.Timeleft; } diff --git a/src/Lidarr.Api.V1/Queue/QueueResource.cs b/src/Lidarr.Api.V1/Queue/QueueResource.cs index 33318dc34..630ce4fbc 100644 --- a/src/Lidarr.Api.V1/Queue/QueueResource.cs +++ b/src/Lidarr.Api.V1/Queue/QueueResource.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Languages; using NzbDrone.Core.Qualities; using Lidarr.Api.V1.Albums; using Lidarr.Api.V1.Artist; @@ -12,10 +13,11 @@ namespace Lidarr.Api.V1.Queue { public class QueueResource : RestResource { - public int ArtistId { get; set; } - public int AlbumId { get; set; } + public int? ArtistId { get; set; } + public int? AlbumId { get; set; } public ArtistResource Artist { get; set; } public AlbumResource Album { get; set; } + public Language Language { get; set; } public QualityModel Quality { get; set; } public decimal Size { get; set; } public string Title { get; set; } @@ -35,17 +37,18 @@ namespace Lidarr.Api.V1.Queue public static class QueueResourceMapper { - public static QueueResource ToResource(this NzbDrone.Core.Queue.Queue model, bool includeSeries, bool includeEpisode) + public static QueueResource ToResource(this NzbDrone.Core.Queue.Queue model, bool includeArtist, bool includeAlbum) { if (model == null) return null; return new QueueResource { Id = model.Id, - ArtistId = model.Artist.Id, - AlbumId = model.Album.Id, - Artist = includeSeries ? model.Artist.ToResource() : null, - Album = includeEpisode ? model.Album.ToResource() : null, + ArtistId = model.Artist?.Id, + AlbumId = model.Album?.Id, + Artist = includeArtist && model.Artist != null ? model.Artist.ToResource() : null, + Album = includeAlbum && model.Album != null ? model.Album.ToResource() : null, + Language = model.Language, Quality = model.Quality, Size = model.Size, Title = model.Title, diff --git a/src/Lidarr.Api.V1/Queue/QueueStatusModule.cs b/src/Lidarr.Api.V1/Queue/QueueStatusModule.cs index 54ff2f74a..e15a03d71 100644 --- a/src/Lidarr.Api.V1/Queue/QueueStatusModule.cs +++ b/src/Lidarr.Api.V1/Queue/QueueStatusModule.cs @@ -47,6 +47,7 @@ namespace Lidarr.Api.V1.Queue var resource = new QueueStatusResource { Count = queue.Count + pending.Count, + UnknownCount = queue.Count(q => q.Artist == null), Errors = queue.Any(q => q.TrackedDownloadStatus.Equals("Error", StringComparison.InvariantCultureIgnoreCase)), Warnings = queue.Any(q => q.TrackedDownloadStatus.Equals("Warning", StringComparison.InvariantCultureIgnoreCase)) }; diff --git a/src/Lidarr.Api.V1/Queue/QueueStatusResource.cs b/src/Lidarr.Api.V1/Queue/QueueStatusResource.cs index 627f0da0d..f2e5080b8 100644 --- a/src/Lidarr.Api.V1/Queue/QueueStatusResource.cs +++ b/src/Lidarr.Api.V1/Queue/QueueStatusResource.cs @@ -1,10 +1,11 @@ -using Lidarr.Http.REST; +using Lidarr.Http.REST; namespace Lidarr.Api.V1.Queue { public class QueueStatusResource : RestResource { public int Count { get; set; } + public int UnknownCount { get; set; } public bool Errors { get; set; } public bool Warnings { get; set; } } diff --git a/src/Lidarr.Api.V1/Restrictions/RestrictionModule.cs b/src/Lidarr.Api.V1/Restrictions/RestrictionModule.cs deleted file mode 100644 index da748ac65..000000000 --- a/src/Lidarr.Api.V1/Restrictions/RestrictionModule.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Generic; -using FluentValidation.Results; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Restrictions; -using Lidarr.Http; - -namespace Lidarr.Api.V1.Restrictions -{ - public class RestrictionModule : LidarrRestModule - { - private readonly IRestrictionService _restrictionService; - - - public RestrictionModule(IRestrictionService restrictionService) - { - _restrictionService = restrictionService; - - GetResourceById = GetRestriction; - GetResourceAll = GetAll; - CreateResource = Create; - UpdateResource = Update; - DeleteResource = DeleteRestriction; - - SharedValidator.Custom(restriction => - { - if (restriction.Ignored.IsNullOrWhiteSpace() && restriction.Required.IsNullOrWhiteSpace()) - { - return new ValidationFailure("", "Either 'Must contain' or 'Must not contain' is required"); - } - - return null; - }); - } - - private RestrictionResource GetRestriction(int id) - { - return _restrictionService.Get(id).ToResource(); - } - - private List GetAll() - { - return _restrictionService.All().ToResource(); - } - - private int Create(RestrictionResource resource) - { - return _restrictionService.Add(resource.ToModel()).Id; - } - - private void Update(RestrictionResource resource) - { - _restrictionService.Update(resource.ToModel()); - } - - private void DeleteRestriction(int id) - { - _restrictionService.Delete(id); - } - } -} diff --git a/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs b/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs index 69c60d739..540a00a65 100644 --- a/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs @@ -25,7 +25,7 @@ namespace Lidarr.Api.V1.TrackFiles AudioChannels = MediaInfoFormatter.FormatAudioChannels(model), AudioCodec = MediaInfoFormatter.FormatAudioCodec(model), AudioBitRate = MediaInfoFormatter.FormatAudioBitrate(model) - }; + }; } } } diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs index 7f3ad420c..59a7e2e73 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs @@ -4,7 +4,7 @@ using System.IO; using System.Linq; using Nancy; using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs index 0f19e9b24..38e57ff14 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -1,6 +1,6 @@ using System; using System.IO; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Languages; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; @@ -69,7 +69,7 @@ namespace Lidarr.Api.V1.TrackFiles Language = model.Language, Quality = model.Quality, MediaInfo = model.MediaInfo.ToResource(), - QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.Profile.Value, model.Quality), + QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.QualityProfile.Value, model.Quality), LanguageCutoffNotMet = upgradableSpecification.LanguageCutoffNotMet(artist.LanguageProfile.Value, model.Language) }; } diff --git a/src/Lidarr.Api.V1/Tracks/TrackModule.cs b/src/Lidarr.Api.V1/Tracks/TrackModule.cs index 62d4863e7..c216c718b 100644 --- a/src/Lidarr.Api.V1/Tracks/TrackModule.cs +++ b/src/Lidarr.Api.V1/Tracks/TrackModule.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using Nancy; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Music; using NzbDrone.SignalR; using Lidarr.Http.Extensions; diff --git a/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs b/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs index cb94d5b60..014aa4027 100644 --- a/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs +++ b/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; diff --git a/src/Lidarr.Api.V1/Wanted/CutoffModule.cs b/src/Lidarr.Api.V1/Wanted/CutoffModule.cs index cf29509c2..96983cc6d 100644 --- a/src/Lidarr.Api.V1/Wanted/CutoffModule.cs +++ b/src/Lidarr.Api.V1/Wanted/CutoffModule.cs @@ -1,6 +1,6 @@ using System.Linq; using NzbDrone.Core.Datastore; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Music; using NzbDrone.Core.ArtistStats; using NzbDrone.SignalR; diff --git a/src/Lidarr.Api.V1/Wanted/MissingModule.cs b/src/Lidarr.Api.V1/Wanted/MissingModule.cs index 8fb06e821..658cb0600 100644 --- a/src/Lidarr.Api.V1/Wanted/MissingModule.cs +++ b/src/Lidarr.Api.V1/Wanted/MissingModule.cs @@ -1,6 +1,6 @@ using System.Linq; using NzbDrone.Core.Datastore; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Music; using NzbDrone.Core.ArtistStats; using NzbDrone.SignalR; diff --git a/src/Lidarr.Http/ClientSchema/Field.cs b/src/Lidarr.Http/ClientSchema/Field.cs index 7b27d4c4d..6aafc89b1 100644 --- a/src/Lidarr.Http/ClientSchema/Field.cs +++ b/src/Lidarr.Http/ClientSchema/Field.cs @@ -14,7 +14,8 @@ namespace Lidarr.Http.ClientSchema public string Type { get; set; } public bool Advanced { get; set; } public List SelectOptions { get; set; } - + public string Section { get; set; } + public Field Clone() { return (Field) MemberwiseClone(); diff --git a/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs index b9d0769a7..15b9cc54e 100644 --- a/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs +++ b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs @@ -97,7 +97,8 @@ namespace Lidarr.Http.ClientSchema HelpLink = fieldAttribute.HelpLink, Order = fieldAttribute.Order, Advanced = fieldAttribute.Advanced, - Type = fieldAttribute.Type.ToString().ToLowerInvariant() + Type = fieldAttribute.Type.ToString().ToLowerInvariant(), + Section = fieldAttribute.Section }; if (fieldAttribute.Type == FieldType.Select) diff --git a/src/Lidarr.Http/Extensions/ReqResExtensions.cs b/src/Lidarr.Http/Extensions/ReqResExtensions.cs index 78a3d911a..b34a7200d 100644 --- a/src/Lidarr.Http/Extensions/ReqResExtensions.cs +++ b/src/Lidarr.Http/Extensions/ReqResExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using Nancy; @@ -42,7 +42,7 @@ namespace Lidarr.Http.Extensions public static IDictionary DisableCache(this IDictionary headers) { - headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; + headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"; headers["Pragma"] = "no-cache"; headers["Expires"] = "0"; @@ -59,4 +59,4 @@ namespace Lidarr.Http.Extensions return headers; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs index d90d8e53d..ca6e539f5 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs @@ -1,11 +1,13 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; using NzbDrone.Test.Common; -using FluentAssertions; namespace NzbDrone.Common.Test.DiskTests { @@ -485,10 +487,10 @@ namespace NzbDrone.Common.Test.DiskTests Mocker.GetMock() .Setup(v => v.CopyFile(_sourcePath, _tempTargetPath, false)) .Callback(() => - { - WithExistingFile(_tempTargetPath, true, 900); - if (retry++ == 1) WithExistingFile(_tempTargetPath, true, 1000); - }); + { + WithExistingFile(_tempTargetPath, true, 900); + if (retry++ == 1) WithExistingFile(_tempTargetPath, true, 1000); + }); var result = Subject.TransferFile(_sourcePath, _targetPath, TransferMode.Copy); @@ -504,10 +506,10 @@ namespace NzbDrone.Common.Test.DiskTests Mocker.GetMock() .Setup(v => v.CopyFile(_sourcePath, _tempTargetPath, false)) .Callback(() => - { - WithExistingFile(_tempTargetPath, true, 900); - if (retry++ == 3) throw new Exception("Test Failed, retried too many times."); - }); + { + WithExistingFile(_tempTargetPath, true, 900); + if (retry++ == 3) throw new Exception("Test Failed, retried too many times."); + }); Assert.Throws(() => Subject.TransferFile(_sourcePath, _targetPath, TransferMode.Copy)); @@ -794,6 +796,75 @@ namespace NzbDrone.Common.Test.DiskTests VerifyCopyFolder(original.FullName, destination.FullName); } + [Test] + public void TransferFolder_should_use_movefolder_if_on_same_mount() + { + WithEmulatedDiskProvider(); + + var src = @"C:\Base1\TestDir1".AsOsAgnostic(); + var dst = @"C:\Base1\TestDir2".AsOsAgnostic(); + + WithMockMount(@"C:\Base1".AsOsAgnostic()); + WithExistingFile(@"C:\Base1\TestDir1\test.file.txt".AsOsAgnostic()); + + Subject.TransferFolder(src, dst, TransferMode.Move); + + Mocker.GetMock() + .Verify(v => v.MoveFolder(src, dst), Times.Once()); + } + + [Test] + public void TransferFolder_should_not_use_movefolder_if_on_same_mount_but_target_already_exists() + { + WithEmulatedDiskProvider(); + + var src = @"C:\Base1\TestDir1".AsOsAgnostic(); + var dst = @"C:\Base1\TestDir2".AsOsAgnostic(); + + WithMockMount(@"C:\Base1".AsOsAgnostic()); + WithExistingFile(@"C:\Base1\TestDir1\test.file.txt".AsOsAgnostic()); + WithExistingFolder(dst); + + Subject.TransferFolder(src, dst, TransferMode.Move); + + Mocker.GetMock() + .Verify(v => v.MoveFolder(src, dst), Times.Never()); + } + + [Test] + public void TransferFolder_should_not_use_movefolder_if_on_same_mount_but_transactional() + { + WithEmulatedDiskProvider(); + + var src = @"C:\Base1\TestDir1".AsOsAgnostic(); + var dst = @"C:\Base1\TestDir2".AsOsAgnostic(); + + WithMockMount(@"C:\Base1".AsOsAgnostic()); + WithExistingFile(@"C:\Base1\TestDir1\test.file.txt".AsOsAgnostic()); + + Subject.TransferFolder(src, dst, TransferMode.Move, DiskTransferVerificationMode.Transactional); + + Mocker.GetMock() + .Verify(v => v.MoveFolder(src, dst), Times.Never()); + } + + [Test] + public void TransferFolder_should_not_use_movefolder_if_on_different_mount() + { + WithEmulatedDiskProvider(); + + var src = @"C:\Base1\TestDir1".AsOsAgnostic(); + var dst = @"C:\Base2\TestDir2".AsOsAgnostic(); + + WithMockMount(@"C:\Base1".AsOsAgnostic()); + WithMockMount(@"C:\Base2".AsOsAgnostic()); + + Subject.TransferFolder(src, dst, TransferMode.Move); + + Mocker.GetMock() + .Verify(v => v.MoveFolder(src, dst), Times.Never()); + } + public DirectoryInfo GetFilledTempFolder() { var tempFolder = GetTempFilePath(); @@ -810,8 +881,23 @@ namespace NzbDrone.Common.Test.DiskTests return new DirectoryInfo(tempFolder); } + private void WithExistingFolder(string path, bool exists = true) + { + var dir = Path.GetDirectoryName(path); + if (exists && dir.IsNotNullOrWhiteSpace()) + WithExistingFolder(dir); + + Mocker.GetMock() + .Setup(v => v.FolderExists(path)) + .Returns(exists); + } + private void WithExistingFile(string path, bool exists = true, int size = 1000) { + var dir = Path.GetDirectoryName(path); + if (exists && dir.IsNotNullOrWhiteSpace()) + WithExistingFolder(dir); + Mocker.GetMock() .Setup(v => v.FileExists(path)) .Returns(exists); @@ -863,6 +949,45 @@ namespace NzbDrone.Common.Test.DiskTests { WithExistingFile(v, false); }); + + + Mocker.GetMock() + .Setup(v => v.FolderExists(It.IsAny())) + .Returns(false); + + Mocker.GetMock() + .Setup(v => v.CreateFolder(It.IsAny())) + .Callback((f) => + { + WithExistingFolder(f); + }); + + Mocker.GetMock() + .Setup(v => v.MoveFolder(It.IsAny(), It.IsAny())) + .Callback((s, d) => + { + WithExistingFolder(s, false); + WithExistingFolder(d); + // Note: Should also deal with the files. + }); + + Mocker.GetMock() + .Setup(v => v.DeleteFolder(It.IsAny(), It.IsAny())) + .Callback((f, r) => + { + WithExistingFolder(f, false); + // Note: Should also deal with the files. + }); + + // Note: never returns anything. + Mocker.GetMock() + .Setup(v => v.GetDirectoryInfos(It.IsAny())) + .Returns(new List()); + + // Note: never returns anything. + Mocker.GetMock() + .Setup(v => v.GetFileInfos(It.IsAny())) + .Returns(new List()); } private void WithRealDiskProvider() @@ -881,7 +1006,7 @@ namespace NzbDrone.Common.Test.DiskTests Mocker.GetMock() .Setup(v => v.DeleteFolder(It.IsAny(), It.IsAny())) - .Callback((v,r) => Directory.Delete(v, r)); + .Callback((v, r) => Directory.Delete(v, r)); Mocker.GetMock() .Setup(v => v.DeleteFile(It.IsAny())) @@ -909,7 +1034,7 @@ namespace NzbDrone.Common.Test.DiskTests Mocker.GetMock() .Setup(v => v.MoveFile(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((s,d,o) => { + .Callback((s, d, o) => { if (File.Exists(d) && o) File.Delete(d); File.Move(s, d); }); @@ -919,6 +1044,18 @@ namespace NzbDrone.Common.Test.DiskTests .Returns(s => new FileStream(s, FileMode.Open, FileAccess.Read)); } + private void WithMockMount(string root) + { + var rootDir = root; + var mock = new Mock(); + mock.SetupGet(v => v.RootDirectory) + .Returns(rootDir); + + Mocker.GetMock() + .Setup(v => v.GetMount(It.Is(s => s.StartsWith(rootDir)))) + .Returns(mock.Object); + } + private void VerifyCopyFolder(string source, string destination) { var sourceFiles = Directory.GetFileSystemEntries(source, "*", SearchOption.AllDirectories).Select(v => v.Substring(source.Length + 1)).ToArray(); diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index c7dcefac8..42ad67aac 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -138,18 +138,34 @@ namespace NzbDrone.Common.Test } [TestCase(@"C:\Test\mydir", @"C:\Test")] - [TestCase(@"C:\Test\", @"C:")] + [TestCase(@"C:\Test\", @"C:\")] [TestCase(@"C:\", null)] - public void path_should_return_parent(string path, string parentPath) + [TestCase(@"\\server\share", null)] + [TestCase(@"\\server\share\test", @"\\server\share")] + public void path_should_return_parent_windows(string path, string parentPath) { + WindowsOnly(); + path.GetParentPath().Should().Be(parentPath); + } + + [TestCase(@"/", null)] + [TestCase(@"/test", "/")] + public void path_should_return_parent_mono(string path, string parentPath) + { + MonoOnly(); path.GetParentPath().Should().Be(parentPath); } [Test] public void path_should_return_parent_for_oversized_path() { - var path = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/lidarr/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories"; - var parentPath = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/lidarr/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing"; + MonoOnly(); + + // This test will fail on Windows if long path support is not enabled: https://www.howtogeek.com/266621/how-to-make-windows-10-accept-file-paths-over-260-characters/ + // It will also fail if the app isn't configured to use long path (such as resharper): https://blogs.msdn.microsoft.com/jeremykuhne/2016/07/30/net-4-6-2-and-long-paths-on-windows-10/ + + var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\lidarr\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories".AsOsAgnostic(); + var parentPath = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\lidarr\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing".AsOsAgnostic(); path.GetParentPath().Should().Be(parentPath); } diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 1f856c7d5..98050c32b 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -227,6 +227,14 @@ namespace NzbDrone.Common.Disk MoveFileInternal(source, destination); } + public void MoveFolder(string source, string destination) + { + Ensure.That(source, () => source).IsValidPath(); + Ensure.That(destination, () => destination).IsValidPath(); + + Directory.Move(source, destination); + } + protected virtual void MoveFileInternal(string source, string destination) { File.Move(source, destination); diff --git a/src/NzbDrone.Common/Disk/DiskTransferService.cs b/src/NzbDrone.Common/Disk/DiskTransferService.cs index baf7134a2..1f210b977 100644 --- a/src/NzbDrone.Common/Disk/DiskTransferService.cs +++ b/src/NzbDrone.Common/Disk/DiskTransferService.cs @@ -55,6 +55,23 @@ namespace NzbDrone.Common.Disk Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath(); + if (mode == TransferMode.Move && !_diskProvider.FolderExists(targetPath)) + { + if (verificationMode == DiskTransferVerificationMode.TryTransactional || verificationMode == DiskTransferVerificationMode.VerifyOnly) + { + var sourceMount = _diskProvider.GetMount(sourcePath); + var targetMount = _diskProvider.GetMount(targetPath); + + // If we're on the same mount, do a simple folder move. + if (sourceMount != null && targetMount != null && sourceMount.RootDirectory == targetMount.RootDirectory) + { + _logger.Debug("Move Directory [{0}] > [{1}]", sourcePath, targetPath); + _diskProvider.MoveFolder(sourcePath, targetPath); + return mode; + } + } + } + if (!_diskProvider.FolderExists(targetPath)) { _diskProvider.CreateFolder(targetPath); diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index f98529ead..20f36c67d 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -28,6 +28,7 @@ namespace NzbDrone.Common.Disk void DeleteFile(string path); void CopyFile(string source, string destination, bool overwrite = false); void MoveFile(string source, string destination, bool overwrite = false); + void MoveFolder(string source, string destination); bool TryCreateHardLink(string source, string destination); void DeleteFolder(string path, bool recursive); string ReadAllText(string filePath); diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index e1b568dbe..68d1eea33 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -24,6 +24,8 @@ namespace NzbDrone.Common.Extensions private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Lidarr.Update" + Path.DirectorySeparatorChar; private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar; + private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(? path).IsNotNullOrWhiteSpace(); @@ -67,15 +69,16 @@ namespace NzbDrone.Common.Extensions public static string GetParentPath(this string childPath) { - var parentPath = childPath.TrimEnd('\\', '/'); - - var index = parentPath.LastIndexOfAny(new[] { '\\', '/' }); + var cleanPath = OsInfo.IsWindows + ? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "") + : childPath.TrimEnd(Path.DirectorySeparatorChar); - if (index != -1) + if (cleanPath.IsNullOrWhiteSpace()) { - return parentPath.Substring(0, index); + return null; } - return null; + + return Directory.GetParent(cleanPath)?.FullName; } public static bool IsParentPath(this string parentPath, string childPath) @@ -191,6 +194,24 @@ namespace NzbDrone.Common.Extensions return directories; } + public static string GetAncestorPath(this string path, string ancestorName) + { + var parent = Path.GetDirectoryName(path); + + while (parent != null) + { + var currentPath = parent; + parent = Path.GetDirectoryName(parent); + + if (Path.GetFileName(currentPath) == ancestorName) + { + return currentPath; + } + } + + return null; + } + public static string GetAppDataPath(this IAppFolderInfo appFolderInfo) { return appFolderInfo.AppDataFolder; diff --git a/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs b/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs index 65c1b69a8..0f121924c 100644 --- a/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs @@ -22,7 +22,7 @@ namespace NzbDrone.Core.Test.Datastore [SetUp] public void Setup() { - var profile = new Profile + var profile = new QualityProfile { Name = "Test", Cutoff = Quality.MP3_320.Id, @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.Datastore var artist = Builder.CreateListOfSize(1) .All() .With(v => v.Id = 0) - .With(v => v.ProfileId = profile.Id) + .With(v => v.QualityProfileId = profile.Id) .With(v => v.LanguageProfileId = languageProfile.Id) .With(v => v.ArtistMetadataId = metadata.Id) .BuildListOfNew(); @@ -129,7 +129,7 @@ namespace NzbDrone.Core.Test.Datastore Assert.IsTrue(track.AlbumRelease.Value.Album.IsLoaded); Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.IsLoaded); Assert.IsNotNull(track.AlbumRelease.Value.Album.Value.Artist.Value); - Assert.IsFalse(track.AlbumRelease.Value.Album.Value.Artist.Value.Profile.IsLoaded); + Assert.IsFalse(track.AlbumRelease.Value.Album.Value.Artist.Value.QualityProfile.IsLoaded); Assert.IsFalse(track.AlbumRelease.Value.Album.Value.Artist.Value.LanguageProfile.IsLoaded); } } @@ -296,7 +296,7 @@ namespace NzbDrone.Core.Test.Datastore .Join(JoinType.Inner, v => v.AlbumRelease, (l, r) => l.AlbumReleaseId == r.Id) .Join(JoinType.Inner, v => v.Album, (l, r) => l.AlbumId == r.Id) .Join(JoinType.Inner, v => v.Artist, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) - .Join(JoinType.Inner, v => v.Profile, (l, r) => l.ProfileId == r.Id) + .Join(JoinType.Inner, v => v.QualityProfile, (l, r) => l.QualityProfileId == r.Id) .ToList(); foreach (var track in tracks) @@ -305,7 +305,7 @@ namespace NzbDrone.Core.Test.Datastore Assert.IsTrue(track.AlbumRelease.Value.Album.IsLoaded); Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.IsLoaded); Assert.IsNotNull(track.AlbumRelease.Value.Album.Value.Artist.Value); - Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.Value.Profile.IsLoaded); + Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.Value.QualityProfile.IsLoaded); Assert.IsFalse(track.AlbumRelease.Value.Album.Value.Artist.Value.LanguageProfile.IsLoaded); } } @@ -329,7 +329,7 @@ namespace NzbDrone.Core.Test.Datastore Assert.IsTrue(track.AlbumRelease.Value.Album.IsLoaded); Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.IsLoaded); Assert.IsNotNull(track.AlbumRelease.Value.Album.Value.Artist.Value); - Assert.IsFalse(track.AlbumRelease.Value.Album.Value.Artist.Value.Profile.IsLoaded); + Assert.IsFalse(track.AlbumRelease.Value.Album.Value.Artist.Value.QualityProfile.IsLoaded); Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.Value.LanguageProfile.IsLoaded); } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs index 3f88b8db1..1aeaa7da6 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs @@ -2,7 +2,7 @@ using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Languages; using NzbDrone.Core.Profiles.Languages; @@ -13,11 +13,13 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestFixture] public class CutoffSpecificationFixture : CoreTest { + private static readonly int NoPreferredWordScore = 0; + [Test] public void should_return_true_if_current_album_is_less_than_cutoff() { Subject.CutoffNotMet( - new Profile + new QualityProfile { Cutoff = Quality.MP3_256.Id, @@ -28,14 +30,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Languages = LanguageFixture.GetDefaultLanguages(Language.English), Cutoff = Language.English }, - new QualityModel(Quality.MP3_192, new Revision(version: 2)), Language.English).Should().BeTrue(); + new QualityModel(Quality.MP3_192, new Revision(version: 2)), Language.English, NoPreferredWordScore).Should().BeTrue(); } [Test] public void should_return_false_if_current_album_is_equal_to_cutoff() { Subject.CutoffNotMet( - new Profile + new QualityProfile { Cutoff = Quality.MP3_256.Id, Items = Qualities.QualityFixture.GetDefaultQualities() @@ -45,14 +47,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Languages = LanguageFixture.GetDefaultLanguages(Language.English), Cutoff = Language.English }, - new QualityModel(Quality.MP3_256, new Revision(version: 2)), Language.English).Should().BeFalse(); + new QualityModel(Quality.MP3_256, new Revision(version: 2)), Language.English, NoPreferredWordScore).Should().BeFalse(); } [Test] public void should_return_false_if_current_album_is_greater_than_cutoff() { Subject.CutoffNotMet( - new Profile + new QualityProfile { Cutoff = Quality.MP3_256.Id, @@ -63,14 +65,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Languages = LanguageFixture.GetDefaultLanguages(Language.English), Cutoff = Language.English }, - new QualityModel(Quality.MP3_320, new Revision(version: 2)), Language.English).Should().BeFalse(); + new QualityModel(Quality.MP3_320, new Revision(version: 2)), Language.English, NoPreferredWordScore).Should().BeFalse(); } [Test] public void should_return_true_when_new_album_is_proper_but_existing_is_not() { Subject.CutoffNotMet( - new Profile + new QualityProfile { Cutoff = Quality.MP3_320.Id, @@ -81,7 +83,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Languages = LanguageFixture.GetDefaultLanguages(Language.English), Cutoff = Language.English }, - new QualityModel(Quality.MP3_320, new Revision(version: 1)),Language.English, + new QualityModel(Quality.MP3_320, new Revision(version: 1)), + Language.English, + NoPreferredWordScore, new QualityModel(Quality.MP3_320, new Revision(version: 2))).Should().BeTrue(); } @@ -90,7 +94,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_false_if_cutoff_is_met_and_quality_is_higher() { Subject.CutoffNotMet( - new Profile + new QualityProfile { Cutoff = Quality.MP3_320.Id, @@ -101,7 +105,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Languages = LanguageFixture.GetDefaultLanguages(Language.English), Cutoff = Language.English }, - new QualityModel(Quality.MP3_320, new Revision(version: 2)),Language.English, + new QualityModel(Quality.MP3_320, new Revision(version: 2)), Language.English, + NoPreferredWordScore, new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse(); } @@ -109,7 +114,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_true_if_quality_cutoff_is_met_and_quality_is_higher_but_language_is_not_met() { - Profile _profile = new Profile + QualityProfile _profile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities(), @@ -125,6 +130,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _langProfile, new QualityModel(Quality.MP3_320, new Revision(version: 2)), Language.English, + NoPreferredWordScore, new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeTrue(); } @@ -132,7 +138,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_false_if_cutoff_is_met_and_quality_is_higher_and_language_is_met() { - Profile _profile = new Profile + QualityProfile _profile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities(), @@ -149,6 +155,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _langProfile, new QualityModel(Quality.MP3_320, new Revision(version: 2)), Language.Spanish, + NoPreferredWordScore, new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse(); } @@ -156,7 +163,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_false_if_cutoff_is_met_and_quality_is_higher_and_language_is_higher() { - Profile _profile = new Profile + QualityProfile _profile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities(), @@ -173,6 +180,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _langProfile, new QualityModel(Quality.MP3_320, new Revision(version: 2)), Language.French, + NoPreferredWordScore, new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse(); } @@ -180,7 +188,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_true_if_cutoff_is_not_met_and_new_quality_is_higher_and_language_is_higher() { - Profile _profile = new Profile + QualityProfile _profile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities(), @@ -197,6 +205,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _langProfile, new QualityModel(Quality.MP3_256, new Revision(version: 2)), Language.French, + NoPreferredWordScore, new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeTrue(); } @@ -204,7 +213,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_true_if_cutoff_is_not_met_and_language_is_higher() { - Profile _profile = new Profile + QualityProfile _profile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities(), @@ -220,7 +229,32 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _profile, _langProfile, new QualityModel(Quality.MP3_256, new Revision(version: 2)), - Language.French).Should().BeTrue(); + Language.French, NoPreferredWordScore).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_cutoffs_are_met_and_score_is_higher() + { + QualityProfile _profile = new QualityProfile + { + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + }; + + LanguageProfile _langProfile = new LanguageProfile + { + Cutoff = Language.Spanish, + Languages = LanguageFixture.GetDefaultLanguages() + }; + + Subject.CutoffNotMet( + _profile, + _langProfile, + new QualityModel(Quality.MP3_320, new Revision(version: 2)), + Language.Spanish, + NoPreferredWordScore, + new QualityModel(Quality.FLAC, new Revision(version: 2)), + 10).Should().BeTrue(); } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs index a3c4be868..8d8c0243f 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs index 2a513f08f..237189a9b 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs @@ -11,7 +11,7 @@ using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.Music; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Languages; @@ -47,9 +47,19 @@ namespace NzbDrone.Core.Test.DecisionEngineTests }; _fakeArtist = Builder.CreateNew() - .With(c => c.Profile = new Profile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }) - .With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, Languages = LanguageFixture.GetDefaultLanguages() }) - .Build(); + .With(c => c.QualityProfile = new QualityProfile + { + UpgradeAllowed = true, + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }) + .With(l => l.LanguageProfile = new LanguageProfile + { + UpgradeAllowed = true, + Cutoff = Language.Spanish, + Languages = LanguageFixture.GetDefaultLanguages() + }) + .Build(); _parseResultMulti = new RemoteAlbum { @@ -162,7 +172,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_not_be_upgradable_if_album_is_of_same_quality_as_existing() { - _fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; + _fakeArtist.QualityProfile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); _upgradableQuality = new Tuple(new QualityModel(Quality.MP3_320, new Revision(version: 1)), Language.English); @@ -174,7 +184,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_not_be_upgradable_if_cutoff_already_met() { - _fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; + _fakeArtist.QualityProfile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); _upgradableQuality = new Tuple(new QualityModel(Quality.MP3_320, new Revision(version: 1)), Language.Spanish); @@ -202,7 +212,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_false_if_cutoff_already_met_and_cdh_is_disabled() { GivenCdhDisabled(); - _fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; + _fakeArtist.QualityProfile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); _upgradableQuality = new Tuple(new QualityModel(Quality.MP3_320, new Revision(version: 1)), Language.Spanish); diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index 287d5e985..bcaaabc41 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -52,7 +52,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests remoteAlbum.Release.DownloadProtocol = downloadProtocol; remoteAlbum.Artist = Builder.CreateNew() - .With(e => e.Profile = new Profile + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs index 176b35c56..34f29df7b 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs @@ -35,7 +35,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void Setup() { var fakeArtist = Builder.CreateNew() - .With(c => c.Profile = (LazyLoaded)new Profile { Cutoff = Quality.MP3_320.Id }) + .With(c => c.QualityProfile = (LazyLoaded)new QualityProfile { Cutoff = Quality.MP3_320.Id }) .Build(); remoteAlbum = new RemoteAlbum @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_allow_if_quality_is_defined_in_profile(Quality qualityType) { remoteAlbum.ParsedAlbumInfo.Quality.Quality = qualityType; - remoteAlbum.Artist.Profile.Value.Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_192, Quality.MP3_256, Quality.MP3_320); + remoteAlbum.Artist.QualityProfile.Value.Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_192, Quality.MP3_256, Quality.MP3_320); Subject.IsSatisfiedBy(remoteAlbum, null).Accepted.Should().BeTrue(); } @@ -58,7 +58,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_not_allow_if_quality_is_not_defined_in_profile(Quality qualityType) { remoteAlbum.ParsedAlbumInfo.Quality.Quality = qualityType; - remoteAlbum.Artist.Profile.Value.Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_192, Quality.MP3_256, Quality.MP3_320); + remoteAlbum.Artist.QualityProfile.Value.Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_192, Quality.MP3_256, Quality.MP3_320); Subject.IsSatisfiedBy(remoteAlbum, null).Accepted.Should().BeFalse(); } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs index 24461e25a..c5e85cef5 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs @@ -3,7 +3,6 @@ using System.Linq; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Qualities; @@ -26,19 +25,23 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private Artist _otherArtist; private Album _otherAlbum; + private ReleaseInfo _releaseInfo; + [SetUp] public void Setup() { Mocker.Resolve(); _artist = Builder.CreateNew() - .With(e => e.Profile = new Profile + .With(e => e.QualityProfile = new QualityProfile { + UpgradeAllowed = true, Items = Qualities.QualityFixture.GetDefaultQualities(), }) .With(l => l.LanguageProfile = new LanguageProfile { Languages = Languages.LanguageFixture.GetDefaultLanguages(), + UpgradeAllowed = true, Cutoff = Language.Spanish }) .Build(); @@ -56,10 +59,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .With(e => e.Id = 2) .Build(); + _releaseInfo = Builder.CreateNew() + .Build(); + _remoteAlbum = Builder.CreateNew() .With(r => r.Artist = _artist) .With(r => r.Albums = new List { _album }) .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256), Language = Language.Spanish }) + .With(r => r.PreferredWordScore = 0) .Build(); } @@ -95,16 +102,38 @@ namespace NzbDrone.Core.Test.DecisionEngineTests var remoteAlbum = Builder.CreateNew() .With(r => r.Artist = _otherArtist) .With(r => r.Albums = new List { _album }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } + [Test] + public void should_return_false_if_everything_is_the_same() + { + _artist.QualityProfile.Value.Cutoff = Quality.FLAC.Id; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_256), + Language = Language.Spanish + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteAlbum }); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + [Test] public void should_return_true_when_quality_in_queue_is_lower() { - _artist.Profile.Value.Cutoff = Quality.MP3_320.Id; + _artist.QualityProfile.Value.Cutoff = Quality.MP3_320.Id; _artist.LanguageProfile.Value.Cutoff = Language.Spanish; var remoteAlbum = Builder.CreateNew() @@ -115,6 +144,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_192), Language = Language.Spanish }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); @@ -124,7 +154,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_true_when_quality_in_queue_is_lower_but_language_is_higher() { - _artist.Profile.Value.Cutoff = Quality.FLAC.Id; + _artist.QualityProfile.Value.Cutoff = Quality.FLAC.Id; _artist.LanguageProfile.Value.Cutoff = Language.Spanish; var remoteAlbum = Builder.CreateNew() @@ -135,6 +165,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_192), Language = Language.English }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); @@ -151,12 +182,33 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { Quality = new QualityModel(Quality.MP3_192) }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } + [Test] + public void should_return_true_when_qualities_are_the_same_and_languages_are_the_same_with_higher_preferred_word_score() + { + _remoteAlbum.PreferredWordScore = 1; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_256), + Language = Language.Spanish, + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + [Test] public void should_return_false_when_qualities_are_the_same_and_languages_are_the_same() { @@ -168,6 +220,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_192), Language = Language.Spanish }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); @@ -185,16 +238,38 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_192), Language = Language.English, }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } + [Test] + public void should_return_true_when_quality_is_better_language_is_better_and_upgrade_allowed_is_false_for_quality_profile() + { + _artist.QualityProfile.Value.Cutoff = Quality.FLAC.Id; + _artist.QualityProfile.Value.UpgradeAllowed = false; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_224), + Language = Language.English + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + [Test] public void should_return_false_when_quality_in_queue_is_better() { - _artist.Profile.Value.Cutoff = Quality.FLAC.Id; + _artist.QualityProfile.Value.Cutoff = Quality.FLAC.Id; var remoteAlbum = Builder.CreateNew() .With(r => r.Artist = _artist) @@ -204,6 +279,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_320), Language = Language.English }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); @@ -221,6 +297,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_320), Language = Language.English }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); @@ -238,6 +315,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_320), Language = Language.English }) + .With(r => r.Release = _releaseInfo) .Build(); _remoteAlbum.Albums.Add(_otherAlbum); @@ -257,6 +335,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_320), Language = Language.English }) + .With(r => r.Release = _releaseInfo) .Build(); _remoteAlbum.Albums.Add(_otherAlbum); @@ -276,6 +355,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_320), Language = Language.English }) + .With(r => r.Release = _releaseInfo) .TheFirst(1) .With(r => r.Albums = new List { _album }) .TheNext(1) @@ -290,7 +370,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_false_if_quality_and_language_in_queue_meets_cutoff() { - _artist.Profile.Value.Cutoff = _remoteAlbum.ParsedAlbumInfo.Quality.Quality.Id; + _artist.QualityProfile.Value.Cutoff = _remoteAlbum.ParsedAlbumInfo.Quality.Quality.Id; var remoteAlbum = Builder.CreateNew() .With(r => r.Artist = _artist) @@ -300,11 +380,53 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Quality = new QualityModel(Quality.MP3_256), Language = Language.Spanish }) + .With(r => r.Release = _releaseInfo) .Build(); GivenQueue(new List { remoteAlbum }); Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } + + [Test] + public void should_return_false_when_quality_are_the_same_language_is_better_and_upgrade_allowed_is_false_for_language_profile() + { + _artist.LanguageProfile.Value.UpgradeAllowed = false; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_256), + Language = Language.English + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_false_when_quality_is_better_languages_are_the_same_and_upgrade_allowed_is_false_for_quality_profile() + { + _artist.QualityProfile.Value.Cutoff = Quality.FLAC.Id; + _artist.QualityProfile.Value.UpgradeAllowed = false; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.FLAC), + Language = Language.Spanish + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs index b9503476e..20a86e75d 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs @@ -4,7 +4,7 @@ using Moq; using NUnit.Framework; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Music; @@ -35,11 +35,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void GivenRestictions(string required, string ignored) { - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List + .Returns(new List { - new Restriction + new ReleaseProfile() { Required = required, Ignored = ignored @@ -50,9 +50,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_be_true_when_restrictions_are_empty() { - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List()); + .Returns(new List()); Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } @@ -116,11 +116,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { _remoteAlbum.Release.Title = "[ www.Speed.cd ] - Katy Perry - Witness (2017) MP3 [320 kbps] "; - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List + .Returns(new List { - new Restriction { Required = "320", Ignored = "www.Speed.cd" } + new ReleaseProfile { Required = "320", Ignored = "www.Speed.cd" } }); Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs index c486483ee..206e5a241 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs @@ -6,7 +6,7 @@ using FluentAssertions; using Marr.Data; using Moq; using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications.RssSync; using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Indexers; @@ -26,7 +26,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync [TestFixture] public class DelaySpecificationFixture : CoreTest { - private Profile _profile; + private QualityProfile _profile; private LanguageProfile _langProfile; private DelayProfile _delayProfile; private RemoteAlbum _remoteAlbum; @@ -34,7 +34,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync [SetUp] public void Setup() { - _profile = Builder.CreateNew() + _profile = Builder.CreateNew() .Build(); _langProfile = Builder.CreateNew() @@ -46,7 +46,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync .Build(); var artist = Builder.CreateNew() - .With(s => s.Profile = _profile) + .With(s => s.QualityProfile = _profile) .With(s => s.LanguageProfile = _langProfile) .Build(); @@ -54,10 +54,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync .With(r => r.Artist = artist) .Build(); - _profile.Items = new List(); - _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }); - _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }); - _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }); + _profile.Items = new List(); + _profile.Items.Add(new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }); + _profile.Items.Add(new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }); + _profile.Items.Add(new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }); _profile.Cutoff = Quality.MP3_320.Id; @@ -96,7 +96,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync private void GivenUpgradeForExistingFile() { Mocker.GetMock() - .Setup(s => s.IsUpgradable(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(s => s.IsUpgradable(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(true); } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs index 8c3c03b0b..698eafce2 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs @@ -60,7 +60,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync var secondTrack = new Track { TrackFile = _secondFile, TrackFileId = 2, AlbumId = 2 }; var fakeArtist = Builder.CreateNew() - .With(c => c.Profile = new Profile { Cutoff = Quality.FLAC.Id }) + .With(c => c.QualityProfile = new QualityProfile { Cutoff = Quality.FLAC.Id }) .With(c => c.Path = @"C:\Music\My.Artist".AsOsAgnostic()) .Build(); diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs index cfc8764d9..5e6b3ae57 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs @@ -12,7 +12,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Music; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Test.Framework; @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync var fakeArtist = Builder.CreateNew() - .With(c => c.Profile = new Profile { Cutoff = Quality.FLAC.Id }) + .With(c => c.QualityProfile = new QualityProfile { Cutoff = Quality.FLAC.Id }) .Build(); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs index 29a128d98..26828524a 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs @@ -40,8 +40,18 @@ namespace NzbDrone.Core.Test.DecisionEngineTests var languages = Languages.LanguageFixture.GetDefaultLanguages(Language.English, Language.Spanish); var fakeArtist = Builder.CreateNew() - .With(c => c.Profile = new Profile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities()}) - .With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, Languages = languages }) + .With(c => c.QualityProfile = new QualityProfile + { + UpgradeAllowed = true, + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }) + .With(l => l.LanguageProfile = new LanguageProfile + { + UpgradeAllowed = true, + Cutoff = Language.Spanish, + Languages = languages + }) .Build(); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs similarity index 67% rename from src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs rename to src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs index 07061743f..2e243bac3 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs @@ -3,7 +3,7 @@ using NUnit.Framework; using NzbDrone.Core.Configuration; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Languages; using NzbDrone.Core.Profiles.Languages; @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { [TestFixture] - public class QualityUpgradeSpecificationFixture : CoreTest + public class UpgradeSpecificationFixture : CoreTest { public static object[] IsUpgradeTestCases = { @@ -35,11 +35,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests new object[] { Quality.MP3_320, 1, Language.Spanish, Quality.MP3_256, 2, Language.French, Quality.MP3_320, Language.Spanish, false } }; - [SetUp] - public void Setup() - { - - } + private static readonly int NoPreferredWordScore = 0; private void GivenAutoDownloadPropers(bool autoDownloadPropers) { @@ -53,21 +49,29 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenAutoDownloadPropers(true); - var profile = new Profile - + var profile = new QualityProfile { + UpgradeAllowed = true, Items = Qualities.QualityFixture.GetDefaultQualities() }; var langProfile = new LanguageProfile - { + UpgradeAllowed = true, Languages = LanguageFixture.GetDefaultLanguages(), Cutoff = Language.English }; - Subject.IsUpgradable(profile, langProfile, new QualityModel(current, new Revision(version: currentVersion)), Language.English, new QualityModel(newQuality, new Revision(version: newVersion)), Language.English) - .Should().Be(expected); + Subject.IsUpgradable( + profile, + langProfile, + new QualityModel(current, new Revision(version: currentVersion)), + Language.English, + NoPreferredWordScore, + new QualityModel(newQuality, new Revision(version: newVersion)), + Language.English, + NoPreferredWordScore) + .Should().Be(expected); } [Test, TestCaseSource(nameof(IsUpgradeTestCasesLanguages))] @@ -75,19 +79,30 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenAutoDownloadPropers(true); - var profile = new Profile + var profile = new QualityProfile { + UpgradeAllowed = true, Items = Qualities.QualityFixture.GetDefaultQualities(), Cutoff = cutoff.Id, }; var langProfile = new LanguageProfile { + UpgradeAllowed = true, Languages = LanguageFixture.GetDefaultLanguages(), Cutoff = languageCutoff }; - Subject.IsUpgradable(profile, langProfile, new QualityModel(current, new Revision(version: currentVersion)), currentLanguage, new QualityModel(newQuality, new Revision(version: newVersion)), newLanguage).Should().Be(expected); + Subject.IsUpgradable( + profile, + langProfile, + new QualityModel(current, new Revision(version: currentVersion)), + currentLanguage, + NoPreferredWordScore, + new QualityModel(newQuality, new Revision(version: newVersion)), + newLanguage, + NoPreferredWordScore) + .Should().Be(expected); } [Test] @@ -95,7 +110,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenAutoDownloadPropers(false); - var profile = new Profile + var profile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities(), }; @@ -107,7 +122,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Cutoff = Language.English }; - Subject.IsUpgradable(profile, langProfile, new QualityModel(Quality.MP3_256, new Revision(version: 2)), Language.English, new QualityModel(Quality.MP3_256, new Revision(version: 1)), Language.English) + Subject.IsUpgradable( + profile, + langProfile, + new QualityModel(Quality.MP3_256, new Revision(version: 2)), + Language.English, + NoPreferredWordScore, + new QualityModel(Quality.MP3_256, new Revision(version: 1)), + Language.English, + NoPreferredWordScore) .Should().BeFalse(); } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs index f60c4fcc1..f7b8c7f3f 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs @@ -51,7 +51,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests remoteAlbum.Release.PublishDate = DateTime.UtcNow; remoteAlbum.Artist = Builder.CreateNew() - .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) .Build(); return remoteAlbum; diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs index da64265e1..84264ce70 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/AddFixture.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests private DownloadDecision _temporarilyRejected; private Artist _artist; private Album _album; - private Profile _profile; + private QualityProfile _profile; private ReleaseInfo _release; private ParsedAlbumInfo _parsedAlbumInfo; private RemoteAlbum _remoteAlbum; @@ -38,19 +38,19 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests _album = Builder.CreateNew() .Build(); - _profile = new Profile + _profile = new QualityProfile { Name = "Test", Cutoff = Quality.MP3_256.Id, - Items = new List + Items = new List { - new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, - new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }, - new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 } + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 } }, }; - _artist.Profile = new LazyLoaded(_profile); + _artist.QualityProfile = new LazyLoaded(_profile); _release = Builder.CreateNew().Build(); diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs index feecf8c2f..350bf47d2 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveGrabbedFixture.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests private DownloadDecision _temporarilyRejected; private Artist _artist; private Album _album; - private Profile _profile; + private QualityProfile _profile; private ReleaseInfo _release; private ParsedAlbumInfo _parsedAlbumInfo; private RemoteAlbum _remoteAlbum; @@ -38,19 +38,19 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests _album = Builder.CreateNew() .Build(); - _profile = new Profile + _profile = new QualityProfile { Name = "Test", Cutoff = Quality.MP3_256.Id, - Items = new List + Items = new List { - new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, - new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }, - new ProfileQualityItem { Allowed = true, Quality = Quality.FLAC } + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.FLAC } }, }; - _artist.Profile = new LazyLoaded(_profile); + _artist.QualityProfile = new LazyLoaded(_profile); _release = Builder.CreateNew().Build(); diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs index 36d6da6bc..6889c6800 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemoveRejectedFixture.cs @@ -24,7 +24,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests private DownloadDecision _temporarilyRejected; private Artist _artist; private Album _album; - private Profile _profile; + private QualityProfile _profile; private ReleaseInfo _release; private ParsedAlbumInfo _parsedAlbumInfo; private RemoteAlbum _remoteAlbum; @@ -38,19 +38,19 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests _album = Builder.CreateNew() .Build(); - _profile = new Profile + _profile = new QualityProfile { Name = "Test", Cutoff = Quality.MP3_192.Id, - Items = new List + Items = new List { - new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_192 }, - new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, - new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 } + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_192 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 } }, }; - _artist.Profile = new LazyLoaded(_profile); + _artist.QualityProfile = new LazyLoaded(_profile); _release = Builder.CreateNew().Build(); diff --git a/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs b/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs index 439736688..a04013826 100644 --- a/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs +++ b/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs @@ -21,20 +21,20 @@ namespace NzbDrone.Core.Test.HistoryTests { public class HistoryServiceFixture : CoreTest { - private Profile _profile; - private Profile _profileCustom; + private QualityProfile _profile; + private QualityProfile _profileCustom; private LanguageProfile _languageProfile; [SetUp] public void Setup() { - _profile = new Profile + _profile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = QualityFixture.GetDefaultQualities(), }; - _profileCustom = new Profile + _profileCustom = new QualityProfile { Cutoff = Quality.MP3_320.Id, diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs index fa7401ef5..70f3582e6 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs @@ -1,10 +1,10 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Housekeeping.Housekeepers; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tags; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.Test.Housekeeping.Housekeepers { @@ -27,7 +27,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers var tags = Builder.CreateListOfSize(2).BuildList(); Db.InsertMany(tags); - var restrictions = Builder.CreateListOfSize(2) + var restrictions = Builder.CreateListOfSize(2) .All() .With(v => v.Tags.Add(tags[0].Id)) .BuildList(); diff --git a/src/NzbDrone.Core.Test/Languages/LanguageFixture.cs b/src/NzbDrone.Core.Test/Languages/LanguageFixture.cs index 4db1719df..91c97b13e 100644 --- a/src/NzbDrone.Core.Test/Languages/LanguageFixture.cs +++ b/src/NzbDrone.Core.Test/Languages/LanguageFixture.cs @@ -77,7 +77,7 @@ namespace NzbDrone.Core.Test.Languages i.Should().Be(expected); } - public static List GetDefaultLanguages(params Language[] allowed) + public static List GetDefaultLanguages(params Language[] allowed) { var languages = new List { @@ -92,7 +92,7 @@ namespace NzbDrone.Core.Test.Languages var items = languages .Except(allowed) .Concat(allowed) - .Select(v => new ProfileLanguageItem { Language = v, Allowed = allowed.Contains(v) }).ToList(); + .Select(v => new LanguageProfileItem { Language = v, Allowed = allowed.Contains(v) }).ToList(); return items; } diff --git a/src/NzbDrone.Core.Test/Languages/LanguageProfileRepositoryFixture.cs b/src/NzbDrone.Core.Test/Languages/LanguageProfileRepositoryFixture.cs index c262d1eeb..8b04967be 100644 --- a/src/NzbDrone.Core.Test/Languages/LanguageProfileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/Languages/LanguageProfileRepositoryFixture.cs @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Test.Languages { var profile = new LanguageProfile { - Languages = Language.All.OrderByDescending(l => l.Name).Select(l => new ProfileLanguageItem {Language = l, Allowed = l == Language.English}).ToList(), + Languages = Language.All.OrderByDescending(l => l.Name).Select(l => new LanguageProfileItem {Language = l, Allowed = l == Language.English}).ToList(), Name = "TestProfile", Cutoff = Language.English }; diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs index 6f90f342f..cfcaf809c 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs @@ -37,7 +37,7 @@ namespace NzbDrone.Core.Test.MediaFiles _approvedDecisions = new List>(); var artist = Builder.CreateNew() - .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) .With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs index df5f09e6b..2d3c03135 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs @@ -42,7 +42,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests .Build(); Mocker.GetMock() - .Setup(s => s.BuildTrackFileName(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Setup(s => s.BuildTrackFileName(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, null)) .Returns("File Name"); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs index 2bf6c9a54..bea720465 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs @@ -84,7 +84,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport _fail3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_fail3")); _artist = Builder.CreateNew() - .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) .With(e => e.LanguageProfile = new LanguageProfile { Languages = Languages.LanguageFixture.GetDefaultLanguages() }) .Build(); diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/UpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/UpgradeSpecificationFixture.cs index a745cb2ee..b1429b900 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/UpgradeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/UpgradeSpecificationFixture.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Specifications public void Setup() { _artist = Builder.CreateNew() - .With(e => e.Profile = new Profile + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities(), }) diff --git a/src/NzbDrone.Core.Test/MusicTests/AlbumMonitoredServiceTests/AlbumMonitoredServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AlbumMonitoredServiceTests/AlbumMonitoredServiceFixture.cs index 077aa0e9f..31019c6e2 100644 --- a/src/NzbDrone.Core.Test/MusicTests/AlbumMonitoredServiceTests/AlbumMonitoredServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/AlbumMonitoredServiceTests/AlbumMonitoredServiceFixture.cs @@ -80,7 +80,7 @@ namespace NzbDrone.Core.Test.MusicTests.AlbumMonitoredServiceTests [Test] public void should_be_able_to_monitor_all_albums() { - Subject.SetAlbumMonitoredStatus(_artist, new MonitoringOptions{Monitored = true}); + Subject.SetAlbumMonitoredStatus(_artist, new MonitoringOptions{Monitor = MonitorTypes.All}); Mocker.GetMock() .Verify(v => v.UpdateAlbums(It.Is>(l => l.All(e => e.Monitored)))); @@ -91,7 +91,7 @@ namespace NzbDrone.Core.Test.MusicTests.AlbumMonitoredServiceTests { var monitoringOptions = new MonitoringOptions { - SelectedOption = MonitoringOption.Future + Monitor = MonitorTypes.Future }; Subject.SetAlbumMonitoredStatus(_artist, monitoringOptions); diff --git a/src/NzbDrone.Core.Test/MusicTests/ArtistRepositoryTests/ArtistRepositoryFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ArtistRepositoryTests/ArtistRepositoryFixture.cs index 9cba40c8d..e07ac772d 100644 --- a/src/NzbDrone.Core.Test/MusicTests/ArtistRepositoryTests/ArtistRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/ArtistRepositoryTests/ArtistRepositoryFixture.cs @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.MusicTests.ArtistRepositoryTests [Test] public void should_lazyload_profiles() { - var profile = new Profile + var profile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities(Quality.FLAC, Quality.MP3_192, Quality.MP3_320), @@ -73,19 +73,19 @@ namespace NzbDrone.Core.Test.MusicTests.ArtistRepositoryTests }; - Mocker.Resolve().Insert(profile); + Mocker.Resolve().Insert(profile); Mocker.Resolve().Insert(langProfile); Mocker.Resolve().Insert(metaProfile); var artist = Builder.CreateNew().BuildNew(); - artist.ProfileId = profile.Id; + artist.QualityProfileId = profile.Id; artist.LanguageProfileId = langProfile.Id; artist.MetadataProfileId = metaProfile.Id; Subject.Insert(artist); - StoredModel.Profile.Should().NotBeNull(); + StoredModel.QualityProfile.Should().NotBeNull(); StoredModel.LanguageProfile.Should().NotBeNull(); StoredModel.MetadataProfile.Should().NotBeNull(); diff --git a/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/UpdateMultipleArtistFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/UpdateMultipleArtistFixture.cs index 40dc075d9..5a8b92124 100644 --- a/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/UpdateMultipleArtistFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/UpdateMultipleArtistFixture.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.Test.MusicTests.ArtistServiceTests { _artists = Builder.CreateListOfSize(5) .All() - .With(s => s.ProfileId = 1) + .With(s => s.QualityProfileId = 1) .With(s => s.Monitored) .With(s => s.Path = @"C:\Test\name".AsOsAgnostic()) .With(s => s.RootFolderPath = "") diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 512d158bf..00dcad6ae 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -142,7 +142,7 @@ - + @@ -327,6 +327,7 @@ + diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs index 6177749da..7ad4f2a83 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs @@ -7,12 +7,12 @@ using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.Profiles { [TestFixture] - public class ProfileRepositoryFixture : DbTest + public class ProfileRepositoryFixture : DbTest { [Test] public void should_be_able_to_read_and_write() { - var profile = new Profile + var profile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_320, Quality.MP3_192, Quality.MP3_256), Cutoff = Quality.MP3_320.Id, diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs index 0bd06e3bd..d7edea846 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Test.Profiles { [TestFixture] - public class ProfileServiceFixture : CoreTest + public class ProfileServiceFixture : CoreTest { [Test] public void init_should_add_default_profiles() @@ -20,7 +20,7 @@ namespace NzbDrone.Core.Test.Profiles Subject.Handle(new ApplicationStartedEvent()); Mocker.GetMock() - .Verify(v => v.Insert(It.IsAny()), Times.Exactly(3)); + .Verify(v => v.Insert(It.IsAny()), Times.Exactly(3)); } [Test] @@ -30,25 +30,25 @@ namespace NzbDrone.Core.Test.Profiles { Mocker.GetMock() .Setup(s => s.All()) - .Returns(Builder.CreateListOfSize(2).Build().ToList()); + .Returns(Builder.CreateListOfSize(2).Build().ToList()); Subject.Handle(new ApplicationStartedEvent()); Mocker.GetMock() - .Verify(v => v.Insert(It.IsAny()), Times.Never()); + .Verify(v => v.Insert(It.IsAny()), Times.Never()); } [Test] public void should_not_be_able_to_delete_profile_if_assigned_to_artist() { - var profile = Builder.CreateNew() + var profile = Builder.CreateNew() .With(p => p.Id = 2) .Build(); var artistList = Builder.CreateListOfSize(3) .Random(1) - .With(c => c.ProfileId = profile.Id) + .With(c => c.QualityProfileId = profile.Id) .Build().ToList(); var importLists = Builder.CreateListOfSize(2) @@ -60,7 +60,7 @@ namespace NzbDrone.Core.Test.Profiles Mocker.GetMock().Setup(c => c.All()).Returns(importLists); Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); - Assert.Throws(() => Subject.Delete(profile.Id)); + Assert.Throws(() => Subject.Delete(profile.Id)); Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); @@ -69,13 +69,13 @@ namespace NzbDrone.Core.Test.Profiles [Test] public void should_not_be_able_to_delete_profile_if_assigned_to_import_list() { - var profile = Builder.CreateNew() + var profile = Builder.CreateNew() .With(p => p.Id = 2) .Build(); var artistList = Builder.CreateListOfSize(3) .All() - .With(c => c.ProfileId = 1) + .With(c => c.QualityProfileId = 1) .Build().ToList(); var importLists = Builder.CreateListOfSize(2) @@ -87,7 +87,7 @@ namespace NzbDrone.Core.Test.Profiles Mocker.GetMock().Setup(c => c.All()).Returns(importLists); Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); - Assert.Throws(() => Subject.Delete(profile.Id)); + Assert.Throws(() => Subject.Delete(profile.Id)); Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); @@ -98,7 +98,7 @@ namespace NzbDrone.Core.Test.Profiles { var artistList = Builder.CreateListOfSize(3) .All() - .With(c => c.ProfileId = 2) + .With(c => c.QualityProfileId = 2) .Build().ToList(); var importLists = Builder.CreateListOfSize(2) diff --git a/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/CalculateFixture.cs b/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/CalculateFixture.cs new file mode 100644 index 000000000..609a44129 --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/CalculateFixture.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.Profiles.Releases.PreferredWordService +{ + [TestFixture] + public class CalculateFixture : CoreTest + { + private Artist _artist = null; + private List _releaseProfiles = null; + private string _title = "Artist.Name-Album.Title.2018.FLAC.24bit-Lidarr"; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(s => s.Tags = new HashSet(new[] {1, 2})) + .Build(); + + _releaseProfiles = new List(); + + _releaseProfiles.Add(new ReleaseProfile + { + Preferred = new List> + { + new KeyValuePair("24bit", 5), + new KeyValuePair("16bit", -10) + } + }); + + Mocker.GetMock() + .Setup(s => s.AllForTags(It.IsAny>())) + .Returns(_releaseProfiles); + } + + + private void GivenMatchingTerms(params string[] terms) + { + Mocker.GetMock() + .Setup(s => s.IsMatch(It.IsAny(), _title)) + .Returns((term, title) => terms.Contains(term)); + } + + [Test] + public void should_return_0_when_there_are_no_release_profiles() + { + Mocker.GetMock() + .Setup(s => s.AllForTags(It.IsAny>())) + .Returns(new List()); + + Subject.Calculate(_artist, _title).Should().Be(0); + } + + [Test] + public void should_return_0_when_there_are_no_matching_preferred_words() + { + GivenMatchingTerms(); + + Subject.Calculate(_artist, _title).Should().Be(0); + } + + [Test] + public void should_calculate_positive_score() + { + GivenMatchingTerms("24bit"); + + Subject.Calculate(_artist, _title).Should().Be(5); + } + + [Test] + public void should_calculate_negative_score() + { + GivenMatchingTerms("16bit"); + + Subject.Calculate(_artist, _title).Should().Be(-10); + } + + [Test] + public void should_calculate_using_multiple_profiles() + { + _releaseProfiles.Add(_releaseProfiles.First()); + + GivenMatchingTerms("24bit"); + + Subject.Calculate(_artist, _title).Should().Be(10); + } + } +} diff --git a/src/NzbDrone.Core.Test/Qualities/QualityFixture.cs b/src/NzbDrone.Core.Test/Qualities/QualityFixture.cs index 31dbe468f..013353a12 100644 --- a/src/NzbDrone.Core.Test/Qualities/QualityFixture.cs +++ b/src/NzbDrone.Core.Test/Qualities/QualityFixture.cs @@ -45,7 +45,7 @@ namespace NzbDrone.Core.Test.Qualities i.Should().Be(expected); } - public static List GetDefaultQualities(params Quality[] allowed) + public static List GetDefaultQualities(params Quality[] allowed) { var qualities = new List { @@ -63,7 +63,7 @@ namespace NzbDrone.Core.Test.Qualities var items = qualities .Except(allowed) .Concat(allowed) - .Select(v => new ProfileQualityItem { Quality = v, Allowed = allowed.Contains(v) }).ToList(); + .Select(v => new QualityProfileQualityItem { Quality = v, Allowed = allowed.Contains(v) }).ToList(); return items; } diff --git a/src/NzbDrone.Core.Test/Qualities/QualityModelComparerFixture.cs b/src/NzbDrone.Core.Test/Qualities/QualityModelComparerFixture.cs index e35294f4f..85f97ba62 100644 --- a/src/NzbDrone.Core.Test/Qualities/QualityModelComparerFixture.cs +++ b/src/NzbDrone.Core.Test/Qualities/QualityModelComparerFixture.cs @@ -14,43 +14,43 @@ namespace NzbDrone.Core.Test.Qualities private void GivenDefaultProfile() { - Subject = new QualityModelComparer(new Profile { Items = QualityFixture.GetDefaultQualities() }); + Subject = new QualityModelComparer(new QualityProfile { Items = QualityFixture.GetDefaultQualities() }); } private void GivenCustomProfile() { - Subject = new QualityModelComparer(new Profile { Items = QualityFixture.GetDefaultQualities(Quality.MP3_320, Quality.MP3_192) }); + Subject = new QualityModelComparer(new QualityProfile { Items = QualityFixture.GetDefaultQualities(Quality.MP3_320, Quality.MP3_192) }); } private void GivenGroupedProfile() { - var profile = new Profile + var profile = new QualityProfile { - Items = new List + Items = new List { - new ProfileQualityItem + new QualityProfileQualityItem { Allowed = false, Quality = Quality.MP3_192 }, - new ProfileQualityItem + new QualityProfileQualityItem { Allowed = true, - Items = new List + Items = new List { - new ProfileQualityItem + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, - new ProfileQualityItem + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 } } }, - new ProfileQualityItem + new QualityProfileQualityItem { Allowed = true, Quality = Quality.FLAC diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 18d015bae..1da298c1d 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.Annotations public FieldType Type { get; set; } public bool Advanced { get; set; } public Type SelectOptions { get; set; } + public string Section { get; set; } } public enum FieldType diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index 6f316df4c..ec5f46545 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -33,6 +33,7 @@ namespace NzbDrone.Core.Configuration AuthenticationType AuthenticationMethod { get; } bool AnalyticsEnabled { get; } string LogLevel { get; } + string ConsoleLogLevel { get; } string Branch { get; } string ApiKey { get; } string SslCertHash { get; } @@ -179,6 +180,7 @@ namespace NzbDrone.Core.Configuration public string Branch => GetValue("Branch", "develop").ToLowerInvariant(); public string LogLevel => GetValue("LogLevel", "Info"); + public string ConsoleLogLevel => GetValue("ConsoleLogLevel", string.Empty, persist: false); public string SslCertHash => GetValue("SslCertHash", ""); diff --git a/src/NzbDrone.Core/Datastore/Migration/025_rename_release_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/025_rename_release_profiles.cs new file mode 100644 index 000000000..0ae44ae45 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/025_rename_release_profiles.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(025)] + public class rename_restrictions_to_release_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Rename.Table("Restrictions").To("ReleaseProfiles"); + Alter.Table("ReleaseProfiles").AddColumn("IncludePreferredWhenRenaming").AsBoolean().WithDefaultValue(true); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/026_rename_quality_profiles_add_upgrade_allowed.cs b/src/NzbDrone.Core/Datastore/Migration/026_rename_quality_profiles_add_upgrade_allowed.cs new file mode 100644 index 000000000..36aa6fbc5 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/026_rename_quality_profiles_add_upgrade_allowed.cs @@ -0,0 +1,23 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(026)] + public class rename_quality_profiles_add_upgrade_allowed : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Rename.Table("Profiles").To("QualityProfiles"); + + Alter.Table("QualityProfiles").AddColumn("UpgradeAllowed").AsInt32().Nullable(); + Alter.Table("LanguageProfiles").AddColumn("UpgradeAllowed").AsInt32().Nullable(); + + // Set upgrade allowed for existing profiles (default will be false for new profiles) + Update.Table("QualityProfiles").Set(new { UpgradeAllowed = true }).AllRows(); + Update.Table("LanguageProfiles").Set(new { UpgradeAllowed = true }).AllRows(); + + Rename.Column("ProfileId").OnTable("Artists").To("QualityProfileId"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSyntaxReader.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSyntaxReader.cs index e60ef4c70..703aff012 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSyntaxReader.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSyntaxReader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Text; @@ -148,7 +148,8 @@ namespace NzbDrone.Core.Datastore.Migration.Framework { var start = Index; var end = start + 1; - while (end < Buffer.Length && (char.IsLetter(Buffer[end]) || Buffer[end] == '_')) end++; + while (end < Buffer.Length && (char.IsLetter(Buffer[end]) || char.IsNumber(Buffer[end]) || Buffer[end] == '_' || Buffer[end] == '(')) end++; + if (end >= Buffer.Length || Buffer[end] == ',' || Buffer[end] == ')' || char.IsWhiteSpace(Buffer[end])) { Index = end; diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 87dbe5223..20bf4042e 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -24,7 +24,6 @@ using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Profiles.Metadata; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Restrictions; using NzbDrone.Core.RootFolders; using NzbDrone.Core.ArtistStats; using NzbDrone.Core.Tags; @@ -41,6 +40,7 @@ using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Music; using NzbDrone.Core.Languages; using Marr.Data.QGen; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.Datastore { @@ -93,7 +93,7 @@ namespace NzbDrone.Core.Datastore .Ignore(s => s.ForeignArtistId) .Relationship() .HasOne(a => a.Metadata, a => a.ArtistMetadataId) - .HasOne(a => a.Profile, a => a.ProfileId) + .HasOne(a => a.QualityProfile, a => a.QualityProfileId) .HasOne(s => s.LanguageProfile, s => s.LanguageProfileId) .HasOne(s => s.MetadataProfile, s => s.MetadataProfileId) .For(a => a.Albums) @@ -163,7 +163,7 @@ namespace NzbDrone.Core.Datastore .Ignore(d => d.GroupWeight) .Ignore(d => d.Weight); - Mapper.Entity().RegisterModel("Profiles"); + Mapper.Entity().RegisterModel("QualityProfiles"); Mapper.Entity().RegisterModel("LanguageProfiles"); Mapper.Entity().RegisterModel("MetadataProfiles"); Mapper.Entity().RegisterModel("Logs"); @@ -179,7 +179,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity().RegisterModel("RemotePathMappings"); Mapper.Entity().RegisterModel("Tags"); - Mapper.Entity().RegisterModel("Restrictions"); + Mapper.Entity().RegisterModel("ReleaseProfiles"); Mapper.Entity().RegisterModel("DelayProfiles"); Mapper.Entity().RegisterModel("Users"); @@ -204,13 +204,14 @@ namespace NzbDrone.Core.Datastore MapRepository.Instance.RegisterTypeConverter(typeof(bool), new BooleanIntConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(Enum), new EnumIntConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(Quality), new QualityIntConverter()); - MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new QualityIntConverter())); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new QualityIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(QualityModel), new EmbeddedDocumentConverter(new QualityIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(Dictionary), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(List>), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(Language), new LanguageIntConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); - MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new LanguageIntConverter())); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new LanguageIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new PrimaryAlbumTypeIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new SecondaryAlbumTypeIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new ReleaseStatusIntConverter())); diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs index 5176b0225..df0ddb403 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs @@ -24,6 +24,7 @@ namespace NzbDrone.Core.DecisionEngine { CompareQuality, CompareLanguage, + ComparePreferredWordScore, CompareProtocol, ComparePeersIfTorrent, CompareAlbumCount, @@ -56,7 +57,7 @@ namespace NzbDrone.Core.DecisionEngine private int CompareQuality(DownloadDecision x, DownloadDecision y) { - return CompareAll(CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.Artist.Profile.Value.GetIndex(remoteAlbum.ParsedAlbumInfo.Quality.Quality)), + return CompareAll(CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.Artist.QualityProfile.Value.GetIndex(remoteAlbum.ParsedAlbumInfo.Quality.Quality)), CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.ParsedAlbumInfo.Quality.Revision.Real), CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.ParsedAlbumInfo.Quality.Revision.Version)); } @@ -66,6 +67,11 @@ namespace NzbDrone.Core.DecisionEngine return CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.Artist.LanguageProfile.Value.Languages.FindIndex(l => l.Language == remoteAlbum.ParsedAlbumInfo.Language)); } + private int ComparePreferredWordScore(DownloadDecision x, DownloadDecision y) + { + return CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.PreferredWordScore); + } + private int CompareProtocol(DownloadDecision x, DownloadDecision y) { var result = CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index 069c242ff..693f0797a 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -5,6 +5,8 @@ using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Serializer; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Download.Aggregation; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; @@ -21,12 +23,17 @@ namespace NzbDrone.Core.DecisionEngine { private readonly IEnumerable _specifications; private readonly IParsingService _parsingService; + private readonly IRemoteAlbumAggregationService _aggregationService; private readonly Logger _logger; - public DownloadDecisionMaker(IEnumerable specifications, IParsingService parsingService, Logger logger) + public DownloadDecisionMaker(IEnumerable specifications, + IParsingService parsingService, + IRemoteAlbumAggregationService aggregationService, + Logger logger) { _specifications = specifications; _parsingService = parsingService; + _aggregationService = aggregationService; _logger = logger; } @@ -126,6 +133,7 @@ namespace NzbDrone.Core.DecisionEngine } else { + _aggregationService.Augment(remoteAlbum); remoteAlbum.DownloadAllowed = remoteAlbum.Albums.Any(); decision = GetDecisionForReport(remoteAlbum, searchCriteria); } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs index 5764f4283..2332538d9 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Music; using NzbDrone.Common.Cache; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.DecisionEngine.Specifications { @@ -16,18 +17,21 @@ namespace NzbDrone.Core.DecisionEngine.Specifications private readonly ITrackService _trackService; private readonly Logger _logger; private readonly ICached _missingFilesCache; + private readonly IPreferredWordService _preferredWordServiceCalculator; public CutoffSpecification(UpgradableSpecification upgradableSpecification, Logger logger, ICacheManager cacheManager, IMediaFileService mediaFileService, + IPreferredWordService preferredWordServiceCalculator, ITrackService trackService) { _upgradableSpecification = upgradableSpecification; - _logger = logger; _mediaFileService = mediaFileService; _trackService = trackService; _missingFilesCache = cacheManager.GetCache(GetType()); + _preferredWordServiceCalculator = preferredWordServiceCalculator; + _logger = logger; } public SpecificationPriority Priority => SpecificationPriority.Default; @@ -36,7 +40,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { - var profile = subject.Artist.Profile.Value; + var profile = subject.Artist.QualityProfile.Value; foreach (var album in subject.Albums) { @@ -54,7 +58,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications subject.Artist.LanguageProfile, lowestQuality, trackFiles[0].Language, - subject.ParsedAlbumInfo.Quality)) + _preferredWordServiceCalculator.Calculate(subject.Artist, trackFiles[0].GetSceneOrFileName()), + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore)) { _logger.Debug("Cutoff already met, rejecting."); var qualityCutoffIndex = profile.GetIndex(profile.Cutoff); diff --git a/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/IDecisionEngineSpecification.cs similarity index 85% rename from src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs rename to src/NzbDrone.Core/DecisionEngine/Specifications/IDecisionEngineSpecification.cs index cafede0fb..a9eec669f 100644 --- a/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/IDecisionEngineSpecification.cs @@ -1,7 +1,7 @@ using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; -namespace NzbDrone.Core.DecisionEngine +namespace NzbDrone.Core.DecisionEngine.Specifications { public interface IDecisionEngineSpecification { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs index a6ea5f207..73bb23fea 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { _logger.Debug("Checking if report meets quality requirements. {0}", subject.ParsedAlbumInfo.Quality); - var profile = subject.Artist.Profile.Value; + var profile = subject.Artist.QualityProfile.Value; var qualityIndex = profile.GetIndex(subject.ParsedAlbumInfo.Quality.Quality); var qualityOrGroup = profile.Items[qualityIndex.Index]; diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs index c286c6707..b137ca527 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs @@ -2,6 +2,7 @@ using System.Linq; using NLog; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Queue; namespace NzbDrone.Core.DecisionEngine.Specifications @@ -10,14 +11,17 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { private readonly IQueueService _queueService; private readonly UpgradableSpecification _upgradableSpecification; + private readonly IPreferredWordService _preferredWordServiceCalculator; private readonly Logger _logger; public QueueSpecification(IQueueService queueService, UpgradableSpecification upgradableSpecification, + IPreferredWordService preferredWordServiceCalculator, Logger logger) { _queueService = queueService; _upgradableSpecification = upgradableSpecification; + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; } @@ -26,33 +30,43 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { - var queue = _queueService.GetQueue() - .Select(q => q.RemoteAlbum).ToList(); + var queue = _queueService.GetQueue(); + var matchingAlbum = queue.Where(q => q.RemoteAlbum != null && + q.RemoteAlbum.Artist != null && + q.RemoteAlbum.Artist.Id == subject.Artist.Id && + q.RemoteAlbum.Albums.Select(e => e.Id).Intersect(subject.Albums.Select(e => e.Id)).Any()) + .ToList(); - var matchingArtist = queue.Where(q => q.Artist.Id == subject.Artist.Id); - var matchingAlbum = matchingArtist.Where(q => q.Albums.Select(e => e.Id).Intersect(subject.Albums.Select(e => e.Id)).Any()); - foreach (var remoteAlbum in matchingAlbum) + foreach (var queueItem in matchingAlbum) { + var remoteAlbum = queueItem.RemoteAlbum; + _logger.Debug("Checking if existing release in queue meets cutoff. Queued quality is: {0} - {1}", remoteAlbum.ParsedAlbumInfo.Quality, remoteAlbum.ParsedAlbumInfo.Language); + var queuedItemPreferredWordScore = _preferredWordServiceCalculator.Calculate(subject.Artist, queueItem.Title); - if (!_upgradableSpecification.CutoffNotMet(subject.Artist.Profile, + if (!_upgradableSpecification.CutoffNotMet(subject.Artist.QualityProfile, subject.Artist.LanguageProfile, remoteAlbum.ParsedAlbumInfo.Quality, remoteAlbum.ParsedAlbumInfo.Language, - subject.ParsedAlbumInfo.Quality)) + queuedItemPreferredWordScore, + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore)) + { return Decision.Reject("Quality for release in queue already meets cutoff: {0}", remoteAlbum.ParsedAlbumInfo.Quality); } _logger.Debug("Checking if release is higher quality than queued release. Queued quality is: {0} - {1}", remoteAlbum.ParsedAlbumInfo.Quality, remoteAlbum.ParsedAlbumInfo.Language); - if (!_upgradableSpecification.IsUpgradable(subject.Artist.Profile, + if (!_upgradableSpecification.IsUpgradable(subject.Artist.QualityProfile, subject.Artist.LanguageProfile, remoteAlbum.ParsedAlbumInfo.Quality, remoteAlbum.ParsedAlbumInfo.Language, + queuedItemPreferredWordScore, subject.ParsedAlbumInfo.Quality, - subject.ParsedAlbumInfo.Language)) + subject.ParsedAlbumInfo.Language, + subject.PreferredWordScore)) { return Decision.Reject("Quality for release in queue is of equal or higher preference: {0} - {1}", remoteAlbum.ParsedAlbumInfo.Quality, remoteAlbum.ParsedAlbumInfo.Language); } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs index 804eddeed..a7f7fcf21 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs @@ -5,20 +5,20 @@ using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.DecisionEngine.Specifications { public class ReleaseRestrictionsSpecification : IDecisionEngineSpecification { - private readonly IRestrictionService _restrictionService; private readonly Logger _logger; + private readonly IReleaseProfileService _releaseProfileService; private readonly ITermMatcher _termMatcher; - public ReleaseRestrictionsSpecification(ITermMatcher termMatcher, IRestrictionService restrictionService, Logger logger) + public ReleaseRestrictionsSpecification(ITermMatcher termMatcher, IReleaseProfileService releaseProfileService, Logger logger) { - _restrictionService = restrictionService; _logger = logger; + _releaseProfileService = releaseProfileService; _termMatcher = termMatcher; } @@ -30,7 +30,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger.Debug("Checking if release meets restrictions: {0}", subject); var title = subject.Release.Title; - var restrictions = _restrictionService.AllForTags(subject.Artist.Tags); + var restrictions = _releaseProfileService.AllForTags(subject.Artist.Tags); var required = restrictions.Where(r => r.Required.IsNotNullOrWhiteSpace()); var ignored = restrictions.Where(r => r.Ignored.IsNotNullOrWhiteSpace()); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs index db57c4bac..2997b8658 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Qualities; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Languages; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync { @@ -16,18 +17,21 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync private readonly IUpgradableSpecification _upgradableSpecification; private readonly IDelayProfileService _delayProfileService; private readonly IMediaFileService _mediaFileService; + private readonly IPreferredWordService _preferredWordServiceCalculator; private readonly Logger _logger; public DelaySpecification(IPendingReleaseService pendingReleaseService, IUpgradableSpecification qualityUpgradableSpecification, IDelayProfileService delayProfileService, IMediaFileService mediaFileService, + IPreferredWordService preferredWordServiceCalculator, Logger logger) { _pendingReleaseService = pendingReleaseService; _upgradableSpecification = qualityUpgradableSpecification; _delayProfileService = delayProfileService; _mediaFileService = mediaFileService; + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; } @@ -42,7 +46,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync return Decision.Accept(); } - var profile = subject.Artist.Profile.Value; + var qualityProfile = subject.Artist.QualityProfile.Value; var languageProfile = subject.Artist.LanguageProfile.Value; var delayProfile = _delayProfileService.BestForTags(subject.Artist.Tags); var delay = delayProfile.GetProtocolDelay(subject.Release.DownloadProtocol); @@ -54,8 +58,8 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync return Decision.Accept(); } - var comparer = new QualityModelComparer(profile); - var comparerLanguage = new LanguageComparer(languageProfile); + var qualityComparer = new QualityModelComparer(qualityProfile); + var languageComparer = new LanguageComparer(languageProfile); if (isPreferredProtocol) { @@ -66,12 +70,14 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync if (trackFiles.Any()) { var lowestQuality = trackFiles.Select(c => c.Quality).OrderBy(c => c.Quality.Id).First(); - var upgradable = _upgradableSpecification.IsUpgradable(profile, + var upgradable = _upgradableSpecification.IsUpgradable(qualityProfile, languageProfile, lowestQuality, trackFiles[0].Language, + _preferredWordServiceCalculator.Calculate(subject.Artist, trackFiles[0].GetSceneOrFileName()), subject.ParsedAlbumInfo.Quality, - subject.ParsedAlbumInfo.Language); + subject.ParsedAlbumInfo.Language, + subject.PreferredWordScore); if (upgradable) { _logger.Debug("New quality is a better revision for existing quality, skipping delay"); @@ -82,9 +88,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync } // If quality meets or exceeds the best allowed quality in the profile accept it immediately - var bestQualityInProfile = profile.LastAllowedQuality(); - var isBestInProfile = comparer.Compare(subject.ParsedAlbumInfo.Quality.Quality, bestQualityInProfile) >= 0; - var isBestInProfileLanguage = comparerLanguage.Compare(subject.ParsedAlbumInfo.Language, languageProfile.LastAllowedLanguage()) >= 0; + var bestQualityInProfile = qualityProfile.LastAllowedQuality(); + var isBestInProfile = qualityComparer.Compare(subject.ParsedAlbumInfo.Quality.Quality, bestQualityInProfile) >= 0; + var isBestInProfileLanguage = languageComparer.Compare(subject.ParsedAlbumInfo.Language, languageProfile.LastAllowedLanguage()) >= 0; if (isBestInProfile && isBestInProfileLanguage && isPreferredProtocol) { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs index ab9b4f30b..e2d408ab1 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs @@ -5,6 +5,7 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.History; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync { @@ -13,16 +14,19 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync private readonly IHistoryService _historyService; private readonly UpgradableSpecification _upgradableSpecification; private readonly IConfigService _configService; + private readonly IPreferredWordService _preferredWordServiceCalculator; private readonly Logger _logger; public HistorySpecification(IHistoryService historyService, UpgradableSpecification qualityUpgradableSpecification, IConfigService configService, + IPreferredWordService preferredWordServiceCalculator, Logger logger) { _historyService = historyService; _upgradableSpecification = qualityUpgradableSpecification; _configService = configService; + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; } @@ -48,8 +52,28 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync if (mostRecent != null && mostRecent.EventType == HistoryEventType.Grabbed) { var recent = mostRecent.Date.After(DateTime.UtcNow.AddHours(-12)); - var cutoffUnmet = _upgradableSpecification.CutoffNotMet(subject.Artist.Profile, subject.Artist.LanguageProfile, mostRecent.Quality, mostRecent.Language, subject.ParsedAlbumInfo.Quality); - var upgradeable = _upgradableSpecification.IsUpgradable(subject.Artist.Profile, subject.Artist.LanguageProfile, mostRecent.Quality, mostRecent.Language, subject.ParsedAlbumInfo.Quality, subject.ParsedAlbumInfo.Language); + // The artist will be the same as the one in history since it's the same episode. + // Instead of fetching the series from the DB reuse the known series. + var preferredWordScore = _preferredWordServiceCalculator.Calculate(subject.Artist, mostRecent.SourceTitle); + + var cutoffUnmet = _upgradableSpecification.CutoffNotMet( + subject.Artist.QualityProfile, + subject.Artist.LanguageProfile, + mostRecent.Quality, + mostRecent.Language, + preferredWordScore, + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore); + + var upgradeable = _upgradableSpecification.IsUpgradable( + subject.Artist.QualityProfile, + subject.Artist.LanguageProfile, + mostRecent.Quality, + mostRecent.Language, + preferredWordScore, + subject.ParsedAlbumInfo.Quality, + subject.ParsedAlbumInfo.Language, + subject.PreferredWordScore); if (!recent && cdhEnabled) { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredAlbumSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredAlbumSpecification.cs index 404b39c3d..e0f26ab11 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredAlbumSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredAlbumSpecification.cs @@ -30,7 +30,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync if (!subject.Artist.Monitored) { - _logger.Debug("{0} is present in the DB but not tracked. skipping.", subject.Artist); + _logger.Debug("{0} is present in the DB but not tracked. Rejecting.", subject.Artist); return Decision.Reject("Artist is not monitored"); } @@ -40,7 +40,21 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync return Decision.Accept(); } - _logger.Debug("Only {0}/{1} albums are monitored. skipping.", monitoredCount, subject.Albums.Count); + if (subject.Albums.Count == 1) + { + _logger.Debug("Album is not monitored. Rejecting", monitoredCount, subject.Albums.Count); + return Decision.Reject("Album is not monitored"); + } + + if (monitoredCount == 0) + { + _logger.Debug("No albums in the release are monitored. Rejecting", monitoredCount, subject.Albums.Count); + } + else + { + _logger.Debug("Only {0}/{1} albums in the release are monitored. Rejecting", monitoredCount, subject.Albums.Count); + } + return Decision.Reject("Album is not monitored"); } } diff --git a/src/NzbDrone.Core/DecisionEngine/SameTracksSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksSpecification.cs similarity index 93% rename from src/NzbDrone.Core/DecisionEngine/SameTracksSpecification.cs rename to src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksSpecification.cs index 56fb258f4..dec6ac052 100644 --- a/src/NzbDrone.Core/DecisionEngine/SameTracksSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksSpecification.cs @@ -1,11 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; using NzbDrone.Common.Extensions; using NzbDrone.Core.Music; -namespace NzbDrone.Core.DecisionEngine +namespace NzbDrone.Core.DecisionEngine.Specifications { public class SameTracksSpecification { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs deleted file mode 100644 index f934f2e55..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications.Search -{ - public class SeasonMatchSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public SeasonMatchSpecification(Logger logger) - { - _logger = logger; - } - - public SpecificationPriority Priority => SpecificationPriority.Default; - public RejectionType Type => RejectionType.Permanent; - - public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) - { - throw new NotImplementedException(); - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeMatchSpecification.cs deleted file mode 100644 index 2a8495492..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeMatchSpecification.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications.Search -{ - public class SingleEpisodeMatchSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public SingleEpisodeMatchSpecification(Logger logger) - { - _logger = logger; - } - - public string RejectionReason - { - get - { - return "Episode doesn't match"; - } - } - - public bool IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchDefinitionBase searchDefinitionBase) - { - var singleEpisodeSpec = searchDefinitionBase as SingleEpisodeSearchDefinition; - if (singleEpisodeSpec == null) return true; - - if (singleEpisodeSpec.SeasonNumber != remoteEpisode.ParsedEpisodeInfo.SeasonNumber) - { - _logger.Trace("Season number does not match searched season number, skipping."); - return false; - } - - if (!remoteEpisode.Episodes.Select(c => c.EpisodeNumber).Contains(singleEpisodeSpec.EpisodeNumber)) - { - _logger.Trace("Episode number does not match searched episode number, skipping."); - return false; - } - - return true; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DecisionEngine/UpgradableSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs similarity index 54% rename from src/NzbDrone.Core/DecisionEngine/UpgradableSpecification.cs rename to src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs index 278844294..66ca3d710 100644 --- a/src/NzbDrone.Core/DecisionEngine/UpgradableSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs @@ -4,14 +4,14 @@ using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; -namespace NzbDrone.Core.DecisionEngine +namespace NzbDrone.Core.DecisionEngine.Specifications { public interface IUpgradableSpecification { - bool IsUpgradable(Profile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, QualityModel newQuality, Language newLanguage); - bool QualityCutoffNotMet(Profile profile, QualityModel currentQuality, QualityModel newQuality = null); + bool IsUpgradable(QualityProfile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, int currentScore, QualityModel newQuality, Language newLanguage, int newScore); + bool QualityCutoffNotMet(QualityProfile profile, QualityModel currentQuality, QualityModel newQuality = null); bool LanguageCutoffNotMet(LanguageProfile languageProfile, Language currentLanguage); - bool CutoffNotMet(Profile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, QualityModel newQuality = null); + bool CutoffNotMet(QualityProfile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, int currentScore, QualityModel newQuality = null, int newScore = 0); bool IsRevisionUpgrade(QualityModel currentQuality, QualityModel newQuality); } @@ -37,40 +37,66 @@ namespace NzbDrone.Core.DecisionEngine return true; } - private bool IsQualityUpgradable(Profile profile, QualityModel currentQuality, QualityModel newQuality = null) + private bool IsQualityUpgradable(QualityProfile profile, QualityModel currentQuality, QualityModel newQuality = null) { if (newQuality != null) { var compare = new QualityModelComparer(profile).Compare(newQuality, currentQuality); + if (compare <= 0) { - _logger.Debug("existing item has better quality. skipping"); + _logger.Debug("Existing item has better quality, skipping"); return false; } } return true; } + private bool IsPreferredWordUpgradable(int currentScore, int newScore) + { + return newScore > currentScore; + } - public bool IsUpgradable(Profile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, QualityModel newQuality, Language newLanguage) + public bool IsUpgradable(QualityProfile qualityProfile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, int currentScore, QualityModel newQuality, Language newLanguage, int newScore) { - // If qualities are the same then check language - if (newQuality != null && new QualityModelComparer(profile).Compare(newQuality, currentQuality) == 0) + if (IsQualityUpgradable(qualityProfile, currentQuality, newQuality) && qualityProfile.UpgradeAllowed) + { + return true; + } + + if (new QualityModelComparer(qualityProfile).Compare(newQuality, currentQuality) < 0) + { + _logger.Debug("Existing item has better quality, skipping"); + return false; + } + + if (IsLanguageUpgradable(languageProfile, currentLanguage, newLanguage) && languageProfile.UpgradeAllowed) + { + return true; + } + + if (new LanguageComparer(languageProfile).Compare(newLanguage, currentLanguage) < 0) + { + _logger.Debug("Existing item has better language, skipping"); + return false; + } + + if (!IsPreferredWordUpgradable(currentScore, newScore)) { - return IsLanguageUpgradable(languageProfile, currentLanguage, newLanguage); + _logger.Debug("Existing item has a better preferred word score, skipping"); + return false; } - // If quality is worse then always return false - if (!IsQualityUpgradable(profile, currentQuality, newQuality)) + if (!IsPreferredWordUpgradable(currentScore, newScore)) { - _logger.Debug("existing item has better quality. skipping"); + _logger.Debug("Existing item has a better preferred word score, skipping"); return false; } return true; } - public bool QualityCutoffNotMet(Profile profile, QualityModel currentQuality, QualityModel newQuality = null) + public bool QualityCutoffNotMet(QualityProfile profile, QualityModel currentQuality, QualityModel newQuality = null) { var qualityCompare = new QualityModelComparer(profile).Compare(currentQuality.Quality.Id, profile.Cutoff); @@ -94,9 +120,10 @@ namespace NzbDrone.Core.DecisionEngine return languageCompare < 0; } - public bool CutoffNotMet(Profile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, QualityModel newQuality = null) + public bool CutoffNotMet(QualityProfile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, int currentScore, QualityModel newQuality = null, int newScore = 0) { - // If we can upgrade the language (it is not the cutoff) then doesn't matter the quality we can always get same quality with prefered language + // If we can upgrade the language (it is not the cutoff) then the quality doesn't + // matter as we can always get same quality with prefered language. if (LanguageCutoffNotMet(languageProfile, currentLanguage)) { return true; @@ -107,6 +134,11 @@ namespace NzbDrone.Core.DecisionEngine return true; } + if (IsPreferredWordUpgradable(currentScore, newScore)) + { + return true; + } + _logger.Debug("Existing item meets cut-off. skipping."); return false; diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs index 083312d67..51fb19c88 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Music; using NzbDrone.Common.Cache; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.DecisionEngine.Specifications { @@ -14,6 +15,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications private readonly IMediaFileService _mediaFileService; private readonly ITrackService _trackService; private readonly UpgradableSpecification _upgradableSpecification; + private readonly IPreferredWordService _preferredWordServiceCalculator; private readonly Logger _logger; private readonly ICached _missingFilesCache; @@ -21,11 +23,13 @@ namespace NzbDrone.Core.DecisionEngine.Specifications IMediaFileService mediaFileService, ITrackService trackService, ICacheManager cacheManager, + IPreferredWordService preferredWordServiceCalculator, Logger logger) { _upgradableSpecification = qualityUpgradableSpecification; _mediaFileService = mediaFileService; _trackService = trackService; + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; _missingFilesCache = cacheManager.GetCache(GetType()); } @@ -46,14 +50,16 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { var lowestQuality = trackFiles.Select(c => c.Quality).OrderBy(c => c.Quality.Id).First(); - if (!_upgradableSpecification.IsUpgradable(subject.Artist.Profile, + if (!_upgradableSpecification.IsUpgradable(subject.Artist.QualityProfile, subject.Artist.LanguageProfile, lowestQuality, trackFiles[0].Language, + _preferredWordServiceCalculator.Calculate(subject.Artist, trackFiles[0].GetSceneOrFileName()), subject.ParsedAlbumInfo.Quality, - subject.ParsedAlbumInfo.Language)) + subject.ParsedAlbumInfo.Language, + subject.PreferredWordScore)) { - return Decision.Reject("Quality for existing file on disk is of equal or higher preference: {0}", lowestQuality); + return Decision.Reject("Existing file on disk is of equal or higher preference: {0} - {1}", lowestQuality, trackFiles[0].Language); } } diff --git a/src/NzbDrone.Core/Download/Aggregation/Aggregators/AggregatePreferredWordScore.cs b/src/NzbDrone.Core/Download/Aggregation/Aggregators/AggregatePreferredWordScore.cs new file mode 100644 index 000000000..cf41c32bc --- /dev/null +++ b/src/NzbDrone.Core/Download/Aggregation/Aggregators/AggregatePreferredWordScore.cs @@ -0,0 +1,22 @@ +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Releases; + +namespace NzbDrone.Core.Download.Aggregation.Aggregators +{ + public class AggregatePreferredWordScore : IAggregateRemoteAlbum + { + private readonly IPreferredWordService _preferredWordServiceCalculator; + + public AggregatePreferredWordScore(IPreferredWordService preferredWordServiceCalculator) + { + _preferredWordServiceCalculator = preferredWordServiceCalculator; + } + + public RemoteAlbum Aggregate(RemoteAlbum remoteAlbum) + { + remoteAlbum.PreferredWordScore = _preferredWordServiceCalculator.Calculate(remoteAlbum.Artist, remoteAlbum.Release.Title); + + return remoteAlbum; + } + } +} diff --git a/src/NzbDrone.Core/Download/Aggregation/Aggregators/IAggregateRemoteAlbum.cs b/src/NzbDrone.Core/Download/Aggregation/Aggregators/IAggregateRemoteAlbum.cs new file mode 100644 index 000000000..c88c95686 --- /dev/null +++ b/src/NzbDrone.Core/Download/Aggregation/Aggregators/IAggregateRemoteAlbum.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download.Aggregation.Aggregators +{ + public interface IAggregateRemoteAlbum + { + RemoteAlbum Aggregate(RemoteAlbum remoteAlbum); + } +} diff --git a/src/NzbDrone.Core/Download/Aggregation/RemoteAlbumAggregationService.cs b/src/NzbDrone.Core/Download/Aggregation/RemoteAlbumAggregationService.cs new file mode 100644 index 000000000..61647a7a2 --- /dev/null +++ b/src/NzbDrone.Core/Download/Aggregation/RemoteAlbumAggregationService.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Core.Download.Aggregation.Aggregators; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download.Aggregation +{ + public interface IRemoteAlbumAggregationService + { + RemoteAlbum Augment(RemoteAlbum remoteAlbum); + } + + public class RemoteAlbumAggregationService : IRemoteAlbumAggregationService + { + private readonly IEnumerable _augmenters; + private readonly Logger _logger; + + public RemoteAlbumAggregationService(IEnumerable augmenters, + Logger logger) + { + _augmenters = augmenters; + _logger = logger; + } + + public RemoteAlbum Augment(RemoteAlbum remoteAlbum) + { + foreach (var augmenter in _augmenters) + { + try + { + augmenter.Aggregate(remoteAlbum); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + } + } + + + return remoteAlbum; + } + } +} diff --git a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs index cab71ac28..cc267c6ce 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs @@ -197,6 +197,7 @@ namespace NzbDrone.Core.Download.Pending Artist = pendingRelease.RemoteAlbum.Artist, Album = album, Quality = pendingRelease.RemoteAlbum.ParsedAlbumInfo.Quality, + Language = pendingRelease.RemoteAlbum.ParsedAlbumInfo.Language, Title = pendingRelease.Title, Size = pendingRelease.RemoteAlbum.Release.Size, Sizeleft = pendingRelease.RemoteAlbum.Release.Size, @@ -217,7 +218,7 @@ namespace NzbDrone.Core.Download.Pending { var artist = g.First().Artist; - return g.OrderByDescending(e => e.Quality, new QualityModelComparer(artist.Profile)) + return g.OrderByDescending(e => e.Quality, new QualityModelComparer(artist.QualityProfile)) .ThenBy(q => PrioritizeDownloadProtocol(q.Artist, q.Protocol)) .First(); }); @@ -368,7 +369,7 @@ namespace NzbDrone.Core.Download.Pending return; } - var profile = remoteAlbum.Artist.Profile.Value; + var profile = remoteAlbum.Artist.QualityProfile.Value; foreach (var existingReport in existingReports) { diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index 66d02f11c..8a2a26244 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -152,10 +152,10 @@ namespace NzbDrone.Core.Download.TrackedDownloads } } + // Track it so it can be displayed in the queue even though we can't determine which artist it is for if (trackedDownload.RemoteAlbum == null) { - _logger.Trace("No Album found for download '{0}', not tracking.", trackedDownload.DownloadItem.Title); - return null; + _logger.Trace("No Album found for download '{0}'", trackedDownload.DownloadItem.Title); } } catch (Exception e) diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadataSettings.cs index 95245306d..f9cb73281 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadataSettings.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadataSettings.cs @@ -20,13 +20,13 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox AlbumImages = true; } - [FieldDefinition(0, Label = "Track Metadata", Type = FieldType.Checkbox, HelpText = "Album\\filename.xml")] + [FieldDefinition(0, Label = "Track Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata, HelpText = "Album\\filename.xml")] public bool TrackMetadata { get; set; } - [FieldDefinition(1, Label = "Artist Images", Type = FieldType.Checkbox, HelpText = "Artist Title.jpg")] + [FieldDefinition(1, Label = "Artist Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Artist Title.jpg")] public bool ArtistImages { get; set; } - [FieldDefinition(2, Label = "Album Images", Type = FieldType.Checkbox, HelpText = "Album Title.jpg")] + [FieldDefinition(2, Label = "Album Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Album Title.jpg")] public bool AlbumImages { get; set; } public bool IsValid => true; diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadataSettings.cs index ae126f252..3f67bc746 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadataSettings.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadataSettings.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv TrackMetadata = true; } - [FieldDefinition(0, Label = "Track Metadata", Type = FieldType.Checkbox)] + [FieldDefinition(0, Label = "Track Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata)] public bool TrackMetadata { get; set; } public bool IsValid => true; diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs index 102803695..375384e19 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs @@ -21,16 +21,16 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc AlbumImages = true; } - [FieldDefinition(0, Label = "Artist Metadata", Type = FieldType.Checkbox, HelpText = "artist.nfo")] + [FieldDefinition(0, Label = "Artist Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata, HelpText = "artist.nfo")] public bool ArtistMetadata { get; set; } - [FieldDefinition(1, Label = "Album Metadata", Type = FieldType.Checkbox, HelpText = "album.nfo")] + [FieldDefinition(1, Label = "Album Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata, HelpText = "album.nfo")] public bool AlbumMetadata { get; set; } - [FieldDefinition(3, Label = "Artist Images", Type = FieldType.Checkbox)] + [FieldDefinition(3, Label = "Artist Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image)] public bool ArtistImages { get; set; } - [FieldDefinition(4, Label = "Album Images", Type = FieldType.Checkbox)] + [FieldDefinition(4, Label = "Album Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image)] public bool AlbumImages { get; set; } public bool IsValid => true; diff --git a/src/NzbDrone.Core/Extras/Metadata/MetadataSectionType.cs b/src/NzbDrone.Core/Extras/Metadata/MetadataSectionType.cs new file mode 100644 index 000000000..7f2251d85 --- /dev/null +++ b/src/NzbDrone.Core/Extras/Metadata/MetadataSectionType.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Extras.Metadata +{ + public static class MetadataSectionType + { + public const string Metadata = "metadata"; + public const string Image = "image"; + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index 1eb50ba2b..ae7f1fdc6 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers { var mapper = _database.GetDataMapper(); - var usedTags = new[] { "Artists", "Notifications", "DelayProfiles", "Restrictions" } + var usedTags = new[] { "Artists", "Notifications", "DelayProfiles", "ReleaseProfiles" } .SelectMany(v => GetUsedTags(v, mapper)) .Distinct() .ToArray(); diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/EnsureValidLanguageProfileId.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/EnsureValidLanguageProfileId.cs new file mode 100644 index 000000000..7878a97cf --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/EnsureValidLanguageProfileId.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Profiles.Languages; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + // For some unknown reason series added through the v2 API can be added without a lanuage profile ID, which breaks things later. + // This ensures there is a language profile ID and it's valid as a safety net. + + public class EnsureValidLanguageProfileId : IHousekeepingTask + { + private readonly IArtistRepository _artistRepository; + private readonly ILanguageProfileService _languageProfileService; + + public EnsureValidLanguageProfileId(IArtistRepository artistRepository, ILanguageProfileService languageProfileService) + { + _artistRepository = artistRepository; + _languageProfileService = languageProfileService; + } + + public void Clean() + { + var languageProfiles = _languageProfileService.All(); + var firstLangaugeProfile = languageProfiles.First(); + var artists = _artistRepository.All().ToList(); + var artistToUpdate = new List(); + + artists.ForEach(s => + { + if (s.LanguageProfileId == 0 || languageProfiles.None(l => l.Id == s.LanguageProfileId)) + { + s.LanguageProfileId = firstLangaugeProfile.Id; + artistToUpdate.Add(s); + } + }); + + _artistRepository.UpdateMany(artistToUpdate); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs index ebe230759..700de0cbb 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs @@ -124,12 +124,16 @@ namespace NzbDrone.Core.ImportLists }, Monitored = importList.ShouldMonitor, RootFolderPath = importList.RootFolderPath, - ProfileId = importList.ProfileId, + QualityProfileId = importList.ProfileId, LanguageProfileId = importList.LanguageProfileId, MetadataProfileId = importList.MetadataProfileId, Tags = importList.Tags, AlbumFolder = true, - AddOptions = new AddArtistOptions { SearchForMissingAlbums = true, Monitored = importList.ShouldMonitor, SelectedOption = 0 } + AddOptions = new AddArtistOptions { + SearchForMissingAlbums = importList.ShouldMonitor, + Monitored = importList.ShouldMonitor, + Monitor = importList.ShouldMonitor ? MonitorTypes.All : MonitorTypes.None + } }); } diff --git a/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs b/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs index dd9052a3f..345c9f1d3 100644 --- a/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs @@ -108,7 +108,7 @@ namespace NzbDrone.Core.IndexerSearch } - var queue = _queueService.GetQueue().Select(q => q.Album.Id); + var queue = _queueService.GetQueue().Where(q => q.Album != null).Select(q => q.Album.Id); var missing = albums.Where(e => !queue.Contains(e.Id)).ToList(); SearchForMissingAlbums(missing, message.Trigger == CommandTrigger.Manual); @@ -134,7 +134,7 @@ namespace NzbDrone.Core.IndexerSearch var albums = _albumCutoffService.AlbumsWhereCutoffUnmet(pagingSpec).Records.ToList(); - var queue = _queueService.GetQueue().Select(q => q.Album.Id); + var queue = _queueService.GetQueue().Where(q => q.Album != null).Select(q => q.Album.Id); var missing = albums.Where(e => !queue.Contains(e.Id)).ToList(); SearchForMissingAlbums(missing, message.Trigger == CommandTrigger.Manual); diff --git a/src/NzbDrone.Core/Instrumentation/ReconfigureLogging.cs b/src/NzbDrone.Core/Instrumentation/ReconfigureLogging.cs index c9ad6fbe8..9c99dfb02 100644 --- a/src/NzbDrone.Core/Instrumentation/ReconfigureLogging.cs +++ b/src/NzbDrone.Core/Instrumentation/ReconfigureLogging.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using NLog; using NLog.Config; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Messaging.Events; @@ -20,11 +21,19 @@ namespace NzbDrone.Core.Instrumentation public void Reconfigure() { var minimumLogLevel = LogLevel.FromString(_configFileProvider.LogLevel); + LogLevel minimumConsoleLogLevel; + + if (_configFileProvider.ConsoleLogLevel.IsNotNullOrWhiteSpace()) + minimumConsoleLogLevel = LogLevel.FromString(_configFileProvider.ConsoleLogLevel); + else if (minimumLogLevel > LogLevel.Info) + minimumConsoleLogLevel = minimumLogLevel; + else + minimumConsoleLogLevel = LogLevel.Info; var rules = LogManager.Configuration.LoggingRules; //Console - SetMinimumLogLevel(rules, "consoleLogger", minimumLogLevel); + SetMinimumLogLevel(rules, "consoleLogger", minimumConsoleLogLevel); //Log Files SetMinimumLogLevel(rules, "appFileInfo", minimumLogLevel <= LogLevel.Info ? LogLevel.Info : LogLevel.Off); diff --git a/src/NzbDrone.Core/MediaFiles/TrackFile.cs b/src/NzbDrone.Core/MediaFiles/TrackFile.cs index 4b308d98a..3d6df7a8e 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackFile.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackFile.cs @@ -4,8 +4,7 @@ using NzbDrone.Core.Music; using NzbDrone.Core.Qualities; using System; using System.Collections.Generic; -using System.Linq; -using System.Text; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Languages; using NzbDrone.Core.Parser.Model; @@ -34,5 +33,25 @@ namespace NzbDrone.Core.MediaFiles { return string.Format("[{0}] {1}", Id, RelativePath); } + + public string GetSceneOrFileName() + { + if (SceneName.IsNotNullOrWhiteSpace()) + { + return SceneName; + } + + if (RelativePath.IsNotNullOrWhiteSpace()) + { + return System.IO.Path.GetFileName(RelativePath); + } + + if (Path.IsNotNullOrWhiteSpace()) + { + return System.IO.Path.GetFileName(Path); + } + + return string.Empty; + } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs index f764dade3..52f6237a1 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs @@ -59,7 +59,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { var qualifiedImports = decisions.Where(c => c.Approved) .GroupBy(c => c.Item.Artist.Id, (i, s) => s - .OrderByDescending(c => c.Item.Quality, new QualityModelComparer(s.First().Item.Artist.Profile)) + .OrderByDescending(c => c.Item.Quality, new QualityModelComparer(s.First().Item.Artist.QualityProfile)) .ThenByDescending(c => c.Item.Language, new LanguageComparer(s.First().Item.Artist.LanguageProfile)) .ThenByDescending(c => c.Item.Size)) .SelectMany(c => c) @@ -185,7 +185,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport if (newDownload) { - //trackFile.SceneName = GetSceneName(downloadClientItem, localTrack); + trackFile.SceneName = GetSceneReleaseName(downloadClientItem, localTrack); var moveResult = _trackFileUpgrader.UpgradeTrackFile(trackFile, localTrack, copyOnly); oldFiles = moveResult.OldFiles; @@ -277,5 +277,23 @@ namespace NzbDrone.Core.MediaFiles.TrackImport return importResults; } + + private string GetSceneReleaseName(DownloadClientItem downloadClientItem, LocalTrack localTrack) + { + if (downloadClientItem != null) + { + var title = Parser.Parser.RemoveFileExtension(downloadClientItem.Title); + + var parsedTitle = Parser.Parser.ParseAlbumTitle(title); + + if (parsedTitle != null) + { + return title; + } + } + + return null; + } + } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs index 070cd276a..0eba4add5 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) { var artist = localAlbumRelease.AlbumRelease.Album.Value.Artist.Value; - var qualityComparer = new QualityModelComparer(artist.Profile); + var qualityComparer = new QualityModelComparer(artist.QualityProfile); // check if we are changing release var currentRelease = localAlbumRelease.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs index 1a95620c1..57535f0d6 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; using NLog; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs index 385871df6..f17fac034 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications public Decision IsSatisfiedBy(LocalTrack localTrack) { - var qualityComparer = new QualityModelComparer(localTrack.Artist.Profile); + var qualityComparer = new QualityModelComparer(localTrack.Artist.QualityProfile); var languageComparer = new LanguageComparer(localTrack.Artist.LanguageProfile); if (localTrack.Tracks.Any(e => e.TrackFileId != 0 && qualityComparer.Compare(e.TrackFile.Value.Quality, localTrack.Quality) > 0)) diff --git a/src/NzbDrone.Core/Music/AddArtistValidator.cs b/src/NzbDrone.Core/Music/AddArtistValidator.cs index 052e5878f..7017b9c0b 100644 --- a/src/NzbDrone.Core/Music/AddArtistValidator.cs +++ b/src/NzbDrone.Core/Music/AddArtistValidator.cs @@ -29,7 +29,7 @@ namespace NzbDrone.Core.Music .SetValidator(artistPathValidator) .SetValidator(artistAncestorValidator); - RuleFor(c => c.ProfileId).SetValidator(profileExistsValidator); + RuleFor(c => c.QualityProfileId).SetValidator(profileExistsValidator); RuleFor(c => c.LanguageProfileId).SetValidator(languageProfileExistsValidator); diff --git a/src/NzbDrone.Core/Music/AlbumMonitoredService.cs b/src/NzbDrone.Core/Music/AlbumMonitoredService.cs index a5e188947..44090dc73 100644 --- a/src/NzbDrone.Core/Music/AlbumMonitoredService.cs +++ b/src/NzbDrone.Core/Music/AlbumMonitoredService.cs @@ -53,37 +53,37 @@ namespace NzbDrone.Core.Music } else { - switch (monitoringOptions.SelectedOption) + switch (monitoringOptions.Monitor) { - case MonitoringOption.All: + case MonitorTypes.All: ToggleAlbumsMonitoredState(albums, true); break; - case MonitoringOption.Future: + case MonitorTypes.Future: _logger.Debug("Unmonitoring Albums with Files"); ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), false); _logger.Debug("Unmonitoring Albums without Files"); ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), false); break; - case MonitoringOption.None: + case MonitorTypes.None: ToggleAlbumsMonitoredState(albums, false); break; - case MonitoringOption.Missing: + case MonitorTypes.Missing: _logger.Debug("Unmonitoring Albums with Files"); ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), false); _logger.Debug("Monitoring Albums without Files"); ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), true); break; - case MonitoringOption.Existing: + case MonitorTypes.Existing: _logger.Debug("Monitoring Albums with Files"); ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), true); _logger.Debug("Unmonitoring Albums without Files"); ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), false); break; - case MonitoringOption.Latest: + case MonitorTypes.Latest: ToggleAlbumsMonitoredState(albums, false); ToggleAlbumsMonitoredState(albums.OrderByDescending(e=>e.ReleaseDate).Take(1),true); break; - case MonitoringOption.First: + case MonitorTypes.First: ToggleAlbumsMonitoredState(albums, false); ToggleAlbumsMonitoredState(albums.OrderBy(e => e.ReleaseDate).Take(1), true); break; diff --git a/src/NzbDrone.Core/Music/AlbumRepository.cs b/src/NzbDrone.Core/Music/AlbumRepository.cs index 71b8ad140..3883b73e7 100644 --- a/src/NzbDrone.Core/Music/AlbumRepository.cs +++ b/src/NzbDrone.Core/Music/AlbumRepository.cs @@ -302,7 +302,7 @@ namespace NzbDrone.Core.Music { foreach (var belowCutoff in profile.QualityIds) { - clauses.Add(string.Format("(Artists.[ProfileId] = {0} AND MIN(TrackFiles.Quality) LIKE '%_quality_: {1},%')", profile.ProfileId, belowCutoff)); + clauses.Add(string.Format("(Artists.[QualityProfileId] = {0} AND MIN(TrackFiles.Quality) LIKE '%_quality_: {1},%')", profile.ProfileId, belowCutoff)); } } diff --git a/src/NzbDrone.Core/Music/Artist.cs b/src/NzbDrone.Core/Music/Artist.cs index c2c0b5197..307667f4d 100644 --- a/src/NzbDrone.Core/Music/Artist.cs +++ b/src/NzbDrone.Core/Music/Artist.cs @@ -29,8 +29,8 @@ namespace NzbDrone.Core.Music public string Path { get; set; } public string RootFolderPath { get; set; } public DateTime Added { get; set; } - public int ProfileId { get; set; } - public LazyLoaded Profile { get; set; } + public int QualityProfileId { get; set; } + public LazyLoaded QualityProfile { get; set; } public int LanguageProfileId { get; set; } public LazyLoaded LanguageProfile { get; set; } public int MetadataProfileId { get; set; } @@ -48,8 +48,8 @@ namespace NzbDrone.Core.Music { Path = otherArtist.Path; - ProfileId = otherArtist.ProfileId; - Profile = otherArtist.Profile; + QualityProfileId = otherArtist.QualityProfileId; + QualityProfile = otherArtist.QualityProfile; LanguageProfileId = otherArtist.LanguageProfileId; MetadataProfileId = otherArtist.MetadataProfileId; diff --git a/src/NzbDrone.Core/Music/MonitoringOptions.cs b/src/NzbDrone.Core/Music/MonitoringOptions.cs index 802a0e809..a51849b35 100644 --- a/src/NzbDrone.Core/Music/MonitoringOptions.cs +++ b/src/NzbDrone.Core/Music/MonitoringOptions.cs @@ -10,19 +10,20 @@ namespace NzbDrone.Core.Music AlbumsToMonitor = new List(); } - public MonitoringOption SelectedOption { get; set; } + public MonitorTypes Monitor { get; set; } public List AlbumsToMonitor { get; set; } public bool Monitored { get; set; } } - public enum MonitoringOption + public enum MonitorTypes { - All = 0, - Future = 1, - Missing = 2, - Existing = 3, - Latest = 4, - First = 5, - None = 6 + All, + Future, + Missing, + Existing, + Latest, + First, + None, + Unknown } } diff --git a/src/NzbDrone.Core/Music/MoveArtistService.cs b/src/NzbDrone.Core/Music/MoveArtistService.cs index 0d538c3c2..846639e76 100644 --- a/src/NzbDrone.Core/Music/MoveArtistService.cs +++ b/src/NzbDrone.Core/Music/MoveArtistService.cs @@ -5,6 +5,7 @@ using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Organizer; +using NzbDrone.Core.RootFolders; using NzbDrone.Core.Music.Commands; using NzbDrone.Core.Music.Events; @@ -54,6 +55,7 @@ namespace NzbDrone.Core.Music try { _diskTransferService.TransferFolder(sourcePath, destinationPath, TransferMode.Move); + _logger.ProgressInfo("{0} moved successfully to {1}", artist.Name, artist.Path); _eventAggregator.PublishEvent(new ArtistMovedEvent(artist, sourcePath, destinationPath)); diff --git a/src/NzbDrone.Core/Music/RefreshArtistService.cs b/src/NzbDrone.Core/Music/RefreshArtistService.cs index c01d79555..a4e0235e9 100644 --- a/src/NzbDrone.Core/Music/RefreshArtistService.cs +++ b/src/NzbDrone.Core/Music/RefreshArtistService.cs @@ -145,7 +145,7 @@ namespace NzbDrone.Core.Music { foreach (var album in albumsToUpdate) { - album.ProfileId = artist.ProfileId; + album.ProfileId = artist.QualityProfileId; album.Monitored = artist.Monitored; } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 02ca86aab..1c9aeef13 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -1,4 +1,4 @@ - + Debug @@ -198,6 +198,8 @@ + + @@ -220,11 +222,11 @@ - + - + @@ -247,16 +249,18 @@ - - + + + + @@ -462,6 +466,7 @@ + @@ -523,6 +528,7 @@ + @@ -1000,7 +1006,7 @@ - + @@ -1008,8 +1014,14 @@ - + + + + + + + @@ -1112,11 +1124,11 @@ - - - + + + - + @@ -1132,11 +1144,6 @@ - - - - - @@ -1290,4 +1297,4 @@ --> - + \ No newline at end of file diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index c78ebee1d..c7abf7874 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Cache; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Qualities; using NzbDrone.Core.Music; @@ -16,7 +17,7 @@ namespace NzbDrone.Core.Organizer { public interface IBuildFileNames { - string BuildTrackFileName(List tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig namingConfig = null); + string BuildTrackFileName(List tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig namingConfig = null, List preferredWords = null); string BuildTrackFilePath(Artist artist, Album album, string fileName, string extension); string BuildAlbumPath(Artist artist, Album album); BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); @@ -28,6 +29,7 @@ namespace NzbDrone.Core.Organizer { private readonly INamingConfigService _namingConfigService; private readonly IQualityDefinitionService _qualityDefinitionService; + private readonly IPreferredWordService _preferredWordService; private readonly ICached _trackFormatCache; private readonly ICached _absoluteTrackFormatCache; private readonly Logger _logger; @@ -71,16 +73,18 @@ namespace NzbDrone.Core.Organizer public FileNameBuilder(INamingConfigService namingConfigService, IQualityDefinitionService qualityDefinitionService, ICacheManager cacheManager, + IPreferredWordService preferredWordService, Logger logger) { _namingConfigService = namingConfigService; _qualityDefinitionService = qualityDefinitionService; + _preferredWordService = preferredWordService; _trackFormatCache = cacheManager.GetCache(GetType(), "trackFormat"); _absoluteTrackFormatCache = cacheManager.GetCache(GetType(), "absoluteTrackFormat"); _logger = logger; } - public string BuildTrackFileName(List tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig namingConfig = null) + public string BuildTrackFileName(List tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig namingConfig = null, List preferredWords = null) { if (namingConfig == null) { @@ -112,6 +116,7 @@ namespace NzbDrone.Core.Organizer AddTrackFileTokens(tokenHandlers, trackFile); AddQualityTokens(tokenHandlers, artist, trackFile); AddMediaInfoTokens(tokenHandlers, trackFile); + AddPreferredWords(tokenHandlers, artist, trackFile, preferredWords); var fileName = ReplaceTokens(pattern, tokenHandlers, namingConfig).Trim(); fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); @@ -318,6 +323,8 @@ namespace NzbDrone.Core.Organizer { if (trackFile.MediaInfo == null) { + _logger.Trace("Media info is unavailable for {0}", trackFile); + return; } @@ -333,6 +340,17 @@ namespace NzbDrone.Core.Organizer tokenHandlers["{MediaInfo AudioSampleRate}"] = m => MediaInfoFormatter.FormatAudioSampleRate(trackFile.MediaInfo); } + private void AddPreferredWords(Dictionary> tokenHandlers, Artist artist, TrackFile trackFile, List preferredWords = null) + { + if (preferredWords == null) + { + preferredWords = _preferredWordService.GetMatchingPreferredWords(artist, trackFile.GetSceneOrFileName(), true); + } + + tokenHandlers["{Preferred Words}"] = m => string.Join(" ", preferredWords); + } + + private string GetLanguagesToken(string mediaInfoLanguages) { List tokens = new List(); diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index d4c4fb7d8..d03b2406a 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -23,6 +23,7 @@ namespace NzbDrone.Core.Organizer private static Track _track1; private static List _singleTrack; private static TrackFile _singleTrackFile; + private static List _preferredWords; public FileNameSampleService(IBuildFileNames buildFileNames) { @@ -89,6 +90,12 @@ namespace NzbDrone.Core.Organizer MediaInfo = mediaInfo }; + _preferredWords = new List + { + "iNTERNAL" + }; + + } public SampleResult GetStandardTrackSample(NamingConfig nameSpec) @@ -119,7 +126,7 @@ namespace NzbDrone.Core.Organizer { try { - return _buildFileNames.BuildTrackFileName(tracks, artist, album, trackFile, nameSpec); + return _buildFileNames.BuildTrackFileName(tracks, artist, album, trackFile, nameSpec, _preferredWords); } catch (NamingFormatException) { diff --git a/src/NzbDrone.Core/Parser/Model/RemoteAlbum.cs b/src/NzbDrone.Core/Parser/Model/RemoteAlbum.cs index 1c15d64c4..ea4b35190 100644 --- a/src/NzbDrone.Core/Parser/Model/RemoteAlbum.cs +++ b/src/NzbDrone.Core/Parser/Model/RemoteAlbum.cs @@ -14,6 +14,12 @@ namespace NzbDrone.Core.Parser.Model public List Albums { get; set; } public bool DownloadAllowed { get; set; } public TorrentSeedConfiguration SeedConfiguration { get; set; } + public int PreferredWordScore { get; set; } + + public RemoteAlbum() + { + Albums = new List(); + } public bool IsRecentAlbum() { diff --git a/src/NzbDrone.Core/Profiles/Languages/LanguageProfile.cs b/src/NzbDrone.Core/Profiles/Languages/LanguageProfile.cs index ff07eed25..b412930d1 100644 --- a/src/NzbDrone.Core/Profiles/Languages/LanguageProfile.cs +++ b/src/NzbDrone.Core/Profiles/Languages/LanguageProfile.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Datastore; using NzbDrone.Core.Languages; @@ -8,7 +8,8 @@ namespace NzbDrone.Core.Profiles.Languages public class LanguageProfile : ModelBase { public string Name { get; set; } - public List Languages { get; set; } + public List Languages { get; set; } + public bool UpgradeAllowed { get; set; } public Language Cutoff { get; set; } public Language LastAllowedLanguage() diff --git a/src/NzbDrone.Core/Profiles/Languages/ProfileLanguageItem.cs b/src/NzbDrone.Core/Profiles/Languages/LanguageProfileItem.cs similarity index 78% rename from src/NzbDrone.Core/Profiles/Languages/ProfileLanguageItem.cs rename to src/NzbDrone.Core/Profiles/Languages/LanguageProfileItem.cs index a25ea2257..6826c002e 100644 --- a/src/NzbDrone.Core/Profiles/Languages/ProfileLanguageItem.cs +++ b/src/NzbDrone.Core/Profiles/Languages/LanguageProfileItem.cs @@ -3,7 +3,7 @@ using NzbDrone.Core.Languages; namespace NzbDrone.Core.Profiles.Languages { - public class ProfileLanguageItem : IEmbeddedDocument + public class LanguageProfileItem : IEmbeddedDocument { public Language Language { get; set; } public bool Allowed { get; set; } diff --git a/src/NzbDrone.Core/Profiles/Languages/LanguageProfileService.cs b/src/NzbDrone.Core/Profiles/Languages/LanguageProfileService.cs index 4f259e54d..0967e7098 100644 --- a/src/NzbDrone.Core/Profiles/Languages/LanguageProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Languages/LanguageProfileService.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Profiles.Languages List All(); LanguageProfile Get(int id); bool Exists(int id); + LanguageProfile GetDefaultProfile(string name, Language cutoff = null, params Language[] allowed); } public class LanguageProfileService : ILanguageProfileService, IHandle @@ -70,11 +71,30 @@ namespace NzbDrone.Core.Profiles.Languages return _profileRepository.Exists(id); } + public LanguageProfile GetDefaultProfile(string name, Language cutoff = null, params Language[] allowed) + { + var orderedLanguages = Language.All + .Where(l => l != Language.Unknown) + .OrderByDescending(l => l.Name) + .ToList(); + + orderedLanguages.Insert(0, Language.Unknown); + + var languages = orderedLanguages.Select(v => new LanguageProfileItem { Language = v, Allowed = false }) + .ToList(); + + return new LanguageProfile + { + Cutoff = Language.Unknown, + Languages = languages + }; + } + private LanguageProfile AddDefaultProfile(string name, Language cutoff, params Language[] allowed) { var languages = Language.All .OrderByDescending(l => l.Name) - .Select(v => new ProfileLanguageItem { Language = v, Allowed = allowed.Contains(v) }) + .Select(v => new LanguageProfileItem { Language = v, Allowed = allowed.Contains(v) }) .ToList(); var profile = new LanguageProfile diff --git a/src/NzbDrone.Core/Profiles/Qualities/ProfileRepository.cs b/src/NzbDrone.Core/Profiles/Qualities/ProfileRepository.cs deleted file mode 100644 index 1724d7a4f..000000000 --- a/src/NzbDrone.Core/Profiles/Qualities/ProfileRepository.cs +++ /dev/null @@ -1,23 +0,0 @@ -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Messaging.Events; - -namespace NzbDrone.Core.Profiles.Qualities -{ - public interface IProfileRepository : IBasicRepository - { - bool Exists(int id); - } - - public class ProfileRepository : BasicRepository, IProfileRepository - { - public ProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) - : base(database, eventAggregator) - { - } - - public bool Exists(int id) - { - return DataMapper.Query().Where(p => p.Id == id).GetRowCount() == 1; - } - } -} diff --git a/src/NzbDrone.Core/Profiles/Qualities/Profile.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs similarity index 76% rename from src/NzbDrone.Core/Profiles/Qualities/Profile.cs rename to src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs index 1371a2852..083a5ca9c 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/Profile.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs @@ -5,11 +5,12 @@ using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Profiles.Qualities { - public class Profile : ModelBase + public class QualityProfile : ModelBase { public string Name { get; set; } + public bool UpgradeAllowed { get; set; } public int Cutoff { get; set; } - public List Items { get; set; } + public List Items { get; set; } public Quality LastAllowedQuality() { @@ -25,12 +26,12 @@ namespace NzbDrone.Core.Profiles.Qualities return lastAllowed.Items.Last().Quality; } - public QualityIndex GetIndex(Quality quality) + public QualityIndex GetIndex(Quality quality, bool respectGroupOrder = false) { - return GetIndex(quality.Id); + return GetIndex(quality.Id, respectGroupOrder); } - public QualityIndex GetIndex(int id) + public QualityIndex GetIndex(int id, bool respectGroupOrder = false) { for (var i = 0; i < Items.Count; i++) { @@ -55,7 +56,7 @@ namespace NzbDrone.Core.Profiles.Qualities if (groupItem.Quality.Id == id) { - return new QualityIndex(i, g); + return respectGroupOrder ? new QualityIndex(i, g) : new QualityIndex(i); } } } diff --git a/src/NzbDrone.Core/Profiles/Qualities/ProfileInUseException.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileInUseException.cs similarity index 62% rename from src/NzbDrone.Core/Profiles/Qualities/ProfileInUseException.cs rename to src/NzbDrone.Core/Profiles/Qualities/QualityProfileInUseException.cs index 77245cf1c..cf64e2dbe 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/ProfileInUseException.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileInUseException.cs @@ -3,9 +3,9 @@ using NzbDrone.Core.Exceptions; namespace NzbDrone.Core.Profiles.Qualities { - public class ProfileInUseException : NzbDroneClientException + public class QualityProfileInUseException : NzbDroneClientException { - public ProfileInUseException(string name) + public QualityProfileInUseException(string name) : base(HttpStatusCode.BadRequest, "Profile [{0}] is in use.", name) { diff --git a/src/NzbDrone.Core/Profiles/Qualities/ProfileQualityItem.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileQualityItem.cs similarity index 81% rename from src/NzbDrone.Core/Profiles/Qualities/ProfileQualityItem.cs rename to src/NzbDrone.Core/Profiles/Qualities/QualityProfileQualityItem.cs index 5a7ad1d07..4b5369749 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/ProfileQualityItem.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileQualityItem.cs @@ -7,19 +7,19 @@ using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Profiles.Qualities { - public class ProfileQualityItem : IEmbeddedDocument + public class QualityProfileQualityItem : IEmbeddedDocument { [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public int Id { get; set; } public string Name { get; set; } public Quality Quality { get; set; } - public List Items { get; set; } + public List Items { get; set; } public bool Allowed { get; set; } - public ProfileQualityItem() + public QualityProfileQualityItem() { - Items = new List(); + Items = new List(); } public List GetQualities() diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileRepository.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileRepository.cs new file mode 100644 index 000000000..9f8bf9a0a --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileRepository.cs @@ -0,0 +1,23 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles.Qualities +{ + public interface IProfileRepository : IBasicRepository + { + bool Exists(int id); + } + + public class QualityProfileRepository : BasicRepository, IProfileRepository + { + public QualityProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public bool Exists(int id) + { + return DataMapper.Query().Where(p => p.Id == id).GetRowCount() == 1; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Qualities/ProfileService.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs similarity index 74% rename from src/NzbDrone.Core/Profiles/Qualities/ProfileService.cs rename to src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs index a4239941a..74012bed2 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/ProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs @@ -13,24 +13,24 @@ namespace NzbDrone.Core.Profiles.Qualities { public interface IProfileService { - Profile Add(Profile profile); - void Update(Profile profile); + QualityProfile Add(QualityProfile profile); + void Update(QualityProfile profile); void Delete(int id); - List All(); - Profile Get(int id); + List All(); + QualityProfile Get(int id); bool Exists(int id); - Profile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed); + QualityProfile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed); } - public class ProfileService : IProfileService, IHandle + public class QualityProfileService : IProfileService, IHandle { private readonly IProfileRepository _profileRepository; private readonly IArtistService _artistService; private readonly IImportListFactory _importListFactory; private readonly Logger _logger; - public ProfileService(IProfileRepository profileRepository, IArtistService artistService, IImportListFactory importListFactory, Logger logger) + public QualityProfileService(IProfileRepository profileRepository, IArtistService artistService, IImportListFactory importListFactory, Logger logger) { _profileRepository = profileRepository; _artistService = artistService; @@ -38,33 +38,33 @@ namespace NzbDrone.Core.Profiles.Qualities _logger = logger; } - public Profile Add(Profile profile) + public QualityProfile Add(QualityProfile profile) { return _profileRepository.Insert(profile); } - public void Update(Profile profile) + public void Update(QualityProfile profile) { _profileRepository.Update(profile); } public void Delete(int id) { - if (_artistService.GetAllArtists().Any(c => c.ProfileId == id) || _importListFactory.All().Any(c => c.ProfileId == id)) + if (_artistService.GetAllArtists().Any(c => c.QualityProfileId == id) || _importListFactory.All().Any(c => c.ProfileId == id)) { var profile = _profileRepository.Get(id); - throw new ProfileInUseException(profile.Name); + throw new QualityProfileInUseException(profile.Name); } _profileRepository.Delete(id); } - public List All() + public List All() { return _profileRepository.All().ToList(); } - public Profile Get(int id) + public QualityProfile Get(int id) { return _profileRepository.Get(id); } @@ -127,10 +127,10 @@ namespace NzbDrone.Core.Profiles.Qualities Quality.MP3_320); } - public Profile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed) + public QualityProfile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed) { var groupedQualites = Quality.DefaultQualityDefinitions.GroupBy(q => q.GroupWeight); - var items = new List(); + var items = new List(); var groupId = 1000; var profileCutoff = cutoff == null ? Quality.Unknown.Id : cutoff.Id; @@ -139,17 +139,17 @@ namespace NzbDrone.Core.Profiles.Qualities if (group.Count() == 1) { var quality = group.First().Quality; - items.Add(new ProfileQualityItem { Quality = quality, Allowed = allowed.Contains(quality) }); + items.Add(new QualityProfileQualityItem { Quality = quality, Allowed = allowed.Contains(quality) }); continue; } var groupAllowed = group.Any(g => allowed.Contains(g.Quality)); - items.Add(new ProfileQualityItem + items.Add(new QualityProfileQualityItem { Id = groupId, Name = group.First().GroupName, - Items = group.Select(g => new ProfileQualityItem + Items = group.Select(g => new QualityProfileQualityItem { Quality = g.Quality, Allowed = groupAllowed @@ -165,7 +165,7 @@ namespace NzbDrone.Core.Profiles.Qualities groupId++; } - var qualityProfile = new Profile + var qualityProfile = new QualityProfile { Name = name, Cutoff = profileCutoff, @@ -175,7 +175,7 @@ namespace NzbDrone.Core.Profiles.Qualities return qualityProfile; } - private Profile AddDefaultProfile(string name, Quality cutoff, params Quality[] allowed) + private QualityProfile AddDefaultProfile(string name, Quality cutoff, params Quality[] allowed) { var profile = GetDefaultProfile(name, cutoff, allowed); diff --git a/src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs b/src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs similarity index 93% rename from src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs rename to src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs index f447f1102..b75e80db9 100644 --- a/src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs +++ b/src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs @@ -1,11 +1,7 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.RegularExpressions; -using NzbDrone.Common.Exceptions; -namespace NzbDrone.Core.Restrictions +namespace NzbDrone.Core.Profiles.Releases { public static class PerlRegexFactory { diff --git a/src/NzbDrone.Core/Profiles/Releases/PreferredWordService.cs b/src/NzbDrone.Core/Profiles/Releases/PreferredWordService.cs new file mode 100644 index 000000000..c88f3f646 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/PreferredWordService.cs @@ -0,0 +1,76 @@ +using NLog; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface IPreferredWordService + { + int Calculate(Artist artist, string title); + List GetMatchingPreferredWords(Artist artist, string title, bool isRenaming); + } + + public class PreferredWordService : IPreferredWordService + { + private readonly IReleaseProfileService _releaseProfileService; + private readonly ITermMatcher _termMatcher; + private readonly Logger _logger; + + public PreferredWordService(IReleaseProfileService releaseProfileService, ITermMatcher termMatcher, Logger logger) + { + _releaseProfileService = releaseProfileService; + _termMatcher = termMatcher; + _logger = logger; + } + + public int Calculate(Artist series, string title) + { + _logger.Trace("Calculating preferred word score for '{0}'", title); + + var matchingPairs = GetMatchingPairs(series, title, false); + var score = matchingPairs.Sum(p => p.Value); + + _logger.Trace("Calculated preferred word score for '{0}': {1}", title, score); + + return score; + } + + public List GetMatchingPreferredWords(Artist artist, string title, bool isRenaming) + { + var matchingPairs = GetMatchingPairs(artist, title, isRenaming); + + return matchingPairs.OrderByDescending(p => p.Value) + .Select(p => p.Key) + .ToList(); + } + + private List> GetMatchingPairs(Artist artist, string title, bool isRenaming) + { + var releaseProfiles = _releaseProfileService.AllForTags(artist.Tags); + var result = new List>(); + + _logger.Trace("Calculating preferred word score for '{0}'", title); + + foreach (var releaseProfile in releaseProfiles) + { + if (isRenaming && !releaseProfile.IncludePreferredWhenRenaming) + { + continue; + } + + foreach (var preferredPair in releaseProfile.Preferred) + { + var term = preferredPair.Key; + + if (_termMatcher.IsMatch(term, title)) + { + result.Add(preferredPair); + } + } + } + + return result; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs new file mode 100644 index 000000000..962a946c9 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Profiles.Releases +{ + public class ReleaseProfile : ModelBase + { + public string Required { get; set; } + public string Ignored { get; set; } + public List> Preferred { get; set; } + public bool IncludePreferredWhenRenaming { get; set; } + public HashSet Tags { get; set; } + + public ReleaseProfile() + { + Preferred = new List>(); + IncludePreferredWhenRenaming = true; + Tags = new HashSet(); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs new file mode 100644 index 000000000..b8bcf3b49 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface IRestrictionRepository : IBasicRepository + { + } + + public class ReleaseProfileRepository : BasicRepository, IRestrictionRepository + { + public ReleaseProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs new file mode 100644 index 000000000..7ae1b79eb --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface IReleaseProfileService + { + List All(); + List AllForTag(int tagId); + List AllForTags(HashSet tagIds); + ReleaseProfile Get(int id); + void Delete(int id); + ReleaseProfile Add(ReleaseProfile restriction); + ReleaseProfile Update(ReleaseProfile restriction); + } + + public class ReleaseProfileService : IReleaseProfileService + { + private readonly IRestrictionRepository _repo; + private readonly Logger _logger; + + public ReleaseProfileService(IRestrictionRepository repo, Logger logger) + { + _repo = repo; + _logger = logger; + } + + public List All() + { + return _repo.All().ToList(); + } + + public List AllForTag(int tagId) + { + return _repo.All().Where(r => r.Tags.Contains(tagId)).ToList(); + } + + public List AllForTags(HashSet tagIds) + { + return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); + } + + public ReleaseProfile Get(int id) + { + return _repo.Get(id); + } + + public void Delete(int id) + { + _repo.Delete(id); + } + + public ReleaseProfile Add(ReleaseProfile restriction) + { + return _repo.Insert(restriction); + } + + public ReleaseProfile Update(ReleaseProfile restriction) + { + return _repo.Update(restriction); + } + } +} diff --git a/src/NzbDrone.Core/Restrictions/TermMatcher.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatcher.cs similarity index 92% rename from src/NzbDrone.Core/Restrictions/TermMatcher.cs rename to src/NzbDrone.Core/Profiles/Releases/TermMatcher.cs index e6bd84d89..f8c8fd82d 100644 --- a/src/NzbDrone.Core/Restrictions/TermMatcher.cs +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatcher.cs @@ -1,11 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System; using System.Text.RegularExpressions; using NzbDrone.Common.Cache; -namespace NzbDrone.Core.Restrictions +namespace NzbDrone.Core.Profiles.Releases { public interface ITermMatcher { diff --git a/src/NzbDrone.Core/Qualities/QualityModelComparer.cs b/src/NzbDrone.Core/Qualities/QualityModelComparer.cs index 259335a8f..46e408e93 100644 --- a/src/NzbDrone.Core/Qualities/QualityModelComparer.cs +++ b/src/NzbDrone.Core/Qualities/QualityModelComparer.cs @@ -6,9 +6,9 @@ namespace NzbDrone.Core.Qualities { public class QualityModelComparer : IComparer, IComparer { - private readonly Profile _profile; + private readonly QualityProfile _profile; - public QualityModelComparer(Profile profile) + public QualityModelComparer(QualityProfile profile) { Ensure.That(profile, () => profile).IsNotNull(); Ensure.That(profile.Items, () => profile.Items).HasItems(); @@ -31,8 +31,8 @@ namespace NzbDrone.Core.Qualities public int Compare(Quality left, Quality right, bool respectGroupOrder) { - var leftIndex = _profile.GetIndex(left); - var rightIndex = _profile.GetIndex(right); + var leftIndex = _profile.GetIndex(left, respectGroupOrder); + var rightIndex = _profile.GetIndex(right, respectGroupOrder); return leftIndex.CompareTo(rightIndex, respectGroupOrder); } diff --git a/src/NzbDrone.Core/Queue/Queue.cs b/src/NzbDrone.Core/Queue/Queue.cs index 1ba20977a..b41289493 100644 --- a/src/NzbDrone.Core/Queue/Queue.cs +++ b/src/NzbDrone.Core/Queue/Queue.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using NzbDrone.Core.Datastore; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Languages; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.Music; @@ -13,6 +14,7 @@ namespace NzbDrone.Core.Queue { public Artist Artist { get; set; } public Album Album { get; set; } + public Language Language { get; set; } public QualityModel Quality { get; set; } public decimal Size { get; set; } public string Title { get; set; } diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index a1d41788f..208a0d3ea 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Crypto; using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Languages; using NzbDrone.Core.History; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Qualities; using NzbDrone.Core.Music; namespace NzbDrone.Core.Queue @@ -44,30 +46,22 @@ namespace NzbDrone.Core.Queue _queue.Remove(Find(id)); } - public void Handle(TrackedDownloadRefreshedEvent message) - { - _queue = message.TrackedDownloads.OrderBy(c => c.DownloadItem.RemainingTime).SelectMany(MapQueue) - .ToList(); - - _eventAggregator.PublishEvent(new QueueUpdatedEvent()); - } - private IEnumerable MapQueue(TrackedDownload trackedDownload) { - if (trackedDownload.RemoteAlbum.Albums != null && trackedDownload.RemoteAlbum.Albums.Any()) + if (trackedDownload.RemoteAlbum?.Albums != null && trackedDownload.RemoteAlbum.Albums.Any()) { foreach (var album in trackedDownload.RemoteAlbum.Albums) { - yield return MapAlbum(trackedDownload, album); + yield return MapQueueItem(trackedDownload, album); } } else { - // FIXME: Present queue items with unknown series/episodes + yield return MapQueueItem(trackedDownload, null); } } - private Queue MapAlbum(TrackedDownload trackedDownload, Album album) + private Queue MapQueueItem(TrackedDownload trackedDownload, Album album) { bool downloadForced = false; var history = _historyService.Find(trackedDownload.DownloadItem.DownloadId, HistoryEventType.Grabbed).FirstOrDefault(); @@ -78,11 +72,11 @@ namespace NzbDrone.Core.Queue var queue = new Queue { - Id = HashConverter.GetHashInt31(string.Format("trackedDownload-{0}-album{1}", trackedDownload.DownloadItem.DownloadId, album.Id)), Artist = trackedDownload.RemoteAlbum.Artist, Album = album, - Quality = trackedDownload.RemoteAlbum.ParsedAlbumInfo.Quality, - Title = trackedDownload.DownloadItem.Title, + Language = trackedDownload.RemoteAlbum?.ParsedAlbumInfo.Language ?? Language.Unknown, + Quality = trackedDownload.RemoteAlbum?.ParsedAlbumInfo.Quality ?? new QualityModel(Quality.Unknown), + Title = Parser.Parser.RemoveFileExtension(trackedDownload.DownloadItem.Title), Size = trackedDownload.DownloadItem.TotalSize, Sizeleft = trackedDownload.DownloadItem.RemainingSize, Timeleft = trackedDownload.DownloadItem.RemainingTime, @@ -98,6 +92,16 @@ namespace NzbDrone.Core.Queue DownloadForced = downloadForced }; + if (album != null) + { + queue.Id = HashConverter.GetHashInt31(string.Format("trackedDownload-{0}-album{1}", trackedDownload.DownloadItem.DownloadId, album.Id)); + } + else + { + queue.Id = HashConverter.GetHashInt31(string.Format("trackedDownload-{0}", trackedDownload.DownloadItem.DownloadId)); + } + + if (queue.Timeleft.HasValue) { queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.Timeleft.Value); @@ -105,5 +109,14 @@ namespace NzbDrone.Core.Queue return queue; } + + public void Handle(TrackedDownloadRefreshedEvent message) + { + _queue = message.TrackedDownloads.OrderBy(c => c.DownloadItem.RemainingTime).SelectMany(MapQueue) + .ToList(); + + _eventAggregator.PublishEvent(new QueueUpdatedEvent()); + } + } } diff --git a/src/NzbDrone.Core/Restrictions/Restriction.cs b/src/NzbDrone.Core/Restrictions/Restriction.cs deleted file mode 100644 index 9be667d81..000000000 --- a/src/NzbDrone.Core/Restrictions/Restriction.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Restrictions -{ - public class Restriction : ModelBase - { - public string Required { get; set; } - public string Preferred { get; set; } - public string Ignored { get; set; } - public HashSet Tags { get; set; } - - public Restriction() - { - Tags = new HashSet(); - } - } -} diff --git a/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs b/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs deleted file mode 100644 index a88b0e67f..000000000 --- a/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs +++ /dev/null @@ -1,17 +0,0 @@ -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Messaging.Events; - -namespace NzbDrone.Core.Restrictions -{ - public interface IRestrictionRepository : IBasicRepository - { - } - - public class RestrictionRepository : BasicRepository, IRestrictionRepository - { - public RestrictionRepository(IMainDatabase database, IEventAggregator eventAggregator) - : base(database, eventAggregator) - { - } - } -} diff --git a/src/NzbDrone.Core/Restrictions/RestrictionService.cs b/src/NzbDrone.Core/Restrictions/RestrictionService.cs deleted file mode 100644 index 90b9d4a6f..000000000 --- a/src/NzbDrone.Core/Restrictions/RestrictionService.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Core.Restrictions -{ - public interface IRestrictionService - { - List All(); - List AllForTag(int tagId); - List AllForTags(HashSet tagIds); - Restriction Get(int id); - void Delete(int id); - Restriction Add(Restriction restriction); - Restriction Update(Restriction restriction); - } - - public class RestrictionService : IRestrictionService - { - private readonly IRestrictionRepository _repo; - private readonly Logger _logger; - - public RestrictionService(IRestrictionRepository repo, Logger logger) - { - _repo = repo; - _logger = logger; - } - - public List All() - { - return _repo.All().ToList(); - } - - public List AllForTag(int tagId) - { - return _repo.All().Where(r => r.Tags.Contains(tagId)).ToList(); - } - - public List AllForTags(HashSet tagIds) - { - return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); - } - - public Restriction Get(int id) - { - return _repo.Get(id); - } - - public void Delete(int id) - { - _repo.Delete(id); - } - - public Restriction Add(Restriction restriction) - { - return _repo.Insert(restriction); - } - - public Restriction Update(Restriction restriction) - { - return _repo.Update(restriction); - } - } -} diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index ff18502a2..ce5335cf3 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -4,7 +4,7 @@ using NzbDrone.Core.ImportLists; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Notifications; using NzbDrone.Core.Profiles.Delay; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Music; namespace NzbDrone.Core.Tags @@ -28,7 +28,7 @@ namespace NzbDrone.Core.Tags private readonly IDelayProfileService _delayProfileService; private readonly IImportListFactory _importListFactory; private readonly INotificationFactory _notificationFactory; - private readonly IRestrictionService _restrictionService; + private readonly IReleaseProfileService _releaseProfileService; private readonly IArtistService _artistService; public TagService(ITagRepository repo, @@ -36,7 +36,7 @@ namespace NzbDrone.Core.Tags IDelayProfileService delayProfileService, ImportListFactory importListFactory, INotificationFactory notificationFactory, - IRestrictionService restrictionService, + IReleaseProfileService releaseProfileService, IArtistService artistService) { _repo = repo; @@ -44,7 +44,7 @@ namespace NzbDrone.Core.Tags _delayProfileService = delayProfileService; _importListFactory = importListFactory; _notificationFactory = notificationFactory; - _restrictionService = restrictionService; + _releaseProfileService = releaseProfileService; _artistService = artistService; } @@ -71,7 +71,7 @@ namespace NzbDrone.Core.Tags var delayProfiles = _delayProfileService.AllForTag(tagId); var importLists = _importListFactory.AllForTag(tagId); var notifications = _notificationFactory.AllForTag(tagId); - var restrictions = _restrictionService.AllForTag(tagId); + var restrictions = _releaseProfileService.AllForTag(tagId); var artist = _artistService.AllForTag(tagId); return new TagDetails @@ -92,7 +92,7 @@ namespace NzbDrone.Core.Tags var delayProfiles = _delayProfileService.All(); var importLists = _importListFactory.All(); var notifications = _notificationFactory.All(); - var restrictions = _restrictionService.All(); + var restrictions = _releaseProfileService.All(); var artists = _artistService.GetAllArtists(); var details = new List(); diff --git a/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs b/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs index 0eb5293dd..b5d9bdb9f 100644 --- a/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs +++ b/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs @@ -1,4 +1,4 @@ -using FluentValidation.Validators; +using FluentValidation.Validators; using NzbDrone.Core.Profiles.Qualities; namespace NzbDrone.Core.Validation @@ -8,7 +8,7 @@ namespace NzbDrone.Core.Validation private readonly IProfileService _profileService; public ProfileExistsValidator(IProfileService profileService) - : base("Profile does not exist") + : base("Quality Profile does not exist") { _profileService = profileService; } @@ -20,4 +20,4 @@ namespace NzbDrone.Core.Validation return _profileService.Exists((int)context.PropertyValue); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/Client/ClientBase.cs b/src/NzbDrone.Integration.Test/Client/ClientBase.cs index 9fd52f0c3..de26b5a81 100644 --- a/src/NzbDrone.Integration.Test/Client/ClientBase.cs +++ b/src/NzbDrone.Integration.Test/Client/ClientBase.cs @@ -63,7 +63,7 @@ namespace NzbDrone.Integration.Test.Client private static void AssertDisableCache(IList headers) { - headers.Single(c => c.Name == "Cache-Control").Value.Should().Be("no-cache, no-store, must-revalidate"); + headers.Single(c => c.Name == "Cache-Control").Value.Should().Be("no-cache, no-store, must-revalidate, max-age=0"); headers.Single(c => c.Name == "Pragma").Value.Should().Be("no-cache"); headers.Single(c => c.Name == "Expires").Value.Should().Be("0"); } diff --git a/yarn.lock b/yarn.lock index f05866c21..df55df660 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1527,6 +1527,11 @@ bail@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.2.tgz#f7d6c1731630a9f9f0d4d35ed1f962e2074a1764" +balanced-match@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.1.0.tgz#b504bd05869b39259dd0c5efc35d843176dccc4a" + integrity sha1-tQS9BYabOSWd0MXvw12EMXbczEo= + balanced-match@^0.4.2: version "0.4.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" @@ -2361,6 +2366,16 @@ crypto-browserify@^3.11.0: public-encrypt "^4.0.0" randombytes "^2.0.0" +css-color-function@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/css-color-function/-/css-color-function-1.3.3.tgz#8ed24c2c0205073339fafa004bc8c141fccb282e" + integrity sha1-jtJMLAIFBzM5+voAS8jBQfzLKC4= + dependencies: + balanced-match "0.1.0" + color "^0.11.0" + debug "^3.1.0" + rgb "~0.1.0" + css-color-names@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" @@ -6104,6 +6119,16 @@ postcss-calc@^5.2.0: postcss-message-helpers "^2.0.0" reduce-css-calc "^1.2.6" +postcss-color-function@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-color-function/-/postcss-color-function-4.0.1.tgz#402b3f2cebc3f6947e618fb6be3654fbecef6444" + integrity sha1-QCs/LOvD9pR+YY+2vjZU++zvZEQ= + dependencies: + css-color-function "~1.3.3" + postcss "^6.0.1" + postcss-message-helpers "^2.0.0" + postcss-value-parser "^3.3.0" + postcss-colormin@^2.1.8: version "2.2.2" resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-2.2.2.tgz#6631417d5f0e909a3d7ec26b24c8a8d1e4f96e4b" @@ -7387,6 +7412,11 @@ ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" +rgb@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/rgb/-/rgb-0.1.0.tgz#be27b291e8feffeac1bd99729721bfa40fc037b5" + integrity sha1-vieykej+/+rBvZlylyG/pA/AN7U= + right-align@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"