From dbd83ebb6a6516e760409c674ea026a5e9bff3a8 Mon Sep 17 00:00:00 2001 From: Simon Diesenreiter Date: Fri, 30 May 2025 22:51:44 +0200 Subject: [PATCH] feat: basic initial implementation of spotify client link validator and song submission form refs: NOISSUE --- song_of_the_day/.editorconfig | 4 ++ .../Auth/LdapAuthenticationService.cs | 2 +- .../Auth/PhoneClaimCodeProviderService.cs | 4 +- song_of_the_day/Config/AppConfiguration.cs | 12 ++++ song_of_the_day/Controllers/AuthController.cs | 2 +- song_of_the_day/Data/Song.cs | 2 + song_of_the_day/Data/SongProvider.cs | 11 +++ song_of_the_day/GlobalSuppressions.cs | 13 ++++ .../MessengerIntegration/SignalIntegration.cs | 19 +++-- .../Pages/Shared/UpdateInputText.razor | 2 + song_of_the_day/Pages/Shared/_Layout.cshtml | 6 ++ .../Pages/Shared/_SongPartial.cshtml | 7 ++ song_of_the_day/Pages/SubmitSongs.cshtml | 25 +++++++ song_of_the_day/Pages/SubmitSongs.cshtml.cs | 71 +++++++++++++++++++ song_of_the_day/Program.cs | 52 +++++++------- .../SongValidators/ISongValidator.cs | 8 +++ .../SongValidators/SongResolver.cs | 66 +++++++++++++++++ .../SongValidators/SongValidatorBase.cs | 16 +++++ .../SongValidators/SpotifyValidator.cs | 40 +++++++++++ .../UriBasedSongValidatorBase.cs | 12 ++++ .../SongValidators/YoutubeMusicValidator.cs | 43 +++++++++++ .../SongValidators/YoutubeValidator.cs | 55 ++++++++++++++ .../SpotifyIntegration/SpotifyClient.cs | 44 ++++++++++++ song_of_the_day/song_of_the_day.csproj | 4 +- 24 files changed, 485 insertions(+), 35 deletions(-) create mode 100644 song_of_the_day/.editorconfig create mode 100644 song_of_the_day/Data/SongProvider.cs create mode 100644 song_of_the_day/GlobalSuppressions.cs create mode 100644 song_of_the_day/Pages/Shared/UpdateInputText.razor create mode 100644 song_of_the_day/Pages/Shared/_SongPartial.cshtml create mode 100644 song_of_the_day/Pages/SubmitSongs.cshtml create mode 100644 song_of_the_day/Pages/SubmitSongs.cshtml.cs create mode 100644 song_of_the_day/SongValidators/ISongValidator.cs create mode 100644 song_of_the_day/SongValidators/SongResolver.cs create mode 100644 song_of_the_day/SongValidators/SongValidatorBase.cs create mode 100644 song_of_the_day/SongValidators/SpotifyValidator.cs create mode 100644 song_of_the_day/SongValidators/UriBasedSongValidatorBase.cs create mode 100644 song_of_the_day/SongValidators/YoutubeMusicValidator.cs create mode 100644 song_of_the_day/SongValidators/YoutubeValidator.cs create mode 100644 song_of_the_day/SpotifyIntegration/SpotifyClient.cs diff --git a/song_of_the_day/.editorconfig b/song_of_the_day/.editorconfig new file mode 100644 index 0000000..0bdc1fd --- /dev/null +++ b/song_of_the_day/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# CS8981: The type name only contains lower-cased ascii characters. Such names may become reserved for the language. +dotnet_diagnostic.CS8981.severity = none diff --git a/song_of_the_day/Auth/LdapAuthenticationService.cs b/song_of_the_day/Auth/LdapAuthenticationService.cs index b97983b..bd95166 100644 --- a/song_of_the_day/Auth/LdapAuthenticationService.cs +++ b/song_of_the_day/Auth/LdapAuthenticationService.cs @@ -10,6 +10,6 @@ public class LdapAuthenticationService : IAuthenticationService public bool Authenticate(string username, string password) { var ldapInstance = LdapIntegration.Instance; - return ldapInstance.TestLogin(username, password); + return ldapInstance == null ? false : ldapInstance.TestLogin(username, password); } } \ No newline at end of file diff --git a/song_of_the_day/Auth/PhoneClaimCodeProviderService.cs b/song_of_the_day/Auth/PhoneClaimCodeProviderService.cs index d4f1694..7c3a0e7 100644 --- a/song_of_the_day/Auth/PhoneClaimCodeProviderService.cs +++ b/song_of_the_day/Auth/PhoneClaimCodeProviderService.cs @@ -18,7 +18,7 @@ public class PhoneClaimCodeProviderService .Select(s => s[random.Next(s.Length)]).ToArray()); } - public void GenerateClaimCodeForUserAndNumber(string username, string phoneNumber) + public async void GenerateClaimCodeForUserAndNumber(string username, string phoneNumber) { var generatedCode = string.Empty; if (IsCodeGeneratedForUser(username)) @@ -32,7 +32,7 @@ public class PhoneClaimCodeProviderService _phoneClaimNumbers[username] = phoneNumber; } - SignalIntegration.Instance.SendMessageToUserAsync("Your phone number validation code is: " + generatedCode, phoneNumber); + await SignalIntegration.Instance.SendMessageToUserAsync("Your phone number validation code is: " + generatedCode, phoneNumber); } public string ValidateClaimCodeForUser(string code, string username) diff --git a/song_of_the_day/Config/AppConfiguration.cs b/song_of_the_day/Config/AppConfiguration.cs index d6b2828..6dcbb85 100644 --- a/song_of_the_day/Config/AppConfiguration.cs +++ b/song_of_the_day/Config/AppConfiguration.cs @@ -20,6 +20,8 @@ public class AppConfiguration var managersGroupName = Environment.GetEnvironmentVariable("LDAP_ADMINGROUP") ?? "admins"; var userGroupName = Environment.GetEnvironmentVariable("LDAP_USERGROUP") ?? "everybody"; var bindValue = Environment.GetEnvironmentVariable("LDAP_BIND"); + this.SpotifyClientId = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_ID") ?? "0c59b625470b4ad1b70743e0254d17fd"; + this.SpotifyClientSecret = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_SECRET") ?? "04daaebd42fc47909c5cbd1f5cf23555"; this.LDAPConfig = new ConfigurationAD() { Username = Environment.GetEnvironmentVariable("LDAP_BIND") ?? "cn=admin,dc=disi,dc=dev", @@ -83,6 +85,16 @@ public class AppConfiguration get; private set; } + public string SpotifyClientId + { + get; private set; + } + + public string SpotifyClientSecret + { + get; private set; + } + public bool UseBotTag { get; private set; diff --git a/song_of_the_day/Controllers/AuthController.cs b/song_of_the_day/Controllers/AuthController.cs index 72f0082..15dfd93 100644 --- a/song_of_the_day/Controllers/AuthController.cs +++ b/song_of_the_day/Controllers/AuthController.cs @@ -10,7 +10,7 @@ public class AuthController : Controller public async Task Login(string username, string password) { var ldapService = HttpContext.RequestServices.GetService(); - if (ldapService.Authenticate(username, password)) + if (ldapService != null && ldapService.Authenticate(username, password)) { var claims = new[] { new Claim(ClaimTypes.Name, username) }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); diff --git a/song_of_the_day/Data/Song.cs b/song_of_the_day/Data/Song.cs index 55aeb9d..e902e04 100644 --- a/song_of_the_day/Data/Song.cs +++ b/song_of_the_day/Data/Song.cs @@ -6,4 +6,6 @@ public class Song public string? Name { get; set; } public string? Artist { get; set; } public string? Url { get; set; } + public SongProvider? Provider { get; set; } + public string? SpotifyId { get; set; } } \ No newline at end of file diff --git a/song_of_the_day/Data/SongProvider.cs b/song_of_the_day/Data/SongProvider.cs new file mode 100644 index 0000000..66518ac --- /dev/null +++ b/song_of_the_day/Data/SongProvider.cs @@ -0,0 +1,11 @@ +public enum SongProvider +{ + Spotify, + YouTube, + YoutubeMusic, + SoundCloud, + Bandcamp, + PlainHttp, + NavidromeSharedLink, + Other +} \ No newline at end of file diff --git a/song_of_the_day/GlobalSuppressions.cs b/song_of_the_day/GlobalSuppressions.cs new file mode 100644 index 0000000..03f36c8 --- /dev/null +++ b/song_of_the_day/GlobalSuppressions.cs @@ -0,0 +1,13 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "", Scope = "member", Target = "~P:sotd.Pages.UnclaimedPhoneNumbersModel.userId")] +[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "", Scope = "member", Target = "~P:sotd.Pages.UserModel.userId")] +[assembly: SuppressMessage("Compiler", "CS8981:The type name only contains lower-cased ascii characters. Such names may become reserved for the language.", Justification = "", Scope = "type", Target = "~T:song_of_the_day.DataMigrations.additionaldataforsongsubmissions")] +[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "", Scope = "type", Target = "~T:song_of_the_day.DataMigrations.additionaldataforsongsubmissions")] +[assembly: SuppressMessage("Compiler", "CS8981:The type name only contains lower-cased ascii characters. Such names may become reserved for the language.", Justification = "", Scope = "type", Target = "~T:song_of_the_day.DataMigrations.keeptrackofusersoickedforsubmission")] +[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "", Scope = "type", Target = "~T:song_of_the_day.DataMigrations.keeptrackofusersoickedforsubmission")] diff --git a/song_of_the_day/MessengerIntegration/SignalIntegration.cs b/song_of_the_day/MessengerIntegration/SignalIntegration.cs index f129242..077851e 100644 --- a/song_of_the_day/MessengerIntegration/SignalIntegration.cs +++ b/song_of_the_day/MessengerIntegration/SignalIntegration.cs @@ -7,8 +7,13 @@ public class SignalIntegration { public static SignalIntegration? Instance; + private readonly ILogger logger; + public SignalIntegration(string uri, int port, string phoneNumber) { + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information)); + this.logger = factory.CreateLogger("SignalIntegration"); + var http = new HttpClient() { BaseAddress = new Uri(uri + ":" + port) @@ -24,17 +29,19 @@ public class SignalIntegration public async Task ListGroupsAsync() { + logger.LogDebug("Listing all groups for phone number: {PhoneNumber}", this.phoneNumber); + try { ICollection groupEntries = await apiClient.GroupsAllAsync(this.phoneNumber); foreach (var group in groupEntries) { - Console.WriteLine($"{group.Name} {group.Id}"); + logger.LogDebug($" {group.Name} {group.Id}"); } } catch (Exception ex) { - Console.WriteLine("Exception (ListGroupsAsync): " + ex.Message); + logger.LogError("Exception (ListGroupsAsync): " + ex.Message); } } @@ -52,7 +59,7 @@ public class SignalIntegration } catch (Exception ex) { - Console.WriteLine("Exception (SendMessageToGroupAsync): " + ex.Message); + logger.LogError("Exception (SendMessageToGroupAsync): " + ex.Message); } } @@ -70,7 +77,7 @@ public class SignalIntegration } catch (Exception ex) { - Console.WriteLine("Exception (SendMessageToUserAsync): " + ex.Message); + logger.LogError("Exception (SendMessageToUserAsync): " + ex.Message); } } @@ -97,7 +104,7 @@ public class SignalIntegration } catch (Exception ex) { - Console.WriteLine("Exception (GetMemberListAsync): " + ex.Message); + logger.LogError("Exception (GetMemberListAsync): " + ex.Message); } return new List(); } @@ -117,7 +124,7 @@ public class SignalIntegration } catch (Exception ex) { - Console.WriteLine("Exception (GetContactAsync): " + ex.Message); + logger.LogError("Exception (GetContactAsync): " + ex.Message); return new ListContactsResponse(); } } diff --git a/song_of_the_day/Pages/Shared/UpdateInputText.razor b/song_of_the_day/Pages/Shared/UpdateInputText.razor new file mode 100644 index 0000000..3981f2a --- /dev/null +++ b/song_of_the_day/Pages/Shared/UpdateInputText.razor @@ -0,0 +1,2 @@ +@inherits Microsoft.AspNetCore.Components.Forms.InputText + \ No newline at end of file diff --git a/song_of_the_day/Pages/Shared/_Layout.cshtml b/song_of_the_day/Pages/Shared/_Layout.cshtml index 07fa8cd..950fcca 100644 --- a/song_of_the_day/Pages/Shared/_Layout.cshtml +++ b/song_of_the_day/Pages/Shared/_Layout.cshtml @@ -43,6 +43,12 @@ Unclaimed Phone Numbers } + @if (this.User.Identity.IsAuthenticated && DoesUserHaveClaimedPhoneNumber()) + { + + } diff --git a/song_of_the_day/Pages/Shared/_SongPartial.cshtml b/song_of_the_day/Pages/Shared/_SongPartial.cshtml new file mode 100644 index 0000000..2b31914 --- /dev/null +++ b/song_of_the_day/Pages/Shared/_SongPartial.cshtml @@ -0,0 +1,7 @@ +@model Song + + + + + + \ No newline at end of file diff --git a/song_of_the_day/Pages/SubmitSongs.cshtml b/song_of_the_day/Pages/SubmitSongs.cshtml new file mode 100644 index 0000000..8f070e6 --- /dev/null +++ b/song_of_the_day/Pages/SubmitSongs.cshtml @@ -0,0 +1,25 @@ +@page +@model SubmitSongsModel +@{ + ViewData["Title"] = "Submit Songs"; +} + +
+
+ + +
+
+
+ +
+ +
+
+ + diff --git a/song_of_the_day/Pages/SubmitSongs.cshtml.cs b/song_of_the_day/Pages/SubmitSongs.cshtml.cs new file mode 100644 index 0000000..62ea862 --- /dev/null +++ b/song_of_the_day/Pages/SubmitSongs.cshtml.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.VisualBasic; + +namespace sotd.Pages; + +public class SubmitSongsModel : PageModel +{ + private readonly ILogger _logger; + + private SongResolver songResolver; + + private string _submitUrl; + + public SubmitSongsModel(ILogger logger, SongResolver songResolver) + { + _logger = logger; + this.songResolver = songResolver; + } + + [BindProperty] + public bool IsValidUrl { get; set; } = true; + + [BindProperty] + public string SubmitUrl { + get { + return _submitUrl; + } + set { + _submitUrl = value.ToString(); + Uri? newValue = default; + try { + newValue = new Uri(_submitUrl); + } + catch (UriFormatException) + { + IsValidUrl = false; + return; + } + + IsValidUrl = true; + + if(this.songResolver.CanValidate(newValue)) + { + this.SongData = this.songResolver.ResolveSongAsync(newValue).Result; + } + } + } + + [BindProperty] + public bool CanSubmit { get { + return !string.IsNullOrEmpty(SongData?.Artist) && ! string.IsNullOrEmpty(SongData?.Name); + } } + + [BindProperty] + public Song SongData { get; set; } + + public void OnPost() + { + // Todo implement save submission + var x = SongData.Name; + } + + public IActionResult OnGetUpdate() + { + var songUrl = Request.Query["SubmitUrl"]; + this.SubmitUrl = songUrl.ToString(); + return Partial("_SongPartial", SongData);; + } +} diff --git a/song_of_the_day/Program.cs b/song_of_the_day/Program.cs index 9fccbbe..8943e41 100644 --- a/song_of_the_day/Program.cs +++ b/song_of_the_day/Program.cs @@ -18,7 +18,10 @@ LdapIntegration.Instance = new LdapIntegration(AppConfiguration.Instance.LDAPCon var builder = WebApplication.CreateBuilder(args); -Console.WriteLine("Setting up user check timer"); +using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information)); +var logger = factory.CreateLogger("SongResolver"); + +logger.LogTrace("Setting up user check timer"); var userCheckTimer = new CronTimer("*/1 * * * *", "Europe/Vienna", includingSeconds: false); userCheckTimer.OnOccurence += async (s, ea) => { @@ -31,9 +34,9 @@ userCheckTimer.OnOccurence += async (s, ea) => if (foundUser == null) { var newUserContact = await SignalIntegration.Instance.GetContactAsync(memberId); - Console.WriteLine("New user:"); - Console.WriteLine($" Name: {newUserContact.Name}"); - Console.WriteLine($" MemberId: {memberId}"); + logger.LogDebug("New user:"); + logger.LogDebug($" Name: {newUserContact.Name}"); + logger.LogDebug($" MemberId: {memberId}"); User newUser = new User() { Name = newUserContact.Name, @@ -55,9 +58,8 @@ userCheckTimer.OnOccurence += async (s, ea) => } await dci.DisposeAsync(); }; -userCheckTimer.Start(); -Console.WriteLine("Setting up user intro timer"); +logger.LogTrace("Setting up user intro timer"); var userIntroTimer = new CronTimer("*/1 * * * *", "Europe/Vienna", includingSeconds: false); userIntroTimer.OnOccurence += async (s, ea) => { @@ -83,10 +85,9 @@ userIntroTimer.OnOccurence += async (s, ea) => } await dci.DisposeAsync(); }; -userIntroTimer.Start(); -Console.WriteLine("Setting up pick of the day timer"); +logger.LogTrace("Setting up pick of the day timer"); var pickOfTheDayTimer = new CronTimer("0 8 * * *", "Europe/Vienna", includingSeconds: false); pickOfTheDayTimer.OnOccurence += async (s, ea) => { @@ -94,32 +95,32 @@ pickOfTheDayTimer.OnOccurence += async (s, ea) => var lastSong = dci.SongSuggestions?.OrderBy(s => s.Id).LastOrDefault(); - if (lastSong != null && lastSong.Date >= DateTime.Today.Subtract(TimeSpan.FromDays(AppConfiguration.Instance.DaysBetweenRequests))) + if (lastSong != null && lastSong.Date > DateTime.Today.Subtract(TimeSpan.FromDays(AppConfiguration.Instance.DaysBetweenRequests))) { - Console.WriteLine("Skipping pick of the day today!"); + logger.LogWarning("Skipping pick of the day today!"); await dci.DisposeAsync(); return; } if (dci.Users == null || dci.SuggestionHelpers == null || dci.SongSuggestions == null) { - Console.WriteLine("Unable to properly initialize DB context!"); + logger.LogError("Unable to properly initialize DB context!"); await dci.DisposeAsync(); return; } var potentialUsers = dci.Users.Where(u => !u.WasChosenForSuggestionThisRound); if (!potentialUsers.Any()) { - Console.WriteLine("Resetting suggestion count on users before resuming"); + logger.LogTrace("Resetting suggestion count on users before resuming"); await dci.Users.ForEachAsync(u => u.WasChosenForSuggestionThisRound = false); await dci.SaveChangesAsync(); potentialUsers = dci.Users.Where(u => !u.WasChosenForSuggestionThisRound); } - Console.WriteLine("Today's pool of pickable users is: " + string.Join(", ", potentialUsers.Select(u => u.Name))); + logger.LogDebug("Today's pool of pickable users is: " + string.Join(", ", potentialUsers.Select(u => u.Name))); var luckyUser = potentialUsers.ElementAt((new Random()).Next(potentialUsers.Count())); if (luckyUser == null) { - Console.WriteLine("Unable to determine today's lucky user!"); + logger.LogError("Unable to determine today's lucky user!"); await dci.DisposeAsync(); return; } @@ -145,7 +146,6 @@ pickOfTheDayTimer.OnOccurence += async (s, ea) => } await dci.DisposeAsync(); }; -pickOfTheDayTimer.Start(); var startUserAssociationProcess = async (User userToAssociate) => { @@ -158,14 +158,14 @@ var startUserAssociationProcess = async (User userToAssociate) => } }; -Console.WriteLine("Setting up LdapAssociation timer"); +logger.LogTrace("Setting up LdapAssociation timer"); var ldapAssociationTimer = new CronTimer("*/10 * * * *", "Europe/Vienna", includingSeconds: false); ldapAssociationTimer.OnOccurence += async (s, ea) => { var dci = DataContext.Instance; if (dci.Users == null) { - Console.WriteLine("Unable to properly initialize DB context!"); + logger.LogError("Unable to properly initialize DB context!"); await dci.DisposeAsync(); return; } @@ -186,13 +186,6 @@ ldapAssociationTimer.OnOccurence += async (s, ea) => } await dci.DisposeAsync(); }; -ldapAssociationTimer.Start(); - -var searchResults = LdapIntegration.Instance.SearchInAD( - AppConfiguration.Instance.LDAPConfig.LDAPQueryBase, - $"(memberOf={AppConfiguration.Instance.LDAPConfig.CrewGroup})", - SearchScope.Subtree -); // Add services to the container. builder.Services.AddRazorPages(); @@ -205,9 +198,20 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); +// only start interaction timers in production builds +// for local/development testing we want those disabled +if (!app.Environment.IsDevelopment()) +{ + userCheckTimer.Start(); + userIntroTimer.Start(); + pickOfTheDayTimer.Start(); + ldapAssociationTimer.Start(); +} + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { diff --git a/song_of_the_day/SongValidators/ISongValidator.cs b/song_of_the_day/SongValidators/ISongValidator.cs new file mode 100644 index 0000000..b8241a6 --- /dev/null +++ b/song_of_the_day/SongValidators/ISongValidator.cs @@ -0,0 +1,8 @@ +public interface ISongValidator +{ + Task ValidateAsync(Uri songUri); + + bool CanValidateUri(Uri songUri); + + Task CanExtractSongMetadataAsync(Uri songUri); +} \ No newline at end of file diff --git a/song_of_the_day/SongValidators/SongResolver.cs b/song_of_the_day/SongValidators/SongResolver.cs new file mode 100644 index 0000000..48a71a9 --- /dev/null +++ b/song_of_the_day/SongValidators/SongResolver.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging; + +public class SongResolver +{ + private readonly IEnumerable _songValidators; + + private readonly ILogger logger; + + public SongResolver() + { + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information)); + this.logger = factory.CreateLogger("SongResolver"); + + this._songValidators = new List(); + + foreach (Type mytype in System.Reflection.Assembly.GetExecutingAssembly().GetTypes() + .Where(mytype => mytype.GetInterfaces().Contains(typeof(ISongValidator)) && !(mytype.Name.EndsWith("Base")))) { + if (Activator.CreateInstance(mytype) is ISongValidator validator) + { + logger.LogDebug("Registering song validator: {ValidatorType}", mytype.Name); + this._songValidators = this._songValidators.Append(validator); + } + } + } + + public async Task ResolveSongAsync(Uri songUri) + { + foreach (var validator in _songValidators) + { + if (validator.CanValidateUri(songUri)) + { + if (!await validator.CanExtractSongMetadataAsync(songUri)) + { + this.logger.LogWarning("Cannot extract metadata for song URI: {SongUri}", songUri); + return new Song { + Artist = "Unknown Artist", + Name = "Unknown Title", + Url = songUri.ToString(), + Provider = SongProvider.PlainHttp, + }; + } + return await validator.ValidateAsync(songUri); + } + } + + return new Song { + Artist = "Unknown Artist", + Name = "Unknown Title", + Url = songUri.ToString(), + Provider = SongProvider.PlainHttp, + }; + } + + public bool CanValidate(Uri songUri) + { + foreach (var validator in _songValidators) + { + if (validator.CanValidateUri(songUri)) + { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/song_of_the_day/SongValidators/SongValidatorBase.cs b/song_of_the_day/SongValidators/SongValidatorBase.cs new file mode 100644 index 0000000..636f729 --- /dev/null +++ b/song_of_the_day/SongValidators/SongValidatorBase.cs @@ -0,0 +1,16 @@ +using System.Text.RegularExpressions; + +public abstract class SongValidatorBase : ISongValidator +{ + public abstract Task ValidateAsync(Uri songUri); + + public abstract Task CanExtractSongMetadataAsync(Uri songUri); + + public abstract bool CanValidateUri(Uri songUri); + + protected string LookupSpotifyId(string songName, string songArtist) + { + // TODO: Implement Spotify ID lookup logic + return songName + " by " + songArtist; + } +} \ No newline at end of file diff --git a/song_of_the_day/SongValidators/SpotifyValidator.cs b/song_of_the_day/SongValidators/SpotifyValidator.cs new file mode 100644 index 0000000..9ee3306 --- /dev/null +++ b/song_of_the_day/SongValidators/SpotifyValidator.cs @@ -0,0 +1,40 @@ +using System.Text.RegularExpressions; +using AngleSharp; +using AngleSharp.Dom; +using AngleSharp.Html.Dom; + +public class SpotifyValidator : UriBasedSongValidatorBase +{ + public override string UriValidatorRegex => @"^(https?://)?open.spotify.com/track/([a-zA-Z0-9_-]{22})(\?si=[a-zA-Z0-9_-]+)?$"; + + private SpotifyApiClient spotifyApiClient; + + public SpotifyValidator() + { + spotifyApiClient = new SpotifyApiClient(); + } + + public override async Task CanExtractSongMetadataAsync(Uri songUri) + { + return this.CanValidateUri(songUri); + } + + public override async Task ValidateAsync(Uri songUri) + { + var regexp = new Regex(UriValidatorRegex, RegexOptions.IgnoreCase); + var trackIdMatch = regexp.Match(songUri.ToString()).Groups[2].Value; + + var track = await spotifyApiClient.GetTrackByIdAsync(trackIdMatch); + + var song = new Song + { + Name = track.Name, + Artist = track.Artists.FirstOrDefault()?.Name ?? "Unknown Artist", + Url = songUri.ToString(), + Provider = SongProvider.Spotify, + SpotifyId = trackIdMatch + }; + + return song; + } +} \ No newline at end of file diff --git a/song_of_the_day/SongValidators/UriBasedSongValidatorBase.cs b/song_of_the_day/SongValidators/UriBasedSongValidatorBase.cs new file mode 100644 index 0000000..f6d310d --- /dev/null +++ b/song_of_the_day/SongValidators/UriBasedSongValidatorBase.cs @@ -0,0 +1,12 @@ +using System.Text.RegularExpressions; + +public abstract class UriBasedSongValidatorBase : SongValidatorBase +{ + public abstract string UriValidatorRegex { get; } + + public override bool CanValidateUri(Uri songUri) + { + var regexp = new Regex(UriValidatorRegex, RegexOptions.IgnoreCase); + return regexp.Match(songUri.ToString()).Success; + } +} \ No newline at end of file diff --git a/song_of_the_day/SongValidators/YoutubeMusicValidator.cs b/song_of_the_day/SongValidators/YoutubeMusicValidator.cs new file mode 100644 index 0000000..e27b1f6 --- /dev/null +++ b/song_of_the_day/SongValidators/YoutubeMusicValidator.cs @@ -0,0 +1,43 @@ +using AngleSharp; +using AngleSharp.Dom; + +public class YoutubeMusicValidator : UriBasedSongValidatorBase +{ + public override string UriValidatorRegex => @"^(https?://)?(music\.youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})"; + + public override async Task CanExtractSongMetadataAsync(Uri songUri) + { + return this.CanValidateUri(songUri); + } + + public override async Task ValidateAsync(Uri songUri) + { + var title = string.Empty; + var artist = string.Empty; + + using(HttpClient httpClient = new HttpClient()) + { + var response = await httpClient.GetAsync(songUri); + var config = Configuration.Default.WithDefaultLoader(); + var context = BrowsingContext.New(config); + using(var document = await context.OpenAsync(async req => req.Content(await response.Content.ReadAsStringAsync()))) + { + // document.getElementsByTagName("ytmusic-player-queue-item")[0].getElementsByClassName("song-title")[0].innerHTML + title = document.QuerySelector(".ytmusic-player-queue-item")?.QuerySelector(".song-title")?.InnerHtml; + // document.getElementsByTagName("ytmusic-player-queue-item")[0].getElementsByClassName("byline")[0].innerHTML + artist = document.QuerySelector(".ytmusic-player-queue-item")?.QuerySelector(".byline")?.InnerHtml; + } + } + + var song = new Song + { + Name = title, + Artist = artist, + Url = songUri.ToString(), + Provider = SongProvider.YouTube, + SpotifyId = this.LookupSpotifyId(title, artist) + }; + + return song; + } +} \ No newline at end of file diff --git a/song_of_the_day/SongValidators/YoutubeValidator.cs b/song_of_the_day/SongValidators/YoutubeValidator.cs new file mode 100644 index 0000000..5dd7fff --- /dev/null +++ b/song_of_the_day/SongValidators/YoutubeValidator.cs @@ -0,0 +1,55 @@ +using AngleSharp; +using AngleSharp.Dom; +using AngleSharp.Html.Dom; + +public class YoutubeValidator : UriBasedSongValidatorBase +{ + public override string UriValidatorRegex => @"^(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})"; + + public override async Task CanExtractSongMetadataAsync(Uri songUri) + { + using(HttpClient httpClient = new HttpClient()) + { + var response = await httpClient.GetAsync(songUri); + var config = Configuration.Default.WithDefaultLoader(); + var context = BrowsingContext.New(config); + using(var document = await context.OpenAsync(async req => req.Content(await response.Content.ReadAsStringAsync()))) + { + var documentContents = (document.ChildNodes[1] as HtmlElement).InnerHtml; + var titleElement = document.QuerySelectorAll(".yt-video-attribute-view-model__title")[0]; + var artistParentElement = document.QuerySelectorAll(".yt-video-attribute-view-model__secondary-subtitle")[0]; + + return titleElement != null && artistParentElement != null && artistParentElement.Children.Length > 0; + } + } + } + + public override async Task ValidateAsync(Uri songUri) + { + var title = string.Empty; + var artist = string.Empty; + + using(HttpClient httpClient = new HttpClient()) + { + var response = await httpClient.GetAsync(songUri); + var config = Configuration.Default.WithDefaultLoader(); + var context = BrowsingContext.New(config); + using(var document = await context.OpenAsync(async req => req.Content(await response.Content.ReadAsStringAsync()))) + { + title = document.QuerySelectorAll(".yt-video-attribute-view-model__title")[0]?.InnerHtml; + artist = document.QuerySelectorAll(".yt-video-attribute-view-model__secondary-subtitle")[0]?.Children[0]?.InnerHtml; + } + } + + var song = new Song + { + Name = title, + Artist = artist, + Url = songUri.ToString(), + Provider = SongProvider.YouTube, + SpotifyId = this.LookupSpotifyId(title, artist) + }; + + return song; + } +} \ No newline at end of file diff --git a/song_of_the_day/SpotifyIntegration/SpotifyClient.cs b/song_of_the_day/SpotifyIntegration/SpotifyClient.cs new file mode 100644 index 0000000..fc5397f --- /dev/null +++ b/song_of_the_day/SpotifyIntegration/SpotifyClient.cs @@ -0,0 +1,44 @@ +using SpotifyAPI.Web; + +public class SpotifyApiClient +{ + private SpotifyClient _spotifyClient; + + public SpotifyApiClient() + { + var config = SpotifyClientConfig.CreateDefault() + .WithAuthenticator(new ClientCredentialsAuthenticator( + AppConfiguration.Instance.SpotifyClientId, + AppConfiguration.Instance.SpotifyClientSecret)); + + _spotifyClient = new SpotifyClient(config); + } + + public async Task> GetTrackCandidatesAsync(string trackName, string artistName) + { + try + { + var searchResponse = await _spotifyClient.Search.Item(new SearchRequest(SearchRequest.Types.Track, $"{trackName} {artistName}") + { + Limit = 5 + }); + return searchResponse.Tracks.Items ?? new List(); + } + catch (APIException ex) + { + throw new Exception($"Error fetching tracks by query: \"{trackName} {artistName}\": {ex.Message}", ex); + } + } + + public async Task GetTrackByIdAsync(string trackId) + { + try + { + return await _spotifyClient.Tracks.Get(trackId); + } + catch (APIException ex) + { + throw new Exception($"Error fetching track by ID: {trackId}: {ex.Message}", ex); + } + } +} \ No newline at end of file diff --git a/song_of_the_day/song_of_the_day.csproj b/song_of_the_day/song_of_the_day.csproj index c3c30ce..f7de384 100644 --- a/song_of_the_day/song_of_the_day.csproj +++ b/song_of_the_day/song_of_the_day.csproj @@ -6,16 +6,18 @@ true + runtime; build; native; contentfiles; analyzers; buildtransitive all - + +