From 220f4d7ffdcbac198c06c8f136ce6ddd1f316ca7 Mon Sep 17 00:00:00 2001 From: Simon Diesenreiter Date: Thu, 5 Jun 2025 00:14:53 +0200 Subject: [PATCH] feat: implement song submission support, refs #5 --- ...144913_some more model updates.Designer.cs | 170 +++++++++++++ .../20250601144913_some more model updates.cs | 38 +++ .../Migrations/DataContextModelSnapshot.cs | 6 + .../MessengerIntegration/SignalIntegration.cs | 65 ++++- song_of_the_day/Pages/Index.cshtml | 27 +- song_of_the_day/Pages/Index.cshtml.cs | 14 +- .../Pages/Shared/SongPartialModel.cs | 16 ++ song_of_the_day/Pages/Shared/_Layout.cshtml | 2 +- .../Pages/Shared/_Layout.cshtml.css | 2 +- .../Pages/Shared/_SongPartial.cshtml | 9 +- .../_SpotifySongSuggestionsPartial.cshtml | 38 +++ .../_SpotifySongSuggestionsPartial.cshtml.css | 16 ++ song_of_the_day/Pages/SongSubmission.cshtml | 158 ++++++++++++ .../Pages/SongSubmission.cshtml.cs | 237 ++++++++++++++++++ song_of_the_day/Pages/SubmitSongs.cshtml | 25 -- song_of_the_day/Pages/SubmitSongs.cshtml.cs | 81 ------ song_of_the_day/Program.cs | 17 +- .../SongValidators/Base64UrlImageBuilder.cs | 23 ++ .../SongValidators/ISongValidator.cs | 2 + .../SongValidators/SongResolver.cs | 22 +- .../SongValidators/SongValidatorBase.cs | 20 +- .../SongValidators/SpotifyValidator.cs | 15 +- .../UriBasedSongValidatorBase.cs | 12 +- .../SongValidators/YoutubeMusicValidator.cs | 45 ++-- .../SongValidators/YoutubeValidator.cs | 53 ++-- song_of_the_day/song_of_the_day.csproj | 1 + song_of_the_day/swagger.json | 20 ++ 27 files changed, 943 insertions(+), 191 deletions(-) create mode 100644 song_of_the_day/Data/Migrations/20250601144913_some more model updates.Designer.cs create mode 100644 song_of_the_day/Data/Migrations/20250601144913_some more model updates.cs create mode 100644 song_of_the_day/Pages/Shared/SongPartialModel.cs create mode 100644 song_of_the_day/Pages/Shared/_SpotifySongSuggestionsPartial.cshtml create mode 100644 song_of_the_day/Pages/Shared/_SpotifySongSuggestionsPartial.cshtml.css create mode 100644 song_of_the_day/Pages/SongSubmission.cshtml create mode 100644 song_of_the_day/Pages/SongSubmission.cshtml.cs delete mode 100644 song_of_the_day/Pages/SubmitSongs.cshtml delete mode 100644 song_of_the_day/Pages/SubmitSongs.cshtml.cs create mode 100644 song_of_the_day/SongValidators/Base64UrlImageBuilder.cs diff --git a/song_of_the_day/Data/Migrations/20250601144913_some more model updates.Designer.cs b/song_of_the_day/Data/Migrations/20250601144913_some more model updates.Designer.cs new file mode 100644 index 0000000..0562ab6 --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250601144913_some more model updates.Designer.cs @@ -0,0 +1,170 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250601144913_some more model updates")] + partial class somemoremodelupdates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Song", b => + { + b.Property("SongId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SongId")); + + b.Property("Artist") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("SpotifyId") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("SongId"); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("SongSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("HasUsedSuggestion") + .HasColumnType("boolean"); + + b.Property("SongId") + .HasColumnType("integer"); + + b.Property("SuggestionHelperId") + .HasColumnType("integer"); + + b.Property("UserHasSubmitted") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("SuggestionHelperId"); + + b.HasIndex("UserId"); + + b.ToTable("SongSuggestions"); + }); + + modelBuilder.Entity("SuggestionHelper", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("SuggestionHelpers"); + }); + + modelBuilder.Entity("User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AssociationInProgress") + .HasColumnType("boolean"); + + b.Property("IsIntroduced") + .HasColumnType("boolean"); + + b.Property("LdapUserName") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("NickName") + .HasColumnType("text"); + + b.Property("SignalMemberId") + .HasColumnType("text"); + + b.Property("WasChosenForSuggestionThisRound") + .HasColumnType("boolean"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SongSuggestion", b => + { + b.HasOne("Song", "Song") + .WithMany() + .HasForeignKey("SongId"); + + b.HasOne("SuggestionHelper", "SuggestionHelper") + .WithMany() + .HasForeignKey("SuggestionHelperId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Song"); + + b.Navigation("SuggestionHelper"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250601144913_some more model updates.cs b/song_of_the_day/Data/Migrations/20250601144913_some more model updates.cs new file mode 100644 index 0000000..08771d1 --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250601144913_some more model updates.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + /// + public partial class somemoremodelupdates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Provider", + table: "Songs", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "SpotifyId", + table: "Songs", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Provider", + table: "Songs"); + + migrationBuilder.DropColumn( + name: "SpotifyId", + table: "Songs"); + } + } +} diff --git a/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs b/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs index 0c486bf..37b13e4 100644 --- a/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs +++ b/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs @@ -35,6 +35,12 @@ namespace song_of_the_day.DataMigrations b.Property("Name") .HasColumnType("text"); + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("SpotifyId") + .HasColumnType("text"); + b.Property("Url") .HasColumnType("text"); diff --git a/song_of_the_day/MessengerIntegration/SignalIntegration.cs b/song_of_the_day/MessengerIntegration/SignalIntegration.cs index dca7a55..7605544 100644 --- a/song_of_the_day/MessengerIntegration/SignalIntegration.cs +++ b/song_of_the_day/MessengerIntegration/SignalIntegration.cs @@ -1,6 +1,14 @@ using song_of_the_day; +public class LinkPreviewAttachment +{ + public string Url { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string Base64Image { get; set; } +} + public class SignalIntegration { private readonly ILogger logger; @@ -15,7 +23,8 @@ public class SignalIntegration var http = new HttpClient() { - BaseAddress = new Uri(uri + ":" + port) + BaseAddress = new Uri(uri + ":" + port), + Timeout = TimeSpan.FromSeconds(180), }; apiClient = new swaggerClient(http); apiClient.BaseUrl = ""; @@ -26,6 +35,12 @@ public class SignalIntegration private string phoneNumber; + public async Task GetMessagesAsync() + { + var messages = await apiClient.ReceiveAsync(this.phoneNumber, "120", "true", "true", "50", "false"); + logger.LogInformation($"Received {messages.Count} Signal messages."); + } + public async Task ListGroupsAsync() { logger.LogDebug("Listing all groups for phone number: {PhoneNumber}", this.phoneNumber); @@ -51,7 +66,7 @@ public class SignalIntegration SendMessageV2 data = new SendMessageV2(); data.Recipients = new List(); data.Recipients.Add(AppConfiguration.Instance.SignalGroupId); - data.Message = message; + data.Message = (AppConfiguration.Instance.UseBotTag ? "**[Proggy]**\n" : "") + message; data.Text_mode = SendMessageV2Text_mode.Styled; data.Number = AppConfiguration.Instance.HostPhoneNumber; var response = await apiClient.Send2Async(data); @@ -62,6 +77,29 @@ public class SignalIntegration } } + public async Task SendMessageToGroupAsync(string message, LinkPreviewAttachment previewData) + { + try + { + SendMessageV2 data = new SendMessageV2(); + data.Recipients = new List(); + data.Recipients.Add(AppConfiguration.Instance.SignalGroupId); + data.Message = (AppConfiguration.Instance.UseBotTag ? "**[Proggy]**\n" : "") + message; + data.Text_mode = SendMessageV2Text_mode.Styled; + data.Number = AppConfiguration.Instance.HostPhoneNumber; + data.Link_preview = new LinkPreviewType(); + data.Link_preview.Url = previewData.Url; + data.Link_preview.Title = previewData.Title; + data.Link_preview.Description = previewData.Description; + data.Link_preview.Base64_thumbnail = previewData.Base64Image; + var response = await apiClient.Send2Async(data); + } + catch (Exception ex) + { + logger.LogError("Exception (SendMessageToGroupAsync): " + ex.Message); + } + } + public async Task SendMessageToUserAsync(string message, string userId) { try @@ -80,6 +118,29 @@ public class SignalIntegration } } + public async Task SendMessageToUserAsync(string message, LinkPreviewAttachment previewData, string userId) + { + try + { + SendMessageV2 data = new SendMessageV2(); + data.Recipients = new List(); + data.Recipients.Add(userId); + data.Message = (AppConfiguration.Instance.UseBotTag ? "**[Proggy]**\n" : "") + message; + data.Text_mode = SendMessageV2Text_mode.Styled; + data.Number = AppConfiguration.Instance.HostPhoneNumber; + data.Link_preview = new LinkPreviewType(); + data.Link_preview.Url = previewData.Url; + data.Link_preview.Title = previewData.Title; + data.Link_preview.Description = previewData.Description; + data.Link_preview.Base64_thumbnail = previewData.Base64Image; + var response = await apiClient.Send2Async(data); + } + catch (Exception ex) + { + logger.LogError("Exception (SendMessageToUserAsync): " + ex.Message); + } + } + public async Task IntroduceUserAsync(User user) { if (user == null || user.SignalMemberId == null) diff --git a/song_of_the_day/Pages/Index.cshtml b/song_of_the_day/Pages/Index.cshtml index b38ff36..11118a5 100644 --- a/song_of_the_day/Pages/Index.cshtml +++ b/song_of_the_day/Pages/Index.cshtml @@ -5,6 +5,29 @@ }
-

Welcome

-

Learn about building Web apps with ASP.NET Core.

+

Submission History

+ + + + + + + + @foreach(var songSuggestion in Model.SongSuggestions) + { + @if(songSuggestion != null && songSuggestion.Song != null && songSuggestion.User != null && songSuggestion.UserHasSubmitted) + { + var displayName = string.IsNullOrEmpty(songSuggestion?.User?.NickName) + ? songSuggestion?.User.Name + : songSuggestion.User.NickName; + + + + + + + + } + } +
DateSongSubmitterDetails
@songSuggestion?.Date.ToString("dd. MM. yyyy")@string.Format("{0} - {1}", songSuggestion?.Song.Name, songSuggestion?.Song.Artist)@displayNameView
diff --git a/song_of_the_day/Pages/Index.cshtml.cs b/song_of_the_day/Pages/Index.cshtml.cs index 566164e..f5edbb9 100644 --- a/song_of_the_day/Pages/Index.cshtml.cs +++ b/song_of_the_day/Pages/Index.cshtml.cs @@ -1,5 +1,7 @@ +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; namespace sotd.Pages; @@ -12,8 +14,16 @@ public class IndexModel : PageModel _logger = logger; } - public void OnGet() - { + [BindProperty] + public List SongSuggestions { get; set; } = new List(); + public async Task OnGet() + { + using var dci = DataContext.Instance; + this.SongSuggestions = dci.SongSuggestions.OrderByDescending(s => s.Date) + .Take(50) + .Include(s => s.Song) + .Include(s => s.User) + .ToList(); } } diff --git a/song_of_the_day/Pages/Shared/SongPartialModel.cs b/song_of_the_day/Pages/Shared/SongPartialModel.cs new file mode 100644 index 0000000..53e91bc --- /dev/null +++ b/song_of_the_day/Pages/Shared/SongPartialModel.cs @@ -0,0 +1,16 @@ +public class SongPartialModel +{ + public Song InnerSong { get; set; } + + public string? Artist => InnerSong.Artist; + + public string? Name => InnerSong.Name; + + public string? SpotifyId => InnerSong.SpotifyId; + + public string? Url => InnerSong.Url; + + public SongProvider? Provider => InnerSong.Provider; + + public bool IsPageReadonly { get; set; } +} \ 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 681c919..19e1772 100644 --- a/song_of_the_day/Pages/Shared/_Layout.cshtml +++ b/song_of_the_day/Pages/Shared/_Layout.cshtml @@ -48,7 +48,7 @@ this.User.Identity.IsAuthenticated && DoesUserHaveClaimedPhoneNumber()) { } diff --git a/song_of_the_day/Pages/Shared/_Layout.cshtml.css b/song_of_the_day/Pages/Shared/_Layout.cshtml.css index 3ff01d4..4b57cfb 100644 --- a/song_of_the_day/Pages/Shared/_Layout.cshtml.css +++ b/song_of_the_day/Pages/Shared/_Layout.cshtml.css @@ -45,4 +45,4 @@ button.accept-policy { width: 100%; white-space: nowrap; line-height: 60px; -} +} \ No newline at end of file diff --git a/song_of_the_day/Pages/Shared/_SongPartial.cshtml b/song_of_the_day/Pages/Shared/_SongPartial.cshtml index 2b31914..788fa7f 100644 --- a/song_of_the_day/Pages/Shared/_SongPartial.cshtml +++ b/song_of_the_day/Pages/Shared/_SongPartial.cshtml @@ -1,7 +1,8 @@ -@model Song +@model SongPartialModel - + - + - \ No newline at end of file + + \ No newline at end of file diff --git a/song_of_the_day/Pages/Shared/_SpotifySongSuggestionsPartial.cshtml b/song_of_the_day/Pages/Shared/_SpotifySongSuggestionsPartial.cshtml new file mode 100644 index 0000000..12638ff --- /dev/null +++ b/song_of_the_day/Pages/Shared/_SpotifySongSuggestionsPartial.cshtml @@ -0,0 +1,38 @@ +@model List +
+ @foreach(var track in Model) + { +
+
@track.Name
+
@track.Artists[0].Name
+
@track.Album.Name
+ +
+ +
+
+ } +
+ + \ No newline at end of file diff --git a/song_of_the_day/Pages/Shared/_SpotifySongSuggestionsPartial.cshtml.css b/song_of_the_day/Pages/Shared/_SpotifySongSuggestionsPartial.cshtml.css new file mode 100644 index 0000000..3439b0a --- /dev/null +++ b/song_of_the_day/Pages/Shared/_SpotifySongSuggestionsPartial.cshtml.css @@ -0,0 +1,16 @@ +.songSelectorButton { + background-color: #e3e6e7; + padding: 5px; +} + +.songSelectorButton:nth-child(2n) { + background-color: #cacbce; +} + +.songSelectorButton.selected { + background-color: #a0c3e5; + border-radius: 2px; + border-style: solid; + border-width: 2px; + border-color: #285786; +} \ No newline at end of file diff --git a/song_of_the_day/Pages/SongSubmission.cshtml b/song_of_the_day/Pages/SongSubmission.cshtml new file mode 100644 index 0000000..8034c6b --- /dev/null +++ b/song_of_the_day/Pages/SongSubmission.cshtml @@ -0,0 +1,158 @@ +@page "{submissionIndex:int?}" +@model SongSubmissionModel +@{ + ViewData["Title"] = this.Model.SubmissionIndex == null ? "Song Submissions" : "New Song Submission"; +} + +@if(Model.SubmissionIndex == null) +{ +
+

Your Submission History:

+ + + + + + + + @foreach(var submission in Model.UserSongSubmissions) + { + + + + + + + } +
DateSuggestionSong
+ @submission.Date.ToString("dd. MM. yyyy") + + @if(submission.Song == null || submission.HasUsedSuggestion) + { + @submission.SuggestionHelper.Title + } + + @if(submission.Song != null) + { + @string.Format("{0} - {1}", submission.Song.Name, submission.Song.Artist); + } + else + { +
No submission yet!
+ } +
+
+} +else +{ +
+
+
Today's suggestionHelper is: @Model.SuggestionHelper.Title
+
@Model.SuggestionHelper.Description
+
+
+ + + + + @{ + var songPartialModel = new SongPartialModel() { + InnerSong = Model.SongData, + IsPageReadonly = Model.IsPageReadonly + }; + } +
+ +
+ @if(!Model.IsPageReadonly) + { +
+ +
+ } + +
+
+} + + +@if(Model.SubmissionIndex == null) +{ + // inject relevant scripts + +} +else +{ + +} \ No newline at end of file diff --git a/song_of_the_day/Pages/SongSubmission.cshtml.cs b/song_of_the_day/Pages/SongSubmission.cshtml.cs new file mode 100644 index 0000000..cf83cdd --- /dev/null +++ b/song_of_the_day/Pages/SongSubmission.cshtml.cs @@ -0,0 +1,237 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualBasic; + +namespace sotd.Pages; + +public class SongSubmissionModel : PageModel +{ + private readonly ILogger _logger; + + private SongResolver songResolver; + + private string _submitUrl; + + private SpotifyApiClient spotifyApiClient; + + private SignalIntegration signalIntegration; + + public SongSubmissionModel(ILogger logger, SongResolver songResolver, SpotifyApiClient spotifyApiClient, SignalIntegration signalIntegration) + { + _logger = logger; + this.spotifyApiClient = spotifyApiClient; + this.songResolver = songResolver; + this.signalIntegration = signalIntegration; + _submitUrl = string.Empty; + SongData = new Song(); + } + + [Parameter] + public int? SubmissionIndex { get; set; } + + [BindProperty] + public bool IsValidUrl { get; set; } = true; + + [BindProperty] + public bool IsPageReadonly { get; set; } = false; + + [BindProperty] + public SuggestionHelper SuggestionHelper { get; set; } = null; + + [BindProperty] + public List UserSongSubmissions { get; set; } = []; + + [BindProperty(SupportsGet = true)] + public string SubmitUrl + { + get + { + return _submitUrl; + } + set + { + _submitUrl = value == null ? string.Empty : 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; + } + } + } + + + public List SpotifySuggestions + { + get + { + var suggestionList = new List(); + if (!string.IsNullOrEmpty(this.SongData.Name) && !string.IsNullOrEmpty(this.SongData.Artist)) + { + suggestionList.AddRange(spotifyApiClient.GetTrackCandidatesAsync(this.SongData.Name, this.SongData.Artist).Result); + } + return suggestionList; + } + } + + public IActionResult OnGetSpotifySuggestions(string song, string artist, string submitUrl, SongProvider provider, string spotifyId) + { + this.SongData.Name = song; + this.SongData.Artist = artist; + this.SongData.Url = submitUrl; + this.SongData.Provider = provider; + this.SongData.SpotifyId = spotifyId; + return Partial("_SpotifySongSuggestionsPartial", this.SpotifySuggestions); + } + + [BindProperty] + public string CanSubmit + { + get + { + var disableCondition = !(string.IsNullOrEmpty(SongData?.Artist) && string.IsNullOrEmpty(SongData?.Name)); + return disableCondition ? "" : "disabled"; + } + } + + [BindProperty(SupportsGet = true)] + public Song SongData { get; set; } + + [BindProperty(SupportsGet = true)] + public bool HasUsedSuggestion { get; set; } = true; + + private async Task UpdateGroup(SongSuggestion suggestion) + { + var isItToday = suggestion.Date.Date == DateTime.Today; + var dateString = isItToday ? "submission for today" : "submission from " + suggestion.Date.ToString("dd. MM. yyyy"); + var displayName = string.IsNullOrEmpty(suggestion.User.NickName) ? suggestion.User.Name : suggestion.User.NickName; + var imageBuilder = await songResolver.GetPreviewImageAsync(suggestion.Song.SpotifyId); + var previewData = new LinkPreviewAttachment() + { + Title = suggestion.Song.Name, + Description = suggestion.Song.Artist, + Url = suggestion.Song.Url, + Base64Image = imageBuilder.ToString(), + }; + await signalIntegration.SendMessageToGroupAsync($"**{displayName}**'s " + dateString + $" is: \n\n {suggestion.Song.Url}", previewData); + if (suggestion.HasUsedSuggestion) + { + await signalIntegration.SendMessageToGroupAsync($"The suggestion used for this pick was: \n\n **{suggestion.SuggestionHelper.Title}**'s \n\n {suggestion.SuggestionHelper.Description}"); + } + } + + public async Task OnPost(int? submissionIndex) + { + if (submissionIndex == null) + { + throw new Exception("Attempt to submit song without submissionId"); + } + // Todo implement save submission + using var dci = DataContext.Instance; + var suggestion = dci.SongSuggestions + .Include(s => s.User) + .Include(s => s.SuggestionHelper) + .Where(s => s.Id == submissionIndex) + .FirstOrDefault(); + + if (suggestion == null) + { + throw new Exception("Attempt to submit song with invalid submissionId"); + } + + this.SongData.Artist = Request.Form["Artist"]; + this.SongData.Url = Request.Form["SubmitUrl"]; + this.SongData.Name = Request.Form["Name"]; + this.SongData.SpotifyId = Request.Form["SpotifyId"]; + this.SongData.Provider = Enum.Parse(Request.Form["Provider"]); + + // Let's check if we already have this song in the DB + var songToUse = dci.Songs.Where(s => s.SpotifyId == this.SongData.SpotifyId).FirstOrDefault(); + if (songToUse == null) + { + // Song was not in DB yet, creating a new one + var newSong = new Song() + { + SpotifyId = this.SongData.SpotifyId, + Artist = this.SongData.Artist, + Name = this.SongData.Name, + Url = this.SongData.Url, + Provider = this.SongData.Provider, + }; + var dbRecord = dci.Songs.Add(newSong); + var newId = await dci.SaveChangesAsync(); + songToUse = dbRecord.Entity; + } + suggestion.Song = songToUse; + suggestion.UserHasSubmitted = true; + suggestion.HasUsedSuggestion = this.HasUsedSuggestion; + dci.Update(suggestion); + await dci.SaveChangesAsync(); + // load overview page again after submitting + + await UpdateGroup(suggestion); + + return RedirectToPage("SongSubmission"); + } + + public void OnGet(int? submissionIndex) + { + this.SubmissionIndex = submissionIndex; + + // we want to show overview, so we need to fetch user submission list + if (submissionIndex == null) + { + using var dci = DataContext.Instance; + var currentUserName = this.User.Identity.Name; + this.UserSongSubmissions = dci.SongSuggestions + .Include(s => s.SuggestionHelper) + .Include(s => s.Song) + .Where(s => s.User.LdapUserName.Equals(currentUserName)) + .OrderByDescending(s => s.Date) + .ToList(); + } + else + { + using var dci = DataContext.Instance; + var songSuggestion = dci.SongSuggestions + .Include(s => s.SuggestionHelper) + .Include(s => s.Song) + .Where(s => s.Id.Equals(submissionIndex.Value)) + .First(); + this.SuggestionHelper = songSuggestion.SuggestionHelper; + this.SongData = songSuggestion.Song == null ? new Song() : songSuggestion.Song; + this.SubmitUrl = this.SongData.Url; + if (!string.IsNullOrEmpty(this.SongData.Name) && !string.IsNullOrEmpty(this.SongData.Artist)) + { + this.IsPageReadonly = true; + } + } + } + + public IActionResult OnGetUpdate() + { + var songUrl = Request.Query["SubmitUrl"]; + this.SubmitUrl = songUrl.ToString(); + var songPartialModel = new SongPartialModel() + { + InnerSong = SongData, + IsPageReadonly = false + }; + return Partial("_SongPartial", songPartialModel); ; + } +} diff --git a/song_of_the_day/Pages/SubmitSongs.cshtml b/song_of_the_day/Pages/SubmitSongs.cshtml deleted file mode 100644 index 8f070e6..0000000 --- a/song_of_the_day/Pages/SubmitSongs.cshtml +++ /dev/null @@ -1,25 +0,0 @@ -@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 deleted file mode 100644 index 886c3d0..0000000 --- a/song_of_the_day/Pages/SubmitSongs.cshtml.cs +++ /dev/null @@ -1,81 +0,0 @@ -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; - _submitUrl = string.Empty; - SongData = new Song(); - } - - [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 d5fc4d5..a9c0148 100644 --- a/song_of_the_day/Program.cs +++ b/song_of_the_day/Program.cs @@ -20,6 +20,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); var app = builder.Build(); @@ -143,15 +144,15 @@ pickOfTheDayTimer.OnOccurence += async (s, ea) => }; if (luckyUser.SignalMemberId is string signalId) { - await dci.SongSuggestions.AddAsync(newSongSuggestion); + var result = await dci.SongSuggestions.AddAsync(newSongSuggestion); + newSongSuggestion = result.Entity; luckyUser.WasChosenForSuggestionThisRound = true; await dci.SaveChangesAsync(); var signalIntegration = app.Services.GetService(); await signalIntegration.SendMessageToGroupAsync($"Today's chosen person to share a song is: **{userName}**"); - await signalIntegration.SendMessageToGroupAsync($"Today's (optional) suggestion helper to help you pick a song is:\n\n**{suggestion.Title}**\n\n*{suggestion.Description}*"); await signalIntegration.SendMessageToUserAsync($"Congratulations, you have been chosen to share a song today!", signalId); await signalIntegration.SendMessageToUserAsync($"Today's (optional) suggestion helper to help you pick a song is:\n\n**{suggestion.Title}**\n\n*{suggestion.Description}*", signalId); - await signalIntegration.SendMessageToUserAsync($"For now please just share your suggestion with the group - in the future I might ask you to share directly with me or via the website to help me keep track of past suggestions!", luckyUser.SignalMemberId); + await signalIntegration.SendMessageToUserAsync($"Please navigate to https://sord.disi.dev/SongSubmission/{newSongSuggestion.Id} to submit your choice!", luckyUser.SignalMemberId); } await dci.DisposeAsync(); }; @@ -197,6 +198,16 @@ ldapAssociationTimer.OnOccurence += async (s, ea) => await dci.DisposeAsync(); }; +logger.LogTrace("Setting up MessageSync timer"); +var messageSyncTimer = new CronTimer("*/10 * * * *", "Europe/Vienna", includingSeconds: false); +messageSyncTimer.OnOccurence += async (s, ea) => +{ + var signalIntegration = app.Services.GetService(); + await signalIntegration.GetMessagesAsync(); +}; +// disabled for now, is still buggy +// messageSyncTimer.Start(); + // only start interaction timers in production builds // for local/development testing we want those disabled if (!app.Environment.IsDevelopment()) diff --git a/song_of_the_day/SongValidators/Base64UrlImageBuilder.cs b/song_of_the_day/SongValidators/Base64UrlImageBuilder.cs new file mode 100644 index 0000000..1a5ef75 --- /dev/null +++ b/song_of_the_day/SongValidators/Base64UrlImageBuilder.cs @@ -0,0 +1,23 @@ +public class Base64UrlImageBuilder +{ + public string ContentType { set; get; } + + public string Url + { + set + { + var httpClient = new HttpClient(); + var response = (httpClient.GetAsync(new Uri($"{value}"))).Result; + var bytes = (response.Content.ReadAsByteArrayAsync()).Result; + FileContents = Convert.ToBase64String(bytes); + } + } + + private string FileContents { get; set; } + + public override string ToString() + { + //return $"data:{ContentType};base64,{FileContents}"; + return $"{FileContents}"; + } +} \ No newline at end of file diff --git a/song_of_the_day/SongValidators/ISongValidator.cs b/song_of_the_day/SongValidators/ISongValidator.cs index b8241a6..72b3789 100644 --- a/song_of_the_day/SongValidators/ISongValidator.cs +++ b/song_of_the_day/SongValidators/ISongValidator.cs @@ -5,4 +5,6 @@ public interface ISongValidator bool CanValidateUri(Uri songUri); Task CanExtractSongMetadataAsync(Uri songUri); + + SongProvider GetSongProvider(); } \ No newline at end of file diff --git a/song_of_the_day/SongValidators/SongResolver.cs b/song_of_the_day/SongValidators/SongResolver.cs index e8747b1..4fd3be4 100644 --- a/song_of_the_day/SongValidators/SongResolver.cs +++ b/song_of_the_day/SongValidators/SongResolver.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using SpotifyAPI.Web; public class SongResolver { @@ -6,16 +7,20 @@ public class SongResolver private readonly ILogger logger; - public SongResolver(ILogger logger) + private SpotifyApiClient spotifyApiClient; + + public SongResolver(ILogger logger, ILoggerFactory loggerFactory, SpotifyApiClient spotifyApiClient) { this.logger = logger; this._songValidators = new List(); + this.spotifyApiClient = spotifyApiClient; foreach (Type mytype in System.Reflection.Assembly.GetExecutingAssembly().GetTypes() - .Where(mytype => mytype.GetInterfaces().Contains(typeof(ISongValidator)) && !(mytype.Name.EndsWith("Base")))) + .Where(mytype => { return (mytype.GetInterfaces().Contains(typeof(ISongValidator)) && !(mytype.Name.Split("`")[0].EndsWith("Base"))); })) { - if (Activator.CreateInstance(mytype) is ISongValidator validator) + var typedLogger = loggerFactory.CreateLogger(mytype); + if (Activator.CreateInstance(mytype, typedLogger, spotifyApiClient) is ISongValidator validator) { logger.LogDebug("Registering song validator: {ValidatorType}", mytype.Name); this._songValidators = this._songValidators.Append(validator); @@ -65,4 +70,15 @@ public class SongResolver return false; } + + public async Task GetPreviewImageAsync(string spotifyId) + { + var track = await spotifyApiClient.GetTrackByIdAsync(spotifyId); + var url = track.Album.Images.FirstOrDefault().Url; + return new Base64UrlImageBuilder() + { + Url = url, + ContentType = "image/jpeg" + }; + } } \ No newline at end of file diff --git a/song_of_the_day/SongValidators/SongValidatorBase.cs b/song_of_the_day/SongValidators/SongValidatorBase.cs index 636f729..00cf8ab 100644 --- a/song_of_the_day/SongValidators/SongValidatorBase.cs +++ b/song_of_the_day/SongValidators/SongValidatorBase.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Threading.Tasks; public abstract class SongValidatorBase : ISongValidator { @@ -8,9 +9,22 @@ public abstract class SongValidatorBase : ISongValidator public abstract bool CanValidateUri(Uri songUri); - protected string LookupSpotifyId(string songName, string songArtist) + public abstract SongProvider GetSongProvider(); + + protected SpotifyApiClient _spotifyApiClient; + + protected ILogger _logger; + + public SongValidatorBase(ILogger logger, SpotifyApiClient spotifyApiClient) { - // TODO: Implement Spotify ID lookup logic - return songName + " by " + songArtist; + _spotifyApiClient = spotifyApiClient; + _logger = logger; + + } + + protected async Task LookupSpotifyIdAsync(string songName, string songArtist) + { + var candidates = await _spotifyApiClient.GetTrackCandidatesAsync(songName, songArtist); + return candidates.Any() ? candidates[0].Id : ""; } } \ No newline at end of file diff --git a/song_of_the_day/SongValidators/SpotifyValidator.cs b/song_of_the_day/SongValidators/SpotifyValidator.cs index 3f2fc8b..a1d46fb 100644 --- a/song_of_the_day/SongValidators/SpotifyValidator.cs +++ b/song_of_the_day/SongValidators/SpotifyValidator.cs @@ -7,24 +7,25 @@ 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 SpotifyValidator(ILogger _logger, SpotifyApiClient spotifyApiClient) : base(_logger, spotifyApiClient) + {} public override Task CanExtractSongMetadataAsync(Uri songUri) { return Task.FromResult(this.CanValidateUri(songUri)); } + public override SongProvider GetSongProvider() + { + return SongProvider.Spotify; + } + 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 track = await _spotifyApiClient.GetTrackByIdAsync(trackIdMatch); var song = new Song { diff --git a/song_of_the_day/SongValidators/UriBasedSongValidatorBase.cs b/song_of_the_day/SongValidators/UriBasedSongValidatorBase.cs index f6d310d..6e25ecc 100644 --- a/song_of_the_day/SongValidators/UriBasedSongValidatorBase.cs +++ b/song_of_the_day/SongValidators/UriBasedSongValidatorBase.cs @@ -4,9 +4,17 @@ public abstract class UriBasedSongValidatorBase : SongValidatorBase { public abstract string UriValidatorRegex { get; } - public override bool CanValidateUri(Uri songUri) + public UriBasedSongValidatorBase(ILogger logger, SpotifyApiClient spotifyApiClient) : base(logger, spotifyApiClient) + {} + + public Match GetUriMatch(Uri songUri) { var regexp = new Regex(UriValidatorRegex, RegexOptions.IgnoreCase); - return regexp.Match(songUri.ToString()).Success; + return regexp.Match(songUri.ToString()); + } + + public override bool CanValidateUri(Uri songUri) + { + return GetUriMatch(songUri).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 index 4e972d2..00df121 100644 --- a/song_of_the_day/SongValidators/YoutubeMusicValidator.cs +++ b/song_of_the_day/SongValidators/YoutubeMusicValidator.cs @@ -1,9 +1,22 @@ using AngleSharp; using AngleSharp.Dom; +using YouTubeMusicAPI.Client; public class YoutubeMusicValidator : UriBasedSongValidatorBase { - public override string UriValidatorRegex => @"^(https?://)?(music\.youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})"; + public override string UriValidatorRegex => @"^(https?://)?(music\.youtube\.com/playlist\?list=)([a-zA-Z0-9_-]+)"; + + private YouTubeMusicClient youtubeClient; + + public YoutubeMusicValidator(ILogger logger, SpotifyApiClient spotifyApiClient) : base(logger, spotifyApiClient) + { + youtubeClient = new YouTubeMusicClient(logger, "AT", null, null, null); + } + + public override SongProvider GetSongProvider() + { + return SongProvider.YoutubeMusic; + } public override Task CanExtractSongMetadataAsync(Uri songUri) { @@ -12,35 +25,23 @@ public class YoutubeMusicValidator : UriBasedSongValidatorBase public override async Task ValidateAsync(Uri songUri) { - var title = string.Empty; - var artist = string.Empty; + var match = this.GetUriMatch(songUri); + var playlistId = match.Groups[3].Value; - 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 playlistResult = await youtubeClient.GetCommunityPlaylistInfoAsync(playlistId); + var songData = playlistResult.Songs[0]; + + var title = songData.Name; + var artist = songData.Artists[0].Name; -#pragma warning disable CS8604 // Possible null reference argument. -#pragma warning disable CS8604 // Possible null reference argument. var song = new Song { Name = title, Artist = artist, Url = songUri.ToString(), - Provider = SongProvider.YouTube, - SpotifyId = this.LookupSpotifyId(title, artist) + Provider = SongProvider.YoutubeMusic, + SpotifyId = await this.LookupSpotifyIdAsync(title, artist) }; -#pragma warning restore CS8604 // Possible null reference argument. -#pragma warning restore CS8604 // Possible null reference argument. return song; } diff --git a/song_of_the_day/SongValidators/YoutubeValidator.cs b/song_of_the_day/SongValidators/YoutubeValidator.cs index b1bd554..470b97f 100644 --- a/song_of_the_day/SongValidators/YoutubeValidator.cs +++ b/song_of_the_day/SongValidators/YoutubeValidator.cs @@ -1,60 +1,47 @@ using AngleSharp; using AngleSharp.Dom; using AngleSharp.Html.Dom; +using YouTubeMusicAPI.Client; public class YoutubeValidator : UriBasedSongValidatorBase { + private YouTubeMusicClient youtubeClient; + + public YoutubeValidator(ILogger logger, SpotifyApiClient spotifyApiClient) : base(logger, spotifyApiClient) + { + youtubeClient = new("AT"); + } + 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()))) - { -#pragma warning disable CS8602 // Dereference of a possibly null reference. - var documentContents = (document.ChildNodes[1] as HtmlElement).InnerHtml; -#pragma warning restore CS8602 // Dereference of a possibly null reference. - var titleElement = document.QuerySelectorAll(".yt-video-attribute-view-model__title")[0]; - var artistParentElement = document.QuerySelectorAll(".yt-video-attribute-view-model__secondary-subtitle")[0]; + return this.CanValidateUri(songUri); + } - return titleElement != null && artistParentElement != null && artistParentElement.Children.Length > 0; - } - } + public override SongProvider GetSongProvider() + { + return SongProvider.YouTube; } public override async Task ValidateAsync(Uri songUri) { - var title = string.Empty; - var artist = string.Empty; + var match = this.GetUriMatch(songUri); + var songId = match.Groups[4].Value; - 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 songData = await youtubeClient.GetSongVideoInfoAsync(songId); + + var title = songData.Name; + var artist = songData.Artists[0].Name; -#pragma warning disable CS8604 // Possible null reference argument. -#pragma warning disable CS8604 // Possible null reference argument. var song = new Song { Name = title, Artist = artist, Url = songUri.ToString(), Provider = SongProvider.YouTube, - SpotifyId = this.LookupSpotifyId(title, artist) + SpotifyId = await this.LookupSpotifyIdAsync(title, artist) }; -#pragma warning restore CS8604 // Possible null reference argument. -#pragma warning restore CS8604 // Possible null reference argument. return song; } diff --git a/song_of_the_day/song_of_the_day.csproj b/song_of_the_day/song_of_the_day.csproj index f7de384..7ba6d46 100644 --- a/song_of_the_day/song_of_the_day.csproj +++ b/song_of_the_day/song_of_the_day.csproj @@ -18,6 +18,7 @@ + diff --git a/song_of_the_day/swagger.json b/song_of_the_day/swagger.json index d14c41d..52efc63 100644 --- a/song_of_the_day/swagger.json +++ b/song_of_the_day/swagger.json @@ -2423,6 +2423,9 @@ "edit_timestamp": { "type": "integer" }, + "link_preview": { + "$ref": "#/definitions/data.LinkPreviewType" + }, "mentions": { "type": "array", "items": { @@ -2817,6 +2820,23 @@ } } }, + "data.LinkPreviewType": { + "type": "object", + "properties": { + "base64_thumbnail": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, "data.MessageMention": { "type": "object", "properties": {