diff --git a/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml b/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml
new file mode 100644
index 0000000..a1bee2d
--- /dev/null
+++ b/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml
@@ -0,0 +1,7 @@
+
+
+
+ Spoti
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.SpotiParty/.idea/encodings.xml b/.idea/.idea.SpotiParty/.idea/encodings.xml
index df87cf9..180f21a 100644
--- a/.idea/.idea.SpotiParty/.idea/encodings.xml
+++ b/.idea/.idea.SpotiParty/.idea/encodings.xml
@@ -1,4 +1,6 @@
-
+
+
+
\ No newline at end of file
diff --git a/SpotiParty.AppHost/.aspire/settings.json b/SpotiParty.AppHost/.aspire/settings.json
new file mode 100644
index 0000000..b74b76a
--- /dev/null
+++ b/SpotiParty.AppHost/.aspire/settings.json
@@ -0,0 +1,3 @@
+{
+ "appHostPath": "../SpotiParty.AppHost.csproj"
+}
\ No newline at end of file
diff --git a/SpotiParty.AppHost/AppHost.cs b/SpotiParty.AppHost/AppHost.cs
index d7cf9c6..180f365 100644
--- a/SpotiParty.AppHost/AppHost.cs
+++ b/SpotiParty.AppHost/AppHost.cs
@@ -2,6 +2,13 @@ using Projects;
var builder = DistributedApplication.CreateBuilder(args);
-var web = builder.AddProject("web");
+var dbServer = builder.AddPostgres("database")
+ .WithDataVolume("database-data");
+
+var database = dbServer.AddDatabase("SpotiParty");
+
+var web = builder.AddProject("web")
+ .WithReference(database)
+ .WaitForStart(database);
builder.Build().Run();
\ No newline at end of file
diff --git a/SpotiParty.AppHost/SpotiParty.AppHost.csproj b/SpotiParty.AppHost/SpotiParty.AppHost.csproj
index d217efc..6b1b894 100644
--- a/SpotiParty.AppHost/SpotiParty.AppHost.csproj
+++ b/SpotiParty.AppHost/SpotiParty.AppHost.csproj
@@ -12,6 +12,7 @@
+
diff --git a/SpotiParty.Web/Components/Pages/CallbackPage.razor b/SpotiParty.Web/Components/Pages/CallbackPage.razor
new file mode 100644
index 0000000..515e0b0
--- /dev/null
+++ b/SpotiParty.Web/Components/Pages/CallbackPage.razor
@@ -0,0 +1,27 @@
+@page "/callback"
+@using SpotiParty.Web.Services
+@inject NavigationManager Navigator
+@inject AuthorizationHandler AuthHandler
+
+@code {
+ [Parameter, SupplyParameterFromQuery(Name = "error")]
+ public string? Error { get; set; }
+
+ [Parameter, SupplyParameterFromQuery(Name = "code")]
+ public string? Code { get; set; }
+
+ [Parameter, SupplyParameterFromQuery(Name = "state")]
+ public string? State { get; set; }
+
+ protected override async Task OnInitializedAsync() {
+ await base.OnInitializedAsync();
+
+ if (string.IsNullOrWhiteSpace(Code)) {
+ Navigator.NavigateTo("/login", forceLoad: true);
+ return;
+ }
+
+ await AuthHandler.HandleCallback(Code);
+ Navigator.NavigateTo("/enqueue", forceLoad: true);
+ }
+}
\ No newline at end of file
diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor b/SpotiParty.Web/Components/Pages/EnqueuePage.razor
index 4aec937..5a95e06 100644
--- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor
+++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor
@@ -1,4 +1,4 @@
-@page "/enqueue"
+@page "/enqueue/{userid}"
@using SpotiParty.Web.Components.Components
@rendermode InteractiveServer
diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs
index 4033a6c..5d7b76d 100644
--- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs
+++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs
@@ -4,7 +4,11 @@ using SpotiParty.Web.Services;
namespace SpotiParty.Web.Components.Pages;
-public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBase {
+public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationManager navigator) : ComponentBase {
+
+ [Parameter]
+ public string UserId { get; set; } = string.Empty;
+
private readonly int _currentYear = DateTime.Now.Year;
private SpotifyClient _client = null!;
@@ -17,7 +21,20 @@ public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBa
protected override async Task OnInitializedAsync() {
await base.OnInitializedAsync();
- _client = await authHandler.ConfigureClient();
+
+ if (!Guid.TryParse(UserId, out var guid)) {
+ navigator.NavigateTo("/", forceLoad: true);
+ return;
+ }
+
+ var client = await authHandler.ConfigureClient(guid);
+
+ if (client is null) {
+ navigator.NavigateTo("/", forceLoad: true);
+ return;
+ }
+
+ _client = client;
}
private async Task ExecuteSearch() {
@@ -51,9 +68,8 @@ public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBa
private async Task DialogAccept() {
if (_selectedTrack is null || _isAdding) return;
_isAdding = true;
- /*var request = new PlayerAddToQueueRequest(_selectedTrack.Uri);
- await _client.Player.AddToQueue(request);*/
- await Task.Delay(3000); //TODO: Simulate adding
+ var request = new PlayerAddToQueueRequest(_selectedTrack.Uri);
+ await _client.Player.AddToQueue(request);
_isAdding = false;
_success = true;
diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css
index 5fa30c7..3a1ded2 100644
--- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css
+++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css
@@ -105,16 +105,3 @@ footer {
background: var(--color-background-light) !important;
}
-.button-secondary {
- padding: 0.6rem 1rem;
- border: none;
- border-radius: 5px;
- background: var(--color-accent);
- color: var(--color-text);
- cursor: pointer;
-}
-
-.button-secondary:hover {
- background: #555555;
-}
-
diff --git a/SpotiParty.Web/Components/Pages/LoginPage.razor b/SpotiParty.Web/Components/Pages/LoginPage.razor
new file mode 100644
index 0000000..31adca1
--- /dev/null
+++ b/SpotiParty.Web/Components/Pages/LoginPage.razor
@@ -0,0 +1,17 @@
+@page "/login"
+@using SpotiParty.Web.Services
+
+Mit Spotify einloggen
+
+@inject AuthorizationHandler AuthHandler
+
+@code {
+
+ private Uri _uri = null!;
+
+ protected override async Task OnInitializedAsync() {
+ await base.OnInitializedAsync();
+ _uri = await AuthHandler.ConstructLoginUri();
+ }
+
+}
\ No newline at end of file
diff --git a/SpotiParty.Web/Components/Pages/NotFound.razor b/SpotiParty.Web/Components/Pages/NotFound.razor
index 3a9526b..76193cc 100644
--- a/SpotiParty.Web/Components/Pages/NotFound.razor
+++ b/SpotiParty.Web/Components/Pages/NotFound.razor
@@ -6,7 +6,7 @@
protected override void OnInitialized() {
base.OnInitialized();
- Navigator.NavigateTo("/enqueue", forceLoad: true);
+ Navigator.NavigateTo("/login", forceLoad: true);
}
}
diff --git a/SpotiParty.Web/DatabaseContext.cs b/SpotiParty.Web/DatabaseContext.cs
new file mode 100644
index 0000000..1887175
--- /dev/null
+++ b/SpotiParty.Web/DatabaseContext.cs
@@ -0,0 +1,10 @@
+using Microsoft.EntityFrameworkCore;
+using SpotiParty.Web.Models;
+
+namespace SpotiParty.Web;
+
+public class DatabaseContext(DbContextOptions options) : DbContext(options) {
+
+ public DbSet Users { get; set; }
+
+}
\ No newline at end of file
diff --git a/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs b/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs
new file mode 100644
index 0000000..a6212ea
--- /dev/null
+++ b/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs
@@ -0,0 +1,51 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using SpotiParty.Web;
+
+#nullable disable
+
+namespace SpotiParty.Web.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20251130141048_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("SpotiParty.Web.Models.User", b =>
+ {
+ b.Property("UserId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("RefreshToken")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("UserId");
+
+ b.ToTable("Users");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/SpotiParty.Web/Migrations/20251130141048_Initial.cs b/SpotiParty.Web/Migrations/20251130141048_Initial.cs
new file mode 100644
index 0000000..e17be45
--- /dev/null
+++ b/SpotiParty.Web/Migrations/20251130141048_Initial.cs
@@ -0,0 +1,35 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SpotiParty.Web.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ UserId = table.Column(type: "uuid", nullable: false),
+ DisplayName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ RefreshToken = table.Column(type: "character varying(255)", maxLength: 255, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.UserId);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Users");
+ }
+ }
+}
diff --git a/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs
new file mode 100644
index 0000000..715d899
--- /dev/null
+++ b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs
@@ -0,0 +1,48 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using SpotiParty.Web;
+
+#nullable disable
+
+namespace SpotiParty.Web.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ partial class DatabaseContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("SpotiParty.Web.Models.User", b =>
+ {
+ b.Property("UserId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("RefreshToken")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("UserId");
+
+ b.ToTable("Users");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/SpotiParty.Web/Models/User.cs b/SpotiParty.Web/Models/User.cs
new file mode 100644
index 0000000..2e4da79
--- /dev/null
+++ b/SpotiParty.Web/Models/User.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace SpotiParty.Web.Models;
+
+public class User {
+ [Key]
+ public Guid UserId { get; init; } = Guid.CreateVersion7();
+
+ [MaxLength(255)]
+ public string DisplayName { get; init; }
+
+ [MaxLength(255)]
+ public string RefreshToken { get; set; }
+}
\ No newline at end of file
diff --git a/SpotiParty.Web/Program.cs b/SpotiParty.Web/Program.cs
index 3d59ae1..f0a16a0 100644
--- a/SpotiParty.Web/Program.cs
+++ b/SpotiParty.Web/Program.cs
@@ -1,3 +1,5 @@
+using Microsoft.EntityFrameworkCore;
+using SpotiParty.Web;
using SpotiParty.Web.Components;
using SpotiParty.Web.Services;
@@ -9,7 +11,8 @@ builder.Services.AddRazorComponents()
builder.AddServiceDefaults();
-builder.Services.AddSingleton();
+builder.AddNpgsqlDbContext("SpotiParty");
+builder.Services.AddScoped();
var app = builder.Build();
@@ -20,10 +23,15 @@ if (!app.Environment.IsDevelopment()) {
app.UseHsts();
}
+await using (var scope = app.Services.CreateAsyncScope()) {
+ var context = scope.ServiceProvider.GetRequiredService();
+ await context.Database.MigrateAsync();
+}
+
app.MapDefaultEndpoints();
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
-app.UseHttpsRedirection();
+//app.UseHttpsRedirection();
app.UseAntiforgery();
diff --git a/SpotiParty.Web/Services/AuthorizationHandler.cs b/SpotiParty.Web/Services/AuthorizationHandler.cs
index 1127112..1cb8e80 100644
--- a/SpotiParty.Web/Services/AuthorizationHandler.cs
+++ b/SpotiParty.Web/Services/AuthorizationHandler.cs
@@ -1,46 +1,57 @@
-using System.Net.Http.Headers;
-using System.Security.Authentication;
-using System.Text;
-using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Components;
using SpotifyAPI.Web;
+using SpotiParty.Web.Models;
namespace SpotiParty.Web.Services;
-public sealed class AuthorizationHandler {
+public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseContext context) {
- private AuthResponse? _token;
-
- private class AuthResponse {
- [JsonPropertyName("access_token")]
- public string AccessToken { get; set; }
-
- [JsonPropertyName("token_type")]
- public string TokenType { get; set; }
-
- [JsonPropertyName("expires_in")]
- public int ExpiresIn { get; set; }
+ private async Task<(string clientId, string clientSecret)> GetClientSecrets() {
+ var fileLines = await File.ReadAllLinesAsync(Path.Combine(Environment.CurrentDirectory, ".dev-token"));
+ return (fileLines[0], fileLines[1]);
}
- public async Task ConfigureClient() {
- if (_token is null) {
- var fileLines = await File.ReadAllLinesAsync(Path.Combine(Environment.CurrentDirectory, ".dev-token"));
- var clientId = fileLines[0];
- var clientSecret = fileLines[1];
+ public async Task ConfigureClient(Guid userId) {
+ var user = await context.Users.FindAsync(userId);
+ if (user is null) return null;
+
+ var (clientId, clientSecret) = await GetClientSecrets();
+ var request = new AuthorizationCodeRefreshRequest(clientId, clientSecret, user.RefreshToken);
+ var response = await new OAuthClient().RequestToken(request);
+
+ return new SpotifyClient(response.AccessToken);
+ }
- var basicAuthToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
- var httpClient = new HttpClient();
- httpClient.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse($"Basic {basicAuthToken}");
+ public async Task ConstructLoginUri() {
+ var (clientId, _) = await GetClientSecrets();
+ var request = new LoginRequest(
+ new Uri(navigator.BaseUri + "callback"),
+ clientId,
+ LoginRequest.ResponseType.Code) {
+ Scope = [Scopes.UserReadPlaybackState, Scopes.UserModifyPlaybackState, Scopes.UserReadPrivate, Scopes.UserReadEmail]
+ };
- var response = await httpClient.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(new[] {
- new KeyValuePair("grant_type", "client_credentials")
- }));
- response.EnsureSuccessStatusCode();
- _token = await response.Content.ReadFromJsonAsync();
+ return request.ToUri();
+ }
- if (_token is null) throw new AuthenticationException("Spotify auth failed!");
- }
+ public async Task HandleCallback(string code) {
+ var (clientId, clientSecret) = await GetClientSecrets();
+ var response = await new OAuthClient().RequestToken(
+ new AuthorizationCodeTokenRequest(
+ clientId,
+ clientSecret,
+ code,
+ new Uri(navigator.BaseUri + "callback")));
- return new SpotifyClient(_token.AccessToken, _token.TokenType);
+ var client = new SpotifyClient(response.AccessToken);
+ var spotiUser = await client.UserProfile.Current();
+ var user = new User {
+ DisplayName = spotiUser.DisplayName,
+ RefreshToken = response.RefreshToken
+ };
+
+ await context.Users.AddAsync(user);
+ await context.SaveChangesAsync();
}
}
\ No newline at end of file
diff --git a/SpotiParty.Web/SpotiParty.Web.csproj b/SpotiParty.Web/SpotiParty.Web.csproj
index b092286..2e2e953 100644
--- a/SpotiParty.Web/SpotiParty.Web.csproj
+++ b/SpotiParty.Web/SpotiParty.Web.csproj
@@ -19,6 +19,12 @@
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/SpotiParty.Web/wwwroot/app.css b/SpotiParty.Web/wwwroot/app.css
index 08d4626..c5ea085 100644
--- a/SpotiParty.Web/wwwroot/app.css
+++ b/SpotiParty.Web/wwwroot/app.css
@@ -2,6 +2,7 @@
--color-primary: #1db954;
--color-primary-dark: #17a74a;
--color-accent: #333333;
+ --color-accent-dark: #555555;
--color-background: #121212;
--color-background-light: #181818;
--color-text: #ffffff;
@@ -38,3 +39,20 @@ body {
background: var(--color-background);
}
}
+
+.button-secondary {
+ padding: 0.6rem 1rem;
+ border: none;
+ border-radius: 5px;
+ background: var(--color-accent);
+ color: var(--color-text);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--color-accent-dark);
+ }
+
+ &:disabled {
+ background: var(--color-background);
+ }
+}
diff --git a/SpotiParty.sln.DotSettings b/SpotiParty.sln.DotSettings
new file mode 100644
index 0000000..436523d
--- /dev/null
+++ b/SpotiParty.sln.DotSettings
@@ -0,0 +1,2 @@
+
+ True
\ No newline at end of file
diff --git a/SpotiParty.sln.DotSettings.user b/SpotiParty.sln.DotSettings.user
new file mode 100644
index 0000000..8696f24
--- /dev/null
+++ b/SpotiParty.sln.DotSettings.user
@@ -0,0 +1,4 @@
+
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
\ No newline at end of file