mirror of https://github.com/lidarr/Lidarr
Compare commits
10 Commits
5abf669850
...
1550b716eb
Author | SHA1 | Date |
---|---|---|
tty418 | 1550b716eb | |
Bogdan | 416d505316 | |
Bogdan | 4816f35256 | |
Josh McKinney | e42e0a72eb | |
Bogdan | db9e62f79d | |
Bogdan | bc69fa4842 | |
Bogdan | 86dad72c49 | |
Bogdan | 4a8d6c367d | |
Bogdan | c1926f8758 | |
TTY Teapot | f344e60299 |
|
@ -0,0 +1,13 @@
|
||||||
|
// This file is used to open the backend and frontend in the same workspace, which is necessary as
|
||||||
|
// the frontend has vscode settings that are distinct from the backend
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": ".."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../frontend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
|
@ -9,13 +9,13 @@ variables:
|
||||||
testsFolder: './_tests'
|
testsFolder: './_tests'
|
||||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||||
majorVersion: '2.3.0'
|
majorVersion: '2.3.1'
|
||||||
minorVersion: $[counter('minorVersion', 1076)]
|
minorVersion: $[counter('minorVersion', 1076)]
|
||||||
lidarrVersion: '$(majorVersion).$(minorVersion)'
|
lidarrVersion: '$(majorVersion).$(minorVersion)'
|
||||||
buildName: '$(Build.SourceBranchName).$(lidarrVersion)'
|
buildName: '$(Build.SourceBranchName).$(lidarrVersion)'
|
||||||
sentryOrg: 'servarr'
|
sentryOrg: 'servarr'
|
||||||
sentryUrl: 'https://sentry.servarr.com'
|
sentryUrl: 'https://sentry.servarr.com'
|
||||||
dotnetVersion: '6.0.417'
|
dotnetVersion: '6.0.421'
|
||||||
nodeVersion: '20.X'
|
nodeVersion: '20.X'
|
||||||
innoVersion: '6.2.0'
|
innoVersion: '6.2.0'
|
||||||
windowsImage: 'windows-2022'
|
windowsImage: 'windows-2022'
|
||||||
|
|
|
@ -28,7 +28,8 @@ module.exports = {
|
||||||
globals: {
|
globals: {
|
||||||
expect: false,
|
expect: false,
|
||||||
chai: false,
|
chai: false,
|
||||||
sinon: false
|
sinon: false,
|
||||||
|
JSX: true
|
||||||
},
|
},
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import CommandAppState from './CommandAppState';
|
||||||
import HistoryAppState from './HistoryAppState';
|
import HistoryAppState from './HistoryAppState';
|
||||||
import QueueAppState from './QueueAppState';
|
import QueueAppState from './QueueAppState';
|
||||||
import SettingsAppState from './SettingsAppState';
|
import SettingsAppState from './SettingsAppState';
|
||||||
|
import SystemAppState from './SystemAppState';
|
||||||
import TagsAppState from './TagsAppState';
|
import TagsAppState from './TagsAppState';
|
||||||
import TrackFilesAppState from './TrackFilesAppState';
|
import TrackFilesAppState from './TrackFilesAppState';
|
||||||
import TracksAppState from './TracksAppState';
|
import TracksAppState from './TracksAppState';
|
||||||
|
@ -62,6 +63,7 @@ interface AppState {
|
||||||
tags: TagsAppState;
|
tags: TagsAppState;
|
||||||
trackFiles: TrackFilesAppState;
|
trackFiles: TrackFilesAppState;
|
||||||
tracksSelection: TracksAppState;
|
tracksSelection: TracksAppState;
|
||||||
|
system: SystemAppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppState;
|
export default AppState;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import AppSectionState, {
|
import AppSectionState, {
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
|
AppSectionItemState,
|
||||||
AppSectionSaveState,
|
AppSectionSaveState,
|
||||||
AppSectionSchemaState,
|
AppSectionSchemaState,
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
|
@ -46,7 +47,7 @@ export interface RootFolderAppState
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
|
|
||||||
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
|
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
|
||||||
export type UiSettingsAppState = AppSectionState<UiSettings>;
|
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||||
|
|
||||||
interface SettingsAppState {
|
interface SettingsAppState {
|
||||||
downloadClients: DownloadClientAppState;
|
downloadClients: DownloadClientAppState;
|
||||||
|
@ -57,7 +58,7 @@ interface SettingsAppState {
|
||||||
notifications: NotificationAppState;
|
notifications: NotificationAppState;
|
||||||
qualityProfiles: QualityProfilesAppState;
|
qualityProfiles: QualityProfilesAppState;
|
||||||
rootFolders: RootFolderAppState;
|
rootFolders: RootFolderAppState;
|
||||||
uiSettings: UiSettingsAppState;
|
ui: UiSettingsAppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsAppState;
|
export default SettingsAppState;
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
import SystemStatus from 'typings/SystemStatus';
|
||||||
|
import { AppSectionItemState } from './AppSectionState';
|
||||||
|
|
||||||
|
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
|
||||||
|
|
||||||
|
interface SystemAppState {
|
||||||
|
status: SystemStatusAppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SystemAppState;
|
|
@ -1,12 +1,32 @@
|
||||||
import ModelBase from 'App/ModelBase';
|
import ModelBase from 'App/ModelBase';
|
||||||
import AppSectionState, {
|
import AppSectionState, {
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
|
AppSectionSaveState,
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
|
|
||||||
export interface Tag extends ModelBase {
|
export interface Tag extends ModelBase {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {}
|
export interface TagDetail extends ModelBase {
|
||||||
|
label: string;
|
||||||
|
autoTagIds: number[];
|
||||||
|
delayProfileIds: number[];
|
||||||
|
downloadClientIds: [];
|
||||||
|
importListIds: number[];
|
||||||
|
indexerIds: number[];
|
||||||
|
notificationIds: number[];
|
||||||
|
restrictionIds: number[];
|
||||||
|
artistIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagDetailAppState
|
||||||
|
extends AppSectionState<TagDetail>,
|
||||||
|
AppSectionDeleteState,
|
||||||
|
AppSectionSaveState {}
|
||||||
|
|
||||||
|
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {
|
||||||
|
details: TagDetailAppState;
|
||||||
|
}
|
||||||
|
|
||||||
export default TagsAppState;
|
export default TagsAppState;
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
function createAllArtistSelector() {
|
function createAllArtistSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state) => state.artist,
|
(state: AppState) => state.artist,
|
||||||
(artist) => {
|
(artist) => {
|
||||||
return artist.items;
|
return artist.items;
|
||||||
}
|
}
|
|
@ -1,18 +1,19 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
import createAllArtistSelector from './createAllArtistSelector';
|
import createAllArtistSelector from './createAllArtistSelector';
|
||||||
|
|
||||||
function createArtistCountSelector() {
|
function createArtistCountSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
createAllArtistSelector(),
|
createAllArtistSelector(),
|
||||||
(state) => state.artist.error,
|
(state: AppState) => state.artist.error,
|
||||||
(state) => state.artist.isFetching,
|
(state: AppState) => state.artist.isFetching,
|
||||||
(state) => state.artist.isPopulated,
|
(state: AppState) => state.artist.isPopulated,
|
||||||
(artists, error, isFetching, isPopulated) => {
|
(artists, error, isFetching, isPopulated) => {
|
||||||
return {
|
return {
|
||||||
count: artists.length,
|
count: artists.length,
|
||||||
error,
|
error,
|
||||||
isFetching,
|
isFetching,
|
||||||
isPopulated
|
isPopulated,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
|
@ -2,13 +2,10 @@ import { createSelector } from 'reselect';
|
||||||
import { isCommandExecuting } from 'Utilities/Command';
|
import { isCommandExecuting } from 'Utilities/Command';
|
||||||
import createCommandSelector from './createCommandSelector';
|
import createCommandSelector from './createCommandSelector';
|
||||||
|
|
||||||
function createCommandExecutingSelector(name, contraints = {}) {
|
function createCommandExecutingSelector(name: string, contraints = {}) {
|
||||||
return createSelector(
|
return createSelector(createCommandSelector(name, contraints), (command) => {
|
||||||
createCommandSelector(name, contraints),
|
return isCommandExecuting(command);
|
||||||
(command) => {
|
});
|
||||||
return isCommandExecuting(command);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createCommandExecutingSelector;
|
export default createCommandExecutingSelector;
|
|
@ -1,14 +0,0 @@
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { findCommand } from 'Utilities/Command';
|
|
||||||
import createCommandsSelector from './createCommandsSelector';
|
|
||||||
|
|
||||||
function createCommandSelector(name, contraints = {}) {
|
|
||||||
return createSelector(
|
|
||||||
createCommandsSelector(),
|
|
||||||
(commands) => {
|
|
||||||
return findCommand(commands, { name, ...contraints });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createCommandSelector;
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { findCommand } from 'Utilities/Command';
|
||||||
|
import createCommandsSelector from './createCommandsSelector';
|
||||||
|
|
||||||
|
function createCommandSelector(name: string, contraints = {}) {
|
||||||
|
return createSelector(createCommandsSelector(), (commands) => {
|
||||||
|
return findCommand(commands, { name, ...contraints });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createCommandSelector;
|
|
@ -1,8 +1,9 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
function createCommandsSelector() {
|
function createCommandsSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state) => state.commands,
|
(state: AppState) => state.commands,
|
||||||
(commands) => {
|
(commands) => {
|
||||||
return commands.items;
|
return commands.items;
|
||||||
}
|
}
|
|
@ -1,9 +0,0 @@
|
||||||
import _ from 'lodash';
|
|
||||||
import { createSelectorCreator, defaultMemoize } from 'reselect';
|
|
||||||
|
|
||||||
const createDeepEqualSelector = createSelectorCreator(
|
|
||||||
defaultMemoize,
|
|
||||||
_.isEqual
|
|
||||||
);
|
|
||||||
|
|
||||||
export default createDeepEqualSelector;
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
import { createSelectorCreator, defaultMemoize } from 'reselect';
|
||||||
|
|
||||||
|
const createDeepEqualSelector = createSelectorCreator(defaultMemoize, isEqual);
|
||||||
|
|
||||||
|
export default createDeepEqualSelector;
|
|
@ -1,9 +1,10 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
import { isCommandExecuting } from 'Utilities/Command';
|
import { isCommandExecuting } from 'Utilities/Command';
|
||||||
|
|
||||||
function createExecutingCommandsSelector() {
|
function createExecutingCommandsSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state) => state.commands.items,
|
(state: AppState) => state.commands.items,
|
||||||
(commands) => {
|
(commands) => {
|
||||||
return commands.filter((command) => isCommandExecuting(command));
|
return commands.filter((command) => isCommandExecuting(command));
|
||||||
}
|
}
|
|
@ -1,13 +1,15 @@
|
||||||
import _ from 'lodash';
|
import { some } from 'lodash';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
import createAllArtistSelector from './createAllArtistSelector';
|
import createAllArtistSelector from './createAllArtistSelector';
|
||||||
|
|
||||||
function createExistingArtistSelector() {
|
function createExistingArtistSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state, { foreignArtistId }) => foreignArtistId,
|
(_: AppState, { foreignArtistId }: { foreignArtistId: string }) =>
|
||||||
|
foreignArtistId,
|
||||||
createAllArtistSelector(),
|
createAllArtistSelector(),
|
||||||
(foreignArtistId, artist) => {
|
(foreignArtistId, artist) => {
|
||||||
return _.some(artist, { foreignArtistId });
|
return some(artist, { foreignArtistId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,15 +0,0 @@
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
|
|
||||||
function createMetadataProfileSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state, { metadataProfileId }) => metadataProfileId,
|
|
||||||
(state) => state.settings.metadataProfiles.items,
|
|
||||||
(metadataProfileId, metadataProfiles) => {
|
|
||||||
return metadataProfiles.find((profile) => {
|
|
||||||
return profile.id === metadataProfileId;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createMetadataProfileSelector;
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
|
function createMetadataProfileSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(_: AppState, { metadataProfileId }: { metadataProfileId: number }) =>
|
||||||
|
metadataProfileId,
|
||||||
|
(state: AppState) => state.settings.metadataProfiles.items,
|
||||||
|
(metadataProfileId, metadataProfiles) => {
|
||||||
|
return metadataProfiles.find(
|
||||||
|
(profile) => profile.id === metadataProfileId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createMetadataProfileSelector;
|
|
@ -1,24 +0,0 @@
|
||||||
import _ from 'lodash';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createAllArtistSelector from './createAllArtistSelector';
|
|
||||||
|
|
||||||
function createProfileInUseSelector(profileProp) {
|
|
||||||
return createSelector(
|
|
||||||
(state, { id }) => id,
|
|
||||||
createAllArtistSelector(),
|
|
||||||
(state) => state.settings.importLists.items,
|
|
||||||
(id, artist, lists) => {
|
|
||||||
if (!id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_.some(artist, { [profileProp]: id }) || _.some(lists, { [profileProp]: id })) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createProfileInUseSelector;
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import Artist from 'Artist/Artist';
|
||||||
|
import ImportList from 'typings/ImportList';
|
||||||
|
import createAllArtistSelector from './createAllArtistSelector';
|
||||||
|
|
||||||
|
function createProfileInUseSelector(profileProp: string) {
|
||||||
|
return createSelector(
|
||||||
|
(_: AppState, { id }: { id: number }) => id,
|
||||||
|
createAllArtistSelector(),
|
||||||
|
(state: AppState) => state.settings.importLists.items,
|
||||||
|
(id, artists, lists) => {
|
||||||
|
if (!id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
artists.some((a) => a[profileProp as keyof Artist] === id) ||
|
||||||
|
lists.some((list) => list[profileProp as keyof ImportList] === id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createProfileInUseSelector;
|
|
@ -1,26 +0,0 @@
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
|
|
||||||
export function createQualityProfileSelectorForHook(qualityProfileId) {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.settings.qualityProfiles.items,
|
|
||||||
(qualityProfiles) => {
|
|
||||||
return qualityProfiles.find((profile) => {
|
|
||||||
return profile.id === qualityProfileId;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createQualityProfileSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state, { qualityProfileId }) => qualityProfileId,
|
|
||||||
(state) => state.settings.qualityProfiles.items,
|
|
||||||
(qualityProfileId, qualityProfiles) => {
|
|
||||||
return qualityProfiles.find((profile) => {
|
|
||||||
return profile.id === qualityProfileId;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createQualityProfileSelector;
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
|
export function createQualityProfileSelectorForHook(qualityProfileId: number) {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.settings.qualityProfiles.items,
|
||||||
|
(qualityProfiles) => {
|
||||||
|
return qualityProfiles.find((profile) => profile.id === qualityProfileId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQualityProfileSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(_: AppState, { qualityProfileId }: { qualityProfileId: number }) =>
|
||||||
|
qualityProfileId,
|
||||||
|
(state: AppState) => state.settings.qualityProfiles.items,
|
||||||
|
(qualityProfileId, qualityProfiles) => {
|
||||||
|
return qualityProfiles.find((profile) => profile.id === qualityProfileId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createQualityProfileSelector;
|
|
@ -1,21 +1,16 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
function createQueueItemSelector() {
|
function createQueueItemSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state, { albumId }) => albumId,
|
(_: AppState, { albumId }: { albumId: number }) => albumId,
|
||||||
(state) => state.queue.details.items,
|
(state: AppState) => state.queue.details.items,
|
||||||
(albumId, details) => {
|
(albumId, details) => {
|
||||||
if (!albumId || !details) {
|
if (!albumId || !details) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return details.find((item) => {
|
return details.find((item) => item.albumId === albumId);
|
||||||
if (item.album) {
|
|
||||||
return item.album.id === albumId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
function createSystemStatusSelector() {
|
function createSystemStatusSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state) => state.system.status,
|
(state: AppState) => state.system.status,
|
||||||
(status) => {
|
(status) => {
|
||||||
return status.item;
|
return status.item;
|
||||||
}
|
}
|
|
@ -1,9 +1,10 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
function createTagDetailsSelector() {
|
function createTagDetailsSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state, { id }) => id,
|
(_: AppState, { id }: { id: number }) => id,
|
||||||
(state) => state.tags.details.items,
|
(state: AppState) => state.tags.details.items,
|
||||||
(id, tagDetails) => {
|
(id, tagDetails) => {
|
||||||
return tagDetails.find((t) => t.id === id);
|
return tagDetails.find((t) => t.id === id);
|
||||||
}
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
function createTagsSelector() {
|
function createTagsSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state) => state.tags.items,
|
(state: AppState) => state.tags.items,
|
||||||
(tags) => {
|
(tags) => {
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
|
@ -1,9 +1,10 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
function createTrackFileSelector() {
|
function createTrackFileSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state, { trackFileId }) => trackFileId,
|
(_: AppState, { trackFileId }: { trackFileId: number }) => trackFileId,
|
||||||
(state) => state.trackFiles,
|
(state: AppState) => state.trackFiles,
|
||||||
(trackFileId, trackFiles) => {
|
(trackFileId, trackFiles) => {
|
||||||
if (!trackFileId) {
|
if (!trackFileId) {
|
||||||
return;
|
return;
|
|
@ -1,8 +1,9 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
function createUISettingsSelector() {
|
function createUISettingsSelector() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state) => state.settings.ui,
|
(state: AppState) => state.settings.ui,
|
||||||
(ui) => {
|
(ui) => {
|
||||||
return ui.item;
|
return ui.item;
|
||||||
}
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
interface SystemStatus {
|
||||||
|
appData: string;
|
||||||
|
appName: string;
|
||||||
|
authentication: string;
|
||||||
|
branch: string;
|
||||||
|
buildTime: string;
|
||||||
|
instanceName: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isDebug: boolean;
|
||||||
|
isDocker: boolean;
|
||||||
|
isLinux: boolean;
|
||||||
|
isNetCore: boolean;
|
||||||
|
isOsx: boolean;
|
||||||
|
isProduction: boolean;
|
||||||
|
isUserInteractive: boolean;
|
||||||
|
isWindows: boolean;
|
||||||
|
migrationVersion: number;
|
||||||
|
mode: string;
|
||||||
|
osName: string;
|
||||||
|
osVersion: string;
|
||||||
|
packageUpdateMechanism: string;
|
||||||
|
runtimeName: string;
|
||||||
|
runtimeVersion: string;
|
||||||
|
sqliteVersion: string;
|
||||||
|
startTime: string;
|
||||||
|
startupPath: string;
|
||||||
|
urlBase: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SystemStatus;
|
42
package.json
42
package.json
|
@ -34,9 +34,9 @@
|
||||||
"@microsoft/signalr": "6.0.25",
|
"@microsoft/signalr": "6.0.25",
|
||||||
"@sentry/browser": "7.51.2",
|
"@sentry/browser": "7.51.2",
|
||||||
"@sentry/integrations": "7.51.2",
|
"@sentry/integrations": "7.51.2",
|
||||||
"@types/node": "18.16.14",
|
"@types/node": "18.19.31",
|
||||||
"@types/react": "18.2.6",
|
"@types/react": "18.2.79",
|
||||||
"@types/react-dom": "18.2.4",
|
"@types/react-dom": "18.2.25",
|
||||||
"ansi-colors": "4.1.3",
|
"ansi-colors": "4.1.3",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
|
@ -87,47 +87,47 @@
|
||||||
"redux-thunk": "2.3.0",
|
"redux-thunk": "2.3.0",
|
||||||
"reselect": "4.1.8",
|
"reselect": "4.1.8",
|
||||||
"stacktrace-js": "2.0.2",
|
"stacktrace-js": "2.0.2",
|
||||||
"typescript": "4.9.5"
|
"typescript": "5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.22.11",
|
"@babel/core": "7.24.4",
|
||||||
"@babel/eslint-parser": "7.22.11",
|
"@babel/eslint-parser": "7.24.1",
|
||||||
"@babel/plugin-proposal-export-default-from": "7.22.5",
|
"@babel/plugin-proposal-export-default-from": "7.24.1",
|
||||||
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
||||||
"@babel/preset-env": "7.22.14",
|
"@babel/preset-env": "7.24.4",
|
||||||
"@babel/preset-react": "7.22.5",
|
"@babel/preset-react": "7.24.1",
|
||||||
"@babel/preset-typescript": "7.22.11",
|
"@babel/preset-typescript": "7.24.1",
|
||||||
"@types/lodash": "4.14.197",
|
"@types/lodash": "4.14.195",
|
||||||
"@types/react-lazyload": "3.2.0",
|
"@types/react-lazyload": "3.2.0",
|
||||||
"@types/react-router-dom": "5.3.3",
|
"@types/react-router-dom": "5.3.3",
|
||||||
"@types/react-text-truncate": "0.14.1",
|
"@types/react-text-truncate": "0.14.1",
|
||||||
"@types/react-window": "1.8.5",
|
"@types/react-window": "1.8.5",
|
||||||
"@types/redux-actions": "2.6.2",
|
"@types/redux-actions": "2.6.2",
|
||||||
"@typescript-eslint/eslint-plugin": "5.59.7",
|
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||||
"@typescript-eslint/parser": "5.59.7",
|
"@typescript-eslint/parser": "6.21.0",
|
||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.14",
|
||||||
"babel-loader": "9.1.3",
|
"babel-loader": "9.1.3",
|
||||||
"babel-plugin-inline-classnames": "2.0.1",
|
"babel-plugin-inline-classnames": "2.0.1",
|
||||||
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
|
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
|
||||||
"core-js": "3.32.1",
|
"core-js": "3.37.0",
|
||||||
"css-loader": "6.7.3",
|
"css-loader": "6.7.3",
|
||||||
"css-modules-typescript-loader": "4.0.1",
|
"css-modules-typescript-loader": "4.0.1",
|
||||||
"eslint": "8.45.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-prettier": "8.8.0",
|
"eslint-config-prettier": "8.10.0",
|
||||||
"eslint-plugin-filenames": "1.3.2",
|
"eslint-plugin-filenames": "1.3.2",
|
||||||
"eslint-plugin-import": "2.27.5",
|
"eslint-plugin-import": "2.29.1",
|
||||||
"eslint-plugin-json": "3.1.0",
|
"eslint-plugin-json": "3.1.0",
|
||||||
"eslint-plugin-prettier": "4.2.1",
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"eslint-plugin-react": "7.32.2",
|
"eslint-plugin-react": "7.34.1",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"eslint-plugin-simple-import-sort": "10.0.0",
|
"eslint-plugin-simple-import-sort": "12.1.0",
|
||||||
"file-loader": "6.2.0",
|
"file-loader": "6.2.0",
|
||||||
"filemanager-webpack-plugin": "8.0.0",
|
"filemanager-webpack-plugin": "8.0.0",
|
||||||
"fork-ts-checker-webpack-plugin": "8.0.0",
|
"fork-ts-checker-webpack-plugin": "8.0.0",
|
||||||
"html-webpack-plugin": "5.5.1",
|
"html-webpack-plugin": "5.5.1",
|
||||||
"loader-utils": "^3.2.1",
|
"loader-utils": "^3.2.1",
|
||||||
"mini-css-extract-plugin": "2.7.5",
|
"mini-css-extract-plugin": "2.7.6",
|
||||||
"postcss": "8.4.23",
|
"postcss": "8.4.38",
|
||||||
"postcss-color-function": "4.1.0",
|
"postcss-color-function": "4.1.0",
|
||||||
"postcss-loader": "7.3.0",
|
"postcss-loader": "7.3.0",
|
||||||
"postcss-mixins": "9.0.4",
|
"postcss-mixins": "9.0.4",
|
||||||
|
|
|
@ -101,8 +101,8 @@
|
||||||
|
|
||||||
<!-- Standard testing packages -->
|
<!-- Standard testing packages -->
|
||||||
<ItemGroup Condition="'$(TestProject)'=='true'">
|
<ItemGroup Condition="'$(TestProject)'=='true'">
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||||
<PackageReference Include="NunitXml.TestLogger" Version="3.0.131" />
|
<PackageReference Include="NunitXml.TestLogger" Version="3.0.131" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
@ -0,0 +1,614 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Moq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using NzbDrone.Common.Disk;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Core.Configuration;
|
||||||
|
using NzbDrone.Core.Datastore;
|
||||||
|
using NzbDrone.Core.Extras;
|
||||||
|
using NzbDrone.Core.Extras.Others;
|
||||||
|
using NzbDrone.Core.MediaFiles;
|
||||||
|
using NzbDrone.Core.MediaFiles.Events;
|
||||||
|
using NzbDrone.Core.MediaFiles.TrackImport;
|
||||||
|
using NzbDrone.Core.Music;
|
||||||
|
using NzbDrone.Core.Organizer;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
using NzbDrone.Core.Profiles.Qualities;
|
||||||
|
using NzbDrone.Core.Test.Framework;
|
||||||
|
using NzbDrone.Test.Common;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Test.Extras
|
||||||
|
{
|
||||||
|
public class ExtraServiceFixture : CoreTest<ExtraService>
|
||||||
|
{
|
||||||
|
private string _albumDir;
|
||||||
|
private Artist _artist;
|
||||||
|
private Album _album;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void CommonSetup()
|
||||||
|
{
|
||||||
|
var artistDir = @"C:\Test\Music\Foo Fooers".AsOsAgnostic();
|
||||||
|
_artist = new Artist()
|
||||||
|
{
|
||||||
|
QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() },
|
||||||
|
Path = artistDir,
|
||||||
|
};
|
||||||
|
_album = new Album()
|
||||||
|
{
|
||||||
|
Id = 15,
|
||||||
|
Artist = _artist,
|
||||||
|
Title = "Twenty Thirties",
|
||||||
|
};
|
||||||
|
var release = new AlbumRelease()
|
||||||
|
{
|
||||||
|
AlbumId = _album.Id,
|
||||||
|
Monitored = true,
|
||||||
|
};
|
||||||
|
_album.AlbumReleases = new List<AlbumRelease> { release };
|
||||||
|
_albumDir = Path.Combine(_artist.Path, $"{_album.Title} (1995) [FLAC]");
|
||||||
|
|
||||||
|
Mocker.GetMock<IDiskProvider>()
|
||||||
|
.Setup(x => x.GetParentFolder(It.IsAny<string>()))
|
||||||
|
.Returns<string>(arg => Path.GetDirectoryName(arg.AsOsAgnostic()));
|
||||||
|
|
||||||
|
Mocker.GetMock<IConfigService>()
|
||||||
|
.Setup(x => x.ImportExtraFiles).Returns(true);
|
||||||
|
Mocker.GetMock<IConfigService>()
|
||||||
|
.Setup(x => x.ExtraFileExtensions).Returns(".cue,.nfo,.log,.jpg");
|
||||||
|
|
||||||
|
// Rename on by default
|
||||||
|
var cfg = NamingConfig.Default;
|
||||||
|
cfg.RenameTracks = true;
|
||||||
|
Mocker.GetMock<INamingConfigService>().Setup(x => x.GetConfig()).Returns(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AlbumImportTests : ExtraServiceFixture
|
||||||
|
{
|
||||||
|
private List<ImportDecision<LocalTrack>> _importDecisions;
|
||||||
|
private List<string> _importDirExtraFiles;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
var track = NewTrack(_album, _albumDir, "01 - hello world.flac");
|
||||||
|
_importDecisions = new ()
|
||||||
|
{
|
||||||
|
new ImportDecision<LocalTrack>(track)
|
||||||
|
};
|
||||||
|
_importDirExtraFiles = new List<string>
|
||||||
|
{
|
||||||
|
Path.Combine(_albumDir, "album.cue"),
|
||||||
|
Path.Combine(_albumDir, "albumfoo_barz.jpg"),
|
||||||
|
Path.Combine(_albumDir, "release.nfo"),
|
||||||
|
Path.Combine(_albumDir, "eac.log"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Mocker.GetMock<IMediaFileService>().Setup(x => x.GetFilesByArtist(_album.ArtistId))
|
||||||
|
.Returns(track.Tracks.Select(t => t.TrackFile.Value).ToList());
|
||||||
|
Mocker.GetMock<ITrackService>().Setup(x => x.GetTracksByArtist(_album.ArtistId))
|
||||||
|
.Returns(new List<Track> { track.Tracks.Single() });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_import_extras_during_manual_import_with_naming_config_having_rename_on()
|
||||||
|
{
|
||||||
|
SetupFilesUnderCommonDir(_albumDir, _importDecisions.Select(d => d.Item.Path).Concat(_importDirExtraFiles));
|
||||||
|
|
||||||
|
// act
|
||||||
|
Subject.ImportAlbumExtras(_importDecisions);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == _importDirExtraFiles.Count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCase(false)]
|
||||||
|
[TestCase(true)]
|
||||||
|
public void should_not_import_extras_when_no_separate_album_dir_set(bool testStandardTrackFormat)
|
||||||
|
{
|
||||||
|
SetupFilesUnderCommonDir(_albumDir, _importDecisions.Select(d => d.Item.Path).Concat(_importDirExtraFiles));
|
||||||
|
|
||||||
|
var cfg = NamingConfig.Default;
|
||||||
|
cfg.RenameTracks = true;
|
||||||
|
|
||||||
|
// modify either standard or multidisc format to test both branches:
|
||||||
|
if (testStandardTrackFormat)
|
||||||
|
{
|
||||||
|
cfg.StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cfg.MultiDiscTrackFormat = "{Medium Format} {medium:00}/{Artist Name} - {Album Title} - {track:00} - {Track Title}";
|
||||||
|
}
|
||||||
|
|
||||||
|
SetupNamingConfig(cfg);
|
||||||
|
|
||||||
|
Subject.ImportAlbumExtras(_importDecisions);
|
||||||
|
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>().VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_import_extra_from_multi_cd_root_dir()
|
||||||
|
{
|
||||||
|
var cd1Subdir = Path.Combine(_albumDir, "CD1");
|
||||||
|
var cd2Subdir = Path.Combine(_albumDir, "CD2");
|
||||||
|
|
||||||
|
var cd1Track = NewTrack(_album, cd1Subdir, "101 - Foo Track.flac");
|
||||||
|
var cd2Track = NewTrack(_album, cd2Subdir, "201 - bonustrackbar.flac");
|
||||||
|
|
||||||
|
var extraFileInAlbumRoot = Path.Combine(_albumDir, "album.cue");
|
||||||
|
|
||||||
|
SetupFilesUnderCommonDir(_albumDir, cd1Track.Path, cd2Track.Path, extraFileInAlbumRoot);
|
||||||
|
|
||||||
|
// act
|
||||||
|
var decisions = new List<ImportDecision<LocalTrack>>
|
||||||
|
{
|
||||||
|
new ImportDecision<LocalTrack>(cd1Track),
|
||||||
|
new ImportDecision<LocalTrack>(cd2Track),
|
||||||
|
};
|
||||||
|
|
||||||
|
Subject.ImportAlbumExtras(decisions);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == 1)));
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(
|
||||||
|
It.Is<List<OtherExtraFile>>(
|
||||||
|
arg => arg.Single().Extension == ".cue"
|
||||||
|
&& arg.Single().RelativePath.AsOsAgnostic() == _artist.Path.GetRelativePath(extraFileInAlbumRoot).AsOsAgnostic())));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCase("")]
|
||||||
|
[TestCase("extras_subdir")]
|
||||||
|
public void should_move_album_extra_to_correct_subdir_on_artist_renamed_event(string extraFilesDir)
|
||||||
|
{
|
||||||
|
var newDir = $"{_albumDir} [Release FOO]".AsOsAgnostic();
|
||||||
|
var renamed = new List<RenamedTrackFile>();
|
||||||
|
foreach (var import in _importDecisions)
|
||||||
|
{
|
||||||
|
renamed.Add(new RenamedTrackFile()
|
||||||
|
{
|
||||||
|
PreviousPath = import.Item.Path,
|
||||||
|
TrackFile = new TrackFile()
|
||||||
|
{
|
||||||
|
Id = 11,
|
||||||
|
Album = _album,
|
||||||
|
AlbumId = _album.Id,
|
||||||
|
Path = import.Item.Path.Replace(_albumDir, newDir),
|
||||||
|
Tracks = new List<Track>()
|
||||||
|
{
|
||||||
|
new Track() { Album = _album, Artist = _artist, TrackFileId = 11 },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePathBeforeMove = Path.Combine(new DirectoryInfo(_albumDir).Name, extraFilesDir, "album.cue");
|
||||||
|
var albumExtra = new OtherExtraFile
|
||||||
|
{
|
||||||
|
Id = 251,
|
||||||
|
AlbumId = _album.Id,
|
||||||
|
ArtistId = _album.ArtistId,
|
||||||
|
RelativePath = relativePathBeforeMove,
|
||||||
|
Extension = ".cue",
|
||||||
|
Added = DateTime.UtcNow,
|
||||||
|
TrackFileId = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>().Setup(x => x.GetFilesByArtist(_album.ArtistId))
|
||||||
|
.Returns(new List<OtherExtraFile>() { albumExtra });
|
||||||
|
|
||||||
|
// act
|
||||||
|
Subject.Handle(new ArtistRenamedEvent(_artist, renamed));
|
||||||
|
|
||||||
|
var expectedExtraDir = Path.Combine(newDir, extraFilesDir);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
Mocker.GetMock<IDiskProvider>()
|
||||||
|
.Verify(x => x.MoveFile(
|
||||||
|
It.Is<string>(arg => arg.Contains(relativePathBeforeMove)),
|
||||||
|
It.Is<string>(arg => arg.Contains(expectedExtraDir)),
|
||||||
|
It.IsAny<bool>()), Times.Once);
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_move_album_extras_for_multicd_release_on_artist_renamed_event()
|
||||||
|
{
|
||||||
|
var newAlbumDir = $"{_albumDir} 2CDs".AsOsAgnostic();
|
||||||
|
|
||||||
|
var oldCd1Subdir = Path.Combine(_albumDir, "Disk 1");
|
||||||
|
var oldCd2Subdir = Path.Combine(_albumDir, "Disk 2");
|
||||||
|
var cd1Subdir = Path.Combine(newAlbumDir, "CD1");
|
||||||
|
var cd2Subdir = Path.Combine(newAlbumDir, "CD2");
|
||||||
|
var cd1Track = NewTrack(_album, cd1Subdir, "101 - Foo Track.flac");
|
||||||
|
var cd2Track = NewTrack(_album, cd2Subdir, "201 - bonustrackbar.flac");
|
||||||
|
|
||||||
|
var renamed = new List<RenamedTrackFile>()
|
||||||
|
{
|
||||||
|
new RenamedTrackFile
|
||||||
|
{
|
||||||
|
PreviousPath = Path.Combine(oldCd1Subdir, "101 - Foo Track.flac"),
|
||||||
|
TrackFile = cd1Track.Tracks.Single().TrackFile.Value,
|
||||||
|
},
|
||||||
|
new RenamedTrackFile
|
||||||
|
{
|
||||||
|
PreviousPath = Path.Combine(oldCd2Subdir, "201 - bonustrackbar.flac"),
|
||||||
|
TrackFile = cd2Track.Tracks.Single().TrackFile.Value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var albumDirExtraOldRelativePath = Path.Combine(new DirectoryInfo(_albumDir).Name, "album.cue");
|
||||||
|
var albumExtraAtRoot = new OtherExtraFile
|
||||||
|
{
|
||||||
|
Id = 251,
|
||||||
|
AlbumId = _album.Id,
|
||||||
|
ArtistId = _album.ArtistId,
|
||||||
|
RelativePath = albumDirExtraOldRelativePath,
|
||||||
|
Extension = ".cue",
|
||||||
|
Added = DateTime.UtcNow,
|
||||||
|
TrackFileId = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
var cd1ExtraOldRelativePath = Path.Combine(_artist.Path.GetRelativePath(oldCd1Subdir), "cd1.log");
|
||||||
|
var cd1ExtraFile = new OtherExtraFile()
|
||||||
|
{
|
||||||
|
Id = 252,
|
||||||
|
AlbumId = _album.Id,
|
||||||
|
ArtistId = _album.ArtistId,
|
||||||
|
RelativePath = cd1ExtraOldRelativePath,
|
||||||
|
Extension = ".log",
|
||||||
|
Added = DateTime.UtcNow,
|
||||||
|
TrackFileId = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>().Setup(x => x.GetFilesByArtist(_album.ArtistId))
|
||||||
|
.Returns(new List<OtherExtraFile>() { albumExtraAtRoot, cd1ExtraFile });
|
||||||
|
|
||||||
|
// act
|
||||||
|
Subject.Handle(new ArtistRenamedEvent(_artist, renamed));
|
||||||
|
|
||||||
|
// verify
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == 2)));
|
||||||
|
|
||||||
|
// assert
|
||||||
|
Mocker.GetMock<IDiskProvider>()
|
||||||
|
.Verify(x => x.MoveFile(
|
||||||
|
It.Is<string>(arg => arg.EndsWithIgnoreCase(albumDirExtraOldRelativePath)),
|
||||||
|
It.Is<string>(arg => arg.StartsWith(newAlbumDir)),
|
||||||
|
It.IsAny<bool>()), Times.Once);
|
||||||
|
|
||||||
|
Mocker.GetMock<IDiskProvider>()
|
||||||
|
.Verify(x => x.MoveFile(
|
||||||
|
It.Is<string>(arg => arg.EndsWithIgnoreCase(cd1ExtraOldRelativePath)),
|
||||||
|
It.Is<string>(arg => arg.StartsWith(cd1Subdir)),
|
||||||
|
It.IsAny<bool>()), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AlbumDownloadTests : ExtraServiceFixture
|
||||||
|
{
|
||||||
|
private string _downloadDir;
|
||||||
|
private List<ImportDecision<LocalTrack>> _approvedDownloadDecisions;
|
||||||
|
private List<string> _downloadDirExtraFiles;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
_downloadDir = @"C:\temp\downloads\Artist - TT (1995) FLAC".AsOsAgnostic();
|
||||||
|
var downloadedTrack = NewTrack(_album, _albumDir, "01 - First seconds.flac", _downloadDir);
|
||||||
|
_approvedDownloadDecisions = new List<ImportDecision<LocalTrack>>()
|
||||||
|
{
|
||||||
|
new ImportDecision<LocalTrack>(downloadedTrack),
|
||||||
|
};
|
||||||
|
_downloadDirExtraFiles = new List<string>
|
||||||
|
{
|
||||||
|
Path.Combine(_downloadDir, "album.cue"),
|
||||||
|
Path.Combine(_downloadDir, "cover.nfo"),
|
||||||
|
Path.Combine(_downloadDir, "eac.log"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_import_extras_from_download_location()
|
||||||
|
{
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir, _approvedDownloadDecisions.Select(d => d.Item.Path).Concat(_downloadDirExtraFiles));
|
||||||
|
|
||||||
|
Subject.ImportAlbumExtras(_approvedDownloadDecisions);
|
||||||
|
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == _downloadDirExtraFiles.Count)));
|
||||||
|
foreach (var sourcePath in _downloadDirExtraFiles)
|
||||||
|
{
|
||||||
|
Mocker.GetMock<IDiskTransferService>()
|
||||||
|
.Verify(x => x.TransferFile(
|
||||||
|
It.Is<string>(arg => arg.AsOsAgnostic() == sourcePath.AsOsAgnostic()),
|
||||||
|
It.Is<string>(arg => arg.AsOsAgnostic().StartsWith(_albumDir.AsOsAgnostic())),
|
||||||
|
It.IsAny<TransferMode>(),
|
||||||
|
It.IsAny<bool>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_not_import_track_specific_extras()
|
||||||
|
{
|
||||||
|
var trackName = Path.GetFileNameWithoutExtension(_approvedDownloadDecisions.First().Item.Path);
|
||||||
|
var trackExtra = Path.Combine(_downloadDir, $"{trackName}.cue");
|
||||||
|
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir,
|
||||||
|
_approvedDownloadDecisions.Select(d => d.Item.Path).Concat(_downloadDirExtraFiles)
|
||||||
|
.Append(trackExtra));
|
||||||
|
|
||||||
|
Subject.ImportAlbumExtras(_approvedDownloadDecisions);
|
||||||
|
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == _downloadDirExtraFiles.Count)));
|
||||||
|
|
||||||
|
Mocker.GetMock<IDiskTransferService>()
|
||||||
|
.Verify(x => x.TransferFile(
|
||||||
|
It.Is<string>(arg => arg.AsOsAgnostic() == trackExtra.AsOsAgnostic()),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<TransferMode>(),
|
||||||
|
It.IsAny<bool>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_import_with_extensions_from_settings()
|
||||||
|
{
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir, _downloadDirExtraFiles);
|
||||||
|
|
||||||
|
Mocker.GetMock<IConfigService>()
|
||||||
|
.Setup(x => x.ExtraFileExtensions)
|
||||||
|
.Returns(".cue, .txt");
|
||||||
|
|
||||||
|
Subject.ImportAlbumExtras(_approvedDownloadDecisions);
|
||||||
|
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(
|
||||||
|
arg => arg.Count == 1
|
||||||
|
&& arg.Single().Extension == ".cue")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_not_import_extras_with_naming_cfg_having_rename_off()
|
||||||
|
{
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir,
|
||||||
|
_approvedDownloadDecisions.Select(d => d.Item.Path)
|
||||||
|
.Concat(_downloadDirExtraFiles));
|
||||||
|
|
||||||
|
var cfg = NamingConfig.Default;
|
||||||
|
cfg.RenameTracks = false; // explicitly set for readability
|
||||||
|
SetupNamingConfig(cfg);
|
||||||
|
|
||||||
|
Subject.ImportAlbumExtras(_approvedDownloadDecisions);
|
||||||
|
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>().VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCase("{Album Title} ({Release Year})")]
|
||||||
|
[TestCase("{ALBUM TITLE} ({Release Year})")]
|
||||||
|
[TestCase("{Album Title}")]
|
||||||
|
[TestCase("{Album.Title}")]
|
||||||
|
[TestCase("{Album_Title}")]
|
||||||
|
public void should_import_extras_rename_pattern_contains_album_title(string albumDirPattern)
|
||||||
|
{
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir,
|
||||||
|
_approvedDownloadDecisions.Select(d => d.Item.Path)
|
||||||
|
.Concat(_downloadDirExtraFiles));
|
||||||
|
|
||||||
|
var cfg = NamingConfig.Default;
|
||||||
|
cfg.RenameTracks = true;
|
||||||
|
|
||||||
|
cfg.StandardTrackFormat = cfg.StandardTrackFormat
|
||||||
|
.Replace("{Album Title} ({Release Year})", albumDirPattern);
|
||||||
|
cfg.MultiDiscTrackFormat = cfg.MultiDiscTrackFormat
|
||||||
|
.Replace("{Album Title} ({Release Year})", albumDirPattern);
|
||||||
|
|
||||||
|
SetupNamingConfig(cfg);
|
||||||
|
|
||||||
|
// act
|
||||||
|
Subject.ImportAlbumExtras(_approvedDownloadDecisions);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == _downloadDirExtraFiles.Count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_import_extra_from_multi_cd_subdirs()
|
||||||
|
{
|
||||||
|
var cd1Source = Path.Combine(_downloadDir, "CD1");
|
||||||
|
var cd2Source = Path.Combine(_downloadDir, "CD2");
|
||||||
|
var cd1Destination = Path.Combine(_albumDir, "Disk 1");
|
||||||
|
var cd2Destination = Path.Combine(_albumDir, "Disk 2");
|
||||||
|
|
||||||
|
var cd1Track = NewTrack(_album, cd1Destination, "101 - Foo Track.flac", cd1Source);
|
||||||
|
var cd2Track = NewTrack(_album, cd2Destination, "201 - bonustrackbar.flac", cd2Source);
|
||||||
|
var decisions = new List<ImportDecision<LocalTrack>>
|
||||||
|
{
|
||||||
|
new ImportDecision<LocalTrack>(cd1Track),
|
||||||
|
new ImportDecision<LocalTrack>(cd2Track),
|
||||||
|
};
|
||||||
|
var cd1Extra = Path.Combine(cd1Source, "cd1_foo.cue");
|
||||||
|
var cd2Extra = Path.Combine(cd2Source, "cd2_bar.cue");
|
||||||
|
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir, cd1Track.Path, cd1Extra, cd2Track.Path, cd2Extra);
|
||||||
|
|
||||||
|
Subject.ImportAlbumExtras(decisions);
|
||||||
|
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_import_from_separate_extras_dir_having_no_tracks()
|
||||||
|
{
|
||||||
|
var cd1Track = NewTrack(_album, _albumDir, "101 - Foo Track.flac", _downloadDir);
|
||||||
|
var cd2Track = NewTrack(_album, _albumDir, "201 - Bonustrackbar.flac", _downloadDir);
|
||||||
|
var extraFileInRoot = Path.Combine(_downloadDir, "cuesheet.cue");
|
||||||
|
var extraFileInSubdir = Path.Combine(_downloadDir, "artwork", "cover.jpg");
|
||||||
|
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir, cd1Track.Path, cd2Track.Path, extraFileInRoot, extraFileInSubdir);
|
||||||
|
var decisions = new List<ImportDecision<LocalTrack>>
|
||||||
|
{
|
||||||
|
new ImportDecision<LocalTrack>(cd1Track),
|
||||||
|
new ImportDecision<LocalTrack>(cd2Track),
|
||||||
|
};
|
||||||
|
Subject.ImportAlbumExtras(decisions);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
Mocker.GetMock<IOtherExtraFileService>()
|
||||||
|
.Verify(x => x.Upsert(It.Is<List<OtherExtraFile>>(arg => arg.Count == 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCase(new string[] { "" }, null)]
|
||||||
|
[TestCase(new string[] { "files" }, null)]
|
||||||
|
[TestCase(new string[] { "first", "second_dir" }, null)]
|
||||||
|
[TestCase(new string[] { "Disk 1" }, new string[] { "CD1" })]
|
||||||
|
[TestCase(new string[] { "Disk 2", "cd2_extras" }, new string[] { "CD2", "cd2_extras" })]
|
||||||
|
public void should_copy_multicd_extra_file_to_correct_subdirectory(string[] sourcePathDirs, string[] destinationPathDirs = null)
|
||||||
|
{
|
||||||
|
var relativeSourcePath = Path.Combine(sourcePathDirs);
|
||||||
|
var relativeDestinationPath = destinationPathDirs != null ? Path.Combine(destinationPathDirs) : relativeSourcePath;
|
||||||
|
|
||||||
|
var cd1Source = Path.Combine(_downloadDir, "Disk 1");
|
||||||
|
var cd2Source = Path.Combine(_downloadDir, "Disk 2");
|
||||||
|
var cd1Destination = Path.Combine(_albumDir, "CD1");
|
||||||
|
var cd2Destination = Path.Combine(_albumDir, "CD2");
|
||||||
|
|
||||||
|
var cd1Track = NewTrack(_album, cd1Destination, "101 - Foo Track.flac", cd1Source);
|
||||||
|
var cd2Track = NewTrack(_album, cd2Destination, "201 - bonustrackbar.flac", cd2Source);
|
||||||
|
var extraFileName = "foobarextra.nfo";
|
||||||
|
var extraFilePath = Path.Combine(_downloadDir, relativeSourcePath, extraFileName);
|
||||||
|
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir, cd1Track.Path, cd2Track.Path, extraFilePath);
|
||||||
|
|
||||||
|
var decisions = new List<ImportDecision<LocalTrack>>
|
||||||
|
{
|
||||||
|
new ImportDecision<LocalTrack>(cd1Track),
|
||||||
|
new ImportDecision<LocalTrack>(cd2Track),
|
||||||
|
};
|
||||||
|
|
||||||
|
Subject.ImportAlbumExtras(decisions);
|
||||||
|
|
||||||
|
var expectedExtraPath = Path.Combine(_albumDir, relativeDestinationPath, extraFileName);
|
||||||
|
|
||||||
|
Mocker.GetMock<IDiskTransferService>()
|
||||||
|
.Verify(x => x.TransferFile(
|
||||||
|
It.Is<string>(arg => arg.AsOsAgnostic() == extraFilePath.AsOsAgnostic()),
|
||||||
|
It.Is<string>(arg => arg.AsOsAgnostic() == expectedExtraPath.AsOsAgnostic()),
|
||||||
|
It.IsAny<TransferMode>(),
|
||||||
|
It.IsAny<bool>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_copy_multicd_nosubdir_extras_at_destination_root()
|
||||||
|
{
|
||||||
|
var cd1Destination = Path.Combine(_albumDir, "CD1");
|
||||||
|
var cd2Destination = Path.Combine(_albumDir, "CD2");
|
||||||
|
var cd1Track = NewTrack(_album, cd1Destination, "101 - Foo Track.flac", _downloadDir);
|
||||||
|
var cd2Track = NewTrack(_album, cd2Destination, "201 - bonustrackbar.flac", _downloadDir);
|
||||||
|
var extraFile = Path.Combine(_downloadDir, "album.jpg");
|
||||||
|
|
||||||
|
SetupFilesUnderCommonDir(_downloadDir, cd1Track.Path, cd2Track.Path, extraFile);
|
||||||
|
|
||||||
|
var decisions = new List<ImportDecision<LocalTrack>>
|
||||||
|
{
|
||||||
|
new ImportDecision<LocalTrack>(cd1Track),
|
||||||
|
new ImportDecision<LocalTrack>(cd2Track),
|
||||||
|
};
|
||||||
|
Subject.ImportAlbumExtras(decisions);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
var expectedExtraDestination = Path.Combine(_albumDir, "album.jpg");
|
||||||
|
Mocker.GetMock<IDiskTransferService>()
|
||||||
|
.Verify(x => x.TransferFile(
|
||||||
|
It.Is<string>(arg => arg == extraFile),
|
||||||
|
It.Is<string>(arg => arg == expectedExtraDestination),
|
||||||
|
It.IsAny<TransferMode>(),
|
||||||
|
It.IsAny<bool>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set <paramref name="cfg"/> as the current naming configuration for the current test.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cfg">The naming config to return from <see cref="INamingConfigService"/>.</param>
|
||||||
|
private void SetupNamingConfig(NamingConfig cfg)
|
||||||
|
{
|
||||||
|
Mocker.GetMock<INamingConfigService>().Setup(x => x.GetConfig()).Returns(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new track record with a given path and optional source dir for the download.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="album">Track album</param>
|
||||||
|
/// <param name="trackDir">The directory of the track file in the Lidarr library dir.</param>
|
||||||
|
/// <param name="trackFileName">File name.</param>
|
||||||
|
/// <param name="downloadSourceDir">The source dir when the import is from a download. Pass null for track import.</param>
|
||||||
|
private LocalTrack NewTrack(Album album, string trackDir, string trackFileName, string downloadSourceDir = null)
|
||||||
|
{
|
||||||
|
var sourcePath = Path.Combine(downloadSourceDir ?? trackDir, trackFileName);
|
||||||
|
var destinationPath = Path.Combine(trackDir, trackFileName);
|
||||||
|
return new LocalTrack
|
||||||
|
{
|
||||||
|
Artist = album.Artist,
|
||||||
|
Album = album,
|
||||||
|
Release = album.AlbumReleases.Value.First(),
|
||||||
|
Tracks = new List<Track>
|
||||||
|
{
|
||||||
|
new Track()
|
||||||
|
{
|
||||||
|
Album = album,
|
||||||
|
TrackFile = new LazyLoaded<TrackFile>(
|
||||||
|
new TrackFile { Album = _album, AlbumId = _album.Id, Path = destinationPath })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Path = sourcePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupFilesUnderCommonDir(string rootDir, IEnumerable<string> filePath)
|
||||||
|
{
|
||||||
|
SetupFilesUnderCommonDir(rootDir, filePath.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupFilesUnderCommonDir(string rootDir, params string[] filePaths)
|
||||||
|
{
|
||||||
|
Mocker.GetMock<IDiskProvider>()
|
||||||
|
.Setup(x => x.GetFiles(It.Is<string>(arg => arg.AsOsAgnostic() == rootDir.AsOsAgnostic()), true))
|
||||||
|
.Returns(filePaths);
|
||||||
|
|
||||||
|
var fileGroups = filePaths.GroupBy(x => Path.GetDirectoryName(x))
|
||||||
|
.OrderBy(p => p.Key.Length).ToArray();
|
||||||
|
|
||||||
|
for (var i = 0; i < fileGroups.Length; i++)
|
||||||
|
{
|
||||||
|
var currentDir = fileGroups[i].Key;
|
||||||
|
|
||||||
|
// current dir
|
||||||
|
Mocker.GetMock<IDiskProvider>()
|
||||||
|
.Setup(x => x.GetFiles(It.Is<string>(arg => arg.AsOsAgnostic() == currentDir.AsOsAgnostic()), false))
|
||||||
|
.Returns(fileGroups[i]);
|
||||||
|
|
||||||
|
// recursive search
|
||||||
|
var subdirs = fileGroups[i..fileGroups.Length]
|
||||||
|
.Where(grp => grp.Key.StartsWith(currentDir));
|
||||||
|
|
||||||
|
Mocker.GetMock<IDiskProvider>()
|
||||||
|
.Setup(x => x.GetFiles(It.Is<string>(arg => arg.AsOsAgnostic() == currentDir.AsOsAgnostic()), true))
|
||||||
|
.Returns(subdirs.SelectMany(f => f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -80,7 +80,7 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
[Ignore("Pending mapping fixes", Until = "2024-04-20 00:00:00Z")]
|
[Ignore("Pending mapping fixes", Until = "2024-06-20 00:00:00Z")]
|
||||||
public void map_artist_should_work()
|
public void map_artist_should_work()
|
||||||
{
|
{
|
||||||
UseRealHttp();
|
UseRealHttp();
|
||||||
|
@ -159,7 +159,7 @@ namespace NzbDrone.Core.Test.ImportListTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
[Ignore("Pending mapping fixes", Until = "2024-04-20 00:00:00Z")]
|
[Ignore("Pending mapping fixes", Until = "2024-06-20 00:00:00Z")]
|
||||||
public void map_album_should_work()
|
public void map_album_should_work()
|
||||||
{
|
{
|
||||||
UseRealHttp();
|
UseRealHttp();
|
||||||
|
|
|
@ -208,14 +208,14 @@ namespace NzbDrone.Core.Configuration
|
||||||
|
|
||||||
public bool ImportExtraFiles
|
public bool ImportExtraFiles
|
||||||
{
|
{
|
||||||
get { return GetValueBoolean("ImportExtraFiles", false); }
|
get { return GetValueBoolean("ImportExtraFiles", true); }
|
||||||
|
|
||||||
set { SetValue("ImportExtraFiles", value); }
|
set { SetValue("ImportExtraFiles", value); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ExtraFileExtensions
|
public string ExtraFileExtensions
|
||||||
{
|
{
|
||||||
get { return GetValue("ExtraFileExtensions", "srt"); }
|
get { return GetValue("ExtraFileExtensions", "log, cue, nfo, jpg, jpeg, png"); }
|
||||||
|
|
||||||
set { SetValue("ExtraFileExtensions", value); }
|
set { SetValue("ExtraFileExtensions", value); }
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
using FluentMigrator;
|
||||||
|
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Datastore.Migration
|
||||||
|
{
|
||||||
|
[Migration(079)]
|
||||||
|
public class relax_not_null_constraints_extra_files : NzbDroneMigrationBase
|
||||||
|
{
|
||||||
|
protected override void MainDbUpgrade()
|
||||||
|
{
|
||||||
|
Alter.Table("ExtraFiles").AlterColumn("TrackFileId").AsInt32().Nullable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,11 +4,14 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using NLog;
|
using NLog;
|
||||||
using NzbDrone.Common.Disk;
|
using NzbDrone.Common.Disk;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
using NzbDrone.Core.Configuration;
|
using NzbDrone.Core.Configuration;
|
||||||
using NzbDrone.Core.Extras.Files;
|
using NzbDrone.Core.Extras.Files;
|
||||||
|
using NzbDrone.Core.Extras.Others;
|
||||||
using NzbDrone.Core.MediaCover;
|
using NzbDrone.Core.MediaCover;
|
||||||
using NzbDrone.Core.MediaFiles;
|
using NzbDrone.Core.MediaFiles;
|
||||||
using NzbDrone.Core.MediaFiles.Events;
|
using NzbDrone.Core.MediaFiles.Events;
|
||||||
|
using NzbDrone.Core.MediaFiles.TrackImport;
|
||||||
using NzbDrone.Core.Messaging.Events;
|
using NzbDrone.Core.Messaging.Events;
|
||||||
using NzbDrone.Core.Music;
|
using NzbDrone.Core.Music;
|
||||||
using NzbDrone.Core.Parser.Model;
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
@ -18,6 +21,7 @@ namespace NzbDrone.Core.Extras
|
||||||
public interface IExtraService
|
public interface IExtraService
|
||||||
{
|
{
|
||||||
void ImportTrack(LocalTrack localTrack, TrackFile trackFile, bool isReadOnly);
|
void ImportTrack(LocalTrack localTrack, TrackFile trackFile, bool isReadOnly);
|
||||||
|
void ImportAlbumExtras(List<ImportDecision<LocalTrack>> importedTracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExtraService : IExtraService,
|
public class ExtraService : IExtraService,
|
||||||
|
@ -32,6 +36,7 @@ namespace NzbDrone.Core.Extras
|
||||||
private readonly IDiskProvider _diskProvider;
|
private readonly IDiskProvider _diskProvider;
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
private readonly List<IManageExtraFiles> _extraFileManagers;
|
private readonly List<IManageExtraFiles> _extraFileManagers;
|
||||||
|
private readonly AlbumExtraFileManager _albumExtraManager;
|
||||||
private readonly Logger _logger;
|
private readonly Logger _logger;
|
||||||
|
|
||||||
public ExtraService(IMediaFileService mediaFileService,
|
public ExtraService(IMediaFileService mediaFileService,
|
||||||
|
@ -40,6 +45,7 @@ namespace NzbDrone.Core.Extras
|
||||||
IDiskProvider diskProvider,
|
IDiskProvider diskProvider,
|
||||||
IConfigService configService,
|
IConfigService configService,
|
||||||
IEnumerable<IManageExtraFiles> extraFileManagers,
|
IEnumerable<IManageExtraFiles> extraFileManagers,
|
||||||
|
AlbumExtraFileManager albumExtraManager,
|
||||||
Logger logger)
|
Logger logger)
|
||||||
{
|
{
|
||||||
_mediaFileService = mediaFileService;
|
_mediaFileService = mediaFileService;
|
||||||
|
@ -48,9 +54,89 @@ namespace NzbDrone.Core.Extras
|
||||||
_diskProvider = diskProvider;
|
_diskProvider = diskProvider;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
_extraFileManagers = extraFileManagers.OrderBy(e => e.Order).ToList();
|
_extraFileManagers = extraFileManagers.OrderBy(e => e.Order).ToList();
|
||||||
|
_albumExtraManager = albumExtraManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ImportAlbumExtras(List<ImportDecision<LocalTrack>> importedTracks)
|
||||||
|
{
|
||||||
|
if (!_configService.ImportExtraFiles)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackDestinationDirs = importedTracks.SelectMany(x => x.Item.Tracks.Select(t => t.TrackFile.Value.Path))
|
||||||
|
.GroupBy(f => _diskProvider.GetParentFolder(f));
|
||||||
|
|
||||||
|
var sourceDirs = importedTracks.GroupBy(x => _diskProvider.GetParentFolder(x.Item.Path));
|
||||||
|
if (!sourceDirs.Any())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string sourceRoot = null;
|
||||||
|
string destinationRoot = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sourceRoot = GetCommonParent(sourceDirs.Select(x => x.Key));
|
||||||
|
destinationRoot = GetCommonParent(trackDestinationDirs.Select(x => x.Key));
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Common parent dir could not be found, extra files will not be imported", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var extraFileImports = new Dictionary<string, AlbumExtraFileImport>();
|
||||||
|
var trackNames = importedTracks.Select(f => Path.GetFileNameWithoutExtension(f.Item.Path));
|
||||||
|
var wantedExtensions = ExtraFileExtensionsList();
|
||||||
|
|
||||||
|
// extra files in track dirs for multi-CD releases
|
||||||
|
foreach (var sourceDirImports in sourceDirs)
|
||||||
|
{
|
||||||
|
var trackFilePath = sourceDirImports.First()
|
||||||
|
.Item?.Tracks?.FirstOrDefault()?.TrackFile?.Value?.Path;
|
||||||
|
if (trackFilePath == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetDir = sourceDirs.Count() == 1
|
||||||
|
? destinationRoot
|
||||||
|
: _diskProvider.GetParentFolder(trackFilePath);
|
||||||
|
|
||||||
|
var trackDirExtras = FindAlbumExtrasInTrackDirs(sourceDirImports.Key, targetDir, trackNames, wantedExtensions);
|
||||||
|
foreach (var newExtraImport in trackDirExtras)
|
||||||
|
{
|
||||||
|
_ = extraFileImports.TryAdd(newExtraImport.Key, newExtraImport.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceDirs.Count() > 1)
|
||||||
|
{
|
||||||
|
// look for common parent dir
|
||||||
|
var parentDirs = sourceDirs.GroupBy(x => _diskProvider.GetParentFolder(x.Key));
|
||||||
|
|
||||||
|
if (parentDirs.Count() == 1)
|
||||||
|
{
|
||||||
|
var albumDirFiles = _diskProvider.GetFiles(parentDirs.Single().Key, true);
|
||||||
|
var albumExtras = FilterAlbumExtraFiles(albumDirFiles, trackNames, wantedExtensions);
|
||||||
|
|
||||||
|
foreach (var albumExtraFile in albumExtras.Where(x => !extraFileImports.ContainsKey(x)))
|
||||||
|
{
|
||||||
|
var newImport = AlbumExtraFileImport.AtRelativePathFromSource(albumExtraFile, sourceRoot, destinationRoot);
|
||||||
|
extraFileImports.Add(albumExtraFile, newImport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstTrack = importedTracks.First();
|
||||||
|
var artist = firstTrack.Item.Artist;
|
||||||
|
var albumId = firstTrack.Item.Album.Id;
|
||||||
|
|
||||||
|
_albumExtraManager.ImportAlbumExtras(artist, albumId, extraFileImports.Values);
|
||||||
|
}
|
||||||
|
|
||||||
public void ImportTrack(LocalTrack localTrack, TrackFile trackFile, bool isReadOnly)
|
public void ImportTrack(LocalTrack localTrack, TrackFile trackFile, bool isReadOnly)
|
||||||
{
|
{
|
||||||
ImportExtraFiles(localTrack, trackFile, isReadOnly);
|
ImportExtraFiles(localTrack, trackFile, isReadOnly);
|
||||||
|
@ -69,10 +155,7 @@ namespace NzbDrone.Core.Extras
|
||||||
var sourceFolder = _diskProvider.GetParentFolder(sourcePath);
|
var sourceFolder = _diskProvider.GetParentFolder(sourcePath);
|
||||||
var sourceFileName = Path.GetFileNameWithoutExtension(sourcePath);
|
var sourceFileName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||||
var files = _diskProvider.GetFiles(sourceFolder, false);
|
var files = _diskProvider.GetFiles(sourceFolder, false);
|
||||||
|
var wantedExtensions = ExtraFileExtensionsList();
|
||||||
var wantedExtensions = _configService.ExtraFileExtensions.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.Select(e => e.Trim(' ', '.'))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var matchingFilenames = files.Where(f => Path.GetFileNameWithoutExtension(f).StartsWith(sourceFileName, StringComparison.InvariantCultureIgnoreCase)).ToList();
|
var matchingFilenames = files.Where(f => Path.GetFileNameWithoutExtension(f).StartsWith(sourceFileName, StringComparison.InvariantCultureIgnoreCase)).ToList();
|
||||||
var filteredFilenames = new List<string>();
|
var filteredFilenames = new List<string>();
|
||||||
|
@ -176,6 +259,49 @@ namespace NzbDrone.Core.Extras
|
||||||
{
|
{
|
||||||
extraFileManager.MoveFilesAfterRename(artist, trackFiles);
|
extraFileManager.MoveFilesAfterRename(artist, trackFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = _albumExtraManager.MoveFilesAfterRename(artist, message.RenamedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, AlbumExtraFileImport> FindAlbumExtrasInTrackDirs(string sourceDir,
|
||||||
|
string targetDir,
|
||||||
|
IEnumerable<string> trackNames,
|
||||||
|
IEnumerable<string> wantedExtensions)
|
||||||
|
{
|
||||||
|
var newImports = new Dictionary<string, AlbumExtraFileImport>();
|
||||||
|
|
||||||
|
var trackDirFiles = _diskProvider.GetFiles(sourceDir, false);
|
||||||
|
var trackDirExtraFiles = FilterAlbumExtraFiles(trackDirFiles, trackNames, wantedExtensions);
|
||||||
|
foreach (var trackDirExtra in trackDirExtraFiles)
|
||||||
|
{
|
||||||
|
var import = AlbumExtraFileImport.AtDestinationDir(trackDirExtra, targetDir);
|
||||||
|
newImports.Add(trackDirExtra, import);
|
||||||
|
}
|
||||||
|
|
||||||
|
// nested files under track dirs:
|
||||||
|
var subdirFiles = _diskProvider.GetFiles(sourceDir, true);
|
||||||
|
subdirFiles = FilterAlbumExtraFiles(subdirFiles, trackNames, wantedExtensions);
|
||||||
|
|
||||||
|
foreach (var subdirExtra in subdirFiles.Where(x => !newImports.ContainsKey(x)))
|
||||||
|
{
|
||||||
|
var extraFileDirectory = _diskProvider.GetParentFolder(subdirExtra);
|
||||||
|
var relative = sourceDir.GetRelativePath(extraFileDirectory);
|
||||||
|
var dest = Path.Combine(targetDir, relative);
|
||||||
|
var import = AlbumExtraFileImport.AtDestinationDir(subdirExtra, dest);
|
||||||
|
newImports.Add(subdirExtra, import);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newImports;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> FilterAlbumExtraFiles(IEnumerable<string> files,
|
||||||
|
IEnumerable<string> trackFileNames,
|
||||||
|
IEnumerable<string> wantedExtensions)
|
||||||
|
{
|
||||||
|
return files
|
||||||
|
.Where(x =>
|
||||||
|
wantedExtensions.Any(ext => x.EndsWith(ext, StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
&& !trackFileNames.Any(t => t.Equals(Path.GetFileNameWithoutExtension(x), StringComparison.OrdinalIgnoreCase)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TrackFile> GetTrackFiles(int artistId)
|
private List<TrackFile> GetTrackFiles(int artistId)
|
||||||
|
@ -191,5 +317,53 @@ namespace NzbDrone.Core.Extras
|
||||||
|
|
||||||
return trackFiles;
|
return trackFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<string> ExtraFileExtensionsList()
|
||||||
|
{
|
||||||
|
return _configService.ExtraFileExtensions
|
||||||
|
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(e => e.Trim(' ', '.'))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetCommonParent(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
if (paths.Count() == 1)
|
||||||
|
{
|
||||||
|
return paths.Single();
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentDirs = paths.GroupBy(p => _diskProvider.GetParentFolder(p));
|
||||||
|
if (parentDirs.Count() == 1)
|
||||||
|
{
|
||||||
|
return parentDirs.Single().Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// search depth limited to 1+1, parent of parent:
|
||||||
|
var parentOfParent = parentDirs.Select(d => _diskProvider.GetParentFolder(d.Key)).GroupBy(i => i);
|
||||||
|
if (parentOfParent.Count() == 1)
|
||||||
|
{
|
||||||
|
return parentOfParent.Single().Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for shortest path and check if this is the parent dir:
|
||||||
|
var ordered = parentDirs.OrderBy(x => x.Key.Length);
|
||||||
|
|
||||||
|
var commonParent = ordered.First().Key;
|
||||||
|
foreach (var childDir in ordered.Skip(1))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = commonParent.GetRelativePath(childDir.Key);
|
||||||
|
}
|
||||||
|
catch (NotParentException ex)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Unable to find common parent: child path not under parent candidate '{commonParent}'", nameof(paths), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commonParent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,214 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using NLog;
|
||||||
|
using NzbDrone.Common.Disk;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Core.Configuration;
|
||||||
|
using NzbDrone.Core.Extras.Others;
|
||||||
|
using NzbDrone.Core.MediaFiles;
|
||||||
|
using NzbDrone.Core.Music;
|
||||||
|
using NzbDrone.Core.Organizer;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Extras.Files
|
||||||
|
{
|
||||||
|
public class AlbumExtraFileManager
|
||||||
|
{
|
||||||
|
private readonly IConfigService _configService;
|
||||||
|
private readonly INamingConfigService _namingConfigService;
|
||||||
|
private readonly IDiskTransferService _diskTransferService;
|
||||||
|
private readonly IDiskProvider _diskProvider;
|
||||||
|
private readonly IOtherExtraFileService _otherExtraFileService;
|
||||||
|
private readonly Logger _logger;
|
||||||
|
|
||||||
|
private static readonly Regex _albumDirRegex = new Regex(
|
||||||
|
@"{Album.+?Title}.*?\/.*?track",
|
||||||
|
RegexOptions.IgnoreCase,
|
||||||
|
TimeSpan.FromMilliseconds(500));
|
||||||
|
|
||||||
|
public AlbumExtraFileManager(
|
||||||
|
IConfigService configService,
|
||||||
|
INamingConfigService namingConfigService,
|
||||||
|
IDiskTransferService diskTransferService,
|
||||||
|
IDiskProvider diskProvider,
|
||||||
|
IOtherExtraFileService otherExtraFileService,
|
||||||
|
Logger logger)
|
||||||
|
{
|
||||||
|
_configService = configService;
|
||||||
|
_namingConfigService = namingConfigService;
|
||||||
|
_diskTransferService = diskTransferService;
|
||||||
|
_diskProvider = diskProvider;
|
||||||
|
_otherExtraFileService = otherExtraFileService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<ExtraFile> ImportAlbumExtras(Artist artist, int albumId, IEnumerable<AlbumExtraFileImport> extraFileImports)
|
||||||
|
{
|
||||||
|
var namingConfig = _namingConfigService.GetConfig();
|
||||||
|
if (!namingConfig.RenameTracks)
|
||||||
|
{
|
||||||
|
_logger.Debug($"File renaming is deactivated, skipping {extraFileImports.Count()} album extras");
|
||||||
|
return new List<ExtraFile>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumDirInStandardFormat = _albumDirRegex.IsMatch(namingConfig.StandardTrackFormat);
|
||||||
|
if (!albumDirInStandardFormat)
|
||||||
|
{
|
||||||
|
_logger.Debug($"Track template does not include an album dir, skipping {extraFileImports.Count()} album extras");
|
||||||
|
return new List<ExtraFile>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumDirInMultiDiscFormat = _albumDirRegex.IsMatch(namingConfig.MultiDiscTrackFormat);
|
||||||
|
if (!albumDirInMultiDiscFormat)
|
||||||
|
{
|
||||||
|
_logger.Debug($"Multi-disc template does not include an album dir, skipping {extraFileImports.Count()} album extras");
|
||||||
|
return new List<ExtraFile>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = new List<OtherExtraFile>(extraFileImports.Count());
|
||||||
|
foreach (var extraFileImport in extraFileImports)
|
||||||
|
{
|
||||||
|
var file = ImportSingleFile(artist, albumId, extraFileImport.SourcePath, extraFileImport.DestinationPath);
|
||||||
|
result.Add(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
_otherExtraFileService.Upsert(result.ToList());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, $"Failed to import {extraFileImports.Count()} album extra files for artist '{artist.CleanName}'");
|
||||||
|
return new List<ExtraFile>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<ExtraFile> MoveFilesAfterRename(Artist artist, List<RenamedTrackFile> trackFiles)
|
||||||
|
{
|
||||||
|
var extraFiles = _otherExtraFileService.GetFilesByArtist(artist.Id);
|
||||||
|
if (!extraFiles.Any())
|
||||||
|
{
|
||||||
|
return new List<ExtraFile>();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Debug($"Found {extraFiles.Count} extra files for artist '{artist.Name}'");
|
||||||
|
|
||||||
|
var movedFiles = new List<OtherExtraFile>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var albumTracks in trackFiles.GroupBy(x => x.TrackFile.AlbumId))
|
||||||
|
{
|
||||||
|
var albumFiles = MoveAlbumExtraFiles(artist, extraFiles.Where(x => x.AlbumId == albumTracks.Key), albumTracks);
|
||||||
|
_otherExtraFileService.Upsert(albumFiles);
|
||||||
|
|
||||||
|
movedFiles.AddRange(albumFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, $"Moving album extras for artist '{artist.Name}' failed");
|
||||||
|
return new List<ExtraFile>();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Info($"Moved {movedFiles.Count} extra files on rename for '{artist.Name}'");
|
||||||
|
|
||||||
|
return movedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OtherExtraFile ImportSingleFile(Artist artist, int albumId, string sourcePath, string destinationPath)
|
||||||
|
{
|
||||||
|
var transferMode = _configService.CopyUsingHardlinks ? TransferMode.HardLinkOrCopy : TransferMode.Copy;
|
||||||
|
|
||||||
|
if (!sourcePath.PathEquals(destinationPath))
|
||||||
|
{
|
||||||
|
_diskProvider.CreateFolder(_diskProvider.GetParentFolder(destinationPath));
|
||||||
|
_diskTransferService.TransferFile(sourcePath, destinationPath, transferMode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = Path.GetExtension(destinationPath);
|
||||||
|
|
||||||
|
return new OtherExtraFile
|
||||||
|
{
|
||||||
|
ArtistId = artist.Id,
|
||||||
|
AlbumId = albumId,
|
||||||
|
TrackFileId = null,
|
||||||
|
RelativePath = artist.Path.GetRelativePath(destinationPath),
|
||||||
|
Extension = extension,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OtherExtraFile> MoveAlbumExtraFiles(Artist artist, IEnumerable<OtherExtraFile> extraFiles, IGrouping<int, RenamedTrackFile> albumTracks)
|
||||||
|
{
|
||||||
|
var movedFiles = new List<OtherExtraFile>();
|
||||||
|
var previousTrackDirs = albumTracks.GroupBy(x => _diskProvider.GetParentFolder(x.PreviousPath));
|
||||||
|
|
||||||
|
// extra files in track directories should stay together with the tracks:
|
||||||
|
foreach (var dir in previousTrackDirs)
|
||||||
|
{
|
||||||
|
var relativeTrackDir = artist.Path.GetRelativePath(dir.Key);
|
||||||
|
var extrasUnderTrackDir = extraFiles.Where(
|
||||||
|
x => x.RelativePath.StartsWithIgnoreCase(relativeTrackDir));
|
||||||
|
|
||||||
|
var oldRelative = artist.Path.GetRelativePath(_diskProvider.GetParentFolder(dir.First().PreviousPath));
|
||||||
|
var newRelative = artist.Path.GetRelativePath(_diskProvider.GetParentFolder(dir.First().TrackFile.Path));
|
||||||
|
foreach (var extraFile in extrasUnderTrackDir)
|
||||||
|
{
|
||||||
|
var oldFilePath = Path.Combine(artist.Path, extraFile.RelativePath);
|
||||||
|
|
||||||
|
var updatedRelativePath = extraFile.RelativePath.Replace(oldRelative, newRelative);
|
||||||
|
extraFile.RelativePath = updatedRelativePath;
|
||||||
|
|
||||||
|
var newFilePath = Path.Combine(artist.Path, updatedRelativePath);
|
||||||
|
MoveToNewDir(oldFilePath, newFilePath);
|
||||||
|
|
||||||
|
movedFiles.Add(extraFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// move remaining files to new album dir:
|
||||||
|
var remainingExtraFiles = extraFiles.Where(x => !movedFiles.Any(f => f.Id == x.Id));
|
||||||
|
var newTrackDirs = albumTracks.GroupBy(x => _diskProvider.GetParentFolder(x.TrackFile.Path));
|
||||||
|
|
||||||
|
if (remainingExtraFiles.Any()
|
||||||
|
&& previousTrackDirs.Count() > 1
|
||||||
|
&& newTrackDirs.Count() > 1)
|
||||||
|
{
|
||||||
|
var oldParentDir = previousTrackDirs.First().Key.GetParentPath();
|
||||||
|
var newParentDir = newTrackDirs.First().Key.GetParentPath();
|
||||||
|
|
||||||
|
if (previousTrackDirs.All(d => d.Key.GetParentPath() == oldParentDir)
|
||||||
|
&& newTrackDirs.All(d => d.Key.GetParentPath() == newParentDir))
|
||||||
|
{
|
||||||
|
var oldRelative = artist.Path.GetRelativePath(oldParentDir);
|
||||||
|
var newRelative = artist.Path.GetRelativePath(newParentDir);
|
||||||
|
|
||||||
|
foreach (var extraFile in remainingExtraFiles)
|
||||||
|
{
|
||||||
|
var oldPath = Path.Combine(artist.Path, extraFile.RelativePath);
|
||||||
|
|
||||||
|
var newExtraRelativePath = extraFile.RelativePath.Replace(oldRelative, newRelative);
|
||||||
|
var newFilePath = Path.Combine(artist.Path, newExtraRelativePath);
|
||||||
|
|
||||||
|
MoveToNewDir(oldPath, newFilePath);
|
||||||
|
|
||||||
|
extraFile.RelativePath = newExtraRelativePath;
|
||||||
|
movedFiles.Add(extraFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return movedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveToNewDir(string oldFilePath, string newFilePath)
|
||||||
|
{
|
||||||
|
_diskProvider.CreateFolder(_diskProvider.GetParentFolder(newFilePath));
|
||||||
|
_diskProvider.MoveFile(oldFilePath, newFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
using System.IO;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Extras.Others
|
||||||
|
{
|
||||||
|
public class AlbumExtraFileImport
|
||||||
|
{
|
||||||
|
public AlbumExtraFileImport(string sourceFilePath, string destinationFilePath)
|
||||||
|
{
|
||||||
|
SourcePath = sourceFilePath;
|
||||||
|
DestinationPath = destinationFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SourcePath { get; }
|
||||||
|
|
||||||
|
public string DestinationPath { get; }
|
||||||
|
|
||||||
|
public static AlbumExtraFileImport AtDestinationDir(string sourceFilePath, string destinationDir)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(sourceFilePath);
|
||||||
|
var destinationPath = Path.Join(destinationDir, fileName);
|
||||||
|
|
||||||
|
return new AlbumExtraFileImport(sourceFilePath, destinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AlbumExtraFileImport AtRelativePathFromSource(string sourceFilePath, string sourceRootDir, string destinationRootDir)
|
||||||
|
{
|
||||||
|
var relative = sourceRootDir.GetRelativePath(sourceFilePath);
|
||||||
|
var destinationPath = Path.Join(destinationRootDir, relative);
|
||||||
|
|
||||||
|
return new AlbumExtraFileImport(sourceFilePath, destinationPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,13 +3,13 @@
|
||||||
<TargetFrameworks>net6.0</TargetFrameworks>
|
<TargetFrameworks>net6.0</TargetFrameworks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
<PackageReference Include="Dapper" Version="2.0.151" />
|
||||||
<PackageReference Include="Diacritical.Net" Version="1.0.4" />
|
<PackageReference Include="Diacritical.Net" Version="1.0.4" />
|
||||||
<PackageReference Include="Polly" Version="8.3.1" />
|
<PackageReference Include="Polly" Version="8.3.1" />
|
||||||
<PackageReference Include="System.Text.Json" Version="6.0.9" />
|
<PackageReference Include="System.Text.Json" Version="6.0.9" />
|
||||||
<PackageReference Include="System.Memory" Version="4.5.5" />
|
<PackageReference Include="System.Memory" Version="4.5.5" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="6.0.25" />
|
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="6.0.29" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
|
||||||
<PackageReference Include="Servarr.FluentMigrator.Runner" Version="3.3.2.9" />
|
<PackageReference Include="Servarr.FluentMigrator.Runner" Version="3.3.2.9" />
|
||||||
|
@ -23,7 +23,6 @@
|
||||||
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
|
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" />
|
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" />
|
||||||
<PackageReference Include="TagLibSharp-Lidarr" Version="2.2.0.27" />
|
<PackageReference Include="TagLibSharp-Lidarr" Version="2.2.0.27" />
|
||||||
<PackageReference Include="Kveer.XmlRPC" Version="1.2.0" />
|
|
||||||
<PackageReference Include="Npgsql" Version="7.0.6" />
|
<PackageReference Include="Npgsql" Version="7.0.6" />
|
||||||
<PackageReference Include="SpotifyAPI.Web" Version="5.1.1" />
|
<PackageReference Include="SpotifyAPI.Web" Version="5.1.1" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
|
||||||
|
|
|
@ -324,6 +324,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
||||||
var album = _albumService.GetAlbum(albumImport.First().ImportDecision.Item.Album.Id);
|
var album = _albumService.GetAlbum(albumImport.First().ImportDecision.Item.Album.Id);
|
||||||
var artist = albumImport.First().ImportDecision.Item.Artist;
|
var artist = albumImport.First().ImportDecision.Item.Artist;
|
||||||
|
|
||||||
|
if (album != null)
|
||||||
|
{
|
||||||
|
_extraService.ImportAlbumExtras(albumImport.Select(x => x.ImportDecision).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
if (albumImport.Where(e => e.Errors.Count == 0).ToList().Count > 0 && artist != null && album != null)
|
if (albumImport.Where(e => e.Errors.Count == 0).ToList().Count > 0 && artist != null && album != null)
|
||||||
{
|
{
|
||||||
_eventAggregator.PublishEvent(new AlbumImportedEvent(
|
_eventAggregator.PublishEvent(new AlbumImportedEvent(
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using NLog;
|
using NLog;
|
||||||
using NzbDrone.Common.Disk;
|
using NzbDrone.Common.Disk;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
using NzbDrone.Common.Instrumentation.Extensions;
|
using NzbDrone.Common.Instrumentation.Extensions;
|
||||||
using NzbDrone.Core.MediaFiles;
|
using NzbDrone.Core.MediaFiles;
|
||||||
using NzbDrone.Core.Messaging.Commands;
|
using NzbDrone.Core.Messaging.Commands;
|
||||||
|
@ -56,6 +57,12 @@ namespace NzbDrone.Core.Music
|
||||||
{
|
{
|
||||||
_logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", artist.Name, sourcePath, destinationPath);
|
_logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", artist.Name, sourcePath, destinationPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourcePath.PathEquals(destinationPath))
|
||||||
|
{
|
||||||
|
_logger.ProgressInfo("{0} is already in the specified location '{1}'.", artist, destinationPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<OutputType>Library</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="6.0.25" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="6.0.29" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Lidarr.Api.V1\Lidarr.Api.V1.csproj" />
|
<ProjectReference Include="..\Lidarr.Api.V1\Lidarr.Api.V1.csproj" />
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
||||||
<PackageReference Include="Moq" Version="4.16.1" />
|
<PackageReference Include="Moq" Version="4.16.1" />
|
||||||
<PackageReference Include="NLog" Version="5.2.0" />
|
<PackageReference Include="NLog" Version="5.2.0" />
|
||||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||||
<PackageReference Include="RestSharp" Version="106.15.0" />
|
<PackageReference Include="RestSharp" Version="106.15.0" />
|
||||||
<PackageReference Include="RestSharp.Serializers.SystemTextJson" Version="106.15.0" />
|
<PackageReference Include="RestSharp.Serializers.SystemTextJson" Version="106.15.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" />
|
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" />
|
||||||
|
|
|
@ -103,7 +103,7 @@ namespace NzbDrone.Test.Common
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void TestBaseSetup()
|
public void TestBaseSetup()
|
||||||
{
|
{
|
||||||
GetType().IsPublic.Should().BeTrue("All Test fixtures should be public to work in mono.");
|
GetType().Should().Match(t => t.IsPublic || t.IsNestedPublic, "All Test fixtures should be public to work in mono.");
|
||||||
|
|
||||||
LogManager.ReconfigExistingLoggers();
|
LogManager.ReconfigExistingLoggers();
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue