bsky feeds about music music-atmosphere-feed.plyr.fm/
bsky feed zig

add unit tests and ci workflow

- 7 tests: filter detection, stats counters, status logic
- ci: lint, build, test on ubuntu/macos matrix
- pre-commit hook now runs zig build
- simplify filter.zig json navigation with zat.json helpers

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

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

+253 -72
+7 -1
.githooks/pre-commit
··· 1 1 #!/bin/sh 2 - # format zig files before commit 2 + # check formatting and build before commit 3 3 4 4 zig fmt --check src/ build.zig 2>/dev/null 5 5 if [ $? -ne 0 ]; then 6 6 echo "zig fmt check failed. run 'zig fmt src/ build.zig' to fix." 7 7 exit 1 8 8 fi 9 + 10 + zig build 2>/dev/null 11 + if [ $? -ne 0 ]; then 12 + echo "zig build failed." 13 + exit 1 14 + fi
+37
.github/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + schedule: 8 + - cron: "0 0 * * 0" # weekly 9 + 10 + jobs: 11 + lint: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v4 15 + - uses: mlugg/setup-zig@v2 16 + - run: zig fmt --check src/ build.zig 17 + 18 + build: 19 + runs-on: ubuntu-latest 20 + steps: 21 + - uses: actions/checkout@v4 22 + - uses: mlugg/setup-zig@v2 23 + - run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev 24 + - run: zig build 25 + 26 + test: 27 + strategy: 28 + matrix: 29 + os: [ubuntu-latest, macos-latest] 30 + runs-on: ${{ matrix.os }} 31 + steps: 32 + - uses: actions/checkout@v4 33 + - uses: mlugg/setup-zig@v2 34 + - name: Install sqlite3 (Ubuntu) 35 + if: matrix.os == 'ubuntu-latest' 36 + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev 37 + - run: zig build test --summary all
+16
build.zig
··· 46 46 47 47 const run_step = b.step("run", "Run the feed server"); 48 48 run_step.dependOn(&run_cmd.step); 49 + 50 + // tests 51 + const tests = b.addTest(.{ 52 + .root_module = b.createModule(.{ 53 + .root_source_file = b.path("src/test_root.zig"), 54 + .target = target, 55 + .optimize = optimize, 56 + .imports = &.{ 57 + .{ .name = "zat", .module = zat.module("zat") }, 58 + }, 59 + }), 60 + }); 61 + 62 + const run_tests = b.addRunArtifact(tests); 63 + const test_step = b.step("test", "Run unit tests"); 64 + test_step.dependOn(&run_tests.step); 49 65 }
+78 -69
src/feed/filter.zig
··· 1 1 const std = @import("std"); 2 2 const mem = std.mem; 3 3 const json = std.json; 4 + const zat = @import("zat"); 4 5 const stats = @import("../server/stats.zig"); 5 6 6 7 const Record = json.ObjectMap; ··· 40 41 }; 41 42 42 43 fn excludeNsfwLabels(record: Record) ?bool { 43 - const labels = record.get("labels") orelse return null; 44 - if (labels != .object) return null; 45 - const values = labels.object.get("values") orelse return null; 46 - if (values != .array) return null; 44 + const val: json.Value = .{ .object = record }; 45 + const values = zat.json.getArray(val, "labels.values") orelse return null; 47 46 48 - for (values.array.items) |item| { 49 - if (item != .object) continue; 50 - const val = item.object.get("val") orelse continue; 51 - if (val != .string) continue; 47 + for (values) |item| { 48 + const label_val = zat.json.getString(item, "val") orelse continue; 52 49 for (nsfw_labels) |label| { 53 - if (mem.eql(u8, val.string, label)) return false; 50 + if (mem.eql(u8, label_val, label)) return false; 54 51 } 55 52 } 56 53 return null; ··· 70 67 }; 71 68 72 69 fn includeMusicLinks(record: Record) ?bool { 73 - // check facets (inline links) 74 - if (record.get("facets")) |facets_val| { 75 - if (facets_val == .array) { 76 - for (facets_val.array.items) |facet| { 77 - if (facet != .object) continue; 78 - const features_val = facet.object.get("features") orelse continue; 79 - if (features_val != .array) continue; 70 + const val: json.Value = .{ .object = record }; 80 71 81 - for (features_val.array.items) |feature| { 82 - if (feature != .object) continue; 83 - 84 - const type_val = feature.object.get("$type") orelse continue; 85 - if (type_val != .string) continue; 86 - if (!mem.eql(u8, type_val.string, "app.bsky.richtext.facet#link")) continue; 87 - 88 - const uri_val = feature.object.get("uri") orelse continue; 89 - if (uri_val != .string) continue; 90 - 91 - if (containsMusicDomain(uri_val.string)) return true; 92 - } 93 - } 94 - } 72 + // check embed.external.uri (link cards) 73 + if (zat.json.getString(val, "embed.external.uri")) |uri| { 74 + if (containsMusicDomain(uri)) return true; 95 75 } 96 76 97 - // check embed.external.uri (link cards) 98 - if (record.get("embed")) |embed_val| { 99 - if (embed_val == .object) { 100 - if (embed_val.object.get("external")) |external_val| { 101 - if (external_val == .object) { 102 - if (external_val.object.get("uri")) |uri_val| { 103 - if (uri_val == .string) { 104 - if (containsMusicDomain(uri_val.string)) return true; 105 - } 106 - } 107 - } 108 - } 77 + // check facets (inline links) 78 + const facets = zat.json.getArray(val, "facets") orelse return null; 79 + for (facets) |facet| { 80 + const features = zat.json.getArray(facet, "features") orelse continue; 81 + for (features) |feature| { 82 + const type_str = zat.json.getString(feature, "$type") orelse continue; 83 + if (!mem.eql(u8, type_str, "app.bsky.richtext.facet#link")) continue; 84 + const uri = zat.json.getString(feature, "uri") orelse continue; 85 + if (containsMusicDomain(uri)) return true; 109 86 } 110 87 } 111 88 ··· 138 115 139 116 /// detect platform from post record (checks facets and embeds) 140 117 pub fn detectPlatformFromRecord(record: Record) ?stats.Platform { 141 - // check facets 142 - if (record.get("facets")) |facets_val| { 143 - if (facets_val == .array) { 144 - for (facets_val.array.items) |facet| { 145 - if (facet != .object) continue; 146 - const features_val = facet.object.get("features") orelse continue; 147 - if (features_val != .array) continue; 118 + const val: json.Value = .{ .object = record }; 148 119 149 - for (features_val.array.items) |feature| { 150 - if (feature != .object) continue; 151 - const uri_val = feature.object.get("uri") orelse continue; 152 - if (uri_val != .string) continue; 153 - if (detectPlatform(uri_val.string)) |p| return p; 154 - } 155 - } 156 - } 120 + // check embed.external.uri 121 + if (zat.json.getString(val, "embed.external.uri")) |uri| { 122 + if (detectPlatform(uri)) |p| return p; 157 123 } 158 124 159 - // check embed 160 - if (record.get("embed")) |embed_val| { 161 - if (embed_val == .object) { 162 - if (embed_val.object.get("external")) |external_val| { 163 - if (external_val == .object) { 164 - if (external_val.object.get("uri")) |uri_val| { 165 - if (uri_val == .string) { 166 - if (detectPlatform(uri_val.string)) |p| return p; 167 - } 168 - } 169 - } 170 - } 125 + // check facets 126 + const facets = zat.json.getArray(val, "facets") orelse return null; 127 + for (facets) |facet| { 128 + const features = zat.json.getArray(facet, "features") orelse continue; 129 + for (features) |feature| { 130 + const uri = zat.json.getString(feature, "uri") orelse continue; 131 + if (detectPlatform(uri)) |p| return p; 171 132 } 172 133 } 173 134 ··· 181 142 } 182 143 return false; 183 144 } 145 + 146 + // ----------------------------------------------------------------------------- 147 + // tests 148 + // ----------------------------------------------------------------------------- 149 + 150 + test "detectPlatform" { 151 + const testing = std.testing; 152 + 153 + // soundcloud 154 + try testing.expectEqual(.soundcloud, detectPlatform("https://soundcloud.com/artist/track")); 155 + try testing.expectEqual(.soundcloud, detectPlatform("https://on.soundcloud.com/abc123")); 156 + 157 + // bandcamp 158 + try testing.expectEqual(.bandcamp, detectPlatform("https://artist.bandcamp.com/album/xyz")); 159 + 160 + // spotify 161 + try testing.expectEqual(.spotify, detectPlatform("https://open.spotify.com/track/123")); 162 + try testing.expectEqual(.spotify, detectPlatform("https://spotify.link/abc")); 163 + 164 + // plyr 165 + try testing.expectEqual(.plyr, detectPlatform("https://plyr.fm/track/123")); 166 + 167 + // unknown 168 + try testing.expectEqual(null, detectPlatform("https://youtube.com/watch?v=123")); 169 + try testing.expectEqual(null, detectPlatform("https://example.com")); 170 + } 171 + 172 + test "containsMusicDomain" { 173 + const testing = std.testing; 174 + 175 + try testing.expect(containsMusicDomain("https://soundcloud.com/foo")); 176 + try testing.expect(containsMusicDomain("https://artist.bandcamp.com/track")); 177 + try testing.expect(containsMusicDomain("https://open.spotify.com/track/x")); 178 + try testing.expect(containsMusicDomain("https://plyr.fm/x")); 179 + 180 + try testing.expect(!containsMusicDomain("https://youtube.com/watch")); 181 + try testing.expect(!containsMusicDomain("https://twitter.com/post")); 182 + try testing.expect(!containsMusicDomain("")); 183 + } 184 + 185 + test "textContainsMusicLink" { 186 + const testing = std.testing; 187 + 188 + try testing.expect(textContainsMusicLink("check out this track https://soundcloud.com/artist/song")); 189 + try testing.expect(textContainsMusicLink("new release on bandcamp.com")); 190 + try testing.expect(!textContainsMusicLink("just vibing to some tunes")); 191 + try testing.expect(!textContainsMusicLink("")); 192 + }
+109 -2
src/server/stats.zig
··· 67 67 if (root.get("cumulative_uptime")) |v| if (v == .integer) { 68 68 self.prior_uptime = @intCast(@max(0, v.integer)); 69 69 }; 70 + if (root.get("matches")) |v| if (v == .integer) { 71 + self.matches.store(@intCast(@max(0, v.integer)), .monotonic); 72 + }; 73 + if (root.get("soundcloud")) |v| if (v == .integer) { 74 + self.soundcloud.store(@intCast(@max(0, v.integer)), .monotonic); 75 + }; 76 + if (root.get("bandcamp")) |v| if (v == .integer) { 77 + self.bandcamp.store(@intCast(@max(0, v.integer)), .monotonic); 78 + }; 79 + if (root.get("spotify")) |v| if (v == .integer) { 80 + self.spotify.store(@intCast(@max(0, v.integer)), .monotonic); 81 + }; 82 + if (root.get("plyr")) |v| if (v == .integer) { 83 + self.plyr.store(@intCast(@max(0, v.integer)), .monotonic); 84 + }; 70 85 71 86 std.debug.print("loaded stats from {s}\n", .{STATS_PATH}); 72 87 } ··· 79 94 const session_uptime: u64 = @intCast(@max(0, now - self.started_at)); 80 95 const total_uptime = self.prior_uptime + session_uptime; 81 96 82 - var buf: [256]u8 = undefined; 83 - const data = std.fmt.bufPrint(&buf, "{{\"messages\":{},\"cumulative_uptime\":{}}}", .{ 97 + var buf: [512]u8 = undefined; 98 + const data = std.fmt.bufPrint(&buf, 99 + \\{{"messages":{},"matches":{},"cumulative_uptime":{},"soundcloud":{},"bandcamp":{},"spotify":{},"plyr":{}}} 100 + , .{ 84 101 self.messages.load(.monotonic), 102 + self.matches.load(.monotonic), 85 103 total_uptime, 104 + self.soundcloud.load(.monotonic), 105 + self.bandcamp.load(.monotonic), 106 + self.spotify.load(.monotonic), 107 + self.plyr.load(.monotonic), 86 108 }) catch return; 87 109 88 110 file.writeAll(data) catch return; ··· 197 219 global_stats.save(); 198 220 } 199 221 } 222 + 223 + // ----------------------------------------------------------------------------- 224 + // tests 225 + // ----------------------------------------------------------------------------- 226 + 227 + test "Stats.recordMessage increments counter" { 228 + var s = Stats{ 229 + .started_at = std.time.timestamp(), 230 + .messages = Atomic(u64).init(0), 231 + .matches = Atomic(u64).init(0), 232 + .last_event_time_us = Atomic(i64).init(0), 233 + .last_event_received_at = Atomic(i64).init(0), 234 + .last_match_time = Atomic(i64).init(0), 235 + .connected_at = Atomic(i64).init(0), 236 + .last_post_time_ms = Atomic(i64).init(0), 237 + .soundcloud = Atomic(u64).init(0), 238 + .bandcamp = Atomic(u64).init(0), 239 + .spotify = Atomic(u64).init(0), 240 + .plyr = Atomic(u64).init(0), 241 + .prev_lag_ms = Atomic(i64).init(0), 242 + }; 243 + 244 + try std.testing.expectEqual(0, s.getMessages()); 245 + s.recordMessage(); 246 + try std.testing.expectEqual(1, s.getMessages()); 247 + s.recordMessage(); 248 + try std.testing.expectEqual(2, s.getMessages()); 249 + } 250 + 251 + test "Stats.recordPlatform increments platform counters" { 252 + var s = Stats{ 253 + .started_at = std.time.timestamp(), 254 + .messages = Atomic(u64).init(0), 255 + .matches = Atomic(u64).init(0), 256 + .last_event_time_us = Atomic(i64).init(0), 257 + .last_event_received_at = Atomic(i64).init(0), 258 + .last_match_time = Atomic(i64).init(0), 259 + .connected_at = Atomic(i64).init(0), 260 + .last_post_time_ms = Atomic(i64).init(0), 261 + .soundcloud = Atomic(u64).init(0), 262 + .bandcamp = Atomic(u64).init(0), 263 + .spotify = Atomic(u64).init(0), 264 + .plyr = Atomic(u64).init(0), 265 + .prev_lag_ms = Atomic(i64).init(0), 266 + }; 267 + 268 + s.recordPlatform(.soundcloud); 269 + s.recordPlatform(.soundcloud); 270 + s.recordPlatform(.bandcamp); 271 + 272 + const counts = s.getPlatformCounts(); 273 + try std.testing.expectEqual(2, counts.soundcloud); 274 + try std.testing.expectEqual(1, counts.bandcamp); 275 + try std.testing.expectEqual(0, counts.spotify); 276 + try std.testing.expectEqual(0, counts.plyr); 277 + } 278 + 279 + test "Stats.getStatus returns live or catching_up" { 280 + var s = Stats{ 281 + .started_at = std.time.timestamp(), 282 + .messages = Atomic(u64).init(0), 283 + .matches = Atomic(u64).init(0), 284 + .last_event_time_us = Atomic(i64).init(0), 285 + .last_event_received_at = Atomic(i64).init(0), 286 + .last_match_time = Atomic(i64).init(0), 287 + .connected_at = Atomic(i64).init(0), 288 + .last_post_time_ms = Atomic(i64).init(0), 289 + .soundcloud = Atomic(u64).init(0), 290 + .bandcamp = Atomic(u64).init(0), 291 + .spotify = Atomic(u64).init(0), 292 + .plyr = Atomic(u64).init(0), 293 + .prev_lag_ms = Atomic(i64).init(0), 294 + }; 295 + 296 + // no post time set = live 297 + try std.testing.expectEqualStrings("live", s.getStatus()); 298 + 299 + // recent post = live 300 + s.last_post_time_ms.store(std.time.milliTimestamp(), .monotonic); 301 + try std.testing.expectEqualStrings("live", s.getStatus()); 302 + 303 + // old post = catching_up 304 + s.last_post_time_ms.store(std.time.milliTimestamp() - 120000, .monotonic); 305 + try std.testing.expectEqualStrings("catching_up", s.getStatus()); 306 + }
+6
src/test_root.zig
··· 1 + // test root - imports modules to run their tests 2 + 3 + test { 4 + _ = @import("feed/filter.zig"); 5 + _ = @import("server/stats.zig"); 6 + }