initial support with user auth and saving tokens
This commit is contained in:
parent
49b9b17837
commit
6814ebd72c
Selector.AppleMusic
Selector.CLI
Selector.Cache
Selector.Event
Selector.MAUI/wwwroot/css
Selector.Model
ApplicationUser.cs
Migrations
Selector.Web
Selector.sln
21
Selector.AppleMusic/AppleMusicApi.cs
Normal file
21
Selector.AppleMusic/AppleMusicApi.cs
Normal file
@ -0,0 +1,21 @@
|
||||
namespace Selector.AppleMusic;
|
||||
|
||||
public class AppleMusicApi(HttpClient client, string developerToken, string userToken)
|
||||
{
|
||||
private static readonly string _apiBaseUrl = "https://api.music.apple.com/v1";
|
||||
|
||||
private async Task<HttpResponseMessage> MakeRequest(HttpMethod httpMethod, string requestUri)
|
||||
{
|
||||
var request = new HttpRequestMessage(httpMethod, _apiBaseUrl + requestUri);
|
||||
request.Headers.Add("Authorization", "Bearer " + developerToken);
|
||||
request.Headers.Add("Music-User-Token", userToken);
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task GetRecentlyPlayedTracks()
|
||||
{
|
||||
var response = await MakeRequest(HttpMethod.Get, "/me/recent/played/tracks");
|
||||
}
|
||||
}
|
16
Selector.AppleMusic/AppleMusicApiProvider.cs
Normal file
16
Selector.AppleMusic/AppleMusicApiProvider.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using Selector.Web.Apple;
|
||||
|
||||
namespace Selector.AppleMusic;
|
||||
|
||||
public class AppleMusicApiProvider(HttpClient client)
|
||||
{
|
||||
public AppleMusicApi GetApi(string developerKey, string teamId, string keyId, string userKey)
|
||||
{
|
||||
var jwtGenerator = new TokenGenerator(developerKey, teamId, keyId);
|
||||
var developerToken = jwtGenerator.Generate();
|
||||
|
||||
var api = new AppleMusicApi(client, developerToken, userKey);
|
||||
|
||||
return api;
|
||||
}
|
||||
}
|
13
Selector.AppleMusic/Extensions/ServiceExtensions.cs
Normal file
13
Selector.AppleMusic/Extensions/ServiceExtensions.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Selector.AppleMusic.Extensions;
|
||||
|
||||
public static class ServiceExtensions
|
||||
{
|
||||
public static IServiceCollection AddAppleMusic(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<AppleMusicApiProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
119
Selector.AppleMusic/Jwt.cs
Normal file
119
Selector.AppleMusic/Jwt.cs
Normal file
@ -0,0 +1,119 @@
|
||||
// https://github.com/CurtisUpdike/AppleDeveloperToken
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2023 Curtis Updike
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
using System;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Selector.Web.Apple;
|
||||
|
||||
internal record AppleAccount(string TeamId, string KeyId, string PrivateKey);
|
||||
|
||||
public class TokenGenerator
|
||||
{
|
||||
private static readonly JwtSecurityTokenHandler _tokenHandler = new();
|
||||
private readonly AppleAccount _account;
|
||||
private int _secondsValid;
|
||||
public int SecondsValid
|
||||
{
|
||||
get { return _secondsValid; }
|
||||
set
|
||||
{
|
||||
ValidateTime(value);
|
||||
_secondsValid = value;
|
||||
}
|
||||
}
|
||||
|
||||
public TokenGenerator(string privateKey, string teamId, string keyId, int secondsValid = 15777000)
|
||||
{
|
||||
ValidateTime(secondsValid);
|
||||
_account = new(teamId, keyId, FormatKey(privateKey));
|
||||
_secondsValid = secondsValid;
|
||||
}
|
||||
|
||||
public string Generate()
|
||||
{
|
||||
return GenerateToken(_account, TimeSpan.FromSeconds(SecondsValid));
|
||||
}
|
||||
|
||||
public string Generate(int secondsValid)
|
||||
{
|
||||
ValidateTime(secondsValid);
|
||||
return GenerateToken(_account, TimeSpan.FromSeconds(secondsValid));
|
||||
|
||||
}
|
||||
|
||||
public string Generate(TimeSpan timeValid)
|
||||
{
|
||||
ValidateTime(timeValid.Seconds);
|
||||
return GenerateToken(_account, timeValid);
|
||||
}
|
||||
|
||||
private static string GenerateToken(AppleAccount account, TimeSpan timeValid)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var algorithm = CreateAlgorithm(account.PrivateKey);
|
||||
var signingCredentials = CreateSigningCredentials(account.KeyId, algorithm);
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Issuer = account.TeamId,
|
||||
IssuedAt = now,
|
||||
NotBefore = now,
|
||||
Expires = now.Add(timeValid),
|
||||
SigningCredentials = signingCredentials
|
||||
};
|
||||
|
||||
var token = _tokenHandler.CreateJwtSecurityToken(tokenDescriptor);
|
||||
return _tokenHandler.WriteToken(token);
|
||||
}
|
||||
|
||||
private static ECDsa CreateAlgorithm(string key)
|
||||
{
|
||||
var algorithm = ECDsa.Create();
|
||||
algorithm.ImportPkcs8PrivateKey(Convert.FromBase64String(key), out _);
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
private static SigningCredentials CreateSigningCredentials(string keyId, ECDsa algorithm)
|
||||
{
|
||||
var key = new ECDsaSecurityKey(algorithm) { KeyId = keyId };
|
||||
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
||||
}
|
||||
|
||||
private static void ValidateTime(int seconds)
|
||||
{
|
||||
if (seconds > 15777000)
|
||||
{
|
||||
throw new ArgumentException("Must be less than 15777000 seconds (6 months).");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatKey(string key)
|
||||
{
|
||||
return key.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\r", "");
|
||||
}
|
||||
}
|
14
Selector.AppleMusic/Selector.AppleMusic.csproj
Normal file
14
Selector.AppleMusic/Selector.AppleMusic.csproj
Normal file
@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
@ -9,6 +9,7 @@ using Selector.Extensions;
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Invocation;
|
||||
using Selector.AppleMusic.Extensions;
|
||||
|
||||
namespace Selector.CLI
|
||||
{
|
||||
@ -108,7 +109,8 @@ namespace Selector.CLI
|
||||
|
||||
services.AddWatcher()
|
||||
.AddEvents()
|
||||
.AddSpotify();
|
||||
.AddSpotify()
|
||||
.AddAppleMusic();
|
||||
|
||||
services.ConfigureLastFm(config)
|
||||
.ConfigureEqual(config)
|
||||
@ -121,6 +123,7 @@ namespace Selector.CLI
|
||||
|
||||
Console.WriteLine("> Adding cache event maps...");
|
||||
services.AddTransient<IEventMapping, FromPubSub.SpotifyLink>()
|
||||
.AddTransient<IEventMapping, FromPubSub.AppleMusicLink>()
|
||||
.AddTransient<IEventMapping, FromPubSub.Lastfm>();
|
||||
|
||||
Console.WriteLine("> Adding caching Spotify consumers...");
|
||||
|
@ -35,6 +35,7 @@ namespace Selector.CLI
|
||||
|
||||
services.Configure<JobsOptions>(config.GetSection(FormatKeys(new[] { RootOptions.Key, JobsOptions.Key })));
|
||||
services.Configure<ScrobbleWatcherJobOptions>(config.GetSection(FormatKeys(new[] { RootOptions.Key, JobsOptions.Key, ScrobbleWatcherJobOptions.Key })));
|
||||
services.Configure<AppleMusicOptions>(config.GetSection(AppleMusicOptions._Key));
|
||||
|
||||
return options;
|
||||
}
|
||||
@ -131,4 +132,15 @@ namespace Selector.CLI
|
||||
public int PageSize { get; set; } = 200;
|
||||
public int Simultaneous { get; set; } = 3;
|
||||
}
|
||||
|
||||
public class AppleMusicOptions
|
||||
{
|
||||
public const string _Key = "AppleMusic";
|
||||
|
||||
|
||||
public string Key { get; set; }
|
||||
public string TeamId { get; set; }
|
||||
public string KeyId { get; set; }
|
||||
public TimeSpan? Expiry { get; set; } = null;
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj" />
|
||||
<ProjectReference Include="..\Selector\Selector.csproj" />
|
||||
<ProjectReference Include="..\Selector.Model\Selector.Model.csproj" />
|
||||
<ProjectReference Include="..\Selector.Cache\Selector.Cache.csproj" />
|
||||
|
@ -23,6 +23,7 @@ namespace Selector.Cache
|
||||
public const string Duration = "DURATION";
|
||||
|
||||
public const string SpotifyName = "SPOTIFY";
|
||||
public const string AppleMusicName = "APPLEMUSIC";
|
||||
public const string LastfmName = "LASTFM";
|
||||
|
||||
public const string WatcherName = "WATCHER";
|
||||
@ -47,7 +48,9 @@ namespace Selector.Cache
|
||||
public static string UserPlayCount(string username) => MajorNamespace(MinorNamespace(UserName, PlayCountName), username);
|
||||
|
||||
public static string UserSpotify(string username) => MajorNamespace(MinorNamespace(UserName, SpotifyName), username);
|
||||
public static string UserAppleMusic(string username) => MajorNamespace(MinorNamespace(UserName, AppleMusicName), username);
|
||||
public static readonly string AllUserSpotify = UserSpotify(All);
|
||||
public static readonly string AllUserAppleMusic = UserAppleMusic(All);
|
||||
public static string UserLastfm(string username) => MajorNamespace(MinorNamespace(UserName, LastfmName), username);
|
||||
public static readonly string AllUserLastfm = UserLastfm(All);
|
||||
|
||||
|
@ -4,6 +4,7 @@ namespace Selector.Events
|
||||
{
|
||||
[JsonSerializable(typeof(LastfmChange))]
|
||||
[JsonSerializable(typeof(SpotifyLinkChange))]
|
||||
[JsonSerializable(typeof(AppleMusicLinkChange))]
|
||||
[JsonSerializable(typeof((string, CurrentlyPlayingDTO)))]
|
||||
public partial class CacheJsonContext: JsonSerializerContext
|
||||
{
|
||||
|
98
Selector.Event/CacheMappings/AppleMusicMapping.cs
Normal file
98
Selector.Event/CacheMappings/AppleMusicMapping.cs
Normal file
@ -0,0 +1,98 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StackExchange.Redis;
|
||||
|
||||
using Selector.Cache;
|
||||
|
||||
namespace Selector.Events
|
||||
{
|
||||
public class AppleMusicLinkChange
|
||||
{
|
||||
public string UserId { get; set; }
|
||||
public bool PreviousLinkState { get; set; }
|
||||
public bool NewLinkState { get; set; }
|
||||
}
|
||||
|
||||
public partial class FromPubSub
|
||||
{
|
||||
public class AppleMusicLink : IEventMapping
|
||||
{
|
||||
private readonly ILogger<AppleMusicLink> Logger;
|
||||
private readonly ISubscriber Subscriber;
|
||||
private readonly UserEventBus UserEvent;
|
||||
|
||||
public AppleMusicLink(ILogger<AppleMusicLink> logger,
|
||||
ISubscriber subscriber,
|
||||
UserEventBus userEvent)
|
||||
{
|
||||
Logger = logger;
|
||||
Subscriber = subscriber;
|
||||
UserEvent = userEvent;
|
||||
}
|
||||
|
||||
public async Task ConstructMapping()
|
||||
{
|
||||
Logger.LogDebug("Forming Apple Music link event mapping FROM cache TO event bus");
|
||||
|
||||
(await Subscriber.SubscribeAsync(Key.AllUserAppleMusic)).OnMessage(message => {
|
||||
|
||||
try
|
||||
{
|
||||
var userId = Key.Param(message.Channel);
|
||||
|
||||
var deserialised = JsonSerializer.Deserialize(message.Message, CacheJsonContext.Default.AppleMusicLinkChange);
|
||||
Logger.LogDebug("Received new Apple Music link event for [{userId}]", deserialised.UserId);
|
||||
|
||||
if (!userId.Equals(deserialised.UserId))
|
||||
{
|
||||
Logger.LogWarning("Serialised user ID [{}] does not match cache channel [{}]", userId, deserialised.UserId);
|
||||
}
|
||||
|
||||
UserEvent.OnAppleMusicLinkChange(this, deserialised);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
Logger.LogDebug("Task Cancelled");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e, "Error parsing new Apple Music link event");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public partial class ToPubSub
|
||||
{
|
||||
public class AppleMusicLink : IEventMapping
|
||||
{
|
||||
private readonly ILogger<AppleMusicLink> Logger;
|
||||
private readonly ISubscriber Subscriber;
|
||||
private readonly UserEventBus UserEvent;
|
||||
|
||||
public AppleMusicLink(ILogger<AppleMusicLink> logger,
|
||||
ISubscriber subscriber,
|
||||
UserEventBus userEvent)
|
||||
{
|
||||
Logger = logger;
|
||||
Subscriber = subscriber;
|
||||
UserEvent = userEvent;
|
||||
}
|
||||
|
||||
public Task ConstructMapping()
|
||||
{
|
||||
Logger.LogDebug("Forming Apple Music link event mapping TO cache FROM event bus");
|
||||
|
||||
UserEvent.AppleLinkChange += async (o, e) =>
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(e, CacheJsonContext.Default.AppleMusicLinkChange);
|
||||
await Subscriber.PublishAsync(Key.UserAppleMusic(e.UserId), payload);
|
||||
};
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ namespace Selector.Events
|
||||
|
||||
public event EventHandler<ApplicationUser> UserChange;
|
||||
public event EventHandler<SpotifyLinkChange> SpotifyLinkChange;
|
||||
public event EventHandler<AppleMusicLinkChange> AppleLinkChange;
|
||||
public event EventHandler<LastfmChange> LastfmCredChange;
|
||||
|
||||
public event EventHandler<CurrentlyPlayingDTO> CurrentlyPlaying;
|
||||
@ -31,6 +32,12 @@ namespace Selector.Events
|
||||
SpotifyLinkChange?.Invoke(sender, args);
|
||||
}
|
||||
|
||||
public void OnAppleMusicLinkChange(object sender, AppleMusicLinkChange args)
|
||||
{
|
||||
Logger.LogTrace("Firing user Apple Music event [{usernamne}]", args?.UserId);
|
||||
AppleLinkChange?.Invoke(sender, args);
|
||||
}
|
||||
|
||||
public void OnLastfmCredChange(object sender, LastfmChange args)
|
||||
{
|
||||
Logger.LogTrace("Firing user Last.fm event [{usernamne}]", args?.UserId);
|
||||
|
@ -65,6 +65,14 @@
|
||||
width: 21px;
|
||||
}
|
||||
|
||||
.apple-logo {
|
||||
width: 21px;
|
||||
}
|
||||
|
||||
.apple-logo img {
|
||||
width: 21px;
|
||||
}
|
||||
|
||||
.lastfm-logo {
|
||||
width: 24px;
|
||||
}
|
||||
|
@ -16,6 +16,12 @@ namespace Selector.Model
|
||||
public string SpotifyAccessToken { get; set; }
|
||||
public string SpotifyRefreshToken { get; set; }
|
||||
|
||||
[PersonalData]
|
||||
public bool AppleMusicLinked { get; set; }
|
||||
public string AppleMusicKey { get; set; }
|
||||
[PersonalData]
|
||||
public DateTime AppleMusicLastRefresh { get; set; }
|
||||
|
||||
[PersonalData]
|
||||
public string LastFmUsername { get; set; }
|
||||
[PersonalData]
|
||||
@ -39,6 +45,10 @@ namespace Selector.Model
|
||||
public string SpotifyAccessToken { get; set; }
|
||||
public string SpotifyRefreshToken { get; set; }
|
||||
|
||||
public bool AppleMusicLinked { get; set; }
|
||||
public string AppleMusicKey { get; set; }
|
||||
public DateTime AppleMusicLastRefresh { get; set; }
|
||||
|
||||
public string LastFmUsername { get; set; }
|
||||
|
||||
public static explicit operator ApplicationUserDTO(ApplicationUser user) => new() {
|
||||
@ -54,6 +64,10 @@ namespace Selector.Model
|
||||
SpotifyAccessToken = user.SpotifyAccessToken,
|
||||
SpotifyRefreshToken = user.SpotifyRefreshToken,
|
||||
|
||||
AppleMusicLinked = user.AppleMusicLinked,
|
||||
AppleMusicKey = user.AppleMusicKey,
|
||||
AppleMusicLastRefresh = user.AppleMusicLastRefresh,
|
||||
|
||||
LastFmUsername = user.LastFmUsername
|
||||
};
|
||||
}
|
||||
|
500
Selector.Model/Migrations/20250329231051_adding_apple_music_user_properties.Designer.cs
generated
Normal file
500
Selector.Model/Migrations/20250329231051_adding_apple_music_user_properties.Designer.cs
generated
Normal file
@ -0,0 +1,500 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Selector.Model;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Selector.Model.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20250329231051_adding_apple_music_user_properties")]
|
||||
partial class adding_apple_music_user_properties
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "en-u-ks-primary,en-u-ks-primary,icu,False")
|
||||
.HasAnnotation("ProductVersion", "9.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = "00c64c0a-3387-4933-9575-83443fa9092b",
|
||||
Name = "Admin",
|
||||
NormalizedName = "ADMIN"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.AlbumLastfmSpotifyMapping", b =>
|
||||
{
|
||||
b.Property<string>("SpotifyUri")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastfmAlbumName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<string>("LastfmArtistName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.HasKey("SpotifyUri");
|
||||
|
||||
b.ToTable("AlbumMapping");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("AppleMusicKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("AppleMusicLastRefresh")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("AppleMusicLinked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastFmUsername")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("SaveScrobbles")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SpotifyAccessToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("SpotifyIsLinked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime>("SpotifyLastRefresh")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SpotifyRefreshToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SpotifyTokenExpiry")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.ArtistLastfmSpotifyMapping", b =>
|
||||
{
|
||||
b.Property<string>("SpotifyUri")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastfmArtistName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.HasKey("SpotifyUri");
|
||||
|
||||
b.ToTable("ArtistMapping");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<string>("ArtistName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<int?>("PlayedDuration")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<string>("TrackUri")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("SpotifyListen");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.TrackLastfmSpotifyMapping", b =>
|
||||
{
|
||||
b.Property<string>("SpotifyUri")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastfmArtistName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<string>("LastfmTrackName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.HasKey("SpotifyUri");
|
||||
|
||||
b.ToTable("TrackMapping");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.UserScrobble", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AlbumArtistName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AlbumName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<string>("ArtistName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.HasColumnType("text")
|
||||
.UseCollation("case_insensitive");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Scrobble");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.Watcher", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Watcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Selector.Model.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("Selector.Model.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Selector.Model.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("Selector.Model.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.SpotifyListen", b =>
|
||||
{
|
||||
b.HasOne("Selector.Model.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.UserScrobble", b =>
|
||||
{
|
||||
b.HasOne("Selector.Model.ApplicationUser", "User")
|
||||
.WithMany("Scrobbles")
|
||||
.HasForeignKey("UserId");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.Watcher", b =>
|
||||
{
|
||||
b.HasOne("Selector.Model.ApplicationUser", "User")
|
||||
.WithMany("Watchers")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Selector.Model.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("Scrobbles");
|
||||
|
||||
b.Navigation("Watchers");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Selector.Model.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class adding_apple_music_user_properties : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AppleMusicKey",
|
||||
table: "AspNetUsers",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "AppleMusicLastRefresh",
|
||||
table: "AspNetUsers",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AppleMusicLinked",
|
||||
table: "AspNetUsers",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppleMusicKey",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppleMusicLastRefresh",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppleMusicLinked",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
@ -189,6 +189,15 @@ namespace Selector.Model.Migrations
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("AppleMusicKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("AppleMusicLastRefresh")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("AppleMusicLinked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
@ -0,0 +1,132 @@
|
||||
@page
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using Microsoft.Extensions.Options
|
||||
@using Selector.Web
|
||||
@using Selector.Web.Apple
|
||||
@inject IOptions<AppleMusicOptions> appleMusicOptions
|
||||
@model AppleMusicModel
|
||||
@{
|
||||
ViewData["Title"] = "Apple Music";
|
||||
ViewData["ActivePage"] = ManageNavPages.AppleMusic;
|
||||
}
|
||||
|
||||
<h4>@ViewData["Title"] <a href="https://www.apple.com/apple-music/" target="_blank"><img src="/apple.png" class="apple-logo central" /></a></h4>
|
||||
<partial name="_StatusMessage" model="Model.StatusMessage" />
|
||||
|
||||
@{
|
||||
var generator = new TokenGenerator(appleMusicOptions.Value.Key, appleMusicOptions.Value.TeamId, appleMusicOptions.Value.KeyId);
|
||||
}
|
||||
<div id="apple-key" jwt="@generator.Generate()"></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div id="apple-music-form" method="post">
|
||||
@if (Model.AppleIsLinked)
|
||||
{
|
||||
<button id="unlink-button" class="btn btn-primary form-element">Unlink</button>
|
||||
<button id="update-button" class="btn btn-primary form-element">Update</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button id="link-button" class="btn btn-primary form-element">Link</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('musickitloaded', async function () {
|
||||
// Call configure() to configure an instance of MusicKit on the Web.
|
||||
try {
|
||||
let div = document.getElementById("apple-key");
|
||||
|
||||
console.log(div);
|
||||
console.log(div.getAttribute("jwt"));
|
||||
await MusicKit.configure({
|
||||
developerToken: div.getAttribute("jwt"),
|
||||
app: {
|
||||
name: 'Selector',
|
||||
build: '2025.3.29',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
async function authorize_apple() {
|
||||
// MusicKit instance is available
|
||||
const music = MusicKit.getInstance();
|
||||
let key = await music.authorize();
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/AppleMusic/token");
|
||||
xhr.setRequestHeader("content-type", "application/json");
|
||||
const body = JSON
|
||||
.stringify(
|
||||
{
|
||||
Key: key
|
||||
});
|
||||
xhr.onload = () =>
|
||||
{
|
||||
if (xhr.readyState == 4 && xhr.status == 201)
|
||||
{
|
||||
console.log(JSON.parse(xhr.responseText));
|
||||
} else
|
||||
{
|
||||
console.log(`Error: ${xhr.status}`);
|
||||
}
|
||||
};
|
||||
xhr.send(body);
|
||||
}
|
||||
|
||||
async function deauthorize_apple() {
|
||||
// MusicKit instance is available
|
||||
const music = MusicKit.getInstance();
|
||||
await music.unauthorize();
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/AppleMusic/unlink");
|
||||
xhr.onload = () =>
|
||||
{
|
||||
if (xhr.readyState == 4 && xhr.status == 201)
|
||||
{
|
||||
console.log(JSON.parse(xhr.responseText));
|
||||
} else
|
||||
{
|
||||
console.log(`Error: ${xhr.status}`);
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
async function link_apple() {
|
||||
await authorize_apple();
|
||||
}
|
||||
|
||||
async function unlink_apple() {
|
||||
await deauthorize_apple();
|
||||
}
|
||||
|
||||
async function update_apple() {
|
||||
await authorize_apple();
|
||||
}
|
||||
|
||||
let unlink = document.getElementById("unlink-button");
|
||||
if (unlink) {
|
||||
unlink.addEventListener("click", async () => await unlink_apple());
|
||||
}
|
||||
let update = document.getElementById("update-button");
|
||||
if (update) {
|
||||
update.addEventListener("click", async () => await update_apple());
|
||||
}
|
||||
let link = document.getElementById("link-button");
|
||||
if (link) {
|
||||
link.addEventListener("click", async () => await link_apple());
|
||||
}
|
||||
</script>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js" crossorigin="anonymous" data-web-components async></script>
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Selector.Model;
|
||||
using Selector.Events;
|
||||
|
||||
namespace Selector.Web.Areas.Identity.Pages.Account.Manage
|
||||
{
|
||||
public partial class AppleMusicModel : PageModel
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<SpotifyModel> Logger;
|
||||
private readonly RootOptions Config;
|
||||
private readonly UserEventBus UserEvent;
|
||||
|
||||
public AppleMusicModel(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<SpotifyModel> logger,
|
||||
IOptions<RootOptions> config,
|
||||
UserEventBus userEvent
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
Logger = logger;
|
||||
Config = config.Value;
|
||||
|
||||
UserEvent = userEvent;
|
||||
}
|
||||
|
||||
[BindProperty]
|
||||
public bool AppleIsLinked { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
public DateTime LastRefresh { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string StatusMessage { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
||||
}
|
||||
|
||||
AppleIsLinked = user.AppleMusicLinked;
|
||||
LastRefresh = user.AppleMusicLastRefresh;
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
}
|
@ -27,6 +27,7 @@ namespace Selector.Web.Areas.Identity.Pages.Account.Manage
|
||||
public static string LastFm => "LastFm";
|
||||
|
||||
public static string Spotify => "Spotify";
|
||||
public static string AppleMusic => "Apple Music";
|
||||
|
||||
public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
|
||||
|
||||
@ -45,6 +46,7 @@ namespace Selector.Web.Areas.Identity.Pages.Account.Manage
|
||||
public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication);
|
||||
public static string LastFmNavClass(ViewContext viewContext) => PageNavClass(viewContext, LastFm);
|
||||
public static string SpotifyNavClass(ViewContext viewContext) => PageNavClass(viewContext, Spotify);
|
||||
public static string AppleMusicNavClass(ViewContext viewContext) => PageNavClass(viewContext, AppleMusic);
|
||||
|
||||
private static string PageNavClass(ViewContext viewContext, string page)
|
||||
{
|
||||
|
@ -6,6 +6,7 @@
|
||||
<ul class="nav nav-pills flex-column">
|
||||
<li class="nav-item"><a class="nav-link dash-underline @ManageNavPages.IndexNavClass(ViewContext)" id="profile" asp-page="./Index">Profile</a></li>
|
||||
<li class="nav-item"><a class="nav-link dash-underline @ManageNavPages.SpotifyNavClass(ViewContext)" id="spotify" asp-page="./Spotify">Spotify <img src="/Spotify_Icon_RGB_White.png" class="spotify-logo menu-icon" /></a></li>
|
||||
<li class="nav-item"><a class="nav-link dash-underline @ManageNavPages.AppleMusicNavClass(ViewContext)" id="apple-music" asp-page="./AppleMusic">Apple Music <img src="/apple.png" class="apple-logo menu-icon" /></a></li>
|
||||
<li class="nav-item"><a class="nav-link dash-underline @ManageNavPages.LastFmNavClass(ViewContext)" id="lastfm" asp-page="./Lastfm">Last.fm <img src="/last-fm.png" class="lastfm-logo menu-icon" /></a></li>
|
||||
<li class="nav-item"><a class="nav-link dash-underline @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">Email</a></li>
|
||||
<li class="nav-item"><a class="nav-link dash-underline @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">Password</a></li>
|
||||
|
@ -73,6 +73,14 @@ $shadow-color: #0f0f0f;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-logo {
|
||||
width: 21px;
|
||||
|
||||
img {
|
||||
width: 21px;
|
||||
}
|
||||
}
|
||||
|
||||
.lastfm-logo {
|
||||
width: 24px;
|
||||
|
||||
|
84
Selector.Web/Controller/AppleMusicController.cs
Normal file
84
Selector.Web/Controller/AppleMusicController.cs
Normal file
@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Selector.Events;
|
||||
using Selector.Model;
|
||||
|
||||
namespace Selector.Web.Controller
|
||||
{
|
||||
public class TokenPost
|
||||
{
|
||||
public string Key { get; set; }
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AppleMusicController : BaseAuthController
|
||||
{
|
||||
private readonly UserEventBus UserEvent;
|
||||
|
||||
public AppleMusicController(
|
||||
ApplicationDbContext context,
|
||||
IAuthorizationService auth,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<UsersController> logger,
|
||||
UserEventBus userEvent
|
||||
) : base(context, auth, userManager, logger)
|
||||
{
|
||||
UserEvent = userEvent;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("token")]
|
||||
public async Task<ActionResult> Token(TokenPost request)
|
||||
{
|
||||
var user = await UserManager.GetUserAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException("No user returned");
|
||||
}
|
||||
|
||||
var alreadyAuthed = user.AppleMusicLinked;
|
||||
|
||||
user.AppleMusicKey = request.Key;
|
||||
user.AppleMusicLinked = true;
|
||||
user.AppleMusicLastRefresh = DateTime.UtcNow;
|
||||
|
||||
await UserManager.UpdateAsync(user);
|
||||
|
||||
UserEvent.OnAppleMusicLinkChange(this, new AppleMusicLinkChange { UserId = user.Id, PreviousLinkState = alreadyAuthed, NewLinkState = true });
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("unlink")]
|
||||
public async Task<ActionResult> Unlink()
|
||||
{
|
||||
var user = await UserManager.GetUserAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException("No user returned");
|
||||
}
|
||||
|
||||
var alreadyAuthed = user.AppleMusicLinked;
|
||||
|
||||
user.AppleMusicKey = null;
|
||||
user.AppleMusicLinked = false;
|
||||
user.AppleMusicLastRefresh = DateTime.MinValue;
|
||||
|
||||
await UserManager.UpdateAsync(user);
|
||||
|
||||
UserEvent.OnAppleMusicLinkChange(this, new AppleMusicLinkChange { UserId = user.Id, PreviousLinkState = alreadyAuthed, NewLinkState = false });
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
}
|
@ -68,4 +68,15 @@ namespace Selector.Web
|
||||
public string Audience { get; set; }
|
||||
public TimeSpan Expiry { get; set; } = TimeSpan.FromDays(7);
|
||||
}
|
||||
|
||||
public class AppleMusicOptions
|
||||
{
|
||||
public const string _Key = "AppleMusic";
|
||||
|
||||
|
||||
public string Key { get; set; }
|
||||
public string TeamId { get; set; }
|
||||
public string KeyId { get; set; }
|
||||
public TimeSpan? Expiry { get; set; } = null;
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Selector.AppleMusic\Selector.AppleMusic.csproj" />
|
||||
<ProjectReference Include="..\Selector\Selector.csproj" />
|
||||
<ProjectReference Include="..\Selector.Model\Selector.Model.csproj" />
|
||||
<ProjectReference Include="..\Selector.Cache\Selector.Cache.csproj" />
|
||||
|
@ -49,6 +49,10 @@ namespace Selector.Web
|
||||
{
|
||||
Configuration.GetSection(JwtOptions._Key).Bind(options);
|
||||
});
|
||||
services.Configure<AppleMusicOptions>(options =>
|
||||
{
|
||||
Configuration.GetSection(AppleMusicOptions._Key).Bind(options);
|
||||
});
|
||||
|
||||
var config = OptionsHelper.ConfigureOptions(Configuration);
|
||||
|
||||
@ -193,6 +197,7 @@ namespace Selector.Web
|
||||
Console.WriteLine("> Adding cache event maps...");
|
||||
|
||||
services.AddTransient<IEventMapping, ToPubSub.SpotifyLink>();
|
||||
services.AddTransient<IEventMapping, ToPubSub.AppleMusicLink>();
|
||||
services.AddTransient<IEventMapping, ToPubSub.Lastfm>();
|
||||
services.AddTransient<IEventMapping, FromPubSub.NowPlaying>();
|
||||
|
||||
|
BIN
Selector.Web/wwwroot/apple.png
Normal file
BIN
Selector.Web/wwwroot/apple.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 5.2 KiB |
@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.MAUI", "Selector.M
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.SignalR", "Selector.SignalR\Selector.SignalR.csproj", "{F41D98F2-7684-4786-969C-BFC8DF7FB489}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.AppleMusic", "Selector.AppleMusic\Selector.AppleMusic.csproj", "{5049A2F6-9604-49B1-B826-1CAC1B009D5D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -69,6 +71,10 @@ Global
|
||||
{F41D98F2-7684-4786-969C-BFC8DF7FB489}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F41D98F2-7684-4786-969C-BFC8DF7FB489}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F41D98F2-7684-4786-969C-BFC8DF7FB489}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5049A2F6-9604-49B1-B826-1CAC1B009D5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5049A2F6-9604-49B1-B826-1CAC1B009D5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5049A2F6-9604-49B1-B826-1CAC1B009D5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5049A2F6-9604-49B1-B826-1CAC1B009D5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
Loading…
x
Reference in New Issue
Block a user