···11+# required
22+FEED_HOSTNAME="zig-bsky-feed.fly.dev" # your fly.io hostname (or custom domain)
33+FEED_PUBLISHER_DID="did:plc:your-did" # your bluesky DID
44+FEED_RECORD_NAME="music-atmosphere" # short name for the feed
55+66+# for publishing (used by publish script)
77+HANDLE="yourhandle.bsky.social"
88+PASSWORD="your-app-password"
99+DISPLAY_NAME="music atmosphere"
1010+1111+# optional (derived automatically if not set)
1212+# FEED_SERVICE_DID="did:web:zig-bsky-feed.fly.dev"
1313+# FEED_URI="at://did:plc:your-did/app.bsky.feed.generator/music-atmosphere"
1414+1515+# server
1616+PORT=3000
···11+MIT License
22+33+Copyright (c) 2026 Nate Wilkins
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+65
README.md
···11+# music-atmosphere-feed
22+33+a bluesky feed that surfaces posts with music links. written in zig.
44+55+## supported platforms
66+77+- soundcloud.com
88+- bandcamp.com
99+- plyr.fm
1010+1111+## how it works
1212+1313+connects to the [bluesky jetstream](https://docs.bsky.app/blog/jetstream) firehose, filters posts containing music platform links in their facets (actual hyperlinks, not just text mentions), and serves them via the AT Protocol feed generator endpoints.
1414+1515+## customizing the filter
1616+1717+edit `src/filter.zig` to change which posts appear in your feed:
1818+1919+```zig
2020+/// Returns true if the post record should be included in the feed.
2121+pub fn matches(record: json.ObjectMap) bool {
2222+ return hasMusicLink(record); // replace with your own logic
2323+}
2424+```
2525+2626+## running locally
2727+2828+```bash
2929+# set environment variables
3030+export HANDLE=your.handle
3131+export PASSWORD=your-app-password
3232+export FEED_HOSTNAME=your-hostname.fly.dev
3333+export PUBLISHER_DID=did:plc:your-did
3434+3535+# build and run
3636+zig build run
3737+```
3838+3939+## deploying to fly.io
4040+4141+```bash
4242+# create app and volume
4343+fly launch --no-deploy
4444+fly volumes create feed_data --region ord --size 1
4545+4646+# set secrets
4747+fly secrets set JETSTREAM_HOST=jetstream.fire.hose.cam
4848+fly secrets set PUBLISHER_DID=did:plc:your-did
4949+fly secrets set FEED_HOSTNAME=your-app.fly.dev
5050+5151+# deploy
5252+fly deploy
5353+```
5454+5555+## publishing the feed
5656+5757+```bash
5858+HANDLE=your.handle PASSWORD=your-app-password FEED_HOSTNAME=your-app.fly.dev \
5959+ FEED_DESCRIPTION="posts with music links" \
6060+ uv run scripts/publish.py
6161+```
6262+6363+## license
6464+6565+MIT
···11+# social graph filtering research
22+33+research notes on filtering feed posts by social graph distance ("kevin bacon hops").
44+55+## the idea
66+77+instead of showing all music posts, show only posts from people within N hops of the viewer's social graph. this would make the feed more relevant/trusted.
88+99+## what we learned
1010+1111+### feed generators receive viewer identity
1212+1313+feed requests include a JWT with the viewer's DID in the `Authorization` header. this enables per-viewer personalization.
1414+1515+### AT Protocol graph APIs
1616+1717+- `app.bsky.graph.getFollows` - paginated list of who a user follows
1818+- `app.bsky.graph.getFollowers` - paginated list of who follows a user
1919+2020+### jaz's graphd
2121+2222+https://github.com/ericvolp12/bsky-experiments
2323+2424+jaz (works at bluesky) built an in-memory graph service using **roaring bitmaps** for fast set operations:
2525+2626+- each user stored as two bitmaps: `following` and `followers`
2727+- endpoints: `/moots`, `/intersect_followers`, `/follows_following`, `/does_follow`
2828+- set operations (intersection, union) are very fast on compressed bitmaps
2929+- used to power the atlas visualization at https://bsky.jazco.dev/
3030+3131+### spacecowboy's "for you" feed
3232+3333+https://bsky.app/profile/spacecowboy17.bsky.social/feed/for-you
3434+3535+uses collaborative filtering: "finds people who liked the same posts as you, and shows you what else they liked recently." source code not public.
3636+3737+## the core problem
3838+3939+to answer "is post author within N hops of viewer?":
4040+4141+- N=1 (direct follows): ~500-2000 people per user - manageable
4242+- N=2 (friends of friends): 100K-1M+ people
4343+- N=3: explodes to millions
4444+4545+key insight: we don't need exact distance, just "<= N or not"
4646+4747+## possible approaches
4848+4949+1. **external graphd** - deploy jaz's graphd, query at feed-serve time
5050+2. **sqlite graph** - store follows in sqlite, compute distance on-demand
5151+3. **pre-computed neighborhoods** - cache each viewer's N-hop set
5252+4. **bloom filters** - approximate set membership (false positives ok)
5353+5454+## references
5555+5656+- https://docs.bsky.app/docs/api/app-bsky-graph-get-follows
5757+- https://docs.bsky.app/docs/api/app-bsky-graph-get-followers
5858+- https://github.com/ericvolp12/bsky-experiments (graphd)
5959+- https://github.com/RoaringBitmap/roaring (bitmap library)
6060+- https://zenodo.org/records/14258401 (bluesky social dataset)
6161+6262+## status
6363+6464+parked for now. feed works fine showing all music posts. revisit if we want to add personalization.
+27
fly.toml
···11+# fly.toml app configuration file generated for zig-bsky-feed on 2026-01-02T20:04:47-06:00
22+#
33+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
44+#
55+66+app = 'zig-bsky-feed'
77+primary_region = 'ord'
88+99+[build]
1010+1111+[http_service]
1212+ internal_port = 3000
1313+ force_https = true
1414+ auto_stop_machines = 'stop'
1515+ auto_start_machines = true
1616+ min_machines_running = 1
1717+ processes = ['app']
1818+1919+[mounts]
2020+ source = "feed_data"
2121+ destination = "/data"
2222+2323+[[vm]]
2424+ memory = '256mb'
2525+ cpu_kind = 'shared'
2626+ cpus = 1
2727+ memory_mb = 256
+18
justfile
···11+default:
22+ @just --list
33+44+build:
55+ zig build
66+77+run:
88+ zig build run
99+1010+deploy:
1111+ fly deploy
1212+1313+logs:
1414+ fly logs
1515+1616+# publish feed to bluesky (requires HANDLE, PASSWORD, FEED_HOSTNAME in .env)
1717+publish:
1818+ set -a && source .env && uv run scripts/publish.py
···11+const std = @import("std");
22+const mem = std.mem;
33+const json = std.json;
44+55+// =============================================================================
66+// CUSTOMIZE YOUR FEED HERE
77+//
88+// This file defines which posts appear in your feed.
99+// Edit the `matches` function to change the filtering logic.
1010+// =============================================================================
1111+1212+/// Returns true if the post record should be included in the feed.
1313+/// The record is the parsed JSON object from the jetstream commit.
1414+pub fn matches(record: json.ObjectMap) bool {
1515+ return hasMusicLink(record);
1616+}
1717+1818+// -----------------------------------------------------------------------------
1919+// music-atmosphere filter: posts with links to music platforms
2020+// -----------------------------------------------------------------------------
2121+2222+const music_domains = [_][]const u8{
2323+ "soundcloud.com",
2424+ "on.soundcloud.com",
2525+ "bandcamp.com",
2626+ "plyr.fm",
2727+};
2828+2929+fn hasMusicLink(record: json.ObjectMap) bool {
3030+ // check facets array for link features
3131+ const facets_val = record.get("facets") orelse return false;
3232+ if (facets_val != .array) return false;
3333+3434+ for (facets_val.array.items) |facet| {
3535+ if (facet != .object) continue;
3636+3737+ const features_val = facet.object.get("features") orelse continue;
3838+ if (features_val != .array) continue;
3939+4040+ for (features_val.array.items) |feature| {
4141+ if (feature != .object) continue;
4242+4343+ // check if it's a link feature
4444+ const type_val = feature.object.get("$type") orelse continue;
4545+ if (type_val != .string) continue;
4646+ if (!mem.eql(u8, type_val.string, "app.bsky.richtext.facet#link")) continue;
4747+4848+ // check the uri
4949+ const uri_val = feature.object.get("uri") orelse continue;
5050+ if (uri_val != .string) continue;
5151+5252+ for (music_domains) |domain| {
5353+ if (mem.indexOf(u8, uri_val.string, domain) != null) return true;
5454+ }
5555+ }
5656+ }
5757+5858+ return false;
5959+}