New: Use Goodreads directly, allow multiple editions of a book (new DB required)

pull/38/head
ta264 4 years ago
parent d83d2548e5
commit 45d49117ca

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import AuthorImage from './AuthorImage';
const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gAAAC5AgMAAADG9/24AAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QkRBgAc5PUQ8QAAB7tJREFUeNrtnb1rHEsMwMdjArtrUrpf3uMg2cOl+5QpXWRvjQkXlyHVK8MVYXFl3F+/GAzrNfdSuXkQnH9ie/Oqw32aFM6bkTQfd85rHrwi0qjJJql+pxlJo9FISv0XqX8mKkmSJEmSJEmSJEmSJEmSJEmS5NeVYrh7HDqJ5DeY23kQB64/u7zW91amzgXqvRqjfGYvarnfxqncuaQlf72Zxv4gSOluudOfjRy1Hzh1L+nPj+KU3jh0MWr3Sp87dDFq98An/msmg3zPW/aFR6/vRaBPglML6Mci0H0g92MYfrjvRgJ5TrDvhuFyGIZv9NdTOev93dDry1Z59mM56/2hU8WZcefFjZgVT/Z90SlEV9VKio3HeKYZLLSGII6WPP+oBv3ZwkL3iE4JG/ZRjUYb16mAripUO/c4Hl3bd/juCF19FuHeJn6nK90VBh0s3SjBvcFW/wTrvfBa118EbHY8qmMOtmgdupoKOLTvAmOH1k059LKAX+Qra/TncExH4hcBXV3Zf/+Dv5Vb43cfod/wt3PLsN5VF6HDiudt56IbB23Ql5/oZ8A7CfZWbgF61sap62XdlOZTZ2rF3c7ltNW1+f7LuDez/uc6uDfO8dwkbPXcKF8vPS9sds527pC2utH0uCb0Jmz2t8wNvN3q2jj4ntDJna94m3g4sb7HH8Gue0RH4Je8z61g4GGr79Wz1qHjbi94m/jcH1IOccsj+lt/sOFr4m0EPz+3XyNueURvSmfn+Ebx1rctMvOxU8foqOwVa+9mfdvHDH+DdYQOxAesvZslvc/wo4vQZy6eY+vdrCVrut/AyTW9CWvO3E1ra4L6i5Fxoka7MDan45vTOmx2MPGc0QH52Tb6kTPxXNGtW5/Tnl+oGP2N/dstY8eeO2M+bqM3zvVxRT+gS8Vdl5/z6CaCzfx/c41o3pP2+030UzrAHDNGv8d4FvMVAf1I4c07V/QlnNsyG9TN23ID/ZjObnO+6CZmydS+y8oG9Dfk2Gd80WcW3Rv44e5bZOLtD8EUHbRq0V0F/DAMn8fQ2UUv2UayEMxplaFvM0G7QR/ufqBcmH/gG85Z9BM8rMO5bdiUDu4ceaLvUjqWfJveQm8hpvnKFv1jOLxUTtkoBWf0nIK5Cd6wO207dAznTlmjH+K630KvuKPbOHYffJsOexykx0iWJ7pNRf+ttEXvW1U49F7ZbJ26RHSe6ehn5NRGMPBVQAfP12EQf8QZPTMxXac30M1RxtaWXLBFn3j0eRsHNIBuCycLtqfWCQZrBcZ0W+gVhXts0RtEXyit4zDOoE/N/5yNzNF3oVB0A93I6ise7bijr1XwbYiurySg7xjfpp+g375mjj5D9HYD3fr6YvkKcxU80Q8duvVt2+gjob/ljX7yFH1a80dvDfpia8GXqp3Wr1XJXevljvFt2QZ6a6tJ2Gvd2Pa8Xuu2iNB7o++r+lUJlQas93oOl04be73Ut7y1Ts4tn/0EfaxZOzcKaXIsqNiI4QtE5x7N7Z08RZ9CKpb38cWs9V26b4sDWURnr3W9foq+gpM8a3S4V+rhKUCcqrBXTufMUxU2QWUTkFtZGls3pjgnqJ4FdB1lZAd8/qMEZGRtAlJvJqONb2syzuj2CuK+1YU5reg2CzGNyqq6fpOpku8VRI7lchX+7SIy8NdQSaKn3K8bsWJOlZGBX2Ed0bUIdBXftJpzG1h2vjetWFqgHXrhczTWtx2h8llXVTytI4FHnaes0bGMqNhC1+jUXmFMx7iCaq6qy4HqxR7dFfMUtc34LQCVDPppcM13Kie5RmTGJYNUKBraDL57xEKalS0UzTgXiiqn1X3fRxR0bBf6TLEvD4aKkslTdO5F4fQUIHseo5ugfrShewbWjvsriHxT65WAByAHVCT7u0fvoKDC+rZClZyf/eSAngUTj1pfCXjshU/8smDiPTr7J374sDPDfI1Hd4cX1g874TmvRc+30U8V9+e88Ig7U6V26Ofk21rzg1ScH3Hj033bTXIZab2iGG6PdWOaQzLx7cShQ0FFfdxqlfFu2DAhxw4vf5zWV3hYZ96m47l7u0+DMAgdu1Dxbs4St+Rx6MbAwzIveLfk8Y2Y9J5HP1tij3DmjZii9lujQy9GXO/M22/5pmvUVdWiV3RkYd50zbfaI7Vb9GmDD0C4t9qLGiy+JPTVKT4A4d5gMY866N4i+p9KuUM767aavpkqLnmDDl10S8W/mSq20O3I3n8x6AXufP4tdKlxcpkZ4HN43FZ0mT3GCmicTO2ysW2uVXmFWp/yb5cdN0kHrb/ADwFN0uPW+ICOt+0SWuPjkW2tPTr+CjcSphjiGIzWodPQGwljMGj4Se/Q0bfJGH4Sj7zx6DJG3tCgo4HQoYiukDHoKB5vZdCtb9MrIUM7DyK169Zu+mEUMtQsjLJrtT7vWl2IGWXnBxjaFwFnraQBhmFs5dDqi66SNLYyGlY6XMgaVip4RG00mHh2LWwwcRhHPVsJG0cdhpDProQNIQ+j52e30kbPa19B5T64H9Wfqn0mTelB7Y04pWMFfCQf5JDTjYOTuSClu5QUSa9EyU0gf5BF7iaP2zxdq6TJjUydgxTD3aPvm5wkSZIkSZIkSZIkSZIkSZIk+Z9kv/534U2+Uyf0XwP9H83PZBlAqkdjAAAAAElFTkSuQmCC';
const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AQMAAAD7QlAQAAAABlBMVEUnJychISEIs8G4AAAEFklEQVRYw+2YMdPOQBDH95KQDEVSMKOj1FFQJx9BQc0nkeuUvoLS+AQ6MQqlRu8xCjpUgsjK7iXz2t1LMsbo8i/ey5vfc3e7m7tk9+DQoUOHDpGe3bu7hS8BwJ117BoAOLfOb/Hf62s4EY1VNrcPVvjNua1WuJ/b8xqoeR3sqFkllx8+AYAra9PniDg1ydr07cT7FQMy6k7ycQMKgJr5F4BrhvI9ZA3xCDU8fJggs9gBXJ35acX8lil74CPmO5w1xhwoIMVFMQcqKCfynH3soLLuEfkB4O5TBArDPZlH05ZkYMxBigyJDEyseylHFjjK4CzPyS4IE3gTgIxuAyulHzbG/as0PYsifM24X8/TA19Vxn2efjagNwFoHE2/GDAKpm86HE2AfMrmLQbqADnI2bzFQPv8y7NlM7naORU+uid+62X4xJg0V6PC1+KfvvSghWMgnh0cVIArCO694Ib+qWR4HQ257F9oRxu+L2FpzK3h7D5vPwqA5k1OPOwA4iaAOYWnZM4XPhPYT3eWDXriX4sHROjpskF7cC2eBHfUdVjeDw6/4Uk9oHqEz18DH9se8IvgCdQDBS/oLUxcPcB24mnAv+jfXvCMOdwI9jNXDxiJp9w9DCd4Afgdz96fF5GGk3xSCFBHw+gF4PAz9SQCwE7K5UGculJHGuTdKPun+IYHrafAUPfPKJdP4OhL7ErDuf9jfnXn6Gu6+Kj654EPKQIG7iu5PMLacGPO7Qf0EOMvx3LhhRh/5l+GOsahnPkw4Mw7sXzLedzxV+DvscsMZ8X51W0Olp/+5P7qIPlLPMEWP+3z5G94rXinuen/RWzAbe6g7hVvRX/DO8FdjMPB9+O3yD5fwf1fc72+/jcfN/cHRPZPJva/7q/27z9zlPyVfL9Abrgv/oW/Nvyx5vL9rbl5f78R/I3iTnP7fRH83QjVDpfCb4Kr71uxz1FzkN9nxfX32XKVHyj+BfweV/mJkM5Pdnkpsc6PfK64BynDM8lTiU1+l+LPP2iLUJj8sj5z3uaXgMPZFDY/rQDHs/rLTRxMfkwx4mX4hPLjaza/TgIfI/l1xvl5y/wT5+dSCd8rmXf8W2/qgx5S5rRYvAMlri+Ic2MKME9FCdQT/wJ8Ga1vSnzE+Z3l06REJi7qI1VfOXw0xusrCPVZ+6aP12dFqO/qN6d4fZeF+rB804X6sInXl/lrT1vBFtAu1KcuCfWpi9e33VLfJjZAS33ckvlZpH4uedu2nOcWhleiPr9peLFT32fyfGD7fMGBlf/jfCLZOd8oIrw6q4/o2jogzlc2z2fAW8w2nwvd3eqp0YXxCcdiS1HzRC8fw2ezJjvHVtn2tPbhqnOzTgNp1/kdv6pV7ig4RQOruuDBCax1+94dOHTo0KFDk34DoJynpPus3GIAAAAASUVORK5CYII=';
function AuthorBanner(props) {
return (

@ -9,7 +9,7 @@ function findImage(images, coverType) {
function getUrl(image, coverType, size) {
if (image) {
// Remove protocol
let url = image.url.replace(/^https?:/, '');
let url = image.url;
url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);

@ -6,6 +6,7 @@ import TextTruncate from 'react-text-truncate';
import formatBytes from 'Utilities/Number/formatBytes';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import stripHtml from 'Utilities/String/stripHtml';
import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import fonts from 'Styles/Variables/fonts';
import HeartRating from 'Components/HeartRating';
@ -166,7 +167,6 @@ class AuthorDetails extends Component {
overview,
links,
images,
authorType,
alternateTitles,
tags,
isSaving,
@ -206,7 +206,6 @@ class AuthorDetails extends Component {
} = this.state;
const continuing = status === 'continuing';
const endedString = authorType === 'Person' ? 'Deceased' : 'Ended';
let bookFilesCountMessage = 'No book files';
@ -458,7 +457,7 @@ class AuthorDetails extends Component {
/>
<span className={styles.qualityProfileName}>
{continuing ? 'Continuing' : endedString}
{continuing ? 'Continuing' : 'Deceased'}
</span>
</Label>
@ -515,7 +514,7 @@ class AuthorDetails extends Component {
<div className={styles.overview}>
<TextTruncate
line={Math.floor(125 / (defaultFontSize * lineHeight))}
text={overview.replace(/<[^>]*>?/gm, '')}
text={stripHtml(overview)}
/>
</div>
</div>
@ -697,9 +696,8 @@ AuthorDetails.propTypes = {
statistics: PropTypes.object.isRequired,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
authorType: PropTypes.string,
status: PropTypes.string.isRequired,
overview: PropTypes.string.isRequired,
overview: PropTypes.string,
links: PropTypes.arrayOf(PropTypes.object).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,

@ -226,7 +226,7 @@ AuthorDetailsSeries.propTypes = {
onSortPress: PropTypes.func.isRequired,
onMonitorBookPress: PropTypes.func.isRequired,
uiSettings: PropTypes.object.isRequired,
authorMonitored: PropTypes.object.isRequired
authorMonitored: PropTypes.bool.isRequired
};
export default AuthorDetailsSeries;

@ -73,7 +73,6 @@ class BookRow extends Component {
title,
position,
ratings,
disambiguation,
isSaving,
authorMonitored,
titleSlug,
@ -124,7 +123,6 @@ class BookRow extends Component {
<BookTitleLink
titleSlug={titleSlug}
title={title}
disambiguation={disambiguation}
/>
</TableRowCell>
);
@ -208,7 +206,6 @@ BookRow.propTypes = {
title: PropTypes.string.isRequired,
position: PropTypes.string,
ratings: PropTypes.object.isRequired,
disambiguation: PropTypes.string,
titleSlug: PropTypes.string.isRequired,
isSaving: PropTypes.bool,
authorMonitored: PropTypes.bool.isRequired,

@ -4,6 +4,7 @@ import TextTruncate from 'react-text-truncate';
import { icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import stripHtml from 'Utilities/String/stripHtml';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
@ -113,7 +114,8 @@ class AuthorIndexOverview extends Component {
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
height: `${posterHeight}px`,
objectFit: 'contain'
};
const contentHeight = getContentHeight(rowHeight, isSmallScreen);
@ -203,7 +205,7 @@ class AuthorIndexOverview extends Component {
>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
text={stripHtml(overview)}
/>
</Link>

@ -110,9 +110,9 @@ class AuthorIndexPoster extends Component {
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
height: `${posterHeight}px`,
objectFit: 'contain'
};
elementStyle.objectFit = 'contain';
return (
<div className={styles.container}>

@ -82,7 +82,6 @@ class AuthorIndexRow extends Component {
status,
authorName,
titleSlug,
authorType,
qualityProfile,
metadataProfile,
nextBook,
@ -134,7 +133,6 @@ class AuthorIndexRow extends Component {
<AuthorStatusCell
key={name}
className={styles[name]}
authorType={authorType}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
@ -184,17 +182,6 @@ class AuthorIndexRow extends Component {
);
}
if (name === 'authorType') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{authorType}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell
@ -421,7 +408,6 @@ AuthorIndexRow.propTypes = {
status: PropTypes.string.isRequired,
authorName: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
authorType: PropTypes.string,
qualityProfile: PropTypes.object.isRequired,
metadataProfile: PropTypes.object.isRequired,
nextBook: PropTypes.object,

@ -8,15 +8,12 @@ import styles from './AuthorStatusCell.css';
function AuthorStatusCell(props) {
const {
className,
authorType,
monitored,
status,
component: Component,
...otherProps
} = props;
const endedString = authorType === 'Person' ? 'Deceased' : 'Ended';
return (
<Component
className={className}
@ -31,7 +28,7 @@ function AuthorStatusCell(props) {
<Icon
className={styles.statusIcon}
name={status === 'ended' ? icons.AUTHOR_ENDED : icons.AUTHOR_CONTINUING}
title={status === 'ended' ? endedString : 'Continuing'}
title={status === 'ended' ? 'Deceased' : 'Continuing'}
/>
</Component>
);
@ -39,7 +36,6 @@ function AuthorStatusCell(props) {
AuthorStatusCell.propTypes = {
className: PropTypes.string.isRequired,
authorType: PropTypes.string,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
component: PropTypes.elementType

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import AuthorImage from 'Author/AuthorImage';
const coverPlaceholder = '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=';
const coverPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AQMAAAD7QlAQAAAABlBMVEUnJychISEIs8G4AAAEFklEQVRYw+2YMdPOQBDH95KQDEVSMKOj1FFQJx9BQc0nkeuUvoLS+AQ6MQqlRu8xCjpUgsjK7iXz2t1LMsbo8i/ey5vfc3e7m7tk9+DQoUOHDpGe3bu7hS8BwJ117BoAOLfOb/Hf62s4EY1VNrcPVvjNua1WuJ/b8xqoeR3sqFkllx8+AYAra9PniDg1ydr07cT7FQMy6k7ycQMKgJr5F4BrhvI9ZA3xCDU8fJggs9gBXJ35acX8lil74CPmO5w1xhwoIMVFMQcqKCfynH3soLLuEfkB4O5TBArDPZlH05ZkYMxBigyJDEyseylHFjjK4CzPyS4IE3gTgIxuAyulHzbG/as0PYsifM24X8/TA19Vxn2efjagNwFoHE2/GDAKpm86HE2AfMrmLQbqADnI2bzFQPv8y7NlM7naORU+uid+62X4xJg0V6PC1+KfvvSghWMgnh0cVIArCO694Ib+qWR4HQ257F9oRxu+L2FpzK3h7D5vPwqA5k1OPOwA4iaAOYWnZM4XPhPYT3eWDXriX4sHROjpskF7cC2eBHfUdVjeDw6/4Uk9oHqEz18DH9se8IvgCdQDBS/oLUxcPcB24mnAv+jfXvCMOdwI9jNXDxiJp9w9DCd4Afgdz96fF5GGk3xSCFBHw+gF4PAz9SQCwE7K5UGculJHGuTdKPun+IYHrafAUPfPKJdP4OhL7ErDuf9jfnXn6Gu6+Kj654EPKQIG7iu5PMLacGPO7Qf0EOMvx3LhhRh/5l+GOsahnPkw4Mw7sXzLedzxV+DvscsMZ8X51W0Olp/+5P7qIPlLPMEWP+3z5G94rXinuen/RWzAbe6g7hVvRX/DO8FdjMPB9+O3yD5fwf1fc72+/jcfN/cHRPZPJva/7q/27z9zlPyVfL9Abrgv/oW/Nvyx5vL9rbl5f78R/I3iTnP7fRH83QjVDpfCb4Kr71uxz1FzkN9nxfX32XKVHyj+BfweV/mJkM5Pdnkpsc6PfK64BynDM8lTiU1+l+LPP2iLUJj8sj5z3uaXgMPZFDY/rQDHs/rLTRxMfkwx4mX4hPLjaza/TgIfI/l1xvl5y/wT5+dSCd8rmXf8W2/qgx5S5rRYvAMlri+Ic2MKME9FCdQT/wJ8Ga1vSnzE+Z3l06REJi7qI1VfOXw0xusrCPVZ+6aP12dFqO/qN6d4fZeF+rB804X6sInXl/lrT1vBFtAu1KcuCfWpi9e33VLfJjZAS33ckvlZpH4uedu2nOcWhleiPr9peLFT32fyfGD7fMGBlf/jfCLZOd8oIrw6q4/o2jogzlc2z2fAW8w2nwvd3eqp0YXxCcdiS1HzRC8fw2ezJjvHVtn2tPbhqnOzTgNp1/kdv6pV7ig4RQOruuDBCax1+94dOHTo0KFDk34DoJynpPus3GIAAAAASUVORK5CYII=';
function BookCover(props) {
return (

@ -7,6 +7,7 @@ import TextTruncate from 'react-text-truncate';
import formatBytes from 'Utilities/Number/formatBytes';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import stripHtml from 'Utilities/String/stripHtml';
import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import fonts from 'Styles/Variables/fonts';
import HeartRating from 'Components/HeartRating';
@ -18,6 +19,7 @@ import Tooltip from 'Components/Tooltip/Tooltip';
import BookCover from 'Book/BookCover';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
// import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import EditBookModalConnector from 'Book/Edit/EditBookModalConnector';
import DeleteBookModal from 'Book/Delete/DeleteBookModal';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
@ -44,28 +46,6 @@ function getFanartUrl(images) {
}
}
function formatDuration(timeSpan) {
const duration = moment.duration(timeSpan);
const hours = duration.get('hours');
const minutes = duration.get('minutes');
let hoursText = 'Hours';
let minText = 'Minutes';
if (minutes === 1) {
minText = 'Minute';
}
if (hours === 0) {
return `${minutes} ${minText}`;
}
if (hours === 1) {
hoursText = 'Hour';
}
return `${hours} ${hoursText} ${minutes} ${minText}`;
}
function getExpandedState(newState) {
return {
allExpanded: newState.allSelected,
@ -85,6 +65,7 @@ class BookDetails extends Component {
this.state = {
isOrganizeModalOpen: false,
isRetagModalOpen: false,
isEditBookModalOpen: false,
isDeleteBookModalOpen: false,
allExpanded: false,
allCollapsed: false,
@ -112,8 +93,17 @@ class BookDetails extends Component {
this.setState({ isRetagModalOpen: false });
}
onEditBookPress = () => {
this.setState({ isEditBookModalOpen: true });
}
onEditBookModalClose = () => {
this.setState({ isEditBookModalOpen: false });
}
onDeleteBookPress = () => {
this.setState({
isEditBookModalOpen: false,
isDeleteBookModalOpen: true
});
}
@ -153,8 +143,7 @@ class BookDetails extends Component {
id,
titleSlug,
title,
disambiguation,
duration,
pageCount,
overview,
statistics = {},
monitored,
@ -179,6 +168,7 @@ class BookDetails extends Component {
const {
isOrganizeModalOpen,
// isRetagModalOpen,
isEditBookModalOpen,
isDeleteBookModalOpen,
allExpanded,
allCollapsed,
@ -222,6 +212,12 @@ class BookDetails extends Component {
<PageToolbarSeparator />
<PageToolbarButton
label="Edit"
iconName={icons.EDIT}
onPress={this.onEditBookPress}
/>
<PageToolbarButton
label="Delete"
iconName={icons.DELETE}
@ -272,8 +268,9 @@ class BookDetails extends Component {
</div>
<div className={styles.title}>
{title}{disambiguation ? ` (${disambiguation})` : ''}
{title}
</div>
</div>
<div className={styles.bookNavigationButtons}>
@ -306,9 +303,9 @@ class BookDetails extends Component {
<div className={styles.details}>
<div>
{
!!duration &&
!!pageCount &&
<span className={styles.duration}>
{formatDuration(duration)}
{`${pageCount} pages`}
</span>
}
@ -397,7 +394,7 @@ class BookDetails extends Component {
<div className={styles.overview}>
<TextTruncate
line={Math.floor(125 / (defaultFontSize * lineHeight))}
text={overview.replace(/<[^>]*>?/gm, '')}
text={stripHtml(overview)}
/>
</div>
</div>
@ -488,6 +485,14 @@ class BookDetails extends Component {
{/* onModalClose={this.onRetagModalClose} */}
{/* /> */}
<EditBookModalConnector
isOpen={isEditBookModalOpen}
bookId={id}
authorId={author.id}
onModalClose={this.onEditBookModalClose}
onDeleteAuthorPress={this.onDeleteBookPress}
/>
<DeleteBookModal
isOpen={isDeleteBookModalOpen}
bookId={id}
@ -505,8 +510,7 @@ BookDetails.propTypes = {
id: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
duration: PropTypes.number,
pageCount: PropTypes.number,
overview: PropTypes.string,
statistics: PropTypes.object.isRequired,
releaseDate: PropTypes.string.isRequired,

@ -98,6 +98,10 @@ const mapDispatchToProps = {
toggleBooksMonitored
};
function getMonitoredEditions(props) {
return _.map(_.filter(props.editions, { monitored: true }), 'id').sort();
}
class BookDetailsConnector extends Component {
componentDidMount() {
@ -106,10 +110,8 @@ class BookDetailsConnector extends Component {
}
componentDidUpdate(prevProps) {
// If the id has changed we need to clear the books
// files and fetch from the server.
if (prevProps.id !== this.props.id) {
if (!_.isEqual(getMonitoredEditions(prevProps), getMonitoredEditions(this.props)) ||
(prevProps.anyReleaseOk === false && this.props.anyReleaseOk === true)) {
this.unpopulate();
this.populate();
}

@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import EditBookModalContentConnector from './EditBookModalContentConnector';
function EditBookModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditBookModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditBookModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditBookModal;

@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditBookModal from './EditBookModal';
const mapDispatchToProps = {
clearPendingChanges
};
class EditBookModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'books' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<EditBookModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditBookModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(undefined, mapDispatchToProps)(EditBookModalConnector);

@ -0,0 +1,133 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
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 Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
class EditBookModalContent extends Component {
//
// Listeners
onSavePress = () => {
const {
onSavePress
} = this.props;
onSavePress(false);
}
//
// Render
render() {
const {
title,
authorName,
statistics,
item,
isSaving,
onInputChange,
onModalClose,
...otherProps
} = this.props;
const {
monitored,
anyEditionOk,
editions
} = item;
const hasFile = statistics ? statistics.bookFileCount : 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Edit - {authorName} - {title}
</ModalHeader>
<ModalBody>
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>Monitored</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="monitored"
helpText="Readarr will search for and download book"
{...monitored}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Automatically Switch Edition</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="anyEditionOk"
helpText="Readarr will automatically switch to the edition best matching downloaded files"
{...anyEditionOk}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Edition</FormLabel>
<FormInputGroup
type={inputTypes.BOOK_EDITION_SELECT}
name="editions"
helpText="Change edition for this book"
isDisabled={anyEditionOk.value && hasFile}
bookEditions={editions}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerButton
isSpinning={isSaving}
onPress={this.onSavePress}
>
Save
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
EditBookModalContent.propTypes = {
bookId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
authorName: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
item: PropTypes.object.isRequired,
isSaving: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditBookModalContent;

@ -0,0 +1,98 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
import createBookSelector from 'Store/Selectors/createBookSelector';
import createAuthorSelector from 'Store/Selectors/createAuthorSelector';
import { setBookValue, saveBook } from 'Store/Actions/bookActions';
import EditBookModalContent from './EditBookModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.books,
createBookSelector(),
createAuthorSelector(),
(bookState, book, author) => {
const {
isSaving,
saveError,
pendingChanges
} = bookState;
const bookSettings = _.pick(book, [
'monitored',
'anyEditionOk',
'editions'
]);
const settings = selectSettings(bookSettings, pendingChanges, saveError);
return {
title: book.title,
authorName: author.authorName,
bookType: book.bookType,
statistics: book.statistics,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
const mapDispatchToProps = {
dispatchSetBookValue: setBookValue,
dispatchSaveBook: saveBook
};
class EditBookModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.dispatchSetBookValue({ name, value });
}
onSavePress = () => {
this.props.dispatchSaveBook({
id: this.props.bookId
});
}
//
// Render
render() {
return (
<EditBookModalContent
{...this.props}
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
/>
);
}
}
EditBookModalContentConnector.propTypes = {
bookId: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
dispatchSetBookValue: PropTypes.func.isRequired,
dispatchSaveBook: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditBookModalContentConnector);

@ -0,0 +1,93 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import titleCase from 'Utilities/String/titleCase';
import SelectInput from './SelectInput';
function createMapStateToProps() {
return createSelector(
(state, { bookEditions }) => bookEditions,
(bookEditions) => {
const values = _.map(bookEditions.value, (bookEdition) => {
let value = `${bookEdition.title}`;
if (bookEdition.disambiguation) {
value = `${value} (${titleCase(bookEdition.disambiguation)})`;
}
const extras = [];
if (bookEdition.language) {
extras.push(bookEdition.language);
}
if (bookEdition.publisher) {
extras.push(bookEdition.publisher);
}
if (bookEdition.isbn13) {
extras.push(bookEdition.isbn13);
}
if (bookEdition.format) {
extras.push(bookEdition.format);
}
if (bookEdition.pageCount > 0) {
extras.push(`${bookEdition.pageCount}p`);
}
if (extras) {
value = `${value} [${extras.join(', ')}]`;
}
return {
key: bookEdition.foreignEditionId,
value
};
});
const sortedValues = _.orderBy(values, ['value']);
const value = _.find(bookEditions.value, { monitored: true }).foreignEditionId;
return {
values: sortedValues,
value
};
}
);
}
class BookEditionSelectInputConnector extends Component {
//
// Listeners
onChange = ({ name, value }) => {
const {
bookEditions
} = this.props;
const updatedEditions = _.map(bookEditions.value, (e) => ({ ...e, monitored: false }));
_.find(updatedEditions, { foreignEditionId: value }).monitored = true;
this.props.onChange({ name, value: updatedEditions });
}
render() {
return (
<SelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
BookEditionSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
bookEditions: PropTypes.object
};
export default connect(createMapStateToProps)(BookEditionSelectInputConnector);

@ -1,70 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import titleCase from 'Utilities/String/titleCase';
import SelectInput from './SelectInput';
function createMapStateToProps() {
return createSelector(
(state, { bookReleases }) => bookReleases,
(bookReleases) => {
const values = _.map(bookReleases.value, (bookRelease) => {
return {
key: bookRelease.foreignReleaseId,
value: `${bookRelease.title}` +
`${bookRelease.disambiguation ? ' (' : ''}${titleCase(bookRelease.disambiguation)}${bookRelease.disambiguation ? ')' : ''}` +
`, ${bookRelease.mediumCount} med, ${bookRelease.bookCount} books` +
`${bookRelease.country.length > 0 ? ', ' : ''}${bookRelease.country}` +
`${bookRelease.format ? ', [' : ''}${bookRelease.format}${bookRelease.format ? ']' : ''}`
};
});
const sortedValues = _.orderBy(values, ['value']);
const value = _.find(bookReleases.value, { monitored: true }).foreignReleaseId;
return {
values: sortedValues,
value
};
}
);
}
class BookReleaseSelectInputConnector extends Component {
//
// Listeners
onChange = ({ name, value }) => {
const {
bookReleases
} = this.props;
const updatedReleases = _.map(bookReleases.value, (e) => ({ ...e, monitored: false }));
_.find(updatedReleases, { foreignReleaseId: value }).monitored = true;
this.props.onChange({ name, value: updatedReleases });
}
render() {
return (
<SelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
BookReleaseSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
bookReleases: PropTypes.object
};
export default connect(createMapStateToProps)(BookReleaseSelectInputConnector);

@ -15,7 +15,7 @@ import PasswordInput from './PasswordInput';
import PathInputConnector from './PathInputConnector';
import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
import MetadataProfileSelectInputConnector from './MetadataProfileSelectInputConnector';
import BookReleaseSelectInputConnector from './BookReleaseSelectInputConnector';
import BookEditionSelectInputConnector from './BookEditionSelectInputConnector';
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
import SeriesTypeSelectInput from './SeriesTypeSelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
@ -66,8 +66,8 @@ function getComponent(type) {
case inputTypes.METADATA_PROFILE_SELECT:
return MetadataProfileSelectInputConnector;
case inputTypes.BOOK_RELEASE_SELECT:
return BookReleaseSelectInputConnector;
case inputTypes.BOOK_EDITION_SELECT:
return BookEditionSelectInputConnector;
case inputTypes.ROOT_FOLDER_SELECT:
return RootFolderSelectInputConnector;

@ -11,7 +11,7 @@ export const PASSWORD = 'password';
export const PATH = 'path';
export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
export const METADATA_PROFILE_SELECT = 'metadataProfileSelect';
export const BOOK_RELEASE_SELECT = 'bookReleaseSelect';
export const BOOK_EDITION_SELECT = 'bookEditionSelect';
export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
export const SELECT = 'select';
export const SERIES_TYPE_SELECT = 'authorTypeSelect';
@ -33,7 +33,7 @@ export const all = [
PATH,
QUALITY_PROFILE_SELECT,
METADATA_PROFILE_SELECT,
BOOK_RELEASE_SELECT,
BOOK_EDITION_SELECT,
ROOT_FOLDER_SELECT,
SELECT,
SERIES_TYPE_SELECT,

@ -0,0 +1,37 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectEditionModalContentConnector from './SelectEditionModalContentConnector';
class SelectEditionModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectEditionModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectEditionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectEditionModal;

@ -0,0 +1,18 @@
.modalBody {
composes: modalBody from '~Components/Modal/ModalBody.css';
display: flex;
flex: 1 1 auto;
flex-direction: column;
}
.filterInput {
composes: input from '~Components/Form/TextInput.css';
flex: 0 0 auto;
margin-bottom: 20px;
}
.scroller {
flex: 1 1 auto;
}

@ -0,0 +1,93 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import { scrollDirections } from 'Helpers/Props';
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 SelectEditionRow from './SelectEditionRow';
import Alert from 'Components/Alert';
import styles from './SelectEditionModalContent.css';
const columns = [
{
name: 'book',
label: 'Book',
isVisible: true
},
{
name: 'edition',
label: 'Edition',
isVisible: true
}
];
class SelectEditionModalContent extends Component {
//
// Render
render() {
const {
books,
onEditionSelect,
onModalClose,
...otherProps
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Manual Import - Select Edition
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<Alert>
Overrriding an edition here will <b>disable automatic edition selection</b> for that book in future.
</Alert>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
books.map((item) => {
return (
<SelectEditionRow
key={item.book.id}
matchedEditionId={item.matchedEditionId}
columns={columns}
onEditionSelect={onEditionSelect}
{...item.book}
/>
);
})
}
</TableBody>
</Table>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectEditionModalContent.propTypes = {
books: PropTypes.arrayOf(PropTypes.object).isRequired,
onEditionSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectEditionModalContent;

@ -0,0 +1,63 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
updateInteractiveImportItem,
saveInteractiveImportItem
} from 'Store/Actions/interactiveImportActions';
import SelectEditionModalContent from './SelectEditionModalContent';
function createMapStateToProps() {
return {};
}
const mapDispatchToProps = {
updateInteractiveImportItem,
saveInteractiveImportItem
};
class SelectEditionModalContentConnector extends Component {
//
// Listeners
onEditionSelect = (bookId, editionId) => {
const ids = this.props.importIdsByBook[bookId];
ids.forEach((id) => {
this.props.updateInteractiveImportItem({
id,
editionId,
disableReleaseSwitching: true,
tracks: [],
rejections: []
});
});
this.props.saveInteractiveImportItem({ id: ids });
this.props.onModalClose(true);
}
//
// Render
render() {
return (
<SelectEditionModalContent
{...this.props}
onEditionSelect={this.onEditionSelect}
/>
);
}
}
SelectEditionModalContentConnector.propTypes = {
importIdsByBook: PropTypes.object.isRequired,
books: PropTypes.arrayOf(PropTypes.object).isRequired,
updateInteractiveImportItem: PropTypes.func.isRequired,
saveInteractiveImportItem: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectEditionModalContentConnector);

@ -0,0 +1,125 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import FormInputGroup from 'Components/Form/FormInputGroup';
import titleCase from 'Utilities/String/titleCase';
class SelectEditionRow extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.onEditionSelect(parseInt(name), parseInt(value));
}
//
// Render
render() {
const {
id,
matchedEditionId,
title,
disambiguation,
editions,
columns
} = this.props;
const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title;
const values = _.map(editions, (bookEdition) => {
let value = `${bookEdition.title}`;
if (bookEdition.disambiguation) {
value = `${value} (${titleCase(bookEdition.disambiguation)})`;
}
const extras = [];
if (bookEdition.language) {
extras.push(bookEdition.language);
}
if (bookEdition.publisher) {
extras.push(bookEdition.publisher);
}
if (bookEdition.isbn13) {
extras.push(bookEdition.isbn13);
}
if (bookEdition.format) {
extras.push(bookEdition.format);
}
if (bookEdition.pageCount > 0) {
extras.push(`${bookEdition.pageCount}p`);
}
if (extras) {
value = `${value} [${extras.join(', ')}]`;
}
return {
key: bookEdition.id,
value
};
});
const sortedValues = _.orderBy(values, ['value']);
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'book') {
return (
<TableRowCell key={name}>
{extendedTitle}
</TableRowCell>
);
}
if (name === 'edition') {
return (
<TableRowCell key={name}>
<FormInputGroup
type={inputTypes.SELECT}
name={id.toString()}
values={sortedValues}
value={matchedEditionId}
onChange={this.onInputChange}
/>
</TableRowCell>
);
}
return null;
})
}
</TableRow>
);
}
}
SelectEditionRow.propTypes = {
id: PropTypes.number.isRequired,
matchedEditionId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
disambiguation: PropTypes.string.isRequired,
editions: PropTypes.arrayOf(PropTypes.object).isRequired,
onEditionSelect: PropTypes.func.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default SelectEditionRow;

@ -23,6 +23,7 @@ import TableBody from 'Components/Table/TableBody';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectAuthorModal from 'InteractiveImport/Author/SelectAuthorModal';
import SelectBookModal from 'InteractiveImport/Book/SelectBookModal';
import SelectEditionModal from 'InteractiveImport/Edition/SelectEditionModal';
import ConfirmImportModal from 'InteractiveImport/Confirmation/ConfirmImportModal';
import InteractiveImportRow from './InteractiveImportRow';
import styles from './InteractiveImportModalContent.css';
@ -79,6 +80,7 @@ const importModeOptions = [
const SELECT = 'select';
const AUTHOR = 'author';
const BOOK = 'book';
const EDITION = 'edition';
const QUALITY = 'quality';
const replaceExistingFilesOptions = {
@ -112,7 +114,7 @@ class InteractiveImportModalContent extends Component {
const selectedItems = _.filter(this.props.items, (x) => _.includes(selectedIds, x.id));
const inconsistent = _(selectedItems)
.map((x) => ({ bookId: x.book ? x.book.id : 0, releaseId: x.bookReleaseId }))
.map((x) => ({ bookId: x.book ? x.book.id : 0, releaseId: x.EditionId }))
.groupBy('bookId')
.mapValues((book) => _(book).groupBy((x) => x.releaseId).values().value().length)
.values()
@ -273,6 +275,7 @@ class InteractiveImportModalContent extends Component {
const bulkSelectOptions = [
{ key: SELECT, value: 'Select...', disabled: true },
{ key: BOOK, value: 'Select Book' },
{ key: EDITION, value: 'Select Edition' },
{ key: QUALITY, value: 'Select Quality' }
];
@ -469,6 +472,13 @@ class InteractiveImportModalContent extends Component {
onModalClose={this.onSelectModalClose}
/>
<SelectEditionModal
isOpen={selectModalOpen === EDITION}
importIdsByBook={_.chain(items).filter((x) => x.album).groupBy((x) => x.book.id).mapValues((x) => x.map((y) => y.id)).value()}
books={_.chain(items).filter((x) => x.book).keyBy((x) => x.book.id).mapValues((x) => ({ matchedEditionId: x.editionId, book: x.book })).values().value()}
onModalClose={this.onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === QUALITY}
ids={selectedIds}

@ -128,6 +128,7 @@ class InteractiveImportModalContentConnector extends Component {
const {
author,
book,
editionId,
quality,
disableReleaseSwitching
} = item;
@ -151,6 +152,7 @@ class InteractiveImportModalContentConnector extends Component {
path: item.path,
authorId: author.id,
bookId: book.id,
editionId,
quality,
downloadId: this.props.downloadId,
disableReleaseSwitching

@ -141,10 +141,11 @@ class AddNewItem extends Component {
);
} else if (item.book) {
const book = item.book;
const edition = book.editions[0];
return (
<AddNewBookSearchResultConnector
key={item.id}
isExistingBook={'id' in book && book.id !== 0}
isExistingBook={'id' in edition && edition.id !== 0}
isExistingAuthor={'id' in book.author && book.author.id !== 0}
{...book}
/>

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import stripHtml from 'Utilities/String/stripHtml';
import { icons, kinds, sizes } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
@ -69,12 +70,10 @@ class AddNewAuthorSearchResult extends Component {
render() {
const {
foreignAuthorId,
goodreadsId,
titleSlug,
authorName,
year,
disambiguation,
authorType,
status,
overview,
ratings,
@ -89,7 +88,7 @@ class AddNewAuthorSearchResult extends Component {
const linkProps = isExistingAuthor ? { to: `/author/${titleSlug}` } : { onPress: this.onPress };
const endedString = authorType === 'Person' ? 'Deceased' : 'Ended';
const endedString = 'Deceased';
const height = calculateHeight(230, isSmallScreen);
@ -143,7 +142,7 @@ class AddNewAuthorSearchResult extends Component {
<Link
className={styles.mbLink}
to={`https://goodreads.com/author/show/${goodreadsId}`}
to={`https://goodreads.com/author/show/${foreignAuthorId}`}
onPress={this.onMBLinkPress}
>
<Icon
@ -155,17 +154,13 @@ class AddNewAuthorSearchResult extends Component {
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
iconSize={13}
/>
</Label>
{
authorType ?
ratings.votes > 0 ?
<Label size={sizes.LARGE}>
{authorType}
<HeartRating
rating={ratings.value}
iconSize={13}
/>
</Label> :
null
}
@ -191,7 +186,7 @@ class AddNewAuthorSearchResult extends Component {
<TextTruncate
truncateText="…"
line={Math.floor(height / (defaultFontSize * lineHeight))}
text={overview}
text={stripHtml(overview)}
/>
</div>
</div>
@ -214,12 +209,10 @@ class AddNewAuthorSearchResult extends Component {
AddNewAuthorSearchResult.propTypes = {
foreignAuthorId: PropTypes.string.isRequired,
goodreadsId: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
authorName: PropTypes.string.isRequired,
year: PropTypes.number,
disambiguation: PropTypes.string,
authorType: PropTypes.string,
status: PropTypes.string.isRequired,
overview: PropTypes.string,
ratings: PropTypes.object.isRequired,

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import stripHtml from 'Utilities/String/stripHtml';
import { kinds } from 'Helpers/Props';
import SpinnerButton from 'Components/Link/SpinnerButton';
import CheckInput from 'Components/Form/CheckInput';
@ -93,7 +94,7 @@ class AddNewBookModalContent extends Component {
<TextTruncate
truncateText="…"
line={8}
text={overview}
text={stripHtml(overview)}
/>
</div> :
null

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import stripHtml from 'Utilities/String/stripHtml';
import { icons, sizes } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
@ -70,7 +71,6 @@ class AddNewBookSearchResult extends Component {
render() {
const {
foreignBookId,
goodreadsId,
titleSlug,
title,
releaseDate,
@ -79,6 +79,7 @@ class AddNewBookSearchResult extends Component {
ratings,
images,
author,
editions,
isExistingBook,
isExistingAuthor,
isSmallScreen
@ -132,7 +133,7 @@ class AddNewBookSearchResult extends Component {
<Link
className={styles.mbLink}
to={`https://goodreads.com/book/show/${goodreadsId}`}
to={`https://goodreads.com/book/show/${editions[0].foreignEditionId}`}
onPress={this.onMBLinkPress}
>
<Icon
@ -185,7 +186,7 @@ class AddNewBookSearchResult extends Component {
<TextTruncate
truncateText="…"
line={Math.floor(height / (defaultFontSize * lineHeight))}
text={overview}
text={stripHtml(overview)}
/>
</div>
</div>
@ -209,7 +210,6 @@ class AddNewBookSearchResult extends Component {
AddNewBookSearchResult.propTypes = {
foreignBookId: PropTypes.string.isRequired,
goodreadsId: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
releaseDate: PropTypes.string,
@ -217,6 +217,7 @@ AddNewBookSearchResult.propTypes = {
overview: PropTypes.string,
ratings: PropTypes.object.isRequired,
author: PropTypes.object,
editions: PropTypes.arrayOf(PropTypes.object).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isExistingBook: PropTypes.bool.isRequired,
isExistingAuthor: PropTypes.bool.isRequired,

@ -32,8 +32,7 @@ function EditMetadataProfileModalContent(props) {
const {
id,
name,
minRating,
minRatingCount,
minPopularity,
skipMissingDate,
skipMissingIsbn,
skipPartsAndSets,
@ -73,27 +72,15 @@ function EditMetadataProfileModalContent(props) {
</FormGroup>
<FormGroup>
<FormLabel>Minimum Rating</FormLabel>
<FormLabel>Minimum Popularity</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minRating"
{...minRating}
name="minPopularity"
{...minPopularity}
helpText="Popularity is average rating * number of votes"
isFloat={true}
min={0}
max={5}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Minimum Number of Ratings</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minRatingCount"
{...minRatingCount}
min={0}
onChange={onInputChange}
/>
</FormGroup>

@ -73,12 +73,6 @@ export const defaultState = {
isVisible: true,
isModifiable: false
},
{
name: 'authorType',
label: 'Type',
isSortable: true,
isVisible: true
},
{
name: 'qualityProfileId',
label: 'Quality Profile',

@ -158,7 +158,7 @@ export const actionHandlers = handleThunks({
}).request;
promise.done((data) => {
data.releases = itemToAdd.book.releases;
data.editions = itemToAdd.book.editions;
itemToAdd.book = data;
dispatch(batchActions([
updateItem({ section: 'authors', ...data.author }),

@ -0,0 +1,13 @@
function stripHtml(html) {
if (!html) {
return html;
}
const fiddled = html.replace(/<br\/>/g, ' ');
const doc = new DOMParser().parseFromString(fiddled, 'text/html');
const text = doc.body.textContent || '';
return text.replace(/([;,.])([^\s.])/g, '$1 $2').replace(/\s{2,}/g, ' ').replace(/s+…/g, '…');
}
export default stripHtml;

@ -186,7 +186,8 @@ namespace NzbDrone.Common.Http.Dispatchers
webRequest.TransferEncoding = header.Value;
break;
case "User-Agent":
throw new NotSupportedException("User-Agent other than Readarr not allowed.");
webRequest.UserAgent = header.Value;
break;
case "Proxy-Connection":
throw new NotImplementedException();
default:

@ -16,6 +16,7 @@ namespace NzbDrone.Core.Test.ArtistStatsTests
{
private Author _artist;
private Book _album;
private Edition _edition;
private BookFile _trackFile;
[SetUp]
@ -32,10 +33,16 @@ namespace NzbDrone.Core.Test.ArtistStatsTests
.BuildNew();
Db.Insert(_album);
_edition = Builder<Edition>.CreateNew()
.With(e => e.BookId = _album.Id)
.With(e => e.Monitored = true)
.BuildNew();
Db.Insert(_edition);
_trackFile = Builder<BookFile>.CreateNew()
.With(e => e.Author = _artist)
.With(e => e.Book = _album)
.With(e => e.BookId == _album.Id)
.With(e => e.Edition = _edition)
.With(e => e.EditionId == _edition.Id)
.With(e => e.Quality = new QualityModel(Quality.MP3_320))
.BuildNew();
}

@ -51,10 +51,23 @@ namespace NzbDrone.Core.Test.Datastore
Db.InsertMany(albums);
var editions = new List<Edition>();
foreach (var album in albums)
{
editions.Add(
Builder<Edition>.CreateNew()
.With(v => v.Id = 0)
.With(v => v.BookId = album.Id)
.With(v => v.ForeignEditionId = "test" + album.Id)
.Build());
}
Db.InsertMany(editions);
var trackFiles = Builder<BookFile>.CreateListOfSize(1)
.All()
.With(v => v.Id = 0)
.With(v => v.BookId = albums[0].Id)
.With(v => v.EditionId = editions[0].Id)
.With(v => v.Quality = new QualityModel())
.BuildListOfNew();
@ -97,40 +110,15 @@ namespace NzbDrone.Core.Test.Datastore
var db = Mocker.Resolve<IDatabase>();
var files = MediaFileRepository.Query(db,
new SqlBuilder()
.Join<BookFile, Book>((t, a) => t.BookId == a.Id)
.Join<BookFile, Edition>((t, a) => t.EditionId == a.Id)
.Join<Edition, Book>((e, b) => e.BookId == b.Id)
.Join<Book, Author>((album, artist) => album.AuthorMetadataId == artist.AuthorMetadataId)
.Join<Author, AuthorMetadata>((a, m) => a.AuthorMetadataId == m.Id));
Assert.IsNotEmpty(files);
foreach (var file in files)
{
Assert.IsTrue(file.Book.IsLoaded);
Assert.IsTrue(file.Author.IsLoaded);
Assert.IsTrue(file.Author.Value.Metadata.IsLoaded);
}
}
[Test]
public void should_lazy_load_tracks_if_not_joined_to_trackfile()
{
var db = Mocker.Resolve<IDatabase>();
var files = db.QueryJoined<BookFile, Book, Author, AuthorMetadata>(
new SqlBuilder()
.Join<BookFile, Book>((t, a) => t.BookId == a.Id)
.Join<Book, Author>((album, artist) => album.AuthorMetadataId == artist.AuthorMetadataId)
.Join<Author, AuthorMetadata>((a, m) => a.AuthorMetadataId == m.Id),
(file, album, artist, metadata) =>
{
file.Book = album;
file.Author = artist;
file.Author.Value.Metadata = metadata;
return file;
});
Assert.IsNotEmpty(files);
foreach (var file in files)
{
Assert.IsTrue(file.Book.IsLoaded);
Assert.IsTrue(file.Edition.IsLoaded);
Assert.IsTrue(file.Author.IsLoaded);
Assert.IsTrue(file.Author.Value.Metadata.IsLoaded);
}

@ -37,7 +37,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
_trackFiles = Builder<BookFile>.CreateListOfSize(3)
.All()
.With(t => t.BookId = _albums.First().Id)
.With(t => t.EditionId = _albums.First().Id)
.BuildList();
Mocker.GetMock<IMediaFileService>()

@ -36,7 +36,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
Path = "/My.Artist.S01E01.mp3",
Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)),
DateAdded = DateTime.Now,
BookId = 1
EditionId = 1
};
_secondFile =
new BookFile
@ -45,7 +45,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
Path = "/My.Artist.S01E02.mp3",
Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)),
DateAdded = DateTime.Now,
BookId = 2
EditionId = 2
};
var singleAlbumList = new List<Book> { new Book { Id = 1 } };

@ -18,12 +18,12 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers
{
var trackFile = Builder<BookFile>.CreateNew()
.With(h => h.Quality = new QualityModel())
.With(h => h.BookId = 1)
.With(h => h.EditionId = 1)
.BuildNew();
Db.Insert(trackFile);
Subject.Clean();
AllStoredModels[0].BookId.Should().Be(0);
AllStoredModels[0].EditionId.Should().Be(0);
}
}
}

@ -43,7 +43,6 @@ namespace NzbDrone.Core.Test.ImportListTests
.Returns<int>(x => Builder<Book>
.CreateListOfSize(1)
.TheFirst(1)
.With(b => b.GoodreadsId = x)
.With(b => b.ForeignBookId = x.ToString())
.BuildList());

@ -18,8 +18,9 @@ namespace NzbDrone.Core.Test.MediaCoverTests
[TestFixture]
public class MediaCoverServiceFixture : CoreTest<MediaCoverService>
{
private Author _artist;
private Book _album;
private Author _author;
private Book _book;
private Edition _edition;
private HttpResponse _httpResponse;
[SetUp]
@ -27,14 +28,20 @@ namespace NzbDrone.Core.Test.MediaCoverTests
{
Mocker.SetConstant<IAppFolderInfo>(new AppFolderInfo(Mocker.Resolve<IStartupContext>()));
_artist = Builder<Author>.CreateNew()
_author = Builder<Author>.CreateNew()
.With(v => v.Id = 2)
.With(v => v.Metadata.Value.Images = new List<MediaCover.MediaCover> { new MediaCover.MediaCover(MediaCoverTypes.Poster, "") })
.Build();
_album = Builder<Book>.CreateNew()
.With(v => v.Id = 4)
_edition = Builder<Edition>.CreateNew()
.With(v => v.Id = 8)
.With(v => v.Images = new List<MediaCover.MediaCover> { new MediaCover.MediaCover(MediaCoverTypes.Cover, "") })
.With(v => v.Monitored = true)
.Build();
_book = Builder<Book>.CreateNew()
.With(v => v.Id = 4)
.With(v => v.Editions = new List<Edition> { _edition })
.Build();
_httpResponse = new HttpResponse(null, new HttpHeader(), "");
@ -110,7 +117,7 @@ namespace NzbDrone.Core.Test.MediaCoverTests
Subject.ConvertToLocalUrls(6, MediaCoverEntity.Book, covers);
covers.Single().Url.Should().Be("/MediaCover/Albums/6/disc" + extension + "?lastWrite=1234");
covers.Single().Url.Should().Be("/MediaCover/Books/6/disc" + extension + "?lastWrite=1234");
}
[TestCase(".png")]
@ -140,13 +147,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests
Mocker.GetMock<IBookService>()
.Setup(v => v.GetBooksByAuthor(It.IsAny<int>()))
.Returns(new List<Book> { _album });
.Returns(new List<Book> { _book });
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.FileExists(It.IsAny<string>()))
.Returns(true);
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist));
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author));
Mocker.GetMock<IImageResizer>()
.Verify(v => v.Resize(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Exactly(2));
@ -161,13 +168,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests
Mocker.GetMock<IBookService>()
.Setup(v => v.GetBooksByAuthor(It.IsAny<int>()))
.Returns(new List<Book> { _album });
.Returns(new List<Book> { _book });
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.FileExists(It.IsAny<string>()))
.Returns(false);
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist));
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author));
Mocker.GetMock<IImageResizer>()
.Verify(v => v.Resize(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Exactly(2));
@ -186,13 +193,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests
Mocker.GetMock<IBookService>()
.Setup(v => v.GetBooksByAuthor(It.IsAny<int>()))
.Returns(new List<Book> { _album });
.Returns(new List<Book> { _book });
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.GetFileSize(It.IsAny<string>()))
.Returns(1000);
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist));
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author));
Mocker.GetMock<IImageResizer>()
.Verify(v => v.Resize(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never());
@ -211,13 +218,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests
Mocker.GetMock<IBookService>()
.Setup(v => v.GetBooksByAuthor(It.IsAny<int>()))
.Returns(new List<Book> { _album });
.Returns(new List<Book> { _book });
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.GetFileSize(It.IsAny<string>()))
.Returns(0);
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist));
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author));
Mocker.GetMock<IImageResizer>()
.Verify(v => v.Resize(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Exactly(2));
@ -236,13 +243,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests
Mocker.GetMock<IBookService>()
.Setup(v => v.GetBooksByAuthor(It.IsAny<int>()))
.Returns(new List<Book> { _album });
.Returns(new List<Book> { _book });
Mocker.GetMock<IImageResizer>()
.Setup(v => v.Resize(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
.Throws<ApplicationException>();
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist));
Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author));
Mocker.GetMock<IImageResizer>()
.Verify(v => v.Resize(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Exactly(2));

@ -315,8 +315,12 @@ namespace NzbDrone.Core.Test.MediaFiles.AudioTagServiceFixture
.With(x => x.Author = artist)
.Build();
var file = Builder<BookFile>.CreateNew()
var edition = Builder<Edition>.CreateNew()
.With(x => x.Book = album)
.Build();
var file = Builder<BookFile>.CreateNew()
.With(x => x.Edition = edition)
.With(x => x.Author = artist)
.Build();

@ -15,6 +15,7 @@ using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
@ -43,6 +44,14 @@ namespace NzbDrone.Core.Test.MediaFiles
.With(e => e.Author = artist)
.Build();
var edition = Builder<Edition>.CreateNew()
.With(e => e.Book = album)
.Build();
var rootFolder = Builder<RootFolder>.CreateNew()
.With(r => r.IsCalibreLibrary = false)
.Build();
_rejectedDecisions.Add(new ImportDecision<LocalBook>(new LocalBook(), new Rejection("Rejected!")));
_rejectedDecisions.Add(new ImportDecision<LocalBook>(new LocalBook(), new Rejection("Rejected!")));
_rejectedDecisions.Add(new ImportDecision<LocalBook>(new LocalBook(), new Rejection("Rejected!")));
@ -52,6 +61,7 @@ namespace NzbDrone.Core.Test.MediaFiles
{
Author = artist,
Book = album,
Edition = edition,
Path = Path.Combine(artist.Path, "Alien Ant Farm - 01 - Pilot.mp3"),
Quality = new QualityModel(Quality.MP3_320),
FileTrackInfo = new ParsedTrackInfo
@ -69,6 +79,10 @@ namespace NzbDrone.Core.Test.MediaFiles
Mocker.GetMock<IMediaFileService>()
.Setup(s => s.GetFilesByBook(It.IsAny<int>()))
.Returns(new List<BookFile>());
Mocker.GetMock<IRootFolderService>()
.Setup(s => s.GetBestRootFolder(It.IsAny<string>()))
.Returns(rootFolder);
}
[Test]
@ -152,6 +166,7 @@ namespace NzbDrone.Core.Test.MediaFiles
{
Author = fileDecision.Item.Author,
Book = fileDecision.Item.Book,
Edition = fileDecision.Item.Edition,
Path = @"C:\Test\Music\Alien Ant Farm\Alien Ant Farm - 01 - Pilot.mp3".AsOsAgnostic(),
Quality = new QualityModel(Quality.MP3_320),
Size = 80.Megabytes()

@ -16,6 +16,7 @@ namespace NzbDrone.Core.Test.MediaFiles
{
private Author _artist;
private Book _album;
private Edition _edition;
[SetUp]
public void Setup()
@ -37,12 +38,20 @@ namespace NzbDrone.Core.Test.MediaFiles
.Build();
Db.Insert(_album);
_edition = Builder<Edition>.CreateNew()
.With(a => a.Id = 0)
.With(a => a.BookId = _album.Id)
.Build();
Db.Insert(_edition);
var files = Builder<BookFile>.CreateListOfSize(10)
.All()
.With(c => c.Id = 0)
.With(c => c.Quality = new QualityModel(Quality.MP3_320))
.TheFirst(5)
.With(c => c.BookId = _album.Id)
.With(c => c.EditionId = _edition.Id)
.TheRest()
.With(c => c.EditionId = 0)
.TheFirst(1)
.With(c => c.Path = @"C:\Test\Path\Artist\somefile1.flac".AsOsAgnostic())
.TheNext(1)
@ -109,8 +118,8 @@ namespace NzbDrone.Core.Test.MediaFiles
var file = Subject.GetFileWithPath(@"C:\Test\Path\Artist\somefile2.flac".AsOsAgnostic());
file.Should().NotBeNull();
file.Book.IsLoaded.Should().BeTrue();
file.Book.Value.Should().NotBeNull();
file.Edition.IsLoaded.Should().BeTrue();
file.Edition.Value.Should().NotBeNull();
file.Author.IsLoaded.Should().BeTrue();
file.Author.Value.Should().NotBeNull();
}
@ -122,7 +131,7 @@ namespace NzbDrone.Core.Test.MediaFiles
var files = Subject.GetFilesByBook(_album.Id);
VerifyEagerLoaded(files);
files.Should().OnlyContain(c => c.BookId == _album.Id);
files.Should().OnlyContain(c => c.EditionId == _album.Id);
}
private void VerifyData()
@ -136,8 +145,8 @@ namespace NzbDrone.Core.Test.MediaFiles
{
foreach (var file in files)
{
file.Book.IsLoaded.Should().BeTrue();
file.Book.Value.Should().NotBeNull();
file.Edition.IsLoaded.Should().BeTrue();
file.Edition.Value.Should().NotBeNull();
file.Author.IsLoaded.Should().BeTrue();
file.Author.Value.Should().NotBeNull();
file.Author.Value.Metadata.IsLoaded.Should().BeTrue();
@ -149,8 +158,8 @@ namespace NzbDrone.Core.Test.MediaFiles
{
foreach (var file in files)
{
file.Book.IsLoaded.Should().BeFalse();
file.Book.Value.Should().BeNull();
file.Edition.IsLoaded.Should().BeFalse();
file.Edition.Value.Should().BeNull();
file.Author.IsLoaded.Should().BeFalse();
file.Author.Value.Should().BeNull();
}
@ -162,7 +171,7 @@ namespace NzbDrone.Core.Test.MediaFiles
Db.Delete(_album);
Subject.DeleteFilesByBook(_album.Id);
Db.All<BookFile>().Where(x => x.BookId == _album.Id).Should().HaveCount(0);
Db.All<BookFile>().Where(x => x.EditionId == _album.Id).Should().HaveCount(0);
}
}
}

@ -213,7 +213,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests
Path = "C:\\file2.avi".AsOsAgnostic(),
Size = 10,
Modified = _lastWrite,
Book = new LazyLoaded<Book>(null)
Edition = new LazyLoaded<Edition>(null)
}
});
@ -239,7 +239,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests
Path = "C:\\file2.avi".AsOsAgnostic(),
Size = 10,
Modified = _lastWrite,
Book = Builder<Book>.CreateNew().Build()
Edition = Builder<Edition>.CreateNew().Build()
}
});

@ -24,9 +24,9 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests
_trackFiles = Builder<BookFile>.CreateListOfSize(3)
.TheFirst(2)
.With(f => f.BookId = _album.Id)
.With(f => f.EditionId = _album.Id)
.TheNext(1)
.With(f => f.BookId = 0)
.With(f => f.EditionId = 0)
.Build().ToList();
}

@ -43,15 +43,15 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests
.Build();
Mocker.GetMock<IBuildFileNames>()
.Setup(s => s.BuildBookFileName(It.IsAny<Author>(), It.IsAny<Book>(), It.IsAny<BookFile>(), null, null))
.Setup(s => s.BuildBookFileName(It.IsAny<Author>(), It.IsAny<Edition>(), It.IsAny<BookFile>(), null, null))
.Returns("File Name");
Mocker.GetMock<IBuildFileNames>()
.Setup(s => s.BuildBookFilePath(It.IsAny<Author>(), It.IsAny<Book>(), It.IsAny<string>(), It.IsAny<string>()))
.Setup(s => s.BuildBookFilePath(It.IsAny<Author>(), It.IsAny<Edition>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns(@"C:\Test\Music\Artist\Album\File Name.mp3".AsOsAgnostic());
Mocker.GetMock<IBuildFileNames>()
.Setup(s => s.BuildBookPath(It.IsAny<Author>(), It.IsAny<Book>()))
.Setup(s => s.BuildBookPath(It.IsAny<Author>()))
.Returns(@"C:\Test\Music\Artist\Album".AsOsAgnostic());
var rootFolder = @"C:\Test\Music\".AsOsAgnostic();

@ -15,7 +15,7 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Aggregation.Aggregators
[TestFixture]
public class AggregateFilenameInfoFixture : CoreTest<AggregateFilenameInfo>
{
private LocalAlbumRelease GivenTracks(List<string> files, string root)
private LocalEdition GivenTracks(List<string> files, string root)
{
var tracks = files.Select(x => new LocalBook
{
@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Aggregation.Aggregators
TrackNumbers = new[] { 0 },
}
}).ToList();
return new LocalAlbumRelease(tracks);
return new LocalEdition(tracks);
}
private void VerifyData(LocalBook track, string artist, string title, int trackNum, int disc)

@ -19,7 +19,7 @@ using NzbDrone.Core.MediaFiles.BookImport.Aggregation.Aggregators;
using NzbDrone.Core.MediaFiles.BookImport.Identification;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.MetadataSource.SkyHook;
using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Metadata;
@ -32,7 +32,7 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Identification
public class IdentificationServiceFixture : DbTest
{
private AuthorService _authorService;
private AddArtistService _addAuthorService;
private AddAuthorService _addAuthorService;
private RefreshAuthorService _refreshArtistService;
private IdentificationService _Subject;
@ -59,10 +59,10 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Identification
Mocker.SetConstant<IMediaFileService>(Mocker.Resolve<MediaFileService>());
Mocker.SetConstant<IConfigService>(Mocker.Resolve<IConfigService>());
Mocker.SetConstant<IProvideAuthorInfo>(Mocker.Resolve<SkyHookProxy>());
Mocker.SetConstant<IProvideBookInfo>(Mocker.Resolve<SkyHookProxy>());
Mocker.SetConstant<IProvideAuthorInfo>(Mocker.Resolve<GoodreadsProxy>());
Mocker.SetConstant<IProvideBookInfo>(Mocker.Resolve<GoodreadsProxy>());
_addAuthorService = Mocker.Resolve<AddArtistService>();
_addAuthorService = Mocker.Resolve<AddAuthorService>();
Mocker.SetConstant<IRefreshBookService>(Mocker.Resolve<RefreshBookService>());
_refreshArtistService = Mocker.Resolve<RefreshAuthorService>();
@ -73,11 +73,11 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Identification
Mocker.SetConstant<ICandidateService>(Mocker.Resolve<CandidateService>());
// set up the augmenters
List<IAggregate<LocalAlbumRelease>> aggregators = new List<IAggregate<LocalAlbumRelease>>
List<IAggregate<LocalEdition>> aggregators = new List<IAggregate<LocalEdition>>
{
Mocker.Resolve<AggregateFilenameInfo>()
};
Mocker.SetConstant<IEnumerable<IAggregate<LocalAlbumRelease>>>(aggregators);
Mocker.SetConstant<IEnumerable<IAggregate<LocalEdition>>>(aggregators);
Mocker.SetConstant<IAugmentingService>(Mocker.Resolve<AugmentingService>());
_Subject = Mocker.Resolve<IdentificationService>();

@ -1,192 +0,0 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.MediaFiles.BookImport.Identification;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MediaFiles.BookImport.Identification
{
[TestFixture]
public class MunkresFixture : TestBase
{
// 2d arrays don't play nicely with attributes
public void RunTest(double[,] costMatrix, double expectedCost)
{
var m = new Munkres(costMatrix);
m.Run();
m.Cost.Should().Be(expectedCost);
}
[Test]
public void MunkresSquareTest1()
{
var c = new double[,]
{
{ 1, 2, 3 },
{ 2, 4, 6 },
{ 3, 6, 9 }
};
RunTest(c, 10);
}
[Test]
public void MunkresSquareTest2()
{
var c = new double[,]
{
{ 400, 150, 400 },
{ 400, 450, 600 },
{ 300, 225, 300 }
};
RunTest(c, 850);
}
[Test]
public void MunkresSquareTest3()
{
var c = new double[,]
{
{ 10, 10, 8 },
{ 9, 8, 1 },
{ 9, 7, 4 }
};
RunTest(c, 18);
}
[Test]
public void MunkresSquareTest4()
{
var c = new double[,]
{
{ 5, 9, 1 },
{ 10, 3, 2 },
{ 8, 7, 4 }
};
RunTest(c, 12);
}
[Test]
public void MunkresSquareTest5()
{
var c = new double[,]
{
{ 12, 26, 17, 0, 0 },
{ 49, 43, 36, 10, 5 },
{ 97, 9, 66, 34, 0 },
{ 52, 42, 19, 36, 0 },
{ 15, 93, 55, 80, 0 }
};
RunTest(c, 48);
}
[Test]
public void Munkres5x5Test()
{
var c = new double[,]
{
{ 12, 9, 27, 10, 23 },
{ 7, 13, 13, 30, 19 },
{ 25, 18, 26, 11, 26 },
{ 9, 28, 26, 23, 13 },
{ 16, 16, 24, 6, 9 }
};
RunTest(c, 51);
}
[Test]
public void Munkres10x10Test()
{
var c = new double[,]
{
{ 37, 34, 29, 26, 19, 8, 9, 23, 19, 29 },
{ 9, 28, 20, 8, 18, 20, 14, 33, 23, 14 },
{ 15, 26, 12, 28, 6, 17, 9, 13, 21, 7 },
{ 2, 8, 38, 36, 39, 5, 36, 2, 38, 27 },
{ 30, 3, 33, 16, 21, 39, 7, 23, 28, 36 },
{ 7, 5, 19, 22, 36, 36, 24, 19, 30, 2 },
{ 34, 20, 13, 36, 12, 33, 9, 10, 23, 5 },
{ 7, 37, 22, 39, 33, 39, 10, 3, 13, 26 },
{ 21, 25, 23, 39, 31, 37, 32, 33, 38, 1 },
{ 17, 34, 40, 10, 29, 37, 40, 3, 25, 3 }
};
RunTest(c, 66);
}
[Test]
public void Munkres20x20Test()
{
var c = new double[,]
{
{ 5, 4, 3, 9, 8, 9, 3, 5, 6, 9, 4, 10, 3, 5, 6, 6, 1, 8, 10, 2 },
{ 10, 9, 9, 2, 8, 3, 9, 9, 10, 1, 7, 10, 8, 4, 2, 1, 4, 8, 4, 8 },
{ 10, 4, 4, 3, 1, 3, 5, 10, 6, 8, 6, 8, 4, 10, 7, 2, 4, 5, 1, 8 },
{ 2, 1, 4, 2, 3, 9, 3, 4, 7, 3, 4, 1, 3, 2, 9, 8, 6, 5, 7, 8 },
{ 3, 4, 4, 1, 4, 10, 1, 2, 6, 4, 5, 10, 2, 2, 3, 9, 10, 9, 9, 10 },
{ 1, 10, 1, 8, 1, 3, 1, 7, 1, 1, 2, 1, 2, 6, 3, 3, 4, 4, 8, 6 },
{ 1, 8, 7, 10, 10, 3, 4, 6, 1, 6, 6, 4, 9, 6, 9, 6, 4, 5, 4, 7 },
{ 8, 10, 3, 9, 4, 9, 3, 3, 4, 6, 4, 2, 6, 7, 7, 4, 4, 3, 4, 7 },
{ 1, 3, 8, 2, 6, 9, 2, 7, 4, 8, 10, 8, 10, 5, 1, 3, 10, 10, 2, 9 },
{ 2, 4, 1, 9, 2, 9, 7, 8, 2, 1, 4, 10, 5, 2, 7, 6, 5, 7, 2, 6 },
{ 4, 5, 1, 4, 2, 3, 3, 4, 1, 8, 8, 2, 6, 9, 5, 9, 6, 3, 9, 3 },
{ 3, 1, 1, 8, 6, 8, 8, 7, 9, 3, 2, 1, 8, 2, 4, 7, 3, 1, 2, 4 },
{ 5, 9, 8, 6, 10, 4, 10, 3, 4, 10, 10, 10, 1, 7, 8, 8, 7, 7, 8, 8 },
{ 1, 4, 6, 1, 6, 1, 2, 10, 5, 10, 2, 6, 2, 4, 5, 5, 3, 5, 1, 5 },
{ 5, 6, 9, 10, 6, 6, 10, 6, 4, 1, 5, 3, 9, 5, 2, 10, 9, 9, 5, 1 },
{ 10, 9, 4, 6, 9, 5, 3, 7, 10, 1, 6, 8, 1, 1, 10, 9, 5, 7, 7, 5 },
{ 2, 6, 6, 6, 6, 2, 9, 4, 7, 5, 3, 2, 10, 3, 4, 5, 10, 9, 1, 7 },
{ 5, 2, 4, 9, 8, 4, 8, 2, 4, 1, 3, 7, 6, 8, 1, 6, 8, 8, 10, 10 },
{ 9, 6, 3, 1, 8, 5, 7, 8, 7, 2, 1, 8, 2, 8, 3, 7, 4, 8, 7, 7 },
{ 8, 4, 4, 9, 7, 10, 6, 2, 1, 5, 8, 5, 1, 1, 1, 9, 1, 3, 5, 3 }
};
RunTest(c, 22);
}
[Test]
public void MunkresRectangularTest1()
{
var c = new double[,]
{
{ 400, 150, 400, 1 },
{ 400, 450, 600, 2 },
{ 300, 225, 300, 3 }
};
RunTest(c, 452);
}
[Test]
public void MunkresRectangularTest2()
{
var c = new double[,]
{
{ 10, 10, 8, 11 },
{ 9, 8, 1, 1 },
{ 9, 7, 4, 10 }
};
RunTest(c, 15);
}
[Test]
public void MunkresRectangularTest3()
{
var c = new double[,]
{
{ 34, 26, 17, 12 },
{ 43, 43, 36, 10 },
{ 97, 47, 66, 34 },
{ 52, 42, 19, 36 },
{ 15, 93, 55, 80 }
};
RunTest(c, 70);
}
}
}

@ -28,18 +28,19 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport
private LocalBook _localTrack;
private Author _artist;
private Book _album;
private Edition _edition;
private QualityModel _quality;
private IdentificationOverrides _idOverrides;
private ImportDecisionMakerConfig _idConfig;
private Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumpass1;
private Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumpass2;
private Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumpass3;
private Mock<IImportDecisionEngineSpecification<LocalEdition>> _albumpass1;
private Mock<IImportDecisionEngineSpecification<LocalEdition>> _albumpass2;
private Mock<IImportDecisionEngineSpecification<LocalEdition>> _albumpass3;
private Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumfail1;
private Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumfail2;
private Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumfail3;
private Mock<IImportDecisionEngineSpecification<LocalEdition>> _albumfail1;
private Mock<IImportDecisionEngineSpecification<LocalEdition>> _albumfail2;
private Mock<IImportDecisionEngineSpecification<LocalEdition>> _albumfail3;
private Mock<IImportDecisionEngineSpecification<LocalBook>> _pass1;
private Mock<IImportDecisionEngineSpecification<LocalBook>> _pass2;
@ -52,13 +53,13 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport
[SetUp]
public void Setup()
{
_albumpass1 = new Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>>();
_albumpass2 = new Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>>();
_albumpass3 = new Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>>();
_albumpass1 = new Mock<IImportDecisionEngineSpecification<LocalEdition>>();
_albumpass2 = new Mock<IImportDecisionEngineSpecification<LocalEdition>>();
_albumpass3 = new Mock<IImportDecisionEngineSpecification<LocalEdition>>();
_albumfail1 = new Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>>();
_albumfail2 = new Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>>();
_albumfail3 = new Mock<IImportDecisionEngineSpecification<LocalAlbumRelease>>();
_albumfail1 = new Mock<IImportDecisionEngineSpecification<LocalEdition>>();
_albumfail2 = new Mock<IImportDecisionEngineSpecification<LocalEdition>>();
_albumfail3 = new Mock<IImportDecisionEngineSpecification<LocalEdition>>();
_pass1 = new Mock<IImportDecisionEngineSpecification<LocalBook>>();
_pass2 = new Mock<IImportDecisionEngineSpecification<LocalBook>>();
@ -68,13 +69,13 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport
_fail2 = new Mock<IImportDecisionEngineSpecification<LocalBook>>();
_fail3 = new Mock<IImportDecisionEngineSpecification<LocalBook>>();
_albumpass1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
_albumpass2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
_albumpass3.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
_albumpass1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
_albumpass2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
_albumpass3.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
_albumfail1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_albumfail1"));
_albumfail2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_albumfail2"));
_albumfail3.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_albumfail3"));
_albumfail1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_albumfail1"));
_albumfail2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_albumfail2"));
_albumfail3.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_albumfail3"));
_pass1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalBook>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
_pass2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalBook>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
@ -93,6 +94,10 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport
.With(x => x.Author = _artist)
.Build();
_edition = Builder<Edition>.CreateNew()
.With(x => x.Book = _album)
.Build();
_quality = new QualityModel(Quality.MP3_320);
_localTrack = new LocalBook
@ -116,9 +121,9 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport
.Setup(s => s.Identify(It.IsAny<List<LocalBook>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns((List<LocalBook> tracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) =>
{
var ret = new LocalAlbumRelease(tracks);
ret.Book = _album;
return new List<LocalAlbumRelease> { ret };
var ret = new LocalEdition(tracks);
ret.Edition = _edition;
return new List<LocalEdition> { ret };
});
Mocker.GetMock<IMediaFileService>()
@ -164,12 +169,12 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport
Subject.GetImportDecisions(_fileInfos, null, itemInfo, _idConfig);
_albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumfail3.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumpass1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumpass2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumpass3.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumfail3.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumpass1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumpass2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>()), Times.Once());
_albumpass3.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalEdition>(), It.IsAny<DownloadClientItem>()), Times.Once());
}
[Test]
@ -317,7 +322,7 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport
.Setup(s => s.Identify(It.IsAny<List<LocalBook>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns((List<LocalBook> tracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) =>
{
return new List<LocalAlbumRelease> { new LocalAlbumRelease(tracks) };
return new List<LocalEdition> { new LocalEdition(tracks) };
});
var decisions = Subject.GetImportDecisions(_fileInfos, _idOverrides, null, _idConfig);

@ -5,14 +5,14 @@ using Moq;
using NUnit.Framework;
using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MetadataSource.SkyHook;
using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.Profiles.Metadata;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.MetadataSource.SkyHook
namespace NzbDrone.Core.Test.MetadataSource.Goodreads
{
[TestFixture]
public class SkyHookProxyFixture : CoreTest<SkyHookProxy>
public class GoodreadsProxyFixture : CoreTest<GoodreadsProxy>
{
private MetadataProfile _metadataProfile;
@ -32,8 +32,8 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook
.Returns(true);
}
[TestCase("amzn1.gr.author.v1.qTrNu9-PIaaBj5gYRDmN4Q", "Terry Pratchett")]
[TestCase("amzn1.gr.author.v1.afCyJgprpWE2xJU2_z3zTQ", "Robert Harris")]
[TestCase("1654", "Terry Pratchett")]
[TestCase("575", "Robert Harris")]
public void should_be_able_to_get_author_detail(string mbId, string name)
{
var details = Subject.GetAuthorInfo(mbId);
@ -43,7 +43,7 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook
details.Name.Should().Be(name);
}
[TestCase("amzn1.gr.book.v1.2rp8a0vJ8clGzMzZf61R9Q", "Guards! Guards!")]
[TestCase("64216", "Guards! Guards!")]
public void should_be_able_to_get_book_detail(string mbId, string name)
{
var details = Subject.GetBookInfo(mbId);
@ -75,9 +75,6 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook
author.Metadata.Value.Overview.Should().NotBeNullOrWhiteSpace();
author.Metadata.Value.Images.Should().NotBeEmpty();
author.ForeignAuthorId.Should().NotBeNullOrWhiteSpace();
author.Books.IsLoaded.Should().BeTrue();
author.Books.Value.Should().NotBeEmpty();
author.Books.Value.Should().OnlyContain(x => x.CleanTitle != null);
}
private void ValidateAlbums(List<Book> albums, bool idOnly = false)

@ -4,15 +4,15 @@ using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Books;
using NzbDrone.Core.MetadataSource.SkyHook;
using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.Profiles.Metadata;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MetadataSource.SkyHook
namespace NzbDrone.Core.Test.MetadataSource.Goodreads
{
[TestFixture]
public class SkyHookProxySearchFixture : CoreTest<SkyHookProxy>
public class GoodreadsProxySearchFixture : CoreTest<GoodreadsProxy>
{
[SetUp]
public void Setup()
@ -45,10 +45,10 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook
}
[TestCase("Harry Potter and the sorcerer's stone", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("readarr:3", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("readarr: 3", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("readarrid:3", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("goodreads:3", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("readarr:3", null, "Harry Potter and the Philosopher's Stone")]
[TestCase("readarr: 3", null, "Harry Potter and the Philosopher's Stone")]
[TestCase("readarrid:3", null, "Harry Potter and the Philosopher's Stone")]
[TestCase("goodreads:3", null, "Harry Potter and the Philosopher's Stone")]
[TestCase("asin:B0192CTMYG", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("isbn:9780439554930", null, "Harry Potter and the Sorcerer's Stone")]
public void successful_album_search(string title, string artist, string expected)

@ -54,11 +54,19 @@ namespace NzbDrone.Core.Test.MusicTests
.Returns<Author, NamingConfig>((c, n) => c.Name);
}
private Book AlbumToAdd(string bookId, string authorId)
private Book AlbumToAdd(string editionId, string bookId, string authorId)
{
return new Book
{
ForeignBookId = bookId,
Editions = new List<Edition>
{
new Edition
{
ForeignEditionId = editionId,
Monitored = true
}
},
AuthorMetadata = new AuthorMetadata
{
ForeignAuthorId = authorId
@ -69,9 +77,9 @@ namespace NzbDrone.Core.Test.MusicTests
[Test]
public void should_be_able_to_add_a_album_without_passing_in_name()
{
var newAlbum = AlbumToAdd("5537624c-3d2f-4f5c-8099-df916082c85c", "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493");
var newAlbum = AlbumToAdd("edition", "book", "author");
GivenValidAlbum(newAlbum.ForeignBookId);
GivenValidAlbum("edition");
GivenValidPath();
var album = Subject.AddBook(newAlbum);
@ -82,11 +90,11 @@ namespace NzbDrone.Core.Test.MusicTests
[Test]
public void should_throw_if_album_cannot_be_found()
{
var newAlbum = AlbumToAdd("5537624c-3d2f-4f5c-8099-df916082c85c", "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493");
var newAlbum = AlbumToAdd("edition", "book", "author");
Mocker.GetMock<IProvideBookInfo>()
.Setup(s => s.GetBookInfo(newAlbum.ForeignBookId))
.Throws(new BookNotFoundException(newAlbum.ForeignBookId));
.Setup(s => s.GetBookInfo("edition"))
.Throws(new BookNotFoundException("edition"));
Assert.Throws<ValidationException>(() => Subject.AddBook(newAlbum));

@ -16,7 +16,7 @@ using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MusicTests
{
[TestFixture]
public class AddArtistFixture : CoreTest<AddArtistService>
public class AddArtistFixture : CoreTest<AddAuthorService>
{
private Author _fakeArtist;

@ -36,7 +36,6 @@ namespace NzbDrone.Core.Test.MusicTests.AlbumRepositoryTests
{
Title = "ANThology",
ForeignBookId = "1",
ForeignWorkId = "1",
TitleSlug = "1-ANThology",
CleanTitle = "anthology",
Author = _artist,
@ -50,7 +49,6 @@ namespace NzbDrone.Core.Test.MusicTests.AlbumRepositoryTests
{
Title = "+",
ForeignBookId = "2",
ForeignWorkId = "2",
TitleSlug = "2-_",
CleanTitle = "",
Author = _artist,

@ -143,6 +143,59 @@ namespace NzbDrone.Core.Test.MusicTests
item1.Should().Be(item2);
}
private Edition GivenEdition()
{
return _fixture.Build<Edition>()
.Without(x => x.Book)
.Without(x => x.BookFiles)
.Create();
}
[Test]
public void two_equivalent_editions_should_be_equal()
{
var item1 = GivenEdition();
var item2 = item1.JsonClone();
item1.Should().NotBeSameAs(item2);
item1.Should().Be(item2);
}
[Test]
[TestCaseSource(typeof(EqualityPropertySource<Edition>), "TestCases")]
public void two_different_editions_should_not_be_equal(PropertyInfo prop)
{
var item1 = GivenEdition();
var item2 = item1.JsonClone();
var different = GivenEdition();
// make item2 different in the property under consideration
if (prop.PropertyType == typeof(bool))
{
prop.SetValue(item2, !(bool)prop.GetValue(item1));
}
else
{
prop.SetValue(item2, prop.GetValue(different));
}
item1.Should().NotBeSameAs(item2);
item1.Should().NotBe(item2);
}
[Test]
public void metadata_and_db_fields_should_replicate_edition()
{
var item1 = GivenEdition();
var item2 = GivenEdition();
item1.Should().NotBe(item2);
item1.UseMetadataFrom(item2);
item1.UseDbFieldsFrom(item2);
item1.Should().Be(item2);
}
private Author GivenArtist()
{
return _fixture.Build<Author>()

@ -1,108 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.History;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MusicTests
{
[TestFixture]
public class RefreshAlbumServiceFixture : CoreTest<RefreshBookService>
{
private Author _artist;
private List<Book> _albums;
[SetUp]
public void Setup()
{
var album1 = Builder<Book>.CreateNew()
.With(x => x.AuthorMetadata = Builder<AuthorMetadata>.CreateNew().Build())
.With(s => s.Id = 1234)
.With(s => s.ForeignBookId = "1")
.Build();
_albums = new List<Book> { album1 };
_artist = Builder<Author>.CreateNew()
.With(s => s.Books = _albums)
.Build();
Mocker.GetMock<IAuthorService>()
.Setup(s => s.GetAuthor(_artist.Id))
.Returns(_artist);
Mocker.GetMock<IAuthorMetadataService>()
.Setup(s => s.UpsertMany(It.IsAny<List<AuthorMetadata>>()))
.Returns(true);
Mocker.GetMock<IProvideBookInfo>()
.Setup(s => s.GetBookInfo(It.IsAny<string>()))
.Callback(() => { throw new BookNotFoundException(album1.ForeignBookId); });
Mocker.GetMock<ICheckIfBookShouldBeRefreshed>()
.Setup(s => s.ShouldRefresh(It.IsAny<Book>()))
.Returns(true);
Mocker.GetMock<IMediaFileService>()
.Setup(x => x.GetFilesByBook(It.IsAny<int>()))
.Returns(new List<BookFile>());
Mocker.GetMock<IHistoryService>()
.Setup(x => x.GetByBook(It.IsAny<int>(), It.IsAny<HistoryEventType?>()))
.Returns(new List<History.History>());
}
[Test]
public void should_update_if_musicbrainz_id_changed_and_no_clash()
{
var newAlbumInfo = _albums.First().JsonClone();
newAlbumInfo.AuthorMetadata = _albums.First().AuthorMetadata.Value.JsonClone();
newAlbumInfo.ForeignBookId = _albums.First().ForeignBookId + 1;
Subject.RefreshBookInfo(_albums, new List<Book> { newAlbumInfo }, null, false, false, null);
Mocker.GetMock<IBookService>()
.Verify(v => v.UpdateMany(It.Is<List<Book>>(s => s.First().ForeignBookId == newAlbumInfo.ForeignBookId)));
}
[Test]
public void should_merge_if_musicbrainz_id_changed_and_new_already_exists()
{
var existing = _albums.First();
var clash = existing.JsonClone();
clash.Id = 100;
clash.AuthorMetadata = existing.AuthorMetadata.Value.JsonClone();
clash.ForeignBookId += 1;
Mocker.GetMock<IBookService>()
.Setup(x => x.FindById(clash.ForeignBookId))
.Returns(clash);
var newAlbumInfo = existing.JsonClone();
newAlbumInfo.AuthorMetadata = existing.AuthorMetadata.Value.JsonClone();
newAlbumInfo.ForeignBookId = _albums.First().ForeignBookId + 1;
Subject.RefreshBookInfo(_albums, new List<Book> { newAlbumInfo }, null, false, false, null);
// check old album is deleted
Mocker.GetMock<IBookService>()
.Verify(v => v.DeleteMany(It.Is<List<Book>>(x => x.First().ForeignBookId == existing.ForeignBookId)));
// check that clash gets updated
Mocker.GetMock<IBookService>()
.Verify(v => v.UpdateMany(It.Is<List<Book>>(s => s.First().ForeignBookId == newAlbumInfo.ForeignBookId)));
ExceptionVerification.ExpectedWarns(1);
}
}
}

@ -45,10 +45,12 @@ namespace NzbDrone.Core.Test.MusicTests
var metadata = Builder<AuthorMetadata>.CreateNew().Build();
var series = Builder<Series>.CreateListOfSize(1).BuildList();
var profile = Builder<MetadataProfile>.CreateNew().Build();
_artist = Builder<Author>.CreateNew()
.With(a => a.Metadata = metadata)
.With(a => a.Series = series)
.With(a => a.MetadataProfile = profile)
.Build();
Mocker.GetMock<IAuthorService>(MockBehavior.Strict)
@ -63,8 +65,8 @@ namespace NzbDrone.Core.Test.MusicTests
.Returns(_albums);
Mocker.GetMock<IProvideAuthorInfo>()
.Setup(s => s.GetAuthorInfo(It.IsAny<string>()))
.Callback(() => { throw new AuthorNotFoundException(_artist.ForeignAuthorId); });
.Setup(s => s.GetAuthorAndBooks(It.IsAny<string>(), It.IsAny<double>()))
.Callback(() => { throw new AuthorNotFoundException(_artist.ForeignAuthorId); });
Mocker.GetMock<IMediaFileService>()
.Setup(x => x.GetFilesByAuthor(It.IsAny<int>()))
@ -86,8 +88,8 @@ namespace NzbDrone.Core.Test.MusicTests
private void GivenNewArtistInfo(Author artist)
{
Mocker.GetMock<IProvideAuthorInfo>()
.Setup(s => s.GetAuthorInfo(_artist.ForeignAuthorId))
.Returns(artist);
.Setup(s => s.GetAuthorAndBooks(_artist.ForeignAuthorId, It.IsAny<double>()))
.Returns(artist);
}
private void GivenArtistFiles()

@ -38,7 +38,13 @@ namespace NzbDrone.Core.Test.OrganizerTests
.With(s => s.Title = "Fake: Book")
.Build();
Subject.BuildBookFilePath(fakeArtist, fakeAlbum, filename, ".mobi").Should().Be(expectedPath.AsOsAgnostic());
var fakeEdition = Builder<Edition>
.CreateNew()
.With(s => s.Title = fakeAlbum.Title)
.With(s => s.Book = fakeAlbum)
.Build();
Subject.BuildBookFilePath(fakeArtist, fakeEdition, filename, ".mobi").Should().Be(expectedPath.AsOsAgnostic());
}
}
}

@ -16,6 +16,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
private Author _artist;
private Book _album;
private Edition _edition;
private BookFile _trackFile;
private NamingConfig _namingConfig;
@ -32,6 +33,12 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
.With(s => s.Title = "Hail to the King")
.Build();
_edition = Builder<Edition>
.CreateNew()
.With(s => s.Title = _album.Title)
.With(s => s.Book = _album)
.Build();
_trackFile = new BookFile { Quality = new QualityModel(Quality.MP3_320), ReleaseGroup = "ReadarrTest" };
_namingConfig = NamingConfig.Default;
@ -68,7 +75,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_artist.Name = name;
_namingConfig.StandardBookFormat = "{Author CleanName}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(expected);
}
}

@ -18,6 +18,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
private Author _artist;
private Book _album;
private Edition _edition;
private BookFile _trackFile;
private NamingConfig _namingConfig;
@ -37,7 +38,13 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_album = Builder<Book>
.CreateNew()
.With(s => s.Title = "Hybrid Theory")
.Build();
_edition = Builder<Edition>
.CreateNew()
.With(s => s.Title = _album.Title)
.With(s => s.Disambiguation = "The Best Album")
.With(s => s.Book = _album)
.Build();
_namingConfig = NamingConfig.Default;
@ -78,7 +85,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author Name}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin Park");
}
@ -87,7 +94,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author_Name}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin_Park");
}
@ -96,7 +103,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author.Name}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin.Park");
}
@ -105,7 +112,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author-Name}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin-Park");
}
@ -114,7 +121,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{AUTHOR NAME}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("LINKIN PARK");
}
@ -123,7 +130,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{aUtHoR-nAmE}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(_artist.Name.Replace(' ', '-'));
}
@ -132,7 +139,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{author name}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("linkin park");
}
@ -142,7 +149,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_namingConfig.StandardBookFormat = "{Author.CleanName}";
_artist.Name = "Linkin Park (1997)";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin.Park.1997");
}
@ -151,16 +158,16 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author Disambiguation}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("US Rock Band");
}
[Test]
public void should_replace_Album_space_Title()
public void should_replace_edition_space_Title()
{
_namingConfig.StandardBookFormat = "{Book Title}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Hybrid Theory");
}
@ -169,7 +176,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Book Disambiguation}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("The Best Album");
}
@ -178,7 +185,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Book_Title}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Hybrid_Theory");
}
@ -187,7 +194,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Book.Title}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Hybrid.Theory");
}
@ -196,7 +203,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Book-Title}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Hybrid-Theory");
}
@ -205,7 +212,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{BOOK TITLE}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("HYBRID THEORY");
}
@ -214,7 +221,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{bOoK-tItLE}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(_album.Title.Replace(' ', '-'));
}
@ -223,7 +230,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{book title}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("hybrid theory");
}
@ -233,7 +240,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_namingConfig.StandardBookFormat = "{Author.CleanName}";
_artist.Name = "Hybrid Theory (2000)";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Hybrid.Theory.2000");
}
@ -242,7 +249,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Quality Title}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("MP3-320");
}
@ -251,7 +258,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{MediaInfo AudioCodec}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("FLAC");
}
@ -260,7 +267,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{MediaInfo AudioBitRate}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("320 kbps");
}
@ -269,7 +276,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{MediaInfo AudioChannels}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("2.0");
}
@ -278,7 +285,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{MediaInfo AudioBitsPerSample}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("16bit");
}
@ -287,7 +294,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{MediaInfo AudioSampleRate}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("44.1kHz");
}
@ -296,7 +303,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author Name} - {Book Title} - [{Quality Title}]";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin Park - Hybrid Theory - [MP3-320]");
}
@ -306,7 +313,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_namingConfig.RenameBooks = false;
_trackFile.Path = "Linkin Park - 06 - Test";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(Path.GetFileNameWithoutExtension(_trackFile.Path));
}
@ -317,7 +324,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.Path = "Linkin Park - 06 - Test";
_trackFile.SceneName = "SceneName";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(Path.GetFileNameWithoutExtension(_trackFile.Path));
}
@ -327,7 +334,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_namingConfig.RenameBooks = false;
_trackFile.Path = @"C:\Test\Unsorted\Artist - 01 - Test";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(Path.GetFileNameWithoutExtension(_trackFile.Path));
}
@ -336,7 +343,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Release Group}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(_trackFile.ReleaseGroup);
}
@ -349,7 +356,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.SceneName = "Linkin.Park.Meteora.320-LOL";
_trackFile.Path = "30 Rock - 01 - Test";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin Park - Linkin.Park.Meteora.320-LOL");
}
@ -358,7 +365,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author.Name}.{Book.Title}";
Subject.BuildBookFileName(new Author { Name = "In The Woods." }, new Book { Title = "30 Rock" }, _trackFile)
Subject.BuildBookFileName(new Author { Name = "In The Woods." }, new Edition { Title = "30 Rock" }, _trackFile)
.Should().Be("In.The.Woods.30.Rock");
}
@ -367,7 +374,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author.Name}.{Book.Title}";
Subject.BuildBookFileName(new Author { Name = "In The Woods..." }, new Book { Title = "30 Rock" }, _trackFile)
Subject.BuildBookFileName(new Author { Name = "In The Woods..." }, new Edition { Title = "30 Rock" }, _trackFile)
.Should().Be("In.The.Woods.30.Rock");
}
@ -376,7 +383,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author.Name}{_Book.Title_}{Quality.Title}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin.Park_Hybrid.Theory_MP3-320");
}
@ -385,7 +392,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author.Name}{_Book.Title_}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin.Park_Hybrid.Theory");
}
@ -395,7 +402,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_artist.Name = "Venture Bros.";
_namingConfig.StandardBookFormat = "{Author.Name}.{Book.Title}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Venture.Bros.Hybrid.Theory");
}
@ -408,7 +415,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.SceneName = null;
_trackFile.Path = "existing.file.mkv";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(Path.GetFileNameWithoutExtension(_trackFile.Path));
}
@ -421,7 +428,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.SceneName = "30.Rock.S01E01.xvid-LOL";
_trackFile.Path = "30 Rock - S01E01 - Test";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("30.Rock.S01E01.xvid-LOL");
}
@ -430,7 +437,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Quality Title} {Quality Proper}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("MP3-320");
}
@ -439,7 +446,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author Name} - {Book Title} [{Quality Title}] {[Quality Proper]}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin Park - Hybrid Theory [MP3-320]");
}
@ -448,7 +455,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = "{Author Name} - {Book Title} [{Quality Full}]";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Linkin Park - Hybrid Theory [MP3-320]");
}
@ -460,7 +467,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = string.Format("{{Quality{0}Title}}{0}{{Quality{0}Proper}}", separator);
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("MP3-320");
}
@ -472,7 +479,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
_namingConfig.StandardBookFormat = string.Format("{{Quality{0}Title}}{0}{{Quality{0}Proper}}{0}{{Book{0}Title}}", separator);
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(string.Format("MP3-320{0}Hybrid{0}Theory", separator));
}
@ -485,7 +492,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.SceneName = "30.Rock.S01E01.xvid-LOL";
_trackFile.Path = "30 Rock - S01E01 - Test";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("30 Rock - 30 Rock - S01E01 - Test");
}
@ -498,7 +505,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.SceneName = "30.Rock.S01E01.xvid-LOL";
_trackFile.Path = "30 Rock - S01E01 - Test";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("30 Rock - S01E01 - Test");
}
@ -508,7 +515,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.ReleaseGroup = null;
_namingConfig.StandardBookFormat = "{Release Group}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be("Readarr");
}
@ -520,7 +527,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.ReleaseGroup = null;
_namingConfig.StandardBookFormat = pattern;
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(expectedFileName);
}
@ -532,7 +539,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_trackFile.ReleaseGroup = releaseGroup;
_namingConfig.StandardBookFormat = "{Release Group}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(releaseGroup);
}
}

@ -16,6 +16,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
private Author _artist;
private Book _album;
private Edition _edition;
private BookFile _trackFile;
private NamingConfig _namingConfig;
@ -32,6 +33,12 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
.With(s => s.Title = "Anthology")
.Build();
_edition = Builder<Edition>
.CreateNew()
.With(s => s.Title = _album.Title)
.With(s => s.Book = _album)
.Build();
_trackFile = new BookFile { Quality = new QualityModel(Quality.MP3_320), ReleaseGroup = "ReadarrTest" };
_namingConfig = NamingConfig.Default;
@ -62,7 +69,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_artist.Name = name;
_namingConfig.StandardBookFormat = "{Author NameThe}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(expected);
}
@ -75,7 +82,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
_artist.Name = name;
_namingConfig.StandardBookFormat = "{Author NameThe}";
Subject.BuildBookFileName(_artist, _album, _trackFile)
Subject.BuildBookFileName(_artist, _edition, _trackFile)
.Should().Be(name);
}
}

@ -16,7 +16,7 @@ namespace NzbDrone.Core.AuthorStats
public class AuthorStatisticsRepository : IAuthorStatisticsRepository
{
private const string _selectTemplate = "SELECT /**select**/ FROM Books /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/ /**orderby**/";
private const string _selectTemplate = "SELECT /**select**/ FROM Editions /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/ /**orderby**/";
private readonly IMainDatabase _database;
@ -28,14 +28,22 @@ namespace NzbDrone.Core.AuthorStats
public List<BookStatistics> AuthorStatistics()
{
var time = DateTime.UtcNow;
return Query(Builder().Where<Book>(x => x.ReleaseDate < time));
var stats = Query(Builder());
#pragma warning disable CS0472
return Query(Builder().OrWhere<Book>(x => x.ReleaseDate < time)
.OrWhere<BookFile>(x => x.Id != null));
#pragma warning restore
}
public List<BookStatistics> AuthorStatistics(int authorId)
{
var time = DateTime.UtcNow;
return Query(Builder().Where<Book>(x => x.ReleaseDate < time)
#pragma warning disable CS0472
return Query(Builder().OrWhere<Book>(x => x.ReleaseDate < time)
.OrWhere<BookFile>(x => x.Id != null)
.Where<Author>(x => x.Id == authorId));
#pragma warning restore
}
private List<BookStatistics> Query(SqlBuilder builder)
@ -56,8 +64,10 @@ namespace NzbDrone.Core.AuthorStats
SUM(CASE WHEN BookFiles.Id IS NULL THEN 0 ELSE 1 END) AS AvailableBookCount,
SUM(CASE WHEN Books.Monitored = 1 OR BookFiles.Id IS NOT NULL THEN 1 ELSE 0 END) AS BookCount,
SUM(CASE WHEN BookFiles.Id IS NULL THEN 0 ELSE 1 END) AS BookFileCount")
.Join<Edition, Book>((e, b) => e.BookId == b.Id)
.Join<Book, Author>((book, author) => book.AuthorMetadataId == author.AuthorMetadataId)
.LeftJoin<Book, BookFile>((t, f) => t.Id == f.BookId)
.LeftJoin<Edition, BookFile>((t, f) => t.Id == f.EditionId)
.Where<Edition>(x => x.Monitored == true)
.GroupBy<Author>(x => x.Id)
.GroupBy<Book>(x => x.Id);
}

@ -117,7 +117,7 @@ namespace NzbDrone.Core.Books.Calibre
public void SetFields(BookFile file, CalibreSettings settings)
{
var book = file.Book.Value;
var book = file.Edition.Value;
var cover = book.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover);
string image = null;
@ -144,7 +144,6 @@ namespace NzbDrone.Core.Books.Calibre
rating = book.Ratings.Value * 2,
identifiers = new Dictionary<string, string>
{
{ "goodreads", book.GoodreadsId.ToString() },
{ "isbn", book.Isbn13 },
{ "asin", book.Asin }
}

@ -0,0 +1,14 @@
using NzbDrone.Common.Messaging;
namespace NzbDrone.Core.Books.Events
{
public class EditionDeletedEvent : IEvent
{
public Edition Edition { get; private set; }
public EditionDeletedEvent(Edition edition)
{
Edition = edition;
}
}
}

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Extensions;
@ -16,13 +17,15 @@ namespace NzbDrone.Core.Books
}
public string ForeignAuthorId { get; set; }
public int GoodreadsId { get; set; }
public string TitleSlug { get; set; }
public string Name { get; set; }
public List<string> Aliases { get; set; }
public string Overview { get; set; }
public string Disambiguation { get; set; }
public string Type { get; set; }
public string Gender { get; set; }
public string Hometown { get; set; }
public DateTime? Born { get; set; }
public DateTime? Died { get; set; }
public AuthorStatusType Status { get; set; }
public List<MediaCover.MediaCover> Images { get; set; }
public List<Links> Links { get; set; }
@ -37,13 +40,15 @@ namespace NzbDrone.Core.Books
public override void UseMetadataFrom(AuthorMetadata other)
{
ForeignAuthorId = other.ForeignAuthorId;
GoodreadsId = other.GoodreadsId;
TitleSlug = other.TitleSlug;
Name = other.Name;
Aliases = other.Aliases;
Overview = other.Overview.IsNullOrWhiteSpace() ? Overview : other.Overview;
Disambiguation = other.Disambiguation;
Type = other.Type;
Gender = other.Gender;
Hometown = other.Hometown;
Born = other.Born;
Died = other.Died;
Status = other.Status;
Images = other.Images.Any() ? other.Images : Images;
Links = other.Links;

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Equ;
using Newtonsoft.Json;
using NzbDrone.Common.Extensions;
@ -13,8 +12,6 @@ namespace NzbDrone.Core.Books
{
public Book()
{
Overview = string.Empty;
Images = new List<MediaCover.MediaCover>();
Links = new List<Links>();
Genres = new List<string>();
Ratings = new Ratings();
@ -26,19 +23,9 @@ namespace NzbDrone.Core.Books
// These are metadata entries
public int AuthorMetadataId { get; set; }
public string ForeignBookId { get; set; }
public string ForeignWorkId { get; set; }
public int GoodreadsId { get; set; }
public string TitleSlug { get; set; }
public string Isbn13 { get; set; }
public string Asin { get; set; }
public string Title { get; set; }
public string Language { get; set; }
public string Overview { get; set; }
public string Disambiguation { get; set; }
public string Publisher { get; set; }
public int PageCount { get; set; }
public DateTime? ReleaseDate { get; set; }
public List<MediaCover.MediaCover> Images { get; set; }
public List<Links> Links { get; set; }
public List<string> Genres { get; set; }
public Ratings Ratings { get; set; }
@ -46,6 +33,7 @@ namespace NzbDrone.Core.Books
// These are Readarr generated/config
public string CleanTitle { get; set; }
public bool Monitored { get; set; }
public bool AnyEditionOk { get; set; }
public DateTime? LastInfoSync { get; set; }
public DateTime Added { get; set; }
[MemberwiseEqualityIgnore]
@ -57,6 +45,8 @@ namespace NzbDrone.Core.Books
[MemberwiseEqualityIgnore]
public LazyLoaded<Author> Author { get; set; }
[MemberwiseEqualityIgnore]
public LazyLoaded<List<Edition>> Editions { get; set; }
[MemberwiseEqualityIgnore]
public LazyLoaded<List<BookFile>> BookFiles { get; set; }
[MemberwiseEqualityIgnore]
public LazyLoaded<List<SeriesBookLink>> SeriesLinks { get; set; }
@ -77,19 +67,9 @@ namespace NzbDrone.Core.Books
public override void UseMetadataFrom(Book other)
{
ForeignBookId = other.ForeignBookId;
ForeignWorkId = other.ForeignWorkId;
GoodreadsId = other.GoodreadsId;
TitleSlug = other.TitleSlug;
Isbn13 = other.Isbn13;
Asin = other.Asin;
Title = other.Title;
Language = other.Language;
Overview = other.Overview.IsNullOrWhiteSpace() ? Overview : other.Overview;
Disambiguation = other.Disambiguation;
Publisher = other.Publisher;
PageCount = other.PageCount;
ReleaseDate = other.ReleaseDate;
Images = other.Images.Any() ? other.Images : Images;
Links = other.Links;
Genres = other.Genres;
Ratings = other.Ratings;
@ -101,6 +81,7 @@ namespace NzbDrone.Core.Books
Id = other.Id;
AuthorMetadataId = other.AuthorMetadataId;
Monitored = other.Monitored;
AnyEditionOk = other.AnyEditionOk;
LastInfoSync = other.LastInfoSync;
Added = other.Added;
AddOptions = other.AddOptions;
@ -109,9 +90,9 @@ namespace NzbDrone.Core.Books
public override void ApplyChanges(Book other)
{
ForeignBookId = other.ForeignBookId;
ForeignWorkId = other.ForeignWorkId;
AddOptions = other.AddOptions;
Monitored = other.Monitored;
AnyEditionOk = other.AnyEditionOk;
}
}
}

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Equ;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.MediaFiles;
namespace NzbDrone.Core.Books
{
public class Edition : Entity<Edition>
{
public Edition()
{
Overview = string.Empty;
Images = new List<MediaCover.MediaCover>();
Links = new List<Links>();
Ratings = new Ratings();
}
// These correspond to columns in the Albums table
// These are metadata entries
public int BookId { get; set; }
public string ForeignEditionId { get; set; }
public string TitleSlug { get; set; }
public string Isbn13 { get; set; }
public string Asin { get; set; }
public string Title { get; set; }
public string Language { get; set; }
public string Overview { get; set; }
public string Format { get; set; }
public bool IsEbook { get; set; }
public string Disambiguation { get; set; }
public string Publisher { get; set; }
public int PageCount { get; set; }
public DateTime? ReleaseDate { get; set; }
public List<MediaCover.MediaCover> Images { get; set; }
public List<Links> Links { get; set; }
public Ratings Ratings { get; set; }
// These are Readarr generated/config
public bool Monitored { get; set; }
public bool ManualAdd { get; set; }
// These are dynamically queried from other tables
[MemberwiseEqualityIgnore]
public LazyLoaded<Book> Book { get; set; }
[MemberwiseEqualityIgnore]
public LazyLoaded<List<BookFile>> BookFiles { get; set; }
public override string ToString()
{
return string.Format("[{0}][{1}]", ForeignEditionId, Title.NullSafe());
}
public override void UseMetadataFrom(Edition other)
{
ForeignEditionId = other.ForeignEditionId;
TitleSlug = other.TitleSlug;
Isbn13 = other.Isbn13;
Asin = other.Asin;
Title = other.Title;
Language = other.Language;
Overview = other.Overview.IsNullOrWhiteSpace() ? Overview : other.Overview;
Format = other.Format;
IsEbook = other.IsEbook;
Disambiguation = other.Disambiguation;
Publisher = other.Publisher;
PageCount = other.PageCount;
ReleaseDate = other.ReleaseDate;
Images = other.Images.Any() ? other.Images : Images;
Links = other.Links;
Ratings = other.Ratings;
}
public override void UseDbFieldsFrom(Edition other)
{
Id = other.Id;
BookId = other.BookId;
Book = other.Book;
Monitored = other.Monitored;
ManualAdd = other.ManualAdd;
}
public override void ApplyChanges(Edition other)
{
ForeignEditionId = other.ForeignEditionId;
Monitored = other.Monitored;
}
}
}

@ -7,5 +7,7 @@ namespace NzbDrone.Core.Books
{
public int Votes { get; set; }
public decimal Value { get; set; }
public double Popularity => (double)Value * Votes;
}
}

@ -70,7 +70,7 @@ namespace NzbDrone.Core.Books
public List<Book> GetBooksByFileIds(IEnumerable<int> fileIds)
{
return Query(new SqlBuilder()
.Join<Book, BookFile>((l, r) => l.Id == r.BookId)
.Join<Book, BookFile>((l, r) => l.Id == r.EditionId)
.Where<BookFile>(f => fileIds.Contains(f.Id)))
.DistinctBy(x => x.Id)
.ToList();
@ -90,7 +90,7 @@ namespace NzbDrone.Core.Books
#pragma warning disable CS0472
private SqlBuilder AlbumsWithoutFilesBuilder(DateTime currentTime) => Builder()
.Join<Book, Author>((l, r) => l.AuthorMetadataId == r.AuthorMetadataId)
.LeftJoin<Book, BookFile>((t, f) => t.Id == f.BookId)
.LeftJoin<Book, BookFile>((t, f) => t.Id == f.EditionId)
.Where<BookFile>(f => f.Id == null)
.Where<Book>(a => a.ReleaseDate <= currentTime);
#pragma warning restore CS0472
@ -107,7 +107,7 @@ namespace NzbDrone.Core.Books
private SqlBuilder AlbumsWhereCutoffUnmetBuilder(List<QualitiesBelowCutoff> qualitiesBelowCutoff) => Builder()
.Join<Book, Author>((l, r) => l.AuthorMetadataId == r.AuthorMetadataId)
.Join<Book, BookFile>((t, f) => t.Id == f.BookId)
.Join<Book, BookFile>((t, f) => t.Id == f.EditionId)
.Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff));
private string BuildQualityCutoffWhereClause(List<QualitiesBelowCutoff> qualitiesBelowCutoff)
@ -193,7 +193,7 @@ namespace NzbDrone.Core.Books
public List<Book> GetAuthorBooksWithFiles(Author author)
{
return Query(Builder()
.Join<Book, BookFile>((t, f) => t.Id == f.BookId)
.Join<Book, BookFile>((t, f) => t.Id == f.EditionId)
.Where<Book>(x => x.AuthorMetadataId == author.AuthorMetadataId));
}
}

@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Books
{
public interface IEditionRepository : IBasicRepository<Edition>
{
Edition FindByForeignEditionId(string foreignEditionId);
List<Edition> FindByBook(int id);
List<Edition> FindByAuthor(int id);
List<Edition> GetEditionsForRefresh(int albumId, IEnumerable<string> foreignEditionIds);
List<Edition> SetMonitored(Edition edition);
}
public class EditionRepository : BasicRepository<Edition>, IEditionRepository
{
public EditionRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public Edition FindByForeignEditionId(string foreignEditionId)
{
var edition = Query(x => x.ForeignEditionId == foreignEditionId).SingleOrDefault();
return edition;
}
public List<Edition> GetEditionsForRefresh(int albumId, IEnumerable<string> foreignEditionIds)
{
return Query(r => r.BookId == albumId || foreignEditionIds.Contains(r.ForeignEditionId));
}
public List<Edition> FindByBook(int id)
{
// populate the albums and artist metadata also
// this hopefully speeds up the track matching a lot
var builder = new SqlBuilder()
.LeftJoin<Edition, Book>((e, b) => e.BookId == b.Id)
.LeftJoin<Book, AuthorMetadata>((b, a) => b.AuthorMetadataId == a.Id)
.Where<Edition>(r => r.BookId == id);
return _database.QueryJoined<Edition, Book, AuthorMetadata>(builder, (edition, book, metadata) =>
{
if (book != null)
{
book.AuthorMetadata = metadata;
edition.Book = book;
}
return edition;
}).ToList();
}
public List<Edition> FindByAuthor(int id)
{
return Query(Builder().Join<Edition, Book>((e, b) => e.BookId == b.Id)
.Join<Book, Author>((b, a) => b.AuthorMetadataId == a.AuthorMetadataId)
.Where<Author>(a => a.Id == id));
}
public List<Edition> SetMonitored(Edition edition)
{
var allEditions = FindByBook(edition.BookId);
allEditions.ForEach(r => r.Monitored = r.Id == edition.Id);
Ensure.That(allEditions.Count(x => x.Monitored) == 1).IsTrue();
UpdateMany(allEditions);
return allEditions;
}
}
}

@ -20,7 +20,7 @@ namespace NzbDrone.Core.Books
List<Author> AddAuthors(List<Author> newAuthors, bool doRefresh = true);
}
public class AddArtistService : IAddAuthorService
public class AddAuthorService : IAddAuthorService
{
private readonly IAuthorService _authorService;
private readonly IAuthorMetadataService _authorMetadataService;
@ -29,7 +29,7 @@ namespace NzbDrone.Core.Books
private readonly IAddAuthorValidator _addAuthorValidator;
private readonly Logger _logger;
public AddArtistService(IAuthorService authorService,
public AddAuthorService(IAuthorService authorService,
IAuthorMetadataService authorMetadataService,
IProvideAuthorInfo authorInfo,
IBuildFileNames fileNameBuilder,

@ -44,7 +44,17 @@ namespace NzbDrone.Core.Books
{
_logger.Debug($"Adding book {book}");
book = AddSkyhookData(book);
// we allow adding extra editions, so check if the book already exists
var dbBook = _bookService.FindById(book.ForeignBookId);
if (dbBook != null)
{
dbBook.Editions = book.Editions;
book = dbBook;
}
else
{
book = AddSkyhookData(book);
}
// Remove any import list exclusions preventing addition
_importListExclusionService.Delete(book.ForeignBookId);
@ -98,7 +108,7 @@ namespace NzbDrone.Core.Books
Tuple<string, Book, List<AuthorMetadata>> tuple = null;
try
{
tuple = _bookInfo.GetBookInfo(newBook.ForeignBookId);
tuple = _bookInfo.GetBookInfo(newBook.Editions.Value.Single(x => x.Monitored).ForeignEditionId);
}
catch (BookNotFoundException)
{

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
@ -45,21 +46,32 @@ namespace NzbDrone.Core.Books
IHandle<AuthorDeletedEvent>
{
private readonly IBookRepository _bookRepository;
private readonly IEditionService _editionService;
private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger;
public BookService(IBookRepository bookRepository,
IEventAggregator eventAggregator,
Logger logger)
IEditionService editionService,
IEventAggregator eventAggregator,
Logger logger)
{
_bookRepository = bookRepository;
_editionService = editionService;
_eventAggregator = eventAggregator;
_logger = logger;
}
public Book AddBook(Book newBook, bool doRefresh = true)
{
_bookRepository.Insert(newBook);
var editions = newBook.Editions.Value;
editions.ForEach(x => x.Monitored = newBook.Id > 0);
_bookRepository.Upsert(newBook);
editions.ForEach(x => x.BookId = newBook.Id);
_editionService.InsertMany(editions);
_editionService.SetMonitored(editions.First());
_eventAggregator.PublishEvent(new BookAddedEvent(GetBook(newBook.Id), doRefresh));

@ -0,0 +1,95 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Books.Events;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Books
{
public interface IEditionService
{
Edition GetEdition(int id);
Edition GetEditionByForeignEditionId(string foreignEditionId);
List<Edition> GetAllEditions();
void InsertMany(List<Edition> editions);
void UpdateMany(List<Edition> editions);
void DeleteMany(List<Edition> editions);
List<Edition> GetEditionsForRefresh(int albumId, IEnumerable<string> foreignEditionIds);
List<Edition> GetEditionsByBook(int bookId);
List<Edition> GetEditionsByAuthor(int authorId);
List<Edition> SetMonitored(Edition edition);
}
public class EditionService : IEditionService,
IHandle<BookDeletedEvent>
{
private readonly IEditionRepository _editionRepository;
private readonly IEventAggregator _eventAggregator;
public EditionService(IEditionRepository editionRepository,
IEventAggregator eventAggregator)
{
_editionRepository = editionRepository;
_eventAggregator = eventAggregator;
}
public Edition GetEdition(int id)
{
return _editionRepository.Get(id);
}
public Edition GetEditionByForeignEditionId(string foreignEditionId)
{
return _editionRepository.FindByForeignEditionId(foreignEditionId);
}
public List<Edition> GetAllEditions()
{
return _editionRepository.All().ToList();
}
public void InsertMany(List<Edition> editions)
{
_editionRepository.InsertMany(editions);
}
public void UpdateMany(List<Edition> editions)
{
_editionRepository.UpdateMany(editions);
}
public void DeleteMany(List<Edition> editions)
{
_editionRepository.DeleteMany(editions);
foreach (var edition in editions)
{
_eventAggregator.PublishEvent(new EditionDeletedEvent(edition));
}
}
public List<Edition> GetEditionsForRefresh(int albumId, IEnumerable<string> foreignEditionIds)
{
return _editionRepository.GetEditionsForRefresh(albumId, foreignEditionIds);
}
public List<Edition> GetEditionsByBook(int bookId)
{
return _editionRepository.FindByBook(bookId);
}
public List<Edition> GetEditionsByAuthor(int authorId)
{
return _editionRepository.FindByAuthor(authorId);
}
public List<Edition> SetMonitored(Edition edition)
{
return _editionRepository.SetMonitored(edition);
}
public void Handle(BookDeletedEvent message)
{
var editions = GetEditionsByBook(message.Book.Id);
DeleteMany(editions);
}
}
}

@ -21,7 +21,12 @@ using NzbDrone.Core.RootFolders;
namespace NzbDrone.Core.Books
{
public interface IRefreshAuthorService
{
}
public class RefreshAuthorService : RefreshEntityServiceBase<Author, Book>,
IRefreshAuthorService,
IExecute<RefreshAuthorCommand>,
IExecute<BulkRefreshAuthorCommand>
{
@ -76,11 +81,11 @@ namespace NzbDrone.Core.Books
_logger = logger;
}
private Author GetSkyhookData(string foreignId)
private Author GetSkyhookData(string foreignId, double minPopularity)
{
try
{
return _authorInfo.GetAuthorInfo(foreignId);
return _authorInfo.GetAuthorAndBooks(foreignId, minPopularity);
}
catch (AuthorNotFoundException)
{
@ -278,7 +283,6 @@ namespace NzbDrone.Core.Books
{
// little hack - trigger the series update here
_refreshSeriesService.RefreshSeriesInfo(entity.AuthorMetadataId, entity.Series, entity, false, false, null);
_eventAggregator.PublishEvent(new AuthorRefreshCompleteEvent(entity));
}
@ -332,7 +336,7 @@ namespace NzbDrone.Core.Books
{
try
{
var data = GetSkyhookData(author.ForeignAuthorId);
var data = GetSkyhookData(author.ForeignAuthorId, author.MetadataProfile.Value.MinPopularity);
updated |= RefreshEntityInfo(author, null, data, true, false, null);
}
catch (Exception e)
@ -381,7 +385,7 @@ namespace NzbDrone.Core.Books
{
try
{
var data = GetSkyhookData(author.ForeignAuthorId);
var data = GetSkyhookData(author.ForeignAuthorId, author.MetadataProfile.Value.MinPopularity);
updated |= RefreshEntityInfo(author, null, data, manualTrigger, false, message.LastStartTime);
}
catch (Exception e)

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Books.Events;
using NzbDrone.Core.History;
@ -18,12 +20,14 @@ namespace NzbDrone.Core.Books
bool RefreshBookInfo(List<Book> books, List<Book> remoteBooks, Author remoteData, bool forceBookRefresh, bool forceUpdateFileTags, DateTime? lastUpdate);
}
public class RefreshBookService : RefreshEntityServiceBase<Book, object>, IRefreshBookService
public class RefreshBookService : RefreshEntityServiceBase<Book, Edition>, IRefreshBookService
{
private readonly IBookService _bookService;
private readonly IAuthorService _authorService;
private readonly IAddAuthorService _addAuthorService;
private readonly IEditionService _editionService;
private readonly IProvideBookInfo _bookInfo;
private readonly IRefreshEditionService _refreshEditionService;
private readonly IMediaFileService _mediaFileService;
private readonly IHistoryService _historyService;
private readonly IEventAggregator _eventAggregator;
@ -32,22 +36,26 @@ namespace NzbDrone.Core.Books
private readonly Logger _logger;
public RefreshBookService(IBookService bookService,
IAuthorService authorService,
IAddAuthorService addAuthorService,
IAuthorMetadataService authorMetadataService,
IProvideBookInfo bookInfo,
IMediaFileService mediaFileService,
IHistoryService historyService,
IEventAggregator eventAggregator,
ICheckIfBookShouldBeRefreshed checkIfBookShouldBeRefreshed,
IMapCoversToLocal mediaCoverService,
Logger logger)
IAuthorService authorService,
IAddAuthorService addAuthorService,
IEditionService editionService,
IAuthorMetadataService authorMetadataService,
IProvideBookInfo bookInfo,
IRefreshEditionService refreshEditionService,
IMediaFileService mediaFileService,
IHistoryService historyService,
IEventAggregator eventAggregator,
ICheckIfBookShouldBeRefreshed checkIfBookShouldBeRefreshed,
IMapCoversToLocal mediaCoverService,
Logger logger)
: base(logger, authorMetadataService)
{
_bookService = bookService;
_authorService = authorService;
_addAuthorService = addAuthorService;
_editionService = editionService;
_bookInfo = bookInfo;
_refreshEditionService = refreshEditionService;
_mediaFileService = mediaFileService;
_historyService = historyService;
_eventAggregator = eventAggregator;
@ -60,7 +68,7 @@ namespace NzbDrone.Core.Books
{
var result = new RemoteData();
var book = remote.SingleOrDefault(x => x.ForeignWorkId == local.ForeignWorkId);
var book = remote.SingleOrDefault(x => x.ForeignBookId == local.ForeignBookId);
if (book == null && ShouldDelete(local))
{
@ -69,7 +77,7 @@ namespace NzbDrone.Core.Books
if (book == null)
{
book = data.Books.Value.SingleOrDefault(x => x.ForeignWorkId == local.ForeignWorkId);
book = data.Books.Value.SingleOrDefault(x => x.ForeignBookId == local.ForeignBookId);
}
result.Entity = book;
@ -167,7 +175,7 @@ namespace NzbDrone.Core.Books
// Update book ids for trackfiles
var files = _mediaFileService.GetFilesByBook(local.Id);
files.ForEach(x => x.BookId = target.Id);
files.ForEach(x => x.EditionId = target.Id);
_mediaFileService.Update(files);
// Update book ids for history
@ -197,36 +205,70 @@ namespace NzbDrone.Core.Books
_bookService.DeleteBook(local.Id, true);
}
protected override List<object> GetRemoteChildren(Book local, Book remote)
protected override List<Edition> GetRemoteChildren(Book local, Book remote)
{
return new List<object>();
return remote.Editions.Value.DistinctBy(m => m.ForeignEditionId).ToList();
}
protected override List<object> GetLocalChildren(Book entity, List<object> remoteChildren)
protected override List<Edition> GetLocalChildren(Book entity, List<Edition> remoteChildren)
{
return new List<object>();
return _editionService.GetEditionsForRefresh(entity.Id, remoteChildren.Select(x => x.ForeignEditionId));
}
protected override Tuple<object, List<object>> GetMatchingExistingChildren(List<object> existingChildren, object remote)
protected override Tuple<Edition, List<Edition>> GetMatchingExistingChildren(List<Edition> existingChildren, Edition remote)
{
return null;
var existingChild = existingChildren.SingleOrDefault(x => x.ForeignEditionId == remote.ForeignEditionId);
return Tuple.Create(existingChild, new List<Edition>());
}
protected override void PrepareNewChild(object child, Book entity)
protected override void PrepareNewChild(Edition child, Book entity)
{
child.BookId = entity.Id;
child.Book = entity;
}
protected override void PrepareExistingChild(object local, object remote, Book entity)
protected override void PrepareExistingChild(Edition local, Edition remote, Book entity)
{
local.BookId = entity.Id;
local.Book = entity;
remote.UseDbFieldsFrom(local);
}
protected override void AddChildren(List<object> children)
protected override void AddChildren(List<Edition> children)
{
// hack - add the chilren in refresh children so we can control monitored status
}
protected override bool RefreshChildren(SortedChildren localChildren, List<object> remoteChildren, Author remoteData, bool forceChildRefresh, bool forceUpdateFileTags, DateTime? lastUpdate)
private void MonitorSingleEdition(List<Edition> releases)
{
return false;
var monitored = releases.Where(x => x.Monitored).ToList();
if (!monitored.Any())
{
monitored = releases;
}
var toMonitor = monitored.OrderByDescending(x => _mediaFileService.GetFilesByEdition(x.Id).Count)
.ThenByDescending(x => x.Ratings.Votes)
.First();
releases.ForEach(x => x.Monitored = false);
toMonitor.Monitored = true;
Debug.Assert(!releases.Any() || releases.Count(x => x.Monitored) == 1, "one edition monitored");
}
protected override bool RefreshChildren(SortedChildren localChildren, List<Edition> remoteChildren, Author remoteData, bool forceChildRefresh, bool forceUpdateFileTags, DateTime? lastUpdate)
{
// make sure only one of the releases ends up monitored
localChildren.Old.ForEach(x => x.Monitored = false);
MonitorSingleEdition(localChildren.Future);
localChildren.All.ForEach(x => _logger.Trace($"release: {x} monitored: {x.Monitored}"));
_editionService.InsertMany(localChildren.Added);
return _refreshEditionService.RefreshEditionInfo(localChildren.Added, localChildren.Updated, localChildren.Merged, localChildren.Deleted, localChildren.UpToDate, remoteChildren, forceUpdateFileTags);
}
protected override void PublishEntityUpdatedEvent(Book entity)

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Core.MediaFiles;
namespace NzbDrone.Core.Books
{
public interface IRefreshEditionService
{
bool RefreshEditionInfo(List<Edition> add, List<Edition> update, List<Tuple<Edition, Edition>> merge, List<Edition> delete, List<Edition> upToDate, List<Edition> remoteEditions, bool forceUpdateFileTags);
}
public class RefreshEditionService : IRefreshEditionService
{
private readonly IEditionService _editionService;
private readonly IAudioTagService _audioTagService;
private readonly Logger _logger;
public RefreshEditionService(IEditionService editionService,
IAudioTagService audioTagService,
Logger logger)
{
_editionService = editionService;
_audioTagService = audioTagService;
_logger = logger;
}
public bool RefreshEditionInfo(List<Edition> add, List<Edition> update, List<Tuple<Edition, Edition>> merge, List<Edition> delete, List<Edition> upToDate, List<Edition> remoteEditions, bool forceUpdateFileTags)
{
var updateList = new List<Edition>();
// for editions that need updating, just grab the remote edition and set db ids
foreach (var edition in update)
{
var remoteEdition = remoteEditions.Single(e => e.ForeignEditionId == edition.ForeignEditionId);
edition.UseMetadataFrom(remoteEdition);
// make sure title is not null
edition.Title = edition.Title ?? "Unknown";
updateList.Add(edition);
}
_editionService.DeleteMany(delete.Concat(merge.Select(x => x.Item1)).ToList());
_editionService.UpdateMany(updateList);
var tagsToUpdate = updateList;
if (forceUpdateFileTags)
{
_logger.Debug("Forcing tag update due to Author/Book/Edition updates");
tagsToUpdate = updateList.Concat(upToDate).ToList();
}
_audioTagService.SyncTags(tagsToUpdate);
return add.Any() || delete.Any() || updateList.Any() || merge.Any();
}
}
}

@ -129,13 +129,13 @@ namespace NzbDrone.Core.Books
var existing = existingByAuthor.Concat(existingBySeries).GroupBy(x => x.ForeignSeriesId).Select(x => x.First()).ToList();
var books = _bookService.GetBooksByAuthorMetadataId(authorMetadataId);
var bookDict = books.ToDictionary(x => x.ForeignWorkId);
var bookDict = books.ToDictionary(x => x.ForeignBookId);
var links = new List<SeriesBookLink>();
foreach (var s in remoteData.Series.Value)
{
s.LinkItems.Value.ForEach(x => x.Series = s);
links.AddRange(s.LinkItems.Value.Where(x => bookDict.ContainsKey(x.Book.Value.ForeignWorkId)));
links.AddRange(s.LinkItems.Value.Where(x => bookDict.ContainsKey(x.Book.Value.ForeignBookId)));
}
var grouped = links.GroupBy(x => x.Series.Value);

@ -53,12 +53,14 @@ namespace NzbDrone.Core.Datastore.Migration
Create.TableForModel("AuthorMetadata")
.WithColumn("ForeignAuthorId").AsString().Unique()
.WithColumn("GoodreadsId").AsInt32()
.WithColumn("TitleSlug").AsString().Unique()
.WithColumn("Name").AsString()
.WithColumn("Overview").AsString().Nullable()
.WithColumn("Disambiguation").AsString().Nullable()
.WithColumn("Type").AsString().Nullable()
.WithColumn("Gender").AsString().Nullable()
.WithColumn("Hometown").AsString().Nullable()
.WithColumn("Born").AsDateTime().Nullable()
.WithColumn("Died").AsDateTime().Nullable()
.WithColumn("Status").AsInt32()
.WithColumn("Images").AsString()
.WithColumn("Links").AsString().Nullable()
@ -68,31 +70,43 @@ namespace NzbDrone.Core.Datastore.Migration
Create.TableForModel("Books")
.WithColumn("AuthorMetadataId").AsInt32().WithDefaultValue(0)
.WithColumn("ForeignBookId").AsString().Unique()
.WithColumn("ForeignWorkId").AsString().Indexed()
.WithColumn("GoodreadsId").AsInt32()
.WithColumn("ForeignBookId").AsString().Indexed()
.WithColumn("TitleSlug").AsString().Unique()
.WithColumn("Title").AsString()
.WithColumn("ReleaseDate").AsDateTime().Nullable()
.WithColumn("Links").AsString().Nullable()
.WithColumn("Genres").AsString().Nullable()
.WithColumn("Ratings").AsString().Nullable()
.WithColumn("CleanTitle").AsString().Indexed()
.WithColumn("Monitored").AsBoolean()
.WithColumn("AnyEditionOk").AsBoolean()
.WithColumn("LastInfoSync").AsDateTime().Nullable()
.WithColumn("Added").AsDateTime().Nullable()
.WithColumn("AddOptions").AsString().Nullable();
Create.TableForModel("Editions")
.WithColumn("BookId").AsInt32().WithDefaultValue(0)
.WithColumn("ForeignEditionId").AsString().Unique()
.WithColumn("Isbn13").AsString().Nullable()
.WithColumn("Asin").AsString().Nullable()
.WithColumn("Title").AsString()
.WithColumn("TitleSlug").AsString()
.WithColumn("Language").AsString().Nullable()
.WithColumn("Overview").AsString().Nullable()
.WithColumn("PageCount").AsInt32().Nullable()
.WithColumn("Format").AsString().Nullable()
.WithColumn("IsEbook").AsBoolean().Nullable()
.WithColumn("Disambiguation").AsString().Nullable()
.WithColumn("Publisher").AsString().Nullable()
.WithColumn("PageCount").AsInt32().Nullable()
.WithColumn("ReleaseDate").AsDateTime().Nullable()
.WithColumn("Images").AsString()
.WithColumn("Links").AsString().Nullable()
.WithColumn("Genres").AsString().Nullable()
.WithColumn("Ratings").AsString().Nullable()
.WithColumn("CleanTitle").AsString().Indexed()
.WithColumn("Monitored").AsBoolean()
.WithColumn("LastInfoSync").AsDateTime().Nullable()
.WithColumn("Added").AsDateTime().Nullable()
.WithColumn("AddOptions").AsString().Nullable();
.WithColumn("ManualAdd").AsBoolean();
Create.TableForModel("BookFiles")
.WithColumn("BookId").AsInt32().Indexed()
.WithColumn("EditionId").AsInt32().Indexed()
.WithColumn("CalibreId").AsInt32()
.WithColumn("Quality").AsString()
.WithColumn("Size").AsInt64()
@ -152,8 +166,7 @@ namespace NzbDrone.Core.Datastore.Migration
Create.TableForModel("MetadataProfiles")
.WithColumn("Name").AsString().Unique()
.WithColumn("MinRating").AsDouble()
.WithColumn("MinRatingCount").AsInt32()
.WithColumn("MinPopularity").AsDouble()
.WithColumn("SkipMissingDate").AsBoolean()
.WithColumn("SkipMissingIsbn").AsBoolean()
.WithColumn("SkipPartsAndSets").AsBoolean()

@ -123,9 +123,12 @@ namespace NzbDrone.Core.Datastore
.HasOne(r => r.AuthorMetadata, r => r.AuthorMetadataId)
.LazyLoad(x => x.BookFiles,
(db, book) => db.Query<BookFile>(new SqlBuilder()
.Join<BookFile, Book>((l, r) => l.BookId == r.Id)
.Join<BookFile, Book>((l, r) => l.EditionId == r.Id)
.Where<Book>(b => b.Id == book.Id)).ToList(),
b => b.Id > 0)
.LazyLoad(x => x.Editions,
(db, book) => db.Query<Edition>(new SqlBuilder().Where<Edition>(e => e.BookId == book.Id)).ToList(),
b => b.Id > 0)
.LazyLoad(a => a.Author,
(db, book) => AuthorRepository.Query(db,
new SqlBuilder()
@ -133,14 +136,22 @@ namespace NzbDrone.Core.Datastore
.Where<Author>(a => a.AuthorMetadataId == book.AuthorMetadataId)).SingleOrDefault(),
a => a.AuthorMetadataId > 0);
Mapper.Entity<Edition>("Editions").RegisterModel()
.HasOne(r => r.Book, r => r.BookId)
.LazyLoad(x => x.BookFiles,
(db, book) => db.Query<BookFile>(new SqlBuilder()
.Join<BookFile, Book>((l, r) => l.EditionId == r.Id)
.Where<Book>(b => b.Id == book.Id)).ToList(),
b => b.Id > 0);
Mapper.Entity<BookFile>("BookFiles").RegisterModel()
.HasOne(f => f.Book, f => f.BookId)
.HasOne(f => f.Edition, f => f.EditionId)
.LazyLoad(x => x.Author,
(db, f) => AuthorRepository.Query(db,
new SqlBuilder()
.Join<Author, AuthorMetadata>((a, m) => a.AuthorMetadataId == m.Id)
.Join<Author, Book>((l, r) => l.AuthorMetadataId == r.AuthorMetadataId)
.Where<Book>(a => a.Id == f.BookId)).SingleOrDefault(),
.Where<Book>(a => a.Id == f.EditionId)).SingleOrDefault(),
t => t.Id > 0);
Mapper.Entity<QualityDefinition>("QualityDefinitions").RegisterModel()

@ -143,7 +143,7 @@ namespace NzbDrone.Core.Extras
public void Handle(TrackFolderCreatedEvent message)
{
var author = message.Author;
var book = _bookService.GetBook(message.BookFile.BookId);
var book = _bookService.GetBook(message.BookFile.EditionId);
foreach (var extraFileManager in _extraFileManagers)
{

@ -72,7 +72,7 @@ namespace NzbDrone.Core.Extras.Files
return new TExtraFile
{
AuthorId = author.Id,
BookId = bookFile.BookId,
BookId = bookFile.EditionId,
BookFileId = bookFile.Id,
RelativePath = author.Path.GetRelativePath(newFileName),
Extension = extension

@ -144,7 +144,7 @@ namespace NzbDrone.Core.Extras.Metadata
foreach (var filePath in distinctTrackFilePaths)
{
var metadataFilesForConsumer = GetMetadataFilesForConsumer(consumer, metadataFiles)
.Where(m => m.BookId == filePath.BookId)
.Where(m => m.BookId == filePath.EditionId)
.Where(m => m.Type == MetadataType.BookImage || m.Type == MetadataType.BookMetadata)
.ToList();
@ -287,7 +287,7 @@ namespace NzbDrone.Core.Extras.Metadata
new MetadataFile
{
AuthorId = author.Id,
BookId = bookFile.BookId,
BookId = bookFile.EditionId,
BookFileId = bookFile.Id,
Consumer = consumer.GetType().Name,
Type = MetadataType.BookMetadata,

@ -294,7 +294,7 @@ namespace NzbDrone.Core.History
Quality = message.BookFile.Quality,
SourceTitle = message.BookFile.Path,
AuthorId = message.BookFile.Author.Value.Id,
BookId = message.BookFile.BookId
BookId = message.BookFile.EditionId
};
history.Data.Add("Reason", message.Reason.ToString());
@ -314,7 +314,7 @@ namespace NzbDrone.Core.History
Quality = message.BookFile.Quality,
SourceTitle = message.OriginalPath,
AuthorId = message.BookFile.Author.Value.Id,
BookId = message.BookFile.BookId
BookId = message.BookFile.EditionId
};
history.Data.Add("SourcePath", sourcePath);
@ -334,7 +334,7 @@ namespace NzbDrone.Core.History
Quality = message.BookFile.Quality,
SourceTitle = path,
AuthorId = message.BookFile.Author.Value.Id,
BookId = message.BookFile.BookId
BookId = message.BookFile.EditionId
};
history.Data.Add("TagsScrubbed", message.Scrubbed.ToString());

@ -18,12 +18,12 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers
{
// Unlink where track no longer exists
mapper.Execute(@"UPDATE BookFiles
SET BookId = 0
SET EditionId = 0
WHERE Id IN (
SELECT BookFiles.Id FROM BookFiles
LEFT OUTER JOIN Books
ON BookFiles.BookId = Books.Id
WHERE Books.Id IS NULL)");
LEFT OUTER JOIN Editions
ON BookFiles.EditionId = Editions.Id
WHERE Editions.Id IS NULL)");
}
}
}

@ -139,7 +139,7 @@ namespace NzbDrone.Core.ImportLists
if (report.AlbumMusicBrainzId.IsNotNullOrWhiteSpace() && int.TryParse(report.AlbumMusicBrainzId, out var goodreadsId))
{
mappedAlbum = _bookSearchService.SearchByGoodreadsId(goodreadsId).FirstOrDefault(x => x.GoodreadsId == goodreadsId);
mappedAlbum = _bookSearchService.SearchByGoodreadsId(goodreadsId).FirstOrDefault(x => int.TryParse(x.ForeignBookId, out var bookId) && bookId == goodreadsId);
}
else
{

@ -72,17 +72,13 @@ namespace NzbDrone.Core.IndexerSearch
var searchSpec = Get<BookSearchCriteria>(author, new List<Book> { book }, userInvokedSearch, interactiveSearch);
searchSpec.BookTitle = book.Title;
searchSpec.BookIsbn = book.Isbn13;
// searchSpec.BookIsbn = book.Isbn13;
if (book.ReleaseDate.HasValue)
{
searchSpec.BookYear = book.ReleaseDate.Value.Year;
}
if (book.Disambiguation.IsNotNullOrWhiteSpace())
{
searchSpec.Disambiguation = book.Disambiguation;
}
return Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec);
}

@ -32,7 +32,7 @@ namespace NzbDrone.Core.Indexers.Newznab
}
}
private bool SupportsAudioSearch
private bool SupportsBookSearch
{
get
{
@ -67,7 +67,7 @@ namespace NzbDrone.Core.Indexers.Newznab
{
var pageableRequests = new IndexerPageableRequestChain();
if (SupportsAudioSearch)
if (SupportsBookSearch)
{
AddBookPageableRequests(pageableRequests,
searchCriteria,
@ -78,12 +78,17 @@ namespace NzbDrone.Core.Indexers.Newznab
{
pageableRequests.AddTier();
pageableRequests.Add(GetPagedRequests(MaxPages,
/* pageableRequests.Add(GetPagedRequests(MaxPages,
Settings.Categories,
"search",
NewsnabifyTitle($"&q={searchCriteria.BookIsbn}")));
pageableRequests.AddTier();
pageableRequests.AddTier();*/
pageableRequests.Add(GetPagedRequests(MaxPages,
Settings.Categories,
"search",
NewsnabifyTitle($"&q={searchCriteria.BookQuery}+{searchCriteria.AuthorQuery}")));
pageableRequests.Add(GetPagedRequests(MaxPages,
Settings.Categories,
@ -98,7 +103,7 @@ namespace NzbDrone.Core.Indexers.Newznab
{
var pageableRequests = new IndexerPageableRequestChain();
if (SupportsAudioSearch)
if (SupportsBookSearch)
{
AddBookPageableRequests(pageableRequests,
searchCriteria,
@ -122,7 +127,7 @@ namespace NzbDrone.Core.Indexers.Newznab
{
chain.AddTier();
chain.Add(GetPagedRequests(MaxPages, Settings.Categories, "book", $"&q={parameters}"));
chain.Add(GetPagedRequests(MaxPages, Settings.Categories, "book", $"{parameters}"));
}
private IEnumerable<IndexerRequest> GetPagedRequests(int maxPages, IEnumerable<int> categories, string searchType, string parameters)

@ -91,7 +91,7 @@ namespace NzbDrone.Core.MediaCover
if (coverEntity == MediaCoverEntity.Book)
{
mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Albums/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + mediaCover.Extension;
mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Books/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + mediaCover.Extension;
}
else
{
@ -113,7 +113,7 @@ namespace NzbDrone.Core.MediaCover
private string GetAlbumCoverPath(int bookId)
{
return Path.Combine(_coverRootFolder, "Albums", bookId.ToString());
return Path.Combine(_coverRootFolder, "Books", bookId.ToString());
}
private void EnsureArtistCovers(Author author)
@ -163,7 +163,7 @@ namespace NzbDrone.Core.MediaCover
public void EnsureAlbumCovers(Book book)
{
foreach (var cover in book.Images.Where(e => e.CoverType == MediaCoverTypes.Cover))
foreach (var cover in book.Editions.Value.Single(x => x.Monitored).Images.Where(e => e.CoverType == MediaCoverTypes.Cover))
{
var fileName = GetCoverPath(book.Id, MediaCoverEntity.Book, cover.CoverType, cover.Extension, null);
var alreadyExists = false;

@ -23,7 +23,7 @@ namespace NzbDrone.Core.MediaFiles
{
ParsedTrackInfo ReadTags(string file);
void WriteTags(BookFile trackfile, bool newDownload, bool force = false);
void SyncTags(List<Book> tracks);
void SyncTags(List<Edition> tracks);
List<RetagBookFilePreview> GetRetagPreviewsByArtist(int authorId);
List<RetagBookFilePreview> GetRetagPreviewsByAlbum(int authorId);
}
@ -148,7 +148,7 @@ namespace NzbDrone.Core.MediaFiles
_eventAggregator.PublishEvent(new BookFileRetaggedEvent(trackfile.Author.Value, trackfile, diff, _configService.ScrubAudioTags));
}
public void SyncTags(List<Book> books)
public void SyncTags(List<Edition> editions)
{
if (_configService.WriteAudioTags != WriteAudioTagsType.Sync)
{
@ -156,9 +156,9 @@ namespace NzbDrone.Core.MediaFiles
}
// get the tracks to update
foreach (var book in books)
foreach (var edition in editions)
{
var bookFiles = book.BookFiles.Value;
var bookFiles = edition.BookFiles.Value;
_logger.Debug($"Syncing audio tags for {bookFiles.Count} files");
@ -166,7 +166,7 @@ namespace NzbDrone.Core.MediaFiles
{
// populate tracks (which should also have release/book/author set) because
// not all of the updates will have been committed to the database yet
file.Book = book;
file.Edition = edition;
WriteTags(file, false);
}
}
@ -188,11 +188,11 @@ namespace NzbDrone.Core.MediaFiles
private IEnumerable<RetagBookFilePreview> GetPreviews(List<BookFile> files)
{
foreach (var f in files.OrderBy(x => x.Book.Value.Title))
foreach (var f in files.OrderBy(x => x.Edition.Value.Title))
{
var file = f;
if (f.Book.Value == null)
if (f.Edition.Value == null)
{
_logger.Warn($"File {f} is not linked to any books");
continue;
@ -207,7 +207,7 @@ namespace NzbDrone.Core.MediaFiles
yield return new RetagBookFilePreview
{
AuthorId = file.Author.Value.Id,
BookId = file.Book.Value.Id,
BookId = file.Edition.Value.Id,
BookFileId = file.Id,
Path = file.Path,
Changes = diff

@ -19,12 +19,12 @@ namespace NzbDrone.Core.MediaFiles
public string ReleaseGroup { get; set; }
public QualityModel Quality { get; set; }
public MediaInfoModel MediaInfo { get; set; }
public int BookId { get; set; }
public int EditionId { get; set; }
public int CalibreId { get; set; }
// These are queried from the database
public LazyLoaded<Author> Author { get; set; }
public LazyLoaded<Book> Book { get; set; }
public LazyLoaded<Edition> Edition { get; set; }
public override string ToString()
{

@ -60,9 +60,9 @@ namespace NzbDrone.Core.MediaFiles
public BookFile MoveBookFile(BookFile bookFile, Author author)
{
var book = _bookService.GetBook(bookFile.BookId);
var newFileName = _buildFileNames.BuildBookFileName(author, book, bookFile);
var filePath = _buildFileNames.BuildBookFilePath(author, book, newFileName, Path.GetExtension(bookFile.Path));
var book = _bookService.GetBook(bookFile.EditionId);
var newFileName = _buildFileNames.BuildBookFileName(author, bookFile.Edition.Value, bookFile);
var filePath = _buildFileNames.BuildBookFilePath(author, bookFile.Edition.Value, newFileName, Path.GetExtension(bookFile.Path));
EnsureBookFolder(bookFile, author, book, filePath);
@ -73,8 +73,8 @@ namespace NzbDrone.Core.MediaFiles
public BookFile MoveBookFile(BookFile bookFile, LocalBook localBook)
{
var newFileName = _buildFileNames.BuildBookFileName(localBook.Author, localBook.Book, bookFile);
var filePath = _buildFileNames.BuildBookFilePath(localBook.Author, localBook.Book, newFileName, Path.GetExtension(localBook.Path));
var newFileName = _buildFileNames.BuildBookFileName(localBook.Author, localBook.Edition, bookFile);
var filePath = _buildFileNames.BuildBookFilePath(localBook.Author, localBook.Edition, newFileName, Path.GetExtension(localBook.Path));
EnsureTrackFolder(bookFile, localBook, filePath);
@ -85,8 +85,8 @@ namespace NzbDrone.Core.MediaFiles
public BookFile CopyBookFile(BookFile bookFile, LocalBook localBook)
{
var newFileName = _buildFileNames.BuildBookFileName(localBook.Author, localBook.Book, bookFile);
var filePath = _buildFileNames.BuildBookFilePath(localBook.Author, localBook.Book, newFileName, Path.GetExtension(localBook.Path));
var newFileName = _buildFileNames.BuildBookFileName(localBook.Author, localBook.Edition, bookFile);
var filePath = _buildFileNames.BuildBookFilePath(localBook.Author, localBook.Edition, newFileName, Path.GetExtension(localBook.Path));
EnsureTrackFolder(bookFile, localBook, filePath);
@ -147,7 +147,7 @@ namespace NzbDrone.Core.MediaFiles
private void EnsureBookFolder(BookFile bookFile, Author author, Book book, string filePath)
{
var trackFolder = Path.GetDirectoryName(filePath);
var bookFolder = _buildFileNames.BuildBookPath(author, book);
var bookFolder = _buildFileNames.BuildBookPath(author);
var authorFolder = author.Path;
var rootFolder = new OsPath(authorFolder).Directory.FullPath;

@ -11,18 +11,18 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Aggregation
public interface IAugmentingService
{
LocalBook Augment(LocalBook localTrack, bool otherFiles);
LocalAlbumRelease Augment(LocalAlbumRelease localAlbum);
LocalEdition Augment(LocalEdition localAlbum);
}
public class AugmentingService : IAugmentingService
{
private readonly IEnumerable<IAggregate<LocalBook>> _trackAugmenters;
private readonly IEnumerable<IAggregate<LocalAlbumRelease>> _albumAugmenters;
private readonly IEnumerable<IAggregate<LocalEdition>> _albumAugmenters;
private readonly IDiskProvider _diskProvider;
private readonly Logger _logger;
public AugmentingService(IEnumerable<IAggregate<LocalBook>> trackAugmenters,
IEnumerable<IAggregate<LocalAlbumRelease>> albumAugmenters,
IEnumerable<IAggregate<LocalEdition>> albumAugmenters,
IDiskProvider diskProvider,
Logger logger)
{
@ -61,7 +61,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Aggregation
return localTrack;
}
public LocalAlbumRelease Augment(LocalAlbumRelease localAlbum)
public LocalEdition Augment(LocalEdition localAlbum)
{
foreach (var augmenter in _albumAugmenters)
{

@ -9,7 +9,7 @@ using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.MediaFiles.BookImport.Aggregation.Aggregators
{
public class AggregateFilenameInfo : IAggregate<LocalAlbumRelease>
public class AggregateFilenameInfo : IAggregate<LocalEdition>
{
private readonly Logger _logger;
@ -55,7 +55,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Aggregation.Aggregators
_logger = logger;
}
public LocalAlbumRelease Aggregate(LocalAlbumRelease release, bool others)
public LocalEdition Aggregate(LocalEdition release, bool others)
{
var tracks = release.LocalBooks;
if (tracks.Count(x => x.FileTrackInfo.Title.IsNullOrWhiteSpace()) > 0

@ -1,21 +0,0 @@
using System.Collections.Generic;
using NzbDrone.Core.Books;
namespace NzbDrone.Core.MediaFiles.BookImport.Identification
{
public class CandidateAlbumRelease
{
public CandidateAlbumRelease()
{
}
public CandidateAlbumRelease(Book book)
{
Book = book;
ExistingTracks = new List<BookFile>();
}
public Book Book { get; set; }
public List<BookFile> ExistingTracks { get; set; }
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save