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