DidlBuilder.cs 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266
  1. #pragma warning disable CS1591
  2. using System;
  3. using System.Globalization;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Text;
  7. using System.Xml;
  8. using Emby.Dlna.ContentDirectory;
  9. using Jellyfin.Data.Entities;
  10. using Jellyfin.Data.Enums;
  11. using MediaBrowser.Controller.Channels;
  12. using MediaBrowser.Controller.Drawing;
  13. using MediaBrowser.Controller.Entities;
  14. using MediaBrowser.Controller.Entities.Audio;
  15. using MediaBrowser.Controller.Entities.Movies;
  16. using MediaBrowser.Controller.Library;
  17. using MediaBrowser.Controller.MediaEncoding;
  18. using MediaBrowser.Controller.Playlists;
  19. using MediaBrowser.Model.Dlna;
  20. using MediaBrowser.Model.Drawing;
  21. using MediaBrowser.Model.Entities;
  22. using MediaBrowser.Model.Globalization;
  23. using MediaBrowser.Model.Net;
  24. using Microsoft.Extensions.Logging;
  25. using Episode = MediaBrowser.Controller.Entities.TV.Episode;
  26. using Genre = MediaBrowser.Controller.Entities.Genre;
  27. using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
  28. using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum;
  29. using Season = MediaBrowser.Controller.Entities.TV.Season;
  30. using Series = MediaBrowser.Controller.Entities.TV.Series;
  31. using XmlAttribute = MediaBrowser.Model.Dlna.XmlAttribute;
  32. namespace Emby.Dlna.Didl
  33. {
  34. public class DidlBuilder
  35. {
  36. private const string NsDidl = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/";
  37. private const string NsDc = "http://purl.org/dc/elements/1.1/";
  38. private const string NsUpnp = "urn:schemas-upnp-org:metadata-1-0/upnp/";
  39. private const string NsDlna = "urn:schemas-dlna-org:metadata-1-0/";
  40. private readonly DeviceProfile _profile;
  41. private readonly IImageProcessor _imageProcessor;
  42. private readonly string _serverAddress;
  43. private readonly string? _accessToken;
  44. private readonly User? _user;
  45. private readonly IUserDataManager _userDataManager;
  46. private readonly ILocalizationManager _localization;
  47. private readonly IMediaSourceManager _mediaSourceManager;
  48. private readonly ILogger _logger;
  49. private readonly IMediaEncoder _mediaEncoder;
  50. private readonly ILibraryManager _libraryManager;
  51. public DidlBuilder(
  52. DeviceProfile profile,
  53. User? user,
  54. IImageProcessor imageProcessor,
  55. string serverAddress,
  56. string? accessToken,
  57. IUserDataManager userDataManager,
  58. ILocalizationManager localization,
  59. IMediaSourceManager mediaSourceManager,
  60. ILogger logger,
  61. IMediaEncoder mediaEncoder,
  62. ILibraryManager libraryManager)
  63. {
  64. _profile = profile;
  65. _user = user;
  66. _imageProcessor = imageProcessor;
  67. _serverAddress = serverAddress;
  68. _accessToken = accessToken;
  69. _userDataManager = userDataManager;
  70. _localization = localization;
  71. _mediaSourceManager = mediaSourceManager;
  72. _logger = logger;
  73. _mediaEncoder = mediaEncoder;
  74. _libraryManager = libraryManager;
  75. }
  76. public static string NormalizeDlnaMediaUrl(string url)
  77. {
  78. return url + "&dlnaheaders=true";
  79. }
  80. public string GetItemDidl(BaseItem item, User? user, BaseItem? context, string deviceId, Filter filter, StreamInfo streamInfo)
  81. {
  82. var settings = new XmlWriterSettings
  83. {
  84. Encoding = Encoding.UTF8,
  85. CloseOutput = false,
  86. OmitXmlDeclaration = true,
  87. ConformanceLevel = ConformanceLevel.Fragment
  88. };
  89. using (StringWriter builder = new StringWriterWithEncoding(Encoding.UTF8))
  90. {
  91. // If this using are changed to single lines, then write.Flush needs to be appended before the return.
  92. using (var writer = XmlWriter.Create(builder, settings))
  93. {
  94. // writer.WriteStartDocument();
  95. writer.WriteStartElement(string.Empty, "DIDL-Lite", NsDidl);
  96. writer.WriteAttributeString("xmlns", "dc", null, NsDc);
  97. writer.WriteAttributeString("xmlns", "dlna", null, NsDlna);
  98. writer.WriteAttributeString("xmlns", "upnp", null, NsUpnp);
  99. // didl.SetAttribute("xmlns:sec", NS_SEC);
  100. WriteXmlRootAttributes(_profile, writer);
  101. WriteItemElement(writer, item, user, context, null, deviceId, filter, streamInfo);
  102. writer.WriteFullEndElement();
  103. // writer.WriteEndDocument();
  104. }
  105. return builder.ToString();
  106. }
  107. }
  108. public static void WriteXmlRootAttributes(DeviceProfile profile, XmlWriter writer)
  109. {
  110. foreach (var att in profile.XmlRootAttributes)
  111. {
  112. var parts = att.Name.Split(':', StringSplitOptions.RemoveEmptyEntries);
  113. if (parts.Length == 2)
  114. {
  115. writer.WriteAttributeString(parts[0], parts[1], null, att.Value);
  116. }
  117. else
  118. {
  119. writer.WriteAttributeString(att.Name, att.Value);
  120. }
  121. }
  122. }
  123. public void WriteItemElement(
  124. XmlWriter writer,
  125. BaseItem item,
  126. User? user,
  127. BaseItem? context,
  128. StubType? contextStubType,
  129. string deviceId,
  130. Filter filter,
  131. StreamInfo? streamInfo = null)
  132. {
  133. var clientId = GetClientId(item, null);
  134. writer.WriteStartElement(string.Empty, "item", NsDidl);
  135. writer.WriteAttributeString("restricted", "1");
  136. writer.WriteAttributeString("id", clientId);
  137. if (context is not null)
  138. {
  139. writer.WriteAttributeString("parentID", GetClientId(context, contextStubType));
  140. }
  141. else
  142. {
  143. var parent = item.DisplayParentId;
  144. if (!parent.Equals(default))
  145. {
  146. writer.WriteAttributeString("parentID", GetClientId(parent, null));
  147. }
  148. }
  149. AddGeneralProperties(item, null, context, writer, filter);
  150. AddSamsungBookmarkInfo(item, user, writer, streamInfo);
  151. // refID?
  152. // storeAttribute(itemNode, object, ClassProperties.REF_ID, false);
  153. if (item is IHasMediaSources)
  154. {
  155. if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
  156. {
  157. AddAudioResource(writer, item, deviceId, filter, streamInfo);
  158. }
  159. else if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
  160. {
  161. AddVideoResource(writer, item, deviceId, filter, streamInfo);
  162. }
  163. }
  164. AddCover(item, null, writer);
  165. writer.WriteFullEndElement();
  166. }
  167. private void AddVideoResource(XmlWriter writer, BaseItem video, string deviceId, Filter filter, StreamInfo? streamInfo = null)
  168. {
  169. if (streamInfo is null)
  170. {
  171. var sources = _mediaSourceManager.GetStaticMediaSources(video, true, _user);
  172. streamInfo = new StreamBuilder(_mediaEncoder, _logger).GetOptimalVideoStream(new MediaOptions
  173. {
  174. ItemId = video.Id,
  175. MediaSources = sources.ToArray(),
  176. Profile = _profile,
  177. DeviceId = deviceId,
  178. MaxBitrate = _profile.MaxStreamingBitrate
  179. }) ?? throw new InvalidOperationException("No optimal video stream found");
  180. }
  181. var targetWidth = streamInfo.TargetWidth;
  182. var targetHeight = streamInfo.TargetHeight;
  183. var contentFeatureList = ContentFeatureBuilder.BuildVideoHeader(
  184. _profile,
  185. streamInfo.Container,
  186. streamInfo.TargetVideoCodec.FirstOrDefault(),
  187. streamInfo.TargetAudioCodec.FirstOrDefault(),
  188. targetWidth,
  189. targetHeight,
  190. streamInfo.TargetVideoBitDepth,
  191. streamInfo.TargetVideoBitrate,
  192. streamInfo.TargetTimestamp,
  193. streamInfo.IsDirectStream,
  194. streamInfo.RunTimeTicks ?? 0,
  195. streamInfo.TargetVideoProfile,
  196. streamInfo.TargetVideoRangeType,
  197. streamInfo.TargetVideoLevel,
  198. streamInfo.TargetFramerate ?? 0,
  199. streamInfo.TargetPacketLength,
  200. streamInfo.TranscodeSeekInfo,
  201. streamInfo.IsTargetAnamorphic,
  202. streamInfo.IsTargetInterlaced,
  203. streamInfo.TargetRefFrames,
  204. streamInfo.TargetVideoStreamCount,
  205. streamInfo.TargetAudioStreamCount,
  206. streamInfo.TargetVideoCodecTag,
  207. streamInfo.IsTargetAVC);
  208. foreach (var contentFeature in contentFeatureList)
  209. {
  210. AddVideoResource(writer, filter, contentFeature, streamInfo);
  211. }
  212. var subtitleProfiles = streamInfo.GetSubtitleProfiles(_mediaEncoder, false, _serverAddress, _accessToken);
  213. foreach (var subtitle in subtitleProfiles)
  214. {
  215. if (subtitle.DeliveryMethod != SubtitleDeliveryMethod.External)
  216. {
  217. continue;
  218. }
  219. var subtitleAdded = AddSubtitleElement(writer, subtitle);
  220. if (subtitleAdded && _profile.EnableSingleSubtitleLimit)
  221. {
  222. break;
  223. }
  224. }
  225. }
  226. private bool AddSubtitleElement(XmlWriter writer, SubtitleStreamInfo info)
  227. {
  228. var subtitleProfile = _profile.SubtitleProfiles
  229. .FirstOrDefault(i => string.Equals(info.Format, i.Format, StringComparison.OrdinalIgnoreCase)
  230. && i.Method == SubtitleDeliveryMethod.External);
  231. if (subtitleProfile is null)
  232. {
  233. return false;
  234. }
  235. var subtitleMode = subtitleProfile.DidlMode;
  236. if (string.Equals(subtitleMode, "CaptionInfoEx", StringComparison.OrdinalIgnoreCase))
  237. {
  238. // <sec:CaptionInfoEx sec:type="srt">http://192.168.1.3:9999/video.srt</sec:CaptionInfoEx>
  239. // <sec:CaptionInfo sec:type="srt">http://192.168.1.3:9999/video.srt</sec:CaptionInfo>
  240. writer.WriteStartElement("sec", "CaptionInfoEx", null);
  241. writer.WriteAttributeString("sec", "type", null, info.Format.ToLowerInvariant());
  242. writer.WriteString(info.Url);
  243. writer.WriteFullEndElement();
  244. }
  245. else if (string.Equals(subtitleMode, "smi", StringComparison.OrdinalIgnoreCase))
  246. {
  247. writer.WriteStartElement(string.Empty, "res", NsDidl);
  248. writer.WriteAttributeString("protocolInfo", "http-get:*:smi/caption:*");
  249. writer.WriteString(info.Url);
  250. writer.WriteFullEndElement();
  251. }
  252. else
  253. {
  254. writer.WriteStartElement(string.Empty, "res", NsDidl);
  255. var protocolInfo = string.Format(
  256. CultureInfo.InvariantCulture,
  257. "http-get:*:text/{0}:*",
  258. info.Format.ToLowerInvariant());
  259. writer.WriteAttributeString("protocolInfo", protocolInfo);
  260. writer.WriteString(info.Url);
  261. writer.WriteFullEndElement();
  262. }
  263. return true;
  264. }
  265. private void AddVideoResource(XmlWriter writer, Filter filter, string contentFeatures, StreamInfo streamInfo)
  266. {
  267. writer.WriteStartElement(string.Empty, "res", NsDidl);
  268. var url = NormalizeDlnaMediaUrl(streamInfo.ToUrl(_serverAddress, _accessToken));
  269. var mediaSource = streamInfo.MediaSource;
  270. if (mediaSource?.RunTimeTicks.HasValue == true)
  271. {
  272. writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture));
  273. }
  274. if (filter.Contains("res@size"))
  275. {
  276. if (streamInfo.IsDirectStream || streamInfo.EstimateContentLength)
  277. {
  278. var size = streamInfo.TargetSize;
  279. if (size.HasValue)
  280. {
  281. writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture));
  282. }
  283. }
  284. }
  285. var totalBitrate = streamInfo.TargetTotalBitrate;
  286. var targetSampleRate = streamInfo.TargetAudioSampleRate;
  287. var targetChannels = streamInfo.TargetAudioChannels;
  288. var targetWidth = streamInfo.TargetWidth;
  289. var targetHeight = streamInfo.TargetHeight;
  290. if (targetChannels.HasValue)
  291. {
  292. writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture));
  293. }
  294. if (filter.Contains("res@resolution"))
  295. {
  296. if (targetWidth.HasValue && targetHeight.HasValue)
  297. {
  298. writer.WriteAttributeString(
  299. "resolution",
  300. string.Format(
  301. CultureInfo.InvariantCulture,
  302. "{0}x{1}",
  303. targetWidth.Value,
  304. targetHeight.Value));
  305. }
  306. }
  307. if (targetSampleRate.HasValue)
  308. {
  309. writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture));
  310. }
  311. if (totalBitrate.HasValue)
  312. {
  313. writer.WriteAttributeString("bitrate", totalBitrate.Value.ToString(CultureInfo.InvariantCulture));
  314. }
  315. var mediaProfile = _profile.GetVideoMediaProfile(
  316. streamInfo.Container,
  317. streamInfo.TargetAudioCodec.FirstOrDefault(),
  318. streamInfo.TargetVideoCodec.FirstOrDefault(),
  319. streamInfo.TargetAudioBitrate,
  320. targetWidth,
  321. targetHeight,
  322. streamInfo.TargetVideoBitDepth,
  323. streamInfo.TargetVideoProfile,
  324. streamInfo.TargetVideoRangeType,
  325. streamInfo.TargetVideoLevel,
  326. streamInfo.TargetFramerate ?? 0,
  327. streamInfo.TargetPacketLength,
  328. streamInfo.TargetTimestamp,
  329. streamInfo.IsTargetAnamorphic,
  330. streamInfo.IsTargetInterlaced,
  331. streamInfo.TargetRefFrames,
  332. streamInfo.TargetVideoStreamCount,
  333. streamInfo.TargetAudioStreamCount,
  334. streamInfo.TargetVideoCodecTag,
  335. streamInfo.IsTargetAVC);
  336. var filename = url.Substring(0, url.IndexOf('?', StringComparison.Ordinal));
  337. var mimeType = mediaProfile is null || string.IsNullOrEmpty(mediaProfile.MimeType)
  338. ? MimeTypes.GetMimeType(filename)
  339. : mediaProfile.MimeType;
  340. writer.WriteAttributeString(
  341. "protocolInfo",
  342. string.Format(
  343. CultureInfo.InvariantCulture,
  344. "http-get:*:{0}:{1}",
  345. mimeType,
  346. contentFeatures));
  347. writer.WriteString(url);
  348. writer.WriteFullEndElement();
  349. }
  350. private string GetDisplayName(BaseItem item, StubType? itemStubType, BaseItem? context)
  351. {
  352. if (itemStubType.HasValue)
  353. {
  354. switch (itemStubType.Value)
  355. {
  356. case StubType.Latest: return _localization.GetLocalizedString("Latest");
  357. case StubType.Playlists: return _localization.GetLocalizedString("Playlists");
  358. case StubType.AlbumArtists: return _localization.GetLocalizedString("HeaderAlbumArtists");
  359. case StubType.Albums: return _localization.GetLocalizedString("Albums");
  360. case StubType.Artists: return _localization.GetLocalizedString("Artists");
  361. case StubType.Songs: return _localization.GetLocalizedString("Songs");
  362. case StubType.Genres: return _localization.GetLocalizedString("Genres");
  363. case StubType.FavoriteAlbums: return _localization.GetLocalizedString("HeaderFavoriteAlbums");
  364. case StubType.FavoriteArtists: return _localization.GetLocalizedString("HeaderFavoriteArtists");
  365. case StubType.FavoriteSongs: return _localization.GetLocalizedString("HeaderFavoriteSongs");
  366. case StubType.ContinueWatching: return _localization.GetLocalizedString("HeaderContinueWatching");
  367. case StubType.Movies: return _localization.GetLocalizedString("Movies");
  368. case StubType.Collections: return _localization.GetLocalizedString("Collections");
  369. case StubType.Favorites: return _localization.GetLocalizedString("Favorites");
  370. case StubType.NextUp: return _localization.GetLocalizedString("HeaderNextUp");
  371. case StubType.FavoriteSeries: return _localization.GetLocalizedString("HeaderFavoriteShows");
  372. case StubType.FavoriteEpisodes: return _localization.GetLocalizedString("HeaderFavoriteEpisodes");
  373. case StubType.Series: return _localization.GetLocalizedString("Shows");
  374. }
  375. }
  376. return item is Episode episode
  377. ? GetEpisodeDisplayName(episode, context)
  378. : item.Name;
  379. }
  380. /// <summary>
  381. /// Gets episode display name appropriate for the given context.
  382. /// </summary>
  383. /// <remarks>
  384. /// If context is a season, this will return a string containing just episode number and name.
  385. /// Otherwise the result will include series names and season number.
  386. /// </remarks>
  387. /// <param name="episode">The episode.</param>
  388. /// <param name="context">Current context.</param>
  389. /// <returns>Formatted name of the episode.</returns>
  390. private string GetEpisodeDisplayName(Episode episode, BaseItem? context)
  391. {
  392. string[] components;
  393. if (context is Season season)
  394. {
  395. // This is a special embedded within a season
  396. if (episode.ParentIndexNumber.HasValue && episode.ParentIndexNumber.Value == 0
  397. && season.IndexNumber.HasValue && season.IndexNumber.Value != 0)
  398. {
  399. return string.Format(
  400. CultureInfo.InvariantCulture,
  401. _localization.GetLocalizedString("ValueSpecialEpisodeName"),
  402. episode.Name);
  403. }
  404. // inside a season use simple format (ex. '12 - Episode Name')
  405. var epNumberName = GetEpisodeIndexFullName(episode);
  406. components = new[] { epNumberName, episode.Name };
  407. }
  408. else
  409. {
  410. // outside a season include series and season details (ex. 'TV Show - S05E11 - Episode Name')
  411. var epNumberName = GetEpisodeNumberDisplayName(episode);
  412. components = new[] { episode.SeriesName, epNumberName, episode.Name };
  413. }
  414. return string.Join(" - ", components.Where(NotNullOrWhiteSpace));
  415. }
  416. /// <summary>
  417. /// Gets complete episode number.
  418. /// </summary>
  419. /// <param name="episode">The episode.</param>
  420. /// <returns>For single episodes returns just the number. For double episodes - current and ending numbers.</returns>
  421. private string GetEpisodeIndexFullName(Episode episode)
  422. {
  423. var name = string.Empty;
  424. if (episode.IndexNumber.HasValue)
  425. {
  426. name += episode.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
  427. if (episode.IndexNumberEnd.HasValue)
  428. {
  429. name += "-" + episode.IndexNumberEnd.Value.ToString("00", CultureInfo.InvariantCulture);
  430. }
  431. }
  432. return name;
  433. }
  434. /// <summary>
  435. /// Gets episode number formatted as 'S##E##'.
  436. /// </summary>
  437. /// <param name="episode">The episode.</param>
  438. /// <returns>Formatted episode number.</returns>
  439. private string GetEpisodeNumberDisplayName(Episode episode)
  440. {
  441. var name = string.Empty;
  442. var seasonNumber = episode.Season?.IndexNumber;
  443. if (seasonNumber.HasValue)
  444. {
  445. name = "S" + seasonNumber.Value.ToString("00", CultureInfo.InvariantCulture);
  446. }
  447. var indexName = GetEpisodeIndexFullName(episode);
  448. if (!string.IsNullOrWhiteSpace(indexName))
  449. {
  450. name += "E" + indexName;
  451. }
  452. return name;
  453. }
  454. private bool NotNullOrWhiteSpace(string s) => !string.IsNullOrWhiteSpace(s);
  455. private void AddAudioResource(XmlWriter writer, BaseItem audio, string deviceId, Filter filter, StreamInfo? streamInfo = null)
  456. {
  457. writer.WriteStartElement(string.Empty, "res", NsDidl);
  458. if (streamInfo is null)
  459. {
  460. var sources = _mediaSourceManager.GetStaticMediaSources(audio, true, _user);
  461. streamInfo = new StreamBuilder(_mediaEncoder, _logger).GetOptimalAudioStream(new MediaOptions
  462. {
  463. ItemId = audio.Id,
  464. MediaSources = sources.ToArray(),
  465. Profile = _profile,
  466. DeviceId = deviceId
  467. }) ?? throw new InvalidOperationException("No optimal audio stream found");
  468. }
  469. var url = NormalizeDlnaMediaUrl(streamInfo.ToUrl(_serverAddress, _accessToken));
  470. var mediaSource = streamInfo.MediaSource;
  471. if (mediaSource?.RunTimeTicks is not null)
  472. {
  473. writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture));
  474. }
  475. if (filter.Contains("res@size"))
  476. {
  477. if (streamInfo.IsDirectStream || streamInfo.EstimateContentLength)
  478. {
  479. var size = streamInfo.TargetSize;
  480. if (size.HasValue)
  481. {
  482. writer.WriteAttributeString("size", size.Value.ToString(CultureInfo.InvariantCulture));
  483. }
  484. }
  485. }
  486. var targetAudioBitrate = streamInfo.TargetAudioBitrate;
  487. var targetSampleRate = streamInfo.TargetAudioSampleRate;
  488. var targetChannels = streamInfo.TargetAudioChannels;
  489. var targetAudioBitDepth = streamInfo.TargetAudioBitDepth;
  490. if (targetChannels.HasValue)
  491. {
  492. writer.WriteAttributeString("nrAudioChannels", targetChannels.Value.ToString(CultureInfo.InvariantCulture));
  493. }
  494. if (targetSampleRate.HasValue)
  495. {
  496. writer.WriteAttributeString("sampleFrequency", targetSampleRate.Value.ToString(CultureInfo.InvariantCulture));
  497. }
  498. if (targetAudioBitrate.HasValue)
  499. {
  500. writer.WriteAttributeString("bitrate", targetAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
  501. }
  502. var mediaProfile = _profile.GetAudioMediaProfile(
  503. streamInfo.Container,
  504. streamInfo.TargetAudioCodec.FirstOrDefault(),
  505. targetChannels,
  506. targetAudioBitrate,
  507. targetSampleRate,
  508. targetAudioBitDepth);
  509. var filename = url.Substring(0, url.IndexOf('?', StringComparison.Ordinal));
  510. var mimeType = mediaProfile is null || string.IsNullOrEmpty(mediaProfile.MimeType)
  511. ? MimeTypes.GetMimeType(filename)
  512. : mediaProfile.MimeType;
  513. var contentFeatures = ContentFeatureBuilder.BuildAudioHeader(
  514. _profile,
  515. streamInfo.Container,
  516. streamInfo.TargetAudioCodec.FirstOrDefault(),
  517. targetAudioBitrate,
  518. targetSampleRate,
  519. targetChannels,
  520. targetAudioBitDepth,
  521. streamInfo.IsDirectStream,
  522. streamInfo.RunTimeTicks ?? 0,
  523. streamInfo.TranscodeSeekInfo);
  524. writer.WriteAttributeString(
  525. "protocolInfo",
  526. string.Format(
  527. CultureInfo.InvariantCulture,
  528. "http-get:*:{0}:{1}",
  529. mimeType,
  530. contentFeatures));
  531. writer.WriteString(url);
  532. writer.WriteFullEndElement();
  533. }
  534. public static bool IsIdRoot(string id)
  535. => string.IsNullOrWhiteSpace(id)
  536. || string.Equals(id, "0", StringComparison.OrdinalIgnoreCase)
  537. // Samsung sometimes uses 1 as root
  538. || string.Equals(id, "1", StringComparison.OrdinalIgnoreCase);
  539. public void WriteFolderElement(XmlWriter writer, BaseItem folder, StubType? stubType, BaseItem context, int childCount, Filter filter, string? requestedId = null)
  540. {
  541. writer.WriteStartElement(string.Empty, "container", NsDidl);
  542. writer.WriteAttributeString("restricted", "1");
  543. writer.WriteAttributeString("searchable", "1");
  544. writer.WriteAttributeString("childCount", childCount.ToString(CultureInfo.InvariantCulture));
  545. var clientId = GetClientId(folder, stubType);
  546. if (string.Equals(requestedId, "0", StringComparison.Ordinal))
  547. {
  548. writer.WriteAttributeString("id", "0");
  549. writer.WriteAttributeString("parentID", "-1");
  550. }
  551. else
  552. {
  553. writer.WriteAttributeString("id", clientId);
  554. if (context is not null)
  555. {
  556. writer.WriteAttributeString("parentID", GetClientId(context, null));
  557. }
  558. else
  559. {
  560. var parent = folder.DisplayParentId;
  561. if (parent.Equals(default))
  562. {
  563. writer.WriteAttributeString("parentID", "0");
  564. }
  565. else
  566. {
  567. writer.WriteAttributeString("parentID", GetClientId(parent, null));
  568. }
  569. }
  570. }
  571. AddGeneralProperties(folder, stubType, context, writer, filter);
  572. AddCover(folder, stubType, writer);
  573. writer.WriteFullEndElement();
  574. }
  575. private void AddSamsungBookmarkInfo(BaseItem item, User? user, XmlWriter writer, StreamInfo? streamInfo)
  576. {
  577. if (!item.SupportsPositionTicksResume || item is Folder)
  578. {
  579. return;
  580. }
  581. XmlAttribute? secAttribute = null;
  582. foreach (var attribute in _profile.XmlRootAttributes)
  583. {
  584. if (string.Equals(attribute.Name, "xmlns:sec", StringComparison.OrdinalIgnoreCase))
  585. {
  586. secAttribute = attribute;
  587. break;
  588. }
  589. }
  590. // Not a samsung device or no user data
  591. if (secAttribute is null || user is null)
  592. {
  593. return;
  594. }
  595. var userdata = _userDataManager.GetUserData(user, item);
  596. var playbackPositionTicks = (streamInfo is not null && streamInfo.StartPositionTicks > 0) ? streamInfo.StartPositionTicks : userdata.PlaybackPositionTicks;
  597. if (playbackPositionTicks > 0)
  598. {
  599. var elementValue = string.Format(
  600. CultureInfo.InvariantCulture,
  601. "BM={0}",
  602. Convert.ToInt32(TimeSpan.FromTicks(playbackPositionTicks).TotalSeconds));
  603. AddValue(writer, "sec", "dcmInfo", elementValue, secAttribute.Value);
  604. }
  605. }
  606. /// <summary>
  607. /// Adds fields used by both items and folders.
  608. /// </summary>
  609. private void AddCommonFields(BaseItem item, StubType? itemStubType, BaseItem? context, XmlWriter writer, Filter filter)
  610. {
  611. // Don't filter on dc:title because not all devices will include it in the filter
  612. // MediaMonkey for example won't display content without a title
  613. // if (filter.Contains("dc:title"))
  614. {
  615. AddValue(writer, "dc", "title", GetDisplayName(item, itemStubType, context), NsDc);
  616. }
  617. WriteObjectClass(writer, item, itemStubType);
  618. if (filter.Contains("dc:date"))
  619. {
  620. if (item.PremiereDate.HasValue)
  621. {
  622. AddValue(writer, "dc", "date", item.PremiereDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), NsDc);
  623. }
  624. }
  625. if (filter.Contains("upnp:genre"))
  626. {
  627. foreach (var genre in item.Genres)
  628. {
  629. AddValue(writer, "upnp", "genre", genre, NsUpnp);
  630. }
  631. }
  632. foreach (var studio in item.Studios)
  633. {
  634. AddValue(writer, "upnp", "publisher", studio, NsUpnp);
  635. }
  636. if (item is not Folder)
  637. {
  638. if (filter.Contains("dc:description"))
  639. {
  640. var desc = item.Overview;
  641. if (!string.IsNullOrWhiteSpace(desc))
  642. {
  643. AddValue(writer, "dc", "description", desc, NsDc);
  644. }
  645. }
  646. // if (filter.Contains("upnp:longDescription"))
  647. // {
  648. // if (!string.IsNullOrWhiteSpace(item.Overview))
  649. // {
  650. // AddValue(writer, "upnp", "longDescription", item.Overview, NsUpnp);
  651. // }
  652. // }
  653. }
  654. if (!string.IsNullOrEmpty(item.OfficialRating))
  655. {
  656. if (filter.Contains("dc:rating"))
  657. {
  658. AddValue(writer, "dc", "rating", item.OfficialRating, NsDc);
  659. }
  660. if (filter.Contains("upnp:rating"))
  661. {
  662. AddValue(writer, "upnp", "rating", item.OfficialRating, NsUpnp);
  663. }
  664. }
  665. AddPeople(item, writer);
  666. }
  667. private void WriteObjectClass(XmlWriter writer, BaseItem item, StubType? stubType)
  668. {
  669. // More types here
  670. // http://oss.linn.co.uk/repos/Public/LibUpnpCil/DidlLite/UpnpAv/Test/TestDidlLite.cs
  671. writer.WriteStartElement("upnp", "class", NsUpnp);
  672. if (item.IsDisplayedAsFolder || stubType.HasValue)
  673. {
  674. string? classType = null;
  675. if (!_profile.RequiresPlainFolders)
  676. {
  677. if (item is MusicAlbum)
  678. {
  679. classType = "object.container.album.musicAlbum";
  680. }
  681. else if (item is MusicArtist)
  682. {
  683. classType = "object.container.person.musicArtist";
  684. }
  685. else if (item is Series || item is Season || item is BoxSet || item is Video)
  686. {
  687. classType = "object.container.album.videoAlbum";
  688. }
  689. else if (item is Playlist)
  690. {
  691. classType = "object.container.playlistContainer";
  692. }
  693. else if (item is PhotoAlbum)
  694. {
  695. classType = "object.container.album.photoAlbum";
  696. }
  697. }
  698. writer.WriteString(classType ?? "object.container.storageFolder");
  699. }
  700. else if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
  701. {
  702. writer.WriteString("object.item.audioItem.musicTrack");
  703. }
  704. else if (string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase))
  705. {
  706. writer.WriteString("object.item.imageItem.photo");
  707. }
  708. else if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
  709. {
  710. if (!_profile.RequiresPlainVideoItems && item is Movie)
  711. {
  712. writer.WriteString("object.item.videoItem.movie");
  713. }
  714. else if (!_profile.RequiresPlainVideoItems && item is MusicVideo)
  715. {
  716. writer.WriteString("object.item.videoItem.musicVideoClip");
  717. }
  718. else
  719. {
  720. writer.WriteString("object.item.videoItem");
  721. }
  722. }
  723. else if (item is MusicGenre)
  724. {
  725. writer.WriteString(_profile.RequiresPlainFolders ? "object.container.storageFolder" : "object.container.genre.musicGenre");
  726. }
  727. else if (item is Genre)
  728. {
  729. writer.WriteString(_profile.RequiresPlainFolders ? "object.container.storageFolder" : "object.container.genre");
  730. }
  731. else
  732. {
  733. writer.WriteString("object.item");
  734. }
  735. writer.WriteFullEndElement();
  736. }
  737. private void AddPeople(BaseItem item, XmlWriter writer)
  738. {
  739. if (!item.SupportsPeople)
  740. {
  741. return;
  742. }
  743. var types = new[]
  744. {
  745. PersonKind.Director,
  746. PersonKind.Writer,
  747. PersonKind.Producer,
  748. PersonKind.Composer,
  749. PersonKind.Creator
  750. };
  751. // Seeing some LG models locking up due content with large lists of people
  752. // The actual issue might just be due to processing a more metadata than it can handle
  753. var people = _libraryManager.GetPeople(
  754. new InternalPeopleQuery
  755. {
  756. ItemId = item.Id,
  757. Limit = 6
  758. });
  759. foreach (var actor in people)
  760. {
  761. var type = types.FirstOrDefault(i => i == actor.Type || string.Equals(actor.Role, i.ToString(), StringComparison.OrdinalIgnoreCase));
  762. if (type == PersonKind.Unknown)
  763. {
  764. type = PersonKind.Actor;
  765. }
  766. AddValue(writer, "upnp", type.ToString().ToLowerInvariant(), actor.Name, NsUpnp);
  767. }
  768. }
  769. private void AddGeneralProperties(BaseItem item, StubType? itemStubType, BaseItem? context, XmlWriter writer, Filter filter)
  770. {
  771. AddCommonFields(item, itemStubType, context, writer, filter);
  772. var hasAlbumArtists = item as IHasAlbumArtist;
  773. if (item is IHasArtist hasArtists)
  774. {
  775. foreach (var artist in hasArtists.Artists)
  776. {
  777. AddValue(writer, "upnp", "artist", artist, NsUpnp);
  778. AddValue(writer, "dc", "creator", artist, NsDc);
  779. // If it doesn't support album artists (musicvideo), then tag as both
  780. if (hasAlbumArtists is null)
  781. {
  782. AddAlbumArtist(writer, artist);
  783. }
  784. }
  785. }
  786. if (hasAlbumArtists is not null)
  787. {
  788. foreach (var albumArtist in hasAlbumArtists.AlbumArtists)
  789. {
  790. AddAlbumArtist(writer, albumArtist);
  791. }
  792. }
  793. if (!string.IsNullOrWhiteSpace(item.Album))
  794. {
  795. AddValue(writer, "upnp", "album", item.Album, NsUpnp);
  796. }
  797. if (item.IndexNumber.HasValue)
  798. {
  799. AddValue(writer, "upnp", "originalTrackNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp);
  800. if (item is Episode)
  801. {
  802. AddValue(writer, "upnp", "episodeNumber", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture), NsUpnp);
  803. }
  804. }
  805. }
  806. private void AddAlbumArtist(XmlWriter writer, string name)
  807. {
  808. try
  809. {
  810. writer.WriteStartElement("upnp", "artist", NsUpnp);
  811. writer.WriteAttributeString("role", "AlbumArtist");
  812. writer.WriteString(name);
  813. writer.WriteFullEndElement();
  814. }
  815. catch (XmlException ex)
  816. {
  817. _logger.LogError(ex, "Error adding xml value: {Value}", name);
  818. }
  819. }
  820. private void AddValue(XmlWriter writer, string prefix, string name, string value, string namespaceUri)
  821. {
  822. try
  823. {
  824. writer.WriteElementString(prefix, name, namespaceUri, value);
  825. }
  826. catch (XmlException ex)
  827. {
  828. _logger.LogError(ex, "Error adding xml value: {Value}", value);
  829. }
  830. }
  831. private void AddCover(BaseItem item, StubType? stubType, XmlWriter writer)
  832. {
  833. ImageDownloadInfo? imageInfo = GetImageInfo(item);
  834. if (imageInfo is null)
  835. {
  836. return;
  837. }
  838. // TODO: Remove these default values
  839. var albumArtUrlInfo = GetImageUrl(
  840. imageInfo,
  841. _profile.MaxAlbumArtWidth ?? 10000,
  842. _profile.MaxAlbumArtHeight ?? 10000,
  843. "jpg");
  844. writer.WriteStartElement("upnp", "albumArtURI", NsUpnp);
  845. if (!string.IsNullOrEmpty(_profile.AlbumArtPn))
  846. {
  847. writer.WriteAttributeString("dlna", "profileID", NsDlna, _profile.AlbumArtPn);
  848. }
  849. writer.WriteString(albumArtUrlInfo.Url);
  850. writer.WriteFullEndElement();
  851. // TODO: Remove these default values
  852. var iconUrlInfo = GetImageUrl(
  853. imageInfo,
  854. _profile.MaxIconWidth ?? 48,
  855. _profile.MaxIconHeight ?? 48,
  856. "jpg");
  857. writer.WriteElementString("upnp", "icon", NsUpnp, iconUrlInfo.Url);
  858. if (!_profile.EnableAlbumArtInDidl)
  859. {
  860. if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
  861. || string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
  862. {
  863. if (!stubType.HasValue)
  864. {
  865. return;
  866. }
  867. }
  868. }
  869. if (!_profile.EnableSingleAlbumArtLimit || string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase))
  870. {
  871. AddImageResElement(item, writer, 4096, 4096, "jpg", "JPEG_LRG");
  872. AddImageResElement(item, writer, 1024, 768, "jpg", "JPEG_MED");
  873. AddImageResElement(item, writer, 640, 480, "jpg", "JPEG_SM");
  874. AddImageResElement(item, writer, 4096, 4096, "png", "PNG_LRG");
  875. AddImageResElement(item, writer, 160, 160, "png", "PNG_TN");
  876. }
  877. AddImageResElement(item, writer, 160, 160, "jpg", "JPEG_TN");
  878. }
  879. private void AddImageResElement(
  880. BaseItem item,
  881. XmlWriter writer,
  882. int maxWidth,
  883. int maxHeight,
  884. string format,
  885. string org_Pn)
  886. {
  887. var imageInfo = GetImageInfo(item);
  888. if (imageInfo is null)
  889. {
  890. return;
  891. }
  892. var albumartUrlInfo = GetImageUrl(imageInfo, maxWidth, maxHeight, format);
  893. writer.WriteStartElement(string.Empty, "res", NsDidl);
  894. // Images must have a reported size or many clients (Bubble upnp), will only use the first thumbnail
  895. // rather than using a larger one when available
  896. var width = albumartUrlInfo.Width ?? maxWidth;
  897. var height = albumartUrlInfo.Height ?? maxHeight;
  898. var contentFeatures = ContentFeatureBuilder.BuildImageHeader(_profile, format, width, height, imageInfo.IsDirectStream, org_Pn);
  899. writer.WriteAttributeString(
  900. "protocolInfo",
  901. string.Format(
  902. CultureInfo.InvariantCulture,
  903. "http-get:*:{0}:{1}",
  904. MimeTypes.GetMimeType("file." + format),
  905. contentFeatures));
  906. writer.WriteAttributeString(
  907. "resolution",
  908. string.Format(CultureInfo.InvariantCulture, "{0}x{1}", width, height));
  909. writer.WriteString(albumartUrlInfo.Url);
  910. writer.WriteFullEndElement();
  911. }
  912. private ImageDownloadInfo? GetImageInfo(BaseItem item)
  913. {
  914. if (item.HasImage(ImageType.Primary))
  915. {
  916. return GetImageInfo(item, ImageType.Primary);
  917. }
  918. if (item.HasImage(ImageType.Thumb))
  919. {
  920. return GetImageInfo(item, ImageType.Thumb);
  921. }
  922. if (item.HasImage(ImageType.Backdrop))
  923. {
  924. if (item is Channel)
  925. {
  926. return GetImageInfo(item, ImageType.Backdrop);
  927. }
  928. }
  929. // For audio tracks without art use album art if available.
  930. if (item is Audio audioItem)
  931. {
  932. var album = audioItem.AlbumEntity;
  933. return album is not null && album.HasImage(ImageType.Primary)
  934. ? GetImageInfo(album, ImageType.Primary)
  935. : null;
  936. }
  937. // Don't look beyond album/playlist level. Metadata service may assign an image from a different album/show to the parent folder.
  938. if (item is MusicAlbum || item is Playlist)
  939. {
  940. return null;
  941. }
  942. // For other item types check parents, but be aware that image retrieved from a parent may be not suitable for this media item.
  943. var parentWithImage = GetFirstParentWithImageBelowUserRoot(item);
  944. if (parentWithImage is not null)
  945. {
  946. return GetImageInfo(parentWithImage, ImageType.Primary);
  947. }
  948. return null;
  949. }
  950. private BaseItem? GetFirstParentWithImageBelowUserRoot(BaseItem item)
  951. {
  952. if (item is null)
  953. {
  954. return null;
  955. }
  956. if (item.HasImage(ImageType.Primary))
  957. {
  958. return item;
  959. }
  960. var parent = item.GetParent();
  961. if (parent is UserRootFolder)
  962. {
  963. return null;
  964. }
  965. // terminate in case we went past user root folder (unlikely?)
  966. if (parent is Folder folder && folder.IsRoot)
  967. {
  968. return null;
  969. }
  970. return GetFirstParentWithImageBelowUserRoot(parent);
  971. }
  972. private ImageDownloadInfo GetImageInfo(BaseItem item, ImageType type)
  973. {
  974. var imageInfo = item.GetImageInfo(type, 0);
  975. string? tag = null;
  976. try
  977. {
  978. tag = _imageProcessor.GetImageCacheTag(item, type);
  979. }
  980. catch (Exception ex)
  981. {
  982. _logger.LogError(ex, "Error getting image cache tag");
  983. }
  984. int? width = imageInfo.Width;
  985. int? height = imageInfo.Height;
  986. if (width == 0 || height == 0)
  987. {
  988. width = null;
  989. height = null;
  990. }
  991. else if (width == -1 || height == -1)
  992. {
  993. width = null;
  994. height = null;
  995. }
  996. var inputFormat = (Path.GetExtension(imageInfo.Path) ?? string.Empty)
  997. .TrimStart('.')
  998. .Replace("jpeg", "jpg", StringComparison.OrdinalIgnoreCase);
  999. return new ImageDownloadInfo
  1000. {
  1001. ItemId = item.Id,
  1002. Type = type,
  1003. ImageTag = tag,
  1004. Width = width,
  1005. Height = height,
  1006. Format = inputFormat,
  1007. ItemImageInfo = imageInfo
  1008. };
  1009. }
  1010. public static string GetClientId(BaseItem item, StubType? stubType)
  1011. {
  1012. return GetClientId(item.Id, stubType);
  1013. }
  1014. public static string GetClientId(Guid idValue, StubType? stubType)
  1015. {
  1016. var id = idValue.ToString("N", CultureInfo.InvariantCulture);
  1017. if (stubType.HasValue)
  1018. {
  1019. id = stubType.Value.ToString().ToLowerInvariant() + "_" + id;
  1020. }
  1021. return id;
  1022. }
  1023. private (string Url, int? Width, int? Height) GetImageUrl(ImageDownloadInfo info, int maxWidth, int maxHeight, string format)
  1024. {
  1025. var url = string.Format(
  1026. CultureInfo.InvariantCulture,
  1027. "{0}/Items/{1}/Images/{2}/0/{3}/{4}/{5}/{6}/0/0",
  1028. _serverAddress,
  1029. info.ItemId.ToString("N", CultureInfo.InvariantCulture),
  1030. info.Type,
  1031. info.ImageTag,
  1032. format,
  1033. maxWidth.ToString(CultureInfo.InvariantCulture),
  1034. maxHeight.ToString(CultureInfo.InvariantCulture));
  1035. var width = info.Width;
  1036. var height = info.Height;
  1037. info.IsDirectStream = false;
  1038. if (width.HasValue && height.HasValue)
  1039. {
  1040. var newSize = DrawingUtils.Resize(new ImageDimensions(width.Value, height.Value), 0, 0, maxWidth, maxHeight);
  1041. width = newSize.Width;
  1042. height = newSize.Height;
  1043. var normalizedFormat = format
  1044. .Replace("jpeg", "jpg", StringComparison.OrdinalIgnoreCase);
  1045. if (string.Equals(info.Format, normalizedFormat, StringComparison.OrdinalIgnoreCase))
  1046. {
  1047. info.IsDirectStream = maxWidth >= width.Value && maxHeight >= height.Value;
  1048. }
  1049. }
  1050. // just lie
  1051. info.IsDirectStream = true;
  1052. return (url, width, height);
  1053. }
  1054. private class ImageDownloadInfo
  1055. {
  1056. internal Guid ItemId { get; set; }
  1057. internal string? ImageTag { get; set; }
  1058. internal ImageType Type { get; set; }
  1059. internal int? Width { get; set; }
  1060. internal int? Height { get; set; }
  1061. internal bool IsDirectStream { get; set; }
  1062. internal required string Format { get; set; }
  1063. internal required ItemImageInfo ItemImageInfo { get; set; }
  1064. }
  1065. }
  1066. }