diff --git a/Adspace/Ad.cs b/Adspace/Ad.cs index 8a9ac094..f2304d5f 100644 --- a/Adspace/Ad.cs +++ b/Adspace/Ad.cs @@ -1,5 +1,5 @@ /** - * Copyright (C) 2021 Xibo Signage Ltd + * Copyright (C) 2022 Xibo Signage Ltd * * Xibo - Digital Signage - http://www.xibo.org.uk * @@ -29,6 +29,7 @@ using System.Collections.Generic; using System.Device.Location; using System.Diagnostics; +using System.Linq; namespace XiboClient.Adspace { @@ -39,20 +40,32 @@ public class Ad public string Title; public string CreativeId; public string Duration; - public string File; public string Type; public string XiboType; public int Width; public int Height; + public string AdTagUri; public string Url; public List ImpressionUrls = new List(); public List ErrorUrls = new List(); + // Wrapper settings + // many of these come from Xibo specific extensions. public bool IsWrapper; + public bool IsWrapperResolved = false; + public bool IsWrapperOpenImmediately = false; + public bool IsWrapperResolving = false; public int CountWraps = 0; - public List AllowedWrapperTypes = new List(); - public string AllowedWrapperDuration; + + public List WrapperAllowedTypes = new List(); + public string WrapperAllowedDuration; + public string WrapperPartner; + public string WrapperFileScheme = "creativeId"; + public string WrapperExtendUrl = ""; + public string WrapperHttpMethod = "GET"; + public int WrapperMaxDuration = 0; + public int WrapperRateLimit = 0; public bool IsGeoAware = false; public string GeoLocation = ""; @@ -75,15 +88,37 @@ public int GetDuration() return (int)TimeSpan.Parse(Duration).TotalSeconds; } + /// + /// Get the duration in seconds + /// + /// + public int GetWrapperAllowedDuration() + { + return (int)TimeSpan.Parse(WrapperAllowedDuration).TotalSeconds; + } + + public string GetFileName() + { + if (WrapperFileScheme == "fileName") + { + return "axe_" + Url.Split('/').Last(); + } + else + { + return "axe_" + CreativeId; + } + } + /// /// Download this ad /// public void Download() { // We should download it. - new Url(Url).DownloadFileAsync(ApplicationSettings.Default.LibraryPath, File).ContinueWith(t => + string fileName = GetFileName(); + new Url(Url).DownloadFileAsync(ApplicationSettings.Default.LibraryPath, fileName).ContinueWith(t => { - CacheManager.Instance.Add(File, CacheManager.Instance.GetMD5(File)); + CacheManager.Instance.Add(fileName, CacheManager.Instance.GetMD5(fileName)); }, System.Threading.Tasks.TaskContinuationOptions.OnlyOnRanToCompletion); } diff --git a/Adspace/ExchangeManager.cs b/Adspace/ExchangeManager.cs index 36204c99..db0b5a9e 100644 --- a/Adspace/ExchangeManager.cs +++ b/Adspace/ExchangeManager.cs @@ -20,9 +20,13 @@ */ using Flurl; using Flurl.Http; +using Newtonsoft.Json.Linq; +using Org.BouncyCastle.Crypto.Engines; +using Swan; using System; using System.Collections.Generic; using System.Diagnostics; +using System.EnterpriseServices; using System.Linq; using System.Threading.Tasks; using System.Xml; @@ -35,11 +39,15 @@ class ExchangeManager private readonly string AdspaceUrl = @"https://exchange.xibo-adspace.com/vast/device"; // State + private readonly object buffetLock = new object(); private bool isActive; + private bool isNewPrefetchAdded = false; private DateTime lastFillDate; private DateTime lastPrefetchDate; private List prefetchUrls = new List(); private List adBuffet = new List(); + private Dictionary lastUnwrapRateLimits = new Dictionary(); + private Dictionary lastUnwrapDates = new Dictionary(); public int ShareOfVoice { get; private set; } = 0; public int AverageAdDuration { get; private set; } = 0; @@ -47,7 +55,7 @@ class ExchangeManager public ExchangeManager() { lastFillDate = DateTime.Now.AddYears(-1); - lastPrefetchDate = DateTime.Now.AddYears(-1); + lastPrefetchDate = DateTime.Now.AddHours(1); } /// @@ -83,7 +91,7 @@ public void SetActive(bool active) // Transitioning to active if (prefetchUrls.Count > 0) { - Task.Factory.StartNew(() => Prefetch()); + Task.Run(() => Prefetch()); } } else if (isActive != active) @@ -101,6 +109,16 @@ public void SetActive(bool active) /// public void Configure() { + // If our last fill date is really old, clear out ads and refresh + if (lastFillDate < DateTime.Now.AddHours(-1)) + { + lock (buffetLock) + { + adBuffet.Clear(); + } + } + + // Should we configure if (IsAdAvailable && ShareOfVoice > 0) { Trace.WriteLine(new LogMessage("ExchangeManager", "Configure: we do not need to configure this time around"), LogType.Audit.ToString()); @@ -117,7 +135,7 @@ public void Configure() // Should we also prefetch? if (lastPrefetchDate < DateTime.Now.AddHours(-24)) { - Task.Factory.StartNew(() => Prefetch()); + Task.Run(() => Prefetch()); } } @@ -134,7 +152,16 @@ public Ad GetAd(double width, double height) throw new AdspaceNoAdException(); } - Ad ad = adBuffet[0]; + Ad ad; + try + { + ad = GetAvailableAd(); + } + catch + { + Trace.WriteLine(new LogMessage("ExchangeManager", "GetAd: no available ad returned while unwrapping"), LogType.Error.ToString()); + throw new AdspaceNoAdException("No ad returned"); + } // Check geo fence if (!ad.IsGeoActive(ClientInfo.Instance.CurrentGeoLocation)) @@ -171,9 +198,9 @@ public Ad GetAd(double width, double height) // TODO: check fault status // Check to see if the file is already there, and if not, download it. - if (!CacheManager.Instance.IsValidPath(ad.File)) + if (!CacheManager.Instance.IsValidPath(ad.GetFileName())) { - Task.Factory.StartNew(() => ad.Download()); + Task.Run(() => ad.Download()); // Don't show it this time adBuffet.Remove(ad); @@ -187,39 +214,120 @@ public Ad GetAd(double width, double height) } /// - /// Prefetch any resources which might play + /// Get an available ad /// - public void Prefetch() + /// + /// + private Ad GetAvailableAd() { - List urls = new List(); - - foreach (Url url in urls) + if (adBuffet.Count > 0) { - // Get a JSON string from the URL. - var result = url.GetJsonAsync>().Result; - - // Download each one - foreach (string fetchUrl in result) { - string fileName = "axe_" + fetchUrl.Split('/').Last(); - if (!CacheManager.Instance.IsValidPath(fileName)) + bool isResolveRequested = false; + Ad chosenAd = null; + foreach (Ad ad in adBuffet) + { + if (ad.IsWrapper && !ad.IsWrapperResolved) { - // We should download it. - new Url(fetchUrl).DownloadFileAsync(ApplicationSettings.Default.LibraryPath, fileName).ContinueWith(t => + // Resolve one + // we only want to do this one time per getAvailableAd, and only if that + // ad isn't already resolving (no need to fire loads of events which won't + // do anything). + if (!isResolveRequested && !ad.IsWrapperResolving) { - CacheManager.Instance.Add(fileName, CacheManager.Instance.GetMD5(fileName)); - }, - TaskContinuationOptions.OnlyOnRanToCompletion); + isResolveRequested = true; + } + continue; } + + // Not a wrapper or a wrapper already resolved. + chosenAd = ad; + break; + } + + if (isResolveRequested) + { + Trace.WriteLine(new LogMessage("ExchangeManager", "GetAvailableAd: will call to unwrap an ad, there are " + CountAvailableAds), LogType.Info.ToString()); + Task.Run(() => UnwrapAds()); + } + + if (chosenAd != null) + { + return chosenAd; } } + throw new AdspaceNoAdException(); } /// - /// Fill the ad buffet + /// Prefetch any resources which might play /// - public void Fill() - { - Fill(false); + public void Prefetch() + { + foreach (string url in prefetchUrls) + { + try + { + // Do we have a simple URL or a URL with a key + string resolvedUrl = url; + string urlProp = null; + string idProp = null; + if (url.Contains("||")) + { + // Split the URL + string[] splits = url.Split(new string[] { "||" }, StringSplitOptions.None); + resolvedUrl = splits[0]; + urlProp = splits[1]; + idProp = splits[2]; + } + + // We either expect a list of strings or a list of objects. + if (urlProp != null && idProp != null) + { + // Expect an array of objects. + var result = resolvedUrl.GetJsonAsync>().Result; + + // Download each one + foreach (JObject creative in result) + { + string fetchUrl = creative.GetValue(urlProp).ToString(); + string fileName = "axe_" + creative.GetValue(idProp).ToString(); + if (!CacheManager.Instance.IsValidPath(fileName)) + { + // We should download it. + new Url(fetchUrl).DownloadFileAsync(ApplicationSettings.Default.LibraryPath, fileName).ContinueWith(t => + { + CacheManager.Instance.Add(fileName, CacheManager.Instance.GetMD5(fileName)); + }, + TaskContinuationOptions.OnlyOnRanToCompletion); + } + } + } + else + { + // Get a JSON string from the URL. + var result = url.GetJsonAsync>().Result; + + // Download each one + foreach (string fetchUrl in result) + { + string fileName = "axe_" + fetchUrl.Split('/').Last(); + if (!CacheManager.Instance.IsValidPath(fileName)) + { + // We should download it. + new Url(fetchUrl).DownloadFileAsync(ApplicationSettings.Default.LibraryPath, fileName).ContinueWith(t => + { + CacheManager.Instance.Add(fileName, CacheManager.Instance.GetMD5(fileName)); + }, + TaskContinuationOptions.OnlyOnRanToCompletion); + } + } + } + } + catch (Exception e) + { + Trace.WriteLine(new LogMessage("ExchangeManager", "Prefetch: failed to call prefetch. e = " + e.Message.ToString()), LogType.Error.ToString()); + } + } } /// @@ -248,7 +356,19 @@ private void Fill(bool force) .SetQueryParam("lng", ClientInfo.Instance.CurrentGeoLocation.Longitude); } - adBuffet.AddRange(Request(url)); + // Request new ads and then lock the buffet while we update it + List newAds = Request(url); + lock (buffetLock) + { + adBuffet.AddRange(newAds); + } + + // Did we add any new prefetch URLs? + if (isNewPrefetchAdded) + { + Task.Run(() => Prefetch()); + isNewPrefetchAdded = false; + } } /// @@ -261,6 +381,27 @@ private List Request(Url url) return Request(url, null); } + private List Request(string url, Ad wrappedAd) + { + if (wrappedAd != null) + { + if (ClientInfo.Instance.CurrentGeoLocation != null && !ClientInfo.Instance.CurrentGeoLocation.IsUnknown) + { + url = url + .Replace("[LAT]", "" + ClientInfo.Instance.CurrentGeoLocation.Latitude) + .Replace("[LNG]", "" + ClientInfo.Instance.CurrentGeoLocation.Longitude); + } + else + { + url = url + .Replace("[LAT]", "") + .Replace("[LNG]", ""); + } + } + + return Request(new Url(url), wrappedAd); + } + /// /// Request new ads /// @@ -269,12 +410,27 @@ private List Request(Url url) /// private List Request(Url url, Ad wrappedAd) { - List buffet = new List(); + LogMessage.Trace("ExchangeManager", "Request", url.ToString()); + + // Track the last time we unwrap an ad for each partner. + if (wrappedAd != null) + { + SetUnwrapLast(wrappedAd.WrapperPartner); + } // Make a request for new ads + List buffet = new List(); try { - var response = url.WithTimeout(10).GetAsync().Result; + IFlurlResponse response = null; + if (wrappedAd != null && wrappedAd.WrapperHttpMethod == "POST") + { + response = url.WithTimeout(10).PostAsync().Result; + } + else + { + response = url.WithTimeout(10).GetAsync().Result; + } var body = response.GetStringAsync().Result; if (string.IsNullOrEmpty(body)) @@ -324,8 +480,10 @@ private List Request(Url url, Ad wrappedAd) Ad ad; if (wrappedAd == null) { - ad = new Ad(); - ad.Id = adNode.Attributes["id"].Value; + ad = new Ad + { + Id = adNode.Attributes["id"].Value + }; } else { @@ -354,52 +512,128 @@ private List Request(Url url, Ad wrappedAd) continue; } - // Make a Url from it. - Url adTagUrl = new Url(adTagUrlNode.Value); + // Capture the URL + ad.AdTagUri = adTagUrlNode.InnerText.Trim(); // Get and impression/error URLs included with this wrap XmlNode errorUrlNode = wrapper.SelectSingleNode("./Error"); if (errorUrlNode != null) { - ad.ErrorUrls.Add(errorUrlNode.Value); + ad.ErrorUrls.Add(errorUrlNode.InnerText.Trim()); } XmlNode impressionUrlNode = wrapper.SelectSingleNode("./Impression"); if (impressionUrlNode != null) { - ad.ImpressionUrls.Add(impressionUrlNode.Value); + ad.ImpressionUrls.Add(impressionUrlNode.InnerText.Trim()); } // Extensions - XmlNodeList extensionNodes = wrapper.SelectNodes("./Extension"); + XmlNodeList extensionNodes = wrapper.SelectNodes(".//Extension"); foreach (XmlNode extensionNode in extensionNodes) { switch (extensionNode.Attributes["type"].Value) { case "prefetch": - if (prefetchUrls.Contains(extensionNode.InnerText)) + case "xiboPrefetch": + if (!prefetchUrls.Contains(extensionNode.InnerText)) { prefetchUrls.Add(extensionNode.InnerText); + isNewPrefetchAdded = true; } break; case "validType": + case "xiboValidType": if (!string.IsNullOrEmpty(extensionNode.InnerText)) { - ad.AllowedWrapperTypes = extensionNode.InnerText.Split(',').ToList(); + ad.WrapperAllowedTypes = extensionNode.InnerText.Split(',').ToList(); } break; case "validDuration": - ad.AllowedWrapperDuration = extensionNode.InnerText; + case "xiboValidDuration": + ad.WrapperAllowedDuration = extensionNode.InnerText; + break; + + case "xiboMaxDuration": + try + { + ad.WrapperMaxDuration = int.Parse(extensionNode.InnerText.Trim()); + } + catch + { + LogMessage.Trace("ExchangeManager", "Request", "Invalid xiboIsWrapperRateLimit"); + + } + break; + + case "xiboIsWrapperOpenImmediately": + try + { + ad.IsWrapperOpenImmediately = int.Parse(extensionNode.InnerText.Trim()) == 1; + } + catch + { + LogMessage.Trace("ExchangeManager", "Request", "Invalid xiboIsWrapperRateLimit"); + + } + break; + + case "xiboPartner": + ad.WrapperPartner = extensionNode.InnerText.Trim(); + break; + + case "xiboIsWrapperRateLimit": + try + { + ad.WrapperRateLimit = int.Parse(extensionNode.InnerText.Trim()); + } + catch + { + LogMessage.Trace("ExchangeManager", "Request", "Invalid xiboIsWrapperRateLimit"); + + } + break; + + case "xiboFileScheme": + ad.WrapperFileScheme = extensionNode.InnerText.Trim(); + break; + + case "xiboExtendUrl": + ad.WrapperExtendUrl = extensionNode.InnerText.Trim(); + break; + + case "xiboHttpMethod": + ad.WrapperHttpMethod = extensionNode.InnerText.Trim(); + break; + + default: + LogMessage.Trace("ExchangeManager", "Request", "Unknown extension"); break; } } - // Resolve our new wrapper + ad.IsWrapperResolved = false; + + // Record our rate limit if we have one + if (ad.WrapperRateLimit > 0 && !string.IsNullOrEmpty(ad.WrapperPartner)) + { + SetUnwrapRateThreshold(ad.WrapperPartner, ad.WrapperRateLimit); + } + + // If we need to unwrap these immediately, then do so, but only + // if we haven't exceeded the rate threshold for this partner. try { - buffet.AddRange(Request(adTagUrl, ad)); + if (ad.IsWrapperOpenImmediately && !IsUnwrapRateThreshold(ad.WrapperPartner)) + { + buffet.AddRange(Request(ad.AdTagUri, ad)); + } + else + { + buffet.Add(ad); + } } catch { @@ -422,20 +656,28 @@ private List Request(Url url, Ad wrappedAd) XmlNode titleNode = inlineNode.SelectSingleNode("./AdTitle"); if (titleNode != null) { - ad.Title = titleNode.InnerText; + ad.Title = titleNode.InnerText.Trim(); } // Get and impression/error URLs included with this wrap XmlNode errorUrlNode = inlineNode.SelectSingleNode("./Error"); if (errorUrlNode != null) { - ad.ErrorUrls.Add(errorUrlNode.InnerText); + string errorUrl = errorUrlNode.InnerText.Trim(); + if (errorUrl != "about:blank") + { + ad.ErrorUrls.Add(errorUrl + ad.WrapperExtendUrl); + } } XmlNode impressionUrlNode = inlineNode.SelectSingleNode("./Impression"); if (impressionUrlNode != null) { - ad.ImpressionUrls.Add(impressionUrlNode.InnerText); + string impressionUrl = impressionUrlNode.InnerText.Trim(); + if (impressionUrl != "about:blank") + { + ad.ImpressionUrls.Add(impressionUrl + ad.WrapperExtendUrl); + } } // Creatives @@ -448,7 +690,7 @@ private List Request(Url url, Ad wrappedAd) XmlNode creativeDurationNode = creativeNode.SelectSingleNode("./Linear/Duration"); if (creativeDurationNode != null) { - ad.Duration = creativeDurationNode.InnerText; + ad.Duration = creativeDurationNode.InnerText.Trim(); } else { @@ -460,7 +702,7 @@ private List Request(Url url, Ad wrappedAd) XmlNode creativeMediaNode = creativeNode.SelectSingleNode("./Linear/MediaFiles/MediaFile"); if (creativeMediaNode != null) { - ad.Url = creativeMediaNode.InnerText; + ad.Url = creativeMediaNode.InnerText.Trim(); ad.Width = int.Parse(creativeMediaNode.Attributes["width"].Value); ad.Height = int.Parse(creativeMediaNode.Attributes["height"].Value); ad.Type = creativeMediaNode.Attributes["type"].Value; @@ -494,28 +736,39 @@ private List Request(Url url, Ad wrappedAd) // Did this resolve from a wrapper? if so do some extra checks. if (ad.IsWrapper) { - if (!ad.AllowedWrapperTypes.Contains("all", StringComparer.OrdinalIgnoreCase) - && !ad.AllowedWrapperTypes.Contains(ad.Type.ToLower(), StringComparer.OrdinalIgnoreCase)) + // Type + if (ad.WrapperAllowedTypes.Count > 0 + && !ad.WrapperAllowedTypes.Contains("all", StringComparer.OrdinalIgnoreCase) + && !ad.WrapperAllowedTypes.Contains(ad.Type.ToLower(), StringComparer.OrdinalIgnoreCase)) { ReportError(ad.ErrorUrls, 200); continue; } - if (!string.IsNullOrEmpty(ad.AllowedWrapperDuration) - && ad.Duration != ad.AllowedWrapperDuration) + // Duration + if (!string.IsNullOrEmpty(ad.WrapperAllowedDuration) + && ad.GetDuration() != ad.GetWrapperAllowedDuration()) { - ReportError(ad.ErrorUrls, 302); + ReportError(ad.ErrorUrls, 202); + continue; + } + + // Max duration + if (ad.WrapperMaxDuration > 0 + && ad.GetDuration() > ad.WrapperMaxDuration) + { + ReportError(ad.ErrorUrls, 202); continue; } - } - // We are good to go. - ad.File = "axe_" + ad.Url.Split('/').Last(); + // Wrapper is resolved + ad.IsWrapperResolved = true; + } // Download if necessary - if (!CacheManager.Instance.IsValidPath(ad.File)) + if (!CacheManager.Instance.IsValidPath(ad.GetFileName())) { - Task.Factory.StartNew(() => ad.Download()); + Task.Run(() => ad.Download()); } // Ad this to our list @@ -541,6 +794,59 @@ private List Request(Url url, Ad wrappedAd) return buffet; } + /// + /// Unwrap ads + /// + private void UnwrapAds() + { + lock (buffetLock) + { + // Keep a list of ads we add + List unwrappedAds = new List(); + + // Backwards loop + for (int i = adBuffet.Count - 1; i >= 0; i--) + { + Ad ad = adBuffet[i]; + if (ad.IsWrapper && !ad.IsWrapperResolved) + { + if (ad.IsWrapperResolving) + { + continue; + } + + // Is this partner rate limited + if (IsUnwrapRateThreshold(ad.WrapperPartner)) + { + continue; + } + + LogMessage.Info("ExchangeManager", "UnwrapAds", "resolving " + ad.Id); + + // Remove this ad (we're resolving it) + ad.IsWrapperResolving = true; + adBuffet.RemoveAt(i); + + // Make a request to unwrap this ad. + try + { + unwrappedAds.AddRange(Request(ad.AdTagUri, ad)); + } + catch (Exception e) + { + LogMessage.Error("ExchangeManager", "UnwrapAds", "wrapped ad did not resolve: " + e.Message.ToString()); + } + } + } + + LogMessage.Trace("ExchangeManager", "UnwrapAds", unwrappedAds.Count + " unwrapped"); + + // Add in any new ones we've got as a result + adBuffet.AddRange(unwrappedAds); + unwrappedAds.Clear(); + } + } + /// /// Report an error code to a list of URLs /// @@ -570,5 +876,59 @@ private void ReportError(List urls, int errorCode) } } } + + private void SetUnwrapRateThreshold(string partner, int seconds) + { + if (!string.IsNullOrEmpty(partner)) + { + lastUnwrapRateLimits[partner] = seconds; + } + } + + private void SetUnwrapLast(string partner) + { + if (!string.IsNullOrEmpty(partner)) + { + lastUnwrapDates[partner] = DateTime.Now; + } + } + + + /// + /// Is the unwrap rate limit reached for this parnter? + /// + /// + /// + private bool IsUnwrapRateThreshold(string partner) + { + if (String.IsNullOrEmpty(partner)) + { + return false; + } + + if (!lastUnwrapRateLimits.ContainsKey(partner)) + { + return false; + } + + int rateLimit = lastUnwrapRateLimits[partner]; + if (rateLimit <= 0) + { + return false; + } + + if (!lastUnwrapDates.ContainsKey(partner)) + { + return false; + } + + DateTime lastUnwrap = lastUnwrapDates[partner]; + if (lastUnwrap == null) + { + return false; + } + + return lastUnwrap.AddSeconds(rateLimit) > DateTime.Now; + } } } diff --git a/Log/LogMessage.cs b/Log/LogMessage.cs index 197fad8e..3738aa73 100644 --- a/Log/LogMessage.cs +++ b/Log/LogMessage.cs @@ -1,6 +1,6 @@ /* - * Xibo - Digitial Signage - http://www.xibo.org.uk - * Copyright (C) 2009-2012 Daniel Garner + * Xibo - Digital Signage - https://xibosignage.com + * Copyright (C) 2022 Xibo Signage Ltd * * This file is part of Xibo. * @@ -36,6 +36,26 @@ public class LogMessage public int _mediaId { get; set; } public DateTime LogDate { get; set; } + public static void Error(string className, string method, string message) + { + System.Diagnostics.Trace.WriteLine(new LogMessage(className, method + ": " + message), LogType.Error.ToString()); + } + + public static void Info(string className, string method, string message) + { + System.Diagnostics.Trace.WriteLine(new LogMessage(className, method + ": " + message), LogType.Info.ToString()); + } + + public static void Audit(string className, string method, string message) + { + System.Diagnostics.Trace.WriteLine(new LogMessage(className, method + ": " + message), LogType.Audit.ToString()); + } + + public static void Trace(string className, string method, string message) + { + System.Diagnostics.Debug.WriteLine(new LogMessage(className, method + ": " + message), "Trace"); + } + public LogMessage(String method, String message) { LogDate = DateTime.Now; diff --git a/Logic/ApplicationSettings.cs b/Logic/ApplicationSettings.cs index a3b0bc56..67964fb4 100644 --- a/Logic/ApplicationSettings.cs +++ b/Logic/ApplicationSettings.cs @@ -52,9 +52,9 @@ private static readonly Lazy /// private List ExcludedProperties; - public string ClientVersion { get; } = "3 R305.1"; + public string ClientVersion { get; } = "3 R306.2"; public string Version { get; } = "6"; - public int ClientCodeVersion { get; } = 305; + public int ClientCodeVersion { get; } = 306; private ApplicationSettings() { diff --git a/Logic/CacheManager.cs b/Logic/CacheManager.cs index d788079f..235845eb 100644 --- a/Logic/CacheManager.cs +++ b/Logic/CacheManager.cs @@ -65,6 +65,16 @@ public static CacheManager Instance /// private Dictionary _layoutDurations = new Dictionary(); + /// + /// Plays Per Hour + /// + private Dictionary PlaysPerHour = new Dictionary(); + + /// + /// The date we last recorded a plays per hour + /// + private DateTime LastPlaysPerHourRecorded = DateTime.Now; + /// /// Hide constructor /// @@ -648,6 +658,47 @@ public int GetLayoutDuration(int layoutId, int defaultValue) } #endregion + + #region Max Plays Per Hour + + private void ResetPlaysPerHourIfNecessary() + { + DateTime now = DateTime.Now; + DateTime startOfHour = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0); + if (LastPlaysPerHourRecorded < startOfHour) + { + PlaysPerHour.Clear(); + } + } + + public void IncrementPlaysPerHour(int scheduleId) + { + ResetPlaysPerHourIfNecessary(); + + if (!PlaysPerHour.ContainsKey(scheduleId)) + { + PlaysPerHour[scheduleId] = 0; + } + + LastPlaysPerHourRecorded = DateTime.Now; + PlaysPerHour[scheduleId]++; + } + + public int GetPlaysPerHour(int scheduleId) + { + ResetPlaysPerHourIfNecessary(); + + if (PlaysPerHour.ContainsKey(scheduleId)) + { + return PlaysPerHour[scheduleId]; + } + else + { + return 0; + } + } + + #endregion } /// diff --git a/Logic/Schedule.cs b/Logic/Schedule.cs index 7cd7091d..16223e6f 100644 --- a/Logic/Schedule.cs +++ b/Logic/Schedule.cs @@ -792,5 +792,10 @@ public Ad GetAd(double width, double height) { return _scheduleManager.CurrentActionsSchedule; } + + public void WakeUpScheduleManager() + { + _scheduleManager.RunNow(); + } } } diff --git a/Logic/ScheduleItem.cs b/Logic/ScheduleItem.cs index 62a5e205..aeb4347e 100644 --- a/Logic/ScheduleItem.cs +++ b/Logic/ScheduleItem.cs @@ -64,6 +64,9 @@ public class ScheduleItem public int CyclePlayCount = 0; public List CycleScheduleItems = new List(); + // Max Plays + public int MaxPlaysPerHour = 0; + /// /// Dependent items /// diff --git a/Logic/ScheduleManager.cs b/Logic/ScheduleManager.cs index b0ec9283..d89c1bf8 100644 --- a/Logic/ScheduleManager.cs +++ b/Logic/ScheduleManager.cs @@ -614,6 +614,14 @@ private List ParseScheduleAndValidate() // Look at the Date/Time to see if it should be on the schedule or not if (layout.FromDt <= DateTime.Now && layout.ToDt >= DateTime.Now) { + // Test max plays per hour + if (layout.MaxPlaysPerHour > 0 + && CacheManager.Instance.GetPlaysPerHour(layout.scheduleid) >= layout.MaxPlaysPerHour) + { + LogMessage.Trace("ScheduleManager", "LoadNewOverlaySchedule", "Event exceeds max plays per hour"); + continue; + } + // Is it GeoAware? if (layout.IsGeoAware) { @@ -981,6 +989,14 @@ private List LoadNewOverlaySchedule() // Look at the Date/Time to see if it should be on the schedule or not if (layout.FromDt <= DateTime.Now && layout.ToDt >= DateTime.Now) { + // Test max plays per hour + if (layout.MaxPlaysPerHour > 0 + && CacheManager.Instance.GetPlaysPerHour(layout.scheduleid) >= layout.MaxPlaysPerHour) + { + LogMessage.Trace("ScheduleManager", "LoadNewOverlaySchedule", "Event exceeds max plays per hour"); + continue; + } + // Change Action and Priority layouts should generate their own list if (layout.Override) { @@ -1211,6 +1227,16 @@ private ScheduleItem ParseNodeIntoScheduleItem(XmlNode node) { Trace.WriteLine(new LogMessage("ScheduleManager", "ParseNodeIntoScheduleItem: invalid cycle playback configuration."), LogType.Audit.ToString()); } + + // Max plays per hour + try + { + temp.MaxPlaysPerHour = int.Parse(XmlHelper.GetAttrib(node, "maxPlaysPerHour", "0")); + } + catch + { + LogMessage.Trace("ScheduleManager", "ParseNodeIntoScheduleItem", "Invalid maxPlaysPerHour"); + } } // Look for dependents nodes diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 5d2ad680..00b1f982 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -504,6 +504,8 @@ private void ChangeToNextLayout(ScheduleItem scheduleItem) this.currentLayout.Stop(); Debug.WriteLine("ChangeToNextLayout: stopped and removed the current Layout: " + this.currentLayout.UniqueId, "MainWindow"); + + this.currentLayout = null; } } catch (Exception e) diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index 171f4b36..b1417f13 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -49,6 +49,6 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("3.305.0.1")] -[assembly: AssemblyFileVersion("3.305.0.1")] +[assembly: AssemblyVersion("3.306.0.2")] +[assembly: AssemblyFileVersion("3.306.0.2")] [assembly: Guid("3bd467a4-4ef9-466a-b156-a79c13a863f7")] diff --git a/Rendering/Layout.xaml.cs b/Rendering/Layout.xaml.cs index c6caeeea..581d2083 100644 --- a/Rendering/Layout.xaml.cs +++ b/Rendering/Layout.xaml.cs @@ -573,7 +573,7 @@ public void LoadFromAd(ScheduleItem scheduleItem, Ad ad) media.SetAttribute("enableStat", "0"); // Url - urlOption.InnerText = ad.File; + urlOption.InnerText = ad.GetFileName(); // Add all these nodes to the docs mediaOptions.AppendChild(urlOption); @@ -612,6 +612,8 @@ public void Start() /// public void Stop() { + LogMessage.Trace("Layout", "Stop", "Stopping: " + UniqueId); + // Stat stop double duration = StatManager.Instance.LayoutStop(UniqueId, ScheduleId, _layoutId, this.isStatEnabled); @@ -638,6 +640,18 @@ public void Stop() } IsRunning = false; + + // Record max plays per hour + if (ScheduleItem.MaxPlaysPerHour > 0) + { + CacheManager.Instance.IncrementPlaysPerHour(ScheduleId); + + if (CacheManager.Instance.GetPlaysPerHour(ScheduleId) >= ScheduleItem.MaxPlaysPerHour) + { + LogMessage.Trace("Layout", "Stop", "Waking up schedule manager as max players per hour exceeded"); + Schedule.WakeUpScheduleManager(); + } + } } /// @@ -645,6 +659,8 @@ public void Stop() /// public void Remove() { + Debug.WriteLine("Remove: " + UniqueId, "Layout"); + if (_regions == null) return; @@ -672,8 +688,6 @@ public void Remove() _regions.Clear(); } - - _regions = null; } /// diff --git a/Stats/StatManager.cs b/Stats/StatManager.cs index 710b4158..d5142aa9 100644 --- a/Stats/StatManager.cs +++ b/Stats/StatManager.cs @@ -1,5 +1,5 @@ /** - * Copyright (C) 2021 Xibo Signage Ltd + * Copyright (C) 2022 Xibo Signage Ltd * * Xibo - Digital Signage - http://www.xibo.org.uk * @@ -353,24 +353,42 @@ public double WidgetStop(int scheduleId, int layoutId, string widgetId, bool sta { // We append parameters to the URL and then send or queue // TODO: the ACTUAL_IMP count can come from a third party source such as Admobilize. - string annotatedUrl = url + "&t=" + ((DateTimeOffset)stat.To).ToUnixTimeMilliseconds(); + string annotatedUrl = url; annotatedUrl = annotatedUrl + .Replace("[UNIX_TIMESTAMP]", "" + ((DateTimeOffset)stat.To).ToUnixTimeMilliseconds()) .Replace("[DURATION]", "" + duration) - .Replace("[ACTUAL_IMP]", "1") + .Replace("[ACTUAL_IMP]", "") .Replace("[TIMESTAMP]", "" + stat.From.ToString("o", CultureInfo.InvariantCulture)); // Geo if (stat.GeoEnd != null) { - annotatedUrl += "&lat=" + stat.GeoEnd.Latitude + "&lng=" + stat.GeoEnd.Longitude; + annotatedUrl = annotatedUrl + .Replace("LAT", "" + stat.GeoEnd.Latitude) + .Replace("LNG", "" + stat.GeoEnd.Longitude); } + else + { + annotatedUrl = annotatedUrl + .Replace("LAT", "") + .Replace("LNG", ""); + } + if (stat.GeoStart != null) { - annotatedUrl += "&latStart=" + stat.GeoStart.Latitude + "&lngStart=" + stat.GeoStart.Longitude; + annotatedUrl = annotatedUrl + .Replace("LAT_START", "" + stat.GeoStart.Latitude) + .Replace("LNG_START", "" + stat.GeoStart.Longitude); + } + else + { + annotatedUrl = annotatedUrl + .Replace("LAT_START", "") + .Replace("LNG_START", ""); } // Call Impress on a new thread - Task.Factory.StartNew(() => Impress(annotatedUrl)); + Task.Run(() => Impress(annotatedUrl)); } } }