New: Allow retagging book files with calibre

pull/965/head
ta264 3 years ago
parent e29b0c318e
commit 7072b913a6

@ -258,12 +258,12 @@ class AuthorDetails extends Component {
onPress={this.onOrganizePress}
/>
{/* <PageToolbarButton */}
{/* label="Preview Retag" */}
{/* iconName={icons.RETAG} */}
{/* isDisabled={!hasBookFiles} */}
{/* onPress={this.onRetagPress} */}
{/* /> */}
<PageToolbarButton
label="Preview Retag"
iconName={icons.RETAG}
isDisabled={!hasBookFiles}
onPress={this.onRetagPress}
/>
<PageToolbarButton
label="Manual Import"

@ -6,3 +6,25 @@
margin-top: 20px;
margin-bottom: 10px;
}
.searchForNewBookLabelContainer {
display: flex;
margin-top: 2px;
}
.searchForNewBookLabel {
margin-right: 8px;
font-weight: normal;
}
.searchForNewBookContainer {
composes: container from '~Components/Form/CheckInput.css';
flex: 0 1 0;
}
.searchForNewBookInput {
composes: input from '~Components/Form/CheckInput.css';
margin-top: 0;
}

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import CheckInput from 'Components/Form/CheckInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
@ -10,58 +11,114 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, kinds } from 'Helpers/Props';
import styles from './RetagAuthorModalContent.css';
function RetagAuthorModalContent(props) {
const {
authorNames,
onModalClose,
onRetagAuthorPress
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Retag Selected Author
</ModalHeader>
<ModalBody>
<Alert>
Tip: To preview the tags that will be written... select "Cancel" then click any author name and use the
<Icon
className={styles.retagIcon}
name={icons.RETAG}
/>
</Alert>
<div className={styles.message}>
Are you sure you want to re-tag all files in the {authorNames.length} selected author?
</div>
<ul>
{
authorNames.map((authorName) => {
return (
<li key={authorName}>
{authorName}
</li>
);
})
}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={onRetagAuthorPress}
>
Retag
</Button>
</ModalFooter>
</ModalContent>
);
class RetagAuthorModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
updateCovers: false,
embedMetadata: false
};
}
//
// Listeners
onCheckInputChange = ({ name, value }) => {
this.setState({ [name]: value });
}
onRetagAuthorPress = () => {
this.props.onRetagAuthorPress(this.state.updateCovers, this.state.embedMetadata);
}
//
// Render
render() {
const {
authorNames,
onModalClose
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Retag Selected Author
</ModalHeader>
<ModalBody>
<Alert>
Tip: To preview the tags that will be written... select "Cancel" then click any author name and use the
<Icon
className={styles.retagIcon}
name={icons.RETAG}
/>
</Alert>
<div className={styles.message}>
Are you sure you want to re-tag all files in the {authorNames.length} selected author?
</div>
<ul>
{
authorNames.map((authorName) => {
return (
<li key={authorName}>
{authorName}
</li>
);
})
}
</ul>
</ModalBody>
<ModalFooter>
<label className={styles.searchForNewBookLabelContainer}>
<span className={styles.searchForNewBookLabel}>
Update Covers
</span>
<CheckInput
containerClassName={styles.searchForNewBookContainer}
className={styles.searchForNewBookInput}
name="updateCovers"
value={this.state.updateCovers}
onChange={this.onCheckInputChange}
/>
</label>
<label className={styles.searchForNewBookLabelContainer}>
<span className={styles.searchForNewBookLabel}>
Embed Metadata
</span>
<CheckInput
containerClassName={styles.searchForNewBookContainer}
className={styles.searchForNewBookInput}
name="embedMetadata"
value={this.state.embedMetadata}
onChange={this.onCheckInputChange}
/>
</label>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRetagAuthorPress}
>
Retag
</Button>
</ModalFooter>
</ModalContent>
);
}
}
RetagAuthorModalContent.propTypes = {

@ -36,10 +36,12 @@ class RetagAuthorModalContentConnector extends Component {
//
// Listeners
onRetagAuthorPress = () => {
onRetagAuthorPress = (updateCovers, embedMetadata) => {
this.props.executeCommand({
name: commandNames.RETAG_AUTHOR,
authorIds: this.props.authorIds
authorIds: this.props.authorIds,
updateCovers,
embedMetadata
});
this.props.onModalClose(true);

@ -7,7 +7,6 @@ import TextTruncate from 'react-text-truncate';
import AuthorHistoryTable from 'Author/History/AuthorHistoryTable';
import BookCover from 'Book/BookCover';
import DeleteBookModal from 'Book/Delete/DeleteBookModal';
// import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import EditBookModalConnector from 'Book/Edit/EditBookModalConnector';
import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable';
import HeartRating from 'Components/HeartRating';
@ -27,6 +26,7 @@ import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import InteractiveSearchFilterMenuConnector from 'InteractiveSearch/InteractiveSearchFilterMenuConnector';
import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import fonts from 'Styles/Variables/fonts';
import formatBytes from 'Utilities/Number/formatBytes';
import stripHtml from 'Utilities/String/stripHtml';
@ -138,7 +138,7 @@ class BookDetails extends Component {
const {
isOrganizeModalOpen,
// isRetagModalOpen,
isRetagModalOpen,
isEditBookModalOpen,
isDeleteBookModalOpen,
selectedTabIndex
@ -445,12 +445,12 @@ class BookDetails extends Component {
onModalClose={this.onOrganizeModalClose}
/>
{/* <RetagPreviewModalConnector */}
{/* isOpen={isRetagModalOpen} */}
{/* authorId={author.id} */}
{/* bookId={id} */}
{/* onModalClose={this.onRetagModalClose} */}
{/* /> */}
<RetagPreviewModalConnector
isOpen={isRetagModalOpen}
authorId={author.id}
bookId={id}
onModalClose={this.onRetagModalClose}
/>
<EditBookModalConnector
isOpen={isEditBookModalOpen}

@ -22,3 +22,25 @@
margin: 0;
}
.searchForNewBookLabelContainer {
display: flex;
margin-top: 2px;
}
.searchForNewBookLabel {
margin-right: 8px;
font-weight: normal;
}
.searchForNewBookContainer {
composes: container from '~Components/Form/CheckInput.css';
flex: 0 1 0;
}
.searchForNewBookInput {
composes: input from '~Components/Form/CheckInput.css';
margin-top: 0;
}

@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import CheckInput from 'Components/Form/CheckInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -37,7 +36,9 @@ class RetagPreviewModalContent extends Component {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {}
selectedState: {},
updateCovers: false,
embedMetadata: false
};
}
@ -61,8 +62,12 @@ class RetagPreviewModalContent extends Component {
});
}
onCheckInputChange = ({ name, value }) => {
this.setState({ [name]: value });
}
onRetagPress = () => {
this.props.onRetagPress(this.getSelectedIds());
this.props.onRetagPress(this.getSelectedIds(), this.state.updateCovers, this.state.embedMetadata);
}
//
@ -110,12 +115,6 @@ class RetagPreviewModalContent extends Component {
{
!isFetching && isPopulated && !!items.length &&
<div>
<Alert>
<div>
MusicBrainz identifiers will also be added to the files; these are not shown below.
</div>
</Alert>
<div className={styles.previews}>
{
items.map((item) => {
@ -148,6 +147,34 @@ class RetagPreviewModalContent extends Component {
/>
}
<label className={styles.searchForNewBookLabelContainer}>
<span className={styles.searchForNewBookLabel}>
Update Covers
</span>
<CheckInput
containerClassName={styles.searchForNewBookContainer}
className={styles.searchForNewBookInput}
name="updateCovers"
value={this.state.updateCovers}
onChange={this.onCheckInputChange}
/>
</label>
<label className={styles.searchForNewBookLabelContainer}>
<span className={styles.searchForNewBookLabel}>
Embed Metadata
</span>
<CheckInput
containerClassName={styles.searchForNewBookContainer}
className={styles.searchForNewBookInput}
name="embedMetadata"
value={this.state.embedMetadata}
onChange={this.onCheckInputChange}
/>
</label>
<Button
onPress={onModalClose}
>

@ -49,10 +49,12 @@ class RetagPreviewModalContentConnector extends Component {
//
// Listeners
onRetagPress = (files) => {
onRetagPress = (files, updateCovers, embedMetadata) => {
this.props.executeCommand({
name: commandNames.RETAG_FILES,
authorId: this.props.authorId,
updateCovers,
embedMetadata,
files
});

@ -1,25 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
namespace NzbDrone.Core.Books.Calibre
{
public class CalibreBook
{
[JsonProperty("format_metadata")]
public Dictionary<string, CalibreBookFormat> Formats { get; set; }
[JsonProperty("application_id")]
public int Id { get; set; }
public string Title { get; set; }
public List<string> Authors { get; set; }
[JsonProperty("author_sort")]
public string AuthorSort { get; set; }
public string Title { get; set; }
[JsonConverter(typeof(CalibreDateConverter))]
public DateTime? PubDate { get; set; }
public string Publisher { get; set; }
public List<string> Languages { get; set; }
public string Comments { get; set; }
public double Rating { get; set; }
public Dictionary<string, string> Identifiers { get; set; }
public string Series { get; set; }
[JsonProperty("series_index")]
public string Position { get; set; }
public double? Position { get; set; }
public Dictionary<string, string> Identifiers { get; set; }
[JsonProperty("format_metadata")]
public Dictionary<string, CalibreBookFormat> Formats { get; set; }
public Dictionary<string, Tuple<string, string>> Diff(CalibreBook other)
{
var output = new Dictionary<string, Tuple<string, string>>();
if (Title != other.Title)
{
output.Add("Title", Tuple.Create(Title, other.Title));
}
if (!Authors.SequenceEqual(other.Authors))
{
var oldValue = Authors.Any() ? string.Join(" / ", Authors) : null;
var newValue = other.Authors.Any() ? string.Join(" / ", other.Authors) : null;
output.Add("Author", Tuple.Create(oldValue, newValue));
}
var oldDate = PubDate.HasValue ? PubDate.Value.ToString("MMM-yyyy") : null;
var newDate = other.PubDate.HasValue ? other.PubDate.Value.ToString("MMM-yyyy") : null;
if (oldDate != newDate)
{
output.Add("PubDate", Tuple.Create(oldDate, newDate));
}
if (Publisher != other.Publisher)
{
output.Add("Publisher", Tuple.Create(Publisher, other.Publisher));
}
if (!Languages.OrderBy(x => x).SequenceEqual(other.Languages.OrderBy(x => x)))
{
output.Add("Languages", Tuple.Create(string.Join(" / ", Languages), string.Join(" / ", other.Languages)));
}
if (Comments != other.Comments)
{
output.Add("Comments", Tuple.Create(Comments, other.Comments));
}
if (Rating != other.Rating)
{
output.Add("Rating", Tuple.Create(Rating.ToString(), other.Rating.ToString()));
}
if (!Identifiers.Where(x => x.Value != null).OrderBy(x => x.Key).SequenceEqual(
other.Identifiers.Where(x => x.Value != null).OrderBy(x => x.Key)))
{
output.Add("Identifiers", Tuple.Create(
string.Join(" / ", Identifiers.Where(x => x.Value != null).OrderBy(x => x.Key)),
string.Join(" / ", other.Identifiers.Where(x => x.Value != null).OrderBy(x => x.Key))));
}
if (Series != other.Series)
{
output.Add("Series", Tuple.Create(Series, other.Series));
}
if (Position != other.Position)
{
output.Add("Series Index", Tuple.Create(Position.ToString(), other.Position.ToString()));
}
return output;
}
}
public class CalibreBookFormat

@ -0,0 +1,24 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace NzbDrone.Core.Books.Calibre
{
public class CalibreDateConverter : IsoDateTimeConverter
{
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.Value == null)
{
return null;
}
if (reader.Value as string == "None")
{
return null;
}
return base.ReadJson(reader, objectType, existingValue, serializer);
}
}
}

@ -25,11 +25,12 @@ namespace NzbDrone.Core.Books.Calibre
void DeleteBook(BookFile book, CalibreSettings settings);
void AddFormat(BookFile file, CalibreSettings settings);
void RemoveFormats(int calibreId, IEnumerable<string> formats, CalibreSettings settings);
void SetFields(BookFile file, CalibreSettings settings);
void SetFields(BookFile file, CalibreSettings settings, bool updateCover = true, bool embed = false);
CalibreBookData GetBookData(int calibreId, CalibreSettings settings);
long ConvertBook(int calibreId, CalibreConversionOptions options, CalibreSettings settings);
List<string> GetAllBookFilePaths(CalibreSettings settings);
CalibreBook GetBook(int calibreId, CalibreSettings settings);
List<CalibreBook> GetBooks(List<int> calibreId, CalibreSettings settings);
void Test(CalibreSettings settings);
}
@ -41,7 +42,6 @@ namespace NzbDrone.Core.Books.Calibre
private readonly IMapCoversToLocal _mediaCoverService;
private readonly IRemotePathMappingService _pathMapper;
private readonly Logger _logger;
private readonly ICached<CalibreBook> _bookCache;
public CalibreProxy(IHttpClient httpClient,
IMapCoversToLocal mediaCoverService,
@ -52,7 +52,6 @@ namespace NzbDrone.Core.Books.Calibre
_httpClient = httpClient;
_mediaCoverService = mediaCoverService;
_pathMapper = pathMapper;
_bookCache = cacheManager.GetCache<CalibreBook>(GetType());
_logger = logger;
}
@ -140,11 +139,11 @@ namespace NzbDrone.Core.Books.Calibre
ExecuteSetFields(calibreId, payload, settings);
}
public void SetFields(BookFile file, CalibreSettings settings)
public void SetFields(BookFile file, CalibreSettings settings, bool updateCover = true, bool embed = false)
{
var edition = file.Edition.Value;
var book = edition.Book.Value;
var serieslink = book.SeriesLinks.Value.FirstOrDefault();
var serieslink = book.SeriesLinks.Value.FirstOrDefault(x => x.Series.Value.Title.IsNotNullOrWhiteSpace());
var series = serieslink?.Series.Value;
double? seriesIndex = null;
@ -176,12 +175,12 @@ namespace NzbDrone.Core.Books.Calibre
{
Title = edition.Title,
Authors = new List<string> { file.Author.Value.Name },
Cover = image,
Cover = updateCover ? image : null,
PubDate = book.ReleaseDate,
Publisher = edition.Publisher,
Languages = edition.Language,
Languages = edition.Language.CanonicalizeLanguage(),
Comments = edition.Overview,
Rating = edition.Ratings.Value * 2,
Rating = (int)(edition.Ratings.Value * 2),
Identifiers = new Dictionary<string, string>
{
{ "isbn", edition.Isbn13 },
@ -194,6 +193,11 @@ namespace NzbDrone.Core.Books.Calibre
};
ExecuteSetFields(file.CalibreId, payload, settings);
if (embed)
{
EmbedMetadata(file.CalibreId, settings);
}
}
private void ExecuteSetFields(int id, CalibreChangesPayload payload, CalibreSettings settings)
@ -208,6 +212,18 @@ namespace NzbDrone.Core.Books.Calibre
_httpClient.Execute(request);
}
private void EmbedMetadata(int id, CalibreSettings settings)
{
var request = GetBuilder($"cdb/cmd/embed_metadata", settings)
.AddQueryParam("library_id", settings.Library)
.Post()
.SetHeader("Content-Type", "application/json")
.Build();
request.SetContent($"[{id}, null]");
_httpClient.Execute(request);
}
public CalibreBookData GetBookData(int calibreId, CalibreSettings settings)
{
try
@ -268,10 +284,37 @@ namespace NzbDrone.Core.Books.Calibre
}
}
public List<string> GetAllBookFilePaths(CalibreSettings settings)
public List<CalibreBook> GetBooks(List<int> calibreIds, CalibreSettings settings)
{
_bookCache.Clear();
var builder = GetBuilder($"ajax/books/{settings.Library}", settings);
builder.LogResponseContent = false;
builder.AddQueryParam("ids", calibreIds.ConcatToString(","));
var request = builder.Build();
try
{
var response = _httpClient.Get<Dictionary<int, CalibreBook>>(request);
var result = response.Resource.Values.ToList();
foreach (var book in result)
{
foreach (var format in book.Formats.Values)
{
format.Path = _pathMapper.RemapRemoteToLocal(settings.Host, new OsPath(format.Path)).FullPath;
}
}
return result;
}
catch (HttpException ex)
{
throw new CalibreException("Unable to connect to Calibre library: {0}", ex, ex.Message);
}
}
public List<string> GetAllBookFilePaths(CalibreSettings settings)
{
var ids = GetAllBookIds(settings);
var result = new List<string>();
@ -297,8 +340,6 @@ namespace NzbDrone.Core.Books.Calibre
var localPath = _pathMapper.RemapRemoteToLocal(settings.Host, new OsPath(remotePath)).FullPath;
result.Add(localPath);
_bookCache.Set(localPath, book, TimeSpan.FromMinutes(5));
}
}
catch (HttpException ex)

@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Core.Books.Calibre
{
public static class Extensions
{
private static readonly Dictionary<string, string> TwoToThree;
private static readonly Dictionary<string, string> ByThree;
private static readonly Dictionary<string, string> NameMap;
static Extensions()
{
var assembly = Assembly.GetExecutingAssembly();
TwoToThree = InitializeDictionary(assembly, "2to3.json");
ByThree = InitializeDictionary(assembly, "by3.json");
NameMap = InitializeDictionary(assembly, "name_map.json");
}
private static Dictionary<string, string> InitializeDictionary(Assembly assembly, string resource)
{
var resources = assembly.GetManifestResourceNames();
var stream = assembly.GetManifestResourceStream(resources.Single(x => x.EndsWith(resource)));
string data;
using (var reader = new StreamReader(stream))
{
data = reader.ReadToEnd();
}
return JsonSerializer.Deserialize<Dictionary<string, string>>(data);
}
// Translated from https://github.com/kovidgoyal/calibre/blob/ba06b7452228cfde9114e4735fb8d5785fba4955/src/calibre/utils/localization.py#L430
public static string CanonicalizeLanguage(this string raw)
{
if (raw.IsNullOrWhiteSpace())
{
return null;
}
raw = raw.ToLowerInvariant().Trim();
if (raw.IsNullOrWhiteSpace())
{
return null;
}
raw = raw.Replace('_', '-').Split('-', 2)[0].Trim();
if (raw.IsNullOrWhiteSpace())
{
return null;
}
if (raw.Length == 2)
{
if (TwoToThree.TryGetValue(raw, out var lang))
{
return lang;
}
}
else if (raw.Length == 3)
{
if (ByThree.ContainsKey(raw))
{
return raw;
}
}
return NameMap.TryGetValue(raw, out var langByName) ? langByName : null;
}
}
}

@ -0,0 +1,186 @@
{
"aa": "aar",
"ab": "abk",
"af": "afr",
"ak": "aka",
"am": "amh",
"ar": "ara",
"an": "arg",
"as": "asm",
"av": "ava",
"ae": "ave",
"ay": "aym",
"az": "aze",
"ba": "bak",
"bm": "bam",
"be": "bel",
"bn": "ben",
"bi": "bis",
"bo": "bod",
"bs": "bos",
"br": "bre",
"bg": "bul",
"ca": "cat",
"cs": "ces",
"ch": "cha",
"ce": "che",
"cu": "chu",
"cv": "chv",
"kw": "cor",
"co": "cos",
"cr": "cre",
"cy": "cym",
"da": "dan",
"de": "deu",
"dv": "div",
"dz": "dzo",
"el": "ell",
"en": "eng",
"eo": "epo",
"et": "est",
"eu": "eus",
"ee": "ewe",
"fo": "fao",
"fa": "fas",
"fj": "fij",
"fi": "fin",
"fr": "fra",
"fy": "fry",
"ff": "ful",
"gd": "gla",
"ga": "gle",
"gl": "glg",
"gv": "glv",
"gn": "grn",
"gu": "guj",
"ht": "hat",
"ha": "hau",
"sh": "hbs",
"he": "heb",
"hz": "her",
"hi": "hin",
"ho": "hmo",
"hr": "hrv",
"hu": "hun",
"hy": "hye",
"ig": "ibo",
"io": "ido",
"ii": "iii",
"iu": "iku",
"ie": "ile",
"ia": "ina",
"id": "ind",
"ik": "ipk",
"is": "isl",
"it": "ita",
"jv": "jav",
"ja": "jpn",
"kl": "kal",
"kn": "kan",
"ks": "kas",
"ka": "kat",
"kr": "kau",
"kk": "kaz",
"km": "khm",
"ki": "kik",
"rw": "kin",
"ky": "kir",
"kv": "kom",
"kg": "kon",
"ko": "kor",
"kj": "kua",
"ku": "kur",
"lo": "lao",
"la": "lat",
"lv": "lav",
"li": "lim",
"ln": "lin",
"lt": "lit",
"lb": "ltz",
"lu": "lub",
"lg": "lug",
"mh": "mah",
"ml": "mal",
"mr": "mar",
"mk": "mkd",
"mg": "mlg",
"mt": "mlt",
"mn": "mon",
"mi": "mri",
"ms": "msa",
"my": "mya",
"na": "nau",
"nv": "nav",
"nr": "nbl",
"nd": "nde",
"ng": "ndo",
"ne": "nep",
"nl": "nld",
"nn": "nno",
"nb": "nob",
"no": "nor",
"ny": "nya",
"oc": "oci",
"oj": "oji",
"or": "ori",
"om": "orm",
"os": "oss",
"pa": "pan",
"pi": "pli",
"pl": "pol",
"pt": "por",
"ps": "pus",
"qu": "que",
"rm": "roh",
"ro": "ron",
"rn": "run",
"ru": "rus",
"sg": "sag",
"sa": "san",
"si": "sin",
"sk": "slk",
"sl": "slv",
"se": "sme",
"sm": "smo",
"sn": "sna",
"sd": "snd",
"so": "som",
"st": "sot",
"es": "spa",
"sq": "sqi",
"sc": "srd",
"sr": "srp",
"ss": "ssw",
"su": "sun",
"sw": "swa",
"sv": "swe",
"ty": "tah",
"ta": "tam",
"tt": "tat",
"te": "tel",
"tg": "tgk",
"tl": "tgl",
"th": "tha",
"ti": "tir",
"to": "ton",
"tn": "tsn",
"ts": "tso",
"tk": "tuk",
"tr": "tur",
"tw": "twi",
"ug": "uig",
"uk": "ukr",
"ur": "urd",
"uz": "uzb",
"ve": "ven",
"vi": "vie",
"vo": "vol",
"wa": "wln",
"wo": "wol",
"xh": "xho",
"yi": "yid",
"yo": "yor",
"za": "zha",
"zh": "zho",
"zu": "zul"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -23,6 +23,7 @@ namespace NzbDrone.Core.Books.Calibre
public string Comments { get; set; }
public decimal Rating { get; set; }
public Dictionary<string, string> Identifiers { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Include)]
public string Series { get; set; }
[JsonProperty("series_index")]
public double? SeriesIndex { get; set; }

@ -237,7 +237,7 @@ namespace NzbDrone.Core.MediaFiles
foreach (var author in authorToRename)
{
var bookFiles = _mediaFileService.GetFilesByAuthor(author.Id);
_logger.ProgressInfo("Re-tagging all files in author: {0}", author.Name);
_logger.ProgressInfo("Re-tagging all files for author: {0}", author.Name);
foreach (var file in bookFiles)
{
WriteTags(file, false, force: true);

@ -6,6 +6,8 @@ namespace NzbDrone.Core.MediaFiles.Commands
public class RetagAuthorCommand : Command
{
public List<int> AuthorIds { get; set; }
public bool UpdateCovers { get; set; }
public bool EmbedMetadata { get; set; }
public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;

@ -7,6 +7,8 @@ namespace NzbDrone.Core.MediaFiles.Commands
{
public int AuthorId { get; set; }
public List<int> Files { get; set; }
public bool UpdateCovers { get; set; }
public bool EmbedMetadata { get; set; }
public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;

@ -3,11 +3,19 @@ using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Runtime.CompilerServices;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Books;
using NzbDrone.Core.Books.Calibre;
using NzbDrone.Core.MediaFiles.Azw;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.RootFolders;
using PdfSharpCore.Pdf.IO;
using VersOne.Epub;
using VersOne.Epub.Schema;
@ -17,14 +25,31 @@ namespace NzbDrone.Core.MediaFiles
public interface IEBookTagService
{
ParsedTrackInfo ReadTags(IFileInfo file);
List<RetagBookFilePreview> GetRetagPreviewsByAuthor(int authorId);
List<RetagBookFilePreview> GetRetagPreviewsByBook(int authorId);
}
public class EBookTagService : IEBookTagService
public class EBookTagService : IEBookTagService,
IExecute<RetagFilesCommand>,
IExecute<RetagAuthorCommand>
{
private readonly IAuthorService _authorService;
private readonly IMediaFileService _mediaFileService;
private readonly IRootFolderService _rootFolderService;
private readonly ICalibreProxy _calibre;
private readonly Logger _logger;
public EBookTagService(Logger logger)
public EBookTagService(IAuthorService authorService,
IMediaFileService mediaFileService,
IRootFolderService rootFolderService,
ICalibreProxy calibre,
Logger logger)
{
_authorService = authorService;
_mediaFileService = mediaFileService;
_rootFolderService = rootFolderService;
_calibre = calibre;
_logger = logger;
}
@ -47,6 +72,126 @@ namespace NzbDrone.Core.MediaFiles
}
}
public List<RetagBookFilePreview> GetRetagPreviewsByAuthor(int authorId)
{
var files = _mediaFileService.GetFilesByAuthor(authorId);
return GetPreviews(files).ToList();
}
public List<RetagBookFilePreview> GetRetagPreviewsByBook(int bookId)
{
var files = _mediaFileService.GetFilesByBook(bookId);
return GetPreviews(files).ToList();
}
public void Execute(RetagFilesCommand message)
{
var author = _authorService.GetAuthor(message.AuthorId);
var files = _mediaFileService.Get(message.Files);
_logger.ProgressInfo("Re-tagging {0} files for {1}", files.Count, author.Name);
foreach (var file in files.Where(x => x.CalibreId != 0))
{
var rootFolder = _rootFolderService.GetBestRootFolder(file.Path);
_calibre.SetFields(file, rootFolder.CalibreSettings, message.UpdateCovers, message.EmbedMetadata);
}
_logger.ProgressInfo("Selected files re-tagged for {0}", author.Name);
}
public void Execute(RetagAuthorCommand message)
{
_logger.Debug("Re-tagging all files for selected authors");
var authorsToRename = _authorService.GetAuthors(message.AuthorIds);
foreach (var author in authorsToRename)
{
var files = _mediaFileService.GetFilesByAuthor(author.Id);
_logger.ProgressInfo("Re-tagging all files for author: {0}", author.Name);
foreach (var file in files.Where(x => x.CalibreId != 0))
{
var rootFolder = _rootFolderService.GetBestRootFolder(file.Path);
_calibre.SetFields(file, rootFolder.CalibreSettings, message.UpdateCovers, message.EmbedMetadata);
}
_logger.ProgressInfo("All files re-tagged for {0}", author.Name);
}
}
private IEnumerable<RetagBookFilePreview> GetPreviews(List<BookFile> files)
{
var calibreFiles = files.Where(x => x.CalibreId > 0).OrderBy(x => x.Edition.Value.Title).ToList();
var rootFolderPairs = calibreFiles.Select(x => Tuple.Create(x, _rootFolderService.GetBestRootFolder(x.Path)));
var rootFolderGroups = rootFolderPairs.GroupBy(x => x.Item2.Path);
var calibreBooks = new List<CalibreBook>();
foreach (var group in rootFolderGroups)
{
var rootFolder = group.First().Item2;
var books = _calibre.GetBooks(group.Select(x => x.Item1.CalibreId).ToList(), rootFolder.CalibreSettings);
calibreBooks.AddRange(books);
}
var dict = calibreBooks.ToDictionary(x => x.Id);
foreach (var file in calibreFiles)
{
var edition = file.Edition.Value;
var book = edition.Book.Value;
var serieslink = book.SeriesLinks.Value.FirstOrDefault(x => x.Series.Value.Title.IsNotNullOrWhiteSpace());
var series = serieslink?.Series.Value;
double? seriesIndex = null;
if (double.TryParse(serieslink?.Position, out var index))
{
_logger.Trace($"Parsed {serieslink?.Position} as {index}");
seriesIndex = index;
}
var oldTags = dict[file.CalibreId];
var newTags = new CalibreBook
{
Title = edition.Title,
Authors = new List<string> { file.Author.Value.Name },
PubDate = book.ReleaseDate,
Publisher = edition.Publisher,
Languages = new List<string> { edition.Language.CanonicalizeLanguage() },
Comments = edition.Overview,
Rating = (int)(edition.Ratings.Value * 2) / 2.0,
Identifiers = new Dictionary<string, string>
{
{ "isbn", edition.Isbn13 },
{ "asin", edition.Asin },
{ "goodreads", edition.ForeignEditionId }
},
Series = series?.Title,
Position = seriesIndex
};
var diff = oldTags.Diff(newTags);
if (diff.Any())
{
yield return new RetagBookFilePreview
{
AuthorId = file.Author.Value.Id,
BookId = file.Edition.Value.Id,
BookFileId = file.Id,
Path = file.Path,
Changes = diff
};
}
}
}
private ParsedTrackInfo ReadEpub(string file)
{
_logger.Trace($"Reading {file}");

@ -7,7 +7,7 @@ namespace NzbDrone.Core.MediaFiles
{
public int AuthorId { get; set; }
public int BookId { get; set; }
public List<int> TrackNumbers { get; set; }
public List<int> TrackNumbers { get; set; } = new List<int>();
public int BookFileId { get; set; }
public string Path { get; set; }
public Dictionary<string, Tuple<string, string>> Changes { get; set; }

@ -31,5 +31,8 @@
<EmbeddedResource Include="..\..\Logo\64.png">
<Link>Resources\Logo\64.png</Link>
</EmbeddedResource>
<EmbeddedResource Include="Books\Calibre\Languages\2to3.json" />
<EmbeddedResource Include="Books\Calibre\Languages\by3.json" />
<EmbeddedResource Include="Books\Calibre\Languages\name_map.json" />
</ItemGroup>
</Project>

@ -10,11 +10,11 @@ namespace Readarr.Api.V1.Books
[V1ApiController("retag")]
public class RetagBookController : Controller
{
private readonly IAudioTagService _audioTagService;
private readonly IEBookTagService _eBookTagService;
public RetagBookController(IAudioTagService audioTagService)
public RetagBookController(IEBookTagService eBookTagService)
{
_audioTagService = audioTagService;
_eBookTagService = eBookTagService;
}
[HttpGet]
@ -22,11 +22,11 @@ namespace Readarr.Api.V1.Books
{
if (bookId.HasValue)
{
return _audioTagService.GetRetagPreviewsByBook(bookId.Value).Where(x => x.Changes.Any()).ToResource();
return _eBookTagService.GetRetagPreviewsByBook(bookId.Value).Where(x => x.Changes.Any()).ToResource();
}
else if (authorId.HasValue)
{
return _audioTagService.GetRetagPreviewsByAuthor(authorId.Value).Where(x => x.Changes.Any()).ToResource();
return _eBookTagService.GetRetagPreviewsByAuthor(authorId.Value).Where(x => x.Changes.Any()).ToResource();
}
else
{

Loading…
Cancel
Save