diff --git a/Selector.AppleMusic/AppleMusicApi.cs b/Selector.AppleMusic/AppleMusicApi.cs new file mode 100644 index 0000000..9a8bf73 --- /dev/null +++ b/Selector.AppleMusic/AppleMusicApi.cs @@ -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"); + } +} \ No newline at end of file diff --git a/Selector.AppleMusic/AppleMusicApiProvider.cs b/Selector.AppleMusic/AppleMusicApiProvider.cs new file mode 100644 index 0000000..4027b69 --- /dev/null +++ b/Selector.AppleMusic/AppleMusicApiProvider.cs @@ -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; + } +} \ No newline at end of file diff --git a/Selector.AppleMusic/Extensions/ServiceExtensions.cs b/Selector.AppleMusic/Extensions/ServiceExtensions.cs new file mode 100644 index 0000000..991a2d8 --- /dev/null +++ b/Selector.AppleMusic/Extensions/ServiceExtensions.cs @@ -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; + } +} \ No newline at end of file diff --git a/Selector.AppleMusic/Jwt.cs b/Selector.AppleMusic/Jwt.cs new file mode 100644 index 0000000..fe3d4aa --- /dev/null +++ b/Selector.AppleMusic/Jwt.cs @@ -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", ""); + } +} \ No newline at end of file diff --git a/Selector.AppleMusic/Selector.AppleMusic.csproj b/Selector.AppleMusic/Selector.AppleMusic.csproj new file mode 100644 index 0000000..13733df --- /dev/null +++ b/Selector.AppleMusic/Selector.AppleMusic.csproj @@ -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> diff --git a/Selector.CLI/Command/HostCommand.cs b/Selector.CLI/Command/HostCommand.cs index 6e822e4..0fb6934 100644 --- a/Selector.CLI/Command/HostCommand.cs +++ b/Selector.CLI/Command/HostCommand.cs @@ -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..."); diff --git a/Selector.CLI/Options.cs b/Selector.CLI/Options.cs index 7ffde60..e5000e3 100644 --- a/Selector.CLI/Options.cs +++ b/Selector.CLI/Options.cs @@ -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; + } } diff --git a/Selector.CLI/Selector.CLI.csproj b/Selector.CLI/Selector.CLI.csproj index 4fabe92..ff6bd19 100644 --- a/Selector.CLI/Selector.CLI.csproj +++ b/Selector.CLI/Selector.CLI.csproj @@ -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" /> diff --git a/Selector.Cache/Key.cs b/Selector.Cache/Key.cs index 9cf26a9..a9d814e 100644 --- a/Selector.Cache/Key.cs +++ b/Selector.Cache/Key.cs @@ -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); diff --git a/Selector.Event/CacheJsonContext.cs b/Selector.Event/CacheJsonContext.cs index 4337d3f..a254b71 100644 --- a/Selector.Event/CacheJsonContext.cs +++ b/Selector.Event/CacheJsonContext.cs @@ -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 { diff --git a/Selector.Event/CacheMappings/AppleMusicMapping.cs b/Selector.Event/CacheMappings/AppleMusicMapping.cs new file mode 100644 index 0000000..337ed68 --- /dev/null +++ b/Selector.Event/CacheMappings/AppleMusicMapping.cs @@ -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; + } + } + } +} \ No newline at end of file diff --git a/Selector.Event/UserEventBus.cs b/Selector.Event/UserEventBus.cs index 5b8a943..3220457 100644 --- a/Selector.Event/UserEventBus.cs +++ b/Selector.Event/UserEventBus.cs @@ -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); diff --git a/Selector.MAUI/wwwroot/css/card.css b/Selector.MAUI/wwwroot/css/card.css index 1be76a3..a8a1d64 100644 --- a/Selector.MAUI/wwwroot/css/card.css +++ b/Selector.MAUI/wwwroot/css/card.css @@ -65,6 +65,14 @@ width: 21px; } +.apple-logo { + width: 21px; +} + +.apple-logo img { + width: 21px; +} + .lastfm-logo { width: 24px; } diff --git a/Selector.Model/ApplicationUser.cs b/Selector.Model/ApplicationUser.cs index ebf21d4..f4f35f9 100644 --- a/Selector.Model/ApplicationUser.cs +++ b/Selector.Model/ApplicationUser.cs @@ -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 }; } diff --git a/Selector.Model/Migrations/20250329231051_adding_apple_music_user_properties.Designer.cs b/Selector.Model/Migrations/20250329231051_adding_apple_music_user_properties.Designer.cs new file mode 100644 index 0000000..4a60486 --- /dev/null +++ b/Selector.Model/Migrations/20250329231051_adding_apple_music_user_properties.Designer.cs @@ -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 + } + } +} diff --git a/Selector.Model/Migrations/20250329231051_adding_apple_music_user_properties.cs b/Selector.Model/Migrations/20250329231051_adding_apple_music_user_properties.cs new file mode 100644 index 0000000..f63f6ed --- /dev/null +++ b/Selector.Model/Migrations/20250329231051_adding_apple_music_user_properties.cs @@ -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"); + } + } +} diff --git a/Selector.Model/Migrations/ApplicationDbContextModelSnapshot.cs b/Selector.Model/Migrations/ApplicationDbContextModelSnapshot.cs index 632ed70..464de93 100644 --- a/Selector.Model/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Selector.Model/Migrations/ApplicationDbContextModelSnapshot.cs @@ -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"); diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/AppleMusic.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/AppleMusic.cshtml new file mode 100644 index 0000000..16d47b2 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/AppleMusic.cshtml @@ -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> +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/AppleMusic.cshtml.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/AppleMusic.cshtml.cs new file mode 100644 index 0000000..3947bf6 --- /dev/null +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/AppleMusic.cshtml.cs @@ -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(); + } + } +} diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/Selector.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs index ac005a6..d9fe6aa 100644 --- a/Selector.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -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) { diff --git a/Selector.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/Selector.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml index 8e89fde..4daa6f0 100644 --- a/Selector.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml +++ b/Selector.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -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> diff --git a/Selector.Web/CSS/now.scss b/Selector.Web/CSS/now.scss index 0cf49e0..45bb8ce 100644 --- a/Selector.Web/CSS/now.scss +++ b/Selector.Web/CSS/now.scss @@ -73,6 +73,14 @@ $shadow-color: #0f0f0f; } } +.apple-logo { + width: 21px; + + img { + width: 21px; + } +} + .lastfm-logo { width: 24px; diff --git a/Selector.Web/Controller/AppleMusicController.cs b/Selector.Web/Controller/AppleMusicController.cs new file mode 100644 index 0000000..13b3e73 --- /dev/null +++ b/Selector.Web/Controller/AppleMusicController.cs @@ -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(); + } + } +} \ No newline at end of file diff --git a/Selector.Web/Options.cs b/Selector.Web/Options.cs index 8157aae..b9fb6b9 100644 --- a/Selector.Web/Options.cs +++ b/Selector.Web/Options.cs @@ -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; + } } diff --git a/Selector.Web/Selector.Web.csproj b/Selector.Web/Selector.Web.csproj index fa44a10..2fd76af 100644 --- a/Selector.Web/Selector.Web.csproj +++ b/Selector.Web/Selector.Web.csproj @@ -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" /> diff --git a/Selector.Web/Startup.cs b/Selector.Web/Startup.cs index d17f7a9..7044b20 100644 --- a/Selector.Web/Startup.cs +++ b/Selector.Web/Startup.cs @@ -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>(); diff --git a/Selector.Web/wwwroot/apple.png b/Selector.Web/wwwroot/apple.png new file mode 100644 index 0000000..2399fbf Binary files /dev/null and b/Selector.Web/wwwroot/apple.png differ diff --git a/Selector.sln b/Selector.sln index f024903..1f7647c 100644 --- a/Selector.sln +++ b/Selector.sln @@ -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