using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using AngleSharp; using AngleSharp.Dom; using CacheCow.Client; using CsvHelper; using CsvHelper.Configuration; using NLog; using Sony.Filtr.Contracts.Abstractions; using Sony.Filtr.Contracts.Entities; using Sony.Filtr.Core.SpotifyCharts.Api; using Sony.Filtr.Core.SpotifyCharts.Data; using Sony.Filtr.Core.SpotifyCharts.Extensions; using Sony.Filtr.Core.SpotifyRegion; using Sony.Filtr.DistributedCaching; using Sony.Filtr.SpotifyWebAPI; using Sony.Filtr.Utility.Extensions; namespace Sony.Filtr.Core.SpotifyCharts { public class SpotifyChartsApi { private readonly DistributedCacheHandler _distributedCacheHandler; private readonly DistributedCachingStore _distributedCachingStore; private readonly IApplicationInstanceManager _applicationInstanceManager; private readonly SpotifyRegionManager _spotifyRegionManager; private readonly ResponseCollector.ResponseCollector _responseCollector; private readonly string _regionalListType = "regional"; private readonly string _viralListType = "viral"; private readonly string _timeWindowDaily = "daily"; private readonly string _timeWindowWeekly = "weekly"; private readonly int _regionalAmount = 200; private readonly int _viralAmount = 50; private readonly HttpClient _httpClient; private readonly Logger _logger; public readonly DateTime MinimalDateForDailyCharts = new DateTime(2017, 1, 1); public SpotifyChartsApi(DistributedCacheHandler distributedCacheHandler, DistributedCachingStore distributedCachingStore, IApplicationInstanceManager applicationInstanceManager, SpotifyRegionManager spotifyRegionManager, ResponseCollector.ResponseCollector responseCollector) { _distributedCacheHandler = distributedCacheHandler; _distributedCachingStore = distributedCachingStore; _applicationInstanceManager = applicationInstanceManager; _spotifyRegionManager = spotifyRegionManager; _responseCollector = responseCollector; this._httpClient = new HttpClient(new HttpClientHandler() { UseCookies = true, AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip }); this._logger = LogManager.GetLogger("SpotifyChartsApi"); } public async Task> GetAvailableChartDates(SpotifyChartsListType chartListType, string market, SpotifyChartsTimeWindow timeWindowType) { #region old code //var listType = GetChartListType(chartListType); //var timeWindow = GetChartTimeWindow(timeWindowType); //var client = GetDefaultClient(); //var requestMessage = new HttpRequestMessage(HttpMethod.Get, $"http://spotifycharts.com/api/?type={listType}&country={market}&recurrence={timeWindow}"); //var datesData = await client.SendAsync(requestMessage); //var content = await datesData.Content.ReadAsStringAsync(); //var dates = JsonConvert.DeserializeObject(content).dates.Where(d => d != "latest").ToDictionary(GetApiDate, d => d); //return dates; #endregion old code Dictionary dates = await GetAllDatesFromSpotifyAsync(market, chartListType, timeWindowType); //if (!dates.Any()) { return dates; } Action addPreviousDaysfNotPresent = (daysToAdd) => { for (var currentDate = DateTime.Today.Date; currentDate >= DateTime.Today.AddDays(-daysToAdd); currentDate = currentDate.AddDays(-1)) { if (!dates.ContainsKey(currentDate)) { dates.Add(currentDate, GetApiDateFormat(currentDate, timeWindowType)); } } }; addPreviousDaysfNotPresent(7); return dates; } private Dictionary GetAllDailyDates(SpotifyChartsTimeWindow timeWindowType, DateTime? minSavedDate) { var dates = new List(); var startDate = minSavedDate.HasValue ? minSavedDate.Value : MinimalDateForDailyCharts; for(var currentDate = DateTime.Today.Date; currentDate >= startDate; currentDate = currentDate.AddDays(-1)) { dates.Add(currentDate); } return dates.ToDictionary(d => d, d => GetApiDateFormat(d, timeWindowType)); } public async Task> GetAllDatesFromSpotifyAsync(string market, SpotifyChartsListType listType, SpotifyChartsTimeWindow timeWindow) { market = market.ToLower(); var listTypeString = this.GetChartListType(listType); var timeWindowString = this.GetChartTimeWindow(timeWindow); var url = $"https://www.spotifycharts.com/{listTypeString}/{market}/{timeWindowString}/latest"; var document = await GetDocumentWithTimeout(url, 4000); if (document == null) { return new Dictionary(); } var listItems = document.QuerySelectorAll("div[data-type=\"date\"] ul li"); var dateKeys = listItems.Select(li => li.Attributes["data-value"]?.Value); var result = dateKeys.ToDictionary(GetDateFromDateString, d => d); _logger.Info($"market {market} {listTypeString} {timeWindowString} '{url}' {document.Title} {result.Keys.Count}"); return result; } private async Task GetDocumentWithTimeout(string url, int timeoutMilliseconds) { CancellationTokenSource tokenSource = new CancellationTokenSource(timeoutMilliseconds); HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.TryAddWithoutValidation("Accept", "*/*"); request.Headers.TryAddWithoutValidation("Accept-Encoding", "gzip, deflate"); request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 6.2; WOW64; rv:19.0) Gecko/20100101 Firefox/19.0"); request.Headers.TryAddWithoutValidation("Connection", "keep-alive"); try { var config = Configuration.Default.WithDefaultLoader(); var response = await _httpClient.SendAsync(request, tokenSource.Token); Console.WriteLine($"{url} --- {response.StatusCode}"); return await BrowsingContext.New(config).OpenAsync(async req => req.Content(await response.Content.ReadAsStringAsync())); } catch (TaskCanceledException) { return null; } } private string GetApiDateFormat(DateTime dateTime, SpotifyChartsTimeWindow timeWindowType) { var dateString = dateTime.ToString("yyyy-MM-dd"); if (timeWindowType == SpotifyChartsTimeWindow.Daily) { return dateString; } return dateTime.AddDays(-7).ToString("yyyy-MM-dd") + "--" + dateString; } private DateTime GetDateFromDateString(string dateString) { if (dateString.Contains("--")) { var lastDash = dateString.LastIndexOf("--", StringComparison.InvariantCultureIgnoreCase); DateTime lastDate; if (DateTime.TryParse(dateString.Substring(lastDash + 2), CultureInfo.InvariantCulture, DateTimeStyles.None, out lastDate)) return lastDate; } return DateTime.Parse(dateString, CultureInfo.InvariantCulture, DateTimeStyles.None); } public async Task> GetMarketsAsync() { //var client = GetDefaultClient(); //var requestMessage = new HttpRequestMessage(HttpMethod.Get, "https://spotifycharts.com/api/?type=regional"); //var marketRequest = await client.SendAsync(requestMessage); //var content = await marketRequest.Content.ReadAsStringAsync(); //var marketResponse = JsonConvert.DeserializeObject(content); // return marketResponse.countries; var applicationRegions = _applicationInstanceManager.GetApplications().Select(a => a.SpotifyRegionCode).Where(r => r.Length == 2).ToList(); var spotifyRegions = await _spotifyRegionManager.GetAvailableRegionsAsync(); var allRegions = new List(); allRegions.Add("global"); allRegions.AddRange(applicationRegions.Union(spotifyRegions).Select(r => r.ToLowerInvariant()).Distinct().OrderBy(m => m)); return allRegions; } //public async Task GetChartDataAsync(SpotifyChartsListType chartListType, string market, string apiDateKey, DateTime date, SpotifyChartsTimeWindow timeWindowType) //{ // var chartEntries = await GetChartEntriesAsync(chartListType, market, timeWindowType, apiDateKey); // var saveInformation = new ChartDataSaveInformation() // { // ChartsListType = chartListType, // Market = market, // ChartsTimeWindow = timeWindowType, // Date = date, // ChartItems = chartEntries.items // }; // return saveInformation; //} public async Task GetChartDataFromCsvAsync(SpotifyChartsListType chartListType, string market, string apiDateKey, DateTime date, SpotifyChartsTimeWindow timeWindowType) { var chartEntries = await GetChartEntriesFromHTMLAsync(chartListType, market, timeWindowType, apiDateKey, date); if (chartEntries.IsNullOrEmpty()) { return null; } var saveInformation = new ChartDataSaveInformation() { ChartsListType = chartListType, Market = market, ChartsTimeWindow = timeWindowType, Date = date, ChartItems = chartEntries.ToList() }; return saveInformation; } private async ValueTask ShouldRetryDateAsLatest(DateTime date, string market, SpotifyChartsListType listType, SpotifyChartsTimeWindow timeWindow) { if(!date.IsYesterday()) { return false; } var availableDates = await this.GetAllDatesFromSpotifyAsync(market, listType, timeWindow); return availableDates.ContainsKey(date.Date); } private HttpClient GetDefaultClient() { return this._httpClient; } //public async Task GetChartEntriesAsync(SpotifyChartsListType chartListType, string market, SpotifyChartsTimeWindow timeWindowType, string date) //{ // var client = GetDefaultClient(); // var url = BuildChartEntriesUrl(chartListType, market, timeWindowType, date); // var requestMessage = new HttpRequestMessage(HttpMethod.Get, url); // var chartData = await client.SendAsync(requestMessage); // var content = await chartData.Content.ReadAsStringAsync(); // var chart = JsonConvert.DeserializeObject(content, new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Ignore }); // return chart.entries; //} public async Task> GetChartEntriesFromCsvAsync(SpotifyChartsListType listType, string market, SpotifyChartsTimeWindow timeWindowType, string dateString, DateTime date) { var client = GetDefaultClient(); var url = BuildChartEntriesUrl(listType, market, timeWindowType, dateString); var requestMessage = new HttpRequestMessage(HttpMethod.Get, url); var chartData = await client.SendAsync(requestMessage); if (!chartData.IsSuccessStatusCode) { if (await this.ShouldRetryDateAsLatest(date, market, listType, timeWindowType)) { url = this.BuildChartEntriesLatestUrl(listType, market, timeWindowType); chartData = await client.GetAsync(url); } } if (chartData.Content == null || chartData.Content.Headers == null || chartData.Content.Headers.ContentType.MediaType != "text/csv") { return null; } var content = await chartData.Content.ReadAsStringAsync(); await _responseCollector.PrepareFile(url, "FetchSpotifyCharts", content); return GetChartDataFromCsv(content, listType); } public async Task> GetChartEntriesFromHTMLAsync(SpotifyChartsListType listType, string market, SpotifyChartsTimeWindow timeWindowType, string dateString, DateTime date) { var url = $"https://www.spotifycharts.com/{GetChartListType(listType)}/{market}/{GetChartTimeWindow(timeWindowType)}/{dateString}"; var document = await GetDocumentWithTimeout(url, 4000); if (document == null) { if (await this.ShouldRetryDateAsLatest(date, market, listType, timeWindowType)) { url = $"https://www.spotifycharts.com/{GetChartListType(listType)}/{market}/{GetChartTimeWindow(timeWindowType)}/latest"; document = await GetDocumentWithTimeout(url, 4000); } } return new DocumentChartReader(document).ToChartItems(); } private List GetChartDataFromCsv(string content, SpotifyChartsListType listType) { var chartItems = new List(); var csvParser = new CsvFactory().CreateParser(new StringReader(content), new CsvConfiguration() { Delimiter = ",", HasHeaderRecord = true }); var reader = new CsvReader(csvParser); reader.Read(); //Advance one step to skip the comment: ,,,"Note that these figures are generated using a formula that protects against any artificial inflation of chart positions.", while (reader.Read()) { var columns = reader.CurrentRecord; ChartItemCsv newItem; if (listType == SpotifyChartsListType.Regional) { newItem = GetRegionalChartItemFromCsvLine(columns); } else { newItem = GetViralChartItemFromCsvLine(columns); } newItem.TrackId = new SpotifyLink(newItem.TrackUrl.Replace("https:", "http:")).ExtractTrackID(); chartItems.Add(newItem); } return chartItems; } private ChartItemCsv GetRegionalChartItemFromCsvLine(string[] columns) { return new ChartItemCsv() { Position = Int32.Parse(columns[0]), TrackName = columns[1].Replace("\"", ""), ArtistName = columns[2].Replace("\"", ""), NumberOfStreams = Int32.Parse(columns[3]), TrackUrl = columns[4] }; } private ChartItemCsv GetViralChartItemFromCsvLine(string[] columns) { return new ChartItemCsv() { Position = Int32.Parse(columns[0]), TrackName = columns[1].Replace("\"", ""), ArtistName = columns[2].Replace("\"", ""), TrackUrl = columns[3] }; } private int GetMaxAmount(SpotifyChartsListType chartListType) { switch (chartListType) { case SpotifyChartsListType.Regional: return _regionalAmount; case SpotifyChartsListType.Viral: return _viralAmount; } throw new Exception("Unknown chartListType"); } private string BuildChartEntriesUrl(SpotifyChartsListType chartListType, string market, SpotifyChartsTimeWindow timeWindowType, string date) { var listType = GetChartListType(chartListType); var timeWindow = GetChartTimeWindow(timeWindowType); return $"https://www.spotifycharts.com/{listType}/{market}/{timeWindow}/{date}/download"; //var maxAmount = GetMaxAmount(chartListType); //return $"https://spotifycharts.com/api/?type={listType}&country={market}&recurrence={timeWindow}&date={date}&limit={maxAmount}"; } private string BuildChartEntriesLatestUrl(SpotifyChartsListType chartListType, string market, SpotifyChartsTimeWindow timeWindowType) { var listType = GetChartListType(chartListType); var timeWindow = GetChartTimeWindow(timeWindowType); return $"https://www.spotifycharts.com/{listType}/{market}/{timeWindow}/latest/download"; } private string GetChartTimeWindow(SpotifyChartsTimeWindow timeWindowType) { switch (timeWindowType) { case SpotifyChartsTimeWindow.Daily: return _timeWindowDaily; case SpotifyChartsTimeWindow.Weekly: return _timeWindowWeekly; } return string.Empty; } private string GetChartListType(SpotifyChartsListType listType) { switch (listType) { case SpotifyChartsListType.Regional: return _regionalListType; case SpotifyChartsListType.Viral: return _viralListType; } return string.Empty; } } }