From 2e876ad628645351341376f474b40e212f9af8b4 Mon Sep 17 00:00:00 2001 From: Simon Diesenreiter Date: Sun, 20 Jul 2025 03:18:22 +0200 Subject: [PATCH] feat: song likes and initial implementation of Spotify playlist support, refs #9 --- song_of_the_day/Data/DataContext.cs | 3 +- ...dding song likes and playlists.Designer.cs | 268 ++++++++++++++++ ...9185147_adding song likes and playlists.cs | 170 +++++++++++ ...Spotify session data for users.Designer.cs | 282 +++++++++++++++++ ...ues with Spotify session data for users.cs | 63 ++++ ...20250719230009_some more fixes.Designer.cs | 282 +++++++++++++++++ .../20250719230009_some more fixes.cs | 58 ++++ ...233912_explicitly specify keys.Designer.cs | 282 +++++++++++++++++ .../20250719233912_explicitly specify keys.cs | 22 ++ ... auth token non-null contraint.Designer.cs | 280 +++++++++++++++++ ...03809_fix auth token non-null contraint.cs | 91 ++++++ .../Migrations/DataContextModelSnapshot.cs | 110 +++++++ .../Data/SmartPlaylistDefinition.cs | 30 ++ song_of_the_day/Data/User.cs | 13 + song_of_the_day/Pages/Index.cshtml | 12 + song_of_the_day/Pages/Index.cshtml.cs | 63 +++- .../Pages/Shared/_LoginView.cshtml | 8 +- song_of_the_day/Pages/SongSubmission.cshtml | 16 +- .../Pages/SongSubmission.cshtml.cs | 52 ++++ song_of_the_day/Pages/User.cshtml | 17 +- song_of_the_day/Pages/User.cshtml.cs | 43 ++- song_of_the_day/Program.cs | 62 ++++ .../PlayListSynchronizer.cs | 65 ++++ .../SpotifyIntegration/SpotifyApiClient.cs | 287 ++++++++++++++++++ .../SpotifyIntegration/SpotifyClient.cs | 44 --- 25 files changed, 2567 insertions(+), 56 deletions(-) create mode 100644 song_of_the_day/Data/Migrations/20250719185147_adding song likes and playlists.Designer.cs create mode 100644 song_of_the_day/Data/Migrations/20250719185147_adding song likes and playlists.cs create mode 100644 song_of_the_day/Data/Migrations/20250719222759_fix issues with Spotify session data for users.Designer.cs create mode 100644 song_of_the_day/Data/Migrations/20250719222759_fix issues with Spotify session data for users.cs create mode 100644 song_of_the_day/Data/Migrations/20250719230009_some more fixes.Designer.cs create mode 100644 song_of_the_day/Data/Migrations/20250719230009_some more fixes.cs create mode 100644 song_of_the_day/Data/Migrations/20250719233912_explicitly specify keys.Designer.cs create mode 100644 song_of_the_day/Data/Migrations/20250719233912_explicitly specify keys.cs create mode 100644 song_of_the_day/Data/Migrations/20250720003809_fix auth token non-null contraint.Designer.cs create mode 100644 song_of_the_day/Data/Migrations/20250720003809_fix auth token non-null contraint.cs create mode 100644 song_of_the_day/Data/SmartPlaylistDefinition.cs create mode 100644 song_of_the_day/SpotifyIntegration/PlayListSynchronizer.cs create mode 100644 song_of_the_day/SpotifyIntegration/SpotifyApiClient.cs delete mode 100644 song_of_the_day/SpotifyIntegration/SpotifyClient.cs diff --git a/song_of_the_day/Data/DataContext.cs b/song_of_the_day/Data/DataContext.cs index b4d54eb..3a87f7c 100644 --- a/song_of_the_day/Data/DataContext.cs +++ b/song_of_the_day/Data/DataContext.cs @@ -12,9 +12,10 @@ public class DataContext : DbContext public DbSet? Songs { get; set; } public DbSet? SongSuggestions { get; set; } public DbSet? SuggestionHelpers { get; set; } + public DbSet? SmartPlaylistDefinitions { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql($"Host={AppConfiguration.Instance.DatabaseUri}:{AppConfiguration.Instance.DatabasePort};" + $"Username={AppConfiguration.Instance.DatabaseUser};Password={AppConfiguration.Instance.DatabasePW};" - + $"Database={AppConfiguration.Instance.DatabaseName}"); + + $"Database={AppConfiguration.Instance.DatabaseName}"); } \ No newline at end of file diff --git a/song_of_the_day/Data/Migrations/20250719185147_adding song likes and playlists.Designer.cs b/song_of_the_day/Data/Migrations/20250719185147_adding song likes and playlists.Designer.cs new file mode 100644 index 0000000..185f956 --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250719185147_adding song likes and playlists.Designer.cs @@ -0,0 +1,268 @@ +// +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("20250719185147_adding song likes and playlists")] + partial class addingsonglikesandplaylists + { + /// + 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("SmartPlaylistDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IncludesLikedSongs") + .HasColumnType("boolean"); + + b.Property("IncludesUnCategorizedSongs") + .HasColumnType("boolean"); + + b.Property("SpotifyPlaylistId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SmartPlaylistDefinitions"); + }); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("SmartPlaylistDefinitionId1") + .HasColumnType("integer"); + + b.Property("SpotifyId") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("SongId"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + b.HasIndex("SmartPlaylistDefinitionId1"); + + b.HasIndex("UserId"); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + 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("SmartPlaylistDefinition", b => + { + b.HasOne("User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Song", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyExcludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId"); + + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyIncludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId1"); + + b.HasOne("User", null) + .WithMany("LikedSongs") + .HasForeignKey("UserId"); + }); + + 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"); + }); + + modelBuilder.Entity("SuggestionHelper", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("Categories") + .HasForeignKey("SmartPlaylistDefinitionId"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.Navigation("Categories"); + + b.Navigation("ExplicitlyExcludedSongs"); + + b.Navigation("ExplicitlyIncludedSongs"); + }); + + modelBuilder.Entity("User", b => + { + b.Navigation("LikedSongs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250719185147_adding song likes and playlists.cs b/song_of_the_day/Data/Migrations/20250719185147_adding song likes and playlists.cs new file mode 100644 index 0000000..086abff --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250719185147_adding song likes and playlists.cs @@ -0,0 +1,170 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + /// + public partial class addingsonglikesandplaylists : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SmartPlaylistDefinitionId", + table: "SuggestionHelpers", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "SmartPlaylistDefinitionId", + table: "Songs", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "SmartPlaylistDefinitionId1", + table: "Songs", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Songs", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "SmartPlaylistDefinitions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "text", nullable: true), + Description = table.Column(type: "text", nullable: true), + CreatedByUserId = table.Column(type: "integer", nullable: true), + IncludesUnCategorizedSongs = table.Column(type: "boolean", nullable: false), + IncludesLikedSongs = table.Column(type: "boolean", nullable: false), + SpotifyPlaylistId = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SmartPlaylistDefinitions", x => x.Id); + table.ForeignKey( + name: "FK_SmartPlaylistDefinitions_Users_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "Users", + principalColumn: "UserId"); + }); + + migrationBuilder.CreateIndex( + name: "IX_SuggestionHelpers_SmartPlaylistDefinitionId", + table: "SuggestionHelpers", + column: "SmartPlaylistDefinitionId"); + + migrationBuilder.CreateIndex( + name: "IX_Songs_SmartPlaylistDefinitionId", + table: "Songs", + column: "SmartPlaylistDefinitionId"); + + migrationBuilder.CreateIndex( + name: "IX_Songs_SmartPlaylistDefinitionId1", + table: "Songs", + column: "SmartPlaylistDefinitionId1"); + + migrationBuilder.CreateIndex( + name: "IX_Songs_UserId", + table: "Songs", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_SmartPlaylistDefinitions_CreatedByUserId", + table: "SmartPlaylistDefinitions", + column: "CreatedByUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Songs_SmartPlaylistDefinitions_SmartPlaylistDefinitionId", + table: "Songs", + column: "SmartPlaylistDefinitionId", + principalTable: "SmartPlaylistDefinitions", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Songs_SmartPlaylistDefinitions_SmartPlaylistDefinitionId1", + table: "Songs", + column: "SmartPlaylistDefinitionId1", + principalTable: "SmartPlaylistDefinitions", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Songs_Users_UserId", + table: "Songs", + column: "UserId", + principalTable: "Users", + principalColumn: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_SuggestionHelpers_SmartPlaylistDefinitions_SmartPlaylistDef~", + table: "SuggestionHelpers", + column: "SmartPlaylistDefinitionId", + principalTable: "SmartPlaylistDefinitions", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Songs_SmartPlaylistDefinitions_SmartPlaylistDefinitionId", + table: "Songs"); + + migrationBuilder.DropForeignKey( + name: "FK_Songs_SmartPlaylistDefinitions_SmartPlaylistDefinitionId1", + table: "Songs"); + + migrationBuilder.DropForeignKey( + name: "FK_Songs_Users_UserId", + table: "Songs"); + + migrationBuilder.DropForeignKey( + name: "FK_SuggestionHelpers_SmartPlaylistDefinitions_SmartPlaylistDef~", + table: "SuggestionHelpers"); + + migrationBuilder.DropTable( + name: "SmartPlaylistDefinitions"); + + migrationBuilder.DropIndex( + name: "IX_SuggestionHelpers_SmartPlaylistDefinitionId", + table: "SuggestionHelpers"); + + migrationBuilder.DropIndex( + name: "IX_Songs_SmartPlaylistDefinitionId", + table: "Songs"); + + migrationBuilder.DropIndex( + name: "IX_Songs_SmartPlaylistDefinitionId1", + table: "Songs"); + + migrationBuilder.DropIndex( + name: "IX_Songs_UserId", + table: "Songs"); + + migrationBuilder.DropColumn( + name: "SmartPlaylistDefinitionId", + table: "SuggestionHelpers"); + + migrationBuilder.DropColumn( + name: "SmartPlaylistDefinitionId", + table: "Songs"); + + migrationBuilder.DropColumn( + name: "SmartPlaylistDefinitionId1", + table: "Songs"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Songs"); + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250719222759_fix issues with Spotify session data for users.Designer.cs b/song_of_the_day/Data/Migrations/20250719222759_fix issues with Spotify session data for users.Designer.cs new file mode 100644 index 0000000..bf2b44d --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250719222759_fix issues with Spotify session data for users.Designer.cs @@ -0,0 +1,282 @@ +// +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("20250719222759_fix issues with Spotify session data for users")] + partial class fixissueswithSpotifysessiondataforusers + { + /// + 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("SmartPlaylistDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IncludesLikedSongs") + .HasColumnType("boolean"); + + b.Property("IncludesUnCategorizedSongs") + .HasColumnType("boolean"); + + b.Property("SpotifyPlaylistId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SmartPlaylistDefinitions"); + }); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("SmartPlaylistDefinitionId1") + .HasColumnType("integer"); + + b.Property("SpotifyId") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("SongId"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + b.HasIndex("SmartPlaylistDefinitionId1"); + + b.HasIndex("UserId"); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + 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("SpotiyAuthAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("SpotiyAuthCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SpotiyAuthExpiresAfterSeconds") + .HasColumnType("integer"); + + b.Property("SpotiyAuthRefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("WasChosenForSuggestionThisRound") + .HasColumnType("boolean"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.HasOne("User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Song", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyExcludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId"); + + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyIncludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId1"); + + b.HasOne("User", null) + .WithMany("LikedSongs") + .HasForeignKey("UserId"); + }); + + 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"); + }); + + modelBuilder.Entity("SuggestionHelper", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("Categories") + .HasForeignKey("SmartPlaylistDefinitionId"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.Navigation("Categories"); + + b.Navigation("ExplicitlyExcludedSongs"); + + b.Navigation("ExplicitlyIncludedSongs"); + }); + + modelBuilder.Entity("User", b => + { + b.Navigation("LikedSongs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250719222759_fix issues with Spotify session data for users.cs b/song_of_the_day/Data/Migrations/20250719222759_fix issues with Spotify session data for users.cs new file mode 100644 index 0000000..e28ccce --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250719222759_fix issues with Spotify session data for users.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + /// + public partial class fixissueswithSpotifysessiondataforusers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SpotiyAuthAccessToken", + table: "Users", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SpotiyAuthCreatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "SpotiyAuthExpiresAfterSeconds", + table: "Users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SpotiyAuthRefreshToken", + table: "Users", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SpotiyAuthAccessToken", + table: "Users"); + + migrationBuilder.DropColumn( + name: "SpotiyAuthCreatedAt", + table: "Users"); + + migrationBuilder.DropColumn( + name: "SpotiyAuthExpiresAfterSeconds", + table: "Users"); + + migrationBuilder.DropColumn( + name: "SpotiyAuthRefreshToken", + table: "Users"); + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250719230009_some more fixes.Designer.cs b/song_of_the_day/Data/Migrations/20250719230009_some more fixes.Designer.cs new file mode 100644 index 0000000..845f5f6 --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250719230009_some more fixes.Designer.cs @@ -0,0 +1,282 @@ +// +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("20250719230009_some more fixes")] + partial class somemorefixes + { + /// + 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("SmartPlaylistDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IncludesLikedSongs") + .HasColumnType("boolean"); + + b.Property("IncludesUnCategorizedSongs") + .HasColumnType("boolean"); + + b.Property("SpotifyPlaylistId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SmartPlaylistDefinitions"); + }); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("SmartPlaylistDefinitionId1") + .HasColumnType("integer"); + + b.Property("SpotifyId") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("SongId"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + b.HasIndex("SmartPlaylistDefinitionId1"); + + b.HasIndex("UserId"); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + 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("SpotifyAuthAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("SpotifyAuthCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SpotifyAuthExpiresAfterSeconds") + .HasColumnType("integer"); + + b.Property("SpotifyAuthRefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("WasChosenForSuggestionThisRound") + .HasColumnType("boolean"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.HasOne("User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Song", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyExcludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId"); + + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyIncludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId1"); + + b.HasOne("User", null) + .WithMany("LikedSongs") + .HasForeignKey("UserId"); + }); + + 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"); + }); + + modelBuilder.Entity("SuggestionHelper", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("Categories") + .HasForeignKey("SmartPlaylistDefinitionId"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.Navigation("Categories"); + + b.Navigation("ExplicitlyExcludedSongs"); + + b.Navigation("ExplicitlyIncludedSongs"); + }); + + modelBuilder.Entity("User", b => + { + b.Navigation("LikedSongs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250719230009_some more fixes.cs b/song_of_the_day/Data/Migrations/20250719230009_some more fixes.cs new file mode 100644 index 0000000..2bf66d4 --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250719230009_some more fixes.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + /// + public partial class somemorefixes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "SpotiyAuthRefreshToken", + table: "Users", + newName: "SpotifyAuthRefreshToken"); + + migrationBuilder.RenameColumn( + name: "SpotiyAuthExpiresAfterSeconds", + table: "Users", + newName: "SpotifyAuthExpiresAfterSeconds"); + + migrationBuilder.RenameColumn( + name: "SpotiyAuthCreatedAt", + table: "Users", + newName: "SpotifyAuthCreatedAt"); + + migrationBuilder.RenameColumn( + name: "SpotiyAuthAccessToken", + table: "Users", + newName: "SpotifyAuthAccessToken"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "SpotifyAuthRefreshToken", + table: "Users", + newName: "SpotiyAuthRefreshToken"); + + migrationBuilder.RenameColumn( + name: "SpotifyAuthExpiresAfterSeconds", + table: "Users", + newName: "SpotiyAuthExpiresAfterSeconds"); + + migrationBuilder.RenameColumn( + name: "SpotifyAuthCreatedAt", + table: "Users", + newName: "SpotiyAuthCreatedAt"); + + migrationBuilder.RenameColumn( + name: "SpotifyAuthAccessToken", + table: "Users", + newName: "SpotiyAuthAccessToken"); + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250719233912_explicitly specify keys.Designer.cs b/song_of_the_day/Data/Migrations/20250719233912_explicitly specify keys.Designer.cs new file mode 100644 index 0000000..29151da --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250719233912_explicitly specify keys.Designer.cs @@ -0,0 +1,282 @@ +// +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("20250719233912_explicitly specify keys")] + partial class explicitlyspecifykeys + { + /// + 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("SmartPlaylistDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IncludesLikedSongs") + .HasColumnType("boolean"); + + b.Property("IncludesUnCategorizedSongs") + .HasColumnType("boolean"); + + b.Property("SpotifyPlaylistId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SmartPlaylistDefinitions"); + }); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("SmartPlaylistDefinitionId1") + .HasColumnType("integer"); + + b.Property("SpotifyId") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("SongId"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + b.HasIndex("SmartPlaylistDefinitionId1"); + + b.HasIndex("UserId"); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + 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("SpotifyAuthAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("SpotifyAuthCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SpotifyAuthExpiresAfterSeconds") + .HasColumnType("integer"); + + b.Property("SpotifyAuthRefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("WasChosenForSuggestionThisRound") + .HasColumnType("boolean"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.HasOne("User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Song", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyExcludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId"); + + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyIncludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId1"); + + b.HasOne("User", null) + .WithMany("LikedSongs") + .HasForeignKey("UserId"); + }); + + 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"); + }); + + modelBuilder.Entity("SuggestionHelper", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("Categories") + .HasForeignKey("SmartPlaylistDefinitionId"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.Navigation("Categories"); + + b.Navigation("ExplicitlyExcludedSongs"); + + b.Navigation("ExplicitlyIncludedSongs"); + }); + + modelBuilder.Entity("User", b => + { + b.Navigation("LikedSongs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250719233912_explicitly specify keys.cs b/song_of_the_day/Data/Migrations/20250719233912_explicitly specify keys.cs new file mode 100644 index 0000000..5c28e13 --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250719233912_explicitly specify keys.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + /// + public partial class explicitlyspecifykeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250720003809_fix auth token non-null contraint.Designer.cs b/song_of_the_day/Data/Migrations/20250720003809_fix auth token non-null contraint.Designer.cs new file mode 100644 index 0000000..ee9d629 --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250720003809_fix auth token non-null contraint.Designer.cs @@ -0,0 +1,280 @@ +// +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("20250720003809_fix auth token non-null contraint")] + partial class fixauthtokennonnullcontraint + { + /// + 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("SmartPlaylistDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IncludesLikedSongs") + .HasColumnType("boolean"); + + b.Property("IncludesUnCategorizedSongs") + .HasColumnType("boolean"); + + b.Property("SpotifyPlaylistId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SmartPlaylistDefinitions"); + }); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("SmartPlaylistDefinitionId1") + .HasColumnType("integer"); + + b.Property("SpotifyId") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("SongId"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + b.HasIndex("SmartPlaylistDefinitionId1"); + + b.HasIndex("UserId"); + + 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("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SmartPlaylistDefinitionId"); + + 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("SpotifyAuthAccessToken") + .HasColumnType("text"); + + b.Property("SpotifyAuthCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SpotifyAuthExpiresAfterSeconds") + .HasColumnType("integer"); + + b.Property("SpotifyAuthRefreshToken") + .HasColumnType("text"); + + b.Property("WasChosenForSuggestionThisRound") + .HasColumnType("boolean"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.HasOne("User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Song", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyExcludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId"); + + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyIncludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId1"); + + b.HasOne("User", null) + .WithMany("LikedSongs") + .HasForeignKey("UserId"); + }); + + 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"); + }); + + modelBuilder.Entity("SuggestionHelper", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("Categories") + .HasForeignKey("SmartPlaylistDefinitionId"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.Navigation("Categories"); + + b.Navigation("ExplicitlyExcludedSongs"); + + b.Navigation("ExplicitlyIncludedSongs"); + }); + + modelBuilder.Entity("User", b => + { + b.Navigation("LikedSongs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/song_of_the_day/Data/Migrations/20250720003809_fix auth token non-null contraint.cs b/song_of_the_day/Data/Migrations/20250720003809_fix auth token non-null contraint.cs new file mode 100644 index 0000000..fa1f30a --- /dev/null +++ b/song_of_the_day/Data/Migrations/20250720003809_fix auth token non-null contraint.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace song_of_the_day.DataMigrations +{ + /// + public partial class fixauthtokennonnullcontraint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SpotifyAuthRefreshToken", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "SpotifyAuthExpiresAfterSeconds", + table: "Users", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AlterColumn( + name: "SpotifyAuthCreatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "SpotifyAuthAccessToken", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SpotifyAuthRefreshToken", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SpotifyAuthExpiresAfterSeconds", + table: "Users", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SpotifyAuthCreatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SpotifyAuthAccessToken", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + } +} diff --git a/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs b/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs index 37b13e4..7c951ed 100644 --- a/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs +++ b/song_of_the_day/Data/Migrations/DataContextModelSnapshot.cs @@ -21,6 +21,39 @@ namespace song_of_the_day.DataMigrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IncludesLikedSongs") + .HasColumnType("boolean"); + + b.Property("IncludesUnCategorizedSongs") + .HasColumnType("boolean"); + + b.Property("SpotifyPlaylistId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SmartPlaylistDefinitions"); + }); + modelBuilder.Entity("Song", b => { b.Property("SongId") @@ -38,14 +71,29 @@ namespace song_of_the_day.DataMigrations b.Property("Provider") .HasColumnType("integer"); + b.Property("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + + b.Property("SmartPlaylistDefinitionId1") + .HasColumnType("integer"); + b.Property("SpotifyId") .HasColumnType("text"); b.Property("Url") .HasColumnType("text"); + b.Property("UserId") + .HasColumnType("integer"); + b.HasKey("SongId"); + b.HasIndex("SmartPlaylistDefinitionId"); + + b.HasIndex("SmartPlaylistDefinitionId1"); + + b.HasIndex("UserId"); + b.ToTable("Songs"); }); @@ -97,11 +145,16 @@ namespace song_of_the_day.DataMigrations b.Property("Description") .HasColumnType("text"); + b.Property("SmartPlaylistDefinitionId") + .HasColumnType("integer"); + b.Property("Title") .HasColumnType("text"); b.HasKey("Id"); + b.HasIndex("SmartPlaylistDefinitionId"); + b.ToTable("SuggestionHelpers"); }); @@ -131,6 +184,18 @@ namespace song_of_the_day.DataMigrations b.Property("SignalMemberId") .HasColumnType("text"); + b.Property("SpotifyAuthAccessToken") + .HasColumnType("text"); + + b.Property("SpotifyAuthCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SpotifyAuthExpiresAfterSeconds") + .HasColumnType("integer"); + + b.Property("SpotifyAuthRefreshToken") + .HasColumnType("text"); + b.Property("WasChosenForSuggestionThisRound") .HasColumnType("boolean"); @@ -139,6 +204,30 @@ namespace song_of_the_day.DataMigrations b.ToTable("Users"); }); + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.HasOne("User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Song", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyExcludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId"); + + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("ExplicitlyIncludedSongs") + .HasForeignKey("SmartPlaylistDefinitionId1"); + + b.HasOne("User", null) + .WithMany("LikedSongs") + .HasForeignKey("UserId"); + }); + modelBuilder.Entity("SongSuggestion", b => { b.HasOne("Song", "Song") @@ -161,6 +250,27 @@ namespace song_of_the_day.DataMigrations b.Navigation("User"); }); + + modelBuilder.Entity("SuggestionHelper", b => + { + b.HasOne("SmartPlaylistDefinition", null) + .WithMany("Categories") + .HasForeignKey("SmartPlaylistDefinitionId"); + }); + + modelBuilder.Entity("SmartPlaylistDefinition", b => + { + b.Navigation("Categories"); + + b.Navigation("ExplicitlyExcludedSongs"); + + b.Navigation("ExplicitlyIncludedSongs"); + }); + + modelBuilder.Entity("User", b => + { + b.Navigation("LikedSongs"); + }); #pragma warning restore 612, 618 } } diff --git a/song_of_the_day/Data/SmartPlaylistDefinition.cs b/song_of_the_day/Data/SmartPlaylistDefinition.cs new file mode 100644 index 0000000..46abcc4 --- /dev/null +++ b/song_of_the_day/Data/SmartPlaylistDefinition.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; + +[PrimaryKey(nameof(Id))] +public class SmartPlaylistDefinition +{ + public int Id { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public User? CreatedBy { get; set; } + public bool IncludesUnCategorizedSongs { get; set; } + public bool IncludesLikedSongs { get; set; } + public List? Categories { get; set; } + public string? SpotifyPlaylistId { get; set; } + + public List? ExplicitlyIncludedSongs { get; set; } + public List? ExplicitlyExcludedSongs { get; set; } + + public bool IsThisUsersLikedSongsPlaylist + { + get + { + return IncludesLikedSongs == true + && IncludesUnCategorizedSongs == false + && (ExplicitlyExcludedSongs == null || ExplicitlyExcludedSongs.Count == 0) + && (ExplicitlyIncludedSongs == null || ExplicitlyIncludedSongs.Count == 0) + && (Categories == null || Categories.Count == 0); + } + } + +} \ No newline at end of file diff --git a/song_of_the_day/Data/User.cs b/song_of_the_day/Data/User.cs index 67f9e67..bb00af3 100644 --- a/song_of_the_day/Data/User.cs +++ b/song_of_the_day/Data/User.cs @@ -10,4 +10,17 @@ public class User public bool AssociationInProgress { get; set; } public string? LdapUserName { get; set; } public bool WasChosenForSuggestionThisRound { get; set; } + public string PreferredName + { + get + { + return (string.IsNullOrEmpty(NickName) ? Name : NickName).ToString(); + } + } + public List LikedSongs { get; set; } + + public string? SpotifyAuthAccessToken { get; set; } + public int? SpotifyAuthExpiresAfterSeconds { get; set; } + public DateTime? SpotifyAuthCreatedAt { get; set; } + public string? SpotifyAuthRefreshToken { get; set; } } \ No newline at end of file diff --git a/song_of_the_day/Pages/Index.cshtml b/song_of_the_day/Pages/Index.cshtml index 11118a5..03b3e52 100644 --- a/song_of_the_day/Pages/Index.cshtml +++ b/song_of_the_day/Pages/Index.cshtml @@ -12,6 +12,7 @@ Song Submitter Details + @foreach(var songSuggestion in Model.SongSuggestions) { @@ -26,6 +27,17 @@ @string.Format("{0} - {1}", songSuggestion?.Song.Name, songSuggestion?.Song.Artist) @displayName View + + @if(songSuggestion?.Song != null) + { + var handler = Model.HasUserLikedThisSong(@songSuggestion.Song) ? "UnlikeSong" : "LikeSong"; + var likebuttonText = Model.HasUserLikedThisSong(@songSuggestion.Song) ? "Unlike" : "Like"; +
+ + +
+ } + } } diff --git a/song_of_the_day/Pages/Index.cshtml.cs b/song_of_the_day/Pages/Index.cshtml.cs index bd569c9..1be6614 100644 --- a/song_of_the_day/Pages/Index.cshtml.cs +++ b/song_of_the_day/Pages/Index.cshtml.cs @@ -17,14 +17,67 @@ public class IndexModel : PageModel [BindProperty] public List SongSuggestions { get; set; } = new List(); - public Task OnGet() - { + private User _currentUser; + + public User CurrentUser + { + get { + if(_currentUser == null) + { + var userName = this.User.Identity.Name; + using(var dci = DataContext.Instance) + { + _currentUser = dci.Users.Include(u => u.LikedSongs).Where(u => u.LdapUserName == userName).SingleOrDefault(); + } + } + + return _currentUser; + } + + } + + public bool HasUserLikedThisSong(Song song) + { + return CurrentUser.LikedSongs.Where(s => s.SongId == song.SongId).FirstOrDefault() != default(Song); + } + + public Task OnGet() + { using var dci = DataContext.Instance; SongSuggestions = dci.SongSuggestions.OrderByDescending(s => s.Date) .Take(50) .Include(s => s.Song) .Include(s => s.User) - .ToList(); - return Task.CompletedTask; - } + .ToList(); + return Task.CompletedTask; + } + + public async Task OnPostLikeSong(int? songId) + { + using (var dci = DataContext.Instance) + { + var user = dci.Users.Include(u => u.LikedSongs).Where(u => u.UserId == CurrentUser.UserId).SingleOrDefault(); + if (user.LikedSongs.Find(s => s.SongId == songId) == default(Song)) + { + user.LikedSongs.Add(dci.Songs.Find(songId)); + await dci.SaveChangesAsync(); + } + } + return RedirectToPage("/"); + } + + public async Task OnPostUnlikeSong(int? songId) + { + using(var dci = DataContext.Instance) + { + var user = dci.Users.Include(u => u.LikedSongs).Where(u => u.UserId == CurrentUser.UserId).SingleOrDefault(); + var songToRemove = user.LikedSongs.Where(s => s.SongId == songId).SingleOrDefault(); + if (songToRemove != default(Song)) + { + user.LikedSongs.Remove(songToRemove); + await dci.SaveChangesAsync(); + } + } + return RedirectToPage("/"); + } } diff --git a/song_of_the_day/Pages/Shared/_LoginView.cshtml b/song_of_the_day/Pages/Shared/_LoginView.cshtml index b34874b..d6fe062 100644 --- a/song_of_the_day/Pages/Shared/_LoginView.cshtml +++ b/song_of_the_day/Pages/Shared/_LoginView.cshtml @@ -23,9 +23,15 @@ } else { + var userName = User.Identity.Name; + var dci = DataContext.Instance; + var selectedUsers = dci.Users.Where(u => u.LdapUserName == userName); + var userId = selectedUsers.SingleOrDefault()?.UserId; + dci.Dispose(); +
- Welcome, @User.Identity.Name! + Welcome, @User.Identity.Name!
diff --git a/song_of_the_day/Pages/SongSubmission.cshtml b/song_of_the_day/Pages/SongSubmission.cshtml index 8034c6b..04c80aa 100644 --- a/song_of_the_day/Pages/SongSubmission.cshtml +++ b/song_of_the_day/Pages/SongSubmission.cshtml @@ -14,6 +14,7 @@ Suggestion Song + @foreach(var submission in Model.UserSongSubmissions) { @@ -44,6 +45,17 @@ } + + @if(submission.Song != null) + { + var handler = Model.HasUserLikedThisSong(submission.Song) ? "UnlikeSong" : "LikeSong"; + var likebuttonText = Model.HasUserLikedThisSong(submission.Song) ? "Unlike" : "Like"; + + + + + } + } @@ -53,8 +65,8 @@ else {
-
Today's suggestionHelper is: @Model.SuggestionHelper.Title
-
@Model.SuggestionHelper.Description
+
Today's suggestionHelper is: @Model.SuggestionHelper?.Title
+
@Model.SuggestionHelper?.Description
diff --git a/song_of_the_day/Pages/SongSubmission.cshtml.cs b/song_of_the_day/Pages/SongSubmission.cshtml.cs index e6b6e13..ddaa8c2 100644 --- a/song_of_the_day/Pages/SongSubmission.cshtml.cs +++ b/song_of_the_day/Pages/SongSubmission.cshtml.cs @@ -75,6 +75,24 @@ public class SongSubmissionModel : PageModel } } + private User _currentUser; + + public User CurrentUser + { + get { + if(_currentUser == null) + { + var userName = this.User.Identity.Name; + using(var dci = DataContext.Instance) + { + _currentUser = dci.Users.Include(u => u.LikedSongs).Where(u => u.LdapUserName == userName).SingleOrDefault(); + } + } + + return _currentUser; + } + + } public List SpotifySuggestions { @@ -115,6 +133,11 @@ public class SongSubmissionModel : PageModel [BindProperty(SupportsGet = true)] public bool HasUsedSuggestion { get; set; } = true; + public bool HasUserLikedThisSong(Song song) + { + return CurrentUser.LikedSongs.Where(s => s.SongId == song.SongId).FirstOrDefault() != default(Song); + } + private async Task UpdateGroup(SongSuggestion suggestion) { var isItToday = suggestion.Date.Date == DateTime.Today; @@ -223,6 +246,35 @@ public class SongSubmissionModel : PageModel } } + public async Task OnPostLikeSong(int? songId) + { + using (var dci = DataContext.Instance) + { + var user = dci.Users.Include(u => u.LikedSongs).Where(u => u.UserId == CurrentUser.UserId).SingleOrDefault(); + if (user.LikedSongs.Find(s => s.SongId == songId) == default(Song)) + { + user.LikedSongs.Add(dci.Songs.Find(songId)); + await dci.SaveChangesAsync(); + } + } + return RedirectToPage("SongSubmission"); + } + + public async Task OnPostUnlikeSong(int? songId) + { + using(var dci = DataContext.Instance) + { + var user = dci.Users.Include(u => u.LikedSongs).Where(u => u.UserId == CurrentUser.UserId).SingleOrDefault(); + var songToRemove = user.LikedSongs.Where(s => s.SongId == songId).SingleOrDefault(); + if (songToRemove != default(Song)) + { + user.LikedSongs.Remove(songToRemove); + await dci.SaveChangesAsync(); + } + } + return RedirectToPage("SongSubmission"); + } + public IActionResult OnGetUpdate() { var songUrl = Request.Query["SubmitUrl"]; diff --git a/song_of_the_day/Pages/User.cshtml b/song_of_the_day/Pages/User.cshtml index b13b674..2981ebb 100644 --- a/song_of_the_day/Pages/User.cshtml +++ b/song_of_the_day/Pages/User.cshtml @@ -1,4 +1,5 @@ @page "{userIndex}" + @model UserModel @{ ViewData["Title"] = "User #" + @Model.userId; @@ -12,6 +13,20 @@
- + + @if(@Model.IsSpotifyAuthenticated) + { +
+ + +
+ } + else + { +
+ + +
+ }
diff --git a/song_of_the_day/Pages/User.cshtml.cs b/song_of_the_day/Pages/User.cshtml.cs index f0e5108..0523f7b 100644 --- a/song_of_the_day/Pages/User.cshtml.cs +++ b/song_of_the_day/Pages/User.cshtml.cs @@ -1,5 +1,8 @@ +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using SpotifyAPI.Web; +using System.Web; namespace sotd.Pages; @@ -18,10 +21,12 @@ public class UserModel : PageModel public string UserName { get; set; } + public bool IsSpotifyAuthenticated { get; set; } + [BindProperty] public string UserNickName { get; set; } - public void OnGet(int userIndex) + public async Task OnGet(int userIndex, [FromServices] SpotifyApiClient spotifyClient) { using (var dci = DataContext.Instance) { @@ -29,10 +34,11 @@ public class UserModel : PageModel this.UserName = user == null ? string.Empty : (user.Name ?? string.Empty); this.UserNickName = user == null ? string.Empty : (user.NickName ?? string.Empty); this.userId = userIndex; + this.IsSpotifyAuthenticated = await spotifyClient.IsUserAuthenticatedAsync(user); } } - public void OnPost(int userIndex) + public async Task OnPost(int userIndex, [FromServices] SpotifyApiClient spotifyClient) { using (var dci = DataContext.Instance) { @@ -42,6 +48,39 @@ public class UserModel : PageModel user.NickName = this.UserNickName; dci.SaveChanges(); this.UserName = user.Name ?? string.Empty; + this.IsSpotifyAuthenticated = await spotifyClient.IsUserAuthenticatedAsync(user); + } + } + } + + public IActionResult OnPostSpotifyLogin(int userIndex, string currentUri, [FromServices] SpotifyApiClient spotifyClient) + { + _logger.LogTrace($"Attempting Spotify login for user with id {userIndex}."); + + var loginRequest = new LoginRequest( + new Uri(spotifyClient.GetLoginRedirectUri()), + AppConfiguration.Instance.SpotifyClientId, + LoginRequest.ResponseType.Code) + { + Scope = new[] { Scopes.PlaylistReadPrivate, Scopes.PlaylistReadCollaborative, Scopes.PlaylistModifyPrivate, Scopes.PlaylistModifyPublic, Scopes.UgcImageUpload } + }; + var redirectUri = loginRequest.ToUri().ToString() + $"&finalRedirect={HttpUtility.UrlEncode($"{Request.Scheme}://{Request.Host}:{Request.Host.Port ?? 80}{Request.Path}")}"; + + return this.Redirect(redirectUri); + } + + public async Task OnPostSpotifyLogout(int userIndex, [FromServices] SpotifyApiClient spotifyClient) + { + _logger.LogTrace($"Attempting to deauthorize Spotify for user with id {userIndex}."); + + using (var dci = DataContext.Instance) + { + var user = dci.Users?.Find(userIndex); + if (user != null) + { + await spotifyClient.DeAuthorizeUserAsync(user); + user.NickName = this.UserNickName; + this.UserName = user.Name ?? string.Empty; } } } diff --git a/song_of_the_day/Program.cs b/song_of_the_day/Program.cs index 3de2056..742da7d 100644 --- a/song_of_the_day/Program.cs +++ b/song_of_the_day/Program.cs @@ -2,6 +2,9 @@ using Scalar.AspNetCore; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using SpotifyAPI.Web; +using Microsoft.AspNetCore.Components.Authorization; var builder = WebApplication.CreateBuilder(args); @@ -21,6 +24,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); var app = builder.Build(); @@ -66,6 +70,43 @@ userCheckTimer.OnOccurence += async (s, ea) => await dci.DisposeAsync(); }; +logger.LogTrace("Setting up liked songs playlist creation timer"); +var likePlaylistCheckTimer = new CronTimer("*/30 * * * *", "Europe/Vienna", includingSeconds: false); +likePlaylistCheckTimer.OnOccurence += async (s, ea) => +{ + var spotifyApiClient = app.Services.GetService(); + var playlistSynchronizer = app.Services.GetService(); + var dci = DataContext.Instance; + var needsSaving = false; + var allUsers = dci.Users.ToList(); + await dci.DisposeAsync(); + dci = DataContext.Instance; + foreach (var user in allUsers) + { + if (!await spotifyApiClient.IsUserAuthenticatedAsync(user)) + { + continue; + } + var foundPlaylist = dci.SmartPlaylistDefinitions?.Where(p => p.CreatedBy == user).ToList().Where(p => p.IsThisUsersLikedSongsPlaylist).SingleOrDefault(); + if (foundPlaylist == null) + { + var title = $"{user.PreferredName}'s liked Songs"; + var description = $"A collection of the songs liked by {user.PreferredName} on their 'Song of the day' server instance."; + // playlist does not exist yet, creating it + var newPlaylist = await (await spotifyApiClient.WithUserAuthorizationAsync(user)).CreateSpotifyPlaylist(title, description, false, true, user); + await playlistSynchronizer.SynchronizePlaylistAsync(newPlaylist); + needsSaving = true; + } + await playlistSynchronizer.SynchronizeUserPlaylistsAsync(user); + } + + if (needsSaving) + { + await dci.SaveChangesAsync(); + } + await dci.DisposeAsync(); +}; + logger.LogTrace("Setting up user intro timer"); var userIntroTimer = new CronTimer("*/1 * * * *", "Europe/Vienna", includingSeconds: false); userIntroTimer.OnOccurence += async (s, ea) => @@ -217,6 +258,7 @@ if (!app.Environment.IsDevelopment()) userIntroTimer.Start(); pickOfTheDayTimer.Start(); ldapAssociationTimer.Start(); + likePlaylistCheckTimer.Start(); } else { @@ -249,6 +291,26 @@ app.MapControllerRoute( name: "logout", pattern: "{controller=Auth}/{action=Logout}" ); +app.MapGet("SpotifyLogin", async (HttpRequest request, HttpResponse response) => { + var spotifyClient = app.Services.GetService(); + var code = request.Query["code"]; + var oAuthResponse = await new OAuthClient().RequestToken( + new AuthorizationCodeTokenRequest(AppConfiguration.Instance.SpotifyClientId, AppConfiguration.Instance.SpotifyClientSecret, code, new Uri(spotifyClient.GetLoginRedirectUri())) + ); + + var dci = DataContext.Instance; + var userName = request.HttpContext.User.Identity.Name; + var loggedInUser = dci.Users.Where(u => u.LdapUserName == userName).SingleOrDefault(); + var userId = loggedInUser.UserId; + loggedInUser.SpotifyAuthAccessToken = oAuthResponse.AccessToken; + loggedInUser.SpotifyAuthExpiresAfterSeconds = oAuthResponse.ExpiresIn; + loggedInUser.SpotifyAuthCreatedAt = oAuthResponse.CreatedAt; + loggedInUser.SpotifyAuthRefreshToken = oAuthResponse.RefreshToken; + await dci.SaveChangesAsync(); + dci.Dispose(); + + response.Redirect($"/User/{userId}"); +}); app.MapGet("/debug/routes", (IEnumerable endpointSources) => string.Join("\n", endpointSources.SelectMany(source => source.Endpoints))); diff --git a/song_of_the_day/SpotifyIntegration/PlayListSynchronizer.cs b/song_of_the_day/SpotifyIntegration/PlayListSynchronizer.cs new file mode 100644 index 0000000..53726fb --- /dev/null +++ b/song_of_the_day/SpotifyIntegration/PlayListSynchronizer.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; + +public class PlayListSynchronizer +{ + private SpotifyApiClient _spotifyAPIClient; + + public PlayListSynchronizer(SpotifyApiClient spotifyAPIClient) + { + _spotifyAPIClient = spotifyAPIClient; + } + + public async Task SynchronizePlaylistAsync(SmartPlaylistDefinition playlist) + { + var songsToInclude = new List(); + using (var dci = DataContext.Instance) + { + songsToInclude.AddRange(dci.SongSuggestions.Where(ss => ss.HasUsedSuggestion && playlist.Categories.Contains(ss.SuggestionHelper) && !songsToInclude.Contains(ss.Song)).Select(ss => ss.Song)); + songsToInclude.AddRange(playlist.ExplicitlyIncludedSongs.Where(ss => !songsToInclude.Contains(ss))); + if (playlist.IncludesUnCategorizedSongs) + { + songsToInclude.AddRange(dci.SongSuggestions.Where(ss => !ss.HasUsedSuggestion && !songsToInclude.Contains(ss.Song)).Select(ss => ss.Song)); + } + if (playlist.IncludesLikedSongs) + { + var userWithLikes = dci.Users.Include(u => u.LikedSongs).Where(u => u.UserId == playlist.CreatedBy.UserId).SingleOrDefault(); + var likedSongs = userWithLikes.LikedSongs; + songsToInclude.AddRange(likedSongs.Where(s => !songsToInclude.Contains(s))); + } + } + songsToInclude.RemoveAll(song => playlist.ExplicitlyExcludedSongs.Contains(song)); + var spotifyIdsToInclude = songsToInclude.Select(s => s.SpotifyId); + + var apiClient = await _spotifyAPIClient.WithUserAuthorizationAsync(playlist.CreatedBy); + + var songsAlreadyInPlaylist = await apiClient.GetSongsInPlaylist(playlist.SpotifyPlaylistId); + + var songsToAdd = spotifyIdsToInclude.Where(sti => !songsAlreadyInPlaylist.Contains(sti)).ToList(); + var songsToRemove = songsAlreadyInPlaylist.Where(sai => !spotifyIdsToInclude.Contains(sai)).ToList(); + + apiClient.AddSongsToPlaylist(playlist.SpotifyPlaylistId, songsToAdd); + apiClient.RemoveSongsFromPlaylist(playlist.SpotifyPlaylistId, songsToRemove); + } + + public async Task SynchronizePlaylistsAsync(IList playlists) + { + foreach(var playlist in playlists) + { + await SynchronizePlaylistAsync(playlist); + } + } + + public async Task SynchronizeUserPlaylistsAsync(User user) + { + using(var dci = DataContext.Instance) + { + var userPlayLists = dci.SmartPlaylistDefinitions + .Include(pl => pl.ExplicitlyIncludedSongs) + .Include(pl => pl.ExplicitlyExcludedSongs) + .Include(pl => pl.Categories) + .Include(pl => pl.CreatedBy) + .Where(pl => pl.CreatedBy == user).ToList(); + await SynchronizePlaylistsAsync(userPlayLists); + } + } +} \ No newline at end of file diff --git a/song_of_the_day/SpotifyIntegration/SpotifyApiClient.cs b/song_of_the_day/SpotifyIntegration/SpotifyApiClient.cs new file mode 100644 index 0000000..ca7eb40 --- /dev/null +++ b/song_of_the_day/SpotifyIntegration/SpotifyApiClient.cs @@ -0,0 +1,287 @@ +using SpotifyAPI.Web; +using System.Web; +using Microsoft.EntityFrameworkCore; + +public class SpotifyApiClient +{ + private SpotifyClient _spotifyClient; + private SpotifyClient _userAuthorizedSpotifyClient; + private ILogger _logger; + + public SpotifyApiClient(ILogger logger) + { + var config = SpotifyClientConfig.CreateDefault() + .WithAuthenticator(new ClientCredentialsAuthenticator( + AppConfiguration.Instance.SpotifyClientId, + AppConfiguration.Instance.SpotifyClientSecret)); + + _spotifyClient = new SpotifyClient(config); + _userAuthorizedSpotifyClient = null; + _logger = logger; + } + + public async Task WithUserAuthorizationAsync(User user) + { + var refreshResponse = await new OAuthClient().RequestToken( + new AuthorizationCodeRefreshRequest( + AppConfiguration.Instance.SpotifyClientId, + AppConfiguration.Instance.SpotifyClientSecret, + user.SpotifyAuthRefreshToken) + ); + var config = SpotifyClientConfig + .CreateDefault() + .WithAuthenticator(new AuthorizationCodeAuthenticator( + AppConfiguration.Instance.SpotifyClientId, + AppConfiguration.Instance.SpotifyClientSecret, + new AuthorizationCodeTokenResponse() { + RefreshToken = refreshResponse.RefreshToken, + AccessToken = refreshResponse.AccessToken, + TokenType = refreshResponse.TokenType, + ExpiresIn = refreshResponse.ExpiresIn, + Scope = refreshResponse.Scope, + CreatedAt = refreshResponse.CreatedAt + })); + + _userAuthorizedSpotifyClient = new SpotifyClient(config); + return this; + } + + private SpotifyClient UserAuthorizedSpotifyClient { + get { + if (_userAuthorizedSpotifyClient == null) + { + throw new Exception("Cannot perform Spotify API call without user authorization. Authorize Spotify access from your user page first!"); + } + return _userAuthorizedSpotifyClient; + } + } + + 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 string GetLoginRedirectUri() + { + return "http://127.0.0.1:5000/SpotifyLogin"; + } + + private bool IsAuthTokenExpired(User user) + { + if (user.SpotifyAuthCreatedAt == null || user.SpotifyAuthExpiresAfterSeconds == null) + { + return true; + } + var expirationdate = user.SpotifyAuthCreatedAt.Value.AddSeconds(user.SpotifyAuthExpiresAfterSeconds.Value); + return expirationdate < DateTime.UtcNow; + } + + public async Task DeAuthorizeUserAsync(User user) + { + using(var dci = DataContext.Instance) + { + var isEntityTracked = dci.Entry(user).State != EntityState.Detached; + + if(!isEntityTracked) + { + user = dci.Users.Find(user.UserId); + } + user.SpotifyAuthAccessToken = string.Empty; + user.SpotifyAuthRefreshToken = string.Empty; + user.SpotifyAuthExpiresAfterSeconds = null; + user.SpotifyAuthCreatedAt = null; + await dci.SaveChangesAsync(); + } + } + + public async Task GetValidAuthorizationTokenAsync(User user) + { + if(string.IsNullOrEmpty(user.SpotifyAuthAccessToken)) + { + // user either never connected Spotify or we failed to refresh token - user needs to re-authenticate + return string.Empty; + } + + if(!this.IsAuthTokenExpired(user)) + { + return user.SpotifyAuthAccessToken; + } + + // if token is expired, attempt a refresh + var dci = DataContext.Instance; + + var isEntityTracked = dci.Entry(user).State != EntityState.Detached; + + if(!isEntityTracked) + { + user = dci.Users.Find(user.UserId); + } + + try + { + var oAuthResponse = await new OAuthClient().RequestToken( + new AuthorizationCodeRefreshRequest(AppConfiguration.Instance.SpotifyClientId, AppConfiguration.Instance.SpotifyClientSecret, user.SpotifyAuthRefreshToken) + ); + user.SpotifyAuthAccessToken = oAuthResponse.AccessToken; + user.SpotifyAuthExpiresAfterSeconds = oAuthResponse.ExpiresIn; + user.SpotifyAuthCreatedAt = oAuthResponse.CreatedAt; + user.SpotifyAuthRefreshToken = oAuthResponse.RefreshToken; + return user.SpotifyAuthAccessToken; + } + catch(Exception ex) + { + _logger.LogWarning($"Failed to refresh SpotifyAuth token for user {user.LdapUserName}: {ex.Message}"); + await DeAuthorizeUserAsync(user); + return string.Empty; + } + finally + { + await dci.SaveChangesAsync(); + dci.Dispose(); + } + } + + public async Task IsUserAuthenticatedAsync(User user) + { + return !string.IsNullOrEmpty(await this.GetValidAuthorizationTokenAsync(user)); + } + + 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); + } + } + + public async Task CreateSpotifyPlaylist(string playlistTitle, + string description, + bool IncludesUnCategorizedSongs, + bool IncludesLikedSongs, + User createdBy) + { + try + { + // for now hardcoded with my user ID + var playlistCreationRequest = new PlaylistCreateRequest(playlistTitle); + playlistCreationRequest.Public = true; + playlistCreationRequest.Collaborative = false; + playlistCreationRequest.Description = description; + var currentUser = await UserAuthorizedSpotifyClient.UserProfile.Current(); + var playlist = await UserAuthorizedSpotifyClient.Playlists.Create(currentUser.Id, playlistCreationRequest); + + _logger.LogWarning($"Creating new playlist '{playlistTitle}'"); + using(var dci = DataContext.Instance) + { + var trackedUserEntity = dci.Users.Find(createdBy.UserId); + var newPlaylist = new SmartPlaylistDefinition() + { + Title = playlistTitle, + Description = description, + Categories = new List(), + IncludesLikedSongs = IncludesLikedSongs, + IncludesUnCategorizedSongs = IncludesUnCategorizedSongs, + SpotifyPlaylistId = playlist.Id, + CreatedBy = trackedUserEntity, + ExplicitlyExcludedSongs = new List(), + ExplicitlyIncludedSongs = new List() + }; + + var trackedEntity = dci.SmartPlaylistDefinitions.Add(newPlaylist); + await dci.SaveChangesAsync(); + return trackedEntity.Entity; + } + } + catch (APIException ex) + { + throw new Exception($"Error creating playlist with title: {playlistTitle}: {ex.Message}", ex); + } + } + + public async Task> GetSongsInPlaylist(string playlistId) + { + try + { + // for now hardcoded with my user ID + var ids = new List(); + var firstContentPage = await UserAuthorizedSpotifyClient.Playlists.GetItems(playlistId); + var allPages = await UserAuthorizedSpotifyClient.PaginateAll(firstContentPage); + ids.AddRange(allPages.Select(track => (track.Track as FullTrack).Id)); + + return ids; + + } + catch (APIException ex) + { + throw new Exception($"Error fetching playlist contents for playlist with id: {playlistId}: {ex.Message}", ex); + } + } + + public async Task AddSongsToPlaylist(string playlistId, List songIds) + { + if (songIds.Count == 0) + { + _logger.LogWarning($"No songs to add to playlist with id '{playlistId}'"); + return string.Empty; + } + try + { + // for now hardcoded with my user ID + var addItemRequest = new PlaylistAddItemsRequest(songIds.Select(id => $"spotify:track:{id}").ToList()); + _logger.LogWarning($"Adding songs to playlist with id '{playlistId}'"); + var response = await UserAuthorizedSpotifyClient.Playlists.AddItems(playlistId, addItemRequest); + return response.SnapshotId; + + } + catch (APIException ex) + { + throw new Exception($"Error adding songs to playlist with id: {playlistId}: {ex.Message}", ex); + } + } + + public async Task RemoveSongsFromPlaylist(string playlistId, List songIds) + { + if (songIds.Count == 0) + { + _logger.LogWarning($"No songs to remove from playlist with id '{playlistId}'"); + return string.Empty; + } + try + { + // for now hardcoded with my user ID + var removeItemsRequest = new PlaylistRemoveItemsRequest(); + removeItemsRequest.Tracks = new List(); + foreach (var song in songIds) + { + var item = new PlaylistRemoveItemsRequest.Item() + { + Uri = $"spotify:track:{song}", + }; + removeItemsRequest.Tracks.Add(item); + } + + _logger.LogWarning($"Removing songs from playlist with id '{playlistId}'"); + var response = await UserAuthorizedSpotifyClient.Playlists.RemoveItems(playlistId, removeItemsRequest); + return response.SnapshotId; + } + catch (APIException ex) + { + throw new Exception($"Error removing songs from playlist with id: {playlistId}: {ex.Message}", ex); + } + } +} \ No newline at end of file diff --git a/song_of_the_day/SpotifyIntegration/SpotifyClient.cs b/song_of_the_day/SpotifyIntegration/SpotifyClient.cs deleted file mode 100644 index 532b937..0000000 --- a/song_of_the_day/SpotifyIntegration/SpotifyClient.cs +++ /dev/null @@ -1,44 +0,0 @@ -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