feat: add apple music support

- filter.zig: add music.apple.com domain detection
- stats.zig: add apple platform counter
- dashboard.zig: display apple music in platforms list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+23 -4
src
+5
src/feed/filter.zig
··· 64 64 "plyr.fm", 65 65 "open.spotify.com", 66 66 "spotify.link", 67 + "music.apple.com", 67 68 }; 68 69 69 70 fn includeMusicLinks(record: Record) ?bool { ··· 110 111 return .spotify; 111 112 } 112 113 if (mem.indexOf(u8, uri, "plyr.fm") != null) return .plyr; 114 + if (mem.indexOf(u8, uri, "music.apple.com") != null) return .apple; 113 115 return null; 114 116 } 115 117 ··· 163 165 164 166 // plyr 165 167 try testing.expectEqual(.plyr, detectPlatform("https://plyr.fm/track/123")); 168 + 169 + // apple music 170 + try testing.expectEqual(.apple, detectPlatform("https://music.apple.com/au/album/gooey/1440817571")); 166 171 167 172 // unknown 168 173 try testing.expectEqual(null, detectPlatform("https://youtube.com/watch?v=123"));
+2 -1
src/server/dashboard.zig
··· 176 176 177 177 // get platform counts 178 178 const platforms = s.getPlatformCounts(); 179 - const total = platforms.soundcloud + platforms.bandcamp + platforms.spotify + platforms.plyr; 179 + const total = platforms.soundcloud + platforms.bandcamp + platforms.spotify + platforms.plyr + platforms.apple; 180 180 181 181 if (total > 0) { 182 182 const PlatformData = struct { name: []const u8, count: u64, color: []const u8 }; ··· 185 185 .{ .name = "bandcamp", .count = platforms.bandcamp, .color = "#1da0c3" }, 186 186 .{ .name = "spotify", .count = platforms.spotify, .color = "#1db954" }, 187 187 .{ .name = "plyr.fm", .count = platforms.plyr, .color = "#7c3aed" }, 188 + .{ .name = "apple music", .count = platforms.apple, .color = "#fa243c" }, 188 189 }; 189 190 190 191 // sort by count descending
+16 -3
src/server/stats.zig
··· 6 6 7 7 const STATS_PATH = "/data/stats.json"; 8 8 9 - pub const Platform = enum { soundcloud, bandcamp, spotify, plyr }; 9 + pub const Platform = enum { soundcloud, bandcamp, spotify, plyr, apple }; 10 10 11 11 pub const Stats = struct { 12 12 started_at: i64, ··· 24 24 bandcamp: Atomic(u64), 25 25 spotify: Atomic(u64), 26 26 plyr: Atomic(u64), 27 + apple: Atomic(u64), 27 28 28 29 // for lag trend tracking 29 30 prev_lag_ms: Atomic(i64), ··· 42 43 .bandcamp = Atomic(u64).init(0), 43 44 .spotify = Atomic(u64).init(0), 44 45 .plyr = Atomic(u64).init(0), 46 + .apple = Atomic(u64).init(0), 45 47 .prev_lag_ms = Atomic(i64).init(0), 46 48 }; 47 49 self.load(); ··· 82 84 if (root.get("plyr")) |v| if (v == .integer) { 83 85 self.plyr.store(@intCast(@max(0, v.integer)), .monotonic); 84 86 }; 87 + if (root.get("apple")) |v| if (v == .integer) { 88 + self.apple.store(@intCast(@max(0, v.integer)), .monotonic); 89 + }; 85 90 86 91 std.debug.print("loaded stats from {s}\n", .{STATS_PATH}); 87 92 } ··· 96 101 97 102 var buf: [512]u8 = undefined; 98 103 const data = std.fmt.bufPrint(&buf, 99 - \\{{"messages":{},"matches":{},"cumulative_uptime":{},"soundcloud":{},"bandcamp":{},"spotify":{},"plyr":{}}} 104 + \\{{"messages":{},"matches":{},"cumulative_uptime":{},"soundcloud":{},"bandcamp":{},"spotify":{},"plyr":{},"apple":{}}} 100 105 , .{ 101 106 self.messages.load(.monotonic), 102 107 self.matches.load(.monotonic), ··· 105 110 self.bandcamp.load(.monotonic), 106 111 self.spotify.load(.monotonic), 107 112 self.plyr.load(.monotonic), 113 + self.apple.load(.monotonic), 108 114 }) catch return; 109 115 110 116 file.writeAll(data) catch return; ··· 152 158 .bandcamp => _ = self.bandcamp.fetchAdd(1, .monotonic), 153 159 .spotify => _ = self.spotify.fetchAdd(1, .monotonic), 154 160 .plyr => _ = self.plyr.fetchAdd(1, .monotonic), 161 + .apple => _ = self.apple.fetchAdd(1, .monotonic), 155 162 } 156 163 } 157 164 158 - pub fn getPlatformCounts(self: *const Stats) struct { soundcloud: u64, bandcamp: u64, spotify: u64, plyr: u64 } { 165 + pub fn getPlatformCounts(self: *const Stats) struct { soundcloud: u64, bandcamp: u64, spotify: u64, plyr: u64, apple: u64 } { 159 166 return .{ 160 167 .soundcloud = self.soundcloud.load(.monotonic), 161 168 .bandcamp = self.bandcamp.load(.monotonic), 162 169 .spotify = self.spotify.load(.monotonic), 163 170 .plyr = self.plyr.load(.monotonic), 171 + .apple = self.apple.load(.monotonic), 164 172 }; 165 173 } 166 174 ··· 238 246 .bandcamp = Atomic(u64).init(0), 239 247 .spotify = Atomic(u64).init(0), 240 248 .plyr = Atomic(u64).init(0), 249 + .apple = Atomic(u64).init(0), 241 250 .prev_lag_ms = Atomic(i64).init(0), 242 251 }; 243 252 ··· 262 271 .bandcamp = Atomic(u64).init(0), 263 272 .spotify = Atomic(u64).init(0), 264 273 .plyr = Atomic(u64).init(0), 274 + .apple = Atomic(u64).init(0), 265 275 .prev_lag_ms = Atomic(i64).init(0), 266 276 }; 267 277 268 278 s.recordPlatform(.soundcloud); 269 279 s.recordPlatform(.soundcloud); 270 280 s.recordPlatform(.bandcamp); 281 + s.recordPlatform(.apple); 271 282 272 283 const counts = s.getPlatformCounts(); 273 284 try std.testing.expectEqual(2, counts.soundcloud); 274 285 try std.testing.expectEqual(1, counts.bandcamp); 275 286 try std.testing.expectEqual(0, counts.spotify); 276 287 try std.testing.expectEqual(0, counts.plyr); 288 + try std.testing.expectEqual(1, counts.apple); 277 289 } 278 290 279 291 test "Stats.getStatus returns live or catching_up" { ··· 290 302 .bandcamp = Atomic(u64).init(0), 291 303 .spotify = Atomic(u64).init(0), 292 304 .plyr = Atomic(u64).init(0), 305 + .apple = Atomic(u64).init(0), 293 306 .prev_lag_ms = Atomic(i64).init(0), 294 307 }; 295 308