Device.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. using MediaBrowser.Common.Net;
  2. using MediaBrowser.Model.Logging;
  3. using System;
  4. using System.Collections.Generic;
  5. using System.Linq;
  6. using System.Threading;
  7. using System.Threading.Tasks;
  8. using System.Xml.Linq;
  9. namespace MediaBrowser.Dlna.PlayTo
  10. {
  11. public sealed class Device : IDisposable
  12. {
  13. const string ServiceAvtransportId = "urn:upnp-org:serviceId:AVTransport";
  14. const string ServiceRenderingId = "urn:upnp-org:serviceId:RenderingControl";
  15. #region Fields & Properties
  16. private Timer _timer;
  17. public DeviceProperties Properties { get; set; }
  18. private int _muteVol;
  19. public bool IsMuted
  20. {
  21. get
  22. {
  23. return _muteVol > 0;
  24. }
  25. }
  26. private string _currentId = String.Empty;
  27. public string CurrentId
  28. {
  29. get
  30. {
  31. return _currentId;
  32. }
  33. set
  34. {
  35. if (_currentId == value)
  36. return;
  37. _currentId = value;
  38. NotifyCurrentIdChanged(value);
  39. }
  40. }
  41. public int Volume { get; set; }
  42. public TimeSpan Duration { get; set; }
  43. private TimeSpan _position = TimeSpan.FromSeconds(0);
  44. public TimeSpan Position
  45. {
  46. get
  47. {
  48. return _position;
  49. }
  50. set
  51. {
  52. _position = value;
  53. }
  54. }
  55. private string _transportState = String.Empty;
  56. public string TransportState
  57. {
  58. get
  59. {
  60. return _transportState;
  61. }
  62. set
  63. {
  64. if (_transportState == value)
  65. return;
  66. _transportState = value;
  67. if (value == "PLAYING" || value == "STOPPED")
  68. NotifyPlaybackChanged(value == "STOPPED");
  69. }
  70. }
  71. public bool IsPlaying
  72. {
  73. get
  74. {
  75. return TransportState == "PLAYING";
  76. }
  77. }
  78. public bool IsTransitioning
  79. {
  80. get
  81. {
  82. return (TransportState == "TRANSITIONING");
  83. }
  84. }
  85. public bool IsPaused
  86. {
  87. get
  88. {
  89. return TransportState == "PAUSED" || TransportState == "PAUSED_PLAYBACK";
  90. }
  91. }
  92. public bool IsStopped
  93. {
  94. get
  95. {
  96. return (TransportState == "STOPPED");
  97. }
  98. }
  99. public DateTime UpdateTime { get; private set; }
  100. #endregion
  101. private readonly IHttpClient _httpClient;
  102. private readonly ILogger _logger;
  103. public Device(DeviceProperties deviceProperties, IHttpClient httpClient, ILogger logger)
  104. {
  105. Properties = deviceProperties;
  106. _httpClient = httpClient;
  107. _logger = logger;
  108. }
  109. private int GetTimerIntervalMs()
  110. {
  111. return 10000;
  112. }
  113. public void Start()
  114. {
  115. UpdateTime = DateTime.UtcNow;
  116. var interval = GetTimerIntervalMs();
  117. _timer = new Timer(TimerCallback, null, interval, interval);
  118. }
  119. private void RestartTimer()
  120. {
  121. var interval = GetTimerIntervalMs();
  122. _timer.Change(interval, interval);
  123. }
  124. private void StopTimer()
  125. {
  126. _timer.Change(Timeout.Infinite, Timeout.Infinite);
  127. }
  128. #region Commanding
  129. public Task<bool> VolumeDown(bool mute = false)
  130. {
  131. var sendVolume = (Volume - 5) > 0 ? Volume - 5 : 0;
  132. if (mute && _muteVol == 0)
  133. {
  134. sendVolume = 0;
  135. _muteVol = Volume;
  136. }
  137. return SetVolume(sendVolume);
  138. }
  139. public Task<bool> VolumeUp(bool unmute = false)
  140. {
  141. var sendVolume = (Volume + 5) < 100 ? Volume + 5 : 100;
  142. if (unmute && _muteVol > 0)
  143. sendVolume = _muteVol;
  144. _muteVol = 0;
  145. return SetVolume(sendVolume);
  146. }
  147. public Task ToggleMute()
  148. {
  149. if (_muteVol == 0)
  150. {
  151. _muteVol = Volume;
  152. return SetVolume(0);
  153. }
  154. var tmp = _muteVol;
  155. _muteVol = 0;
  156. return SetVolume(tmp);
  157. }
  158. public async Task<bool> SetVolume(int value)
  159. {
  160. var command = RendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetVolume");
  161. if (command == null)
  162. return true;
  163. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceRenderingId);
  164. if (service == null)
  165. {
  166. throw new InvalidOperationException("Unable to find service");
  167. }
  168. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, RendererCommands.BuildPost(command, service.ServiceType, value))
  169. .ConfigureAwait(false);
  170. Volume = value;
  171. return true;
  172. }
  173. public async Task<TimeSpan> Seek(TimeSpan value)
  174. {
  175. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "Seek");
  176. if (command == null)
  177. return value;
  178. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  179. if (service == null)
  180. {
  181. throw new InvalidOperationException("Unable to find service");
  182. }
  183. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, AvCommands.BuildPost(command, service.ServiceType, String.Format("{0:hh}:{0:mm}:{0:ss}", value), "REL_TIME"))
  184. .ConfigureAwait(false);
  185. return value;
  186. }
  187. public async Task<bool> SetAvTransport(string url, string header, string metaData)
  188. {
  189. StopTimer();
  190. TransportState = "STOPPED";
  191. CurrentId = "0";
  192. await Task.Delay(50).ConfigureAwait(false);
  193. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetAVTransportURI");
  194. if (command == null)
  195. return false;
  196. var dictionary = new Dictionary<string, string>
  197. {
  198. {"CurrentURI", url},
  199. {"CurrentURIMetaData", CreateDidlMeta(metaData)}
  200. };
  201. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  202. if (service == null)
  203. {
  204. throw new InvalidOperationException("Unable to find service");
  205. }
  206. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, AvCommands.BuildPost(command, service.ServiceType, url, dictionary), header)
  207. .ConfigureAwait(false);
  208. if (!IsPlaying)
  209. {
  210. await Task.Delay(50).ConfigureAwait(false);
  211. await SetPlay().ConfigureAwait(false);
  212. }
  213. _count = 5;
  214. RestartTimer();
  215. return true;
  216. }
  217. private string CreateDidlMeta(string value)
  218. {
  219. if (value == null)
  220. return String.Empty;
  221. var escapedData = value.Replace("<", "&lt;").Replace(">", "&gt;");
  222. return String.Format(BaseDidl, escapedData.Replace("\r\n", ""));
  223. }
  224. private const string BaseDidl = "&lt;DIDL-Lite xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" xmlns:dlna=\"urn:schemas-dlna-org:metadata-1-0/\"&gt;{0}&lt;/DIDL-Lite&gt;";
  225. public async Task<bool> SetNextAvTransport(string value, string header, string metaData)
  226. {
  227. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "SetNextAVTransportURI");
  228. if (command == null)
  229. return false;
  230. var dictionary = new Dictionary<string, string>
  231. {
  232. {"NextURI", value},
  233. {"NextURIMetaData", CreateDidlMeta(metaData)}
  234. };
  235. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  236. if (service == null)
  237. {
  238. throw new InvalidOperationException("Unable to find service");
  239. }
  240. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, AvCommands.BuildPost(command, service.ServiceType, value, dictionary), header)
  241. .ConfigureAwait(false);
  242. await Task.Delay(100).ConfigureAwait(false);
  243. return true;
  244. }
  245. public async Task<bool> SetPlay()
  246. {
  247. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "Play");
  248. if (command == null)
  249. return false;
  250. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  251. if (service == null)
  252. {
  253. throw new InvalidOperationException("Unable to find service");
  254. }
  255. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, RendererCommands.BuildPost(command, service.ServiceType, 1))
  256. .ConfigureAwait(false);
  257. _count = 5;
  258. return true;
  259. }
  260. public async Task<bool> SetStop()
  261. {
  262. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "Stop");
  263. if (command == null)
  264. return false;
  265. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  266. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, RendererCommands.BuildPost(command, service.ServiceType, 1))
  267. .ConfigureAwait(false);
  268. await Task.Delay(50).ConfigureAwait(false);
  269. _count = 4;
  270. return true;
  271. }
  272. public async Task<bool> SetPause()
  273. {
  274. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "Pause");
  275. if (command == null)
  276. return false;
  277. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  278. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, RendererCommands.BuildPost(command, service.ServiceType, 0))
  279. .ConfigureAwait(false);
  280. await Task.Delay(50).ConfigureAwait(false);
  281. TransportState = "PAUSED_PLAYBACK";
  282. return true;
  283. }
  284. #endregion
  285. #region Get data
  286. // TODO: What is going on here
  287. int _count = 5;
  288. private async void TimerCallback(object sender)
  289. {
  290. if (_disposed)
  291. return;
  292. StopTimer();
  293. try
  294. {
  295. var hasTrack = await GetPositionInfo().ConfigureAwait(false);
  296. // TODO: Why make these requests if hasTrack==false?
  297. if (_count > 5)
  298. {
  299. await GetTransportInfo().ConfigureAwait(false);
  300. if (!hasTrack)
  301. {
  302. await GetMediaInfo().ConfigureAwait(false);
  303. }
  304. await GetVolume().ConfigureAwait(false);
  305. _count = 0;
  306. }
  307. }
  308. catch (Exception ex)
  309. {
  310. _logger.ErrorException("Error updating device info", ex);
  311. }
  312. _count++;
  313. if (_disposed)
  314. return;
  315. RestartTimer();
  316. }
  317. private async Task GetVolume()
  318. {
  319. var command = RendererCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetVolume");
  320. if (command == null)
  321. return;
  322. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceRenderingId);
  323. if (service == null)
  324. {
  325. throw new InvalidOperationException("Unable to find service");
  326. }
  327. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, RendererCommands.BuildPost(command, service.ServiceType))
  328. .ConfigureAwait(false);
  329. if (result == null || result.Document == null)
  330. return;
  331. var volume = result.Document.Descendants(uPnpNamespaces.RenderingControl + "GetVolumeResponse").Select(i => i.Element("CurrentVolume")).FirstOrDefault(i => i != null);
  332. var volumeValue = volume == null ? null : volume.Value;
  333. if (volumeValue == null)
  334. return;
  335. Volume = Int32.Parse(volumeValue);
  336. //Reset the Mute value if Volume is bigger than zero
  337. if (Volume > 0 && _muteVol > 0)
  338. {
  339. _muteVol = 0;
  340. }
  341. }
  342. private async Task GetTransportInfo()
  343. {
  344. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetTransportInfo");
  345. if (command == null)
  346. return;
  347. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  348. if (service == null)
  349. return;
  350. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, RendererCommands.BuildPost(command, service.ServiceType))
  351. .ConfigureAwait(false);
  352. if (result == null || result.Document == null)
  353. return;
  354. var transportState =
  355. result.Document.Descendants(uPnpNamespaces.AvTransport + "GetTransportInfoResponse").Select(i => i.Element("CurrentTransportState")).FirstOrDefault(i => i != null);
  356. var transportStateValue = transportState == null ? null : transportState.Value;
  357. if (transportStateValue != null)
  358. TransportState = transportStateValue;
  359. UpdateTime = DateTime.UtcNow;
  360. }
  361. private async Task GetMediaInfo()
  362. {
  363. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetMediaInfo");
  364. if (command == null)
  365. return;
  366. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  367. if (service == null)
  368. {
  369. throw new InvalidOperationException("Unable to find service");
  370. }
  371. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, RendererCommands.BuildPost(command, service.ServiceType))
  372. .ConfigureAwait(false);
  373. if (result == null || result.Document == null)
  374. return;
  375. var track = result.Document.Descendants("CurrentURIMetaData").Select(i => i.Value).FirstOrDefault();
  376. if (String.IsNullOrEmpty(track))
  377. {
  378. CurrentId = "0";
  379. return;
  380. }
  381. var uPnpResponse = XElement.Parse(track);
  382. var e = uPnpResponse.Element(uPnpNamespaces.items) ?? uPnpResponse;
  383. var uTrack = uParser.CreateObjectFromXML(new uParserObject
  384. {
  385. Type = e.GetValue(uPnpNamespaces.uClass),
  386. Element = e
  387. });
  388. if (uTrack != null)
  389. CurrentId = uTrack.Id;
  390. }
  391. private async Task<bool> GetPositionInfo()
  392. {
  393. var command = AvCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetPositionInfo");
  394. if (command == null)
  395. return true;
  396. var service = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  397. if (service == null)
  398. {
  399. throw new InvalidOperationException("Unable to find service");
  400. }
  401. var result = await new SsdpHttpClient(_httpClient).SendCommandAsync(Properties.BaseUrl, service, command.Name, RendererCommands.BuildPost(command, service.ServiceType))
  402. .ConfigureAwait(false);
  403. if (result == null || result.Document == null)
  404. return true;
  405. var durationElem = result.Document.Descendants(uPnpNamespaces.AvTransport + "GetPositionInfoResponse").Select(i => i.Element("TrackDuration")).FirstOrDefault(i => i != null);
  406. var duration = durationElem == null ? null : durationElem.Value;
  407. if (duration != null)
  408. {
  409. Duration = TimeSpan.Parse(duration);
  410. }
  411. var positionElem = result.Document.Descendants(uPnpNamespaces.AvTransport + "GetPositionInfoResponse").Select(i => i.Element("RelTime")).FirstOrDefault(i => i != null);
  412. var position = positionElem == null ? null : positionElem.Value;
  413. if (position != null)
  414. {
  415. Position = TimeSpan.Parse(position);
  416. }
  417. var track = result.Document.Descendants("TrackMetaData").Select(i => i.Value)
  418. .FirstOrDefault();
  419. if (String.IsNullOrEmpty(track))
  420. {
  421. //If track is null, some vendors do this, use GetMediaInfo instead
  422. return false;
  423. }
  424. var uPnpResponse = XElement.Parse(track);
  425. var e = uPnpResponse.Element(uPnpNamespaces.items) ?? uPnpResponse;
  426. var uTrack = uBaseObject.Create(e);
  427. if (uTrack == null)
  428. return true;
  429. CurrentId = uTrack.Id;
  430. return true;
  431. }
  432. #endregion
  433. #region From XML
  434. private async Task GetAVProtocolAsync()
  435. {
  436. var avService = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceAvtransportId);
  437. if (avService == null)
  438. return;
  439. var url = avService.SCPDURL;
  440. if (!url.Contains("/"))
  441. url = "/dmr/" + url;
  442. if (!url.StartsWith("/"))
  443. url = "/" + url;
  444. var httpClient = new SsdpHttpClient(_httpClient);
  445. var document = await httpClient.GetDataAsync(new Uri(Properties.BaseUrl + url));
  446. AvCommands = TransportCommands.Create(document);
  447. }
  448. private async Task GetRenderingProtocolAsync()
  449. {
  450. var avService = Properties.Services.FirstOrDefault(s => s.ServiceId == ServiceRenderingId);
  451. if (avService == null)
  452. return;
  453. string url = avService.SCPDURL;
  454. if (!url.Contains("/"))
  455. url = "/dmr/" + url;
  456. if (!url.StartsWith("/"))
  457. url = "/" + url;
  458. var httpClient = new SsdpHttpClient(_httpClient);
  459. var document = await httpClient.GetDataAsync(new Uri(Properties.BaseUrl + url));
  460. RendererCommands = TransportCommands.Create(document);
  461. }
  462. internal TransportCommands AvCommands
  463. {
  464. get;
  465. set;
  466. }
  467. internal TransportCommands RendererCommands
  468. {
  469. get;
  470. set;
  471. }
  472. public static async Task<Device> CreateuPnpDeviceAsync(Uri url, IHttpClient httpClient, ILogger logger)
  473. {
  474. var ssdpHttpClient = new SsdpHttpClient(httpClient);
  475. var document = await ssdpHttpClient.GetDataAsync(url).ConfigureAwait(false);
  476. var deviceProperties = new DeviceProperties();
  477. var name = document.Descendants(uPnpNamespaces.ud.GetName("friendlyName")).FirstOrDefault();
  478. if (name != null)
  479. deviceProperties.Name = name.Value;
  480. var name2 = document.Descendants(uPnpNamespaces.ud.GetName("roomName")).FirstOrDefault();
  481. if (name2 != null)
  482. deviceProperties.Name = name2.Value;
  483. var model = document.Descendants(uPnpNamespaces.ud.GetName("modelName")).FirstOrDefault();
  484. if (model != null)
  485. deviceProperties.ModelName = model.Value;
  486. var modelNumber = document.Descendants(uPnpNamespaces.ud.GetName("modelNumber")).FirstOrDefault();
  487. if (modelNumber != null)
  488. deviceProperties.ModelNumber = modelNumber.Value;
  489. var uuid = document.Descendants(uPnpNamespaces.ud.GetName("UDN")).FirstOrDefault();
  490. if (uuid != null)
  491. deviceProperties.UUID = uuid.Value;
  492. var manufacturer = document.Descendants(uPnpNamespaces.ud.GetName("manufacturer")).FirstOrDefault();
  493. if (manufacturer != null)
  494. deviceProperties.Manufacturer = manufacturer.Value;
  495. var manufacturerUrl = document.Descendants(uPnpNamespaces.ud.GetName("manufacturerURL")).FirstOrDefault();
  496. if (manufacturerUrl != null)
  497. deviceProperties.ManufacturerUrl = manufacturerUrl.Value;
  498. var presentationUrl = document.Descendants(uPnpNamespaces.ud.GetName("presentationURL")).FirstOrDefault();
  499. if (presentationUrl != null)
  500. deviceProperties.PresentationUrl = presentationUrl.Value;
  501. deviceProperties.BaseUrl = String.Format("http://{0}:{1}", url.Host, url.Port);
  502. var icon = document.Descendants(uPnpNamespaces.ud.GetName("icon")).FirstOrDefault();
  503. if (icon != null)
  504. {
  505. deviceProperties.Icon = uIcon.Create(icon);
  506. }
  507. var isRenderer = false;
  508. foreach (var services in document.Descendants(uPnpNamespaces.ud.GetName("serviceList")))
  509. {
  510. if (services == null)
  511. return null;
  512. var servicesList = services.Descendants(uPnpNamespaces.ud.GetName("service"));
  513. if (servicesList == null)
  514. return null;
  515. foreach (var element in servicesList)
  516. {
  517. var service = uService.Create(element);
  518. if (service != null)
  519. {
  520. deviceProperties.Services.Add(service);
  521. if (service.ServiceId == ServiceAvtransportId)
  522. {
  523. isRenderer = true;
  524. }
  525. }
  526. }
  527. }
  528. if (isRenderer)
  529. {
  530. var device = new Device(deviceProperties, httpClient, logger);
  531. await device.GetRenderingProtocolAsync().ConfigureAwait(false);
  532. await device.GetAVProtocolAsync().ConfigureAwait(false);
  533. return device;
  534. }
  535. return null;
  536. }
  537. #endregion
  538. #region Events
  539. public event EventHandler<TransportStateEventArgs> PlaybackChanged;
  540. public event EventHandler<CurrentIdEventArgs> CurrentIdChanged;
  541. private void NotifyPlaybackChanged(bool value)
  542. {
  543. if (PlaybackChanged != null)
  544. {
  545. PlaybackChanged.Invoke(this, new TransportStateEventArgs
  546. {
  547. Stopped = IsStopped
  548. });
  549. }
  550. }
  551. private void NotifyCurrentIdChanged(string value)
  552. {
  553. if (CurrentIdChanged != null)
  554. CurrentIdChanged.Invoke(this, new CurrentIdEventArgs(value));
  555. }
  556. #endregion
  557. #region IDisposable
  558. bool _disposed;
  559. public void Dispose()
  560. {
  561. if (!_disposed)
  562. {
  563. _disposed = true;
  564. _timer.Dispose();
  565. }
  566. }
  567. #endregion
  568. public override string ToString()
  569. {
  570. return String.Format("{0} - {1}", Properties.Name, Properties.BaseUrl);
  571. }
  572. }
  573. }