diff --git a/frontend/src/Author/AuthorBanner.js b/frontend/src/Author/AuthorBanner.js index ef249926d..61708f829 100644 --- a/frontend/src/Author/AuthorBanner.js +++ b/frontend/src/Author/AuthorBanner.js @@ -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 ( diff --git a/frontend/src/Author/AuthorImage.js b/frontend/src/Author/AuthorImage.js index e86a45faf..3900deb19 100644 --- a/frontend/src/Author/AuthorImage.js +++ b/frontend/src/Author/AuthorImage.js @@ -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`); diff --git a/frontend/src/Author/Details/AuthorDetails.js b/frontend/src/Author/Details/AuthorDetails.js index ff3b640e2..40bf4eb35 100644 --- a/frontend/src/Author/Details/AuthorDetails.js +++ b/frontend/src/Author/Details/AuthorDetails.js @@ -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 { /> - {continuing ? 'Continuing' : endedString} + {continuing ? 'Continuing' : 'Deceased'} @@ -515,7 +514,7 @@ class AuthorDetails extends Component {
]*>?/gm, '')} + text={stripHtml(overview)} />
@@ -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, diff --git a/frontend/src/Author/Details/AuthorDetailsSeries.js b/frontend/src/Author/Details/AuthorDetailsSeries.js index 78b6a0b18..3973505fc 100644 --- a/frontend/src/Author/Details/AuthorDetailsSeries.js +++ b/frontend/src/Author/Details/AuthorDetailsSeries.js @@ -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; diff --git a/frontend/src/Author/Details/BookRow.js b/frontend/src/Author/Details/BookRow.js index 1bfe6b114..a0c6d85c0 100644 --- a/frontend/src/Author/Details/BookRow.js +++ b/frontend/src/Author/Details/BookRow.js @@ -73,7 +73,6 @@ class BookRow extends Component { title, position, ratings, - disambiguation, isSaving, authorMonitored, titleSlug, @@ -124,7 +123,6 @@ class BookRow extends Component { ); @@ -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, diff --git a/frontend/src/Author/Index/Overview/AuthorIndexOverview.js b/frontend/src/Author/Index/Overview/AuthorIndexOverview.js index c236d3a7f..81fb73a11 100644 --- a/frontend/src/Author/Index/Overview/AuthorIndexOverview.js +++ b/frontend/src/Author/Index/Overview/AuthorIndexOverview.js @@ -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 { > diff --git a/frontend/src/Author/Index/Posters/AuthorIndexPoster.js b/frontend/src/Author/Index/Posters/AuthorIndexPoster.js index c97bc3240..23d5de42e 100644 --- a/frontend/src/Author/Index/Posters/AuthorIndexPoster.js +++ b/frontend/src/Author/Index/Posters/AuthorIndexPoster.js @@ -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 (
diff --git a/frontend/src/Author/Index/Table/AuthorIndexRow.js b/frontend/src/Author/Index/Table/AuthorIndexRow.js index 66fe2ee39..476033217 100644 --- a/frontend/src/Author/Index/Table/AuthorIndexRow.js +++ b/frontend/src/Author/Index/Table/AuthorIndexRow.js @@ -82,7 +82,6 @@ class AuthorIndexRow extends Component { status, authorName, titleSlug, - authorType, qualityProfile, metadataProfile, nextBook, @@ -134,7 +133,6 @@ class AuthorIndexRow extends Component { - {authorType} - - ); - } - if (name === 'qualityProfileId') { return ( ); @@ -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 diff --git a/frontend/src/Book/BookCover.js b/frontend/src/Book/BookCover.js index 22fde5ae4..2d27302d2 100644 --- a/frontend/src/Book/BookCover.js +++ b/frontend/src/Book/BookCover.js @@ -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 ( diff --git a/frontend/src/Book/Details/BookDetails.js b/frontend/src/Book/Details/BookDetails.js index 0cb83f59c..4bddffd2c 100644 --- a/frontend/src/Book/Details/BookDetails.js +++ b/frontend/src/Book/Details/BookDetails.js @@ -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 { + +
- {title}{disambiguation ? ` (${disambiguation})` : ''} + {title}
+
@@ -306,9 +303,9 @@ class BookDetails extends Component {
{ - !!duration && + !!pageCount && - {formatDuration(duration)} + {`${pageCount} pages`} } @@ -397,7 +394,7 @@ class BookDetails extends Component {
]*>?/gm, '')} + text={stripHtml(overview)} />
@@ -488,6 +485,14 @@ class BookDetails extends Component { {/* onModalClose={this.onRetagModalClose} */} {/* /> */} + + + + + ); +} + +EditBookModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditBookModal; diff --git a/frontend/src/Book/Edit/EditBookModalConnector.js b/frontend/src/Book/Edit/EditBookModalConnector.js new file mode 100644 index 000000000..3f861dc66 --- /dev/null +++ b/frontend/src/Book/Edit/EditBookModalConnector.js @@ -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 ( + + ); + } +} + +EditBookModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(EditBookModalConnector); diff --git a/frontend/src/Book/Edit/EditBookModalContent.js b/frontend/src/Book/Edit/EditBookModalContent.js new file mode 100644 index 000000000..97c21edc8 --- /dev/null +++ b/frontend/src/Book/Edit/EditBookModalContent.js @@ -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 ( + + + Edit - {authorName} - {title} + + + +
+ + Monitored + + + + + + Automatically Switch Edition + + + + + + Edition + + + + +
+
+ + + + + Save + + + +
+ ); + } +} + +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; diff --git a/frontend/src/Book/Edit/EditBookModalContentConnector.js b/frontend/src/Book/Edit/EditBookModalContentConnector.js new file mode 100644 index 000000000..39dc6b591 --- /dev/null +++ b/frontend/src/Book/Edit/EditBookModalContentConnector.js @@ -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 ( + + ); + } +} + +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); diff --git a/frontend/src/Components/Form/BookEditionSelectInputConnector.js b/frontend/src/Components/Form/BookEditionSelectInputConnector.js new file mode 100644 index 000000000..fecede0c8 --- /dev/null +++ b/frontend/src/Components/Form/BookEditionSelectInputConnector.js @@ -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 ( + + ); + } +} + +BookEditionSelectInputConnector.propTypes = { + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + bookEditions: PropTypes.object +}; + +export default connect(createMapStateToProps)(BookEditionSelectInputConnector); diff --git a/frontend/src/Components/Form/BookReleaseSelectInputConnector.js b/frontend/src/Components/Form/BookReleaseSelectInputConnector.js deleted file mode 100644 index 47219acf6..000000000 --- a/frontend/src/Components/Form/BookReleaseSelectInputConnector.js +++ /dev/null @@ -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 ( - - ); - } -} - -BookReleaseSelectInputConnector.propTypes = { - name: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - bookReleases: PropTypes.object -}; - -export default connect(createMapStateToProps)(BookReleaseSelectInputConnector); diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 73552bef1..c4711fc31 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -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; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index ae6ba08ee..f55bd3248 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -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, diff --git a/frontend/src/InteractiveImport/Edition/SelectEditionModal.js b/frontend/src/InteractiveImport/Edition/SelectEditionModal.js new file mode 100644 index 000000000..59a387490 --- /dev/null +++ b/frontend/src/InteractiveImport/Edition/SelectEditionModal.js @@ -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 ( + + + + ); + } +} + +SelectEditionModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectEditionModal; diff --git a/frontend/src/InteractiveImport/Edition/SelectEditionModalContent.css b/frontend/src/InteractiveImport/Edition/SelectEditionModalContent.css new file mode 100644 index 000000000..54f67bb07 --- /dev/null +++ b/frontend/src/InteractiveImport/Edition/SelectEditionModalContent.css @@ -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; +} diff --git a/frontend/src/InteractiveImport/Edition/SelectEditionModalContent.js b/frontend/src/InteractiveImport/Edition/SelectEditionModalContent.js new file mode 100644 index 000000000..e5146a0e9 --- /dev/null +++ b/frontend/src/InteractiveImport/Edition/SelectEditionModalContent.js @@ -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 ( + + + Manual Import - Select Edition + + + + + Overrriding an edition here will disable automatic edition selection for that book in future. + + + + + { + books.map((item) => { + return ( + + ); + }) + } + +
+
+ + + + +
+ ); + } +} + +SelectEditionModalContent.propTypes = { + books: PropTypes.arrayOf(PropTypes.object).isRequired, + onEditionSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectEditionModalContent; diff --git a/frontend/src/InteractiveImport/Edition/SelectEditionModalContentConnector.js b/frontend/src/InteractiveImport/Edition/SelectEditionModalContentConnector.js new file mode 100644 index 000000000..d02682260 --- /dev/null +++ b/frontend/src/InteractiveImport/Edition/SelectEditionModalContentConnector.js @@ -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 ( + + ); + } +} + +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); diff --git a/frontend/src/InteractiveImport/Edition/SelectEditionRow.css b/frontend/src/InteractiveImport/Edition/SelectEditionRow.css new file mode 100644 index 000000000..e78f0bc19 --- /dev/null +++ b/frontend/src/InteractiveImport/Edition/SelectEditionRow.css @@ -0,0 +1,3 @@ +.albumRow { + cursor: pointer; +} diff --git a/frontend/src/InteractiveImport/Edition/SelectEditionRow.js b/frontend/src/InteractiveImport/Edition/SelectEditionRow.js new file mode 100644 index 000000000..1d2037a1f --- /dev/null +++ b/frontend/src/InteractiveImport/Edition/SelectEditionRow.js @@ -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 ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'book') { + return ( + + {extendedTitle} + + ); + } + + if (name === 'edition') { + return ( + + + + ); + } + + return null; + }) + } + + + ); + } +} + +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; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index bd382a37e..e6090baf5 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -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} /> + 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} + /> + diff --git a/frontend/src/Search/Author/AddNewAuthorSearchResult.js b/frontend/src/Search/Author/AddNewAuthorSearchResult.js index df837fe73..fd74959ec 100644 --- a/frontend/src/Search/Author/AddNewAuthorSearchResult.js +++ b/frontend/src/Search/Author/AddNewAuthorSearchResult.js @@ -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 {
- - { - authorType ? + ratings.votes > 0 ? : null } @@ -191,7 +186,7 @@ class AddNewAuthorSearchResult extends Component {
@@ -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, diff --git a/frontend/src/Search/Book/AddNewBookModalContent.js b/frontend/src/Search/Book/AddNewBookModalContent.js index 8a612c10b..e540fe7c6 100644 --- a/frontend/src/Search/Book/AddNewBookModalContent.js +++ b/frontend/src/Search/Book/AddNewBookModalContent.js @@ -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 {
: null diff --git a/frontend/src/Search/Book/AddNewBookSearchResult.js b/frontend/src/Search/Book/AddNewBookSearchResult.js index ad7be4357..b58567fa4 100644 --- a/frontend/src/Search/Book/AddNewBookSearchResult.js +++ b/frontend/src/Search/Book/AddNewBookSearchResult.js @@ -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 { @@ -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, diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js index c486ce8f6..26df2a7f7 100644 --- a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js @@ -32,8 +32,7 @@ function EditMetadataProfileModalContent(props) { const { id, name, - minRating, - minRatingCount, + minPopularity, skipMissingDate, skipMissingIsbn, skipPartsAndSets, @@ -73,27 +72,15 @@ function EditMetadataProfileModalContent(props) { - Minimum Rating + Minimum Popularity - - - - Minimum Number of Ratings - - diff --git a/frontend/src/Store/Actions/authorIndexActions.js b/frontend/src/Store/Actions/authorIndexActions.js index ab984527a..b4d5579f8 100644 --- a/frontend/src/Store/Actions/authorIndexActions.js +++ b/frontend/src/Store/Actions/authorIndexActions.js @@ -73,12 +73,6 @@ export const defaultState = { isVisible: true, isModifiable: false }, - { - name: 'authorType', - label: 'Type', - isSortable: true, - isVisible: true - }, { name: 'qualityProfileId', label: 'Quality Profile', diff --git a/frontend/src/Store/Actions/searchActions.js b/frontend/src/Store/Actions/searchActions.js index 1b2128118..510c6ba0d 100644 --- a/frontend/src/Store/Actions/searchActions.js +++ b/frontend/src/Store/Actions/searchActions.js @@ -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 }), diff --git a/frontend/src/Utilities/String/stripHtml.js b/frontend/src/Utilities/String/stripHtml.js new file mode 100644 index 000000000..c5dd964b4 --- /dev/null +++ b/frontend/src/Utilities/String/stripHtml.js @@ -0,0 +1,13 @@ +function stripHtml(html) { + if (!html) { + return html; + } + + const fiddled = html.replace(//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; diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 23be73b75..08dd23811 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -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: diff --git a/src/NzbDrone.Core.Test/ArtistStatsTests/ArtistStatisticsFixture.cs b/src/NzbDrone.Core.Test/ArtistStatsTests/ArtistStatisticsFixture.cs index e0ab1d989..ec3ec87b7 100644 --- a/src/NzbDrone.Core.Test/ArtistStatsTests/ArtistStatisticsFixture.cs +++ b/src/NzbDrone.Core.Test/ArtistStatsTests/ArtistStatisticsFixture.cs @@ -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.CreateNew() + .With(e => e.BookId = _album.Id) + .With(e => e.Monitored = true) + .BuildNew(); + Db.Insert(_edition); + _trackFile = Builder.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(); } diff --git a/src/NzbDrone.Core.Test/Datastore/LazyLoadingFixture.cs b/src/NzbDrone.Core.Test/Datastore/LazyLoadingFixture.cs index 5afd1eabd..693dc0bec 100644 --- a/src/NzbDrone.Core.Test/Datastore/LazyLoadingFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/LazyLoadingFixture.cs @@ -51,10 +51,23 @@ namespace NzbDrone.Core.Test.Datastore Db.InsertMany(albums); + var editions = new List(); + foreach (var album in albums) + { + editions.Add( + Builder.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.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(); var files = MediaFileRepository.Query(db, new SqlBuilder() - .Join((t, a) => t.BookId == a.Id) + .Join((t, a) => t.EditionId == a.Id) + .Join((e, b) => e.BookId == b.Id) .Join((album, artist) => album.AuthorMetadataId == artist.AuthorMetadataId) .Join((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(); - var files = db.QueryJoined( - new SqlBuilder() - .Join((t, a) => t.BookId == a.Id) - .Join((album, artist) => album.AuthorMetadataId == artist.AuthorMetadataId) - .Join((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); } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RepackSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RepackSpecificationFixture.cs index a584a4263..d660ab54d 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RepackSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RepackSpecificationFixture.cs @@ -37,7 +37,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _trackFiles = Builder.CreateListOfSize(3) .All() - .With(t => t.BookId = _albums.First().Id) + .With(t => t.EditionId = _albums.First().Id) .BuildList(); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs index 953f3b35f..4914b4b6a 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs @@ -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 { new Book { Id = 1 } }; diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedBookFilesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedBookFilesFixture.cs index 02e3938ee..8844613ab 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedBookFilesFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedBookFilesFixture.cs @@ -18,12 +18,12 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers { var trackFile = Builder.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); } } } diff --git a/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs index 5337c8ec7..c1898e7d5 100644 --- a/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs +++ b/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs @@ -43,7 +43,6 @@ namespace NzbDrone.Core.Test.ImportListTests .Returns(x => Builder .CreateListOfSize(1) .TheFirst(1) - .With(b => b.GoodreadsId = x) .With(b => b.ForeignBookId = x.ToString()) .BuildList()); diff --git a/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs b/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs index b1e2a8ad6..b4641be10 100644 --- a/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs @@ -18,8 +18,9 @@ namespace NzbDrone.Core.Test.MediaCoverTests [TestFixture] public class MediaCoverServiceFixture : CoreTest { - 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(new AppFolderInfo(Mocker.Resolve())); - _artist = Builder.CreateNew() + _author = Builder.CreateNew() .With(v => v.Id = 2) .With(v => v.Metadata.Value.Images = new List { new MediaCover.MediaCover(MediaCoverTypes.Poster, "") }) .Build(); - _album = Builder.CreateNew() - .With(v => v.Id = 4) + _edition = Builder.CreateNew() + .With(v => v.Id = 8) .With(v => v.Images = new List { new MediaCover.MediaCover(MediaCoverTypes.Cover, "") }) + .With(v => v.Monitored = true) + .Build(); + + _book = Builder.CreateNew() + .With(v => v.Id = 4) + .With(v => v.Editions = new List { _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() .Setup(v => v.GetBooksByAuthor(It.IsAny())) - .Returns(new List { _album }); + .Returns(new List { _book }); Mocker.GetMock() .Setup(v => v.FileExists(It.IsAny())) .Returns(true); - Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist)); + Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); @@ -161,13 +168,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests Mocker.GetMock() .Setup(v => v.GetBooksByAuthor(It.IsAny())) - .Returns(new List { _album }); + .Returns(new List { _book }); Mocker.GetMock() .Setup(v => v.FileExists(It.IsAny())) .Returns(false); - Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist)); + Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); @@ -186,13 +193,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests Mocker.GetMock() .Setup(v => v.GetBooksByAuthor(It.IsAny())) - .Returns(new List { _album }); + .Returns(new List { _book }); Mocker.GetMock() .Setup(v => v.GetFileSize(It.IsAny())) .Returns(1000); - Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist)); + Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); @@ -211,13 +218,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests Mocker.GetMock() .Setup(v => v.GetBooksByAuthor(It.IsAny())) - .Returns(new List { _album }); + .Returns(new List { _book }); Mocker.GetMock() .Setup(v => v.GetFileSize(It.IsAny())) .Returns(0); - Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist)); + Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); @@ -236,13 +243,13 @@ namespace NzbDrone.Core.Test.MediaCoverTests Mocker.GetMock() .Setup(v => v.GetBooksByAuthor(It.IsAny())) - .Returns(new List { _album }); + .Returns(new List { _book }); Mocker.GetMock() .Setup(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny())) .Throws(); - Subject.HandleAsync(new AuthorRefreshCompleteEvent(_artist)); + Subject.HandleAsync(new AuthorRefreshCompleteEvent(_author)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); diff --git a/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs index 12c382c81..bb68724e9 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs @@ -315,8 +315,12 @@ namespace NzbDrone.Core.Test.MediaFiles.AudioTagServiceFixture .With(x => x.Author = artist) .Build(); - var file = Builder.CreateNew() + var edition = Builder.CreateNew() .With(x => x.Book = album) + .Build(); + + var file = Builder.CreateNew() + .With(x => x.Edition = edition) .With(x => x.Author = artist) .Build(); diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs index 1da292901..2b944b28e 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs @@ -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.CreateNew() + .With(e => e.Book = album) + .Build(); + + var rootFolder = Builder.CreateNew() + .With(r => r.IsCalibreLibrary = false) + .Build(); + _rejectedDecisions.Add(new ImportDecision(new LocalBook(), new Rejection("Rejected!"))); _rejectedDecisions.Add(new ImportDecision(new LocalBook(), new Rejection("Rejected!"))); _rejectedDecisions.Add(new ImportDecision(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() .Setup(s => s.GetFilesByBook(It.IsAny())) .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.GetBestRootFolder(It.IsAny())) + .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() diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs index 714e2dd62..4b0082946 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs @@ -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.CreateNew() + .With(a => a.Id = 0) + .With(a => a.BookId = _album.Id) + .Build(); + Db.Insert(_edition); + var files = Builder.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().Where(x => x.BookId == _album.Id).Should().HaveCount(0); + Db.All().Where(x => x.EditionId == _album.Id).Should().HaveCount(0); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs index c3bc653e0..80791e688 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs @@ -213,7 +213,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests Path = "C:\\file2.avi".AsOsAgnostic(), Size = 10, Modified = _lastWrite, - Book = new LazyLoaded(null) + Edition = new LazyLoaded(null) } }); @@ -239,7 +239,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests Path = "C:\\file2.avi".AsOsAgnostic(), Size = 10, Modified = _lastWrite, - Book = Builder.CreateNew().Build() + Edition = Builder.CreateNew().Build() } }); diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs index 55f561da5..652ef1449 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs @@ -24,9 +24,9 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests _trackFiles = Builder.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(); } diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs index 69d4a64a2..770dc36d3 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs @@ -43,15 +43,15 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests .Build(); Mocker.GetMock() - .Setup(s => s.BuildBookFileName(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) + .Setup(s => s.BuildBookFileName(It.IsAny(), It.IsAny(), It.IsAny(), null, null)) .Returns("File Name"); Mocker.GetMock() - .Setup(s => s.BuildBookFilePath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(s => s.BuildBookFilePath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(@"C:\Test\Music\Artist\Album\File Name.mp3".AsOsAgnostic()); Mocker.GetMock() - .Setup(s => s.BuildBookPath(It.IsAny(), It.IsAny())) + .Setup(s => s.BuildBookPath(It.IsAny())) .Returns(@"C:\Test\Music\Artist\Album".AsOsAgnostic()); var rootFolder = @"C:\Test\Music\".AsOsAgnostic(); diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs index e908a708c..cc675949a 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Aggregation.Aggregators [TestFixture] public class AggregateFilenameInfoFixture : CoreTest { - private LocalAlbumRelease GivenTracks(List files, string root) + private LocalEdition GivenTracks(List 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) diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs index eebdd110a..3e60e29cb 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs @@ -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(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); - Mocker.SetConstant(Mocker.Resolve()); - Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); - _addAuthorService = Mocker.Resolve(); + _addAuthorService = Mocker.Resolve(); Mocker.SetConstant(Mocker.Resolve()); _refreshArtistService = Mocker.Resolve(); @@ -73,11 +73,11 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Identification Mocker.SetConstant(Mocker.Resolve()); // set up the augmenters - List> aggregators = new List> + List> aggregators = new List> { Mocker.Resolve() }; - Mocker.SetConstant>>(aggregators); + Mocker.SetConstant>>(aggregators); Mocker.SetConstant(Mocker.Resolve()); _Subject = Mocker.Resolve(); diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs deleted file mode 100644 index 30fb2cde8..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs +++ /dev/null @@ -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); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs index 3f25059e1..5f6ca677f 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs @@ -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> _albumpass1; - private Mock> _albumpass2; - private Mock> _albumpass3; + private Mock> _albumpass1; + private Mock> _albumpass2; + private Mock> _albumpass3; - private Mock> _albumfail1; - private Mock> _albumfail2; - private Mock> _albumfail3; + private Mock> _albumfail1; + private Mock> _albumfail2; + private Mock> _albumfail3; private Mock> _pass1; private Mock> _pass2; @@ -52,13 +53,13 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport [SetUp] public void Setup() { - _albumpass1 = new Mock>(); - _albumpass2 = new Mock>(); - _albumpass3 = new Mock>(); + _albumpass1 = new Mock>(); + _albumpass2 = new Mock>(); + _albumpass3 = new Mock>(); - _albumfail1 = new Mock>(); - _albumfail2 = new Mock>(); - _albumfail3 = new Mock>(); + _albumfail1 = new Mock>(); + _albumfail2 = new Mock>(); + _albumfail3 = new Mock>(); _pass1 = new Mock>(); _pass2 = new Mock>(); @@ -68,13 +69,13 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport _fail2 = new Mock>(); _fail3 = new Mock>(); - _albumpass1.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); - _albumpass2.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); - _albumpass3.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); + _albumpass1.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); + _albumpass2.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); + _albumpass3.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); - _albumfail1.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_albumfail1")); - _albumfail2.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_albumfail2")); - _albumfail3.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_albumfail3")); + _albumfail1.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_albumfail1")); + _albumfail2.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_albumfail2")); + _albumfail3.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_albumfail3")); _pass1.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); _pass2.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Accept()); @@ -93,6 +94,10 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport .With(x => x.Author = _artist) .Build(); + _edition = Builder.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>(), It.IsAny(), It.IsAny())) .Returns((List tracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) => { - var ret = new LocalAlbumRelease(tracks); - ret.Book = _album; - return new List { ret }; + var ret = new LocalEdition(tracks); + ret.Edition = _edition; + return new List { ret }; }); Mocker.GetMock() @@ -164,12 +169,12 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport Subject.GetImportDecisions(_fileInfos, null, itemInfo, _idConfig); - _albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); - _albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); - _albumfail3.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); - _albumpass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); - _albumpass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); - _albumpass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); + _albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); + _albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); + _albumfail3.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); + _albumpass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); + _albumpass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); + _albumpass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -317,7 +322,7 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny())) .Returns((List tracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) => { - return new List { new LocalAlbumRelease(tracks) }; + return new List { new LocalEdition(tracks) }; }); var decisions = Subject.GetImportDecisions(_fileInfos, _idOverrides, null, _idConfig); diff --git a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/Goodreads/GoodreadsProxyFixture.cs similarity index 84% rename from src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs rename to src/NzbDrone.Core.Test/MetadataSource/Goodreads/GoodreadsProxyFixture.cs index f9c43e952..d3170d96f 100644 --- a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSource/Goodreads/GoodreadsProxyFixture.cs @@ -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 + public class GoodreadsProxyFixture : CoreTest { 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 albums, bool idOnly = false) diff --git a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/Goodreads/GoodreadsProxySearchFixture.cs similarity index 86% rename from src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs rename to src/NzbDrone.Core.Test/MetadataSource/Goodreads/GoodreadsProxySearchFixture.cs index 10263ebbc..a63cd1818 100644 --- a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSource/Goodreads/GoodreadsProxySearchFixture.cs @@ -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 + public class GoodreadsProxySearchFixture : CoreTest { [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) diff --git a/src/NzbDrone.Core.Test/MusicTests/AddAlbumFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AddAlbumFixture.cs index bda0bd0a3..821b4f4f2 100644 --- a/src/NzbDrone.Core.Test/MusicTests/AddAlbumFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/AddAlbumFixture.cs @@ -54,11 +54,19 @@ namespace NzbDrone.Core.Test.MusicTests .Returns((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 + { + 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() - .Setup(s => s.GetBookInfo(newAlbum.ForeignBookId)) - .Throws(new BookNotFoundException(newAlbum.ForeignBookId)); + .Setup(s => s.GetBookInfo("edition")) + .Throws(new BookNotFoundException("edition")); Assert.Throws(() => Subject.AddBook(newAlbum)); diff --git a/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs index 72b06199f..42f353d4b 100644 --- a/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs @@ -16,7 +16,7 @@ using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.MusicTests { [TestFixture] - public class AddArtistFixture : CoreTest + public class AddArtistFixture : CoreTest { private Author _fakeArtist; diff --git a/src/NzbDrone.Core.Test/MusicTests/AlbumRepositoryTests/AlbumRepositoryFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AlbumRepositoryTests/AlbumRepositoryFixture.cs index f30aef238..033d5905f 100644 --- a/src/NzbDrone.Core.Test/MusicTests/AlbumRepositoryTests/AlbumRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/AlbumRepositoryTests/AlbumRepositoryFixture.cs @@ -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, diff --git a/src/NzbDrone.Core.Test/MusicTests/EntityFixture.cs b/src/NzbDrone.Core.Test/MusicTests/EntityFixture.cs index 2183ff4a2..30908d5a1 100644 --- a/src/NzbDrone.Core.Test/MusicTests/EntityFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/EntityFixture.cs @@ -143,6 +143,59 @@ namespace NzbDrone.Core.Test.MusicTests item1.Should().Be(item2); } + private Edition GivenEdition() + { + return _fixture.Build() + .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), "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() diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs deleted file mode 100644 index 251009287..000000000 --- a/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs +++ /dev/null @@ -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 - { - private Author _artist; - private List _albums; - - [SetUp] - public void Setup() - { - var album1 = Builder.CreateNew() - .With(x => x.AuthorMetadata = Builder.CreateNew().Build()) - .With(s => s.Id = 1234) - .With(s => s.ForeignBookId = "1") - .Build(); - - _albums = new List { album1 }; - - _artist = Builder.CreateNew() - .With(s => s.Books = _albums) - .Build(); - - Mocker.GetMock() - .Setup(s => s.GetAuthor(_artist.Id)) - .Returns(_artist); - - Mocker.GetMock() - .Setup(s => s.UpsertMany(It.IsAny>())) - .Returns(true); - - Mocker.GetMock() - .Setup(s => s.GetBookInfo(It.IsAny())) - .Callback(() => { throw new BookNotFoundException(album1.ForeignBookId); }); - - Mocker.GetMock() - .Setup(s => s.ShouldRefresh(It.IsAny())) - .Returns(true); - - Mocker.GetMock() - .Setup(x => x.GetFilesByBook(It.IsAny())) - .Returns(new List()); - - Mocker.GetMock() - .Setup(x => x.GetByBook(It.IsAny(), It.IsAny())) - .Returns(new List()); - } - - [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 { newAlbumInfo }, null, false, false, null); - - Mocker.GetMock() - .Verify(v => v.UpdateMany(It.Is>(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() - .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 { newAlbumInfo }, null, false, false, null); - - // check old album is deleted - Mocker.GetMock() - .Verify(v => v.DeleteMany(It.Is>(x => x.First().ForeignBookId == existing.ForeignBookId))); - - // check that clash gets updated - Mocker.GetMock() - .Verify(v => v.UpdateMany(It.Is>(s => s.First().ForeignBookId == newAlbumInfo.ForeignBookId))); - - ExceptionVerification.ExpectedWarns(1); - } - } -} diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs index 5fd1486ae..2f5a709ba 100644 --- a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs @@ -45,10 +45,12 @@ namespace NzbDrone.Core.Test.MusicTests var metadata = Builder.CreateNew().Build(); var series = Builder.CreateListOfSize(1).BuildList(); + var profile = Builder.CreateNew().Build(); _artist = Builder.CreateNew() .With(a => a.Metadata = metadata) .With(a => a.Series = series) + .With(a => a.MetadataProfile = profile) .Build(); Mocker.GetMock(MockBehavior.Strict) @@ -63,8 +65,8 @@ namespace NzbDrone.Core.Test.MusicTests .Returns(_albums); Mocker.GetMock() - .Setup(s => s.GetAuthorInfo(It.IsAny())) - .Callback(() => { throw new AuthorNotFoundException(_artist.ForeignAuthorId); }); + .Setup(s => s.GetAuthorAndBooks(It.IsAny(), It.IsAny())) + .Callback(() => { throw new AuthorNotFoundException(_artist.ForeignAuthorId); }); Mocker.GetMock() .Setup(x => x.GetFilesByAuthor(It.IsAny())) @@ -86,8 +88,8 @@ namespace NzbDrone.Core.Test.MusicTests private void GivenNewArtistInfo(Author artist) { Mocker.GetMock() - .Setup(s => s.GetAuthorInfo(_artist.ForeignAuthorId)) - .Returns(artist); + .Setup(s => s.GetAuthorAndBooks(_artist.ForeignAuthorId, It.IsAny())) + .Returns(artist); } private void GivenArtistFiles() diff --git a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs index 6dd126d96..060d347ee 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs @@ -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 + .CreateNew() + .With(s => s.Title = fakeAlbum.Title) + .With(s => s.Book = fakeAlbum) + .Build(); + + Subject.BuildBookFilePath(fakeArtist, fakeEdition, filename, ".mobi").Should().Be(expectedPath.AsOsAgnostic()); } } } diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/CleanTitleFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/CleanTitleFixture.cs index 31398bdc3..256eb66e6 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/CleanTitleFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/CleanTitleFixture.cs @@ -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 + .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); } } diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 09d20006d..69a24a3a0 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -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 .CreateNew() .With(s => s.Title = "Hybrid Theory") + .Build(); + + _edition = Builder + .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); } } diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TitleTheFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TitleTheFixture.cs index 0bb4506f9..5c5f9158b 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TitleTheFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TitleTheFixture.cs @@ -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 + .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); } } diff --git a/src/NzbDrone.Core/AuthorStats/AuthorStatisticsRepository.cs b/src/NzbDrone.Core/AuthorStats/AuthorStatisticsRepository.cs index 1fec6a273..027fd2766 100644 --- a/src/NzbDrone.Core/AuthorStats/AuthorStatisticsRepository.cs +++ b/src/NzbDrone.Core/AuthorStats/AuthorStatisticsRepository.cs @@ -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 AuthorStatistics() { var time = DateTime.UtcNow; - return Query(Builder().Where(x => x.ReleaseDate < time)); + var stats = Query(Builder()); + +#pragma warning disable CS0472 + return Query(Builder().OrWhere(x => x.ReleaseDate < time) + .OrWhere(x => x.Id != null)); +#pragma warning restore } public List AuthorStatistics(int authorId) { var time = DateTime.UtcNow; - return Query(Builder().Where(x => x.ReleaseDate < time) +#pragma warning disable CS0472 + return Query(Builder().OrWhere(x => x.ReleaseDate < time) + .OrWhere(x => x.Id != null) .Where(x => x.Id == authorId)); +#pragma warning restore } private List 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((e, b) => e.BookId == b.Id) .Join((book, author) => book.AuthorMetadataId == author.AuthorMetadataId) - .LeftJoin((t, f) => t.Id == f.BookId) + .LeftJoin((t, f) => t.Id == f.EditionId) + .Where(x => x.Monitored == true) .GroupBy(x => x.Id) .GroupBy(x => x.Id); } diff --git a/src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs b/src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs index 0de8806ed..efdf311d5 100644 --- a/src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs +++ b/src/NzbDrone.Core/Books/Calibre/CalibreProxy.cs @@ -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 { - { "goodreads", book.GoodreadsId.ToString() }, { "isbn", book.Isbn13 }, { "asin", book.Asin } } diff --git a/src/NzbDrone.Core/Books/Events/EditionDeletedEvent.cs b/src/NzbDrone.Core/Books/Events/EditionDeletedEvent.cs new file mode 100644 index 000000000..559ef0ab1 --- /dev/null +++ b/src/NzbDrone.Core/Books/Events/EditionDeletedEvent.cs @@ -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; + } + } +} diff --git a/src/NzbDrone.Core/Books/Model/AuthorMetadata.cs b/src/NzbDrone.Core/Books/Model/AuthorMetadata.cs index 6176a853a..cbac6d7e5 100644 --- a/src/NzbDrone.Core/Books/Model/AuthorMetadata.cs +++ b/src/NzbDrone.Core/Books/Model/AuthorMetadata.cs @@ -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 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 Images { get; set; } public List 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; diff --git a/src/NzbDrone.Core/Books/Model/Book.cs b/src/NzbDrone.Core/Books/Model/Book.cs index fd2283f75..26bf4712d 100644 --- a/src/NzbDrone.Core/Books/Model/Book.cs +++ b/src/NzbDrone.Core/Books/Model/Book.cs @@ -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(); Links = new List(); Genres = new List(); 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 Images { get; set; } public List Links { get; set; } public List 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 { get; set; } [MemberwiseEqualityIgnore] + public LazyLoaded> Editions { get; set; } + [MemberwiseEqualityIgnore] public LazyLoaded> BookFiles { get; set; } [MemberwiseEqualityIgnore] public LazyLoaded> 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; } } } diff --git a/src/NzbDrone.Core/Books/Model/Edition.cs b/src/NzbDrone.Core/Books/Model/Edition.cs new file mode 100644 index 000000000..fc9d40dae --- /dev/null +++ b/src/NzbDrone.Core/Books/Model/Edition.cs @@ -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 + { + public Edition() + { + Overview = string.Empty; + Images = new List(); + Links = new List(); + 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 Images { get; set; } + public List 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 { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded> 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; + } + } +} diff --git a/src/NzbDrone.Core/Books/Model/Ratings.cs b/src/NzbDrone.Core/Books/Model/Ratings.cs index 2eb445ad8..ce2b13651 100644 --- a/src/NzbDrone.Core/Books/Model/Ratings.cs +++ b/src/NzbDrone.Core/Books/Model/Ratings.cs @@ -7,5 +7,7 @@ namespace NzbDrone.Core.Books { public int Votes { get; set; } public decimal Value { get; set; } + + public double Popularity => (double)Value * Votes; } } diff --git a/src/NzbDrone.Core/Books/Repositories/BookRepository.cs b/src/NzbDrone.Core/Books/Repositories/BookRepository.cs index bb76f1149..dd21614b0 100644 --- a/src/NzbDrone.Core/Books/Repositories/BookRepository.cs +++ b/src/NzbDrone.Core/Books/Repositories/BookRepository.cs @@ -70,7 +70,7 @@ namespace NzbDrone.Core.Books public List GetBooksByFileIds(IEnumerable fileIds) { return Query(new SqlBuilder() - .Join((l, r) => l.Id == r.BookId) + .Join((l, r) => l.Id == r.EditionId) .Where(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((l, r) => l.AuthorMetadataId == r.AuthorMetadataId) - .LeftJoin((t, f) => t.Id == f.BookId) + .LeftJoin((t, f) => t.Id == f.EditionId) .Where(f => f.Id == null) .Where(a => a.ReleaseDate <= currentTime); #pragma warning restore CS0472 @@ -107,7 +107,7 @@ namespace NzbDrone.Core.Books private SqlBuilder AlbumsWhereCutoffUnmetBuilder(List qualitiesBelowCutoff) => Builder() .Join((l, r) => l.AuthorMetadataId == r.AuthorMetadataId) - .Join((t, f) => t.Id == f.BookId) + .Join((t, f) => t.Id == f.EditionId) .Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff)); private string BuildQualityCutoffWhereClause(List qualitiesBelowCutoff) @@ -193,7 +193,7 @@ namespace NzbDrone.Core.Books public List GetAuthorBooksWithFiles(Author author) { return Query(Builder() - .Join((t, f) => t.Id == f.BookId) + .Join((t, f) => t.Id == f.EditionId) .Where(x => x.AuthorMetadataId == author.AuthorMetadataId)); } } diff --git a/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs b/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs new file mode 100644 index 000000000..1e6186184 --- /dev/null +++ b/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs @@ -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 FindByForeignEditionId(string foreignEditionId); + List FindByBook(int id); + List FindByAuthor(int id); + List GetEditionsForRefresh(int albumId, IEnumerable foreignEditionIds); + List SetMonitored(Edition edition); + } + + public class EditionRepository : BasicRepository, 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 GetEditionsForRefresh(int albumId, IEnumerable foreignEditionIds) + { + return Query(r => r.BookId == albumId || foreignEditionIds.Contains(r.ForeignEditionId)); + } + + public List FindByBook(int id) + { + // populate the albums and artist metadata also + // this hopefully speeds up the track matching a lot + var builder = new SqlBuilder() + .LeftJoin((e, b) => e.BookId == b.Id) + .LeftJoin((b, a) => b.AuthorMetadataId == a.Id) + .Where(r => r.BookId == id); + + return _database.QueryJoined(builder, (edition, book, metadata) => + { + if (book != null) + { + book.AuthorMetadata = metadata; + edition.Book = book; + } + + return edition; + }).ToList(); + } + + public List FindByAuthor(int id) + { + return Query(Builder().Join((e, b) => e.BookId == b.Id) + .Join((b, a) => b.AuthorMetadataId == a.AuthorMetadataId) + .Where(a => a.Id == id)); + } + + public List 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; + } + } +} diff --git a/src/NzbDrone.Core/Books/Services/AddAuthorService.cs b/src/NzbDrone.Core/Books/Services/AddAuthorService.cs index 4a0b6df87..bb222cd3c 100644 --- a/src/NzbDrone.Core/Books/Services/AddAuthorService.cs +++ b/src/NzbDrone.Core/Books/Services/AddAuthorService.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.Books List AddAuthors(List 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, diff --git a/src/NzbDrone.Core/Books/Services/AddBookService.cs b/src/NzbDrone.Core/Books/Services/AddBookService.cs index fd94a1429..6cf37c2a4 100644 --- a/src/NzbDrone.Core/Books/Services/AddBookService.cs +++ b/src/NzbDrone.Core/Books/Services/AddBookService.cs @@ -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> tuple = null; try { - tuple = _bookInfo.GetBookInfo(newBook.ForeignBookId); + tuple = _bookInfo.GetBookInfo(newBook.Editions.Value.Single(x => x.Monitored).ForeignEditionId); } catch (BookNotFoundException) { diff --git a/src/NzbDrone.Core/Books/Services/BookService.cs b/src/NzbDrone.Core/Books/Services/BookService.cs index 566e30227..0a23fcd36 100644 --- a/src/NzbDrone.Core/Books/Services/BookService.cs +++ b/src/NzbDrone.Core/Books/Services/BookService.cs @@ -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 { 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)); diff --git a/src/NzbDrone.Core/Books/Services/EditionService.cs b/src/NzbDrone.Core/Books/Services/EditionService.cs new file mode 100644 index 000000000..608804476 --- /dev/null +++ b/src/NzbDrone.Core/Books/Services/EditionService.cs @@ -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 GetAllEditions(); + void InsertMany(List editions); + void UpdateMany(List editions); + void DeleteMany(List editions); + List GetEditionsForRefresh(int albumId, IEnumerable foreignEditionIds); + List GetEditionsByBook(int bookId); + List GetEditionsByAuthor(int authorId); + List SetMonitored(Edition edition); + } + + public class EditionService : IEditionService, + IHandle + { + 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 GetAllEditions() + { + return _editionRepository.All().ToList(); + } + + public void InsertMany(List editions) + { + _editionRepository.InsertMany(editions); + } + + public void UpdateMany(List editions) + { + _editionRepository.UpdateMany(editions); + } + + public void DeleteMany(List editions) + { + _editionRepository.DeleteMany(editions); + foreach (var edition in editions) + { + _eventAggregator.PublishEvent(new EditionDeletedEvent(edition)); + } + } + + public List GetEditionsForRefresh(int albumId, IEnumerable foreignEditionIds) + { + return _editionRepository.GetEditionsForRefresh(albumId, foreignEditionIds); + } + + public List GetEditionsByBook(int bookId) + { + return _editionRepository.FindByBook(bookId); + } + + public List GetEditionsByAuthor(int authorId) + { + return _editionRepository.FindByAuthor(authorId); + } + + public List SetMonitored(Edition edition) + { + return _editionRepository.SetMonitored(edition); + } + + public void Handle(BookDeletedEvent message) + { + var editions = GetEditionsByBook(message.Book.Id); + DeleteMany(editions); + } + } +} diff --git a/src/NzbDrone.Core/Books/Services/RefreshAuthorService.cs b/src/NzbDrone.Core/Books/Services/RefreshAuthorService.cs index 637697b2f..7cd1f6e76 100644 --- a/src/NzbDrone.Core/Books/Services/RefreshAuthorService.cs +++ b/src/NzbDrone.Core/Books/Services/RefreshAuthorService.cs @@ -21,7 +21,12 @@ using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.Books { + public interface IRefreshAuthorService + { + } + public class RefreshAuthorService : RefreshEntityServiceBase, + IRefreshAuthorService, IExecute, IExecute { @@ -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) diff --git a/src/NzbDrone.Core/Books/Services/RefreshBookService.cs b/src/NzbDrone.Core/Books/Services/RefreshBookService.cs index 17ce92fea..64589dffb 100644 --- a/src/NzbDrone.Core/Books/Services/RefreshBookService.cs +++ b/src/NzbDrone.Core/Books/Services/RefreshBookService.cs @@ -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 books, List remoteBooks, Author remoteData, bool forceBookRefresh, bool forceUpdateFileTags, DateTime? lastUpdate); } - public class RefreshBookService : RefreshEntityServiceBase, IRefreshBookService + public class RefreshBookService : RefreshEntityServiceBase, 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 GetRemoteChildren(Book local, Book remote) + protected override List GetRemoteChildren(Book local, Book remote) { - return new List(); + return remote.Editions.Value.DistinctBy(m => m.ForeignEditionId).ToList(); } - protected override List GetLocalChildren(Book entity, List remoteChildren) + protected override List GetLocalChildren(Book entity, List remoteChildren) { - return new List(); + return _editionService.GetEditionsForRefresh(entity.Id, remoteChildren.Select(x => x.ForeignEditionId)); } - protected override Tuple> GetMatchingExistingChildren(List existingChildren, object remote) + protected override Tuple> GetMatchingExistingChildren(List existingChildren, Edition remote) { - return null; + var existingChild = existingChildren.SingleOrDefault(x => x.ForeignEditionId == remote.ForeignEditionId); + return Tuple.Create(existingChild, new List()); } - 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 children) + protected override void AddChildren(List children) { + // hack - add the chilren in refresh children so we can control monitored status } - protected override bool RefreshChildren(SortedChildren localChildren, List remoteChildren, Author remoteData, bool forceChildRefresh, bool forceUpdateFileTags, DateTime? lastUpdate) + private void MonitorSingleEdition(List 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 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) diff --git a/src/NzbDrone.Core/Books/Services/RefreshEditionService.cs b/src/NzbDrone.Core/Books/Services/RefreshEditionService.cs new file mode 100644 index 000000000..c214a6ed2 --- /dev/null +++ b/src/NzbDrone.Core/Books/Services/RefreshEditionService.cs @@ -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 add, List update, List> merge, List delete, List upToDate, List 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 add, List update, List> merge, List delete, List upToDate, List remoteEditions, bool forceUpdateFileTags) + { + var updateList = new List(); + + // 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(); + } + } +} diff --git a/src/NzbDrone.Core/Books/Services/RefreshSeriesService.cs b/src/NzbDrone.Core/Books/Services/RefreshSeriesService.cs index bfcaee389..23f668b32 100644 --- a/src/NzbDrone.Core/Books/Services/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Books/Services/RefreshSeriesService.cs @@ -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(); 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); diff --git a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs index dbe313993..886662e0c 100644 --- a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs +++ b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs @@ -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() diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index b7ac35b9d..78370c63f 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -123,9 +123,12 @@ namespace NzbDrone.Core.Datastore .HasOne(r => r.AuthorMetadata, r => r.AuthorMetadataId) .LazyLoad(x => x.BookFiles, (db, book) => db.Query(new SqlBuilder() - .Join((l, r) => l.BookId == r.Id) + .Join((l, r) => l.EditionId == r.Id) .Where(b => b.Id == book.Id)).ToList(), b => b.Id > 0) + .LazyLoad(x => x.Editions, + (db, book) => db.Query(new SqlBuilder().Where(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(a => a.AuthorMetadataId == book.AuthorMetadataId)).SingleOrDefault(), a => a.AuthorMetadataId > 0); + Mapper.Entity("Editions").RegisterModel() + .HasOne(r => r.Book, r => r.BookId) + .LazyLoad(x => x.BookFiles, + (db, book) => db.Query(new SqlBuilder() + .Join((l, r) => l.EditionId == r.Id) + .Where(b => b.Id == book.Id)).ToList(), + b => b.Id > 0); + Mapper.Entity("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((a, m) => a.AuthorMetadataId == m.Id) .Join((l, r) => l.AuthorMetadataId == r.AuthorMetadataId) - .Where(a => a.Id == f.BookId)).SingleOrDefault(), + .Where(a => a.Id == f.EditionId)).SingleOrDefault(), t => t.Id > 0); Mapper.Entity("QualityDefinitions").RegisterModel() diff --git a/src/NzbDrone.Core/Extras/ExtraService.cs b/src/NzbDrone.Core/Extras/ExtraService.cs index 255f6ef67..48d5e7dc5 100644 --- a/src/NzbDrone.Core/Extras/ExtraService.cs +++ b/src/NzbDrone.Core/Extras/ExtraService.cs @@ -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) { diff --git a/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs b/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs index cbc74c54e..a9d859283 100644 --- a/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs +++ b/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs @@ -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 diff --git a/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs b/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs index 90ba59969..e85252804 100644 --- a/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs +++ b/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs @@ -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, diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index d72350a9b..e45c9fe01 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -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()); diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBookFiles.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBookFiles.cs index 37a13499b..581fc841f 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBookFiles.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBookFiles.cs @@ -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)"); } } } diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs index d37e7d82d..846684800 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs @@ -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 { diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index af88a906d..5e9dc5466 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -72,17 +72,13 @@ namespace NzbDrone.Core.IndexerSearch var searchSpec = Get(author, new List { 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); } diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs index 56ebeea7f..d5764aad8 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs @@ -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 GetPagedRequests(int maxPages, IEnumerable categories, string searchType, string parameters) diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs index ac7f4bd97..30a1d909c 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs @@ -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; diff --git a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs index e0a5c98f9..614a866f1 100644 --- a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs +++ b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs @@ -23,7 +23,7 @@ namespace NzbDrone.Core.MediaFiles { ParsedTrackInfo ReadTags(string file); void WriteTags(BookFile trackfile, bool newDownload, bool force = false); - void SyncTags(List tracks); + void SyncTags(List tracks); List GetRetagPreviewsByArtist(int authorId); List 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 books) + public void SyncTags(List 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 GetPreviews(List 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 diff --git a/src/NzbDrone.Core/MediaFiles/BookFile.cs b/src/NzbDrone.Core/MediaFiles/BookFile.cs index 927b2d6a2..7fcea767c 100644 --- a/src/NzbDrone.Core/MediaFiles/BookFile.cs +++ b/src/NzbDrone.Core/MediaFiles/BookFile.cs @@ -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 { get; set; } - public LazyLoaded Book { get; set; } + public LazyLoaded Edition { get; set; } public override string ToString() { diff --git a/src/NzbDrone.Core/MediaFiles/BookFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/BookFileMovingService.cs index 34e88a0d0..eaa207aa4 100644 --- a/src/NzbDrone.Core/MediaFiles/BookFileMovingService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookFileMovingService.cs @@ -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; diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Aggregation/AggregationService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Aggregation/AggregationService.cs index 860261779..a9bf7a96a 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Aggregation/AggregationService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Aggregation/AggregationService.cs @@ -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> _trackAugmenters; - private readonly IEnumerable> _albumAugmenters; + private readonly IEnumerable> _albumAugmenters; private readonly IDiskProvider _diskProvider; private readonly Logger _logger; public AugmentingService(IEnumerable> trackAugmenters, - IEnumerable> albumAugmenters, + IEnumerable> 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) { diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Aggregation/Aggregators/AggregateFilenameInfo.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Aggregation/Aggregators/AggregateFilenameInfo.cs index eebc79921..dc628ff3a 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Aggregation/Aggregators/AggregateFilenameInfo.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Aggregation/Aggregators/AggregateFilenameInfo.cs @@ -9,7 +9,7 @@ using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.BookImport.Aggregation.Aggregators { - public class AggregateFilenameInfo : IAggregate + public class AggregateFilenameInfo : IAggregate { 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 diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateAlbumRelease.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateAlbumRelease.cs deleted file mode 100644 index 22456163c..000000000 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateAlbumRelease.cs +++ /dev/null @@ -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(); - } - - public Book Book { get; set; } - public List ExistingTracks { get; set; } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateEdition.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateEdition.cs new file mode 100644 index 000000000..459bee161 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateEdition.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using NzbDrone.Core.Books; + +namespace NzbDrone.Core.MediaFiles.BookImport.Identification +{ + public class CandidateEdition + { + public CandidateEdition() + { + } + + public CandidateEdition(Edition edition) + { + Edition = edition; + ExistingFiles = new List(); + } + + public Edition Edition { get; set; } + public List ExistingFiles { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateService.cs index 04d538066..c26b1b771 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/CandidateService.cs @@ -11,8 +11,8 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification { public interface ICandidateService { - List GetDbCandidatesFromTags(LocalAlbumRelease localAlbumRelease, IdentificationOverrides idOverrides, bool includeExisting); - List GetRemoteCandidates(LocalAlbumRelease localAlbumRelease); + List GetDbCandidatesFromTags(LocalEdition localEdition, IdentificationOverrides idOverrides, bool includeExisting); + List GetRemoteCandidates(LocalEdition localEdition); } public class CandidateService : ICandidateService @@ -20,36 +20,39 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification private readonly ISearchForNewBook _bookSearchService; private readonly IAuthorService _authorService; private readonly IBookService _bookService; + private readonly IEditionService _editionService; private readonly IMediaFileService _mediaFileService; private readonly Logger _logger; public CandidateService(ISearchForNewBook bookSearchService, IAuthorService authorService, IBookService bookService, + IEditionService editionService, IMediaFileService mediaFileService, Logger logger) { _bookSearchService = bookSearchService; _authorService = authorService; _bookService = bookService; + _editionService = editionService; _mediaFileService = mediaFileService; _logger = logger; } - public List GetDbCandidatesFromTags(LocalAlbumRelease localAlbumRelease, IdentificationOverrides idOverrides, bool includeExisting) + public List GetDbCandidatesFromTags(LocalEdition localEdition, IdentificationOverrides idOverrides, bool includeExisting) { var watch = System.Diagnostics.Stopwatch.StartNew(); // Generally author, book and release are null. But if they're not then limit candidates appropriately. // We've tried to make sure that tracks are all for a single release. - List candidateReleases; + List candidateReleases; // if we have a Book ID, use that Book tagMbidRelease = null; - List tagCandidate = null; + List tagCandidate = null; // TODO: select by ISBN? - // var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList(); + // var releaseIds = localEdition.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList(); // if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace()) // { // _logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]); @@ -60,7 +63,13 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification // tagCandidate = GetDbCandidatesByRelease(new List { tagMbidRelease }, includeExisting); // } // } - if (idOverrides?.Book != null) + if (idOverrides?.Edition != null) + { + var release = idOverrides.Edition; + _logger.Debug("Edition {0} was forced", release); + candidateReleases = GetDbCandidatesByEdition(new List { release }, includeExisting); + } + else if (idOverrides?.Book != null) { // use the release from file tags if it exists and agrees with the specified book if (tagMbidRelease?.Id == idOverrides.Book.Id) @@ -69,7 +78,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification } else { - candidateReleases = GetDbCandidatesByAlbum(idOverrides.Book, includeExisting); + candidateReleases = GetDbCandidatesByBook(idOverrides.Book, includeExisting); } } else if (idOverrides?.Author != null) @@ -81,7 +90,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification } else { - candidateReleases = GetDbCandidatesByArtist(localAlbumRelease, idOverrides.Author, includeExisting); + candidateReleases = GetDbCandidatesByAuthor(localEdition, idOverrides.Author, includeExisting); } } else @@ -92,87 +101,97 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification } else { - candidateReleases = GetDbCandidates(localAlbumRelease, includeExisting); + candidateReleases = GetDbCandidates(localEdition, includeExisting); } } watch.Stop(); - _logger.Debug($"Getting {candidateReleases.Count} candidates from tags for {localAlbumRelease.LocalBooks.Count} tracks took {watch.ElapsedMilliseconds}ms"); + _logger.Debug($"Getting {candidateReleases.Count} candidates from tags for {localEdition.LocalBooks.Count} tracks took {watch.ElapsedMilliseconds}ms"); return candidateReleases; } - private List GetDbCandidatesByAlbum(Book book, bool includeExisting) + private List GetDbCandidatesByEdition(List editions, bool includeExisting) { - return new List + // get the local tracks on disk for each album + var bookFiles = editions.Select(x => x.BookId) + .Distinct() + .ToDictionary(id => id, id => includeExisting ? _mediaFileService.GetFilesByBook(id) : new List()); + + return editions.Select(x => new CandidateEdition { - new CandidateAlbumRelease - { - Book = book, - ExistingTracks = includeExisting ? _mediaFileService.GetFilesByBook(book.Id) : new List() - } - }; + Edition = x, + ExistingFiles = bookFiles[x.BookId] + }).ToList(); } - private List GetDbCandidatesByArtist(LocalAlbumRelease localAlbumRelease, Author author, bool includeExisting) + private List GetDbCandidatesByBook(Book book, bool includeExisting) + { + // Sort by most voted so less likely to swap to a random release + return GetDbCandidatesByEdition(_editionService.GetEditionsByBook(book.Id) + .OrderByDescending(x => x.Ratings.Votes) + .ToList(), includeExisting); + } + + private List GetDbCandidatesByAuthor(LocalEdition localEdition, Author author, bool includeExisting) { _logger.Trace("Getting candidates for {0}", author); - var candidateReleases = new List(); + var candidateReleases = new List(); - var albumTag = localAlbumRelease.LocalBooks.MostCommon(x => x.FileTrackInfo.AlbumTitle) ?? ""; + var albumTag = localEdition.LocalBooks.MostCommon(x => x.FileTrackInfo.AlbumTitle) ?? ""; if (albumTag.IsNotNullOrWhiteSpace()) { var possibleAlbums = _bookService.GetCandidates(author.AuthorMetadataId, albumTag); foreach (var book in possibleAlbums) { - candidateReleases.AddRange(GetDbCandidatesByAlbum(book, includeExisting)); + candidateReleases.AddRange(GetDbCandidatesByBook(book, includeExisting)); } } return candidateReleases; } - private List GetDbCandidates(LocalAlbumRelease localAlbumRelease, bool includeExisting) + private List GetDbCandidates(LocalEdition localEdition, bool includeExisting) { // most general version, nothing has been specified. // get all plausible artists, then all plausible albums, then get releases for each of these. - var candidateReleases = new List(); + var candidateReleases = new List(); // check if it looks like VA. - if (TrackGroupingService.IsVariousArtists(localAlbumRelease.LocalBooks)) + if (TrackGroupingService.IsVariousArtists(localEdition.LocalBooks)) { var va = _authorService.FindById(DistanceCalculator.VariousAuthorIds[0]); if (va != null) { - candidateReleases.AddRange(GetDbCandidatesByArtist(localAlbumRelease, va, includeExisting)); + candidateReleases.AddRange(GetDbCandidatesByAuthor(localEdition, va, includeExisting)); } } - var artistTag = localAlbumRelease.LocalBooks.MostCommon(x => x.FileTrackInfo.ArtistTitle) ?? ""; + var artistTag = localEdition.LocalBooks.MostCommon(x => x.FileTrackInfo.ArtistTitle) ?? ""; if (artistTag.IsNotNullOrWhiteSpace()) { var possibleArtists = _authorService.GetCandidates(artistTag); foreach (var author in possibleArtists) { - candidateReleases.AddRange(GetDbCandidatesByArtist(localAlbumRelease, author, includeExisting)); + candidateReleases.AddRange(GetDbCandidatesByAuthor(localEdition, author, includeExisting)); } } return candidateReleases; } - public List GetRemoteCandidates(LocalAlbumRelease localAlbumRelease) + public List GetRemoteCandidates(LocalEdition localEdition) { // Gets candidate book releases from the metadata server. // Will eventually need adding locally if we find a match var watch = System.Diagnostics.Stopwatch.StartNew(); List remoteBooks = null; - var candidates = new List(); + var candidates = new List(); - var goodreads = localAlbumRelease.LocalBooks.Select(x => x.FileTrackInfo.GoodreadsId).Distinct().ToList(); - var isbns = localAlbumRelease.LocalBooks.Select(x => x.FileTrackInfo.Isbn).Distinct().ToList(); - var asins = localAlbumRelease.LocalBooks.Select(x => x.FileTrackInfo.Asin).Distinct().ToList(); + var goodreads = localEdition.LocalBooks.Select(x => x.FileTrackInfo.GoodreadsId).Distinct().ToList(); + var isbns = localEdition.LocalBooks.Select(x => x.FileTrackInfo.Isbn).Distinct().ToList(); + var asins = localEdition.LocalBooks.Select(x => x.FileTrackInfo.Asin).Distinct().ToList(); try { @@ -212,16 +231,16 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification // fall back to author / book name search string artistTag; - if (TrackGroupingService.IsVariousArtists(localAlbumRelease.LocalBooks)) + if (TrackGroupingService.IsVariousArtists(localEdition.LocalBooks)) { artistTag = "Various Artists"; } else { - artistTag = localAlbumRelease.LocalBooks.MostCommon(x => x.FileTrackInfo.ArtistTitle) ?? ""; + artistTag = localEdition.LocalBooks.MostCommon(x => x.FileTrackInfo.ArtistTitle) ?? ""; } - var albumTag = localAlbumRelease.LocalBooks.MostCommon(x => x.FileTrackInfo.AlbumTitle) ?? ""; + var albumTag = localEdition.LocalBooks.MostCommon(x => x.FileTrackInfo.AlbumTitle) ?? ""; if (artistTag.IsNullOrWhiteSpace() || albumTag.IsNullOrWhiteSpace()) { @@ -247,15 +266,21 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification foreach (var book in remoteBooks) { - candidates.Add(new CandidateAlbumRelease + // We have to make sure various bits and pieces are populated that are normally handled + // by a database lazy load + foreach (var edition in book.Editions.Value) { - Book = book, - ExistingTracks = new List() - }); + edition.Book = book; + candidates.Add(new CandidateEdition + { + Edition = edition, + ExistingFiles = new List() + }); + } } watch.Stop(); - _logger.Debug($"Getting {candidates.Count} remote candidates from tags for {localAlbumRelease.LocalBooks.Count} tracks took {watch.ElapsedMilliseconds}ms"); + _logger.Debug($"Getting {candidates.Count} remote candidates from tags for {localEdition.LocalBooks.Count} tracks took {watch.ElapsedMilliseconds}ms"); return candidates; } diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/DistanceCalculator.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/DistanceCalculator.cs index cb8f675e4..b0a8a5726 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/DistanceCalculator.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/DistanceCalculator.cs @@ -27,7 +27,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification private static readonly RegexReplace StripSeriesRegex = new RegexReplace(@"\([^\)].+?\)$", string.Empty, RegexOptions.Compiled); - public static Distance BookDistance(List localTracks, Book release) + public static Distance BookDistance(List localTracks, Edition edition) { var dist = new Distance(); @@ -39,22 +39,22 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification artists.Add(artists[0].Split(',').Select(x => x.Trim()).Reverse().ConcatToString(" ")); } - dist.AddString("artist", artists, release.AuthorMetadata.Value.Name); - Logger.Trace("artist: '{0}' vs '{1}'; {2}", artists.ConcatToString("' or '"), release.AuthorMetadata.Value.Name, dist.NormalizedDistance()); + dist.AddString("artist", artists, edition.Book.Value.AuthorMetadata.Value.Name); + Logger.Trace("artist: '{0}' vs '{1}'; {2}", artists.ConcatToString("' or '"), edition.Book.Value.AuthorMetadata.Value.Name, dist.NormalizedDistance()); var title = localTracks.MostCommon(x => x.FileTrackInfo.AlbumTitle) ?? ""; - var titleOptions = new List { release.Title }; + var titleOptions = new List { edition.Title, edition.Book.Value.Title }; if (titleOptions[0].Contains("#")) { titleOptions.Add(StripSeriesRegex.Replace(titleOptions[0])); } - if (release.SeriesLinks?.Value?.Any() ?? false) + if (edition.Book.Value.SeriesLinks?.Value?.Any() ?? false) { - foreach (var l in release.SeriesLinks.Value) + foreach (var l in edition.Book.Value.SeriesLinks.Value) { - titleOptions.Add($"{l.Series.Value.Title} {l.Position} {release.Title}"); - titleOptions.Add($"{release.Title} {l.Series.Value.Title} {l.Position}"); + titleOptions.Add($"{l.Series.Value.Title} {l.Position} {edition.Title}"); + titleOptions.Add($"{edition.Title} {l.Series.Value.Title} {l.Position}"); } } @@ -63,9 +63,9 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification // Year var localYear = localTracks.MostCommon(x => x.FileTrackInfo.Year); - if (localYear > 0 && release.ReleaseDate.HasValue) + if (localYear > 0 && edition.ReleaseDate.HasValue) { - var albumYear = release.ReleaseDate?.Year ?? 0; + var albumYear = edition.ReleaseDate?.Year ?? 0; if (localYear == albumYear) { dist.Add("year", 0.0); @@ -78,7 +78,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification dist.AddRatio("year", diff, diff_max); } - Logger.Trace($"year: {localYear} vs {release.ReleaseDate?.Year}; {dist.NormalizedDistance()}"); + Logger.Trace($"year: {localYear} vs {edition.ReleaseDate?.Year}; {dist.NormalizedDistance()}"); } return dist; diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/IdentificationService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/IdentificationService.cs index cf87b11b2..8c18059cb 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/IdentificationService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/IdentificationService.cs @@ -11,7 +11,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification { public interface IIdentificationService { - List Identify(List localTracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config); + List Identify(List localTracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config); } public class IdentificationService : IIdentificationService @@ -35,13 +35,13 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification _logger = logger; } - public List GetLocalAlbumReleases(List localTracks, bool singleRelease) + public List GetLocalAlbumReleases(List localTracks, bool singleRelease) { var watch = System.Diagnostics.Stopwatch.StartNew(); - List releases; + List releases; if (singleRelease) { - releases = new List { new LocalAlbumRelease(localTracks) }; + releases = new List { new LocalEdition(localTracks) }; } else { @@ -65,7 +65,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification return releases; } - public List Identify(List localTracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) + public List Identify(List localTracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) { // 1 group localTracks so that we think they represent a single release // 2 get candidates given specified author, album and release. Candidates can include extra files already on disk. @@ -91,7 +91,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification return releases; } - private List ToLocalTrack(IEnumerable trackfiles, LocalAlbumRelease localRelease) + private List ToLocalTrack(IEnumerable trackfiles, LocalEdition localRelease) { var scanned = trackfiles.Join(localRelease.LocalBooks, t => t.Path, l => l.Path, (track, localTrack) => localTrack); var toScan = trackfiles.ExceptBy(t => t.Path, scanned, s => s.Path, StringComparer.InvariantCulture); @@ -112,7 +112,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification return localTracks; } - private void IdentifyRelease(LocalAlbumRelease localAlbumRelease, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) + private void IdentifyRelease(LocalEdition localAlbumRelease, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) { var watch = System.Diagnostics.Stopwatch.StartNew(); @@ -129,6 +129,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification // populate the overrides and return foreach (var localTrack in localAlbumRelease.LocalBooks) { + localTrack.Edition = idOverrides.Edition; localTrack.Book = idOverrides.Book; localTrack.Author = idOverrides.Author; } @@ -140,7 +141,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification // convert all the TrackFiles that represent extra files to List var allLocalTracks = ToLocalTrack(candidateReleases - .SelectMany(x => x.ExistingTracks) + .SelectMany(x => x.ExistingFiles) .DistinctBy(x => x.Path), localAlbumRelease); _logger.Debug($"Retrieved {allLocalTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms"); @@ -154,7 +155,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification _logger.Debug($"IdentifyRelease done in {watch.ElapsedMilliseconds}ms"); } - private void GetBestRelease(LocalAlbumRelease localAlbumRelease, List candidateReleases, List extraTracksOnDisk) + private void GetBestRelease(LocalEdition localAlbumRelease, List candidateReleases, List extraTracksOnDisk) { var watch = System.Diagnostics.Stopwatch.StartNew(); @@ -165,11 +166,11 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification foreach (var candidateRelease in candidateReleases) { - var release = candidateRelease.Book; + var release = candidateRelease.Edition; _logger.Debug($"Trying Release {release}"); var rwatch = System.Diagnostics.Stopwatch.StartNew(); - var extraTrackPaths = candidateRelease.ExistingTracks.Select(x => x.Path).ToList(); + var extraTrackPaths = candidateRelease.ExistingFiles.Select(x => x.Path).ToList(); var extraTracks = extraTracksOnDisk.Where(x => extraTrackPaths.Contains(x.Path)).ToList(); var allLocalTracks = localAlbumRelease.LocalBooks.Concat(extraTracks).DistinctBy(x => x.Path).ToList(); @@ -186,7 +187,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification { bestDistance = currDistance; localAlbumRelease.Distance = distance; - localAlbumRelease.Book = release; + localAlbumRelease.Edition = release; localAlbumRelease.ExistingTracks = extraTracks; if (currDistance == 0.0) { @@ -196,7 +197,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification } watch.Stop(); - _logger.Debug($"Best release: {localAlbumRelease.Book} Distance {localAlbumRelease.Distance.NormalizedDistance()} found in {watch.ElapsedMilliseconds}ms"); + _logger.Debug($"Best release: {localAlbumRelease.Edition} Distance {localAlbumRelease.Distance.NormalizedDistance()} found in {watch.ElapsedMilliseconds}ms"); } } } diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/Munkres.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/Munkres.cs deleted file mode 100644 index 151d0c185..000000000 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/Munkres.cs +++ /dev/null @@ -1,504 +0,0 @@ -/* - The MIT License (MIT) - - Copyright (c) 2000 Robert A. Pilgrim - Murray State University - Dept. of Computer Science & Information Systems - Murray,Kentucky - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - - */ - -#pragma warning disable SX1101, SA1108, SA1119, SA1124, SA1200, SA1208, SA1214, SA1314, SA1403, SA1503, SA1514, SA1515, SA1519, SX1309 - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace NzbDrone.Core.MediaFiles.BookImport.Identification -{ - public class Munkres - { - private double[,] C; - private readonly double[,] C_orig; - private int[,] M; - private int[,] path; - private int[] RowCover; - private int[] ColCover; - private readonly int n; - private readonly int nrow_orig; - private readonly int ncol_orig; - private int path_count; - private int path_row_0; - private int path_col_0; - private int step; - - public Munkres(double[,] costMatrix) - { - C = PadMatrix(costMatrix); - n = C.GetLength(0); - nrow_orig = costMatrix.GetLength(0); - ncol_orig = costMatrix.GetLength(1); - - C_orig = C.Clone() as double[,]; - RowCover = new int[n]; - ColCover = new int[n]; - M = new int[n, n]; - path = new int[(2 * n) + 1, 2]; - - step = 1; - } - - public int[,] Marked - { - get - { - return M; - } - } - - public List> Solution - { - get - { - var value = new List>(); - for (int row = 0; row < nrow_orig; row++) - { - for (int col = 0; col < ncol_orig; col++) - { - if (M[row, col] == 1) - { - value.Add(Tuple.Create(row, col)); - } - } - } - - return value; - } - } - - public double Cost - { - get - { - var soln = Solution; - return soln.Select(x => C_orig[x.Item1, x.Item2]).Sum(); - } - } - - private double[,] PadMatrix(double[,] matrix) - { - var newdim = Math.Max(matrix.GetLength(0), matrix.GetLength(1)); - var outp = new double[newdim, newdim]; - for (int row = 0; row < matrix.GetLength(0); row++) - { - for (int col = 0; col < matrix.GetLength(1); col++) - { - outp[row, col] = matrix[row, col]; - } - } - - return outp; - } - - //For each row of the cost matrix, find the smallest element and subtract - //it from every element in its row. When finished, Go to Step 2. - private void step_one(ref int step) - { - double min_in_row; - - for (int r = 0; r < n; r++) - { - min_in_row = C[r, 0]; - for (int c = 0; c < n; c++) - { - if (C[r, c] < min_in_row) - { - min_in_row = C[r, c]; - } - } - - for (int c = 0; c < n; c++) - { - C[r, c] -= min_in_row; - } - } - - step = 2; - } - - //Find a zero (Z) in the resulting matrix. If there is no starred - //zero in its row or column, star Z. Repeat for each element in the - //matrix. Go to Step 3. - private void step_two(ref int step) - { - for (int r = 0; r < n; r++) - { - for (int c = 0; c < n; c++) - { - if (C[r, c] == 0 && RowCover[r] == 0 && ColCover[c] == 0) - { - M[r, c] = 1; - RowCover[r] = 1; - ColCover[c] = 1; - } - } - } - - for (int r = 0; r < n; r++) - { - RowCover[r] = 0; - } - - for (int c = 0; c < n; c++) - { - ColCover[c] = 0; - } - - step = 3; - } - - //Cover each column containing a starred zero. If K columns are covered, - //the starred zeros describe a complete set of unique assignments. In this - //case, Go to DONE, otherwise, Go to Step 4. - private void step_three(ref int step) - { - int colcount; - for (int r = 0; r < n; r++) - { - for (int c = 0; c < n; c++) - { - if (M[r, c] == 1) - { - ColCover[c] = 1; - } - } - } - - colcount = 0; - for (int c = 0; c < n; c++) - { - if (ColCover[c] == 1) - { - colcount += 1; - } - } - - if (colcount >= n) - { - step = 7; - } - else - { - step = 4; - } - } - - //methods to support step 4 - private void find_a_zero(ref int row, ref int col) - { - int r = 0; - int c; - bool done; - row = -1; - col = -1; - done = false; - while (!done) - { - c = 0; - while (true) - { - if (C[r, c] == 0 && RowCover[r] == 0 && ColCover[c] == 0) - { - row = r; - col = c; - done = true; - } - - c += 1; - if (c >= n || done) - { - break; - } - } - - r += 1; - if (r >= n) - { - done = true; - } - } - } - - private bool star_in_row(int row) - { - bool tmp = false; - for (int c = 0; c < n; c++) - { - if (M[row, c] == 1) - { - tmp = true; - } - } - - return tmp; - } - - private void find_star_in_row(int row, ref int col) - { - col = -1; - for (int c = 0; c < n; c++) - { - if (M[row, c] == 1) - { - col = c; - } - } - } - - //Find a noncovered zero and prime it. If there is no starred zero - //in the row containing this primed zero, Go to Step 5. Otherwise, - //cover this row and uncover the column containing the starred zero. - //Continue in this manner until there are no uncovered zeros left. - //Save the smallest uncovered value and Go to Step 6. - private void step_four(ref int step) - { - int row = -1; - int col = -1; - bool done; - - done = false; - while (!done) - { - find_a_zero(ref row, ref col); - if (row == -1) - { - done = true; - step = 6; - } - else - { - M[row, col] = 2; - if (star_in_row(row)) - { - find_star_in_row(row, ref col); - RowCover[row] = 1; - ColCover[col] = 0; - } - else - { - done = true; - step = 5; - path_row_0 = row; - path_col_0 = col; - } - } - } - } - - // methods to support step 5 - private void find_star_in_col(int c, ref int r) - { - r = -1; - for (int i = 0; i < n; i++) - { - if (M[i, c] == 1) - { - r = i; - } - } - } - - private void find_prime_in_row(int r, ref int c) - { - for (int j = 0; j < n; j++) - { - if (M[r, j] == 2) - { - c = j; - } - } - } - - private void augment_path() - { - for (int p = 0; p < path_count; p++) - { - if (M[path[p, 0], path[p, 1]] == 1) - { - M[path[p, 0], path[p, 1]] = 0; - } - else - { - M[path[p, 0], path[p, 1]] = 1; - } - } - } - - private void clear_covers() - { - for (int r = 0; r < n; r++) - { - RowCover[r] = 0; - } - - for (int c = 0; c < n; c++) - { - ColCover[c] = 0; - } - } - - private void erase_primes() - { - for (int r = 0; r < n; r++) - { - for (int c = 0; c < n; c++) - { - if (M[r, c] == 2) - { - M[r, c] = 0; - } - } - } - } - - //Construct a series of alternating primed and starred zeros as follows. - //Let Z0 represent the uncovered primed zero found in Step 4. Let Z1 denote - //the starred zero in the column of Z0 (if any). Let Z2 denote the primed zero - //in the row of Z1 (there will always be one). Continue until the series - //terminates at a primed zero that has no starred zero in its column. - //Unstar each starred zero of the series, star each primed zero of the series, - //erase all primes and uncover every line in the matrix. Return to Step 3. - private void step_five(ref int step) - { - bool done; - int r = -1; - int c = -1; - - path_count = 1; - path[path_count - 1, 0] = path_row_0; - path[path_count - 1, 1] = path_col_0; - done = false; - while (!done) - { - find_star_in_col(path[path_count - 1, 1], ref r); - if (r > -1) - { - path_count += 1; - path[path_count - 1, 0] = r; - path[path_count - 1, 1] = path[path_count - 2, 1]; - } - else - { - done = true; - } - - if (!done) - { - find_prime_in_row(path[path_count - 1, 0], ref c); - path_count += 1; - path[path_count - 1, 0] = path[path_count - 2, 0]; - path[path_count - 1, 1] = c; - } - } - - augment_path(); - clear_covers(); - erase_primes(); - step = 3; - } - - //methods to support step 6 - private void find_smallest(ref double minval) - { - for (int r = 0; r < n; r++) - { - for (int c = 0; c < n; c++) - { - if (RowCover[r] == 0 && ColCover[c] == 0) - { - if (minval > C[r, c]) - { - minval = C[r, c]; - } - } - } - } - } - - //Add the value found in Step 4 to every element of each covered row, and subtract - //it from every element of each uncovered column. Return to Step 4 without - //altering any stars, primes, or covered lines. - private void step_six(ref int step) - { - double minval = double.MaxValue; - find_smallest(ref minval); - for (int r = 0; r < n; r++) - { - for (int c = 0; c < n; c++) - { - if (RowCover[r] == 1) - { - C[r, c] += minval; - } - - if (ColCover[c] == 0) - { - C[r, c] -= minval; - } - } - } - - step = 4; - } - - public void Run() - { - bool done = false; - while (!done) - { - switch (step) - { - case 1: - step_one(ref step); - break; - case 2: - step_two(ref step); - break; - case 3: - step_three(ref step); - break; - case 4: - step_four(ref step); - break; - case 5: - step_five(ref step); - break; - case 6: - step_six(ref step); - break; - case 7: - done = true; - break; - } - } - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/TrackGroupingService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/TrackGroupingService.cs index c059c27ac..6d67aa629 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Identification/TrackGroupingService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Identification/TrackGroupingService.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification { public interface ITrackGroupingService { - List GroupTracks(List localTracks); + List GroupTracks(List localTracks); } public class TrackGroupingService : ITrackGroupingService @@ -25,17 +25,17 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification private static readonly string MultiDiscPatternFormat = @"^(?.*%s[\W_]*)\d"; private static readonly List VariousArtistTitles = new List { "", "various artists", "various", "va", "unknown" }; - public List GroupTracks(List localTracks) + public List GroupTracks(List localTracks) { _logger.ProgressInfo($"Grouping {localTracks.Count} tracks"); - var releases = new List(); + var releases = new List(); // text files are always single file releases var textfiles = localTracks.Where(x => MediaFileExtensions.TextExtensions.Contains(Path.GetExtension(x.Path))); foreach (var file in textfiles) { - releases.Add(new LocalAlbumRelease(new List { file })); + releases.Add(new LocalEdition(new List { file })); } // first attempt, assume grouped by folder @@ -45,7 +45,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification var tracks = group.ToList(); if (LooksLikeSingleRelease(tracks)) { - releases.Add(new LocalAlbumRelease(tracks)); + releases.Add(new LocalEdition(tracks)); } else { @@ -61,7 +61,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification var tracks = group.ToList(); if (LooksLikeSingleRelease(tracks)) { - releases.Add(new LocalAlbumRelease(tracks)); + releases.Add(new LocalEdition(tracks)); } else { @@ -73,7 +73,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification foreach (var group in unprocessed2.GroupBy(x => new { x.FileTrackInfo.ArtistTitle, x.FileTrackInfo.AlbumTitle })) { _logger.Debug("Falling back to grouping by album+author tag"); - releases.Add(new LocalAlbumRelease(group.ToList())); + releases.Add(new LocalEdition(group.ToList())); } return releases; diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs b/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs index a7598bfcc..5b06fd726 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs @@ -33,7 +33,9 @@ namespace NzbDrone.Core.MediaFiles.BookImport private readonly IAudioTagService _audioTagService; private readonly IAuthorService _authorService; private readonly IAddAuthorService _addAuthorService; + private readonly IRefreshAuthorService _refreshAuthorService; private readonly IBookService _bookService; + private readonly IEditionService _editionService; private readonly IRootFolderService _rootFolderService; private readonly IRecycleBinProvider _recycleBinProvider; private readonly IExtraService _extraService; @@ -43,25 +45,29 @@ namespace NzbDrone.Core.MediaFiles.BookImport private readonly Logger _logger; public ImportApprovedBooks(IUpgradeMediaFiles bookFileUpgrader, - IMediaFileService mediaFileService, - IAudioTagService audioTagService, - IAuthorService authorService, - IAddAuthorService addAuthorService, - IBookService bookService, - IRootFolderService rootFolderService, - IRecycleBinProvider recycleBinProvider, - IExtraService extraService, - IDiskProvider diskProvider, - IEventAggregator eventAggregator, - IManageCommandQueue commandQueueManager, - Logger logger) + IMediaFileService mediaFileService, + IAudioTagService audioTagService, + IAuthorService authorService, + IAddAuthorService addAuthorService, + IRefreshAuthorService refreshAuthorService, + IBookService bookService, + IEditionService editionService, + IRootFolderService rootFolderService, + IRecycleBinProvider recycleBinProvider, + IExtraService extraService, + IDiskProvider diskProvider, + IEventAggregator eventAggregator, + IManageCommandQueue commandQueueManager, + Logger logger) { _bookFileUpgrader = bookFileUpgrader; _mediaFileService = mediaFileService; _audioTagService = audioTagService; _authorService = authorService; _addAuthorService = addAuthorService; + _refreshAuthorService = refreshAuthorService; _bookService = bookService; + _editionService = editionService; _rootFolderService = rootFolderService; _recycleBinProvider = recycleBinProvider; _extraService = extraService; @@ -81,7 +87,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport var bookDecisions = decisions.Where(e => e.Item.Book != null && e.Approved) .GroupBy(e => e.Item.Book.ForeignBookId).ToList(); - int iDecision = 1; + var iDecision = 1; foreach (var albumDecision in bookDecisions) { _logger.ProgressInfo($"Importing book {iDecision++}/{bookDecisions.Count} {albumDecision.First().Item.Book}"); @@ -109,6 +115,11 @@ namespace NzbDrone.Core.MediaFiles.BookImport RemoveExistingTrackFiles(author, book); } + // set the correct release to be monitored before importing the new files + var newRelease = albumDecision.First().Item.Edition; + _logger.Debug("Updating release to {0}", newRelease); + book.Editions = _editionService.SetMonitored(newRelease); + // Publish book edited event. // Deliberatly don't put in the old book since we don't want to trigger an ArtistScan. _eventAggregator.PublishEvent(new BookEditedEvent(book, book)); @@ -152,9 +163,9 @@ namespace NzbDrone.Core.MediaFiles.BookImport ReleaseGroup = localTrack.ReleaseGroup, Quality = localTrack.Quality, MediaInfo = localTrack.FileTrackInfo.MediaInfo, - BookId = localTrack.Book.Id, + EditionId = localTrack.Edition.Id, Author = localTrack.Author, - Book = localTrack.Book + Edition = localTrack.Edition }; bool copyOnly; @@ -232,10 +243,9 @@ namespace NzbDrone.Core.MediaFiles.BookImport importResults.Add(new ImportResult(importDecision, "Failed to import book, Permissions error")); } - catch (Exception e) + catch (Exception) { - _logger.Warn(e, "Couldn't import book " + localTrack); - importResults.Add(new ImportResult(importDecision, "Failed to import book")); + throw; } } @@ -263,8 +273,8 @@ namespace NzbDrone.Core.MediaFiles.BookImport _eventAggregator.PublishEvent(new BookImportedEvent( author, book, - allImportedTrackFiles.Where(s => s.BookId == book.Id).ToList(), - allOldTrackFiles.Where(s => s.BookId == book.Id).ToList(), + allImportedTrackFiles.Where(s => s.EditionId == book.Id).ToList(), + allOldTrackFiles.Where(s => s.EditionId == book.Id).ToList(), replaceExisting, downloadClientItem)); } @@ -352,16 +362,20 @@ namespace NzbDrone.Core.MediaFiles.BookImport if (book.Id == 0) { - var dbAlbum = _bookService.FindById(book.ForeignBookId); + var dbBook = _bookService.FindById(book.ForeignBookId); - if (dbAlbum == null) + if (dbBook == null) { _logger.Debug($"Adding remote book {book}"); try { book.Added = DateTime.UtcNow; _bookService.InsertMany(new List { book }); - dbAlbum = _bookService.FindById(book.ForeignBookId); + + book.Editions.Value.ForEach(x => x.BookId = book.Id); + _editionService.InsertMany(book.Editions.Value); + + dbBook = _bookService.FindById(book.ForeignBookId); } catch (Exception e) { @@ -372,10 +386,18 @@ namespace NzbDrone.Core.MediaFiles.BookImport } } + var edition = dbBook.Editions.Value.ExclusiveOrDefault(x => x.ForeignEditionId == decisions.First().Item.Edition.ForeignEditionId); + if (edition == null) + { + RejectAlbum(decisions); + return null; + } + // Populate the new DB book foreach (var decision in decisions) { - decision.Item.Book = dbAlbum; + decision.Item.Book = dbBook; + decision.Item.Edition = edition; } } diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/BookImport/ImportDecisionMaker.cs index 5f1442cdb..1d03df1a3 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/ImportDecisionMaker.cs @@ -25,6 +25,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport { public Author Author { get; set; } public Book Book { get; set; } + public Edition Edition { get; set; } } public class ImportDecisionMakerInfo @@ -45,7 +46,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport public class ImportDecisionMaker : IMakeImportDecision { private readonly IEnumerable> _trackSpecifications; - private readonly IEnumerable> _bookSpecifications; + private readonly IEnumerable> _bookSpecifications; private readonly IMediaFileService _mediaFileService; private readonly IEBookTagService _eBookTagService; private readonly IAudioTagService _audioTagService; @@ -56,7 +57,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport private readonly Logger _logger; public ImportDecisionMaker(IEnumerable> trackSpecifications, - IEnumerable> albumSpecifications, + IEnumerable> albumSpecifications, IMediaFileService mediaFileService, IEBookTagService eBookTagService, IAudioTagService audioTagService, @@ -102,7 +103,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport downloadClientItemInfo = Parser.Parser.ParseBookTitle(downloadClientItem.Title); } - int i = 1; + var i = 1; foreach (var file in files) { _logger.ProgressInfo($"Reading file {i++}/{files.Count}"); @@ -179,38 +180,38 @@ namespace NzbDrone.Core.MediaFiles.BookImport return decisions; } - private void EnsureData(LocalAlbumRelease release) + private void EnsureData(LocalEdition edition) { - if (release.Book != null && release.Book.Author.Value.QualityProfileId == 0) + if (edition.Edition != null && edition.Edition.Book.Value.Author.Value.QualityProfileId == 0) { - var rootFolder = _rootFolderService.GetBestRootFolder(release.LocalBooks.First().Path); + var rootFolder = _rootFolderService.GetBestRootFolder(edition.LocalBooks.First().Path); var qualityProfile = _qualityProfileService.Get(rootFolder.DefaultQualityProfileId); - var author = release.Book.Author.Value; + var author = edition.Edition.Book.Value.Author.Value; author.QualityProfileId = qualityProfile.Id; author.QualityProfile = qualityProfile; } } - private ImportDecision GetDecision(LocalAlbumRelease localAlbumRelease, DownloadClientItem downloadClientItem) + private ImportDecision GetDecision(LocalEdition localEdition, DownloadClientItem downloadClientItem) { - ImportDecision decision = null; + ImportDecision decision = null; - if (localAlbumRelease.Book == null) + if (localEdition.Edition == null) { - decision = new ImportDecision(localAlbumRelease, new Rejection($"Couldn't find similar book for {localAlbumRelease}")); + decision = new ImportDecision(localEdition, new Rejection($"Couldn't find similar book for {localEdition}")); } else { - var reasons = _bookSpecifications.Select(c => EvaluateSpec(c, localAlbumRelease, downloadClientItem)) + var reasons = _bookSpecifications.Select(c => EvaluateSpec(c, localEdition, downloadClientItem)) .Where(c => c != null); - decision = new ImportDecision(localAlbumRelease, reasons.ToArray()); + decision = new ImportDecision(localEdition, reasons.ToArray()); } if (decision == null) { - _logger.Error("Unable to make a decision on {0}", localAlbumRelease); + _logger.Error("Unable to make a decision on {0}", localEdition); } else if (decision.Rejections.Any()) { diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportFile.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportFile.cs index 58156d2ca..0709b2d8e 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportFile.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportFile.cs @@ -9,8 +9,10 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual public string Path { get; set; } public int AuthorId { get; set; } public int BookId { get; set; } + public int EditionId { get; set; } public QualityModel Quality { get; set; } public string DownloadId { get; set; } + public bool DisableReleaseSwitching { get; set; } public bool Equals(ManualImportFile other) { diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportItem.cs index 75621e34e..9184ae2a8 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportItem.cs @@ -18,11 +18,13 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual public long Size { get; set; } public Author Author { get; set; } public Book Book { get; set; } + public Edition Edition { get; set; } public QualityModel Quality { get; set; } public string DownloadId { get; set; } public IEnumerable Rejections { get; set; } public ParsedTrackInfo Tags { get; set; } public bool AdditionalFile { get; set; } public bool ReplaceExistingFiles { get; set; } + public bool DisableReleaseSwitching { get; set; } } } diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs index df9edd6af..c3ec33075 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs @@ -36,6 +36,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual private readonly IMakeImportDecision _importDecisionMaker; private readonly IAuthorService _authorService; private readonly IBookService _bookService; + private readonly IEditionService _editionService; private readonly IAudioTagService _audioTagService; private readonly IImportApprovedBooks _importApprovedBooks; private readonly ITrackedDownloadService _trackedDownloadService; @@ -50,6 +51,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual IMakeImportDecision importDecisionMaker, IAuthorService authorService, IBookService bookService, + IEditionService editionService, IAudioTagService audioTagService, IImportApprovedBooks importApprovedBooks, ITrackedDownloadService trackedDownloadService, @@ -64,6 +66,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual _importDecisionMaker = importDecisionMaker; _authorService = authorService; _bookService = bookService; + _editionService = editionService; _audioTagService = audioTagService; _importApprovedBooks = importApprovedBooks; _trackedDownloadService = trackedDownloadService; @@ -105,7 +108,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual }; var decision = _importDecisionMaker.GetImportDecisions(files, null, null, config); - var result = MapItem(decision.First(), downloadId, replaceExistingFiles); + var result = MapItem(decision.First(), downloadId, replaceExistingFiles, false); return new List { result }; } @@ -158,9 +161,9 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual (f, d) => new { File = f, Decision = d }, PathEqualityComparer.Instance); - var newItems = newFiles.Select(x => MapItem(x.Decision, downloadId, replaceExistingFiles)); + var newItems = newFiles.Select(x => MapItem(x.Decision, downloadId, replaceExistingFiles, false)); var existingDecisions = decisions.Except(newFiles.Select(x => x.Decision)); - var existingItems = existingDecisions.Select(x => MapItem(x, null, replaceExistingFiles)); + var existingItems = existingDecisions.Select(x => MapItem(x, null, replaceExistingFiles, false)); return newItems.Concat(existingItems).ToList(); } @@ -177,11 +180,14 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual { _logger.Debug("UpdateItems, group key: {0}", group.Key); + var disableReleaseSwitching = group.First().DisableReleaseSwitching; + var files = group.Select(x => _diskProvider.GetFileInfo(x.Path)).ToList(); var idOverride = new IdentificationOverrides { Author = group.First().Author, Book = group.First().Book, + Edition = group.First().Edition }; var config = new ImportDecisionMakerConfig { @@ -212,6 +218,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual if (decision.Item.Book != null) { item.Book = decision.Item.Book; + item.Edition = decision.Item.Edition; } item.Rejections = decision.Rejections; @@ -220,13 +227,13 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual } var newDecisions = decisions.Except(existingItems.Select(x => x.Decision)); - result.AddRange(newDecisions.Select(x => MapItem(x, null, replaceExistingFiles))); + result.AddRange(newDecisions.Select(x => MapItem(x, null, replaceExistingFiles, disableReleaseSwitching))); } return result; } - private ManualImportItem MapItem(ImportDecision decision, string downloadId, bool replaceExistingFiles) + private ManualImportItem MapItem(ImportDecision decision, string downloadId, bool replaceExistingFiles, bool disableReleaseSwitching) { var item = new ManualImportItem(); @@ -243,6 +250,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual if (decision.Item.Book != null) { item.Book = decision.Item.Book; + item.Edition = decision.Item.Edition; } item.Quality = decision.Item.Quality; @@ -251,6 +259,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual item.Tags = decision.Item.FileTrackInfo; item.AdditionalFile = decision.Item.AdditionalFile; item.ReplaceExistingFiles = replaceExistingFiles; + item.DisableReleaseSwitching = disableReleaseSwitching; return item; } @@ -268,12 +277,21 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual { var albumImportDecisions = new List>(); + // turn off anyReleaseOk if specified + if (importBookId.First().DisableReleaseSwitching) + { + var book = _bookService.GetBook(importBookId.First().BookId); + book.AnyEditionOk = false; + _bookService.UpdateBook(book); + } + foreach (var file in importBookId) { _logger.ProgressTrace("Processing file {0} of {1}", fileCount + 1, message.Files.Count); var author = _authorService.GetAuthor(file.AuthorId); var book = _bookService.GetBook(file.BookId); + var edition = _editionService.GetEdition(file.EditionId); var fileTrackInfo = _audioTagService.ReadTags(file.Path) ?? new ParsedTrackInfo(); var fileInfo = _diskProvider.GetFileInfo(file.Path); @@ -286,7 +304,8 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual Modified = fileInfo.LastWriteTimeUtc, Quality = file.Quality, Author = author, - Book = book + Book = book, + Edition = edition }; var importDecision = new ImportDecision(localTrack); diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AlbumUpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AlbumUpgradeSpecification.cs index 12ddea265..92ef19bfa 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AlbumUpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AlbumUpgradeSpecification.cs @@ -7,7 +7,7 @@ using NzbDrone.Core.Qualities; namespace NzbDrone.Core.MediaFiles.BookImport.Specifications { - public class AlbumUpgradeSpecification : IImportDecisionEngineSpecification + public class AlbumUpgradeSpecification : IImportDecisionEngineSpecification { private readonly Logger _logger; @@ -16,9 +16,9 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) + public Decision IsSatisfiedBy(LocalEdition item, DownloadClientItem downloadClientItem) { - var qualityComparer = new QualityModelComparer(item.Book.Author.Value.QualityProfile); + var qualityComparer = new QualityModelComparer(item.Edition.Book.Value.Author.Value.QualityProfile); // min quality of all new tracks var newMinQuality = item.LocalBooks.Select(x => x.Quality).OrderBy(x => x, qualityComparer).First(); diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AlreadyImportedSpecification.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AlreadyImportedSpecification.cs index d91c571e9..9f39e169c 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AlreadyImportedSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AlreadyImportedSpecification.cs @@ -4,12 +4,11 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.History; -using NzbDrone.Core.MediaFiles.BookImport; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.BookImport.Specifications { - public class AlreadyImportedSpecification : IImportDecisionEngineSpecification + public class AlreadyImportedSpecification : IImportDecisionEngineSpecification { private readonly IHistoryService _historyService; private readonly Logger _logger; @@ -23,7 +22,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Specifications public SpecificationPriority Priority => SpecificationPriority.Database; - public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease, DownloadClientItem downloadClientItem) + public Decision IsSatisfiedBy(LocalEdition localAlbumRelease, DownloadClientItem downloadClientItem) { if (downloadClientItem == null) { @@ -31,7 +30,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Specifications return Decision.Accept(); } - var albumRelease = localAlbumRelease.Book; + var albumRelease = localAlbumRelease.Edition; if ((!albumRelease?.BookFiles?.Value?.Any()) ?? true) { diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AuthorPathInRootFolderSpecification.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AuthorPathInRootFolderSpecification.cs index e98f6e89c..403a7e63a 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AuthorPathInRootFolderSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/AuthorPathInRootFolderSpecification.cs @@ -8,7 +8,7 @@ using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.MediaFiles.BookImport.Specifications { - public class AuthorPathInRootFolderSpecification : IImportDecisionEngineSpecification + public class AuthorPathInRootFolderSpecification : IImportDecisionEngineSpecification { private readonly IRootFolderService _rootFolderService; private readonly Logger _logger; @@ -20,10 +20,10 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) + public Decision IsSatisfiedBy(LocalEdition item, DownloadClientItem downloadClientItem) { // Prevent imports to artists that are no longer inside a root folder Readarr manages - var author = item.Book.Author.Value; + var author = item.Edition.Book.Value.Author.Value; // a new author will have empty path, and will end up having path assinged based on file location var pathToCheck = author.Path.IsNotNullOrWhiteSpace() ? author.Path : item.LocalBooks.First().Path.GetParentPath(); diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs index a8a0020c8..98a5cf427 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Specifications/CloseAlbumMatchSpecification.cs @@ -6,7 +6,7 @@ using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.BookImport.Specifications { - public class CloseAlbumMatchSpecification : IImportDecisionEngineSpecification + public class CloseAlbumMatchSpecification : IImportDecisionEngineSpecification { private const double _albumThreshold = 0.20; private readonly Logger _logger; @@ -16,7 +16,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) + public Decision IsSatisfiedBy(LocalEdition item, DownloadClientItem downloadClientItem) { double dist; string reasons; diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs index a577722ae..041cd31ca 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.MediaFiles { List GetFilesByAuthor(int authorId); List GetFilesByBook(int bookId); + List GetFilesByEdition(int editionId); List GetUnmappedFiles(); List GetFilesWithBasePath(string path); List GetFileWithPath(List paths); @@ -31,7 +32,8 @@ namespace NzbDrone.Core.MediaFiles // always join with all the other good stuff // needed more often than not so better to load it all now protected override SqlBuilder Builder() => new SqlBuilder() - .LeftJoin((t, a) => t.BookId == a.Id) + .LeftJoin((b, e) => b.EditionId == e.Id) + .LeftJoin((e, b) => e.BookId == b.Id) .LeftJoin((book, author) => book.AuthorMetadataId == author.AuthorMetadataId) .LeftJoin((a, m) => a.AuthorMetadataId == m.Id); @@ -39,12 +41,17 @@ namespace NzbDrone.Core.MediaFiles public static IEnumerable Query(IDatabase database, SqlBuilder builder) { - return database.QueryJoined(builder, (file, book, author, metadata) => Map(file, book, author, metadata)); + return database.QueryJoined(builder, (file, edition, book, author, metadata) => Map(file, edition, book, author, metadata)); } - private static BookFile Map(BookFile file, Book book, Author author, AuthorMetadata metadata) + private static BookFile Map(BookFile file, Edition edition, Book book, Author author, AuthorMetadata metadata) { - file.Book = book; + file.Edition = edition; + + if (edition != null) + { + edition.Book = book; + } if (author != null) { @@ -63,29 +70,30 @@ namespace NzbDrone.Core.MediaFiles public List GetFilesByBook(int bookId) { - return Query(Builder().Where(f => f.BookId == bookId)); + return Query(Builder().Where(b => b.Id == bookId)); + } + + public List GetFilesByEdition(int editionId) + { + return Query(Builder().Where(f => f.EditionId == editionId)); } public List GetUnmappedFiles() { - //x.Id == null is converted to SQL, so warning incorrect -#pragma warning disable CS0472 return _database.Query(new SqlBuilder().Select(typeof(BookFile)) - .LeftJoin((f, t) => f.BookId == t.Id) - .Where(t => t.Id == null)).ToList(); -#pragma warning restore CS0472 + .Where(t => t.EditionId == 0)).ToList(); } public void DeleteFilesByBook(int bookId) { - Delete(x => x.BookId == bookId); + Delete(x => x.EditionId == bookId); } public void UnlinkFilesByBook(int bookId) { - var files = Query(x => x.BookId == bookId); - files.ForEach(x => x.BookId = 0); - SetFields(files, f => f.BookId); + var files = Query(x => x.EditionId == bookId); + files.ForEach(x => x.EditionId = 0); + SetFields(files, f => f.EditionId); } public List GetFilesWithBasePath(string path) @@ -104,17 +112,17 @@ namespace NzbDrone.Core.MediaFiles { // use more limited join for speed var builder = new SqlBuilder() - .LeftJoin((f, t) => f.BookId == t.Id); + .LeftJoin((f, t) => f.EditionId == t.Id); - var all = _database.QueryJoined(builder, (file, book) => MapTrack(file, book)).ToList(); + var all = _database.QueryJoined(builder, (file, book) => MapTrack(file, book)).ToList(); var joined = all.Join(paths, x => x.Path, x => x, (file, path) => file, PathEqualityComparer.Instance).ToList(); return joined; } - private BookFile MapTrack(BookFile file, Book book) + private BookFile MapTrack(BookFile file, Edition book) { - file.Book = book; + file.Edition = book; return file; } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index 640f4e6c7..437aee7d9 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -23,6 +23,7 @@ namespace NzbDrone.Core.MediaFiles void DeleteMany(List bookFiles, DeleteMediaFileReason reason); List GetFilesByAuthor(int authorId); List GetFilesByBook(int bookId); + List GetFilesByEdition(int editionId); List GetUnmappedFiles(); List FilterUnchangedFiles(List files, FilterFilesType filter); BookFile Get(int id); @@ -80,7 +81,7 @@ namespace NzbDrone.Core.MediaFiles _mediaFileRepository.Delete(bookFile); // If the trackfile wasn't mapped to a track, don't publish an event - if (bookFile.BookId > 0) + if (bookFile.EditionId > 0) { _eventAggregator.PublishEvent(new BookFileDeletedEvent(bookFile, reason)); } @@ -91,7 +92,7 @@ namespace NzbDrone.Core.MediaFiles _mediaFileRepository.DeleteMany(bookFiles); // publish events where trackfile was mapped to a track - foreach (var bookFile in bookFiles.Where(x => x.BookId > 0)) + foreach (var bookFile in bookFiles.Where(x => x.EditionId > 0)) { _eventAggregator.PublishEvent(new BookFileDeletedEvent(bookFile, reason)); } @@ -138,7 +139,7 @@ namespace NzbDrone.Core.MediaFiles unwanted = combined .Where(x => x.DiskFile.Length == x.DbFile.Size && Math.Abs((x.DiskFile.LastWriteTimeUtc - x.DbFile.Modified).TotalSeconds) <= 1 && - (x.DbFile.Book == null || (x.DbFile.Book.IsLoaded && x.DbFile.Book.Value != null))) + (x.DbFile.Edition == null || (x.DbFile.Edition.IsLoaded && x.DbFile.Edition.Value != null))) .Select(x => x.DiskFile) .ToList(); _logger.Trace($"{unwanted.Count} unchanged and matched files"); @@ -186,6 +187,11 @@ namespace NzbDrone.Core.MediaFiles return _mediaFileRepository.GetFilesByBook(bookId); } + public List GetFilesByEdition(int editionId) + { + return _mediaFileRepository.GetFilesByEdition(editionId); + } + public List GetUnmappedFiles() { return _mediaFileRepository.GetUnmappedFiles(); diff --git a/src/NzbDrone.Core/MediaFiles/RenameBookFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameBookFileService.cs index da913c251..eda191525 100644 --- a/src/NzbDrone.Core/MediaFiles/RenameBookFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/RenameBookFileService.cs @@ -74,7 +74,7 @@ namespace NzbDrone.Core.MediaFiles foreach (var f in files) { var file = f; - var book = file.Book.Value; + var book = file.Edition.Value; var bookFilePath = file.Path; if (book == null) diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Extensions/HttpResponseExtensions.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Extensions/HttpResponseExtensions.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/Extensions/HttpResponseExtensions.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Extensions/HttpResponseExtensions.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Extensions/XmlExtensions.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Extensions/XmlExtensions.cs similarity index 99% rename from src/NzbDrone.Core/MetadataSource/SkyHook/Extensions/XmlExtensions.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Extensions/XmlExtensions.cs index 6fb2f10fe..67d0a17ca 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Extensions/XmlExtensions.cs +++ b/src/NzbDrone.Core/MetadataSource/Goodreads/Extensions/XmlExtensions.cs @@ -142,7 +142,7 @@ namespace NzbDrone.Core.MetadataSource.Goodreads try { - return new DateTime(publicationYear.Value, publicationMonth.Value, publicationDay.Value); + return new DateTime(publicationYear.Value, publicationMonth.Value, publicationDay.Value, 0, 0, 0, DateTimeKind.Utc); } catch { diff --git a/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsException.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsException.cs new file mode 100644 index 000000000..748426b58 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsException.cs @@ -0,0 +1,18 @@ +using System.Net; +using NzbDrone.Core.Exceptions; + +namespace NzbDrone.Core.MetadataSource.Goodreads +{ + public class GoodreadsException : NzbDroneClientException + { + public GoodreadsException(string message) + : base(HttpStatusCode.ServiceUnavailable, message) + { + } + + public GoodreadsException(string message, params object[] args) + : base(HttpStatusCode.ServiceUnavailable, message, args) + { + } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsProxy.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsProxy.cs new file mode 100644 index 000000000..96408a6db --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsProxy.cs @@ -0,0 +1,764 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Books; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.MetadataSource.Goodreads +{ + public class GoodreadsProxy : IProvideAuthorInfo, ISearchForNewAuthor, IProvideBookInfo, ISearchForNewBook, ISearchForNewEntity + { + private static readonly RegexReplace FullSizeImageRegex = new RegexReplace(@"\._[SU][XY]\d+_.jpg$", + ".jpg", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex DuplicateSpacesRegex = new Regex(@"\s{2,}", RegexOptions.Compiled); + + private static readonly Regex NoPhotoRegex = new Regex(@"/nophoto/(book|user)/", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly IAuthorService _authorService; + private readonly IBookService _bookService; + private readonly IEditionService _editionService; + private readonly IHttpRequestBuilderFactory _requestBuilder; + private readonly IHttpRequestBuilderFactory _searchBuilder; + private readonly ICached> _cache; + + public GoodreadsProxy(IHttpClient httpClient, + IAuthorService authorService, + IBookService bookService, + IEditionService editionService, + Logger logger, + ICacheManager cacheManager) + { + _httpClient = httpClient; + _authorService = authorService; + _bookService = bookService; + _editionService = editionService; + _cache = cacheManager.GetCache>(GetType()); + _logger = logger; + + _requestBuilder = new HttpRequestBuilder("https://www.goodreads.com/{route}") + .AddQueryParam("key", new string("gSuM2Onzl6sjMU25HY1Xcd".Reverse().ToArray())) + .AddQueryParam("_nc", "1") + .SetHeader("User-Agent", "Dalvik/1.6.0 (Linux; U; Android 4.1.2; GT-I9100 Build/JZO54K)") + .KeepAlive() + .CreateFactory(); + + _searchBuilder = new HttpRequestBuilder("https://www.goodreads.com/book/auto_complete") + .AddQueryParam("format", "json") + .SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36") + .KeepAlive() + .CreateFactory(); + } + + public HashSet GetChangedArtists(DateTime startTime) + { + return null; + } + + public Author GetAuthorInfo(string foreignAuthorId) + { + _logger.Debug("Getting Author details GoodreadsId of {0}", foreignAuthorId); + + var httpRequest = _requestBuilder.Create() + .SetSegment("route", $"author/show/{foreignAuthorId}.xml") + .AddQueryParam("exclude_books", "true") + .Build(); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _httpClient.Get(httpRequest); + + if (httpResponse.HasHttpError) + { + if (httpResponse.StatusCode == HttpStatusCode.NotFound) + { + throw new AuthorNotFoundException(foreignAuthorId); + } + else if (httpResponse.StatusCode == HttpStatusCode.BadRequest) + { + throw new BadRequestException(foreignAuthorId); + } + else + { + throw new HttpException(httpRequest, httpResponse); + } + } + + var resource = httpResponse.Deserialize(); + var author = new Author + { + Metadata = MapAuthor(resource) + }; + author.CleanName = Parser.Parser.CleanAuthorName(author.Metadata.Value.Name); + author.SortName = Parser.Parser.NormalizeTitle(author.Metadata.Value.Name); + + // we can only get a rating from the author list page... + var listResource = GetAuthorBooksPageResource(foreignAuthorId, 10, 1); + var authorResource = listResource.List.First().Authors.First(a => a.Id.ToString() == foreignAuthorId); + author.Metadata.Value.Ratings = new Ratings + { + Votes = authorResource.RatingsCount ?? 0, + Value = authorResource.AverageRating ?? 0 + }; + + return author; + } + + public Author GetAuthorAndBooks(string foreignAuthorId, double minPopularity = 0) + { + var author = GetAuthorInfo(foreignAuthorId); + + var bookList = GetAuthorBooks(foreignAuthorId, minPopularity); + var books = bookList.Select(x => GetBookInfo(x.Editions.Value.First().ForeignEditionId).Item2).ToList(); + + var existingAuthor = _authorService.FindById(foreignAuthorId); + if (existingAuthor != null) + { + var existingEditions = _editionService.GetEditionsByAuthor(existingAuthor.Id); + var extraEditionIds = existingEditions.Select(x => x.ForeignEditionId).Except(books.Select(x => x.Editions.Value.First().ForeignEditionId)); + + _logger.Debug($"Getting data for extra editions {extraEditionIds.ConcatToString()}"); + var extraEditions = extraEditionIds.Select(x => GetBookInfo(x)); + + var bookDict = books.ToDictionary(x => x.ForeignBookId); + foreach (var edition in extraEditions) + { + var b = edition.Item2; + + if (bookDict.TryGetValue(b.ForeignBookId, out var book)) + { + book.Editions.Value.Add(b.Editions.Value.First()); + } + else + { + bookDict.Add(b.ForeignBookId, b); + } + } + + books = bookDict.Values.ToList(); + } + + books.ForEach(x => x.AuthorMetadata = author.Metadata.Value); + author.Books = books; + + author.Series = GetAuthorSeries(foreignAuthorId, author.Books); + + return author; + } + + private List GetAuthorBooks(string foreignAuthorId, double minPopularity) + { + var perPage = 100; + var page = 0; + + var result = new List(); + List current; + IEnumerable filtered; + + do + { + current = GetAuthorBooksPage(foreignAuthorId, perPage, ++page); + filtered = current.Where(x => x.Editions.Value.First().Ratings.Popularity >= minPopularity); + result.AddRange(filtered); + } + while (current.Count == perPage && filtered.Any()); + + return result; + } + + private List GetAuthorBooksPage(string foreignAuthorId, int perPage, int page) + { + var resource = GetAuthorBooksPageResource(foreignAuthorId, perPage, page); + + var books = resource?.List.Where(x => x.Authors.First().Id.ToString() == foreignAuthorId) + .Select(MapBook) + .ToList() ?? + new List(); + + books.ForEach(x => x.CleanTitle = x.Title.CleanAuthorName()); + + return books; + } + + private AuthorBookListResource GetAuthorBooksPageResource(string foreignAuthorId, int perPage, int page) + { + _logger.Debug("Getting Author Books with GoodreadsId of {0}", foreignAuthorId); + + var httpRequest = _requestBuilder.Create() + .SetSegment("route", $"author/list/{foreignAuthorId}.xml") + .AddQueryParam("per_page", perPage) + .AddQueryParam("page", page) + .AddQueryParam("sort", "popularity") + .Build(); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _httpClient.Get(httpRequest); + + if (httpResponse.HasHttpError) + { + if (httpResponse.StatusCode == HttpStatusCode.NotFound) + { + throw new AuthorNotFoundException(foreignAuthorId); + } + else if (httpResponse.StatusCode == HttpStatusCode.BadRequest) + { + throw new BadRequestException(foreignAuthorId); + } + else + { + throw new HttpException(httpRequest, httpResponse); + } + } + + return httpResponse.Deserialize(); + } + + private List GetAuthorSeries(string foreignAuthorId, List books) + { + _logger.Debug("Getting Author Series with GoodreadsId of {0}", foreignAuthorId); + + var httpRequest = _requestBuilder.Create() + .SetSegment("route", $"series/list/{foreignAuthorId}.xml") + .Build(); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _httpClient.Get(httpRequest); + + if (httpResponse.HasHttpError) + { + if (httpResponse.StatusCode == HttpStatusCode.NotFound) + { + throw new AuthorNotFoundException(foreignAuthorId); + } + else if (httpResponse.StatusCode == HttpStatusCode.BadRequest) + { + throw new BadRequestException(foreignAuthorId); + } + else + { + throw new HttpException(httpRequest, httpResponse); + } + } + + var resource = httpResponse.Deserialize(); + + var result = new List(); + var bookDict = books.ToDictionary(x => x.ForeignBookId); + + // only take series where there are some works + foreach (var seriesResource in resource.List.Where(x => x.Works.Any())) + { + var series = MapSeries(seriesResource); + series.LinkItems = new List(); + + var works = seriesResource.Works + .Where(x => x.BestBook.AuthorId.ToString() == foreignAuthorId && + bookDict.ContainsKey(x.Id.ToString())); + foreach (var work in works) + { + series.LinkItems.Value.Add(new SeriesBookLink + { + Book = bookDict[work.Id.ToString()], + Series = series, + IsPrimary = true, + Position = work.UserPosition + }); + } + + if (series.LinkItems.Value.Any()) + { + result.Add(series); + } + } + + return result; + } + + public HashSet GetChangedBooks(DateTime startTime) + { + return _cache.Get("ChangedBooks", () => GetChangedBooksUncached(startTime), TimeSpan.FromMinutes(30)); + } + + private HashSet GetChangedBooksUncached(DateTime startTime) + { + return null; + } + + public Tuple> GetBookInfo(string foreignEditionId) + { + _logger.Debug("Getting Book with GoodreadsId of {0}", foreignEditionId); + + var httpRequest = _requestBuilder.Create() + .SetSegment("route", $"api/book/basic_book_data/{foreignEditionId}") + .AddQueryParam("format", "xml") + .Build(); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _httpClient.Get(httpRequest); + + if (httpResponse.HasHttpError) + { + if (httpResponse.StatusCode == HttpStatusCode.NotFound) + { + throw new BookNotFoundException(foreignEditionId); + } + else if (httpResponse.StatusCode == HttpStatusCode.BadRequest) + { + throw new BadRequestException(foreignEditionId); + } + else + { + throw new HttpException(httpRequest, httpResponse); + } + } + + var resource = httpResponse.Deserialize(); + + var book = MapBook(resource); + book.CleanTitle = Parser.Parser.CleanAuthorName(book.Title); + + var authors = resource.Authors.SelectList(MapAuthor); + book.AuthorMetadata = authors.First(); + + return new Tuple>(resource.Authors.First().Id.ToString(), book, authors); + } + + public List SearchForNewAuthor(string title) + { + var books = SearchForNewBook(title, null); + + return books.Select(x => x.Author.Value).ToList(); + } + + public List SearchForNewBook(string title, string author) + { + try + { + var lowerTitle = title.ToLowerInvariant(); + + var split = lowerTitle.Split(':'); + var prefix = split[0]; + + if (split.Length == 2 && new[] { "readarr", "readarrid", "goodreads", "isbn", "asin" }.Contains(prefix)) + { + var slug = split[1].Trim(); + + if (slug.IsNullOrWhiteSpace() || slug.Any(char.IsWhiteSpace)) + { + return new List(); + } + + if (prefix == "goodreads" || prefix == "readarr" || prefix == "readarrid") + { + var isValid = int.TryParse(slug, out var searchId); + if (!isValid) + { + return new List(); + } + + return SearchByGoodreadsId(searchId); + } + else if (prefix == "isbn") + { + return SearchByIsbn(slug); + } + else if (prefix == "asin") + { + return SearchByAsin(slug); + } + } + + var q = title.ToLower().Trim(); + if (author != null) + { + q += " " + author; + } + + return SearchByField("all", q); + } + catch (HttpException) + { + throw new GoodreadsException("Search for '{0}' failed. Unable to communicate with Goodreads.", title); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + throw new GoodreadsException("Search for '{0}' failed. Invalid response received from Goodreads.", title); + } + } + + public List SearchByIsbn(string isbn) + { + return SearchByField("isbn", isbn); + } + + public List SearchByAsin(string asin) + { + return SearchByField("isbn", asin); + } + + public List SearchByGoodreadsId(int id) + { + try + { + var remote = GetBookInfo(id.ToString()); + + var book = _bookService.FindById(remote.Item2.ForeignBookId); + var result = book ?? remote.Item2; + + var edition = _editionService.GetEditionByForeignEditionId(remote.Item2.Editions.Value.Single(x => x.Monitored).ForeignEditionId); + if (edition != null) + { + result.Editions = new List { edition }; + } + + var author = _authorService.FindById(remote.Item1); + if (author == null) + { + author = new Author + { + CleanName = Parser.Parser.CleanAuthorName(remote.Item2.AuthorMetadata.Value.Name), + Metadata = remote.Item2.AuthorMetadata.Value + }; + } + + result.Author = author; + + return new List { result }; + } + catch (BookNotFoundException) + { + return new List(); + } + } + + public List SearchByField(string field, string query) + { + try + { + var httpRequest = _searchBuilder.Create() + .AddQueryParam("q", query) + .Build(); + + var result = _httpClient.Get>(httpRequest); + + return result.Resource.SelectList(MapJsonSearchResult); + } + catch (HttpException) + { + throw new GoodreadsException("Search for {0} '{1}' failed. Unable to communicate with Goodreads.", field, query); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + throw new GoodreadsException("Search for {0} '{1}' failed. Invalid response received from Goodreads.", field, query); + } + } + + public List SearchForNewEntity(string title) + { + var books = SearchForNewBook(title, null); + + var result = new List(); + foreach (var book in books) + { + var author = book.Author.Value; + + if (!result.Contains(author)) + { + result.Add(author); + } + + result.Add(book); + } + + return result; + } + + private static AuthorMetadata MapAuthor(AuthorResource resource) + { + var author = new AuthorMetadata + { + ForeignAuthorId = resource.Id.ToString(), + TitleSlug = resource.Id.ToString(), + Name = resource.Name.CleanSpaces(), + Overview = resource.About, + Gender = resource.Gender, + Hometown = resource.Hometown, + Born = resource.BornOnDate, + Died = resource.DiedOnDate, + Status = resource.DiedOnDate < DateTime.UtcNow ? AuthorStatusType.Ended : AuthorStatusType.Continuing + }; + + if (!NoPhotoRegex.IsMatch(resource.LargeImageUrl)) + { + author.Images.Add(new MediaCover.MediaCover + { + Url = FullSizeImageRegex.Replace(resource.LargeImageUrl), + CoverType = MediaCoverTypes.Poster + }); + } + + author.Links.Add(new Links { Url = resource.Link, Name = "Goodreads" }); + + return author; + } + + private static AuthorMetadata MapAuthor(AuthorSummaryResource resource) + { + var author = new AuthorMetadata + { + ForeignAuthorId = resource.Id.ToString(), + Name = resource.Name.CleanSpaces(), + TitleSlug = resource.Id.ToString() + }; + + if (resource.RatingsCount.HasValue) + { + author.Ratings = new Ratings + { + Votes = resource.RatingsCount ?? 0, + Value = resource.AverageRating ?? 0 + }; + } + + if (!NoPhotoRegex.IsMatch(resource.ImageUrl)) + { + author.Images.Add(new MediaCover.MediaCover + { + Url = FullSizeImageRegex.Replace(resource.ImageUrl), + CoverType = MediaCoverTypes.Poster + }); + } + + return author; + } + + private static Series MapSeries(SeriesResource resource) + { + var series = new Series + { + ForeignSeriesId = resource.Id.ToString(), + Title = resource.Title, + Description = resource.Description, + Numbered = resource.IsNumbered, + WorkCount = resource.SeriesWorksCount, + PrimaryWorkCount = resource.PrimaryWorksCount + }; + + return series; + } + + private static Book MapBook(BookResource resource) + { + var book = new Book + { + ForeignBookId = resource.Work.Id.ToString(), + Title = (resource.Work.OriginalTitle ?? resource.Title).CleanSpaces(), + TitleSlug = resource.Id.ToString(), + ReleaseDate = resource.Work.OriginalPublicationDate ?? resource.PublicationDate, + Ratings = new Ratings { Votes = resource.Work.RatingsCount, Value = resource.Work.AverageRating }, + AnyEditionOk = true + }; + + if (resource.EditionsUrl != null) + { + book.Links.Add(new Links { Url = resource.EditionsUrl, Name = "Goodreads Editions" }); + } + + var edition = new Edition + { + ForeignEditionId = resource.Id.ToString(), + TitleSlug = resource.Id.ToString(), + Isbn13 = resource.Isbn13, + Asin = resource.Asin ?? resource.KindleAsin, + Title = resource.TitleWithoutSeries, + Language = resource.LanguageCode, + Overview = resource.Description, + Format = resource.Format, + IsEbook = resource.IsEbook, + Disambiguation = resource.EditionInformation, + Publisher = resource.Publisher, + PageCount = resource.Pages, + ReleaseDate = resource.PublicationDate, + Ratings = new Ratings { Votes = resource.RatingsCount, Value = resource.AverageRating }, + Monitored = true + }; + + if (resource.ImageUrl.IsNotNullOrWhiteSpace() && !NoPhotoRegex.IsMatch(resource.ImageUrl)) + { + edition.Images.Add(new MediaCover.MediaCover + { + Url = FullSizeImageRegex.Replace(resource.ImageUrl), + CoverType = MediaCoverTypes.Cover + }); + } + + edition.Links.Add(new Links { Url = resource.Url, Name = "Goodreads Book" }); + + book.Editions = new List { edition }; + + Debug.Assert(!book.Editions.Value.Any() || book.Editions.Value.Count(x => x.Monitored) == 1, "one edition monitored"); + + return book; + } + + private Book MapSearchResult(WorkResource resource) + { + var book = _bookService.FindById(resource.Id.ToString()); + if (resource.BestBook != null) + { + var edition = _editionService.GetEditionByForeignEditionId(resource.BestBook.Id.ToString()); + + if (edition == null) + { + edition = new Edition + { + ForeignEditionId = resource.BestBook.Id.ToString(), + Title = resource.BestBook.Title, + TitleSlug = resource.BestBook.Id.ToString(), + Ratings = new Ratings { Votes = resource.RatingsCount, Value = resource.AverageRating }, + }; + } + + edition.Monitored = true; + edition.ManualAdd = true; + + if (resource.BestBook.ImageUrl.IsNotNullOrWhiteSpace() && !NoPhotoRegex.IsMatch(resource.BestBook.ImageUrl)) + { + edition.Images.Add(new MediaCover.MediaCover + { + Url = FullSizeImageRegex.Replace(resource.BestBook.ImageUrl), + CoverType = MediaCoverTypes.Cover + }); + } + + if (book == null) + { + book = new Book + { + ForeignBookId = resource.Id.ToString(), + Title = resource.BestBook.Title, + TitleSlug = resource.Id.ToString(), + ReleaseDate = resource.OriginalPublicationDate, + Ratings = new Ratings { Votes = resource.RatingsCount, Value = resource.AverageRating }, + AnyEditionOk = true + }; + } + + book.Editions = new List { edition }; + + var authorId = resource.BestBook.AuthorId.ToString(); + var author = _authorService.FindById(authorId); + + if (author == null) + { + author = new Author + { + CleanName = Parser.Parser.CleanAuthorName(resource.BestBook.AuthorName), + Metadata = new AuthorMetadata() + { + ForeignAuthorId = resource.BestBook.AuthorId.ToString(), + Name = resource.BestBook.AuthorName, + TitleSlug = resource.BestBook.AuthorId.ToString() + } + }; + } + + book.Author = author; + book.AuthorMetadata = book.Author.Value.Metadata.Value; + book.CleanTitle = book.Title.CleanAuthorName(); + } + + return book; + } + + private Book MapJsonSearchResult(SearchJsonResource resource) + { + var book = _bookService.FindById(resource.WorkId.ToString()); + var edition = _editionService.GetEditionByForeignEditionId(resource.BookId.ToString()); + + if (edition == null) + { + edition = new Edition + { + ForeignEditionId = resource.BookId.ToString(), + Title = resource.BookTitleBare, + TitleSlug = resource.BookId.ToString(), + Ratings = new Ratings { Votes = resource.RatingsCount, Value = resource.AverageRating }, + PageCount = resource.PageCount, + Overview = resource.Description?.Html ?? string.Empty + }; + } + + edition.Monitored = true; + edition.ManualAdd = true; + + if (resource.ImageUrl.IsNotNullOrWhiteSpace() && !NoPhotoRegex.IsMatch(resource.ImageUrl)) + { + edition.Images.Add(new MediaCover.MediaCover + { + Url = FullSizeImageRegex.Replace(resource.ImageUrl), + CoverType = MediaCoverTypes.Cover + }); + } + + if (book == null) + { + book = new Book + { + ForeignBookId = resource.WorkId.ToString(), + Title = resource.BookTitleBare, + TitleSlug = resource.WorkId.ToString(), + Ratings = new Ratings { Votes = resource.RatingsCount, Value = resource.AverageRating }, + AnyEditionOk = true + }; + } + + book.Editions = new List { edition }; + + var authorId = resource.Author.Id.ToString(); + var author = _authorService.FindById(authorId); + + if (author == null) + { + author = new Author + { + CleanName = Parser.Parser.CleanAuthorName(resource.Author.Name), + Metadata = new AuthorMetadata() + { + ForeignAuthorId = resource.Author.Id.ToString(), + Name = DuplicateSpacesRegex.Replace(resource.Author.Name, " "), + TitleSlug = resource.Author.Id.ToString() + } + }; + } + + book.Author = author; + book.AuthorMetadata = book.Author.Value.Metadata.Value; + book.CleanTitle = book.Title.CleanAuthorName(); + + return book; + } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/AuthorBookListResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/AuthorBookListResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/AuthorBookListResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/AuthorBookListResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/AuthorResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/AuthorResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/AuthorResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/AuthorResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/AuthorSeriesListResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/AuthorSeriesListResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/AuthorSeriesListResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/AuthorSeriesListResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/AuthorSummaryResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/AuthorSummaryResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/AuthorSummaryResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/AuthorSummaryResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BestBookResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BestBookResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BestBookResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BestBookResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookLinkResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookLinkResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookLinkResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookLinkResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookResource.cs similarity index 94% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookResource.cs index 245a4185c..2867a6db5 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookResource.cs +++ b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookResource.cs @@ -24,6 +24,8 @@ namespace NzbDrone.Core.MetadataSource.Goodreads /// public string Title { get; private set; } + public string TitleWithoutSeries { get; private set; } + /// /// The description of this book. /// @@ -64,11 +66,6 @@ namespace NzbDrone.Core.MetadataSource.Goodreads /// public string ImageUrl { get; private set; } - /// - /// The small cover image for this book. - /// - public string SmallImageUrl { get; private set; } - /// /// The date this book was published. /// @@ -124,6 +121,8 @@ namespace NzbDrone.Core.MetadataSource.Goodreads /// public string Url { get; private set; } + public string EditionsUrl { get; private set; } + /// /// The aggregate information for this work across all editions of the book. /// @@ -171,14 +170,16 @@ namespace NzbDrone.Core.MetadataSource.Goodreads { Id = element.ElementAsLong("id"); Title = element.ElementAsString("title"); + TitleWithoutSeries = element.ElementAsString("title_without_series"); Isbn = element.ElementAsString("isbn"); Isbn13 = element.ElementAsString("isbn13"); Asin = element.ElementAsString("asin"); KindleAsin = element.ElementAsString("kindle_asin"); MarketplaceId = element.ElementAsString("marketplace_id"); CountryCode = element.ElementAsString("country_code"); - ImageUrl = element.ElementAsString("image_url"); - SmallImageUrl = element.ElementAsString("small_image_url"); + ImageUrl = element.ElementAsString("large_image_url") ?? + element.ElementAsString("image_url") ?? + element.ElementAsString("small_image_url"); PublicationDate = element.ElementAsMultiDateField("publication"); Publisher = element.ElementAsString("publisher"); LanguageCode = element.ElementAsString("language_code"); @@ -190,7 +191,8 @@ namespace NzbDrone.Core.MetadataSource.Goodreads EditionInformation = element.ElementAsString("edition_information"); RatingsCount = element.ElementAsInt("ratings_count"); TextReviewsCount = element.ElementAsInt("text_reviews_count"); - Url = element.ElementAsString("url"); + Url = element.ElementAsString("link"); + EditionsUrl = element.ElementAsString("editions_url"); ReviewsWidget = element.ElementAsString("reviews_widget"); var workElement = element.Element("work"); diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookSearchResultResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookSearchResultResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookSearchResultResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookSearchResultResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookSummaryResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookSummaryResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/BookSummaryResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/BookSummaryResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/GoodreadsResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/GoodreadsResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/GoodreadsResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/GoodreadsResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/OwnedBookResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/OwnedBookResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/OwnedBookResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/OwnedBookResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/PaginatedList.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/PaginatedList.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/PaginatedList.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/PaginatedList.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/PaginationModel.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/PaginationModel.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/PaginationModel.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/PaginationModel.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/ReviewResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/ReviewResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/ReviewResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/ReviewResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/SearchJsonResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/SearchJsonResource.cs new file mode 100644 index 000000000..c0ee1c8b2 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/SearchJsonResource.cs @@ -0,0 +1,85 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.MetadataSource.Goodreads +{ + public class SearchJsonResource + { + [JsonProperty("imageUrl")] + public string ImageUrl { get; set; } + + [JsonProperty("bookId")] + public int BookId { get; set; } + + [JsonProperty("workId")] + public int WorkId { get; set; } + + [JsonProperty("bookUrl")] + public string BookUrl { get; set; } + + [JsonProperty("from_search")] + public bool FromSearch { get; set; } + + [JsonProperty("from_srp")] + public bool FromSrp { get; set; } + + [JsonProperty("qid")] + public string Qid { get; set; } + + [JsonProperty("rank")] + public int Rank { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("bookTitleBare")] + public string BookTitleBare { get; set; } + + [JsonProperty("numPages")] + public int PageCount { get; set; } + + [JsonProperty("avgRating")] + public decimal AverageRating { get; set; } + + [JsonProperty("ratingsCount")] + public int RatingsCount { get; set; } + + [JsonProperty("author")] + public AuthorJsonResource Author { get; set; } + + [JsonProperty("kcrPreviewUrl")] + public string KcrPreviewUrl { get; set; } + + [JsonProperty("description")] + public DescriptionJsonResource Description { get; set; } + } + + public class AuthorJsonResource + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("isGoodreadsAuthor")] + public bool IsGoodreadsAuthor { get; set; } + + [JsonProperty("profileUrl")] + public string ProfileUrl { get; set; } + + [JsonProperty("worksListUrl")] + public string WorksListUrl { get; set; } + } + + public class DescriptionJsonResource + { + [JsonProperty("html")] + public string Html { get; set; } + + [JsonProperty("truncated")] + public bool Truncated { get; set; } + + [JsonProperty("fullContentUrl")] + public string FullContentUrl { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/SeriesResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/SeriesResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/SeriesResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/SeriesResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/UserShelfResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/UserShelfResource.cs similarity index 100% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/UserShelfResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/UserShelfResource.cs diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/WorkResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/WorkResource.cs similarity index 99% rename from src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/WorkResource.cs rename to src/NzbDrone.Core/MetadataSource/Goodreads/Resources/WorkResource.cs index 0260d6e62..e43b6447d 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/GoodreadsResource/WorkResource.cs +++ b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/WorkResource.cs @@ -125,7 +125,7 @@ namespace NzbDrone.Core.MetadataSource.Goodreads var originalPublicationDay = element.ElementAsInt("original_publication_day"); if (originalPublicationYear != 0) { - OriginalPublicationDate = new DateTime(originalPublicationYear, Math.Max(originalPublicationMonth, 1), Math.Max(originalPublicationDay, 1)); + OriginalPublicationDate = new DateTime(originalPublicationYear, Math.Max(originalPublicationMonth, 1), Math.Max(originalPublicationDay, 1), 0, 0, 0, DateTimeKind.Utc); } OriginalTitle = element.ElementAsString("original_title"); diff --git a/src/NzbDrone.Core/MetadataSource/IProvideAuthorInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideAuthorInfo.cs index dfab0f1b5..6dc6b2888 100644 --- a/src/NzbDrone.Core/MetadataSource/IProvideAuthorInfo.cs +++ b/src/NzbDrone.Core/MetadataSource/IProvideAuthorInfo.cs @@ -7,6 +7,7 @@ namespace NzbDrone.Core.MetadataSource public interface IProvideAuthorInfo { Author GetAuthorInfo(string readarrId); + Author GetAuthorAndBooks(string readarrId, double minPopularity = 0); HashSet GetChangedArtists(DateTime startTime); } } diff --git a/src/NzbDrone.Core/MetadataSource/ISearchForNewBook.cs b/src/NzbDrone.Core/MetadataSource/ISearchForNewBook.cs index 701d95207..7cf674e21 100644 --- a/src/NzbDrone.Core/MetadataSource/ISearchForNewBook.cs +++ b/src/NzbDrone.Core/MetadataSource/ISearchForNewBook.cs @@ -9,6 +9,5 @@ namespace NzbDrone.Core.MetadataSource List SearchByIsbn(string isbn); List SearchByAsin(string asin); List SearchByGoodreadsId(int goodreadsId); - List SearchForNewAlbumByRecordingIds(List recordingIds); } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 23b8690c2..484b32a24 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net; using NLog; @@ -12,7 +13,7 @@ using NzbDrone.Core.MediaCover; namespace NzbDrone.Core.MetadataSource.SkyHook { - public class SkyHookProxy : IProvideAuthorInfo, ISearchForNewAuthor, IProvideBookInfo, ISearchForNewBook, ISearchForNewEntity + public class SkyHookProxy { private readonly IHttpClient _httpClient; private readonly Logger _logger; @@ -85,6 +86,8 @@ namespace NzbDrone.Core.MetadataSource.SkyHook public Tuple> GetBookInfo(string foreignBookId) { + return null; + /* _logger.Debug("Getting Book with ReadarrAPI.MetadataID of {0}", foreignBookId); var httpRequest = _requestBuilder.GetRequestBuilder().Create() @@ -115,11 +118,12 @@ namespace NzbDrone.Core.MetadataSource.SkyHook var b = httpResponse.Resource; var book = MapBook(b); - var authors = httpResponse.Resource.AuthorMetadata.SelectList(MapAuthor); - var authorid = GetAuthorId(b); - book.AuthorMetadata = authors.First(x => x.ForeignAuthorId == authorid); + // var authors = httpResponse.Resource.AuthorMetadata.SelectList(MapAuthor); + var authorid = GetAuthorId(b).ToString(); - return new Tuple>(authorid, book, authors); + // book.AuthorMetadata = authors.First(x => x.ForeignAuthorId == authorid); + return new Tuple>(authorid, book, null); + */ } public List SearchForNewAuthor(string title) @@ -233,11 +237,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook } } - public List SearchForNewAlbumByRecordingIds(List recordingIds) - { - return null; - } - public List SearchForNewEntity(string title) { var books = SearchForNewBook(title, null); @@ -260,10 +259,10 @@ namespace NzbDrone.Core.MetadataSource.SkyHook private Author MapAuthor(AuthorResource resource) { - var metadata = MapAuthor(resource.AuthorMetadata.First(x => x.ForeignId == resource.ForeignId)); + var metadata = MapAuthor(resource.AuthorMetadata.First(x => x.GoodreadsId == resource.GoodreadsId)); - var books = resource.Books - .Where(x => GetAuthorId(x) == resource.ForeignId) + var books = resource.Works + .Where(x => GetAuthorId(x) == resource.GoodreadsId) .Select(MapBook) .ToList(); @@ -291,50 +290,26 @@ namespace NzbDrone.Core.MetadataSource.SkyHook var seriesDict = series.ToDictionary(x => x.ForeignSeriesId); // only take series where there are some works - foreach (var s in resource.Series.Where(x => x.BookLinks.Any())) + foreach (var s in resource.Series.Where(x => x.Works.Any())) { - if (seriesDict.TryGetValue(s.ForeignId, out var curr)) + if (seriesDict.TryGetValue(s.GoodreadsId.ToString(), out var curr)) { - curr.LinkItems = s.BookLinks.Where(x => bookDict.ContainsKey(x.BookId)).Select(l => new SeriesBookLink + curr.LinkItems = s.Works.Where(x => bookDict.ContainsKey(x.GoodreadsId.ToString())).Select(l => new SeriesBookLink { - Book = bookDict[l.BookId], + Book = bookDict[l.GoodreadsId.ToString()], Series = curr, - IsPrimary = l.Primary + IsPrimary = l.Primary, + Position = l.Position }).ToList(); } } - - foreach (var b in resource.Books) - { - if (bookDict.TryGetValue(b.ForeignId, out var curr)) - { - curr.SeriesLinks = b.SeriesLinks.Where(l => seriesDict.ContainsKey(l.SeriesId)).Select(l => new SeriesBookLink - { - Series = seriesDict[l.SeriesId], - Position = l.Position, - Book = curr - }).ToList(); - } - } - - _ = series.SelectMany(x => x.LinkItems.Value) - .Join(books.SelectMany(x => x.SeriesLinks.Value), - sl => Tuple.Create(sl.Series.Value.ForeignSeriesId, sl.Book.Value.ForeignBookId), - bl => Tuple.Create(bl.Series.Value.ForeignSeriesId, bl.Book.Value.ForeignBookId), - (sl, bl) => - { - sl.Position = bl.Position; - bl.IsPrimary = sl.IsPrimary; - return sl; - }).ToList(); } private static AuthorMetadata MapAuthor(AuthorSummaryResource resource) { var author = new AuthorMetadata { - ForeignAuthorId = resource.ForeignId, - GoodreadsId = resource.GoodreadsId, + ForeignAuthorId = resource.GoodreadsId.ToString(), TitleSlug = resource.TitleSlug, Name = resource.Name.CleanSpaces(), Overview = resource.Description, @@ -350,7 +325,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook }); } - author.Links.Add(new Links { Url = resource.WebUrl, Name = "Goodreads" }); + author.Links.Add(new Links { Url = resource.Url, Name = "Goodreads" }); return author; } @@ -359,7 +334,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { var series = new Series { - ForeignSeriesId = resource.ForeignId, + ForeignSeriesId = resource.GoodreadsId.ToString(), Title = resource.Title, Description = resource.Description }; @@ -367,37 +342,95 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return series; } - private static Book MapBook(BookResource resource) + private static Book MapBook(WorkResource resource) { var book = new Book { - ForeignBookId = resource.ForeignId, - ForeignWorkId = resource.WorkForeignId, - GoodreadsId = resource.GoodreadsId, + ForeignBookId = resource.GoodreadsId.ToString(), + Title = resource.Title, + TitleSlug = resource.TitleSlug, + CleanTitle = Parser.Parser.CleanAuthorName(resource.Title), + ReleaseDate = resource.ReleaseDate, + }; + + book.Links.Add(new Links { Url = resource.Url, Name = "Goodreads Editions" }); + + if (resource.Books != null) + { + book.Editions = resource.Books.Select(x => MapEdition(x)).ToList(); + + // monitor the most rated release + var mostPopular = book.Editions.Value.OrderByDescending(x => x.Ratings.Votes).FirstOrDefault(); + if (mostPopular != null) + { + mostPopular.Monitored = true; + + // fix work title if missing + if (book.Title.IsNullOrWhiteSpace()) + { + book.Title = mostPopular.Title; + } + } + } + else + { + book.Editions = new List(); + } + + Debug.Assert(!book.Editions.Value.Any() || book.Editions.Value.Count(x => x.Monitored) == 1, "one edition monitored"); + + book.AnyEditionOk = true; + + var ratingCount = book.Editions.Value.Sum(x => x.Ratings.Votes); + + if (ratingCount > 0) + { + book.Ratings = new Ratings + { + Votes = ratingCount, + Value = book.Editions.Value.Sum(x => x.Ratings.Votes * x.Ratings.Value) / ratingCount + }; + } + else + { + book.Ratings = new Ratings { Votes = 0, Value = 0 }; + } + + return book; + } + + private static Edition MapEdition(BookResource resource) + { + var edition = new Edition + { + ForeignEditionId = resource.GoodreadsId.ToString(), TitleSlug = resource.TitleSlug, Isbn13 = resource.Isbn13, Asin = resource.Asin, Title = resource.Title.CleanSpaces(), Language = resource.Language, - Publisher = resource.Publisher, - CleanTitle = Parser.Parser.CleanAuthorName(resource.Title), Overview = resource.Description, + Format = resource.Format, + IsEbook = resource.IsEbook, + Disambiguation = resource.EditionInformation, + Publisher = resource.Publisher, + PageCount = resource.NumPages ?? 0, ReleaseDate = resource.ReleaseDate, Ratings = new Ratings { Votes = resource.RatingCount, Value = (decimal)resource.AverageRating } }; if (resource.ImageUrl.IsNotNullOrWhiteSpace()) { - book.Images.Add(new MediaCover.MediaCover + edition.Images.Add(new MediaCover.MediaCover { Url = resource.ImageUrl, CoverType = MediaCoverTypes.Cover }); } - book.Links.Add(new Links { Url = resource.WebUrl, Name = "Goodreads" }); + edition.Links.Add(new Links { Url = resource.Url, Name = "Goodreads Book" }); - return book; + return edition; } private List MapSearchResult(BookSearchResource resource) @@ -406,25 +439,25 @@ namespace NzbDrone.Core.MetadataSource.SkyHook var result = new List(); - foreach (var b in resource.Books) + foreach (var b in resource.Works) { - var book = _bookService.FindById(b.ForeignId); + var book = _bookService.FindById(b.GoodreadsId.ToString()); if (book == null) { book = MapBook(b); var authorid = GetAuthorId(b); - if (authorid == null) + if (authorid == 0) { continue; } - var author = _authorService.FindById(authorid); + var author = _authorService.FindById(authorid.ToString()); if (author == null) { - var authorMetadata = metadata[authorid]; + var authorMetadata = metadata[authorid.ToString()]; author = new Author { @@ -447,9 +480,9 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return result; } - private string GetAuthorId(BookResource b) + private int GetAuthorId(WorkResource b) { - return b.Contributors.FirstOrDefault()?.ForeignId; + return b.Books.First().Contributors.FirstOrDefault()?.GoodreadsId ?? 0; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/AuthorResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/AuthorResource.cs index bdb3602bf..4e487759a 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/AuthorResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/AuthorResource.cs @@ -2,6 +2,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { public class AuthorResource : BulkResource { - public string ForeignId { get; set; } + public int GoodreadsId { get; set; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/AuthorSummaryResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/AuthorSummaryResource.cs index 9fc3f4fe0..c1804169a 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/AuthorSummaryResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/AuthorSummaryResource.cs @@ -2,15 +2,13 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { public class AuthorSummaryResource { - public string ForeignId { get; set; } public int GoodreadsId { get; set; } public string TitleSlug { get; set; } public string Name { get; set; } public string Description { get; set; } public string ImageUrl { get; set; } - public string ProfileUri { get; set; } - public string WebUrl { get; set; } + public string Url { get; set; } public int ReviewCount { get; set; } public int RatingsCount { get; set; } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/BookResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/BookResource.cs index 2b187bc7b..b08535555 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/BookResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/BookResource.cs @@ -5,36 +5,25 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { public class BookResource { - public string ForeignId { get; set; } public int GoodreadsId { get; set; } public string TitleSlug { get; set; } public string Asin { get; set; } public string Description { get; set; } public string Isbn13 { get; set; } - public long Rvn { get; set; } public string Title { get; set; } - public string Publisher { get; set; } public string Language { get; set; } - public string DisplayGroup { get; set; } + public string Format { get; set; } + public string EditionInformation { get; set; } + public string Publisher { get; set; } public string ImageUrl { get; set; } - public string KindleMappingStatus { get; set; } - public string Marketplace { get; set; } + public bool IsEbook { get; set; } public int? NumPages { get; set; } public int ReviewsCount { get; set; } public int RatingCount { get; set; } public double AverageRating { get; set; } - public IList SeriesLinks { get; set; } = new List(); - public string WebUrl { get; set; } - public string WorkForeignId { get; set; } + public string Url { get; set; } public DateTime? ReleaseDate { get; set; } public List Contributors { get; set; } = new List(); - public List AuthorMetadata { get; set; } = new List(); - } - - public class BookSeriesLinkResource - { - public string SeriesId { get; set; } - public string Position { get; set; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/BulkResourceBase.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/BulkResourceBase.cs index bae551d88..595a88e5c 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/BulkResourceBase.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/BulkResourceBase.cs @@ -5,7 +5,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook public class BulkResource { public List AuthorMetadata { get; set; } = new List(); - public List Books { get; set; } + public List Works { get; set; } public List Series { get; set; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/ContributorResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/ContributorResource.cs index c6f632b09..f6fd5e96e 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/ContributorResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/ContributorResource.cs @@ -2,7 +2,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { public class ContributorResource { - public string ForeignId { get; set; } + public int GoodreadsId { get; set; } public string Role { get; set; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/SeriesResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/SeriesResource.cs index 0062de038..24204f6d9 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/SeriesResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/SeriesResource.cs @@ -4,16 +4,17 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { public class SeriesResource { - public string ForeignId { get; set; } + public int GoodreadsId { get; set; } public string Title { get; set; } public string Description { get; set; } - public List BookLinks { get; set; } + public List Works { get; set; } } - public class SeriesBookLinkResource + public class SeriesWorkLinkResource { - public string BookId { get; set; } + public int GoodreadsId { get; set; } + public string Position { get; set; } public bool Primary { get; set; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/WorkResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/WorkResource.cs new file mode 100644 index 000000000..96702221a --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookResource/WorkResource.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource.SkyHook +{ + public class WorkResource + { + public int GoodreadsId { get; set; } + public string Title { get; set; } + public string TitleSlug { get; set; } + public string Url { get; set; } + public DateTime? ReleaseDate { get; set; } + public List Books { get; set; } = new List(); + } +} diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 5d35a620b..7c708fbba 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -43,7 +43,6 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Readarr_Author_Id", author.Id.ToString()); environmentVariables.Add("Readarr_Author_Name", author.Metadata.Value.Name); environmentVariables.Add("Readarr_Author_MBId", author.Metadata.Value.ForeignAuthorId); - environmentVariables.Add("Readarr_Author_Type", author.Metadata.Value.Type); environmentVariables.Add("Readarr_Release_BookCount", remoteBook.Books.Count.ToString()); environmentVariables.Add("Readarr_Release_BookReleaseDates", string.Join(",", remoteBook.Books.Select(e => e.ReleaseDate))); environmentVariables.Add("Readarr_Release_BookTitles", string.Join("|", remoteBook.Books.Select(e => e.Title))); @@ -70,7 +69,6 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Readarr_Author_Name", author.Metadata.Value.Name); environmentVariables.Add("Readarr_Author_Path", author.Path); environmentVariables.Add("Readarr_Author_MBId", author.Metadata.Value.ForeignAuthorId); - environmentVariables.Add("Readarr_Author_Type", author.Metadata.Value.Type); environmentVariables.Add("Readarr_Book_Id", book.Id.ToString()); environmentVariables.Add("Readarr_Book_Title", book.Title); environmentVariables.Add("Readarr_Book_MBId", book.ForeignBookId); @@ -100,7 +98,6 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Readarr_Author_Name", author.Metadata.Value.Name); environmentVariables.Add("Readarr_Author_Path", author.Path); environmentVariables.Add("Readarr_Author_MBId", author.Metadata.Value.ForeignAuthorId); - environmentVariables.Add("Readarr_Author_Type", author.Metadata.Value.Type); ExecuteScript(environmentVariables); } @@ -117,7 +114,6 @@ namespace NzbDrone.Core.Notifications.CustomScript environmentVariables.Add("Readarr_Author_Name", author.Metadata.Value.Name); environmentVariables.Add("Readarr_Author_Path", author.Path); environmentVariables.Add("Readarr_Author_MBId", author.Metadata.Value.ForeignAuthorId); - environmentVariables.Add("Readarr_Author_Type", author.Metadata.Value.Type); environmentVariables.Add("Readarr_Book_Id", book.Id.ToString()); environmentVariables.Add("Readarr_Book_Title", book.Title); environmentVariables.Add("Readarr_Book_MBId", book.ForeignBookId); diff --git a/src/NzbDrone.Core/Notifications/NotificationService.cs b/src/NzbDrone.Core/Notifications/NotificationService.cs index 06326c357..1d2068c23 100644 --- a/src/NzbDrone.Core/Notifications/NotificationService.cs +++ b/src/NzbDrone.Core/Notifications/NotificationService.cs @@ -256,7 +256,7 @@ namespace NzbDrone.Core.Notifications { Message = GetTrackRetagMessage(message.Author, message.BookFile, message.Diff), Author = message.Author, - Book = message.BookFile.Book, + Book = message.BookFile.Edition.Value.Book.Value, BookFile = message.BookFile, Diff = message.Diff, Scrubbed = message.Scrubbed diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index f85a50623..a6fd1f143 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -17,9 +17,9 @@ namespace NzbDrone.Core.Organizer { public interface IBuildFileNames { - string BuildBookFileName(Author author, Book book, BookFile bookFile, NamingConfig namingConfig = null, List preferredWords = null); - string BuildBookFilePath(Author author, Book book, string fileName, string extension); - string BuildBookPath(Author author, Book book); + string BuildBookFileName(Author author, Edition edition, BookFile bookFile, NamingConfig namingConfig = null, List preferredWords = null); + string BuildBookFilePath(Author author, Edition edition, string fileName, string extension); + string BuildBookPath(Author author); BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); string GetAuthorFolder(Author author, NamingConfig namingConfig = null); } @@ -30,7 +30,6 @@ namespace NzbDrone.Core.Organizer private readonly IQualityDefinitionService _qualityDefinitionService; private readonly IPreferredWordService _preferredWordService; private readonly ICached _trackFormatCache; - private readonly ICached _absoluteTrackFormatCache; private readonly Logger _logger; private static readonly Regex TitleRegex = new Regex(@"\{(?[- ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[a-z0-9]+))?(?[- ._)\]]*)\}", @@ -76,11 +75,10 @@ namespace NzbDrone.Core.Organizer _qualityDefinitionService = qualityDefinitionService; _preferredWordService = preferredWordService; _trackFormatCache = cacheManager.GetCache(GetType(), "bookFormat"); - _absoluteTrackFormatCache = cacheManager.GetCache(GetType(), "absoluteBookFormat"); _logger = logger; } - public string BuildBookFileName(Author author, Book book, BookFile bookFile, NamingConfig namingConfig = null, List preferredWords = null) + public string BuildBookFileName(Author author, Edition edition, BookFile bookFile, NamingConfig namingConfig = null, List preferredWords = null) { if (namingConfig == null) { @@ -105,7 +103,7 @@ namespace NzbDrone.Core.Organizer var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); AddAuthorTokens(tokenHandlers, author); - AddBookTokens(tokenHandlers, book); + AddBookTokens(tokenHandlers, edition); AddBookFileTokens(tokenHandlers, bookFile); AddQualityTokens(tokenHandlers, author, bookFile); AddMediaInfoTokens(tokenHandlers, bookFile); @@ -118,20 +116,18 @@ namespace NzbDrone.Core.Organizer return fileName; } - public string BuildBookFilePath(Author author, Book book, string fileName, string extension) + public string BuildBookFilePath(Author author, Edition edition, string fileName, string extension) { Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); - var path = BuildBookPath(author, book); + var path = BuildBookPath(author); return Path.Combine(path, fileName + extension); } - public string BuildBookPath(Author author, Book book) + public string BuildBookPath(Author author) { - var path = author.Path; - - return path; + return author.Path; } public BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec) @@ -239,20 +235,20 @@ namespace NzbDrone.Core.Organizer } } - private void AddBookTokens(Dictionary> tokenHandlers, Book book) + private void AddBookTokens(Dictionary> tokenHandlers, Edition edition) { - tokenHandlers["{Book Title}"] = m => book.Title; - tokenHandlers["{Book CleanTitle}"] = m => CleanTitle(book.Title); - tokenHandlers["{Book TitleThe}"] = m => TitleThe(book.Title); + tokenHandlers["{Book Title}"] = m => edition.Title; + tokenHandlers["{Book CleanTitle}"] = m => CleanTitle(edition.Title); + tokenHandlers["{Book TitleThe}"] = m => TitleThe(edition.Title); - if (book.Disambiguation != null) + if (edition.Disambiguation != null) { - tokenHandlers["{Book Disambiguation}"] = m => book.Disambiguation; + tokenHandlers["{Book Disambiguation}"] = m => edition.Disambiguation; } - if (book.ReleaseDate.HasValue) + if (edition.ReleaseDate.HasValue) { - tokenHandlers["{Release Year}"] = m => book.ReleaseDate.Value.Year.ToString(); + tokenHandlers["{Release Year}"] = m => edition.ReleaseDate.Value.Year.ToString(); } else { diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index e82d095a7..b670986e5 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Core.Organizer private static Author _standardAuthor; private static Book _standardBook; + private static Edition _standardEdition; private static BookFile _singleTrackFile; private static List _preferredWords; @@ -39,7 +40,14 @@ namespace NzbDrone.Core.Organizer { Title = "The Book Title", ReleaseDate = System.DateTime.Today, - Disambiguation = "First Book" + Author = _standardAuthor, + AuthorMetadata = _standardAuthor.Metadata.Value + }; + + _standardEdition = new Edition + { + Title = "The Edition Title", + Book = _standardBook }; var mediaInfo = new MediaInfoModel() @@ -57,7 +65,8 @@ namespace NzbDrone.Core.Organizer Path = "/music/Author.Name.Book.Name.TrackNum.Track.Title.MP3256.mp3", SceneName = "Author.Name.Book.Name.TrackNum.Track.Title.MP3256", ReleaseGroup = "RlsGrp", - MediaInfo = mediaInfo + MediaInfo = mediaInfo, + Edition = _standardEdition }; _preferredWords = new List @@ -70,7 +79,7 @@ namespace NzbDrone.Core.Organizer { var result = new SampleResult { - FileName = BuildTrackSample(_standardAuthor, _standardBook, _singleTrackFile, nameSpec), + FileName = BuildTrackSample(_standardAuthor, _singleTrackFile, nameSpec), Author = _standardAuthor, Book = _standardBook, BookFile = _singleTrackFile @@ -83,7 +92,7 @@ namespace NzbDrone.Core.Organizer { var result = new SampleResult { - FileName = BuildTrackSample(_standardAuthor, _standardBook, _singleTrackFile, nameSpec), + FileName = BuildTrackSample(_standardAuthor, _singleTrackFile, nameSpec), Author = _standardAuthor, Book = _standardBook, BookFile = _singleTrackFile @@ -97,11 +106,11 @@ namespace NzbDrone.Core.Organizer return _buildFileNames.GetAuthorFolder(_standardAuthor, nameSpec); } - private string BuildTrackSample(Author author, Book book, BookFile bookFile, NamingConfig nameSpec) + private string BuildTrackSample(Author author, BookFile bookFile, NamingConfig nameSpec) { try { - return _buildFileNames.BuildBookFileName(author, book, bookFile, nameSpec, _preferredWords); + return _buildFileNames.BuildBookFileName(author, bookFile.Edition.Value, bookFile, nameSpec, _preferredWords); } catch (NamingFormatException) { diff --git a/src/NzbDrone.Core/Parser/Model/LocalBook.cs b/src/NzbDrone.Core/Parser/Model/LocalBook.cs index e3760727b..64e641898 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalBook.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalBook.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Parser.Model public List AcoustIdResults { get; set; } public Author Author { get; set; } public Book Book { get; set; } + public Edition Edition { get; set; } public Distance Distance { get; set; } public QualityModel Quality { get; set; } public bool ExistingFile { get; set; } diff --git a/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs b/src/NzbDrone.Core/Parser/Model/LocalEdition.cs similarity index 78% rename from src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs rename to src/NzbDrone.Core/Parser/Model/LocalEdition.cs index aa59b93db..c8f6eb199 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalEdition.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -8,9 +7,9 @@ using NzbDrone.Core.MediaFiles.BookImport.Identification; namespace NzbDrone.Core.Parser.Model { - public class LocalAlbumRelease + public class LocalEdition { - public LocalAlbumRelease() + public LocalEdition() { LocalBooks = new List(); @@ -19,7 +18,7 @@ namespace NzbDrone.Core.Parser.Model Distance.Add("album_id", 1.0); } - public LocalAlbumRelease(List tracks) + public LocalEdition(List tracks) { LocalBooks = tracks; @@ -32,19 +31,20 @@ namespace NzbDrone.Core.Parser.Model public int TrackCount => LocalBooks.Count; public Distance Distance { get; set; } - public Book Book { get; set; } + public Edition Edition { get; set; } public List ExistingTracks { get; set; } public bool NewDownload { get; set; } public void PopulateMatch() { - if (Book != null) + if (Edition != null) { LocalBooks = LocalBooks.Concat(ExistingTracks).DistinctBy(x => x.Path).ToList(); foreach (var localTrack in LocalBooks) { - localTrack.Book = Book; - localTrack.Author = Book.Author.Value; + localTrack.Edition = Edition; + localTrack.Book = Edition.Book.Value; + localTrack.Author = Edition.Book.Value.Author.Value; } } } diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 741a52a8f..3c63f94cd 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -266,10 +266,10 @@ namespace NzbDrone.Core.Parser var tracksInAlbum = _mediaFileService.GetFilesByAuthor(author.Id) .FindAll(s => Path.GetDirectoryName(s.Path) == filename) - .DistinctBy(s => s.BookId) + .DistinctBy(s => s.EditionId) .ToList(); - return tracksInAlbum.Count == 1 ? _bookService.GetBook(tracksInAlbum.First().BookId) : null; + return tracksInAlbum.Count == 1 ? _bookService.GetBook(tracksInAlbum.First().EditionId) : null; } } } diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs index d34f6dbc7..de7689d67 100644 --- a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs @@ -5,8 +5,7 @@ namespace NzbDrone.Core.Profiles.Metadata public class MetadataProfile : ModelBase { public string Name { get; set; } - public double MinRating { get; set; } - public int MinRatingCount { get; set; } + public double MinPopularity { get; set; } public bool SkipMissingDate { get; set; } public bool SkipMissingIsbn { get; set; } public bool SkipPartsAndSets { get; set; } diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs index e37ca809f..52a0180a3 100644 --- a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Books; using NzbDrone.Core.ImportLists; using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.RootFolders; @@ -31,18 +32,24 @@ namespace NzbDrone.Core.Profiles.Metadata private readonly IMetadataProfileRepository _profileRepository; private readonly IAuthorService _authorService; + private readonly IBookService _bookService; + private readonly IMediaFileService _mediaFileService; private readonly IImportListFactory _importListFactory; private readonly IRootFolderService _rootFolderService; private readonly Logger _logger; public MetadataProfileService(IMetadataProfileRepository profileRepository, IAuthorService authorService, + IBookService bookService, + IMediaFileService mediaFileService, IImportListFactory importListFactory, IRootFolderService rootFolderService, Logger logger) { _profileRepository = profileRepository; _authorService = authorService; + _bookService = bookService; + _mediaFileService = mediaFileService; _importListFactory = importListFactory; _rootFolderService = rootFolderService; _logger = logger; @@ -99,36 +106,64 @@ namespace NzbDrone.Core.Profiles.Metadata .GroupBy(x => x.Book.Value) .ToDictionary(x => x.Key, y => y.ToList()); - return FilterBooks(input.Books.Value, seriesLinks, profileId); + var dbAuthor = _authorService.FindById(input.ForeignAuthorId); + var localBooks = dbAuthor?.Books.Value ?? new List(); + var localFiles = _mediaFileService.GetFilesByAuthor(dbAuthor?.Id ?? 0); + + return FilterBooks(input.Books.Value, localBooks, localFiles, seriesLinks, profileId); } - private List FilterBooks(IEnumerable books, Dictionary> seriesLinks, int metadataProfileId) + private List FilterBooks(IEnumerable remoteBooks, List localBooks, List localFiles, Dictionary> seriesLinks, int metadataProfileId) { var profile = Get(metadataProfileId); + + _logger.Trace($"Filtering:\n{remoteBooks.Select(x => x.ToString()).Join("\n")}"); + + var hash = new HashSet(remoteBooks); + var titles = new HashSet(remoteBooks.Select(x => x.Title)); + + var localHash = new HashSet(localBooks.Where(x => x.AddOptions.AddType == BookAddType.Manual).Select(x => x.ForeignBookId)); + localHash.UnionWith(localFiles.Select(x => x.Edition.Value.Book.Value.ForeignBookId)); + + FilterByPredicate(hash, x => x.ForeignBookId, localHash, profile, (x, p) => (x.Ratings.Popularity >= p.MinPopularity) || x.ReleaseDate > DateTime.UtcNow, "rating criteria not met"); + FilterByPredicate(hash, x => x.ForeignBookId, localHash, profile, (x, p) => !p.SkipMissingDate || x.ReleaseDate.HasValue, "release date is missing"); + FilterByPredicate(hash, x => x.ForeignBookId, localHash, profile, (x, p) => !p.SkipPartsAndSets || !IsPartOrSet(x, seriesLinks.GetValueOrDefault(x), titles), "book is part of set"); + FilterByPredicate(hash, x => x.ForeignBookId, localHash, profile, (x, p) => !p.SkipSeriesSecondary || !seriesLinks.ContainsKey(x) || seriesLinks[x].Any(y => y.IsPrimary), "book is a secondary series item"); + + foreach (var book in hash) + { + var localEditions = localBooks.SingleOrDefault(x => x.ForeignBookId == book.ForeignBookId)?.Editions.Value ?? new List(); + + book.Editions = FilterEditions(book.Editions.Value, localEditions, localFiles, profile); + } + + FilterByPredicate(hash, x => x.ForeignBookId, localHash, profile, (x, p) => x.Editions.Value.Any(), "all editions filterd out"); + + return hash.ToList(); + } + + private List FilterEditions(IEnumerable editions, List localEditions, List localFiles, MetadataProfile profile) + { var allowedLanguages = profile.AllowedLanguages.IsNotNullOrWhiteSpace() ? new HashSet(profile.AllowedLanguages.Split(',').Select(x => x.Trim().ToLower())) : new HashSet(); - _logger.Trace($"Filtering:\n{books.Select(x => x.ToString()).Join("\n")}"); + var hash = new HashSet(editions); - var hash = new HashSet(books); - var titles = new HashSet(books.Select(x => x.Title)); + var localHash = new HashSet(localEditions.Where(x => x.ManualAdd).Select(x => x.ForeignEditionId)); + localHash.UnionWith(localFiles.Select(x => x.Edition.Value.ForeignEditionId)); - FilterByPredicate(hash, profile, (x, p) => x.Ratings.Votes >= p.MinRatingCount && (double)x.Ratings.Value >= p.MinRating, "rating criteria not met"); - FilterByPredicate(hash, profile, (x, p) => !p.SkipMissingDate || x.ReleaseDate.HasValue, "release date is missing"); - FilterByPredicate(hash, profile, (x, p) => !p.SkipMissingIsbn || x.Isbn13.IsNotNullOrWhiteSpace() || x.Asin.IsNotNullOrWhiteSpace(), "isbn and asin is missing"); - FilterByPredicate(hash, profile, (x, p) => !p.SkipPartsAndSets || !IsPartOrSet(x, seriesLinks.GetValueOrDefault(x), titles), "book is part of set"); - FilterByPredicate(hash, profile, (x, p) => !p.SkipSeriesSecondary || !seriesLinks.ContainsKey(x) || seriesLinks[x].Any(y => y.IsPrimary), "book is a secondary series item"); - FilterByPredicate(hash, profile, (x, p) => !allowedLanguages.Any() || allowedLanguages.Contains(x.Language?.ToLower() ?? "null"), "book language not allowed"); + FilterByPredicate(hash, x => x.ForeignEditionId, localHash, profile, (x, p) => !allowedLanguages.Any() || allowedLanguages.Contains(x.Language?.ToLower() ?? "null"), "edition language not allowed"); + FilterByPredicate(hash, x => x.ForeignEditionId, localHash, profile, (x, p) => !p.SkipMissingIsbn || x.Isbn13.IsNotNullOrWhiteSpace() || x.Asin.IsNotNullOrWhiteSpace(), "isbn and asin is missing"); return hash.ToList(); } - private void FilterByPredicate(HashSet books, MetadataProfile profile, Func bookAllowed, string message) + private void FilterByPredicate(HashSet remoteItems, Func getId, HashSet localItems, MetadataProfile profile, Func bookAllowed, string message) { - var filtered = new HashSet(books.Where(x => !bookAllowed(x, profile))); + var filtered = new HashSet(remoteItems.Where(x => !bookAllowed(x, profile) && !localItems.Contains(getId(x)))); if (filtered.Any()) { - _logger.Trace($"Skipping {filtered.Count} books because {message}:\n{filtered.ConcatToString(x => x.ToString(), "\n")}"); - books.RemoveWhere(x => filtered.Contains(x)); + _logger.Trace($"Skipping {filtered.Count} {typeof(T).Name} because {message}:\n{filtered.ConcatToString(x => x.ToString(), "\n")}"); + remoteItems.RemoveWhere(x => filtered.Contains(x)); } } @@ -170,7 +205,7 @@ namespace NzbDrone.Core.Profiles.Metadata // make sure empty profile exists and is actually empty // TODO: reinstate if (emptyProfile != null && - emptyProfile.MinRating == 100) + emptyProfile.MinPopularity == 1e10) { return; } @@ -182,8 +217,7 @@ namespace NzbDrone.Core.Profiles.Metadata Add(new MetadataProfile { Name = "Standard", - MinRating = 0, - MinRatingCount = 100, + MinPopularity = 350, SkipMissingDate = true, SkipPartsAndSets = true, AllowedLanguages = "eng, en-US, en-GB" @@ -213,7 +247,7 @@ namespace NzbDrone.Core.Profiles.Metadata Add(new MetadataProfile { Name = NONE_PROFILE_NAME, - MinRating = 100 + MinPopularity = 1e10 }); } } diff --git a/src/NzbDrone.Integration.Test/ApiTests/AuthorFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/AuthorFixture.cs index 3a682d56f..09352419b 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/AuthorFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/AuthorFixture.cs @@ -13,10 +13,10 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(0)] public void add_artist_with_tags_should_store_them() { - EnsureNoArtist("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "J.K. Rowling"); + EnsureNoArtist("14586394", "Andrew Hunter Murray"); var tag = EnsureTag("abc"); - var author = Author.Lookup("readarr:1").Single(); + var author = Author.Lookup("readarr:43765115").Single(); author.QualityProfileId = 1; author.MetadataProfileId = 1; @@ -36,9 +36,9 @@ namespace NzbDrone.Integration.Test.ApiTests { IgnoreOnMonoVersions("5.12", "5.14"); - EnsureNoArtist("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "J.K. Rowling"); + EnsureNoArtist("14586394", "Andrew Hunter Murray"); - var artist = Author.Lookup("readarr:1").Single(); + var artist = Author.Lookup("readarr:43765115").Single(); artist.Path = Path.Combine(AuthorRootFolder, artist.AuthorName); @@ -51,9 +51,9 @@ namespace NzbDrone.Integration.Test.ApiTests { IgnoreOnMonoVersions("5.12", "5.14"); - EnsureNoArtist("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "J.K. Rowling"); + EnsureNoArtist("14586394", "Andrew Hunter Murray"); - var artist = Author.Lookup("readarr:1").Single(); + var artist = Author.Lookup("readarr:43765115").Single(); artist.QualityProfileId = 1; @@ -64,9 +64,9 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(1)] public void add_artist() { - EnsureNoArtist("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "J.K. Rowling"); + EnsureNoArtist("14586394", "Andrew Hunter Murray"); - var artist = Author.Lookup("readarr:1").Single(); + var artist = Author.Lookup("readarr:43765115").Single(); artist.QualityProfileId = 1; artist.MetadataProfileId = 1; @@ -85,25 +85,25 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(2)] public void get_all_artist() { - EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling"); - EnsureAuthor("amzn1.gr.author.v1.qTrNu9-PIaaBj5gYRDmN4Q", "34497", "Terry Pratchett"); + EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray"); + EnsureAuthor("383606", "16160797", "Robert Galbraith"); var artists = Author.All(); artists.Should().NotBeNullOrEmpty(); - artists.Should().Contain(v => v.ForeignAuthorId == "amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ"); - artists.Should().Contain(v => v.ForeignAuthorId == "amzn1.gr.author.v1.qTrNu9-PIaaBj5gYRDmN4Q"); + artists.Should().Contain(v => v.ForeignAuthorId == "14586394"); + artists.Should().Contain(v => v.ForeignAuthorId == "383606"); } [Test] [Order(2)] public void get_artist_by_id() { - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling"); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray"); var result = Author.Get(artist.Id); - result.ForeignAuthorId.Should().Be("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ"); + result.ForeignAuthorId.Should().Be("14586394"); } [Test] @@ -118,7 +118,7 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(2)] public void update_artist_profile_id() { - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling"); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray"); var profileId = 1; if (artist.QualityProfileId == profileId) @@ -137,29 +137,22 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(3)] public void update_artist_monitored() { - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", false); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", false); artist.Monitored.Should().BeFalse(); - //artist.Seasons.First().Monitored.Should().BeFalse(); artist.Monitored = true; - //artist.Seasons.ForEach(season => - //{ - // season.Monitored = true; - //}); var result = Author.Put(artist); result.Monitored.Should().BeTrue(); - - //result.Seasons.First().Monitored.Should().BeTrue(); } [Test] [Order(3)] public void update_artist_tags() { - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling"); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray"); var tag = EnsureTag("abc"); if (artist.Tags.Contains(tag.Id)) @@ -182,13 +175,13 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(4)] public void delete_artist() { - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling"); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray"); Author.Get(artist.Id).Should().NotBeNull(); Author.Delete(artist.Id); - Author.All().Should().NotContain(v => v.ForeignAuthorId == "amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ"); + Author.All().Should().NotContain(v => v.ForeignAuthorId == "14586394"); } } } diff --git a/src/NzbDrone.Integration.Test/ApiTests/BlacklistFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/BlacklistFixture.cs index c1980fe05..bdb5da2fe 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/BlacklistFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/BlacklistFixture.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Integration.Test.ApiTests [Ignore("Adding to blacklist not supported")] public void should_be_able_to_add_to_blacklist() { - _artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling"); + _artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray"); Blacklist.Post(new BlacklistResource { diff --git a/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs index 8e344ee32..31b7278a8 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs @@ -23,27 +23,27 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void should_be_able_to_get_albums() { - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", true); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", true); var request = Calendar.BuildRequest(); - request.AddParameter("start", new DateTime(2003, 06, 20).ToString("s") + "Z"); - request.AddParameter("end", new DateTime(2003, 06, 22).ToString("s") + "Z"); + request.AddParameter("start", new DateTime(2020, 02, 01).ToString("s") + "Z"); + request.AddParameter("end", new DateTime(2020, 02, 28).ToString("s") + "Z"); var items = Calendar.Get>(request); items = items.Where(v => v.AuthorId == artist.Id).ToList(); items.Should().HaveCount(1); - items.First().Title.Should().Be("Harry Potter and the Order of the Phoenix"); + items.First().Title.Should().Be("The Last Day"); } [Test] public void should_not_be_able_to_get_unmonitored_albums() { - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", false); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", false); var request = Calendar.BuildRequest(); - request.AddParameter("start", new DateTime(2003, 06, 20).ToString("s") + "Z"); - request.AddParameter("end", new DateTime(2003, 06, 22).ToString("s") + "Z"); + request.AddParameter("start", new DateTime(2020, 02, 01).ToString("s") + "Z"); + request.AddParameter("end", new DateTime(2020, 02, 28).ToString("s") + "Z"); request.AddParameter("unmonitored", "false"); var items = Calendar.Get>(request); @@ -55,18 +55,18 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void should_be_able_to_get_unmonitored_albums() { - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", false); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", false); var request = Calendar.BuildRequest(); - request.AddParameter("start", new DateTime(2003, 06, 20).ToString("s") + "Z"); - request.AddParameter("end", new DateTime(2003, 06, 22).ToString("s") + "Z"); + request.AddParameter("start", new DateTime(2020, 02, 01).ToString("s") + "Z"); + request.AddParameter("end", new DateTime(2020, 02, 28).ToString("s") + "Z"); request.AddParameter("unmonitored", "true"); var items = Calendar.Get>(request); items = items.Where(v => v.AuthorId == artist.Id).ToList(); items.Should().HaveCount(1); - items.First().Title.Should().Be("Harry Potter and the Order of the Phoenix"); + items.First().Title.Should().Be("The Last Day"); } } } diff --git a/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs index 3be5c9014..e3fa8e37c 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs @@ -28,7 +28,7 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(0)] public void missing_should_be_empty() { - EnsureNoArtist("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "J.K. Rowling"); + EnsureNoArtist("14586394", "Andrew Hunter Murray"); var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc"); @@ -39,7 +39,7 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(1)] public void missing_should_have_monitored_items() { - EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", true); + EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", true); var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc"); @@ -50,21 +50,21 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(1)] public void missing_should_have_artist() { - EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", true); + EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", true); var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc"); result.Records.First().Author.Should().NotBeNull(); - result.Records.First().Author.AuthorName.Should().Be("J.K. Rowling"); + result.Records.First().Author.AuthorName.Should().Be("Andrew Hunter Murray"); } [Test] - [Order(1)] + [Order(2)] public void cutoff_should_have_monitored_items() { EnsureProfileCutoff(1, Quality.AZW3); - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", true); - EnsureBookFile(artist, 1, Quality.MOBI); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", true); + EnsureBookFile(artist, 1, 1, Quality.MOBI); var result = WantedCutoffUnmet.GetPaged(0, 15, "releaseDate", "desc"); @@ -75,7 +75,7 @@ namespace NzbDrone.Integration.Test.ApiTests [Order(1)] public void missing_should_not_have_unmonitored_items() { - EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", false); + EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", false); var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc"); @@ -83,12 +83,12 @@ namespace NzbDrone.Integration.Test.ApiTests } [Test] - [Order(1)] + [Order(2)] public void cutoff_should_not_have_unmonitored_items() { EnsureProfileCutoff(1, Quality.AZW3); - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", false); - EnsureBookFile(artist, 1, Quality.MOBI); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", false); + EnsureBookFile(artist, 1, 1, Quality.MOBI); var result = WantedCutoffUnmet.GetPaged(0, 15, "releaseDate", "desc"); @@ -96,24 +96,24 @@ namespace NzbDrone.Integration.Test.ApiTests } [Test] - [Order(1)] + [Order(2)] public void cutoff_should_have_artist() { EnsureProfileCutoff(1, Quality.AZW3); - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", true); - EnsureBookFile(artist, 1, Quality.MOBI); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", true); + EnsureBookFile(artist, 1, 1, Quality.MOBI); var result = WantedCutoffUnmet.GetPaged(0, 15, "releaseDate", "desc"); result.Records.First().Author.Should().NotBeNull(); - result.Records.First().Author.AuthorName.Should().Be("J.K. Rowling"); + result.Records.First().Author.AuthorName.Should().Be("Andrew Hunter Murray"); } [Test] - [Order(2)] + [Order(1)] public void missing_should_have_unmonitored_items() { - EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", false); + EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", false); var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc", "monitored", "false"); @@ -125,8 +125,8 @@ namespace NzbDrone.Integration.Test.ApiTests public void cutoff_should_have_unmonitored_items() { EnsureProfileCutoff(1, Quality.AZW3); - var artist = EnsureAuthor("amzn1.gr.author.v1.SHA8asP5mFyLIP9NlujvLQ", "1", "J.K. Rowling", false); - EnsureBookFile(artist, 1, Quality.MOBI); + var artist = EnsureAuthor("14586394", "43765115", "Andrew Hunter Murray", false); + EnsureBookFile(artist, 1, 1, Quality.MOBI); var result = WantedCutoffUnmet.GetPaged(0, 15, "releaseDate", "desc", "monitored", "false"); diff --git a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs index 6fe2932b8..27bf931dc 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs @@ -305,9 +305,9 @@ namespace NzbDrone.Integration.Test } } - public void EnsureBookFile(AuthorResource artist, int bookId, Quality quality) + public void EnsureBookFile(AuthorResource artist, int bookId, int editionId, Quality quality) { - var result = Books.GetBooksInAuthor(artist.Id).Single(v => v.Id == bookId); + var result = Books.GetBooksInAuthor(artist.Id).Single(v => v.Id == editionId); // if (result.BookFile == null) if (true) @@ -326,13 +326,14 @@ namespace NzbDrone.Integration.Test Path = path, AuthorId = artist.Id, BookId = bookId, + EditionId = editionId, Quality = new QualityModel(quality) } } }); Commands.WaitAll(); - var track = Books.GetBooksInAuthor(artist.Id).Single(x => x.Id == bookId); + var track = Books.GetBooksInAuthor(artist.Id).Single(x => x.Id == editionId); // track.BookFileId.Should().NotBe(0); } diff --git a/src/Readarr.Api.V1/Author/AuthorResource.cs b/src/Readarr.Api.V1/Author/AuthorResource.cs index b554716bf..6a807c54f 100644 --- a/src/Readarr.Api.V1/Author/AuthorResource.cs +++ b/src/Readarr.Api.V1/Author/AuthorResource.cs @@ -22,10 +22,8 @@ namespace Readarr.Api.V1.Author public string AuthorName { get; set; } public string ForeignAuthorId { get; set; } - public int GoodreadsId { get; set; } public string TitleSlug { get; set; } public string Overview { get; set; } - public string AuthorType { get; set; } public string Disambiguation { get; set; } public List Links { get; set; } @@ -77,7 +75,6 @@ namespace Readarr.Api.V1.Author Status = model.Metadata.Value.Status, Overview = model.Metadata.Value.Overview, - AuthorType = model.Metadata.Value.Type, Disambiguation = model.Metadata.Value.Disambiguation, Images = model.Metadata.Value.Images.JsonClone(), @@ -91,7 +88,6 @@ namespace Readarr.Api.V1.Author CleanName = model.CleanName, ForeignAuthorId = model.Metadata.Value.ForeignAuthorId, - GoodreadsId = model.Metadata.Value.GoodreadsId, TitleSlug = model.Metadata.Value.TitleSlug, // Root folder path is now calculated from the artist path @@ -120,7 +116,6 @@ namespace Readarr.Api.V1.Author Metadata = new NzbDrone.Core.Books.AuthorMetadata { ForeignAuthorId = resource.ForeignAuthorId, - GoodreadsId = resource.GoodreadsId, TitleSlug = resource.TitleSlug, Name = resource.AuthorName, Status = resource.Status, @@ -129,7 +124,6 @@ namespace Readarr.Api.V1.Author Images = resource.Images, Genres = resource.Genres, Ratings = resource.Ratings, - Type = resource.AuthorType }, //AlternateTitles @@ -145,7 +139,7 @@ namespace Readarr.Api.V1.Author Tags = resource.Tags, Added = resource.Added, - AddOptions = resource.AddOptions, + AddOptions = resource.AddOptions }; } diff --git a/src/Readarr.Api.V1/BookFiles/BookFileModule.cs b/src/Readarr.Api.V1/BookFiles/BookFileModule.cs index 4da739ec3..6b77c1f64 100644 --- a/src/Readarr.Api.V1/BookFiles/BookFileModule.cs +++ b/src/Readarr.Api.V1/BookFiles/BookFileModule.cs @@ -54,7 +54,7 @@ namespace Readarr.Api.V1.BookFiles private BookFileResource MapToResource(BookFile bookFile) { - if (bookFile.BookId > 0 && bookFile.Author != null && bookFile.Author.Value != null) + if (bookFile.EditionId > 0 && bookFile.Author != null && bookFile.Author.Value != null) { return bookFile.ToResource(bookFile.Author.Value, _upgradableSpecification); } @@ -164,7 +164,7 @@ namespace Readarr.Api.V1.BookFiles throw new NzbDroneClientException(HttpStatusCode.NotFound, "Book file not found"); } - if (bookFile.BookId > 0 && bookFile.Author != null && bookFile.Author.Value != null) + if (bookFile.EditionId > 0 && bookFile.Author != null && bookFile.Author.Value != null) { _mediaFileDeletionService.DeleteTrackFile(bookFile.Author.Value, bookFile); } diff --git a/src/Readarr.Api.V1/BookFiles/BookFileResource.cs b/src/Readarr.Api.V1/BookFiles/BookFileResource.cs index 968b4d5c2..742b7f90b 100644 --- a/src/Readarr.Api.V1/BookFiles/BookFileResource.cs +++ b/src/Readarr.Api.V1/BookFiles/BookFileResource.cs @@ -49,7 +49,7 @@ namespace Readarr.Api.V1.BookFiles return new BookFileResource { Id = model.Id, - BookId = model.BookId, + BookId = model.EditionId, Path = model.Path, Size = model.Size, DateAdded = model.DateAdded, @@ -71,7 +71,7 @@ namespace Readarr.Api.V1.BookFiles Id = model.Id, AuthorId = author.Id, - BookId = model.BookId, + BookId = model.EditionId, Path = model.Path, Size = model.Size, DateAdded = model.DateAdded, diff --git a/src/Readarr.Api.V1/Books/BookLookupModule.cs b/src/Readarr.Api.V1/Books/BookLookupModule.cs index f099aa9a2..d62fa403e 100644 --- a/src/Readarr.Api.V1/Books/BookLookupModule.cs +++ b/src/Readarr.Api.V1/Books/BookLookupModule.cs @@ -29,7 +29,7 @@ namespace Readarr.Api.V1.Books foreach (var currentBook in books) { var resource = currentBook.ToResource(); - var cover = currentBook.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover); + var cover = currentBook.Editions.Value.Single(x => x.Monitored).Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover); if (cover != null) { resource.RemoteCover = cover.Url; diff --git a/src/Readarr.Api.V1/Books/BookModule.cs b/src/Readarr.Api.V1/Books/BookModule.cs index bf7cb69e7..b29f6dce4 100644 --- a/src/Readarr.Api.V1/Books/BookModule.cs +++ b/src/Readarr.Api.V1/Books/BookModule.cs @@ -30,21 +30,24 @@ namespace Readarr.Api.V1.Books IHandle { protected readonly IAuthorService _authorService; + protected readonly IEditionService _editionService; protected readonly IAddBookService _addBookService; public BookModule(IAuthorService authorService, - IBookService bookService, - IAddBookService addBookService, - IAuthorStatisticsService authorStatisticsService, - IMapCoversToLocal coverMapper, - IUpgradableSpecification upgradableSpecification, - IBroadcastSignalRMessage signalRBroadcaster, - QualityProfileExistsValidator qualityProfileExistsValidator, - MetadataProfileExistsValidator metadataProfileExistsValidator) + IBookService bookService, + IAddBookService addBookService, + IEditionService editionService, + IAuthorStatisticsService authorStatisticsService, + IMapCoversToLocal coverMapper, + IUpgradableSpecification upgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster, + QualityProfileExistsValidator qualityProfileExistsValidator, + MetadataProfileExistsValidator metadataProfileExistsValidator) : base(bookService, authorStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster) { _authorService = authorService; + _editionService = editionService; _addBookService = addBookService; GetResourceAll = GetBooks; @@ -72,10 +75,19 @@ namespace Readarr.Api.V1.Books var books = _bookService.GetAllBooks(); var authors = _authorService.GetAllAuthors().ToDictionary(x => x.AuthorMetadataId); + var editions = _editionService.GetAllEditions().GroupBy(x => x.BookId).ToDictionary(x => x.Key, y => y.ToList()); foreach (var book in books) { book.Author = authors[book.AuthorMetadataId]; + if (editions.TryGetValue(book.Id, out var bookEditions)) + { + book.Editions = bookEditions; + } + else + { + book.Editions = new List(); + } } return MapToResource(books, false); @@ -84,8 +96,27 @@ namespace Readarr.Api.V1.Books if (authorIdQuery.HasValue) { int authorId = Convert.ToInt32(authorIdQuery.Value); + var books = _bookService.GetBooksByAuthor(authorId); - return MapToResource(_bookService.GetBooksByAuthor(authorId), false); + var author = _authorService.GetAuthor(authorId); + var editions = _editionService.GetEditionsByAuthor(authorId) + .GroupBy(x => x.BookId) + .ToDictionary(x => x.Key, y => y.ToList()); + + foreach (var book in books) + { + book.Author = author; + if (editions.TryGetValue(book.Id, out var bookEditions)) + { + book.Editions = bookEditions; + } + else + { + book.Editions = new List(); + } + } + + return MapToResource(books, false); } if (slugQuery.HasValue) @@ -132,6 +163,7 @@ namespace Readarr.Api.V1.Books var model = bookResource.ToModel(book); _bookService.UpdateBook(model); + _editionService.UpdateMany(model.Editions.Value); BroadcastResourceChange(ModelAction.Updated, model.Id); } @@ -196,7 +228,7 @@ namespace Readarr.Api.V1.Books return; } - BroadcastResourceChange(ModelAction.Updated, MapToResource(message.BookFile.Book.Value, true)); + BroadcastResourceChange(ModelAction.Updated, MapToResource(message.BookFile.Edition.Value.Book.Value, true)); } } } diff --git a/src/Readarr.Api.V1/Books/BookResource.cs b/src/Readarr.Api.V1/Books/BookResource.cs index 1cf0dc4c8..55f18fbf3 100644 --- a/src/Readarr.Api.V1/Books/BookResource.cs +++ b/src/Readarr.Api.V1/Books/BookResource.cs @@ -15,17 +15,14 @@ namespace Readarr.Api.V1.Books public string Title { get; set; } public string Disambiguation { get; set; } public string Overview { get; set; } - public string Publisher { get; set; } - public string Language { get; set; } public int AuthorId { get; set; } public string ForeignBookId { get; set; } - public int GoodreadsId { get; set; } public string TitleSlug { get; set; } - public string Isbn { get; set; } - public string Asin { get; set; } public bool Monitored { get; set; } + public bool AnyEditionOk { get; set; } public Ratings Ratings { get; set; } public DateTime? ReleaseDate { get; set; } + public int PageCount { get; set; } public List Genres { get; set; } public AuthorResource Author { get; set; } public List Images { get; set; } @@ -33,6 +30,7 @@ namespace Readarr.Api.V1.Books public BookStatisticsResource Statistics { get; set; } public AddBookOptions AddOptions { get; set; } public string RemoteCover { get; set; } + public List Editions { get; set; } //Hiding this so people don't think its usable (only used to set the initial state) [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] @@ -48,27 +46,27 @@ namespace Readarr.Api.V1.Books return null; } + var selectedEdition = model.Editions?.Value.Where(x => x.Monitored).SingleOrDefault(); + return new BookResource { Id = model.Id, AuthorId = model.AuthorId, ForeignBookId = model.ForeignBookId, - GoodreadsId = model.GoodreadsId, TitleSlug = model.TitleSlug, - Asin = model.Asin, - Isbn = model.Isbn13, Monitored = model.Monitored, + AnyEditionOk = model.AnyEditionOk, ReleaseDate = model.ReleaseDate, + PageCount = selectedEdition?.PageCount ?? 0, Genres = model.Genres, - Title = model.Title, - Disambiguation = model.Disambiguation, - Overview = model.Overview, - Publisher = model.Publisher, - Language = model.Language, - Images = model.Images, - Links = model.Links, - Ratings = model.Ratings, - Author = model.Author?.Value.ToResource() + Title = selectedEdition?.Title ?? model.Title, + Disambiguation = selectedEdition?.Disambiguation, + Overview = selectedEdition?.Overview, + Images = selectedEdition?.Images ?? new List(), + Links = model.Links.Concat(selectedEdition?.Links ?? new List()).ToList(), + Ratings = selectedEdition?.Ratings ?? new Ratings(), + Author = model.Author?.Value.ToResource(), + Editions = model.Editions?.Value.ToResource() ?? new List() }; } @@ -85,17 +83,11 @@ namespace Readarr.Api.V1.Books { Id = resource.Id, ForeignBookId = resource.ForeignBookId, - GoodreadsId = resource.GoodreadsId, TitleSlug = resource.TitleSlug, - Asin = resource.Asin, - Isbn13 = resource.Isbn, Title = resource.Title, - Disambiguation = resource.Disambiguation, - Overview = resource.Overview, - Publisher = resource.Publisher, - Language = resource.Language, - Images = resource.Images, Monitored = resource.Monitored, + AnyEditionOk = resource.AnyEditionOk, + Editions = resource.Editions.ToModel(), AddOptions = resource.AddOptions, Author = author, AuthorMetadata = author.Metadata.Value @@ -107,6 +99,7 @@ namespace Readarr.Api.V1.Books var updatedBook = resource.ToModel(); book.ApplyChanges(updatedBook); + book.Editions = updatedBook.Editions; return book; } diff --git a/src/Readarr.Api.V1/Books/EditionResource.cs b/src/Readarr.Api.V1/Books/EditionResource.cs new file mode 100644 index 000000000..8e76b4130 --- /dev/null +++ b/src/Readarr.Api.V1/Books/EditionResource.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NzbDrone.Core.Books; +using NzbDrone.Core.MediaCover; +using Readarr.Http.REST; + +namespace Readarr.Api.V1.Books +{ + public class EditionResource : RestResource + { + 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 Images { get; set; } + public List Links { get; set; } + public Ratings Ratings { get; set; } + public bool Monitored { get; set; } + public bool ManualAdd { get; set; } + public string RemoteCover { get; set; } + + //Hiding this so people don't think its usable (only used to set the initial state) + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public bool Grabbed { get; set; } + } + + public static class EditionResourceMapper + { + public static EditionResource ToResource(this Edition model) + { + if (model == null) + { + return null; + } + + return new EditionResource + { + Id = model.Id, + BookId = model.BookId, + ForeignEditionId = model.ForeignEditionId, + TitleSlug = model.TitleSlug, + Isbn13 = model.Isbn13, + Asin = model.Asin, + Title = model.Title, + Language = model.Language, + Overview = model.Overview, + Format = model.Format, + IsEbook = model.IsEbook, + Disambiguation = model.Disambiguation, + Publisher = model.Publisher, + PageCount = model.PageCount, + ReleaseDate = model.ReleaseDate, + Images = model.Images, + Links = model.Links, + Ratings = model.Ratings, + Monitored = model.Monitored, + ManualAdd = model.ManualAdd + }; + } + + public static Edition ToModel(this EditionResource resource) + { + if (resource == null) + { + return null; + } + + return new Edition + { + Id = resource.Id, + BookId = resource.BookId, + ForeignEditionId = resource.ForeignEditionId, + TitleSlug = resource.TitleSlug, + Isbn13 = resource.Isbn13, + Asin = resource.Asin, + Title = resource.Title, + Language = resource.Language, + Overview = resource.Overview, + Format = resource.Format, + IsEbook = resource.IsEbook, + Disambiguation = resource.Disambiguation, + Publisher = resource.Publisher, + PageCount = resource.PageCount, + ReleaseDate = resource.ReleaseDate, + Images = resource.Images, + Links = resource.Links, + Ratings = resource.Ratings, + Monitored = resource.Monitored, + ManualAdd = resource.ManualAdd + }; + } + + public static List ToResource(this IEnumerable models) + { + return models?.Select(ToResource).ToList(); + } + + public static List ToModel(this IEnumerable resources) + { + return resources.Select(ToModel).ToList(); + } + } +} diff --git a/src/Readarr.Api.V1/Calendar/CalendarFeedModule.cs b/src/Readarr.Api.V1/Calendar/CalendarFeedModule.cs index e670e46e4..3e96fce0c 100644 --- a/src/Readarr.Api.V1/Calendar/CalendarFeedModule.cs +++ b/src/Readarr.Api.V1/Calendar/CalendarFeedModule.cs @@ -84,7 +84,7 @@ namespace Readarr.Api.V1.Calendar occurrence.Uid = "Readarr_book_" + book.Id; //occurrence.Status = album.HasFile ? EventStatus.Confirmed : EventStatus.Tentative; - occurrence.Description = book.Overview; + occurrence.Description = book.Editions.Value.Single(x => x.Monitored).Overview; occurrence.Categories = book.Genres; occurrence.Start = new CalDateTime(book.ReleaseDate.Value.ToLocalTime()) { HasTime = false }; diff --git a/src/Readarr.Api.V1/ManualImport/ManualImportModule.cs b/src/Readarr.Api.V1/ManualImport/ManualImportModule.cs index 6f08e47c3..c9aa4fc0a 100644 --- a/src/Readarr.Api.V1/ManualImport/ManualImportModule.cs +++ b/src/Readarr.Api.V1/ManualImport/ManualImportModule.cs @@ -16,16 +16,19 @@ namespace Readarr.Api.V1.ManualImport { private readonly IAuthorService _authorService; private readonly IBookService _bookService; + private readonly IEditionService _editionService; private readonly IManualImportService _manualImportService; private readonly Logger _logger; public ManualImportModule(IManualImportService manualImportService, IAuthorService authorService, + IEditionService editionService, IBookService bookService, Logger logger) { _authorService = authorService; _bookService = bookService; + _editionService = editionService; _manualImportService = manualImportService; _logger = logger; @@ -86,10 +89,12 @@ namespace Readarr.Api.V1.ManualImport Size = resource.Size, Author = resource.Author == null ? null : _authorService.GetAuthor(resource.Author.Id), Book = resource.Book == null ? null : _bookService.GetBook(resource.Book.Id), + Edition = resource.EditionId == 0 ? null : _editionService.GetEdition(resource.EditionId), Quality = resource.Quality, DownloadId = resource.DownloadId, AdditionalFile = resource.AdditionalFile, - ReplaceExistingFiles = resource.ReplaceExistingFiles + ReplaceExistingFiles = resource.ReplaceExistingFiles, + DisableReleaseSwitching = resource.DisableReleaseSwitching }); } diff --git a/src/Readarr.Api.V1/ManualImport/ManualImportResource.cs b/src/Readarr.Api.V1/ManualImport/ManualImportResource.cs index 91ecc35b2..ccaea6eb0 100644 --- a/src/Readarr.Api.V1/ManualImport/ManualImportResource.cs +++ b/src/Readarr.Api.V1/ManualImport/ManualImportResource.cs @@ -17,6 +17,7 @@ namespace Readarr.Api.V1.ManualImport public long Size { get; set; } public AuthorResource Author { get; set; } public BookResource Book { get; set; } + public int EditionId { get; set; } public QualityModel Quality { get; set; } public int QualityWeight { get; set; } public string DownloadId { get; set; } @@ -24,6 +25,7 @@ namespace Readarr.Api.V1.ManualImport public ParsedTrackInfo AudioTags { get; set; } public bool AdditionalFile { get; set; } public bool ReplaceExistingFiles { get; set; } + public bool DisableReleaseSwitching { get; set; } } public static class ManualImportResourceMapper @@ -43,6 +45,7 @@ namespace Readarr.Api.V1.ManualImport Size = model.Size, Author = model.Author.ToResource(), Book = model.Book.ToResource(), + EditionId = model.Edition?.Id ?? 0, Quality = model.Quality, //QualityWeight @@ -50,7 +53,8 @@ namespace Readarr.Api.V1.ManualImport Rejections = model.Rejections, AudioTags = model.Tags, AdditionalFile = model.AdditionalFile, - ReplaceExistingFiles = model.ReplaceExistingFiles + ReplaceExistingFiles = model.ReplaceExistingFiles, + DisableReleaseSwitching = model.DisableReleaseSwitching }; } diff --git a/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs b/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs index f1c9d5b4e..8a3c84249 100644 --- a/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs +++ b/src/Readarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs @@ -8,8 +8,7 @@ namespace Readarr.Api.V1.Profiles.Metadata public class MetadataProfileResource : RestResource { public string Name { get; set; } - public double MinRating { get; set; } - public int MinRatingCount { get; set; } + public double MinPopularity { get; set; } public bool SkipMissingDate { get; set; } public bool SkipMissingIsbn { get; set; } public bool SkipPartsAndSets { get; set; } @@ -30,8 +29,7 @@ namespace Readarr.Api.V1.Profiles.Metadata { Id = model.Id, Name = model.Name, - MinRating = model.MinRating, - MinRatingCount = model.MinRatingCount, + MinPopularity = model.MinPopularity, SkipMissingDate = model.SkipMissingDate, SkipMissingIsbn = model.SkipMissingIsbn, SkipPartsAndSets = model.SkipPartsAndSets, @@ -51,8 +49,7 @@ namespace Readarr.Api.V1.Profiles.Metadata { Id = resource.Id, Name = resource.Name, - MinRating = resource.MinRating, - MinRatingCount = resource.MinRatingCount, + MinPopularity = resource.MinPopularity, SkipMissingDate = resource.SkipMissingDate, SkipMissingIsbn = resource.SkipMissingIsbn, SkipPartsAndSets = resource.SkipPartsAndSets, diff --git a/src/Readarr.Api.V1/Search/SearchModule.cs b/src/Readarr.Api.V1/Search/SearchModule.cs index e9b8c4ca4..603875d15 100644 --- a/src/Readarr.Api.V1/Search/SearchModule.cs +++ b/src/Readarr.Api.V1/Search/SearchModule.cs @@ -53,7 +53,7 @@ namespace Readarr.Api.V1.Search resource.Book = book.ToResource(); resource.ForeignId = book.ForeignBookId; - var cover = book.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover); + var cover = book.Editions.Value.Single(x => x.Monitored).Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover); if (cover != null) { resource.Book.RemoteCover = cover.Url; diff --git a/src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs b/src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs index 9ffd84bea..466b4746c 100644 --- a/src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs +++ b/src/Readarr.Http/ErrorManagement/ReadarrErrorPipeline.cs @@ -23,6 +23,7 @@ namespace Readarr.Http.ErrorManagement public Response HandleException(NancyContext context, Exception exception) { _logger.Trace("Handling Exception"); + _logger.Warn(exception); if (exception is ApiException apiException) { @@ -57,6 +58,7 @@ namespace Readarr.Http.ErrorManagement if (exception is ModelConflictException conflictException) { + _logger.Error(exception, "DB error"); return new ErrorModel { Message = exception.Message, diff --git a/src/Readarr.Http/ReadarrRestModule.cs b/src/Readarr.Http/ReadarrRestModule.cs index 069ab1190..331521443 100644 --- a/src/Readarr.Http/ReadarrRestModule.cs +++ b/src/Readarr.Http/ReadarrRestModule.cs @@ -35,7 +35,8 @@ namespace Readarr.Http : base(BaseUrl() + resource.Trim('/').ToLower()) { Resource = resource; - PostValidator.RuleFor(r => r.Id).IsZero(); + + // PostValidator.RuleFor(r => r.Id).IsZero(); PutValidator.RuleFor(r => r.Id).ValidId(); }