BaseNfoParser.cs 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Globalization;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Text;
  7. using System.Threading;
  8. using System.Xml;
  9. using Jellyfin.Extensions;
  10. using MediaBrowser.Common.Configuration;
  11. using MediaBrowser.Common.Providers;
  12. using MediaBrowser.Controller.Entities;
  13. using MediaBrowser.Controller.Entities.Movies;
  14. using MediaBrowser.Controller.Entities.TV;
  15. using MediaBrowser.Controller.Library;
  16. using MediaBrowser.Controller.Providers;
  17. using MediaBrowser.Model.Entities;
  18. using MediaBrowser.XbmcMetadata.Configuration;
  19. using MediaBrowser.XbmcMetadata.Savers;
  20. using Microsoft.Extensions.Logging;
  21. namespace MediaBrowser.XbmcMetadata.Parsers
  22. {
  23. /// <summary>
  24. /// The BaseNfoParser class.
  25. /// </summary>
  26. /// <typeparam name="T">The type.</typeparam>
  27. public class BaseNfoParser<T>
  28. where T : BaseItem
  29. {
  30. private readonly IConfigurationManager _config;
  31. private readonly IUserManager _userManager;
  32. private readonly IUserDataManager _userDataManager;
  33. private readonly IDirectoryService _directoryService;
  34. private Dictionary<string, string> _validProviderIds;
  35. /// <summary>
  36. /// Initializes a new instance of the <see cref="BaseNfoParser{T}" /> class.
  37. /// </summary>
  38. /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
  39. /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
  40. /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
  41. /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
  42. /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
  43. /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
  44. public BaseNfoParser(
  45. ILogger logger,
  46. IConfigurationManager config,
  47. IProviderManager providerManager,
  48. IUserManager userManager,
  49. IUserDataManager userDataManager,
  50. IDirectoryService directoryService)
  51. {
  52. Logger = logger;
  53. _config = config;
  54. ProviderManager = providerManager;
  55. _validProviderIds = new Dictionary<string, string>();
  56. _userManager = userManager;
  57. _userDataManager = userDataManager;
  58. _directoryService = directoryService;
  59. }
  60. /// <summary>
  61. /// Gets the logger.
  62. /// </summary>
  63. protected ILogger Logger { get; }
  64. /// <summary>
  65. /// Gets the provider manager.
  66. /// </summary>
  67. protected IProviderManager ProviderManager { get; }
  68. /// <summary>
  69. /// Gets a value indicating whether URLs after a closing XML tag are supporrted.
  70. /// </summary>
  71. protected virtual bool SupportsUrlAfterClosingXmlTag => false;
  72. /// <summary>
  73. /// Fetches metadata for an item from one xml file.
  74. /// </summary>
  75. /// <param name="item">The <see cref="MetadataResult{T}"/>.</param>
  76. /// <param name="metadataFile">The metadata file.</param>
  77. /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
  78. /// <exception cref="ArgumentNullException"><c>item</c> is <c>null</c>.</exception>
  79. /// <exception cref="ArgumentException"><c>metadataFile</c> is <c>null</c> or empty.</exception>
  80. public void Fetch(MetadataResult<T> item, string metadataFile, CancellationToken cancellationToken)
  81. {
  82. if (item.Item is null)
  83. {
  84. throw new ArgumentException("Item can't be null.", nameof(item));
  85. }
  86. ArgumentException.ThrowIfNullOrEmpty(metadataFile);
  87. _validProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  88. var idInfos = ProviderManager.GetExternalIdInfos(item.Item);
  89. foreach (var info in idInfos)
  90. {
  91. var id = info.Key + "Id";
  92. if (!_validProviderIds.ContainsKey(id))
  93. {
  94. _validProviderIds.Add(id, info.Key);
  95. }
  96. }
  97. // Additional Mappings
  98. _validProviderIds.Add("collectionnumber", "TmdbCollection");
  99. _validProviderIds.Add("tmdbcolid", "TmdbCollection");
  100. _validProviderIds.Add("imdb_id", "Imdb");
  101. Fetch(item, metadataFile, GetXmlReaderSettings(), cancellationToken);
  102. }
  103. /// <summary>
  104. /// Fetches the specified item.
  105. /// </summary>
  106. /// <param name="item">The <see cref="MetadataResult{T}"/>.</param>
  107. /// <param name="metadataFile">The metadata file.</param>
  108. /// <param name="settings">The <see cref="XmlReaderSettings"/>.</param>
  109. /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
  110. protected virtual void Fetch(MetadataResult<T> item, string metadataFile, XmlReaderSettings settings, CancellationToken cancellationToken)
  111. {
  112. if (!SupportsUrlAfterClosingXmlTag)
  113. {
  114. using (var fileStream = File.OpenRead(metadataFile))
  115. using (var streamReader = new StreamReader(fileStream, Encoding.UTF8))
  116. using (var reader = XmlReader.Create(streamReader, settings))
  117. {
  118. item.ResetPeople();
  119. reader.MoveToContent();
  120. reader.Read();
  121. // Loop through each element
  122. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  123. {
  124. cancellationToken.ThrowIfCancellationRequested();
  125. if (reader.NodeType == XmlNodeType.Element)
  126. {
  127. FetchDataFromXmlNode(reader, item);
  128. }
  129. else
  130. {
  131. reader.Read();
  132. }
  133. }
  134. }
  135. return;
  136. }
  137. item.ResetPeople();
  138. // Need to handle a url after the xml data
  139. // http://kodi.wiki/view/NFO_files/movies
  140. var xml = File.ReadAllText(metadataFile);
  141. // Find last closing Tag
  142. // Need to do this in two steps to account for random > characters after the closing xml
  143. var index = xml.LastIndexOf(@"</", StringComparison.Ordinal);
  144. // If closing tag exists, move to end of Tag
  145. if (index != -1)
  146. {
  147. index = xml.IndexOf('>', index);
  148. }
  149. if (index != -1)
  150. {
  151. var endingXml = xml.AsSpan().Slice(index);
  152. ParseProviderLinks(item.Item, endingXml);
  153. // If the file is just an IMDb url, don't go any further
  154. if (index == 0)
  155. {
  156. return;
  157. }
  158. xml = xml.Substring(0, index + 1);
  159. }
  160. else
  161. {
  162. // If the file is just provider urls, handle that
  163. ParseProviderLinks(item.Item, xml);
  164. return;
  165. }
  166. // These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions
  167. try
  168. {
  169. using (var stringReader = new StringReader(xml))
  170. using (var reader = XmlReader.Create(stringReader, settings))
  171. {
  172. reader.MoveToContent();
  173. reader.Read();
  174. // Loop through each element
  175. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  176. {
  177. cancellationToken.ThrowIfCancellationRequested();
  178. if (reader.NodeType == XmlNodeType.Element)
  179. {
  180. FetchDataFromXmlNode(reader, item);
  181. }
  182. else
  183. {
  184. reader.Read();
  185. }
  186. }
  187. }
  188. }
  189. catch (XmlException)
  190. {
  191. }
  192. }
  193. /// <summary>
  194. /// Parses a XML tag to a provider id.
  195. /// </summary>
  196. /// <param name="item">The item.</param>
  197. /// <param name="xml">The xml tag.</param>
  198. protected void ParseProviderLinks(T item, ReadOnlySpan<char> xml)
  199. {
  200. if (ProviderIdParsers.TryFindImdbId(xml, out var imdbId))
  201. {
  202. item.SetProviderId(MetadataProvider.Imdb, imdbId.ToString());
  203. }
  204. if (item is Movie)
  205. {
  206. if (ProviderIdParsers.TryFindTmdbMovieId(xml, out var tmdbId))
  207. {
  208. item.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString());
  209. }
  210. }
  211. if (item is Series)
  212. {
  213. if (ProviderIdParsers.TryFindTmdbSeriesId(xml, out var tmdbId))
  214. {
  215. item.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString());
  216. }
  217. if (ProviderIdParsers.TryFindTvdbId(xml, out var tvdbId))
  218. {
  219. item.SetProviderId(MetadataProvider.Tvdb, tvdbId.ToString());
  220. }
  221. }
  222. }
  223. /// <summary>
  224. /// Fetches metadata from an XML node.
  225. /// </summary>
  226. /// <param name="reader">The <see cref="XmlReader"/>.</param>
  227. /// <param name="itemResult">The <see cref="MetadataResult{T}"/>.</param>
  228. protected virtual void FetchDataFromXmlNode(XmlReader reader, MetadataResult<T> itemResult)
  229. {
  230. var item = itemResult.Item;
  231. var nfoConfiguration = _config.GetNfoConfiguration();
  232. UserItemData? userData = null;
  233. switch (reader.Name)
  234. {
  235. // DateCreated
  236. case "dateadded":
  237. {
  238. var val = reader.ReadElementContentAsString();
  239. if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added))
  240. {
  241. item.DateCreated = added;
  242. }
  243. else
  244. {
  245. Logger.LogWarning("Invalid Added value found: {Value}", val);
  246. }
  247. break;
  248. }
  249. case "originaltitle":
  250. {
  251. var val = reader.ReadElementContentAsString();
  252. if (!string.IsNullOrEmpty(val))
  253. {
  254. item.OriginalTitle = val;
  255. }
  256. break;
  257. }
  258. case "name":
  259. case "title":
  260. case "localtitle":
  261. item.Name = reader.ReadElementContentAsString();
  262. break;
  263. case "sortname":
  264. item.SortName = reader.ReadElementContentAsString();
  265. break;
  266. case "criticrating":
  267. {
  268. var text = reader.ReadElementContentAsString();
  269. if (float.TryParse(text, CultureInfo.InvariantCulture, out var value))
  270. {
  271. item.CriticRating = value;
  272. }
  273. break;
  274. }
  275. case "sorttitle":
  276. {
  277. var val = reader.ReadElementContentAsString();
  278. if (!string.IsNullOrWhiteSpace(val))
  279. {
  280. item.ForcedSortName = val;
  281. }
  282. break;
  283. }
  284. case "biography":
  285. case "plot":
  286. case "review":
  287. {
  288. var val = reader.ReadElementContentAsString();
  289. if (!string.IsNullOrWhiteSpace(val))
  290. {
  291. item.Overview = val;
  292. }
  293. break;
  294. }
  295. case "language":
  296. {
  297. var val = reader.ReadElementContentAsString();
  298. item.PreferredMetadataLanguage = val;
  299. break;
  300. }
  301. case "watched":
  302. {
  303. var val = reader.ReadElementContentAsBoolean();
  304. if (!string.IsNullOrWhiteSpace(nfoConfiguration.UserId))
  305. {
  306. var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId));
  307. userData = _userDataManager.GetUserData(user, item);
  308. userData.Played = val;
  309. _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
  310. }
  311. break;
  312. }
  313. case "playcount":
  314. {
  315. var val = reader.ReadElementContentAsString();
  316. if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count)
  317. && Guid.TryParse(nfoConfiguration.UserId, out var guid))
  318. {
  319. var user = _userManager.GetUserById(guid);
  320. userData = _userDataManager.GetUserData(user, item);
  321. userData.PlayCount = count;
  322. _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
  323. }
  324. break;
  325. }
  326. case "lastplayed":
  327. {
  328. var val = reader.ReadElementContentAsString();
  329. if (Guid.TryParse(nfoConfiguration.UserId, out var guid))
  330. {
  331. if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added))
  332. {
  333. var user = _userManager.GetUserById(guid);
  334. userData = _userDataManager.GetUserData(user, item);
  335. userData.LastPlayedDate = added;
  336. _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
  337. }
  338. else
  339. {
  340. Logger.LogWarning("Invalid lastplayed value found: {Value}", val);
  341. }
  342. }
  343. break;
  344. }
  345. case "countrycode":
  346. {
  347. var val = reader.ReadElementContentAsString();
  348. item.PreferredMetadataCountryCode = val;
  349. break;
  350. }
  351. case "lockedfields":
  352. {
  353. var val = reader.ReadElementContentAsString();
  354. if (!string.IsNullOrWhiteSpace(val))
  355. {
  356. item.LockedFields = val.Split('|').Select(i =>
  357. {
  358. if (Enum.TryParse(i, true, out MetadataField field))
  359. {
  360. return (MetadataField?)field;
  361. }
  362. return null;
  363. }).OfType<MetadataField>().ToArray();
  364. }
  365. break;
  366. }
  367. case "tagline":
  368. item.Tagline = reader.ReadElementContentAsString();
  369. break;
  370. case "country":
  371. {
  372. var val = reader.ReadElementContentAsString();
  373. if (!string.IsNullOrWhiteSpace(val))
  374. {
  375. item.ProductionLocations = val.Split('/')
  376. .Select(i => i.Trim())
  377. .Where(i => !string.IsNullOrWhiteSpace(i))
  378. .ToArray();
  379. }
  380. break;
  381. }
  382. case "mpaa":
  383. {
  384. var rating = reader.ReadElementContentAsString();
  385. if (!string.IsNullOrWhiteSpace(rating))
  386. {
  387. item.OfficialRating = rating;
  388. }
  389. break;
  390. }
  391. case "customrating":
  392. {
  393. var val = reader.ReadElementContentAsString();
  394. if (!string.IsNullOrWhiteSpace(val))
  395. {
  396. item.CustomRating = val;
  397. }
  398. break;
  399. }
  400. case "runtime":
  401. {
  402. var text = reader.ReadElementContentAsString();
  403. if (int.TryParse(text.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime))
  404. {
  405. item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks;
  406. }
  407. break;
  408. }
  409. case "aspectratio":
  410. {
  411. var val = reader.ReadElementContentAsString();
  412. if (!string.IsNullOrWhiteSpace(val)
  413. && item is IHasAspectRatio hasAspectRatio)
  414. {
  415. hasAspectRatio.AspectRatio = val;
  416. }
  417. break;
  418. }
  419. case "lockdata":
  420. {
  421. var val = reader.ReadElementContentAsString();
  422. if (!string.IsNullOrWhiteSpace(val))
  423. {
  424. item.IsLocked = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
  425. }
  426. break;
  427. }
  428. case "studio":
  429. {
  430. var val = reader.ReadElementContentAsString();
  431. if (!string.IsNullOrWhiteSpace(val))
  432. {
  433. item.AddStudio(val);
  434. }
  435. break;
  436. }
  437. case "director":
  438. {
  439. var val = reader.ReadElementContentAsString();
  440. foreach (var p in SplitNames(val).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Director }))
  441. {
  442. if (string.IsNullOrWhiteSpace(p.Name))
  443. {
  444. continue;
  445. }
  446. itemResult.AddPerson(p);
  447. }
  448. break;
  449. }
  450. case "credits":
  451. {
  452. var val = reader.ReadElementContentAsString();
  453. if (!string.IsNullOrWhiteSpace(val))
  454. {
  455. var parts = val.Split('/').Select(i => i.Trim())
  456. .Where(i => !string.IsNullOrEmpty(i));
  457. foreach (var p in parts.Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Writer }))
  458. {
  459. if (string.IsNullOrWhiteSpace(p.Name))
  460. {
  461. continue;
  462. }
  463. itemResult.AddPerson(p);
  464. }
  465. }
  466. break;
  467. }
  468. case "writer":
  469. {
  470. var val = reader.ReadElementContentAsString();
  471. foreach (var p in SplitNames(val).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonType.Writer }))
  472. {
  473. if (string.IsNullOrWhiteSpace(p.Name))
  474. {
  475. continue;
  476. }
  477. itemResult.AddPerson(p);
  478. }
  479. break;
  480. }
  481. case "actor":
  482. {
  483. if (!reader.IsEmptyElement)
  484. {
  485. using (var subtree = reader.ReadSubtree())
  486. {
  487. var person = GetPersonFromXmlNode(subtree);
  488. if (!string.IsNullOrWhiteSpace(person.Name))
  489. {
  490. itemResult.AddPerson(person);
  491. }
  492. }
  493. }
  494. else
  495. {
  496. reader.Read();
  497. }
  498. break;
  499. }
  500. case "trailer":
  501. {
  502. var val = reader.ReadElementContentAsString();
  503. if (!string.IsNullOrWhiteSpace(val))
  504. {
  505. val = val.Replace("plugin://plugin.video.youtube/?action=play_video&videoid=", BaseNfoSaver.YouTubeWatchUrl, StringComparison.OrdinalIgnoreCase);
  506. item.AddTrailerUrl(val);
  507. }
  508. break;
  509. }
  510. case "displayorder":
  511. {
  512. var val = reader.ReadElementContentAsString();
  513. if (item is IHasDisplayOrder hasDisplayOrder && !string.IsNullOrWhiteSpace(val))
  514. {
  515. hasDisplayOrder.DisplayOrder = val;
  516. }
  517. break;
  518. }
  519. case "year":
  520. {
  521. var val = reader.ReadElementContentAsString();
  522. if (int.TryParse(val, out var productionYear) && productionYear > 1850)
  523. {
  524. item.ProductionYear = productionYear;
  525. }
  526. break;
  527. }
  528. case "rating":
  529. {
  530. var rating = reader.ReadElementContentAsString();
  531. // All external meta is saving this as '.' for decimal I believe...but just to be sure
  532. if (float.TryParse(rating.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var val))
  533. {
  534. item.CommunityRating = val;
  535. }
  536. break;
  537. }
  538. case "ratings":
  539. {
  540. if (!reader.IsEmptyElement)
  541. {
  542. using var subtree = reader.ReadSubtree();
  543. FetchFromRatingsNode(subtree, item);
  544. }
  545. else
  546. {
  547. reader.Read();
  548. }
  549. break;
  550. }
  551. case "aired":
  552. case "formed":
  553. case "premiered":
  554. case "releasedate":
  555. {
  556. var formatString = nfoConfiguration.ReleaseDateFormat;
  557. var val = reader.ReadElementContentAsString();
  558. if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850)
  559. {
  560. item.PremiereDate = date;
  561. item.ProductionYear = date.Year;
  562. }
  563. break;
  564. }
  565. case "enddate":
  566. {
  567. var formatString = nfoConfiguration.ReleaseDateFormat;
  568. var val = reader.ReadElementContentAsString();
  569. if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850)
  570. {
  571. item.EndDate = date;
  572. }
  573. break;
  574. }
  575. case "genre":
  576. {
  577. var val = reader.ReadElementContentAsString();
  578. if (!string.IsNullOrWhiteSpace(val))
  579. {
  580. var parts = val.Split('/')
  581. .Select(i => i.Trim())
  582. .Where(i => !string.IsNullOrWhiteSpace(i));
  583. foreach (var p in parts)
  584. {
  585. item.AddGenre(p);
  586. }
  587. }
  588. break;
  589. }
  590. case "style":
  591. case "tag":
  592. {
  593. var val = reader.ReadElementContentAsString();
  594. if (!string.IsNullOrWhiteSpace(val))
  595. {
  596. item.AddTag(val);
  597. }
  598. break;
  599. }
  600. case "fileinfo":
  601. {
  602. if (!reader.IsEmptyElement)
  603. {
  604. using (var subtree = reader.ReadSubtree())
  605. {
  606. FetchFromFileInfoNode(subtree, item);
  607. }
  608. }
  609. else
  610. {
  611. reader.Read();
  612. }
  613. break;
  614. }
  615. case "uniqueid":
  616. {
  617. if (reader.IsEmptyElement)
  618. {
  619. reader.Read();
  620. break;
  621. }
  622. var provider = reader.GetAttribute("type");
  623. var id = reader.ReadElementContentAsString();
  624. if (!string.IsNullOrWhiteSpace(provider) && !string.IsNullOrWhiteSpace(id))
  625. {
  626. item.SetProviderId(provider, id);
  627. }
  628. break;
  629. }
  630. case "thumb":
  631. {
  632. FetchThumbNode(reader, itemResult, "thumb");
  633. break;
  634. }
  635. case "fanart":
  636. {
  637. if (reader.IsEmptyElement)
  638. {
  639. reader.Read();
  640. break;
  641. }
  642. using var subtree = reader.ReadSubtree();
  643. if (!subtree.ReadToDescendant("thumb"))
  644. {
  645. break;
  646. }
  647. FetchThumbNode(subtree, itemResult, "fanart");
  648. break;
  649. }
  650. default:
  651. string readerName = reader.Name;
  652. if (_validProviderIds.TryGetValue(readerName, out string? providerIdValue))
  653. {
  654. var id = reader.ReadElementContentAsString();
  655. if (!string.IsNullOrWhiteSpace(providerIdValue) && !string.IsNullOrWhiteSpace(id))
  656. {
  657. item.SetProviderId(providerIdValue, id);
  658. }
  659. }
  660. else
  661. {
  662. reader.Skip();
  663. }
  664. break;
  665. }
  666. }
  667. private void FetchThumbNode(XmlReader reader, MetadataResult<T> itemResult, string parentNode)
  668. {
  669. var artType = reader.GetAttribute("aspect");
  670. var val = reader.ReadElementContentAsString();
  671. // artType is null if the thumb node is a child of the fanart tag
  672. // -> set image type to fanart
  673. if (string.IsNullOrWhiteSpace(artType) && parentNode.Equals("fanart", StringComparison.Ordinal))
  674. {
  675. artType = "fanart";
  676. }
  677. else if (string.IsNullOrWhiteSpace(artType))
  678. {
  679. // Sonarr writes thumb tags for posters without aspect property
  680. artType = "poster";
  681. }
  682. // skip:
  683. // - empty uri
  684. // - tag containing '.' because we can't set images for seasons, episodes or movie sets within series or movies
  685. if (string.IsNullOrEmpty(val) || artType.Contains('.', StringComparison.Ordinal))
  686. {
  687. return;
  688. }
  689. ImageType imageType = GetImageType(artType);
  690. if (!Uri.TryCreate(val, UriKind.Absolute, out var uri))
  691. {
  692. Logger.LogError("Image location {Path} specified in nfo file for {ItemName} is not a valid URL or file path.", val, itemResult.Item.Name);
  693. return;
  694. }
  695. if (uri.IsFile)
  696. {
  697. // only allow one item of each type
  698. if (itemResult.Images.Any(x => x.Type == imageType))
  699. {
  700. return;
  701. }
  702. var fileSystemMetadata = _directoryService.GetFile(val);
  703. // non existing file returns null
  704. if (fileSystemMetadata is null || !fileSystemMetadata.Exists)
  705. {
  706. Logger.LogWarning("Artwork file {Path} specified in nfo file for {ItemName} does not exist.", uri, itemResult.Item.Name);
  707. return;
  708. }
  709. itemResult.Images.Add(new LocalImageInfo()
  710. {
  711. FileInfo = fileSystemMetadata,
  712. Type = imageType
  713. });
  714. }
  715. else
  716. {
  717. // only allow one item of each type
  718. if (itemResult.RemoteImages.Any(x => x.Type == imageType))
  719. {
  720. return;
  721. }
  722. itemResult.RemoteImages.Add((uri.ToString(), imageType));
  723. }
  724. }
  725. private void FetchFromFileInfoNode(XmlReader reader, T item)
  726. {
  727. reader.MoveToContent();
  728. reader.Read();
  729. // Loop through each element
  730. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  731. {
  732. if (reader.NodeType == XmlNodeType.Element)
  733. {
  734. switch (reader.Name)
  735. {
  736. case "streamdetails":
  737. {
  738. if (reader.IsEmptyElement)
  739. {
  740. reader.Read();
  741. continue;
  742. }
  743. using (var subtree = reader.ReadSubtree())
  744. {
  745. FetchFromStreamDetailsNode(subtree, item);
  746. }
  747. break;
  748. }
  749. default:
  750. reader.Skip();
  751. break;
  752. }
  753. }
  754. else
  755. {
  756. reader.Read();
  757. }
  758. }
  759. }
  760. private void FetchFromStreamDetailsNode(XmlReader reader, T item)
  761. {
  762. reader.MoveToContent();
  763. reader.Read();
  764. // Loop through each element
  765. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  766. {
  767. if (reader.NodeType == XmlNodeType.Element)
  768. {
  769. switch (reader.Name)
  770. {
  771. case "video":
  772. {
  773. if (reader.IsEmptyElement)
  774. {
  775. reader.Read();
  776. continue;
  777. }
  778. using (var subtree = reader.ReadSubtree())
  779. {
  780. FetchFromVideoNode(subtree, item);
  781. }
  782. break;
  783. }
  784. case "subtitle":
  785. {
  786. if (reader.IsEmptyElement)
  787. {
  788. reader.Read();
  789. continue;
  790. }
  791. using (var subtree = reader.ReadSubtree())
  792. {
  793. FetchFromSubtitleNode(subtree, item);
  794. }
  795. break;
  796. }
  797. default:
  798. reader.Skip();
  799. break;
  800. }
  801. }
  802. else
  803. {
  804. reader.Read();
  805. }
  806. }
  807. }
  808. private void FetchFromVideoNode(XmlReader reader, T item)
  809. {
  810. reader.MoveToContent();
  811. reader.Read();
  812. // Loop through each element
  813. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  814. {
  815. if (reader.NodeType == XmlNodeType.Element)
  816. {
  817. switch (reader.Name)
  818. {
  819. case "format3d":
  820. {
  821. var val = reader.ReadElementContentAsString();
  822. var video = item as Video;
  823. if (video is not null)
  824. {
  825. if (string.Equals("HSBS", val, StringComparison.OrdinalIgnoreCase))
  826. {
  827. video.Video3DFormat = Video3DFormat.HalfSideBySide;
  828. }
  829. else if (string.Equals("HTAB", val, StringComparison.OrdinalIgnoreCase))
  830. {
  831. video.Video3DFormat = Video3DFormat.HalfTopAndBottom;
  832. }
  833. else if (string.Equals("FTAB", val, StringComparison.OrdinalIgnoreCase))
  834. {
  835. video.Video3DFormat = Video3DFormat.FullTopAndBottom;
  836. }
  837. else if (string.Equals("FSBS", val, StringComparison.OrdinalIgnoreCase))
  838. {
  839. video.Video3DFormat = Video3DFormat.FullSideBySide;
  840. }
  841. else if (string.Equals("MVC", val, StringComparison.OrdinalIgnoreCase))
  842. {
  843. video.Video3DFormat = Video3DFormat.MVC;
  844. }
  845. }
  846. break;
  847. }
  848. case "aspect":
  849. {
  850. var val = reader.ReadElementContentAsString();
  851. if (item is Video video)
  852. {
  853. video.AspectRatio = val;
  854. }
  855. break;
  856. }
  857. case "width":
  858. {
  859. var val = reader.ReadElementContentAsInt();
  860. if (item is Video video)
  861. {
  862. video.Width = val;
  863. }
  864. break;
  865. }
  866. case "height":
  867. {
  868. var val = reader.ReadElementContentAsInt();
  869. if (item is Video video)
  870. {
  871. video.Height = val;
  872. }
  873. break;
  874. }
  875. case "durationinseconds":
  876. {
  877. var val = reader.ReadElementContentAsInt();
  878. if (item is Video video)
  879. {
  880. video.RunTimeTicks = new TimeSpan(0, 0, val).Ticks;
  881. }
  882. break;
  883. }
  884. default:
  885. reader.Skip();
  886. break;
  887. }
  888. }
  889. else
  890. {
  891. reader.Read();
  892. }
  893. }
  894. }
  895. private void FetchFromSubtitleNode(XmlReader reader, T item)
  896. {
  897. reader.MoveToContent();
  898. reader.Read();
  899. // Loop through each element
  900. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  901. {
  902. if (reader.NodeType == XmlNodeType.Element)
  903. {
  904. switch (reader.Name)
  905. {
  906. case "language":
  907. _ = reader.ReadElementContentAsString();
  908. if (item is Video video)
  909. {
  910. video.HasSubtitles = true;
  911. }
  912. break;
  913. default:
  914. reader.Skip();
  915. break;
  916. }
  917. }
  918. else
  919. {
  920. reader.Read();
  921. }
  922. }
  923. }
  924. private void FetchFromRatingsNode(XmlReader reader, T item)
  925. {
  926. reader.MoveToContent();
  927. reader.Read();
  928. // Loop through each element
  929. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  930. {
  931. if (reader.NodeType == XmlNodeType.Element)
  932. {
  933. switch (reader.Name)
  934. {
  935. case "rating":
  936. {
  937. if (reader.IsEmptyElement)
  938. {
  939. reader.Read();
  940. continue;
  941. }
  942. var ratingName = reader.GetAttribute("name");
  943. using var subtree = reader.ReadSubtree();
  944. FetchFromRatingNode(subtree, item, ratingName);
  945. break;
  946. }
  947. default:
  948. reader.Skip();
  949. break;
  950. }
  951. }
  952. else
  953. {
  954. reader.Read();
  955. }
  956. }
  957. }
  958. private void FetchFromRatingNode(XmlReader reader, T item, string? ratingName)
  959. {
  960. reader.MoveToContent();
  961. reader.Read();
  962. // Loop through each element
  963. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  964. {
  965. if (reader.NodeType == XmlNodeType.Element)
  966. {
  967. switch (reader.Name)
  968. {
  969. case "value":
  970. var val = reader.ReadElementContentAsString();
  971. if (float.TryParse(val, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var ratingValue))
  972. {
  973. // if ratingName contains tomato --> assume critic rating
  974. if (ratingName is not null
  975. && ratingName.Contains("tomato", StringComparison.OrdinalIgnoreCase)
  976. && !ratingName.Contains("audience", StringComparison.OrdinalIgnoreCase))
  977. {
  978. if (!ratingName.Contains("avg", StringComparison.OrdinalIgnoreCase))
  979. {
  980. item.CriticRating = ratingValue;
  981. }
  982. }
  983. else
  984. {
  985. item.CommunityRating = ratingValue;
  986. }
  987. }
  988. break;
  989. default:
  990. reader.Skip();
  991. break;
  992. }
  993. }
  994. else
  995. {
  996. reader.Read();
  997. }
  998. }
  999. }
  1000. /// <summary>
  1001. /// Gets the persons from a XML node.
  1002. /// </summary>
  1003. /// <param name="reader">The <see cref="XmlReader"/>.</param>
  1004. /// <returns>IEnumerable{PersonInfo}.</returns>
  1005. private PersonInfo GetPersonFromXmlNode(XmlReader reader)
  1006. {
  1007. var name = string.Empty;
  1008. var type = PersonType.Actor; // If type is not specified assume actor
  1009. var role = string.Empty;
  1010. int? sortOrder = null;
  1011. string? imageUrl = null;
  1012. reader.MoveToContent();
  1013. reader.Read();
  1014. // Loop through each element
  1015. while (!reader.EOF && reader.ReadState == ReadState.Interactive)
  1016. {
  1017. if (reader.NodeType == XmlNodeType.Element)
  1018. {
  1019. switch (reader.Name)
  1020. {
  1021. case "name":
  1022. name = reader.ReadElementContentAsString();
  1023. break;
  1024. case "role":
  1025. {
  1026. var val = reader.ReadElementContentAsString();
  1027. if (!string.IsNullOrWhiteSpace(val))
  1028. {
  1029. role = val;
  1030. }
  1031. break;
  1032. }
  1033. case "type":
  1034. {
  1035. var val = reader.ReadElementContentAsString();
  1036. if (!string.IsNullOrWhiteSpace(val))
  1037. {
  1038. type = val switch
  1039. {
  1040. PersonType.Composer => PersonType.Composer,
  1041. PersonType.Conductor => PersonType.Conductor,
  1042. PersonType.Director => PersonType.Director,
  1043. PersonType.Lyricist => PersonType.Lyricist,
  1044. PersonType.Producer => PersonType.Producer,
  1045. PersonType.Writer => PersonType.Writer,
  1046. PersonType.GuestStar => PersonType.GuestStar,
  1047. // unknown type --> actor
  1048. _ => PersonType.Actor
  1049. };
  1050. }
  1051. break;
  1052. }
  1053. case "order":
  1054. case "sortorder":
  1055. {
  1056. var val = reader.ReadElementContentAsString();
  1057. if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal))
  1058. {
  1059. sortOrder = intVal;
  1060. }
  1061. break;
  1062. }
  1063. case "thumb":
  1064. {
  1065. var val = reader.ReadElementContentAsString();
  1066. if (!string.IsNullOrWhiteSpace(val))
  1067. {
  1068. imageUrl = val;
  1069. }
  1070. break;
  1071. }
  1072. default:
  1073. reader.Skip();
  1074. break;
  1075. }
  1076. }
  1077. else
  1078. {
  1079. reader.Read();
  1080. }
  1081. }
  1082. return new PersonInfo
  1083. {
  1084. Name = name.Trim(),
  1085. Role = role,
  1086. Type = type,
  1087. SortOrder = sortOrder,
  1088. ImageUrl = imageUrl
  1089. };
  1090. }
  1091. internal XmlReaderSettings GetXmlReaderSettings()
  1092. => new XmlReaderSettings()
  1093. {
  1094. ValidationType = ValidationType.None,
  1095. CheckCharacters = false,
  1096. IgnoreProcessingInstructions = true,
  1097. IgnoreComments = true
  1098. };
  1099. /// <summary>
  1100. /// Used to split names of comma or pipe delimited genres and people.
  1101. /// </summary>
  1102. /// <param name="value">The value.</param>
  1103. /// <returns>IEnumerable{System.String}.</returns>
  1104. private IEnumerable<string> SplitNames(string value)
  1105. {
  1106. // Only split by comma if there is no pipe in the string
  1107. // We have to be careful to not split names like Matthew, Jr.
  1108. var separator = !value.Contains('|', StringComparison.Ordinal) && !value.Contains(';', StringComparison.Ordinal)
  1109. ? new[] { ',' }
  1110. : new[] { '|', ';' };
  1111. value = value.Trim().Trim(separator);
  1112. return string.IsNullOrWhiteSpace(value) ? Array.Empty<string>() : value.Split(separator, StringSplitOptions.RemoveEmptyEntries);
  1113. }
  1114. /// <summary>
  1115. /// Parses the <see cref="ImageType"/> from the NFO aspect property.
  1116. /// </summary>
  1117. /// <param name="aspect">The NFO aspect property.</param>
  1118. /// <returns>The <see cref="ImageType"/>.</returns>
  1119. private static ImageType GetImageType(string aspect)
  1120. {
  1121. return aspect switch
  1122. {
  1123. "banner" => ImageType.Banner,
  1124. "clearlogo" => ImageType.Logo,
  1125. "discart" => ImageType.Disc,
  1126. "landscape" => ImageType.Thumb,
  1127. "clearart" => ImageType.Art,
  1128. "fanart" => ImageType.Backdrop,
  1129. // unknown type (including "poster") --> primary
  1130. _ => ImageType.Primary,
  1131. };
  1132. }
  1133. }
  1134. }