[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

sounds (#40)

* soundsss

* nu

* title

* trending

* sobrou nem pro json

* codegen after merge

* move to new lexicon category

* rm unneeded typing, change lex to getAudioPosts

---------

Co-authored-by: Roscoe Rubin-Rottenberg <roscoe@knotbin.com>

authored by

Davi Rodrigues
Roscoe Rubin-Rottenberg
and committed by
GitHub
fe18333d b49b9350

+1520 -183
+8
api/index.ts
··· 7 7 import getProfile from "./so/sprk/actor/getProfile.ts"; 8 8 import getAuthorFeed from "./so/sprk/feed/getAuthorFeed.ts"; 9 9 import getPostThread from "./so/sprk/feed/getPostThread.ts"; 10 + import getAudios from "./so/sprk/sound/getAudios.ts"; 11 + import getAudioPosts from "./so/sprk/sound/getAudioPosts.ts"; 10 12 import getFollows from "./so/sprk/graph/getFollows.ts"; 11 13 import getFollowers from "./so/sprk/graph/getFollowers.ts"; 12 14 import putPreferences from "./so/sprk/actor/putPreferences.ts"; ··· 18 20 import getStoriesTimeline from "./so/sprk/feed/getStoriesTimeline.ts"; 19 21 import getProfiles from "./so/sprk/actor/getProfiles.ts"; 20 22 import searchPosts from "./so/sprk/feed/searchPosts.ts"; 23 + import getActorAudios from "./so/sprk/sound/getActorAudios.ts"; 24 + import getTrendingAudios from "./so/sprk/sound/getTrendingAudios.ts"; 21 25 import getSuggestedFeeds from "./so/sprk/feed/getSuggestedFeeds.ts"; 22 26 import getTimeline from "./so/sprk/feed/getTimeline.ts"; 23 27 ··· 30 34 getProfiles(server, ctx); 31 35 getAuthorFeed(server, ctx); 32 36 getPostThread(server, ctx); 37 + getAudios(server, ctx); 38 + getAudioPosts(server, ctx); 33 39 getFollows(server, ctx); 34 40 getFollowers(server, ctx); 35 41 putPreferences(server, ctx); ··· 40 46 getStories(server, ctx); 41 47 getStoriesTimeline(server, ctx); 42 48 searchPosts(server, ctx); 49 + getActorAudios(server, ctx); 50 + getTrendingAudios(server, ctx); 43 51 getSuggestedFeeds(server, ctx); 44 52 getTimeline(server, ctx); 45 53 }
+132
api/so/sprk/sound/getActorAudios.ts
··· 1 + import { Server } from "../../../../lex/index.ts"; 2 + import { AppContext } from "../../../../main.ts"; 3 + import { transformAudiosToAudioViews } from "../../../../utils/audio-transformer.ts"; 4 + import { decodeBase64, encodeBase64 } from "jsr:@std/encoding"; 5 + 6 + interface CursorData { 7 + createdAt: string; 8 + id: string; 9 + } 10 + 11 + function parseCursor(cursor: string): CursorData { 12 + try { 13 + const decoded = new TextDecoder().decode(decodeBase64(cursor)); 14 + const [createdAt, id] = decoded.split("::"); 15 + if (!createdAt || !id) throw new Error("Invalid cursor format"); 16 + return { createdAt, id }; 17 + } catch { 18 + throw new Error("Invalid cursor format"); 19 + } 20 + } 21 + 22 + function generateCursor(createdAt: string, id: string): string { 23 + return encodeBase64(new TextEncoder().encode(`${createdAt}::${id}`)); 24 + } 25 + 26 + export default function (server: Server, ctx: AppContext) { 27 + server.so.sprk.sound.getActorAudios({ 28 + auth: ctx.authVerifier.standardOptional, 29 + handler: async ({ params, auth }) => { 30 + try { 31 + const { actor, limit = 50, cursor } = params; 32 + const userDid = auth.credentials.type === "standard" 33 + ? auth.credentials.iss 34 + : undefined; 35 + 36 + // Resolve handle to DID if necessary 37 + let actorDid = actor; 38 + if (!actor.startsWith("did:")) { 39 + try { 40 + const didDoc = await ctx.resolver.resolveHandleToDidDoc(actor); 41 + actorDid = didDoc.did; 42 + } catch (err) { 43 + console.error("Failed to resolve handle:", err); 44 + return { status: 400, message: "Could not resolve actor handle" }; 45 + } 46 + } 47 + 48 + // Block checks when authed 49 + if (userDid) { 50 + const [blockedByActor, blockingActor] = await Promise.all([ 51 + ctx.db.models.Block.findOne({ 52 + authorDid: actorDid, 53 + subject: userDid, 54 + }).lean(), 55 + ctx.db.models.Block.findOne({ 56 + authorDid: userDid, 57 + subject: actorDid, 58 + }).lean(), 59 + ]); 60 + if (blockedByActor) { 61 + return { 62 + status: 400, 63 + error: "BlockedByActor" as const, 64 + message: "The requesting account is blocked by the actor", 65 + }; 66 + } 67 + if (blockingActor) { 68 + return { 69 + status: 400, 70 + error: "BlockedActor" as const, 71 + message: "The requesting account has blocked the actor", 72 + }; 73 + } 74 + } 75 + 76 + // Parse cursor 77 + let cursorData: CursorData | undefined; 78 + if (cursor) { 79 + try { 80 + cursorData = parseCursor(cursor); 81 + } catch { 82 + return { status: 400, message: "The provided cursor is invalid" }; 83 + } 84 + } 85 + 86 + // Build query with keyset pagination 87 + const query: Record<string, unknown> = { authorDid: actorDid }; 88 + if (cursorData) { 89 + query.$or = [ 90 + { createdAt: { $lt: cursorData.createdAt } }, 91 + { createdAt: cursorData.createdAt, _id: { $lt: cursorData.id } }, 92 + ]; 93 + } 94 + 95 + const audios = await ctx.db.models.Audio.find(query) 96 + .sort({ createdAt: -1, _id: -1 }) 97 + .limit(limit + 1) 98 + .lean(); 99 + 100 + const hasMore = audios.length > limit; 101 + if (hasMore) audios.pop(); 102 + 103 + const audioViews = await transformAudiosToAudioViews(audios, ctx); 104 + 105 + let nextCursor: string | undefined; 106 + if (hasMore && audios.length > 0) { 107 + const last = audios[audios.length - 1]; 108 + nextCursor = generateCursor(String(last.createdAt), String(last._id)); 109 + } 110 + 111 + const body = { 112 + audios: audioViews, 113 + ...(nextCursor ? { cursor: nextCursor } : {}), 114 + }; 115 + 116 + return { encoding: "application/json", body }; 117 + } catch (error) { 118 + console.error("Unexpected error in getActorAudios:", error); 119 + if (error instanceof Error) { 120 + const msg = error.message.toLowerCase(); 121 + if (msg.includes("cursor")) { 122 + return { status: 400, message: "The provided cursor is invalid" }; 123 + } 124 + if (msg.includes("limit")) { 125 + return { status: 400, message: "Limit must be between 1 and 100" }; 126 + } 127 + } 128 + return { status: 500, message: "Internal server error" }; 129 + } 130 + }, 131 + }); 132 + }
+123
api/so/sprk/sound/getAudioPosts.ts
··· 1 + import { Server } from "../../../../lex/index.ts"; 2 + import { AppContext } from "../../../../main.ts"; 3 + import { transformPostsToPostViews } from "../../../../utils/post-transformer.ts"; 4 + import { decodeBase64, encodeBase64 } from "jsr:@std/encoding"; 5 + import { transformAudioToAudioView } from "../../../../utils/audio-transformer.ts"; 6 + import { RootFilterQuery } from "mongoose"; 7 + import { PostDocument } from "../../../../data-plane/server/models.ts"; 8 + 9 + interface CursorData { 10 + createdAt: string; 11 + id: string; 12 + } 13 + 14 + function parseCursor(cursor: string): CursorData { 15 + try { 16 + const decoded = new TextDecoder().decode(decodeBase64(cursor)); 17 + const [createdAt, id] = decoded.split("::"); 18 + if (!createdAt || !id) throw new Error("Invalid cursor format"); 19 + return { createdAt, id }; 20 + } catch { 21 + throw new Error("Invalid cursor format"); 22 + } 23 + } 24 + 25 + function generateCursor(createdAt: string, id: string): string { 26 + return encodeBase64(new TextEncoder().encode(`${createdAt}::${id}`)); 27 + } 28 + 29 + export default function (server: Server, ctx: AppContext) { 30 + server.so.sprk.sound.getAudioPosts({ 31 + auth: ctx.authVerifier.standardOptional, 32 + handler: async ({ params, auth }) => { 33 + try { 34 + const { uri, limit = 50, cursor } = params; 35 + const userDid = auth.credentials.type === "standard" 36 + ? auth.credentials.iss 37 + : undefined; 38 + 39 + let cursorData: CursorData | undefined; 40 + if (cursor) { 41 + try { 42 + cursorData = parseCursor(cursor); 43 + } catch { 44 + return { status: 400, message: "The provided cursor is invalid" }; 45 + } 46 + } 47 + 48 + const dbAudio = await ctx.db.models.Audio.findOne({ 49 + uri: uri, 50 + }) 51 + .lean() 52 + .exec(); 53 + 54 + if (!dbAudio) { 55 + return { status: 404, message: "Audio not found" }; 56 + } 57 + 58 + const audioView = await transformAudioToAudioView(dbAudio, ctx); 59 + 60 + const query: RootFilterQuery<PostDocument> = { 61 + "sound.uri": uri, 62 + reply: null, 63 + }; 64 + 65 + if (cursorData) { 66 + query.$or = [ 67 + { createdAt: { $lt: cursorData.createdAt } }, 68 + { createdAt: cursorData.createdAt, _id: { $lt: cursorData.id } }, 69 + ]; 70 + } 71 + 72 + const posts = await ctx.db.models.Post 73 + .find(query) 74 + .sort({ createdAt: -1, _id: -1 }) 75 + .limit(limit + 1) 76 + .lean(); 77 + 78 + const hasMore = posts.length > limit; 79 + if (hasMore) posts.pop(); 80 + 81 + // Block relationships: filter out posts by authors blocked by or blocking user 82 + if (userDid && posts.length > 0) { 83 + const authorDids = [...new Set(posts.map((p) => p.authorDid))]; 84 + const [userBlocking, userBlocked] = await Promise.all([ 85 + ctx.db.models.Block.find({ 86 + authorDid: userDid, 87 + subject: { $in: authorDids }, 88 + }).lean(), 89 + ctx.db.models.Block.find({ 90 + authorDid: { $in: authorDids }, 91 + subject: userDid, 92 + }).lean(), 93 + ]); 94 + const blockedAuthorDids = new Set<string>([ 95 + ...userBlocking.map((b) => b.subject), 96 + ...userBlocked.map((b) => b.authorDid), 97 + ]); 98 + for (let i = posts.length - 1; i >= 0; i--) { 99 + if (blockedAuthorDids.has(posts[i].authorDid)) posts.splice(i, 1); 100 + } 101 + } 102 + 103 + const postViews = await transformPostsToPostViews(posts, ctx, userDid); 104 + 105 + let nextCursor: string | undefined; 106 + if (hasMore && posts.length > 0) { 107 + const last = posts[posts.length - 1]; 108 + nextCursor = generateCursor(String(last.createdAt), String(last._id)); 109 + } 110 + 111 + const body = { 112 + audio: audioView, 113 + posts: postViews, 114 + ...(nextCursor ? { cursor: nextCursor } : {}), 115 + }; 116 + return { encoding: "application/json", body }; 117 + } catch (error) { 118 + console.error("Unexpected error in getAudioPosts:", error); 119 + return { status: 500, message: "Internal server error" }; 120 + } 121 + }, 122 + }); 123 + }
+171
api/so/sprk/sound/getAudios.ts
··· 1 + import { Server } from "../../../../lex/index.ts"; 2 + import { AppContext } from "../../../../main.ts"; 3 + import { transformAudiosToAudioViews } from "../../../../utils/audio-transformer.ts"; 4 + import { AudioDocument } from "../../../../data-plane/server/models.ts"; 5 + 6 + // Constants 7 + const MAX_URI_LENGTH = 3000; 8 + 9 + // Helper function to validate URIs 10 + function validateUris(uris: string[]): { valid: string[]; invalid: string[] } { 11 + const valid: string[] = []; 12 + const invalid: string[] = []; 13 + 14 + for (const uri of uris) { 15 + if (typeof uri !== "string" || uri.length === 0) { 16 + invalid.push(uri); 17 + continue; 18 + } 19 + 20 + if (uri.length > MAX_URI_LENGTH) { 21 + invalid.push(uri); 22 + continue; 23 + } 24 + 25 + if (!uri.startsWith("at://")) { 26 + invalid.push(uri); 27 + continue; 28 + } 29 + 30 + valid.push(uri); 31 + } 32 + 33 + return { valid, invalid }; 34 + } 35 + 36 + // Helper function to deduplicate URIs while preserving order 37 + function deduplicateUris(uris: string[]): string[] { 38 + const seen = new Set<string>(); 39 + return uris.filter((uri) => { 40 + if (seen.has(uri)) return false; 41 + seen.add(uri); 42 + return true; 43 + }); 44 + } 45 + 46 + // Helper function to check for blocked relationships 47 + async function checkBlockedAudios( 48 + ctx: AppContext, 49 + audios: AudioDocument[], 50 + userDid?: string, 51 + ): Promise<Set<string>> { 52 + if (!userDid || audios.length === 0) return new Set(); 53 + 54 + const authorDids = [...new Set(audios.map((a) => a.authorDid))]; 55 + 56 + const [userBlocking, userBlocked] = await Promise.all([ 57 + ctx.db.models.Block.find({ 58 + authorDid: userDid, 59 + subject: { $in: authorDids }, 60 + }).lean(), 61 + ctx.db.models.Block.find({ 62 + authorDid: { $in: authorDids }, 63 + subject: userDid, 64 + }).lean(), 65 + ]); 66 + 67 + const blockedAuthorDids = new Set([ 68 + ...userBlocking.map((b) => b.subject), 69 + ...userBlocked.map((b) => b.authorDid), 70 + ]); 71 + 72 + return new Set( 73 + audios.filter((a) => blockedAuthorDids.has(a.authorDid)).map((a) => a.uri), 74 + ); 75 + } 76 + 77 + // Helper function to sort audios by original URI order 78 + function sortAudiosByUriOrder( 79 + audios: AudioDocument[], 80 + originalUris: string[], 81 + ): AudioDocument[] { 82 + const map = new Map(audios.map((a) => [a.uri, a] as const)); 83 + const sorted: AudioDocument[] = []; 84 + for (const uri of originalUris) { 85 + const audio = map.get(uri); 86 + if (audio) sorted.push(audio); 87 + } 88 + return sorted; 89 + } 90 + 91 + export default function (server: Server, ctx: AppContext) { 92 + server.so.sprk.sound.getAudios({ 93 + auth: ctx.authVerifier.standardOptional, 94 + handler: async ({ params, auth }) => { 95 + try { 96 + const { uris } = params; 97 + const userDid = auth.credentials.type === "standard" 98 + ? auth.credentials.iss 99 + : undefined; 100 + 101 + const { valid: validUris, invalid: invalidUris } = validateUris( 102 + uris, 103 + ); 104 + if (invalidUris.length > 0) { 105 + console.warn( 106 + `Invalid URIs provided: ${invalidUris.slice(0, 5).join(", ")}${ 107 + invalidUris.length > 5 ? "..." : "" 108 + }`, 109 + ); 110 + } 111 + if (validUris.length === 0) { 112 + return { 113 + encoding: "application/json", 114 + body: { audios: [] }, 115 + }; 116 + } 117 + 118 + const uniqueUris = deduplicateUris(validUris); 119 + 120 + const dbAudios = await ctx.db.models.Audio.find({ 121 + uri: { $in: uniqueUris }, 122 + }) 123 + .lean() 124 + .exec(); 125 + 126 + if (dbAudios.length === 0) { 127 + return { 128 + encoding: "application/json", 129 + body: { audios: [] }, 130 + }; 131 + } 132 + 133 + const blockedAudioUris = await checkBlockedAudios( 134 + ctx, 135 + dbAudios, 136 + userDid, 137 + ); 138 + const accessibleAudios = dbAudios.filter((a) => 139 + !blockedAudioUris.has(a.uri) 140 + ); 141 + if (accessibleAudios.length === 0) { 142 + return { 143 + encoding: "application/json", 144 + body: { audios: [] }, 145 + }; 146 + } 147 + 148 + const sorted = sortAudiosByUriOrder(accessibleAudios, uniqueUris); 149 + const views = await transformAudiosToAudioViews(sorted, ctx); 150 + 151 + const response = { audios: views }; 152 + return { encoding: "application/json", body: response }; 153 + } catch (error) { 154 + console.error("Error in getAudios:", error); 155 + if (error instanceof Error) { 156 + const message = error.message; 157 + if (message.includes("connection") || message.includes("timeout")) { 158 + return { status: 503, message: "Database temporarily unavailable" }; 159 + } 160 + if (message.includes("validation") || message.includes("invalid")) { 161 + return { status: 400, message: "Invalid request parameters" }; 162 + } 163 + if (message.includes("limit") || message.includes("quota")) { 164 + return { status: 429, message: "Rate limit exceeded" }; 165 + } 166 + } 167 + return { status: 500, message: "Internal server error" }; 168 + } 169 + }, 170 + }); 171 + }
+85
api/so/sprk/sound/getTrendingAudios.ts
··· 1 + import { Server } from "../../../../lex/index.ts"; 2 + import { AppContext } from "../../../../main.ts"; 3 + import { transformAudiosToAudioViews } from "../../../../utils/audio-transformer.ts"; 4 + import { AudioDocument } from "../../../../data-plane/server/models.ts"; 5 + 6 + interface AudioAggDoc { 7 + uri: string; 8 + createdAt: string | Date; 9 + } 10 + interface BlockDoc { 11 + authorDid: string; 12 + subject: string; 13 + } 14 + 15 + export default function (server: Server, ctx: AppContext) { 16 + server.so.sprk.sound.getTrendingAudios({ 17 + auth: ctx.authVerifier.standardOptional, 18 + handler: async ({ params, auth }) => { 19 + const { limit = 25, cursor } = params; 20 + const userDid = auth.credentials.type === "standard" 21 + ? auth.credentials.iss 22 + : undefined; 23 + 24 + let skip = 0; 25 + if (cursor) { 26 + const parsed = parseInt(cursor, 10); 27 + if (!isNaN(parsed) && parsed > 0) skip = parsed; 28 + } 29 + 30 + // Rank by: useCount desc, then createdAt desc 31 + const docsPage = await ctx.db.models.Audio.find({}) 32 + .sort({ useCount: -1, createdAt: -1 }) 33 + .skip(skip) 34 + .limit(limit) 35 + .lean(); 36 + const uris = docsPage.map((a) => a.uri); 37 + 38 + // Fetch full audio documents and preserve order 39 + const docs = await ctx.db.models.Audio.find({ uri: { $in: uris } }) 40 + .lean(); 41 + const byUri = new Map(docs.map((d) => [d.uri, d] as const)); 42 + let audiosOrdered: AudioDocument[] = []; 43 + for (const uri of uris) { 44 + const doc = byUri.get(uri); 45 + if (doc) audiosOrdered.push(doc); 46 + } 47 + 48 + // Block filtering like other endpoints: when authed, filter out audios authored by blocked accounts 49 + if (userDid && audiosOrdered.length > 0) { 50 + const authorDids = [...new Set(audiosOrdered.map((a) => a.authorDid))]; 51 + const [blocking, blockedBy] = await Promise.all([ 52 + ctx.db.models.Block.find({ 53 + authorDid: userDid, 54 + subject: { $in: authorDids }, 55 + }).lean(), 56 + ctx.db.models.Block.find({ 57 + authorDid: { $in: authorDids }, 58 + subject: userDid, 59 + }).lean(), 60 + ]) satisfies [BlockDoc[], BlockDoc[]]; 61 + const blockedSet = new Set<string>([ 62 + ...blocking.map((b) => b.subject), 63 + ...blockedBy.map((b) => b.authorDid), 64 + ]); 65 + audiosOrdered = audiosOrdered.filter((a) => 66 + !blockedSet.has(a.authorDid) 67 + ); 68 + } 69 + 70 + const views = await transformAudiosToAudioViews(audiosOrdered, ctx); 71 + 72 + let nextCursor: string | undefined; 73 + if (views.length === limit) { 74 + nextCursor = (skip + limit).toString(); 75 + } 76 + 77 + const body = { 78 + audios: views, 79 + ...(nextCursor ? { cursor: nextCursor } : {}), 80 + }; 81 + 82 + return { encoding: "application/json", body }; 83 + }, 84 + }); 85 + }
+7 -10
data-plane/server/indexing/plugins/audio.ts
··· 1 1 import { CID } from "multiformats/cid"; 2 2 import { AtUri, normalizeDatetimeAlways } from "@atproto/syntax"; 3 3 import * as lex from "../../../../lex/lexicons.ts"; 4 - import * as Audio from "../../../../lex/types/so/sprk/feed/audio.ts"; 4 + import * as Audio from "../../../../lex/types/so/sprk/sound/audio.ts"; 5 5 import { BackgroundQueue } from "../../background.ts"; 6 6 import { Database } from "../../index.ts"; 7 7 import { AudioDocument } from "../../models.ts"; 8 8 import { RecordProcessor } from "../processor.ts"; 9 9 import { normalizeObject } from "../../../../utils/embed-normalizer.ts"; 10 10 11 - const lexId = lex.ids.SoSprkFeedAudio; 11 + const lexId = lex.ids.SoSprkSoundAudio; 12 12 type IndexedAudio = AudioDocument; 13 13 14 14 const insertFn = async ( ··· 18 18 obj: Audio.Record, 19 19 timestamp: string, 20 20 ): Promise<IndexedAudio | null> => { 21 - const audio = { 21 + const audio: Record<string, unknown> = { 22 22 uri: uri.toString(), 23 23 cid: cid.toString(), 24 24 authorDid: uri.host, 25 - sound: normalizeObject(obj.sound?.ref) || null, 26 - origin: { 27 - uri: obj.origin.uri, 28 - cid: obj.origin.cid, 29 - }, 30 - title: obj.title || null, 31 - text: obj.text || null, 25 + sound: normalizeObject(obj.sound) || null, 26 + origin: obj.origin ? { uri: obj.origin.uri, cid: obj.origin.cid } : null, 27 + title: obj.title, 32 28 labels: obj.labels || null, 29 + details: obj.details || null, 33 30 createdAt: normalizeDatetimeAlways(obj.createdAt), 34 31 indexedAt: timestamp, 35 32 };
+13 -7
data-plane/server/models.ts
··· 202 202 }); 203 203 204 204 export interface AudioDocument extends AuthoredDocument { 205 - sound: string; 206 - origin: { 205 + sound: MediaRef; 206 + origin?: { 207 207 uri: string; 208 208 cid: string; 209 209 }; 210 - title?: string; 211 - text?: string; 210 + title: string; 211 + details?: { 212 + artist?: string; 213 + title?: string; 214 + }; 212 215 labels?: Label[]; 216 + useCount: number; 213 217 } 214 218 215 219 export const audioSchema = new Schema<AudioDocument>({ 216 220 ...authoredSchema, 217 - sound: { type: String, required: true }, 221 + sound: { type: Object, required: true }, 218 222 origin: { 219 223 uri: { type: String, required: true }, 220 224 cid: { type: String, required: true }, 221 225 }, 222 - title: { type: String, required: false }, 223 - text: { type: String, required: false }, 226 + title: { type: String, required: true }, 227 + details: { type: Object, required: false }, 224 228 labels: { type: [Object], required: false }, 229 + useCount: { type: Number, required: true, default: 0 }, 225 230 }); 226 231 227 232 export interface RepostDocument extends AuthoredDocument { ··· 405 410 blockSchema.index({ subject: 1, createdAt: -1 }); 406 411 407 412 audioSchema.index({ authorDid: 1, createdAt: -1 }); 413 + audioSchema.index({ useCount: -1, createdAt: -1 }); 408 414 repostSchema.index({ authorDid: 1, createdAt: -1 }); 409 415 repostSchema.index({ "subject.uri": 1, createdAt: -1 }); 410 416
+62
lex/index.ts
··· 181 181 import * as SoSprkFeedGetListFeed from "./types/so/sprk/feed/getListFeed.ts"; 182 182 import * as SoSprkFeedGetSuggestedFeeds from "./types/so/sprk/feed/getSuggestedFeeds.ts"; 183 183 import * as SoSprkFeedGetActorFeeds from "./types/so/sprk/feed/getActorFeeds.ts"; 184 + import * as SoSprkSoundGetActorAudios from "./types/so/sprk/sound/getActorAudios.ts"; 185 + import * as SoSprkSoundGetAudioPosts from "./types/so/sprk/sound/getAudioPosts.ts"; 186 + import * as SoSprkSoundGetAudios from "./types/so/sprk/sound/getAudios.ts"; 187 + import * as SoSprkSoundGetTrendingAudios from "./types/so/sprk/sound/getTrendingAudios.ts"; 184 188 import * as SoSprkActorSearchActorsTypeahead from "./types/so/sprk/actor/searchActorsTypeahead.ts"; 185 189 import * as SoSprkActorPutPreferences from "./types/so/sprk/actor/putPreferences.ts"; 186 190 import * as SoSprkActorGetProfile from "./types/so/sprk/actor/getProfile.ts"; ··· 1982 1986 graph: SoSprkGraphNS; 1983 1987 feed: SoSprkFeedNS; 1984 1988 richtext: SoSprkRichtextNS; 1989 + sound: SoSprkSoundNS; 1985 1990 actor: SoSprkActorNS; 1986 1991 labeler: SoSprkLabelerNS; 1987 1992 ··· 1994 1999 this.graph = new SoSprkGraphNS(server); 1995 2000 this.feed = new SoSprkFeedNS(server); 1996 2001 this.richtext = new SoSprkRichtextNS(server); 2002 + this.sound = new SoSprkSoundNS(server); 1997 2003 this.actor = new SoSprkActorNS(server); 1998 2004 this.labeler = new SoSprkLabelerNS(server); 1999 2005 } ··· 2736 2742 2737 2743 constructor(server: Server) { 2738 2744 this._server = server; 2745 + } 2746 + } 2747 + 2748 + export class SoSprkSoundNS { 2749 + _server: Server; 2750 + 2751 + constructor(server: Server) { 2752 + this._server = server; 2753 + } 2754 + 2755 + getActorAudios<A extends Auth = void>( 2756 + cfg: MethodConfigOrHandler< 2757 + A, 2758 + SoSprkSoundGetActorAudios.QueryParams, 2759 + SoSprkSoundGetActorAudios.HandlerInput, 2760 + SoSprkSoundGetActorAudios.HandlerOutput 2761 + >, 2762 + ) { 2763 + const nsid = "so.sprk.sound.getActorAudios"; // @ts-ignore - userType.nsid is dynamically generated and TypeScript can't infer its type 2764 + return this._server.xrpc.method(nsid, cfg); 2765 + } 2766 + 2767 + getAudioPosts<A extends Auth = void>( 2768 + cfg: MethodConfigOrHandler< 2769 + A, 2770 + SoSprkSoundGetAudioPosts.QueryParams, 2771 + SoSprkSoundGetAudioPosts.HandlerInput, 2772 + SoSprkSoundGetAudioPosts.HandlerOutput 2773 + >, 2774 + ) { 2775 + const nsid = "so.sprk.sound.getAudioPosts"; // @ts-ignore - userType.nsid is dynamically generated and TypeScript can't infer its type 2776 + return this._server.xrpc.method(nsid, cfg); 2777 + } 2778 + 2779 + getAudios<A extends Auth = void>( 2780 + cfg: MethodConfigOrHandler< 2781 + A, 2782 + SoSprkSoundGetAudios.QueryParams, 2783 + SoSprkSoundGetAudios.HandlerInput, 2784 + SoSprkSoundGetAudios.HandlerOutput 2785 + >, 2786 + ) { 2787 + const nsid = "so.sprk.sound.getAudios"; // @ts-ignore - userType.nsid is dynamically generated and TypeScript can't infer its type 2788 + return this._server.xrpc.method(nsid, cfg); 2789 + } 2790 + 2791 + getTrendingAudios<A extends Auth = void>( 2792 + cfg: MethodConfigOrHandler< 2793 + A, 2794 + SoSprkSoundGetTrendingAudios.QueryParams, 2795 + SoSprkSoundGetTrendingAudios.HandlerInput, 2796 + SoSprkSoundGetTrendingAudios.HandlerOutput 2797 + >, 2798 + ) { 2799 + const nsid = "so.sprk.sound.getTrendingAudios"; // @ts-ignore - userType.nsid is dynamically generated and TypeScript can't infer its type 2800 + return this._server.xrpc.method(nsid, cfg); 2739 2801 } 2740 2802 } 2741 2803
+361 -105
lex/lexicons.ts
··· 11058 11058 "knownValues": [ 11059 11059 "JOB_STATE_COMPLETED", 11060 11060 "JOB_STATE_FAILED", 11061 + "JOB_STATE_QUEUED", 11062 + "JOB_STATE_PROCESSING", 11061 11063 ], 11062 11064 }, 11063 11065 "progress": { ··· 11068 11070 }, 11069 11071 "blob": { 11070 11072 "type": "blob", 11073 + }, 11074 + "audio": { 11075 + "type": "ref", 11076 + "ref": "lex:so.sprk.video.defs#extractedAudio", 11071 11077 }, 11072 11078 "error": { 11073 11079 "type": "string", 11074 11080 }, 11075 11081 "message": { 11076 11082 "type": "string", 11083 + }, 11084 + }, 11085 + }, 11086 + "extractedAudio": { 11087 + "type": "object", 11088 + "description": 11089 + "Audio extracted from the uploaded video for client-side record creation.", 11090 + "required": [ 11091 + "blob", 11092 + ], 11093 + "properties": { 11094 + "details": { 11095 + "type": "ref", 11096 + "ref": "lex:so.sprk.sound.defs#audioDetails", 11097 + }, 11098 + "blob": { 11099 + "type": "blob", 11077 11100 }, 11078 11101 }, 11079 11102 }, ··· 14037 14060 }, 14038 14061 "sound": { 14039 14062 "type": "ref", 14040 - "ref": "lex:so.sprk.feed.defs#soundView", 14063 + "ref": "lex:so.sprk.sound.defs#audioView", 14041 14064 }, 14042 14065 "replyCount": { 14043 14066 "type": "integer", ··· 14066 14089 "threadgate": { 14067 14090 "type": "ref", 14068 14091 "ref": "lex:so.sprk.feed.defs#threadgateView", 14069 - }, 14070 - }, 14071 - }, 14072 - "soundView": { 14073 - "type": "object", 14074 - "required": [ 14075 - "uri", 14076 - "cid", 14077 - "author", 14078 - "record", 14079 - "indexedAt", 14080 - ], 14081 - "properties": { 14082 - "uri": { 14083 - "type": "string", 14084 - "format": "at-uri", 14085 - }, 14086 - "cid": { 14087 - "type": "string", 14088 - "format": "cid", 14089 - }, 14090 - "author": { 14091 - "type": "ref", 14092 - "ref": "lex:so.sprk.actor.defs#profileViewBasic", 14093 - }, 14094 - "record": { 14095 - "type": "unknown", 14096 - }, 14097 - "useCount": { 14098 - "type": "integer", 14099 - }, 14100 - "likeCount": { 14101 - "type": "integer", 14102 - }, 14103 - "indexedAt": { 14104 - "type": "string", 14105 - "format": "datetime", 14106 - }, 14107 - "labels": { 14108 - "type": "array", 14109 - "items": { 14110 - "type": "ref", 14111 - "ref": "lex:com.atproto.label.defs#label", 14112 - }, 14113 14092 }, 14114 14093 }, 14115 14094 }, ··· 15643 15622 }, 15644 15623 }, 15645 15624 }, 15646 - "SoSprkFeedAudio": { 15647 - "lexicon": 1, 15648 - "id": "so.sprk.feed.audio", 15649 - "description": 15650 - "A audio record referencable in a Spark record (e.g, a post)", 15651 - "defs": { 15652 - "main": { 15653 - "type": "record", 15654 - "key": "tid", 15655 - "record": { 15656 - "type": "object", 15657 - "required": [ 15658 - "sound", 15659 - "origin", 15660 - "createdAt", 15661 - ], 15662 - "properties": { 15663 - "sound": { 15664 - "type": "blob", 15665 - "accept": [ 15666 - "audio/mp3", 15667 - ], 15668 - "maxSize": 10485760, 15669 - }, 15670 - "origin": { 15671 - "type": "ref", 15672 - "ref": "lex:com.atproto.repo.strongRef", 15673 - }, 15674 - "title": { 15675 - "type": "string", 15676 - "maxLength": 1000, 15677 - "maxGraphemes": 100, 15678 - "description": "The audio's title.", 15679 - }, 15680 - "text": { 15681 - "type": "string", 15682 - "maxLength": 3000, 15683 - "maxGraphemes": 300, 15684 - "description": "The audio's description.", 15685 - }, 15686 - "labels": { 15687 - "type": "union", 15688 - "description": 15689 - "Self-label values for this audio. Effectively content warnings.", 15690 - "refs": [ 15691 - "lex:com.atproto.label.defs#selfLabels", 15692 - ], 15693 - }, 15694 - "createdAt": { 15695 - "type": "string", 15696 - "format": "datetime", 15697 - "description": 15698 - "Client-declared timestamp when this post was originally created.", 15699 - }, 15700 - }, 15701 - }, 15702 - }, 15703 - }, 15704 - }, 15705 15625 "SoSprkFeedGetQuotes": { 15706 15626 "lexicon": 1, 15707 15627 "id": "so.sprk.feed.getQuotes", ··· 16223 16143 "byteEnd": { 16224 16144 "type": "integer", 16225 16145 "minimum": 0, 16146 + }, 16147 + }, 16148 + }, 16149 + }, 16150 + }, 16151 + "SoSprkSoundDefs": { 16152 + "lexicon": 1, 16153 + "id": "so.sprk.sound.defs", 16154 + "defs": { 16155 + "audioView": { 16156 + "type": "object", 16157 + "required": [ 16158 + "uri", 16159 + "cid", 16160 + "author", 16161 + "title", 16162 + "coverArt", 16163 + "record", 16164 + "indexedAt", 16165 + ], 16166 + "properties": { 16167 + "uri": { 16168 + "type": "string", 16169 + "format": "at-uri", 16170 + }, 16171 + "cid": { 16172 + "type": "string", 16173 + "format": "cid", 16174 + }, 16175 + "author": { 16176 + "type": "ref", 16177 + "ref": "lex:so.sprk.actor.defs#profileViewBasic", 16178 + }, 16179 + "record": { 16180 + "type": "unknown", 16181 + }, 16182 + "useCount": { 16183 + "type": "integer", 16184 + }, 16185 + "title": { 16186 + "type": "string", 16187 + }, 16188 + "coverArt": { 16189 + "type": "string", 16190 + }, 16191 + "details": { 16192 + "type": "ref", 16193 + "ref": "lex:so.sprk.sound.defs#audioDetails", 16194 + }, 16195 + "indexedAt": { 16196 + "type": "string", 16197 + "format": "datetime", 16198 + }, 16199 + "labels": { 16200 + "type": "array", 16201 + "items": { 16202 + "type": "ref", 16203 + "ref": "lex:com.atproto.label.defs#label", 16204 + }, 16205 + }, 16206 + }, 16207 + }, 16208 + "audioDetails": { 16209 + "type": "object", 16210 + "description": "Metadata about the audio content.", 16211 + "properties": { 16212 + "artist": { 16213 + "type": "string", 16214 + }, 16215 + "title": { 16216 + "type": "string", 16217 + }, 16218 + }, 16219 + }, 16220 + }, 16221 + }, 16222 + "SoSprkSoundGetActorAudios": { 16223 + "lexicon": 1, 16224 + "id": "so.sprk.sound.getActorAudios", 16225 + "defs": { 16226 + "main": { 16227 + "type": "query", 16228 + "description": "Get a list of audio records created by the actor.", 16229 + "parameters": { 16230 + "type": "params", 16231 + "required": [ 16232 + "actor", 16233 + ], 16234 + "properties": { 16235 + "actor": { 16236 + "type": "string", 16237 + "format": "at-identifier", 16238 + }, 16239 + "limit": { 16240 + "type": "integer", 16241 + "minimum": 1, 16242 + "maximum": 100, 16243 + "default": 50, 16244 + }, 16245 + "cursor": { 16246 + "type": "string", 16247 + }, 16248 + }, 16249 + }, 16250 + "output": { 16251 + "encoding": "application/json", 16252 + "schema": { 16253 + "type": "object", 16254 + "required": [ 16255 + "audios", 16256 + ], 16257 + "properties": { 16258 + "cursor": { 16259 + "type": "string", 16260 + }, 16261 + "audios": { 16262 + "type": "array", 16263 + "items": { 16264 + "type": "ref", 16265 + "ref": "lex:so.sprk.sound.defs#audioView", 16266 + }, 16267 + }, 16268 + }, 16269 + }, 16270 + }, 16271 + }, 16272 + }, 16273 + }, 16274 + "SoSprkSoundGetAudioPosts": { 16275 + "lexicon": 1, 16276 + "id": "so.sprk.sound.getAudioPosts", 16277 + "defs": { 16278 + "main": { 16279 + "type": "query", 16280 + "description": 16281 + "Get a list of posts that use a given audio (by AT-URI).", 16282 + "parameters": { 16283 + "type": "params", 16284 + "required": [ 16285 + "uri", 16286 + ], 16287 + "properties": { 16288 + "uri": { 16289 + "type": "string", 16290 + "format": "at-uri", 16291 + "description": "Audio AT-URI to find referencing posts for.", 16292 + }, 16293 + "limit": { 16294 + "type": "integer", 16295 + "minimum": 1, 16296 + "maximum": 100, 16297 + "default": 50, 16298 + }, 16299 + "cursor": { 16300 + "type": "string", 16301 + }, 16302 + }, 16303 + }, 16304 + "output": { 16305 + "encoding": "application/json", 16306 + "schema": { 16307 + "type": "object", 16308 + "required": [ 16309 + "posts", 16310 + ], 16311 + "properties": { 16312 + "cursor": { 16313 + "type": "string", 16314 + }, 16315 + "posts": { 16316 + "type": "array", 16317 + "items": { 16318 + "type": "ref", 16319 + "ref": "lex:so.sprk.feed.defs#postView", 16320 + }, 16321 + }, 16322 + "audio": { 16323 + "type": "ref", 16324 + "ref": "lex:so.sprk.sound.defs#audioView", 16325 + }, 16326 + }, 16327 + }, 16328 + }, 16329 + }, 16330 + }, 16331 + }, 16332 + "SoSprkSoundGetAudios": { 16333 + "lexicon": 1, 16334 + "id": "so.sprk.sound.getAudios", 16335 + "defs": { 16336 + "main": { 16337 + "type": "query", 16338 + "description": 16339 + "Gets audio views for a specified list of audios (by AT-URI).", 16340 + "parameters": { 16341 + "type": "params", 16342 + "required": [ 16343 + "uris", 16344 + ], 16345 + "properties": { 16346 + "uris": { 16347 + "type": "array", 16348 + "description": "List of audio AT-URIs to return views for.", 16349 + "items": { 16350 + "type": "string", 16351 + "format": "at-uri", 16352 + }, 16353 + "maxLength": 25, 16354 + }, 16355 + }, 16356 + }, 16357 + "output": { 16358 + "encoding": "application/json", 16359 + "schema": { 16360 + "type": "object", 16361 + "required": [ 16362 + "audios", 16363 + ], 16364 + "properties": { 16365 + "audios": { 16366 + "type": "array", 16367 + "items": { 16368 + "type": "ref", 16369 + "ref": "lex:so.sprk.sound.defs#audioView", 16370 + }, 16371 + }, 16372 + }, 16373 + }, 16374 + }, 16375 + }, 16376 + }, 16377 + }, 16378 + "SoSprkSoundAudio": { 16379 + "lexicon": 1, 16380 + "id": "so.sprk.sound.audio", 16381 + "description": 16382 + "A audio record referencable in a Spark record (e.g, a post)", 16383 + "defs": { 16384 + "main": { 16385 + "type": "record", 16386 + "key": "tid", 16387 + "record": { 16388 + "type": "object", 16389 + "required": [ 16390 + "sound", 16391 + "title", 16392 + "createdAt", 16393 + ], 16394 + "properties": { 16395 + "sound": { 16396 + "type": "blob", 16397 + "accept": [ 16398 + "audio/*", 16399 + ], 16400 + "maxSize": 10485760, 16401 + }, 16402 + "origin": { 16403 + "type": "ref", 16404 + "ref": "lex:com.atproto.repo.strongRef", 16405 + }, 16406 + "title": { 16407 + "type": "string", 16408 + "maxLength": 1000, 16409 + "maxGraphemes": 100, 16410 + "description": "The audio's title.", 16411 + }, 16412 + "details": { 16413 + "type": "ref", 16414 + "ref": "lex:so.sprk.sound.defs#audioDetails", 16415 + }, 16416 + "labels": { 16417 + "type": "union", 16418 + "description": 16419 + "Self-label values for this audio. Effectively content warnings.", 16420 + "refs": [ 16421 + "lex:com.atproto.label.defs#selfLabels", 16422 + ], 16423 + }, 16424 + "createdAt": { 16425 + "type": "string", 16426 + "format": "datetime", 16427 + "description": 16428 + "Client-declared timestamp when this audio was originally created.", 16429 + }, 16430 + }, 16431 + }, 16432 + }, 16433 + }, 16434 + }, 16435 + "SoSprkSoundGetTrendingAudios": { 16436 + "lexicon": 1, 16437 + "id": "so.sprk.sound.getTrendingAudios", 16438 + "defs": { 16439 + "main": { 16440 + "type": "query", 16441 + "description": 16442 + "Return trending audios ranked by popularity, returning AudioViews.", 16443 + "parameters": { 16444 + "type": "params", 16445 + "properties": { 16446 + "limit": { 16447 + "type": "integer", 16448 + "minimum": 1, 16449 + "maximum": 100, 16450 + "default": 25, 16451 + }, 16452 + "cursor": { 16453 + "type": "string", 16454 + "description": "Opaque cursor for pagination", 16455 + }, 16456 + }, 16457 + }, 16458 + "output": { 16459 + "encoding": "application/json", 16460 + "schema": { 16461 + "type": "object", 16462 + "required": [ 16463 + "audios", 16464 + ], 16465 + "properties": { 16466 + "cursor": { 16467 + "type": "string", 16468 + }, 16469 + "audios": { 16470 + "type": "array", 16471 + "items": { 16472 + "type": "ref", 16473 + "ref": "lex:so.sprk.sound.defs#audioView", 16474 + }, 16475 + }, 16476 + }, 16226 16477 }, 16227 16478 }, 16228 16479 }, ··· 22267 22518 SoSprkFeedGetPosts: "so.sprk.feed.getPosts", 22268 22519 SoSprkFeedGetFeed: "so.sprk.feed.getFeed", 22269 22520 SoSprkFeedGetStories: "so.sprk.feed.getStories", 22270 - SoSprkFeedAudio: "so.sprk.feed.audio", 22271 22521 SoSprkFeedGetQuotes: "so.sprk.feed.getQuotes", 22272 22522 SoSprkFeedGetStoriesTimeline: "so.sprk.feed.getStoriesTimeline", 22273 22523 SoSprkFeedGetFeedSkeleton: "so.sprk.feed.getFeedSkeleton", ··· 22276 22526 SoSprkFeedGetActorFeeds: "so.sprk.feed.getActorFeeds", 22277 22527 SoSprkFeedPost: "so.sprk.feed.post", 22278 22528 SoSprkRichtextFacet: "so.sprk.richtext.facet", 22529 + SoSprkSoundDefs: "so.sprk.sound.defs", 22530 + SoSprkSoundGetActorAudios: "so.sprk.sound.getActorAudios", 22531 + SoSprkSoundGetAudioPosts: "so.sprk.sound.getAudioPosts", 22532 + SoSprkSoundGetAudios: "so.sprk.sound.getAudios", 22533 + SoSprkSoundAudio: "so.sprk.sound.audio", 22534 + SoSprkSoundGetTrendingAudios: "so.sprk.sound.getTrendingAudios", 22279 22535 SoSprkActorSearchActorsTypeahead: "so.sprk.actor.searchActorsTypeahead", 22280 22536 SoSprkActorDefs: "so.sprk.actor.defs", 22281 22537 SoSprkActorPutPreferences: "so.sprk.actor.putPreferences",
+7 -7
lex/types/so/sprk/feed/audio.ts lex/types/so/sprk/sound/audio.ts
··· 6 6 import { is$typed as _is$typed } from "../../../../util.ts"; 7 7 import { type $Typed } from "../../../../util.ts"; 8 8 import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; 9 + import type * as SoSprkSoundDefs from "./defs.ts"; 9 10 import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; 10 11 11 12 const is$typed = _is$typed, validate = _validate; 12 - const id = "so.sprk.feed.audio"; 13 + const id = "so.sprk.sound.audio"; 13 14 14 15 export interface Record { 15 - $type: "so.sprk.feed.audio"; 16 + $type: "so.sprk.sound.audio"; 16 17 sound: BlobRef; 17 - origin: ComAtprotoRepoStrongRef.Main; 18 + origin?: ComAtprotoRepoStrongRef.Main; 18 19 /** The audio's title. */ 19 - title?: string; 20 - /** The audio's description. */ 21 - text?: string; 20 + title: string; 21 + details?: SoSprkSoundDefs.AudioDetails; 22 22 labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string }; 23 - /** Client-declared timestamp when this post was originally created. */ 23 + /** Client-declared timestamp when this audio was originally created. */ 24 24 createdAt: string; 25 25 [k: string]: unknown; 26 26 }
+2 -23
lex/types/so/sprk/feed/defs.ts
··· 7 7 import type * as SoSprkActorDefs from "../actor/defs.ts"; 8 8 import type * as SoSprkEmbedImages from "../embed/images.ts"; 9 9 import type * as SoSprkEmbedVideo from "../embed/video.ts"; 10 + import type * as SoSprkSoundDefs from "../sound/defs.ts"; 10 11 import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; 11 12 import type * as SoSprkRichtextFacet from "../richtext/facet.ts"; 12 13 import type * as SoSprkGraphDefs from "../graph/defs.ts"; ··· 45 46 embed?: $Typed<SoSprkEmbedImages.View> | $Typed<SoSprkEmbedVideo.View> | { 46 47 $type: string; 47 48 }; 48 - sound?: SoundView; 49 + sound?: SoSprkSoundDefs.AudioView; 49 50 replyCount?: number; 50 51 repostCount?: number; 51 52 likeCount?: number; ··· 63 64 64 65 export function validatePostView<V>(v: V) { 65 66 return validate<PostView & V>(v, id, hashPostView); 66 - } 67 - 68 - export interface SoundView { 69 - $type?: "so.sprk.feed.defs#soundView"; 70 - uri: string; 71 - cid: string; 72 - author: SoSprkActorDefs.ProfileViewBasic; 73 - record: { [_ in string]: unknown }; 74 - useCount?: number; 75 - likeCount?: number; 76 - indexedAt: string; 77 - labels?: (ComAtprotoLabelDefs.Label)[]; 78 - } 79 - 80 - const hashSoundView = "soundView"; 81 - 82 - export function isSoundView<V>(v: V) { 83 - return is$typed(v, id, hashSoundView); 84 - } 85 - 86 - export function validateSoundView<V>(v: V) { 87 - return validate<SoundView & V>(v, id, hashSoundView); 88 67 } 89 68 90 69 /** Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests. */
+51
lex/types/so/sprk/sound/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { validate as _validate } from "../../../../lexicons.ts"; 5 + import { is$typed as _is$typed } from "../../../../util.ts"; 6 + import type * as SoSprkActorDefs from "../actor/defs.ts"; 7 + import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; 8 + 9 + const is$typed = _is$typed, validate = _validate; 10 + const id = "so.sprk.sound.defs"; 11 + 12 + export interface AudioView { 13 + $type?: "so.sprk.sound.defs#audioView"; 14 + uri: string; 15 + cid: string; 16 + author: SoSprkActorDefs.ProfileViewBasic; 17 + record: { [_ in string]: unknown }; 18 + useCount?: number; 19 + title: string; 20 + coverArt: string; 21 + details?: AudioDetails; 22 + indexedAt: string; 23 + labels?: (ComAtprotoLabelDefs.Label)[]; 24 + } 25 + 26 + const hashAudioView = "audioView"; 27 + 28 + export function isAudioView<V>(v: V) { 29 + return is$typed(v, id, hashAudioView); 30 + } 31 + 32 + export function validateAudioView<V>(v: V) { 33 + return validate<AudioView & V>(v, id, hashAudioView); 34 + } 35 + 36 + /** Metadata about the audio content. */ 37 + export interface AudioDetails { 38 + $type?: "so.sprk.sound.defs#audioDetails"; 39 + artist?: string; 40 + title?: string; 41 + } 42 + 43 + const hashAudioDetails = "audioDetails"; 44 + 45 + export function isAudioDetails<V>(v: V) { 46 + return is$typed(v, id, hashAudioDetails); 47 + } 48 + 49 + export function validateAudioDetails<V>(v: V) { 50 + return validate<AudioDetails & V>(v, id, hashAudioDetails); 51 + }
+31
lex/types/so/sprk/sound/getActorAudios.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type * as SoSprkSoundDefs from "./defs.ts"; 5 + 6 + export type QueryParams = { 7 + actor: string; 8 + limit: number; 9 + cursor?: string; 10 + }; 11 + export type InputSchema = undefined; 12 + 13 + export interface OutputSchema { 14 + cursor?: string; 15 + audios: (SoSprkSoundDefs.AudioView)[]; 16 + } 17 + 18 + export type HandlerInput = void; 19 + 20 + export interface HandlerSuccess { 21 + encoding: "application/json"; 22 + body: OutputSchema; 23 + headers?: { [key: string]: string }; 24 + } 25 + 26 + export interface HandlerError { 27 + status: number; 28 + message?: string; 29 + } 30 + 31 + export type HandlerOutput = HandlerError | HandlerSuccess;
+34
lex/types/so/sprk/sound/getAudioPosts.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type * as SoSprkFeedDefs from "../feed/defs.ts"; 5 + import type * as SoSprkSoundDefs from "./defs.ts"; 6 + 7 + export type QueryParams = { 8 + /** Audio AT-URI to find referencing posts for. */ 9 + uri: string; 10 + limit: number; 11 + cursor?: string; 12 + }; 13 + export type InputSchema = undefined; 14 + 15 + export interface OutputSchema { 16 + cursor?: string; 17 + posts: (SoSprkFeedDefs.PostView)[]; 18 + audio?: SoSprkSoundDefs.AudioView; 19 + } 20 + 21 + export type HandlerInput = void; 22 + 23 + export interface HandlerSuccess { 24 + encoding: "application/json"; 25 + body: OutputSchema; 26 + headers?: { [key: string]: string }; 27 + } 28 + 29 + export interface HandlerError { 30 + status: number; 31 + message?: string; 32 + } 33 + 34 + export type HandlerOutput = HandlerError | HandlerSuccess;
+29
lex/types/so/sprk/sound/getAudios.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type * as SoSprkSoundDefs from "./defs.ts"; 5 + 6 + export type QueryParams = { 7 + /** List of audio AT-URIs to return views for. */ 8 + uris: string[]; 9 + }; 10 + export type InputSchema = undefined; 11 + 12 + export interface OutputSchema { 13 + audios: (SoSprkSoundDefs.AudioView)[]; 14 + } 15 + 16 + export type HandlerInput = void; 17 + 18 + export interface HandlerSuccess { 19 + encoding: "application/json"; 20 + body: OutputSchema; 21 + headers?: { [key: string]: string }; 22 + } 23 + 24 + export interface HandlerError { 25 + status: number; 26 + message?: string; 27 + } 28 + 29 + export type HandlerOutput = HandlerError | HandlerSuccess;
+31
lex/types/so/sprk/sound/getTrendingAudios.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type * as SoSprkSoundDefs from "./defs.ts"; 5 + 6 + export type QueryParams = { 7 + limit: number; 8 + /** Opaque cursor for pagination */ 9 + cursor?: string; 10 + }; 11 + export type InputSchema = undefined; 12 + 13 + export interface OutputSchema { 14 + cursor?: string; 15 + audios: (SoSprkSoundDefs.AudioView)[]; 16 + } 17 + 18 + export type HandlerInput = void; 19 + 20 + export interface HandlerSuccess { 21 + encoding: "application/json"; 22 + body: OutputSchema; 23 + headers?: { [key: string]: string }; 24 + } 25 + 26 + export interface HandlerError { 27 + status: number; 28 + message?: string; 29 + } 30 + 31 + export type HandlerOutput = HandlerError | HandlerSuccess;
+21
lex/types/so/sprk/video/defs.ts
··· 4 4 import { BlobRef } from "@atproto/lexicon"; 5 5 import { validate as _validate } from "../../../../lexicons.ts"; 6 6 import { is$typed as _is$typed } from "../../../../util.ts"; 7 + import type * as SoSprkSoundDefs from "../sound/defs.ts"; 7 8 8 9 const is$typed = _is$typed, validate = _validate; 9 10 const id = "so.sprk.video.defs"; ··· 16 17 state: 17 18 | "JOB_STATE_COMPLETED" 18 19 | "JOB_STATE_FAILED" 20 + | "JOB_STATE_QUEUED" 21 + | "JOB_STATE_PROCESSING" 19 22 | (string & globalThis.Record<PropertyKey, never>); 20 23 /** Progress within the current processing state. */ 21 24 progress?: number; 22 25 blob?: BlobRef; 26 + audio?: ExtractedAudio; 23 27 error?: string; 24 28 message?: string; 25 29 } ··· 33 37 export function validateJobStatus<V>(v: V) { 34 38 return validate<JobStatus & V>(v, id, hashJobStatus); 35 39 } 40 + 41 + /** Audio extracted from the uploaded video for client-side record creation. */ 42 + export interface ExtractedAudio { 43 + $type?: "so.sprk.video.defs#extractedAudio"; 44 + details?: SoSprkSoundDefs.AudioDetails; 45 + blob: BlobRef; 46 + } 47 + 48 + const hashExtractedAudio = "extractedAudio"; 49 + 50 + export function isExtractedAudio<V>(v: V) { 51 + return is$typed(v, id, hashExtractedAudio); 52 + } 53 + 54 + export function validateExtractedAudio<V>(v: V) { 55 + return validate<ExtractedAudio & V>(v, id, hashExtractedAudio); 56 + }
+7 -9
lexicons/so/sprk/feed/audio.json lexicons/so/sprk/sound/audio.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "so.sprk.feed.audio", 3 + "id": "so.sprk.sound.audio", 4 4 "description": "A audio record referencable in a Spark record (e.g, a post)", 5 5 "defs": { 6 6 "main": { ··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["sound", "origin", "createdAt"], 11 + "required": ["sound", "title", "createdAt"], 12 12 "properties": { 13 13 "sound": { 14 14 "type": "blob", 15 - "accept": ["audio/mp3"], 15 + "accept": ["audio/*"], 16 16 "maxSize": 10485760 17 17 }, 18 18 "origin": { ··· 25 25 "maxGraphemes": 100, 26 26 "description": "The audio's title." 27 27 }, 28 - "text": { 29 - "type": "string", 30 - "maxLength": 3000, 31 - "maxGraphemes": 300, 32 - "description": "The audio's description." 28 + "details": { 29 + "type": "ref", 30 + "ref": "so.sprk.sound.defs#audioDetails" 33 31 }, 34 32 "labels": { 35 33 "type": "union", ··· 39 37 "createdAt": { 40 38 "type": "string", 41 39 "format": "datetime", 42 - "description": "Client-declared timestamp when this post was originally created." 40 + "description": "Client-declared timestamp when this audio was originally created." 43 41 } 44 42 } 45 43 }
+1 -21
lexicons/so/sprk/feed/defs.json
··· 35 35 "type": "union", 36 36 "refs": ["so.sprk.embed.images#view", "so.sprk.embed.video#view"] 37 37 }, 38 - "sound": { "type": "ref", "ref": "#soundView" }, 38 + "sound": { "type": "ref", "ref": "so.sprk.sound.defs#audioView" }, 39 39 "replyCount": { "type": "integer" }, 40 40 "repostCount": { "type": "integer" }, 41 41 "likeCount": { "type": "integer" }, ··· 46 46 "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 47 47 }, 48 48 "threadgate": { "type": "ref", "ref": "#threadgateView" } 49 - } 50 - }, 51 - "soundView": { 52 - "type": "object", 53 - "required": ["uri", "cid", "author", "record", "indexedAt"], 54 - "properties": { 55 - "uri": { "type": "string", "format": "at-uri" }, 56 - "cid": { "type": "string", "format": "cid" }, 57 - "author": { 58 - "type": "ref", 59 - "ref": "so.sprk.actor.defs#profileViewBasic" 60 - }, 61 - "record": { "type": "unknown" }, 62 - "useCount": { "type": "integer" }, 63 - "likeCount": { "type": "integer" }, 64 - "indexedAt": { "type": "string", "format": "datetime" }, 65 - "labels": { 66 - "type": "array", 67 - "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 68 - } 69 49 } 70 50 }, 71 51 "viewerState": {
+44
lexicons/so/sprk/sound/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.sound.defs", 4 + "defs": { 5 + "audioView": { 6 + "type": "object", 7 + "required": [ 8 + "uri", 9 + "cid", 10 + "author", 11 + "title", 12 + "coverArt", 13 + "record", 14 + "indexedAt" 15 + ], 16 + "properties": { 17 + "uri": { "type": "string", "format": "at-uri" }, 18 + "cid": { "type": "string", "format": "cid" }, 19 + "author": { 20 + "type": "ref", 21 + "ref": "so.sprk.actor.defs#profileViewBasic" 22 + }, 23 + "record": { "type": "unknown" }, 24 + "useCount": { "type": "integer" }, 25 + "title": { "type": "string" }, 26 + "coverArt": { "type": "string" }, 27 + "details": { "type": "ref", "ref": "#audioDetails" }, 28 + "indexedAt": { "type": "string", "format": "datetime" }, 29 + "labels": { 30 + "type": "array", 31 + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 32 + } 33 + } 34 + }, 35 + "audioDetails": { 36 + "type": "object", 37 + "description": "Metadata about the audio content.", 38 + "properties": { 39 + "artist": { "type": "string" }, 40 + "title": { "type": "string" } 41 + } 42 + } 43 + } 44 + }
+41
lexicons/so/sprk/sound/getActorAudios.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.sound.getActorAudios", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a list of audio records created by the actor.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { "type": "string", "format": "at-identifier" }, 13 + "limit": { 14 + "type": "integer", 15 + "minimum": 1, 16 + "maximum": 100, 17 + "default": 50 18 + }, 19 + "cursor": { "type": "string" } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["audios"], 27 + "properties": { 28 + "cursor": { "type": "string" }, 29 + "audios": { 30 + "type": "array", 31 + "items": { 32 + "type": "ref", 33 + "ref": "so.sprk.sound.defs#audioView" 34 + } 35 + } 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }
+46
lexicons/so/sprk/sound/getAudioPosts.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.sound.getAudioPosts", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a list of posts that use a given audio (by AT-URI).", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["uri"], 11 + "properties": { 12 + "uri": { 13 + "type": "string", 14 + "format": "at-uri", 15 + "description": "Audio AT-URI to find referencing posts for." 16 + }, 17 + "limit": { 18 + "type": "integer", 19 + "minimum": 1, 20 + "maximum": 100, 21 + "default": 50 22 + }, 23 + "cursor": { "type": "string" } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["posts"], 31 + "properties": { 32 + "cursor": { "type": "string" }, 33 + "posts": { 34 + "type": "array", 35 + "items": { "type": "ref", "ref": "so.sprk.feed.defs#postView" } 36 + }, 37 + "audio": { 38 + "type": "ref", 39 + "ref": "so.sprk.sound.defs#audioView" 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+35
lexicons/so/sprk/sound/getAudios.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.sound.getAudios", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Gets audio views for a specified list of audios (by AT-URI).", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["uris"], 11 + "properties": { 12 + "uris": { 13 + "type": "array", 14 + "description": "List of audio AT-URIs to return views for.", 15 + "items": { "type": "string", "format": "at-uri" }, 16 + "maxLength": 25 17 + } 18 + } 19 + }, 20 + "output": { 21 + "encoding": "application/json", 22 + "schema": { 23 + "type": "object", 24 + "required": ["audios"], 25 + "properties": { 26 + "audios": { 27 + "type": "array", 28 + "items": { "type": "ref", "ref": "so.sprk.sound.defs#audioView" } 29 + } 30 + } 31 + } 32 + } 33 + } 34 + } 35 + }
+39
lexicons/so/sprk/sound/getTrendingAudios.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.sound.getTrendingAudios", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Return trending audios ranked by popularity, returning AudioViews.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "minimum": 1, 14 + "maximum": 100, 15 + "default": 25 16 + }, 17 + "cursor": { 18 + "type": "string", 19 + "description": "Opaque cursor for pagination" 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": ["audios"], 28 + "properties": { 29 + "cursor": { "type": "string" }, 30 + "audios": { 31 + "type": "array", 32 + "items": { "type": "ref", "ref": "so.sprk.sound.defs#audioView" } 33 + } 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }
+22 -1
lexicons/so/sprk/video/defs.json
··· 11 11 "state": { 12 12 "type": "string", 13 13 "description": "The state of the video processing job. All values not listed as a known value indicate that the job is in process.", 14 - "knownValues": ["JOB_STATE_COMPLETED", "JOB_STATE_FAILED"] 14 + "knownValues": [ 15 + "JOB_STATE_COMPLETED", 16 + "JOB_STATE_FAILED", 17 + "JOB_STATE_QUEUED", 18 + "JOB_STATE_PROCESSING" 19 + ] 15 20 }, 16 21 "progress": { 17 22 "type": "integer", ··· 20 25 "description": "Progress within the current processing state." 21 26 }, 22 27 "blob": { "type": "blob" }, 28 + "audio": { 29 + "type": "ref", 30 + "ref": "so.sprk.video.defs#extractedAudio" 31 + }, 23 32 "error": { "type": "string" }, 24 33 "message": { "type": "string" } 34 + } 35 + }, 36 + "extractedAudio": { 37 + "type": "object", 38 + "description": "Audio extracted from the uploaded video for client-side record creation.", 39 + "required": ["blob"], 40 + "properties": { 41 + "details": { 42 + "type": "ref", 43 + "ref": "so.sprk.sound.defs#audioDetails" 44 + }, 45 + "blob": { "type": "blob" } 25 46 } 26 47 } 27 48 }
+108
utils/audio-transformer.ts
··· 1 + import type * as SoSprkSoundDefs from "../lex/types/so/sprk/sound/defs.ts"; 2 + import { AudioDocument } from "../data-plane/server/models.ts"; 3 + import { AppContext } from "../main.ts"; 4 + import { createProfileViewBasic } from "./profile-helper.ts"; 5 + import type { Label } from "../lex/types/com/atproto/label/defs.ts"; 6 + 7 + // Transform a single DB audio to AudioView format 8 + export async function transformAudioToAudioView( 9 + audio: AudioDocument, 10 + ctx: AppContext, 11 + usageCount?: number, 12 + ): Promise<SoSprkSoundDefs.AudioView> { 13 + const authorView = await createProfileViewBasic(audio.authorDid, ctx); 14 + 15 + const labels = audio.labels 16 + ? Array.isArray(audio.labels) ? (audio.labels as Label[]) : undefined 17 + : undefined; 18 + const details = audio.details ? { ...audio.details } : undefined; 19 + 20 + const isMusic = !!details; 21 + const musicTitle = details?.title; 22 + const musicArtist = details?.artist; 23 + const baseTitle = isMusic ? audio.title : "Original Audio"; 24 + const musicSuffix = isMusic 25 + ? ` contains music of ${musicTitle ?? "Unknown"} - ${ 26 + musicArtist ?? "Unknown" 27 + }` 28 + : ""; 29 + const computedTitle = `${baseTitle}${musicSuffix}`; 30 + 31 + const record = { 32 + title: computedTitle, 33 + origin: audio.origin ?? undefined, 34 + sound: audio.sound ?? undefined, 35 + labels: audio.labels ?? undefined, 36 + createdAt: audio.createdAt, 37 + } as Record<string, unknown>; 38 + 39 + return { 40 + uri: audio.uri, 41 + cid: audio.cid, 42 + author: authorView, 43 + title: computedTitle, 44 + coverArt: isMusic ? "" : (authorView.avatar ?? ""), 45 + record, 46 + useCount: usageCount ?? 0, 47 + details, 48 + indexedAt: audio.indexedAt, 49 + labels, 50 + }; 51 + } 52 + 53 + // Batch transform DB audios to AudioView format 54 + export async function transformAudiosToAudioViews( 55 + audios: AudioDocument[], 56 + ctx: AppContext, 57 + ): Promise<SoSprkSoundDefs.AudioView[]> { 58 + if (audios.length === 0) return []; 59 + 60 + // Prefetch authors 61 + const authorDids = [...new Set(audios.map((a) => a.authorDid))]; 62 + const authors = await Promise.all( 63 + authorDids.map((did) => createProfileViewBasic(did, ctx)), 64 + ); 65 + const authorsMap = new Map(authors.map((a) => [a.did, a])); 66 + 67 + // Usage count: how many posts reference this audio record 68 + const audioUris = audios.map((a) => a.uri); 69 + const usageAgg = await ctx.db.models.Post.aggregate([ 70 + { $match: { "sound.uri": { $in: audioUris } } }, 71 + { $group: { _id: "$sound.uri", count: { $sum: 1 } } }, 72 + ]); 73 + const usageMap = new Map<string, number>( 74 + usageAgg.map((u: { _id: string; count: number }) => [u._id, u.count]), 75 + ); 76 + 77 + return audios.map((audio) => { 78 + const labels = audio.labels 79 + ? Array.isArray(audio.labels) ? (audio.labels as Label[]) : undefined 80 + : undefined; 81 + 82 + const details = audio.details ? { ...audio.details } : undefined; 83 + 84 + const record = { 85 + title: audio.title, 86 + origin: audio.origin ?? undefined, 87 + sound: audio.sound ?? undefined, 88 + labels: audio.labels ?? undefined, 89 + details: audio.details ?? undefined, 90 + createdAt: audio.createdAt, 91 + } satisfies Record<string, unknown>; 92 + 93 + const coverArt = authorsMap.get(audio.authorDid)?.avatar ?? ""; 94 + 95 + return { 96 + uri: audio.uri, 97 + cid: audio.cid, 98 + author: authorsMap.get(audio.authorDid)!, 99 + title: audio.title, 100 + coverArt, 101 + record, 102 + useCount: usageMap.get(audio.uri) ?? 0, 103 + details, 104 + indexedAt: audio.indexedAt, 105 + labels, 106 + } satisfies SoSprkSoundDefs.AudioView; 107 + }); 108 + }
+9
utils/post-transformer.ts
··· 3 3 import type * as SoSprkFeedDefs from "../lex/types/so/sprk/feed/defs.ts"; 4 4 import type * as SoSprkFeedPost from "../lex/types/so/sprk/feed/post.ts"; 5 5 import { AppContext } from "../main.ts"; 6 + import { transformAudiosToAudioViews } from "./audio-transformer.ts"; 6 7 import { transformEmbed } from "./embed-transformer.ts"; 7 8 import { createProfileViewBasic } from "./profile-helper.ts"; 8 9 ··· 18 19 19 20 const postUris = posts.map((p) => p.uri); 20 21 const authorDids = [...new Set(posts.map((p) => p.authorDid))]; 22 + const soundUris = posts 23 + .map((p) => p.sound?.uri) 24 + .filter((u): u is string => typeof u === "string"); 21 25 22 26 const [ 23 27 likeCounts, ··· 102 106 ]), 103 107 ); 104 108 109 + const audios = await ctx.db.models.Audio.find({ uri: { $in: soundUris } }); 110 + const audioViews = await transformAudiosToAudioViews(audios, ctx); 111 + const audioViewsMap = new Map(audioViews.map((av) => [av.uri, av])); 112 + 105 113 return posts.map((post) => { 106 114 const videoMapping = post.embed?.$type === "so.sprk.embed.video" 107 115 ? videoMappingsMap.get( ··· 145 153 likeCount: likeCountsMap.get(post.uri) || 0, 146 154 indexedAt: post.indexedAt, 147 155 labels, 156 + sound: post.sound?.uri ? audioViewsMap.get(post.sound.uri) : undefined, 148 157 }; 149 158 }); 150 159 }