ActivityLogEntryPoint.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Globalization;
  4. using System.Linq;
  5. using System.Text;
  6. using System.Threading.Tasks;
  7. using Jellyfin.Data.Entities;
  8. using MediaBrowser.Common.Plugins;
  9. using MediaBrowser.Common.Updates;
  10. using MediaBrowser.Controller.Authentication;
  11. using MediaBrowser.Controller.Devices;
  12. using MediaBrowser.Controller.Library;
  13. using MediaBrowser.Controller.Plugins;
  14. using MediaBrowser.Controller.Session;
  15. using MediaBrowser.Controller.Subtitles;
  16. using MediaBrowser.Model.Activity;
  17. using MediaBrowser.Model.Dto;
  18. using MediaBrowser.Model.Entities;
  19. using MediaBrowser.Model.Events;
  20. using MediaBrowser.Model.Globalization;
  21. using MediaBrowser.Model.Notifications;
  22. using MediaBrowser.Model.Tasks;
  23. using MediaBrowser.Model.Updates;
  24. using Microsoft.Extensions.Logging;
  25. namespace Emby.Server.Implementations.Activity
  26. {
  27. /// <summary>
  28. /// Entry point for the activity logger.
  29. /// </summary>
  30. public sealed class ActivityLogEntryPoint : IServerEntryPoint
  31. {
  32. private readonly ILogger _logger;
  33. private readonly IInstallationManager _installationManager;
  34. private readonly ISessionManager _sessionManager;
  35. private readonly ITaskManager _taskManager;
  36. private readonly IActivityManager _activityManager;
  37. private readonly ILocalizationManager _localization;
  38. private readonly ISubtitleManager _subManager;
  39. private readonly IUserManager _userManager;
  40. private readonly IDeviceManager _deviceManager;
  41. /// <summary>
  42. /// Initializes a new instance of the <see cref="ActivityLogEntryPoint"/> class.
  43. /// </summary>
  44. /// <param name="logger">The logger.</param>
  45. /// <param name="sessionManager">The session manager.</param>
  46. /// <param name="deviceManager">The device manager.</param>
  47. /// <param name="taskManager">The task manager.</param>
  48. /// <param name="activityManager">The activity manager.</param>
  49. /// <param name="localization">The localization manager.</param>
  50. /// <param name="installationManager">The installation manager.</param>
  51. /// <param name="subManager">The subtitle manager.</param>
  52. /// <param name="userManager">The user manager.</param>
  53. public ActivityLogEntryPoint(
  54. ILogger<ActivityLogEntryPoint> logger,
  55. ISessionManager sessionManager,
  56. IDeviceManager deviceManager,
  57. ITaskManager taskManager,
  58. IActivityManager activityManager,
  59. ILocalizationManager localization,
  60. IInstallationManager installationManager,
  61. ISubtitleManager subManager,
  62. IUserManager userManager)
  63. {
  64. _logger = logger;
  65. _sessionManager = sessionManager;
  66. _deviceManager = deviceManager;
  67. _taskManager = taskManager;
  68. _activityManager = activityManager;
  69. _localization = localization;
  70. _installationManager = installationManager;
  71. _subManager = subManager;
  72. _userManager = userManager;
  73. }
  74. /// <inheritdoc />
  75. public Task RunAsync()
  76. {
  77. _taskManager.TaskCompleted += OnTaskCompleted;
  78. _installationManager.PluginInstalled += OnPluginInstalled;
  79. _installationManager.PluginUninstalled += OnPluginUninstalled;
  80. _installationManager.PluginUpdated += OnPluginUpdated;
  81. _installationManager.PackageInstallationFailed += OnPackageInstallationFailed;
  82. _sessionManager.SessionStarted += OnSessionStarted;
  83. _sessionManager.AuthenticationFailed += OnAuthenticationFailed;
  84. _sessionManager.AuthenticationSucceeded += OnAuthenticationSucceeded;
  85. _sessionManager.SessionEnded += OnSessionEnded;
  86. _sessionManager.PlaybackStart += OnPlaybackStart;
  87. _sessionManager.PlaybackStopped += OnPlaybackStopped;
  88. _subManager.SubtitleDownloadFailure += OnSubtitleDownloadFailure;
  89. _userManager.UserCreated += OnUserCreated;
  90. _userManager.UserPasswordChanged += OnUserPasswordChanged;
  91. _userManager.UserDeleted += OnUserDeleted;
  92. _userManager.UserPolicyUpdated += OnUserPolicyUpdated;
  93. _userManager.UserLockedOut += OnUserLockedOut;
  94. _deviceManager.CameraImageUploaded += OnCameraImageUploaded;
  95. return Task.CompletedTask;
  96. }
  97. private async void OnCameraImageUploaded(object sender, GenericEventArgs<CameraImageUploadInfo> e)
  98. {
  99. await CreateLogEntry(new ActivityLog(
  100. string.Format(
  101. CultureInfo.InvariantCulture,
  102. _localization.GetLocalizedString("CameraImageUploadedFrom"),
  103. e.Argument.Device.Name),
  104. NotificationType.CameraImageUploaded.ToString(),
  105. Guid.Empty,
  106. DateTime.UtcNow,
  107. LogLevel.Trace))
  108. .ConfigureAwait(false);
  109. }
  110. private async void OnUserLockedOut(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
  111. {
  112. await CreateLogEntry(new ActivityLog(
  113. string.Format(
  114. CultureInfo.InvariantCulture,
  115. _localization.GetLocalizedString("UserLockedOutWithName"),
  116. e.Argument.Name),
  117. NotificationType.UserLockedOut.ToString(),
  118. e.Argument.Id,
  119. DateTime.UtcNow,
  120. LogLevel.Trace))
  121. .ConfigureAwait(false);
  122. }
  123. private async void OnSubtitleDownloadFailure(object sender, SubtitleDownloadFailureEventArgs e)
  124. {
  125. await CreateLogEntry(new ActivityLog(
  126. string.Format(
  127. CultureInfo.InvariantCulture,
  128. _localization.GetLocalizedString("SubtitleDownloadFailureFromForItem"),
  129. e.Provider,
  130. Emby.Notifications.NotificationEntryPoint.GetItemName(e.Item)),
  131. "SubtitleDownloadFailure",
  132. Guid.Empty,
  133. DateTime.UtcNow,
  134. LogLevel.Trace)
  135. {
  136. ItemId = e.Item.Id.ToString("N", CultureInfo.InvariantCulture),
  137. ShortOverview = e.Exception.Message
  138. }).ConfigureAwait(false);
  139. }
  140. private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e)
  141. {
  142. var item = e.MediaInfo;
  143. if (item == null)
  144. {
  145. _logger.LogWarning("PlaybackStopped reported with null media info.");
  146. return;
  147. }
  148. if (e.Item != null && e.Item.IsThemeMedia)
  149. {
  150. // Don't report theme song or local trailer playback
  151. return;
  152. }
  153. if (e.Users.Count == 0)
  154. {
  155. return;
  156. }
  157. var user = e.Users[0];
  158. await CreateLogEntry(new ActivityLog(
  159. string.Format(
  160. CultureInfo.InvariantCulture,
  161. _localization.GetLocalizedString("UserStoppedPlayingItemWithValues"),
  162. user.Name,
  163. GetItemName(item),
  164. e.DeviceName),
  165. GetPlaybackStoppedNotificationType(item.MediaType),
  166. user.Id,
  167. DateTime.UtcNow,
  168. LogLevel.Trace))
  169. .ConfigureAwait(false);
  170. }
  171. private async void OnPlaybackStart(object sender, PlaybackProgressEventArgs e)
  172. {
  173. var item = e.MediaInfo;
  174. if (item == null)
  175. {
  176. _logger.LogWarning("PlaybackStart reported with null media info.");
  177. return;
  178. }
  179. if (e.Item != null && e.Item.IsThemeMedia)
  180. {
  181. // Don't report theme song or local trailer playback
  182. return;
  183. }
  184. if (e.Users.Count == 0)
  185. {
  186. return;
  187. }
  188. var user = e.Users.First();
  189. await CreateLogEntry(new ActivityLog(
  190. string.Format(
  191. CultureInfo.InvariantCulture,
  192. _localization.GetLocalizedString("UserStartedPlayingItemWithValues"),
  193. user.Name,
  194. GetItemName(item),
  195. e.DeviceName),
  196. GetPlaybackNotificationType(item.MediaType),
  197. user.Id,
  198. DateTime.UtcNow,
  199. LogLevel.Trace))
  200. .ConfigureAwait(false);
  201. }
  202. private static string GetItemName(BaseItemDto item)
  203. {
  204. var name = item.Name;
  205. if (!string.IsNullOrEmpty(item.SeriesName))
  206. {
  207. name = item.SeriesName + " - " + name;
  208. }
  209. if (item.Artists != null && item.Artists.Count > 0)
  210. {
  211. name = item.Artists[0] + " - " + name;
  212. }
  213. return name;
  214. }
  215. private static string GetPlaybackNotificationType(string mediaType)
  216. {
  217. if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
  218. {
  219. return NotificationType.AudioPlayback.ToString();
  220. }
  221. if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
  222. {
  223. return NotificationType.VideoPlayback.ToString();
  224. }
  225. return null;
  226. }
  227. private static string GetPlaybackStoppedNotificationType(string mediaType)
  228. {
  229. if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
  230. {
  231. return NotificationType.AudioPlaybackStopped.ToString();
  232. }
  233. if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
  234. {
  235. return NotificationType.VideoPlaybackStopped.ToString();
  236. }
  237. return null;
  238. }
  239. private async void OnSessionEnded(object sender, SessionEventArgs e)
  240. {
  241. var session = e.SessionInfo;
  242. if (string.IsNullOrEmpty(session.UserName))
  243. {
  244. return;
  245. }
  246. await CreateLogEntry(new ActivityLog(
  247. string.Format(
  248. CultureInfo.InvariantCulture,
  249. _localization.GetLocalizedString("UserOfflineFromDevice"),
  250. session.UserName,
  251. session.DeviceName),
  252. "SessionEnded",
  253. session.UserId,
  254. DateTime.UtcNow,
  255. LogLevel.Trace)
  256. {
  257. ShortOverview = string.Format(
  258. CultureInfo.InvariantCulture,
  259. _localization.GetLocalizedString("LabelIpAddressValue"),
  260. session.RemoteEndPoint),
  261. }).ConfigureAwait(false);
  262. }
  263. private async void OnAuthenticationSucceeded(object sender, GenericEventArgs<AuthenticationResult> e)
  264. {
  265. var user = e.Argument.User;
  266. await CreateLogEntry(new ActivityLog(
  267. string.Format(
  268. CultureInfo.InvariantCulture,
  269. _localization.GetLocalizedString("AuthenticationSucceededWithUserName"),
  270. user.Name),
  271. "AuthenticationSucceeded",
  272. user.Id,
  273. DateTime.UtcNow,
  274. LogLevel.Trace)
  275. {
  276. ShortOverview = string.Format(
  277. CultureInfo.InvariantCulture,
  278. _localization.GetLocalizedString("LabelIpAddressValue"),
  279. e.Argument.SessionInfo.RemoteEndPoint),
  280. }).ConfigureAwait(false);
  281. }
  282. private async void OnAuthenticationFailed(object sender, GenericEventArgs<AuthenticationRequest> e)
  283. {
  284. await CreateLogEntry(new ActivityLog(
  285. string.Format(
  286. CultureInfo.InvariantCulture,
  287. _localization.GetLocalizedString("FailedLoginAttemptWithUserName"),
  288. e.Argument.Username),
  289. "AuthenticationFailed",
  290. Guid.Empty,
  291. DateTime.UtcNow,
  292. LogLevel.Error)
  293. {
  294. ShortOverview = string.Format(
  295. CultureInfo.InvariantCulture,
  296. _localization.GetLocalizedString("LabelIpAddressValue"),
  297. e.Argument.RemoteEndPoint),
  298. }).ConfigureAwait(false);
  299. }
  300. private async void OnUserPolicyUpdated(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
  301. {
  302. await CreateLogEntry(new ActivityLog(
  303. string.Format(
  304. CultureInfo.InvariantCulture,
  305. _localization.GetLocalizedString("UserPolicyUpdatedWithName"),
  306. e.Argument.Name),
  307. "UserPolicyUpdated",
  308. e.Argument.Id,
  309. DateTime.UtcNow,
  310. LogLevel.Trace))
  311. .ConfigureAwait(false);
  312. }
  313. private async void OnUserDeleted(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
  314. {
  315. await CreateLogEntry(new ActivityLog(
  316. string.Format(
  317. CultureInfo.InvariantCulture,
  318. _localization.GetLocalizedString("UserDeletedWithName"),
  319. e.Argument.Name),
  320. "UserDeleted",
  321. Guid.Empty,
  322. DateTime.UtcNow,
  323. LogLevel.Trace))
  324. .ConfigureAwait(false);
  325. }
  326. private async void OnUserPasswordChanged(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
  327. {
  328. await CreateLogEntry(new ActivityLog(
  329. string.Format(
  330. CultureInfo.InvariantCulture,
  331. _localization.GetLocalizedString("UserPasswordChangedWithName"),
  332. e.Argument.Name),
  333. "UserPasswordChanged",
  334. e.Argument.Id,
  335. DateTime.UtcNow,
  336. LogLevel.Trace)).ConfigureAwait(false);
  337. }
  338. private async void OnUserCreated(object sender, GenericEventArgs<MediaBrowser.Controller.Entities.User> e)
  339. {
  340. await CreateLogEntry(new ActivityLog(
  341. string.Format(
  342. CultureInfo.InvariantCulture,
  343. _localization.GetLocalizedString("UserCreatedWithName"),
  344. e.Argument.Name),
  345. "UserCreated",
  346. e.Argument.Id,
  347. DateTime.UtcNow,
  348. LogLevel.Trace))
  349. .ConfigureAwait(false);
  350. }
  351. private async void OnSessionStarted(object sender, SessionEventArgs e)
  352. {
  353. var session = e.SessionInfo;
  354. if (string.IsNullOrEmpty(session.UserName))
  355. {
  356. return;
  357. }
  358. await CreateLogEntry(new ActivityLog(
  359. string.Format(
  360. CultureInfo.InvariantCulture,
  361. _localization.GetLocalizedString("UserOnlineFromDevice"),
  362. session.UserName,
  363. session.DeviceName),
  364. "SessionStarted",
  365. session.UserId,
  366. DateTime.UtcNow,
  367. LogLevel.Trace)
  368. {
  369. ShortOverview = string.Format(
  370. CultureInfo.InvariantCulture,
  371. _localization.GetLocalizedString("LabelIpAddressValue"),
  372. session.RemoteEndPoint)
  373. }).ConfigureAwait(false);
  374. }
  375. private async void OnPluginUpdated(object sender, GenericEventArgs<(IPlugin, VersionInfo)> e)
  376. {
  377. await CreateLogEntry(new ActivityLog(
  378. string.Format(
  379. CultureInfo.InvariantCulture,
  380. _localization.GetLocalizedString("PluginUpdatedWithName"),
  381. e.Argument.Item1.Name),
  382. NotificationType.PluginUpdateInstalled.ToString(),
  383. Guid.Empty,
  384. DateTime.UtcNow,
  385. LogLevel.Trace)
  386. {
  387. ShortOverview = string.Format(
  388. CultureInfo.InvariantCulture,
  389. _localization.GetLocalizedString("VersionNumber"),
  390. e.Argument.Item2.version),
  391. Overview = e.Argument.Item2.changelog
  392. }).ConfigureAwait(false);
  393. }
  394. private async void OnPluginUninstalled(object sender, GenericEventArgs<IPlugin> e)
  395. {
  396. await CreateLogEntry(new ActivityLog(
  397. string.Format(
  398. CultureInfo.InvariantCulture,
  399. _localization.GetLocalizedString("PluginUninstalledWithName"),
  400. e.Argument.Name),
  401. NotificationType.PluginUninstalled.ToString(),
  402. Guid.Empty,
  403. DateTime.UtcNow,
  404. LogLevel.Trace))
  405. .ConfigureAwait(false);
  406. }
  407. private async void OnPluginInstalled(object sender, GenericEventArgs<VersionInfo> e)
  408. {
  409. await CreateLogEntry(new ActivityLog(
  410. string.Format(
  411. CultureInfo.InvariantCulture,
  412. _localization.GetLocalizedString("PluginInstalledWithName"),
  413. e.Argument.name),
  414. NotificationType.PluginInstalled.ToString(),
  415. Guid.Empty,
  416. DateTime.UtcNow,
  417. LogLevel.Trace)
  418. {
  419. ShortOverview = string.Format(
  420. CultureInfo.InvariantCulture,
  421. _localization.GetLocalizedString("VersionNumber"),
  422. e.Argument.version)
  423. }).ConfigureAwait(false);
  424. }
  425. private async void OnPackageInstallationFailed(object sender, InstallationFailedEventArgs e)
  426. {
  427. var installationInfo = e.InstallationInfo;
  428. await CreateLogEntry(new ActivityLog(
  429. string.Format(
  430. CultureInfo.InvariantCulture,
  431. _localization.GetLocalizedString("NameInstallFailed"),
  432. installationInfo.Name),
  433. NotificationType.InstallationFailed.ToString(),
  434. Guid.Empty,
  435. DateTime.UtcNow,
  436. LogLevel.Trace)
  437. {
  438. ShortOverview = string.Format(
  439. CultureInfo.InvariantCulture,
  440. _localization.GetLocalizedString("VersionNumber"),
  441. installationInfo.Version),
  442. Overview = e.Exception.Message
  443. }).ConfigureAwait(false);
  444. }
  445. private async void OnTaskCompleted(object sender, TaskCompletionEventArgs e)
  446. {
  447. var result = e.Result;
  448. var task = e.Task;
  449. if (task.ScheduledTask is IConfigurableScheduledTask activityTask
  450. && !activityTask.IsLogged)
  451. {
  452. return;
  453. }
  454. var time = result.EndTimeUtc - result.StartTimeUtc;
  455. var runningTime = string.Format(
  456. CultureInfo.InvariantCulture,
  457. _localization.GetLocalizedString("LabelRunningTimeValue"),
  458. ToUserFriendlyString(time));
  459. if (result.Status == TaskCompletionStatus.Failed)
  460. {
  461. var vals = new List<string>();
  462. if (!string.IsNullOrEmpty(e.Result.ErrorMessage))
  463. {
  464. vals.Add(e.Result.ErrorMessage);
  465. }
  466. if (!string.IsNullOrEmpty(e.Result.LongErrorMessage))
  467. {
  468. vals.Add(e.Result.LongErrorMessage);
  469. }
  470. await CreateLogEntry(new ActivityLog(
  471. string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("ScheduledTaskFailedWithName"), task.Name),
  472. NotificationType.TaskFailed.ToString(),
  473. Guid.Empty,
  474. DateTime.UtcNow,
  475. LogLevel.Error)
  476. {
  477. Overview = string.Join(Environment.NewLine, vals),
  478. ShortOverview = runningTime
  479. }).ConfigureAwait(false);
  480. }
  481. }
  482. private async Task CreateLogEntry(ActivityLog entry)
  483. => await _activityManager.CreateAsync(entry).ConfigureAwait(false);
  484. /// <inheritdoc />
  485. public void Dispose()
  486. {
  487. _taskManager.TaskCompleted -= OnTaskCompleted;
  488. _installationManager.PluginInstalled -= OnPluginInstalled;
  489. _installationManager.PluginUninstalled -= OnPluginUninstalled;
  490. _installationManager.PluginUpdated -= OnPluginUpdated;
  491. _installationManager.PackageInstallationFailed -= OnPackageInstallationFailed;
  492. _sessionManager.SessionStarted -= OnSessionStarted;
  493. _sessionManager.AuthenticationFailed -= OnAuthenticationFailed;
  494. _sessionManager.AuthenticationSucceeded -= OnAuthenticationSucceeded;
  495. _sessionManager.SessionEnded -= OnSessionEnded;
  496. _sessionManager.PlaybackStart -= OnPlaybackStart;
  497. _sessionManager.PlaybackStopped -= OnPlaybackStopped;
  498. _subManager.SubtitleDownloadFailure -= OnSubtitleDownloadFailure;
  499. _userManager.UserCreated -= OnUserCreated;
  500. _userManager.UserPasswordChanged -= OnUserPasswordChanged;
  501. _userManager.UserDeleted -= OnUserDeleted;
  502. _userManager.UserPolicyUpdated -= OnUserPolicyUpdated;
  503. _userManager.UserLockedOut -= OnUserLockedOut;
  504. _deviceManager.CameraImageUploaded -= OnCameraImageUploaded;
  505. }
  506. /// <summary>
  507. /// Constructs a user-friendly string for this TimeSpan instance.
  508. /// </summary>
  509. private static string ToUserFriendlyString(TimeSpan span)
  510. {
  511. const int DaysInYear = 365;
  512. const int DaysInMonth = 30;
  513. // Get each non-zero value from TimeSpan component
  514. var values = new List<string>();
  515. // Number of years
  516. int days = span.Days;
  517. if (days >= DaysInYear)
  518. {
  519. int years = days / DaysInYear;
  520. values.Add(CreateValueString(years, "year"));
  521. days = days % DaysInYear;
  522. }
  523. // Number of months
  524. if (days >= DaysInMonth)
  525. {
  526. int months = days / DaysInMonth;
  527. values.Add(CreateValueString(months, "month"));
  528. days = days % DaysInMonth;
  529. }
  530. // Number of days
  531. if (days >= 1)
  532. {
  533. values.Add(CreateValueString(days, "day"));
  534. }
  535. // Number of hours
  536. if (span.Hours >= 1)
  537. {
  538. values.Add(CreateValueString(span.Hours, "hour"));
  539. }
  540. // Number of minutes
  541. if (span.Minutes >= 1)
  542. {
  543. values.Add(CreateValueString(span.Minutes, "minute"));
  544. }
  545. // Number of seconds (include when 0 if no other components included)
  546. if (span.Seconds >= 1 || values.Count == 0)
  547. {
  548. values.Add(CreateValueString(span.Seconds, "second"));
  549. }
  550. // Combine values into string
  551. var builder = new StringBuilder();
  552. for (int i = 0; i < values.Count; i++)
  553. {
  554. if (builder.Length > 0)
  555. {
  556. builder.Append(i == values.Count - 1 ? " and " : ", ");
  557. }
  558. builder.Append(values[i]);
  559. }
  560. // Return result
  561. return builder.ToString();
  562. }
  563. /// <summary>
  564. /// Constructs a string description of a time-span value.
  565. /// </summary>
  566. /// <param name="value">The value of this item.</param>
  567. /// <param name="description">The name of this item (singular form).</param>
  568. private static string CreateValueString(int value, string description)
  569. {
  570. return string.Format(
  571. CultureInfo.InvariantCulture,
  572. "{0:#,##0} {1}",
  573. value,
  574. value == 1 ? description : string.Format(CultureInfo.InvariantCulture, "{0}s", description));
  575. }
  576. }
  577. }