diff --git a/Makefile b/Makefile index c2123bc46..91247a7a7 100644 --- a/Makefile +++ b/Makefile @@ -381,13 +381,14 @@ lexicons: .PHONY: go-lexicons go-lexicons: - rm -rf ./pkg/streamplace \ - && mkdir -p ./pkg/streamplace \ - && rm -rf ./pkg/streamplace/cbor_gen.go \ - && $(MAKE) lexgen \ - && sed -i.bak 's/\tlexutil\.RegisterType/\/\/\tlexutil.RegisterType/' $$(find ./pkg/streamplace -type f) \ - && go run golang.org/x/tools/cmd/goimports@latest -w $$(find ./pkg/streamplace -type f) \ + rm -rf ./pkg/streamplace ./pkg/gamesgamesgamesgames \ + && mkdir -p ./pkg/streamplace ./pkg/gamesgamesgamesgames \ + && $(MAKE) lexgen-types \ + && sed -i.bak 's/\tlexutil\.RegisterType/\/\/\tlexutil.RegisterType/' $$(find ./pkg/streamplace ./pkg/gamesgamesgamesgames -type f) \ + && go run golang.org/x/tools/cmd/goimports@latest -w $$(find ./pkg/streamplace ./pkg/gamesgamesgamesgames -type f) \ + && go run ./pkg/gen/gen_stubs.go \ && go run ./pkg/gen/gen.go \ + && rm -f ./pkg/streamplace/cbor_stubs.go \ && $(MAKE) lexgen \ && find . | grep bak$$ | xargs rm \ && rm -rf api @@ -434,6 +435,7 @@ lexgen-types: --build-file util/lexgen-types.json \ --external-lexicons subprojects/atproto/lexicons \ lexicons/place/stream \ + lexicons/games/gamesgamesgamesgames \ ./subprojects/atproto/lexicons .PHONY: lexgen-server @@ -442,6 +444,7 @@ lexgen-server: && go tool github.com/bluesky-social/indigo/cmd/lexgen \ --gen-server \ --types-import place.stream:stream.place/streamplace/pkg/streamplace \ + --types-import games.gamesgamesgamesgames:stream.place/streamplace/pkg/gamesgamesgamesgames \ --types-import app.bsky:github.com/bluesky-social/indigo/api/bsky \ --types-import com.atproto:github.com/bluesky-social/indigo/api/atproto \ --types-import chat.bsky:github.com/bluesky-social/indigo/api/chat \ @@ -449,6 +452,7 @@ lexgen-server: -outdir ./pkg/spxrpc \ --build-file util/lexgen-types.json \ --external-lexicons subprojects/atproto/lexicons \ + --external-lexicons lexicons/games/gamesgamesgamesgames \ --package spxrpc \ lexicons/place/stream \ lexicons/app/bsky \ diff --git a/js/app/components/activity-picker.tsx b/js/app/components/activity-picker.tsx new file mode 100644 index 000000000..e4dddb3d6 --- /dev/null +++ b/js/app/components/activity-picker.tsx @@ -0,0 +1,280 @@ +import { Text } from "@streamplace/components"; +import { usePossiblyUnauthedPDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import { useEffect, useRef, useState } from "react"; +import { Pressable, ScrollView, TextInput, View } from "react-native"; +import { PlaceStreamDefs, PlaceStreamLivestream } from "streamplace"; + +const ACTIVITY_LABELS: Array<{ + value: PlaceStreamDefs.ActivityLabel["label"]; + display: string; +}> = [ + { value: "just_chatting", display: "Just Chatting" }, + { value: "music", display: "Music" }, + { value: "art", display: "Art" }, + { value: "programming", display: "Programming" }, + { value: "cooking", display: "Cooking" }, + { value: "fitness", display: "Fitness" }, + { value: "sports", display: "Sports" }, +]; + +interface GameResult { + uri: string; + name: string; +} + +interface ActivityPickerProps { + value: PlaceStreamLivestream.Record["activity"] | undefined; + onChange: ( + activity: PlaceStreamLivestream.Record["activity"] | undefined, + ) => void; +} + +export default function ActivityPicker({ + value, + onChange, +}: ActivityPickerProps) { + const agent = usePossiblyUnauthedPDSAgent(); + const [mode, setMode] = useState<"game" | "label">( + value?.$type === "place.stream.defs#activityLabel" ? "label" : "game", + ); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [showResults, setShowResults] = useState(false); + const debounceRef = useRef | null>(null); + + const selectedGame = + value?.$type === "place.stream.defs#activityGame" + ? (value as PlaceStreamDefs.ActivityGame) + : null; + const selectedLabel = + value?.$type === "place.stream.defs#activityLabel" + ? (value as PlaceStreamDefs.ActivityLabel) + : null; + + useEffect(() => { + if (!query.trim() || !agent) { + setResults([]); + setShowResults(false); + return; + } + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setSearching(true); + try { + const res = await agent.place.stream.game.search({ + q: query, + limit: 8, + }); + const games: GameResult[] = []; + for (const result of res.data.results) { + if ( + result.$type === "games.gamesgamesgamesgames.defs#gameSummaryView" + ) { + const view = result as { uri: string; name: string }; + if (view.uri && view.name) { + games.push({ uri: view.uri, name: view.name }); + } + } + } + setResults(games); + setShowResults(true); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, agent]); + + const selectGame = (game: GameResult) => { + onChange({ + $type: "place.stream.defs#activityGame", + uri: game.uri, + name: game.name, + }); + setQuery(""); + setShowResults(false); + }; + + const clearActivity = () => { + onChange(undefined); + setQuery(""); + }; + + return ( + + {/* Mode toggle */} + + { + setMode("game"); + if (selectedLabel) onChange(undefined); + }} + style={{ + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + borderWidth: 1, + borderColor: mode === "game" ? "#0066cc" : "#ccc", + backgroundColor: mode === "game" ? "#e8f0fe" : "transparent", + }} + > + + Game + + + { + setMode("label"); + if (selectedGame) onChange(undefined); + }} + style={{ + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + borderWidth: 1, + borderColor: mode === "label" ? "#0066cc" : "#ccc", + backgroundColor: mode === "label" ? "#e8f0fe" : "transparent", + }} + > + + Other Activity + + + + + {mode === "game" && ( + + {selectedGame ? ( + + + {selectedGame.name} + + + + × + + + + ) : ( + + )} + {showResults && results.length > 0 && ( + + + {results.map((game) => ( + selectGame(game)} + style={({ pressed }) => ({ + padding: 10, + backgroundColor: pressed ? "#f0f0f0" : "white", + borderBottomWidth: 1, + borderBottomColor: "#eee", + })} + > + {game.name} + + ))} + + + )} + {searching && ( + + Searching... + + )} + + )} + + {mode === "label" && ( + + {ACTIVITY_LABELS.map(({ value: labelValue, display }) => { + const selected = selectedLabel?.label === labelValue; + return ( + + onChange( + selected + ? undefined + : { + $type: "place.stream.defs#activityLabel", + label: labelValue, + }, + ) + } + style={{ + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + borderColor: selected ? "#0066cc" : "#ccc", + backgroundColor: selected ? "#0066cc" : "transparent", + }} + > + + {display} + + + ); + })} + + )} + + ); +} diff --git a/js/app/components/create-livestream.tsx b/js/app/components/create-livestream.tsx index 8d3d72eeb..6746632d2 100644 --- a/js/app/components/create-livestream.tsx +++ b/js/app/components/create-livestream.tsx @@ -1,4 +1,5 @@ -import { formatHandleWithAt, zero } from "@streamplace/components"; +import { formatHandleWithAt, Text, zero } from "@streamplace/components"; +import ActivityPicker from "components/activity-picker"; import ThumbnailSelector from "components/thumbnail-selector"; import { useCaptureVideoFrame } from "hooks/useCaptureVideoFrame"; import { useLiveUser } from "hooks/useLiveUser"; @@ -7,13 +8,13 @@ import { Platform, Pressable, ScrollView, - Text, TextInput, useWindowDimensions, View, } from "react-native"; import { useStore } from "store"; import { useNewLivestream, useUserProfile } from "store/hooks"; +import type { PlaceStreamLivestream } from "streamplace"; const isWeb = Platform.OS === "web"; @@ -25,6 +26,11 @@ export default function CreateLivestream() { // Note: Toast functionality removed, would need simple alert replacement const userIsLive = useLiveUser(); const [title, setTitle] = useState(""); + const [activity, setActivity] = useState< + PlaceStreamLivestream.Record["activity"] | undefined + >(undefined); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(""); const [loading, setLoading] = useState(false); const [customThumbnail, setCustomThumbnail] = useState( undefined, @@ -40,8 +46,10 @@ export default function CreateLivestream() { useEffect(() => { if (newLivestream?.record) { - // Would show toast: "Livestream announced" with newLivestream.record.title setTitle(""); + setActivity(undefined); + setTags([]); + setTagInput(""); setCustomThumbnail(undefined); } }, [newLivestream?.record]); @@ -65,7 +73,7 @@ export default function CreateLivestream() { } } - await createLivestreamRecord(title, thumbnailToUse); + await createLivestreamRecord(title, thumbnailToUse, activity, tags); } catch (error) { console.error("Error creating livestream:", error); // Would show toast: "Error creating livestream" @@ -160,6 +168,81 @@ export default function CreateLivestream() { + + + Activity + + + + + + + + + Tags + + + + {tags.map((tag) => ( + setTags(tags.filter((t) => t !== tag))} + style={{ + flexDirection: "row", + alignItems: "center", + backgroundColor: "#e8f0fe", + borderRadius: 16, + paddingHorizontal: 10, + paddingVertical: 4, + gap: 4, + }} + > + + {tag} + + + × + + + ))} + + {tags.length < 10 && ( + { + const trimmed = tagInput.trim(); + if (trimmed && !tags.includes(trimmed)) { + setTags([...tags, trimmed]); + } + setTagInput(""); + }} + style={{ + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 8, + padding: 10, + }} + /> + )} + + + diff --git a/js/app/components/edit-livestream.tsx b/js/app/components/edit-livestream.tsx index 50bb0e3b8..e3ee85f2d 100644 --- a/js/app/components/edit-livestream.tsx +++ b/js/app/components/edit-livestream.tsx @@ -4,11 +4,13 @@ import { useLivestream, zero, } from "@streamplace/components"; +import ActivityPicker from "components/activity-picker"; import { useLiveUser } from "hooks/useLiveUser"; import { useEffect, useState } from "react"; import { Pressable, ScrollView, TextInput, View } from "react-native"; import { useStore } from "store"; import { useNewLivestream, useUserProfile } from "store/hooks"; +import type { PlaceStreamLivestream } from "streamplace"; export default function UpdateLivestream() { const updateLivestreamRecord = useStore( @@ -18,14 +20,26 @@ export default function UpdateLivestream() { // Note: Toast functionality removed, would need simple alert replacement const userIsLive = useLiveUser(); const [title, setTitle] = useState(""); + const [activity, setActivity] = useState< + PlaceStreamLivestream.Record["activity"] | undefined + >(undefined); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(""); const [loading, setLoading] = useState(false); const profile = useUserProfile(); const livestream = useLivestream(); const newLivestream = useNewLivestream(); + useEffect(() => { + if (livestream?.record) { + const rec = livestream.record as PlaceStreamLivestream.Record; + setActivity(rec.activity as PlaceStreamLivestream.Record["activity"]); + setTags(rec.tags ?? []); + } + }, [livestream?.uri]); + useEffect(() => { if (newLivestream?.record) { - // Would show toast: "Livestream title updated" with newLivestream.record.title setTitle(""); } }, [newLivestream?.record]); @@ -41,7 +55,7 @@ export default function UpdateLivestream() { const handleSubmit = async () => { setLoading(true); try { - await updateLivestreamRecord(title, livestream); + await updateLivestreamRecord(title, livestream, activity, tags); } catch (error) { console.error("Error updating livestream:", error); // Would show toast: "Error updating livestream" @@ -128,6 +142,81 @@ export default function UpdateLivestream() { + + + Activity + + + + + + + + + Tags + + + + {tags.map((tag) => ( + setTags(tags.filter((t) => t !== tag))} + style={{ + flexDirection: "row", + alignItems: "center", + backgroundColor: "#e8f0fe", + borderRadius: 16, + paddingHorizontal: 10, + paddingVertical: 4, + gap: 4, + }} + > + + {tag} + + + × + + + ))} + + {tags.length < 10 && ( + { + const trimmed = tagInput.trim(); + if (trimmed && !tags.includes(trimmed)) { + setTags([...tags, trimmed]); + } + setTagInput(""); + }} + style={{ + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 8, + padding: 10, + }} + /> + )} + + + = { + just_chatting: "Just Chatting", + music: "Music", + art: "Art", + programming: "Programming", + cooking: "Cooking", + fitness: "Fitness", + sports: "Sports", +}; + +function getStreamCategories(record: PlaceStreamLivestream.Record): string[] { + const categories: string[] = []; + if (record.activity) { + if (record.activity.$type === "place.stream.defs#activityGame") { + const game = record.activity as PlaceStreamDefs.ActivityGame; + if (game.name) categories.push(game.name); + } else if (record.activity.$type === "place.stream.defs#activityLabel") { + const label = record.activity as PlaceStreamDefs.ActivityLabel; + categories.push(LABEL_DISPLAY[label.label] ?? label.label); + } + } + if (record.tags) { + categories.push(...record.tags); + } + return categories; +} // as we're not using a specific grid library these are necessary // to constrain the cards @@ -115,7 +142,9 @@ function HomeScreenItem({ thumbnailUrl={`/api/playback/${user}/stream.jpg?ts=${(Date.now() / 120000).toFixed(0)}`} avatarUrl={avatarUrl} streamerName={user} - category={[]} + category={getStreamCategories( + item.record as PlaceStreamLivestream.Record, + )} viewers={item.viewerCount?.count} isLive={true} /> diff --git a/js/app/store/slices/blueskySlice.ts b/js/app/store/slices/blueskySlice.ts index ba52e5eb7..54fca3d6d 100644 --- a/js/app/store/slices/blueskySlice.ts +++ b/js/app/store/slices/blueskySlice.ts @@ -103,8 +103,15 @@ export interface BlueskySlice { createLivestreamRecord: ( title: string, customThumbnail?: Blob, + activity?: PlaceStreamLivestream.Record["activity"], + tags?: string[], + ) => Promise; + updateLivestreamRecord: ( + title: string, + livestream: any, + activity?: PlaceStreamLivestream.Record["activity"], + tags?: string[], ) => Promise; - updateLivestreamRecord: (title: string, livestream: any) => Promise; getChatProfileRecordFromPDS: () => Promise; createChatProfileRecord: ( red: number, @@ -755,7 +762,12 @@ export const createBlueskySlice: StateCreator< } }, - createLivestreamRecord: async (title: string, customThumbnail?: Blob) => { + createLivestreamRecord: async ( + title: string, + customThumbnail?: Blob, + activity?: PlaceStreamLivestream.Record["activity"], + tags?: string[], + ) => { set({ newLivestream: { loading: true, @@ -854,6 +866,8 @@ export const createBlueskySlice: StateCreator< cid: newPost.cid, }, thumb: thumbnail, + activity, + tags: tags && tags.length > 0 ? tags : undefined, }; await state.pdsAgent.com.atproto.repo.createRecord({ @@ -880,7 +894,12 @@ export const createBlueskySlice: StateCreator< } }, - updateLivestreamRecord: async (title: string, livestream: any) => { + updateLivestreamRecord: async ( + title: string, + livestream: any, + activity?: PlaceStreamLivestream.Record["activity"], + tags?: string[], + ) => { set({ newLivestream: { loading: true, @@ -925,6 +944,8 @@ export const createBlueskySlice: StateCreator< url: streamplaceUrl, createdAt: new Date().toISOString(), post: oldRecordValue.post, + activity, + tags: tags && tags.length > 0 ? tags : undefined, }; await state.pdsAgent.com.atproto.repo.putRecord({ diff --git a/js/docs/src/content/docs/lex-reference/game/place-stream-game-search.md b/js/docs/src/content/docs/lex-reference/game/place-stream-game-search.md new file mode 100644 index 000000000..61f8a3551 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/game/place-stream-game-search.md @@ -0,0 +1,102 @@ +--- +title: place.stream.game.search +description: Reference for the place.stream.game.search lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `query` + +Search for games and other entities via the games.gamesgamesgamesgames catalog. Proxied from the configured games API. + +**Parameters:** + +| Name | Type | Req'd | Description | Constraints | +| -------- | --------- | ----- | ----------- | ------------------------------------- | +| `q` | `string` | ✅ | | Min Length: 1
Max Length: 200 | +| `limit` | `integer` | ❌ | | Min: 1
Max: 100
Default: `20` | +| `cursor` | `string` | ❌ | | | + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- | +| `cursor` | `string` | ❌ | | | +| `totalResults` | `integer` | ❌ | | | +| `results` | Array of Union of:
  `games.gamesgamesgamesgames.defs#gameSummaryView`
  `games.gamesgamesgamesgames.defs#profileSummaryView`
  `games.gamesgamesgamesgames.defs#platformSummaryView`
  `games.gamesgamesgamesgames.defs#collectionSummaryView`
  `games.gamesgamesgamesgames.defs#engineSummaryView` | ✅ | | | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.game.search", + "defs": { + "main": { + "type": "query", + "description": "Search for games and other entities via the games.gamesgamesgamesgames catalog. Proxied from the configured games API.", + "parameters": { + "type": "params", + "required": ["q"], + "properties": { + "q": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "cursor": { + "type": "string" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["results"], + "properties": { + "cursor": { + "type": "string" + }, + "totalResults": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "games.gamesgamesgamesgames.defs#gameSummaryView", + "games.gamesgamesgamesgames.defs#profileSummaryView", + "games.gamesgamesgamesgames.defs#platformSummaryView", + "games.gamesgamesgamesgames.defs#collectionSummaryView", + "games.gamesgamesgamesgames.defs#engineSummaryView" + ] + } + } + } + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 41cb5845e..38d5be96a 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -1967,6 +1967,87 @@ ] } }, + "/xrpc/place.stream.game.search": { + "get": { + "summary": "Search for games and other entities via the games.gamesgamesgamesgames catalog. Proxied from the configured games API.", + "operationId": "place.stream.game.search", + "tags": ["place.stream.game"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cursor": { + "type": "string" + }, + "totalResults": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/games.gamesgamesgamesgames.defs_gameSummaryView" + }, + { + "$ref": "#/components/schemas/games.gamesgamesgamesgames.defs_profileSummaryView" + }, + { + "$ref": "#/components/schemas/games.gamesgamesgamesgames.defs_platformSummaryView" + }, + { + "$ref": "#/components/schemas/games.gamesgamesgamesgames.defs_collectionSummaryView" + }, + { + "$ref": "#/components/schemas/games.gamesgamesgamesgames.defs_engineSummaryView" + } + ] + } + } + }, + "required": ["results"] + } + } + } + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, "/xrpc/place.stream.config.getEnv": { "get": { "summary": "Get client-facing environment configuration from the server.", diff --git a/js/docs/src/content/docs/lex-reference/place-stream-defs.md b/js/docs/src/content/docs/lex-reference/place-stream-defs.md index a0c204e03..0b378bb40 100644 --- a/js/docs/src/content/docs/lex-reference/place-stream-defs.md +++ b/js/docs/src/content/docs/lex-reference/place-stream-defs.md @@ -53,6 +53,39 @@ description: Reference for the place.stream.defs lexicon --- + + +### `activityGame` + +**Type:** `object` + +A game from the gamesgamesgamesgames catalog, identified by its AT URI. + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ------ | -------- | ----- | -------------------------------- | ---------------- | +| `uri` | `string` | ✅ | | Format: `at-uri` | +| `name` | `string` | ❌ | Cached display name of the game. | | + +--- + + + +### `activityLabel` + +**Type:** `object` + +A non-game activity with a well-known label. + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ------- | -------- | ----- | ----------- | -------------------------------------------------------------------------------------------- | +| `label` | `string` | ✅ | | Known Values: `just_chatting`, `music`, `art`, `programming`, `cooking`, `fitness`, `sports` | + +--- + ## Lexicon Source ```json @@ -107,6 +140,40 @@ description: Reference for the place.stream.defs lexicon "type": "string" } } + }, + "activityGame": { + "type": "object", + "description": "A game from the gamesgamesgamesgames catalog, identified by its AT URI.", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "name": { + "type": "string", + "description": "Cached display name of the game." + } + } + }, + "activityLabel": { + "type": "object", + "description": "A non-game activity with a well-known label.", + "required": ["label"], + "properties": { + "label": { + "type": "string", + "knownValues": [ + "just_chatting", + "music", + "art", + "programming", + "cooking", + "fitness", + "sports" + ] + } + } } } } diff --git a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md index db8ee1665..a61a3df5a 100644 --- a/js/docs/src/content/docs/lex-reference/place-stream-livestream.md +++ b/js/docs/src/content/docs/lex-reference/place-stream-livestream.md @@ -19,19 +19,21 @@ Record announcing a livestream is happening **Record Properties:** -| Name | Type | Req'd | Description | Constraints | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| `title` | `string` | ✅ | The title of the livestream, as it will be announced to followers. | Max Length: 1400
Max Graphemes: 140 | -| `url` | `string` | ❌ | The URL where this stream can be found. This is primarily a hint for other Streamplace nodes to locate and replicate the stream. | Format: `uri` | -| `createdAt` | `string` | ✅ | Client-declared timestamp when this livestream started. | Format: `datetime` | -| `lastSeenAt` | `string` | ❌ | Client-declared timestamp when this livestream was last seen by the Streamplace station. | Format: `datetime` | -| `endedAt` | `string` | ❌ | Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. | Format: `datetime` | -| `idleTimeoutSeconds` | `integer` | ❌ | Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout. | | -| `post` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ❌ | The post that announced this livestream. | | -| `agent` | `string` | ❌ | The source of the livestream, if available, in a User Agent format: ` / ` e.g. Streamplace/0.7.5 iOS | | -| `canonicalUrl` | `string` | ❌ | The primary URL where this livestream can be viewed, if available. | Format: `uri` | -| `thumb` | `blob` | ❌ | | Accept: `image/*`
Max Size: 1000000 bytes | -| `notificationSettings` | [`place.stream.livestream#notificationSettings`](/lex-reference/place-stream-livestream#notificationsettings) | ❌ | | | +| Name | Type | Req'd | Description | Constraints | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| `title` | `string` | ✅ | The title of the livestream, as it will be announced to followers. | Max Length: 1400
Max Graphemes: 140 | +| `url` | `string` | ❌ | The URL where this stream can be found. This is primarily a hint for other Streamplace nodes to locate and replicate the stream. | Format: `uri` | +| `createdAt` | `string` | ✅ | Client-declared timestamp when this livestream started. | Format: `datetime` | +| `lastSeenAt` | `string` | ❌ | Client-declared timestamp when this livestream was last seen by the Streamplace station. | Format: `datetime` | +| `endedAt` | `string` | ❌ | Client-declared timestamp when this livestream ended. Ended livestreams are not supposed to start up again. | Format: `datetime` | +| `idleTimeoutSeconds` | `integer` | ❌ | Time in seconds after which this livestream should be automatically ended if idle. Zero means no timeout. | | +| `post` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ❌ | The post that announced this livestream. | | +| `agent` | `string` | ❌ | The source of the livestream, if available, in a User Agent format: ` / ` e.g. Streamplace/0.7.5 iOS | | +| `canonicalUrl` | `string` | ❌ | The primary URL where this livestream can be viewed, if available. | Format: `uri` | +| `thumb` | `blob` | ❌ | | Accept: `image/*`
Max Size: 1000000 bytes | +| `notificationSettings` | [`place.stream.livestream#notificationSettings`](/lex-reference/place-stream-livestream#notificationsettings) | ❌ | | | +| `activity` | Union of:
  [`place.stream.defs#activityGame`](/lex-reference/place-stream-defs#activitygame)
  [`place.stream.defs#activityLabel`](/lex-reference/place-stream-defs#activitylabel) | ❌ | The game or activity being streamed. | | +| `tags` | Array of `string` | ❌ | Freeform tags for this stream. | Max Items: 10 | --- @@ -196,6 +198,24 @@ Record announcing a livestream is happening "notificationSettings": { "type": "ref", "ref": "place.stream.livestream#notificationSettings" + }, + "activity": { + "type": "union", + "description": "The game or activity being streamed.", + "refs": [ + "place.stream.defs#activityGame", + "place.stream.defs#activityLabel" + ] + }, + "tags": { + "type": "array", + "description": "Freeform tags for this stream.", + "maxLength": 10, + "items": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + } } } } diff --git a/lexicons/games/gamesgamesgamesgames/defs.json b/lexicons/games/gamesgamesgamesgames/defs.json new file mode 100644 index 000000000..01c656268 --- /dev/null +++ b/lexicons/games/gamesgamesgamesgames/defs.json @@ -0,0 +1,1001 @@ +{ + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "id": "games.gamesgamesgamesgames.defs", + "defs": { + "applicationType": { + "type": "string", + "enum": [ + "addon", + "bundle", + "dlc", + "episode", + "expandedGame", + "expansion", + "fork", + "game", + "mod", + "port", + "remake", + "remaster", + "season", + "standaloneExpansion", + "update" + ] + }, + + "genre": { + "type": "string", + "knownValues": [ + "fighting", + "music", + "platform", + "pointAndClick", + "puzzle", + "racing", + "rpg", + "rts", + "shooter", + "simulator" + ] + }, + + "mediaItem": { + "type": "object", + "properties": { + "blob": { + "type": "blob", + "accept": ["image/*", "video/*"], + "maxSize": 200000000 + }, + "description": { "type": "string" }, + "height": { "type": "integer" }, + "locale": { "type": "string" }, + "mediaType": { + "type": "string", + "enum": [ + "accoladesTrailer", + "announcementTrailer", + "artwork", + "cinematicTrailer", + "conceptArt", + "cover", + "coverAlt", + "coverHistorical", + "coverSquare", + "cutscene", + "devDiary", + "gameplayImage", + "gameplayTrailer", + "icon", + "infographic", + "intro", + "keyArt", + "keyArtLogo", + "launchTrailer", + "logoBlack", + "logoColor", + "logoWhite", + "releaseDateTrailer", + "screenshot", + "teaser", + "trailer" + ] + }, + "title": { "type": "string" }, + "width": { "type": "integer" } + } + }, + + "mode": { + "type": "string", + "knownValues": [ + "battleRoyale", + "cooperative", + "mmo", + "multiplayer", + "singlePlayer", + "splitScreen" + ] + }, + + "playerPerspective": { + "type": "string", + "knownValues": [ + "auditory", + "firstPerson", + "isometric", + "sideView", + "text", + "thirdPerson", + "topDown", + "vr" + ] + }, + + "theme": { + "type": "string", + "knownValues": [ + "4x", + "action", + "business", + "comedy", + "drama", + "educational", + "erotic", + "fantasy", + "historical", + "horror", + "kids", + "mystery", + "nonfiction", + "openWorld", + "party", + "romance", + "sandbox", + "scifi", + "stealth", + "survival", + "thriller", + "warfare" + ] + }, + + "ageRating": { + "type": "object", + "required": ["organization", "rating"], + "properties": { + "organization": { + "type": "string", + "knownValues": [ + "esrb", + "pegi", + "cero", + "usk", + "grac", + "classInd", + "acb" + ] + }, + "rating": { "type": "string" }, + "contentDescriptors": { + "type": "array", + "items": { "type": "string" } + } + } + }, + + "alternativeName": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "locale": { "type": "string" }, + "comment": { "type": "string" } + } + }, + + "companyRole": { + "type": "string", + "knownValues": ["developer", "publisher", "porter", "supporter"] + }, + + "creditEntry": { + "type": "object", + "required": ["role"], + "properties": { + "role": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#individualRole" + }, + "department": { + "type": "string", + "maxLength": 640 + } + } + }, + + "individualRole": { + "type": "string", + "knownValues": [ + "director", + "producer", + "designer", + "programmer", + "artist", + "animator", + "writer", + "composer", + "soundDesigner", + "voiceActor", + "qa", + "localization", + "communityManager", + "marketing" + ] + }, + + "itchIoId": { + "type": "object", + "required": ["developer", "game"], + "properties": { + "developer": { "type": "string" }, + "game": { "type": "string" } + } + }, + + "externalIds": { + "type": "object", + "properties": { + "igdb": { "type": "string" }, + "steam": { "type": "string" }, + "gog": { "type": "string" }, + "epicGames": { "type": "string" }, + "itchIo": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#itchIoId" + }, + "humbleBundle": { "type": "string" }, + "playStation": { "type": "string" }, + "xbox": { "type": "string" }, + "nintendoEshop": { "type": "string" }, + "appleAppStore": { "type": "string" }, + "googlePlay": { "type": "string" }, + "twitch": { "type": "string" } + } + }, + + "externalVideo": { + "type": "object", + "required": ["videoId", "platform"], + "properties": { + "videoId": { "type": "string" }, + "platform": { + "type": "string", + "knownValues": ["youtube", "twitch", "vimeo"] + }, + "title": { "type": "string" } + } + }, + + "languageSupport": { + "type": "object", + "required": ["language"], + "properties": { + "language": { "type": "string" }, + "audio": { "type": "boolean" }, + "subtitles": { "type": "boolean" }, + "interface": { "type": "boolean" } + } + }, + + "multiplayerMode": { + "type": "object", + "properties": { + "platform": { "type": "string" }, + "onlineMax": { "type": "integer" }, + "offlineMax": { "type": "integer" }, + "onlineCoopMax": { "type": "integer" }, + "offlineCoopMax": { "type": "integer" }, + "hasCampaignCoop": { "type": "boolean" }, + "hasDropIn": { "type": "boolean" }, + "hasLanCoop": { "type": "boolean" }, + "hasSplitscreen": { "type": "boolean" }, + "hasSplitscreenOnline": { "type": "boolean" } + } + }, + + "timeToBeat": { + "type": "object", + "properties": { + "hastily": { "type": "integer" }, + "normally": { "type": "integer" }, + "completely": { "type": "integer" } + } + }, + + "website": { + "type": "object", + "required": ["url"], + "properties": { + "url": { "type": "string", "format": "uri" }, + "type": { + "type": "string", + "knownValues": [ + "official", + "wiki", + "steam", + "gog", + "epicGames", + "itchIo", + "twitter", + "instagram", + "youtube", + "twitch", + "discord", + "reddit", + "facebook", + "wikipedia", + "bluesky", + "xbox", + "playstation", + "nintendo", + "meta", + "other" + ] + } + } + }, + + "skeletonGameFeedItem": { + "type": "object", + "required": ["game"], + "properties": { + "game": { "type": "string", "format": "at-uri" }, + "feedContext": { "type": "string", "maxLength": 2000 } + } + }, + + "gameView": { + "type": "object", + "required": ["uri", "name", "applicationType"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "name": { "type": "string" }, + "applicationType": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#applicationType" + }, + "summary": { "type": "string" }, + "genres": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#genre" + } + }, + "themes": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#theme" + } + }, + "media": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#mediaItem" + } + }, + "releases": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#release" + } + }, + "slug": { "type": "string" }, + "likeCount": { "type": "integer", "minimum": 0 }, + "viewer": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#viewerState" + } + } + }, + + "viewerState": { + "type": "object", + "properties": { + "like": { "type": "string", "format": "at-uri" } + } + }, + + "gameFeedViewItem": { + "type": "object", + "required": ["game"], + "properties": { + "game": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#gameView" + }, + "feedContext": { "type": "string", "maxLength": 2000 } + } + }, + + "gameDetailView": { + "type": "object", + "required": ["name", "uri", "createdAt"], + "properties": { + "applicationType": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#applicationType" + }, + "createdAt": { "type": "string", "format": "datetime" }, + "genres": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#genre" + } + }, + "media": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#mediaItem" + } + }, + "modes": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#mode" + } + }, + "name": { "type": "string" }, + "parent": { "type": "string", "format": "at-uri" }, + "playerPerspectives": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#playerPerspective" + } + }, + "publishedAt": { "type": "string", "format": "datetime" }, + "releases": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#release" + } + }, + "summary": { "type": "string" }, + "themes": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#theme" + } + }, + "uri": { "type": "string", "format": "at-uri" }, + "storyline": { "type": "string" }, + "keywords": { + "type": "array", + "items": { "type": "string" } + }, + "websites": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#website" + } + }, + "videos": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#externalVideo" + } + }, + "alternativeNames": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#alternativeName" + } + }, + "timeToBeat": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#timeToBeat" + }, + "ageRatings": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#ageRating" + } + }, + "languageSupports": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#languageSupport" + } + }, + "multiplayerModes": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#multiplayerMode" + } + }, + "collections": { + "type": "array", + "items": { "type": "string", "format": "at-uri" } + }, + "engines": { + "type": "array", + "items": { "type": "string", "format": "at-uri" } + }, + "externalIds": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#externalIds" + }, + "slug": { "type": "string" }, + "orgCredits": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#orgCreditView" + } + }, + "actorCredits": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#actorCreditView" + } + } + } + }, + + "orgCreditView": { + "type": "object", + "required": ["uri", "roles"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "orgUri": { "type": "string", "format": "at-uri" }, + "displayName": { "type": "string", "maxLength": 640 }, + "roles": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#companyRole" + } + } + } + }, + + "actorCreditView": { + "type": "object", + "required": ["uri", "credits"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "actorUri": { "type": "string", "format": "at-uri" }, + "displayName": { "type": "string", "maxLength": 640 }, + "credits": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#creditEntry" + } + } + } + }, + + "gameSummaryView": { + "type": "object", + "required": ["uri", "name"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "name": { "type": "string" }, + "summary": { "type": "string" }, + "media": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#mediaItem" + } + }, + "genres": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#genre" + } + }, + "slug": { "type": "string" }, + "applicationType": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#applicationType" + }, + "firstReleaseDate": { + "type": "integer", + "description": "Earliest release date as YYYYMMDD integer." + } + } + }, + + "profileSummaryView": { + "type": "object", + "required": ["uri", "did", "profileType"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "did": { "type": "string", "format": "did" }, + "profileType": { "type": "string", "knownValues": ["actor", "org"] }, + "displayName": { "type": "string", "maxLength": 640 }, + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 10000000 + } + } + }, + + "actorProfileDetailView": { + "type": "object", + "required": ["uri", "did"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "did": { "type": "string", "format": "did" }, + "displayName": { + "type": "string", + "maxLength": 640 + }, + "description": { + "type": "string", + "maxLength": 3000 + }, + "descriptionFacets": { + "type": "array", + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } + }, + "pronouns": { + "type": "string", + "maxLength": 200 + }, + "websites": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#website" + } + }, + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 10000000 + }, + "createdAt": { "type": "string", "format": "datetime" } + } + }, + + "actorProfileSummaryView": { + "type": "object", + "required": ["uri", "did"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "did": { "type": "string", "format": "did" }, + "displayName": { + "type": "string", + "maxLength": 640 + }, + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 10000000 + } + } + }, + + "orgProfileDetailView": { + "type": "object", + "required": ["uri", "did"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "did": { "type": "string", "format": "did" }, + "displayName": { + "type": "string", + "maxLength": 640 + }, + "description": { + "type": "string", + "maxLength": 3000 + }, + "descriptionFacets": { + "type": "array", + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } + }, + "country": { "type": "string" }, + "status": { + "type": "string", + "knownValues": ["active", "inactive", "merged", "acquired", "defunct"] + }, + "parent": { + "type": "string", + "format": "at-uri" + }, + "foundedAt": { + "type": "string", + "format": "datetime" + }, + "websites": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#website" + } + }, + "media": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#mediaItem" + } + }, + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 10000000 + }, + "createdAt": { "type": "string", "format": "datetime" } + } + }, + + "orgProfileSummaryView": { + "type": "object", + "required": ["uri", "did"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "did": { "type": "string", "format": "did" }, + "displayName": { + "type": "string", + "maxLength": 640 + }, + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 10000000 + } + } + }, + + "platformSummaryView": { + "type": "object", + "required": ["uri", "name"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "name": { "type": "string" }, + "abbreviation": { "type": "string" }, + "category": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#platformCategory" + }, + "slug": { "type": "string" } + } + }, + + "collectionSummaryView": { + "type": "object", + "required": ["uri", "name"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "name": { "type": "string" }, + "type": { + "type": "string", + "knownValues": ["franchise", "series", "curated"] + }, + "slug": { "type": "string" } + } + }, + + "engineSummaryView": { + "type": "object", + "required": ["uri", "name"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "name": { "type": "string" }, + "slug": { "type": "string" } + } + }, + + "release": { + "type": "object", + "properties": { + "platform": { + "type": "string", + "description": "Free-text platform name, used when no platform record exists." + }, + "platformUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of a platform record." + }, + "releaseDates": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#releaseDate" + } + } + } + }, + + "platformCategory": { + "type": "string", + "knownValues": [ + "console", + "portable", + "computer", + "arcade", + "operatingSystem" + ] + }, + + "platformVersion": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "summary": { "type": "string" }, + "cpu": { "type": "string" }, + "gpu": { "type": "string" }, + "memory": { "type": "string" }, + "storage": { "type": "string" }, + "connectivity": { "type": "string" }, + "os": { "type": "string" }, + "output": { "type": "string" }, + "maxResolution": { "type": "string" }, + "media": { + "type": "array", + "items": { + "type": "ref", + "ref": "games.gamesgamesgamesgames.defs#mediaItem" + } + } + } + }, + + "systemSpec": { + "type": "object", + "description": "Hardware/software specification for a platform.", + "properties": { + "os": { "type": "string" }, + "processor": { "type": "string" }, + "memory": { "type": "string" }, + "graphics": { "type": "string" }, + "directx": { "type": "string" }, + "storage": { "type": "string" }, + "soundCard": { "type": "string" }, + "additionalNotes": { "type": "string" } + } + }, + + "systemRequirements": { + "type": "object", + "description": "System requirements for a game on a specific platform.", + "required": ["platform"], + "properties": { + "platform": { + "type": "string", + "knownValues": ["windows", "mac", "linux"] + }, + "minimum": { "type": "ref", "ref": "#systemSpec" }, + "recommended": { "type": "ref", "ref": "#systemSpec" } + } + }, + + "platformFeatures": { + "type": "object", + "description": "Features supported by a game on a specific storefront/platform.", + "required": ["platform", "features"], + "properties": { + "platform": { + "type": "string", + "knownValues": [ + "steam", + "gog", + "epicGames", + "playStation", + "xbox", + "nintendoEshop" + ] + }, + "features": { + "type": "array", + "items": { + "type": "string", + "knownValues": [ + "achievements", + "cloudSaves", + "controllerSupport", + "controllerSupportFull", + "coop", + "coopLan", + "coopOnline", + "crossPlatformMultiplayer", + "familySharing", + "inAppPurchases", + "leaderboards", + "levelEditor", + "moddingSupport", + "multiPlayer", + "multiPlayerLan", + "multiPlayerOnline", + "pvp", + "pvpLan", + "pvpOnline", + "remotePlayPhone", + "remotePlayTablet", + "remotePlayTV", + "remotePlayTogether", + "singlePlayer", + "steamTradingCards", + "steamWorkshop", + "trackingAndManagement", + "vrSupport" + ] + } + } + } + }, + + "releaseDate": { + "type": "object", + "properties": { + "releasedAt": { + "type": "string" + }, + "releasedAtFormat": { + "type": "string", + "enum": [ + "YYYY-MM-DD", + "YYYY-MM", + "YYYY-Q1", + "YYYY-Q2", + "YYYY-Q3", + "YYYY-Q4", + "YYYY", + "TBD" + ] + }, + "region": { + "type": "string", + "knownValues": [ + "worldwide", + "europe", + "northAmerica", + "australia", + "newZealand", + "japan", + "china", + "asia", + "korea", + "brazil" + ] + }, + "status": { + "type": "string", + "knownValues": [ + "advancedAccess", + "alpha", + "beta", + "cancelled", + "digitalCompatibilityRelease", + "earlyAccess", + "nextGenOptimizationRelease", + "offline", + "release" + ] + } + } + }, + + "signature": { + "type": "object", + "description": "An inline attestation signature.", + "required": ["key", "signature"], + "properties": { + "key": { + "type": "string", + "description": "DID key reference (e.g., did:web:example.com#signing1)." + }, + "signature": { + "type": "bytes", + "description": "The signature bytes." + } + } + } + } +} diff --git a/lexicons/games/gamesgamesgamesgames/search.json b/lexicons/games/gamesgamesgamesgames/search.json new file mode 100644 index 000000000..c20adeefc --- /dev/null +++ b/lexicons/games/gamesgamesgamesgames/search.json @@ -0,0 +1,135 @@ +{ + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "id": "games.gamesgamesgamesgames.search", + "defs": { + "main": { + "type": "query", + "description": "Search across all entity types (games, profiles, platforms, collections, engines).", + "parameters": { + "type": "params", + "required": ["q"], + "properties": { + "q": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "types": { + "type": "array", + "items": { + "type": "string", + "knownValues": [ + "game", + "profile", + "platform", + "collection", + "engine" + ] + } + }, + "applicationTypes": { + "type": "array", + "description": "Filter game results by application type (e.g. game, dlc, bundle). Only applies when types includes game.", + "items": { + "type": "string", + "knownValues": [ + "addon", + "bundle", + "dlc", + "episode", + "expandedGame", + "expansion", + "fork", + "game", + "mod", + "pack", + "port", + "remake", + "remaster", + "season", + "standaloneExpansion", + "update" + ] + } + }, + "sort": { + "type": "string", + "knownValues": [ + "name_asc", + "name_desc", + "published_asc", + "published_desc" + ] + }, + "genres": { + "type": "array", + "items": { "type": "string" } + }, + "themes": { + "type": "array", + "items": { "type": "string" } + }, + "modes": { + "type": "array", + "items": { "type": "string" } + }, + "playerPerspectives": { + "type": "array", + "items": { "type": "string" } + }, + "ageRatings": { + "type": "array", + "description": "Filter game results by age rating in 'organization:rating' format (e.g. esrb:M, pegi:Eighteen).", + "items": { "type": "string" } + }, + "includeUnrated": { + "type": "boolean", + "description": "When age rating filters are active, also include games with no age ratings.", + "default": false + }, + "includeCancelled": { + "type": "boolean", + "default": false + }, + "cursor": { + "type": "string" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["results"], + "properties": { + "cursor": { "type": "string" }, + "totalResults": { + "type": "integer", + "description": "Total number of results matching the query above the relevance threshold." + }, + "results": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "games.gamesgamesgamesgames.defs#gameSummaryView", + "games.gamesgamesgamesgames.defs#profileSummaryView", + "games.gamesgamesgamesgames.defs#platformSummaryView", + "games.gamesgamesgamesgames.defs#collectionSummaryView", + "games.gamesgamesgamesgames.defs#engineSummaryView" + ] + } + } + } + } + } + } + } +} diff --git a/lexicons/place/stream/defs.json b/lexicons/place/stream/defs.json index bd218aad4..ef73b5409 100644 --- a/lexicons/place/stream/defs.json +++ b/lexicons/place/stream/defs.json @@ -32,6 +32,37 @@ "properties": { "name": { "type": "string" } } + }, + "activityGame": { + "type": "object", + "description": "A game from the gamesgamesgamesgames catalog, identified by its AT URI.", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "name": { + "type": "string", + "description": "Cached display name of the game." + } + } + }, + "activityLabel": { + "type": "object", + "description": "A non-game activity with a well-known label.", + "required": ["label"], + "properties": { + "label": { + "type": "string", + "knownValues": [ + "just_chatting", + "music", + "art", + "programming", + "cooking", + "fitness", + "sports" + ] + } + } } } } diff --git a/lexicons/place/stream/game/search.json b/lexicons/place/stream/game/search.json new file mode 100644 index 000000000..82197c2d9 --- /dev/null +++ b/lexicons/place/stream/game/search.json @@ -0,0 +1,54 @@ +{ + "lexicon": 1, + "id": "place.stream.game.search", + "defs": { + "main": { + "type": "query", + "description": "Search for games and other entities via the games.gamesgamesgamesgames catalog. Proxied from the configured games API.", + "parameters": { + "type": "params", + "required": ["q"], + "properties": { + "q": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "cursor": { + "type": "string" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["results"], + "properties": { + "cursor": { "type": "string" }, + "totalResults": { "type": "integer" }, + "results": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "games.gamesgamesgamesgames.defs#gameSummaryView", + "games.gamesgamesgamesgames.defs#profileSummaryView", + "games.gamesgamesgamesgames.defs#platformSummaryView", + "games.gamesgamesgamesgames.defs#collectionSummaryView", + "games.gamesgamesgamesgames.defs#engineSummaryView" + ] + } + } + } + } + } + } + } +} diff --git a/lexicons/place/stream/livestream.json b/lexicons/place/stream/livestream.json index 19b73cf3f..7f67b62bc 100644 --- a/lexicons/place/stream/livestream.json +++ b/lexicons/place/stream/livestream.json @@ -62,6 +62,24 @@ "notificationSettings": { "type": "ref", "ref": "place.stream.livestream#notificationSettings" + }, + "activity": { + "type": "union", + "description": "The game or activity being streamed.", + "refs": [ + "place.stream.defs#activityGame", + "place.stream.defs#activityLabel" + ] + }, + "tags": { + "type": "array", + "description": "Freeform tags for this stream.", + "maxLength": 10, + "items": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + } } } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 96ee1d438..64c882796 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -156,6 +156,7 @@ type CLI struct { S3Region string DisableSyndication bool LegacySegmentation bool + GamesAPIURL string } // ContentFilters represents the content filtering configuration @@ -687,6 +688,12 @@ func (cli *CLI) NewCommand(name string) *urfavecli.Command { Destination: &cli.PlaybackWorkerURL, Sources: urfavecli.EnvVars("SP_PLAYBACK_WORKER_URL"), }, + &urfavecli.StringFlag{ + Name: "games-api-url", + Usage: "URL of the games.gamesgamesgamesgames API (e.g. http://localhost:3001)", + Destination: &cli.GamesAPIURL, + Sources: urfavecli.EnvVars("SP_GAMES_API_URL"), + }, &urfavecli.BoolFlag{ Name: "livepeer-debug", Usage: "log livepeer segments to $SP_DATA_DIR/livepeer-debug", diff --git a/pkg/gamesgamesgamesgames/gamesgamesgamesgamesdefs.go b/pkg/gamesgamesgamesgames/gamesgamesgamesgamesdefs.go new file mode 100644 index 000000000..79609f77d --- /dev/null +++ b/pkg/gamesgamesgamesgames/gamesgamesgamesgamesdefs.go @@ -0,0 +1,355 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: games.gamesgamesgamesgames.defs + +package gamesgamesgamesgames + +import ( + appbsky "github.com/bluesky-social/indigo/api/bsky" + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// Defs_ActorCreditView is a "actorCreditView" in the games.gamesgamesgamesgames.defs schema. +type Defs_ActorCreditView struct { + ActorUri *string `json:"actorUri,omitempty" cborgen:"actorUri,omitempty"` + Credits []*Defs_CreditEntry `json:"credits" cborgen:"credits"` + DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_ActorProfileDetailView is a "actorProfileDetailView" in the games.gamesgamesgamesgames.defs schema. +type Defs_ActorProfileDetailView struct { + Avatar *lexutil.LexBlob `json:"avatar,omitempty" cborgen:"avatar,omitempty"` + CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"` + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + DescriptionFacets []*appbsky.RichtextFacet `json:"descriptionFacets,omitempty" cborgen:"descriptionFacets,omitempty"` + Did string `json:"did" cborgen:"did"` + DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"` + Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` + Uri string `json:"uri" cborgen:"uri"` + Websites []*Defs_Website `json:"websites,omitempty" cborgen:"websites,omitempty"` +} + +// Defs_ActorProfileSummaryView is a "actorProfileSummaryView" in the games.gamesgamesgamesgames.defs schema. +type Defs_ActorProfileSummaryView struct { + Avatar *lexutil.LexBlob `json:"avatar,omitempty" cborgen:"avatar,omitempty"` + Did string `json:"did" cborgen:"did"` + DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_AgeRating is a "ageRating" in the games.gamesgamesgamesgames.defs schema. +type Defs_AgeRating struct { + ContentDescriptors []string `json:"contentDescriptors,omitempty" cborgen:"contentDescriptors,omitempty"` + Organization string `json:"organization" cborgen:"organization"` + Rating string `json:"rating" cborgen:"rating"` +} + +// Defs_AlternativeName is a "alternativeName" in the games.gamesgamesgamesgames.defs schema. +type Defs_AlternativeName struct { + Comment *string `json:"comment,omitempty" cborgen:"comment,omitempty"` + Locale *string `json:"locale,omitempty" cborgen:"locale,omitempty"` + Name string `json:"name" cborgen:"name"` +} + +// Defs_CollectionSummaryView is a "collectionSummaryView" in the games.gamesgamesgamesgames.defs schema. +type Defs_CollectionSummaryView struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=games.gamesgamesgamesgames.defs#collectionSummaryView"` + Name string `json:"name" cborgen:"name"` + Slug *string `json:"slug,omitempty" cborgen:"slug,omitempty"` + Type *string `json:"type,omitempty" cborgen:"type,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_CreditEntry is a "creditEntry" in the games.gamesgamesgamesgames.defs schema. +type Defs_CreditEntry struct { + Department *string `json:"department,omitempty" cborgen:"department,omitempty"` + Role *string `json:"role" cborgen:"role"` +} + +// Defs_EngineSummaryView is a "engineSummaryView" in the games.gamesgamesgamesgames.defs schema. +type Defs_EngineSummaryView struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=games.gamesgamesgamesgames.defs#engineSummaryView"` + Name string `json:"name" cborgen:"name"` + Slug *string `json:"slug,omitempty" cborgen:"slug,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_ExternalIds is a "externalIds" in the games.gamesgamesgamesgames.defs schema. +type Defs_ExternalIds struct { + AppleAppStore *string `json:"appleAppStore,omitempty" cborgen:"appleAppStore,omitempty"` + EpicGames *string `json:"epicGames,omitempty" cborgen:"epicGames,omitempty"` + Gog *string `json:"gog,omitempty" cborgen:"gog,omitempty"` + GooglePlay *string `json:"googlePlay,omitempty" cborgen:"googlePlay,omitempty"` + HumbleBundle *string `json:"humbleBundle,omitempty" cborgen:"humbleBundle,omitempty"` + Igdb *string `json:"igdb,omitempty" cborgen:"igdb,omitempty"` + ItchIo *Defs_ItchIoId `json:"itchIo,omitempty" cborgen:"itchIo,omitempty"` + NintendoEshop *string `json:"nintendoEshop,omitempty" cborgen:"nintendoEshop,omitempty"` + PlayStation *string `json:"playStation,omitempty" cborgen:"playStation,omitempty"` + Steam *string `json:"steam,omitempty" cborgen:"steam,omitempty"` + Twitch *string `json:"twitch,omitempty" cborgen:"twitch,omitempty"` + Xbox *string `json:"xbox,omitempty" cborgen:"xbox,omitempty"` +} + +// Defs_ExternalVideo is a "externalVideo" in the games.gamesgamesgamesgames.defs schema. +type Defs_ExternalVideo struct { + Platform string `json:"platform" cborgen:"platform"` + Title *string `json:"title,omitempty" cborgen:"title,omitempty"` + VideoId string `json:"videoId" cborgen:"videoId"` +} + +// Defs_GameDetailView is a "gameDetailView" in the games.gamesgamesgamesgames.defs schema. +type Defs_GameDetailView struct { + ActorCredits []*Defs_ActorCreditView `json:"actorCredits,omitempty" cborgen:"actorCredits,omitempty"` + AgeRatings []*Defs_AgeRating `json:"ageRatings,omitempty" cborgen:"ageRatings,omitempty"` + AlternativeNames []*Defs_AlternativeName `json:"alternativeNames,omitempty" cborgen:"alternativeNames,omitempty"` + ApplicationType *string `json:"applicationType,omitempty" cborgen:"applicationType,omitempty"` + Collections []string `json:"collections,omitempty" cborgen:"collections,omitempty"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + Engines []string `json:"engines,omitempty" cborgen:"engines,omitempty"` + ExternalIds *Defs_ExternalIds `json:"externalIds,omitempty" cborgen:"externalIds,omitempty"` + Genres []*string `json:"genres,omitempty" cborgen:"genres,omitempty"` + Keywords []string `json:"keywords,omitempty" cborgen:"keywords,omitempty"` + LanguageSupports []*Defs_LanguageSupport `json:"languageSupports,omitempty" cborgen:"languageSupports,omitempty"` + Media []*Defs_MediaItem `json:"media,omitempty" cborgen:"media,omitempty"` + Modes []*string `json:"modes,omitempty" cborgen:"modes,omitempty"` + MultiplayerModes []*Defs_MultiplayerMode `json:"multiplayerModes,omitempty" cborgen:"multiplayerModes,omitempty"` + Name string `json:"name" cborgen:"name"` + OrgCredits []*Defs_OrgCreditView `json:"orgCredits,omitempty" cborgen:"orgCredits,omitempty"` + Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` + PlayerPerspectives []*string `json:"playerPerspectives,omitempty" cborgen:"playerPerspectives,omitempty"` + PublishedAt *string `json:"publishedAt,omitempty" cborgen:"publishedAt,omitempty"` + Releases []*Defs_Release `json:"releases,omitempty" cborgen:"releases,omitempty"` + Slug *string `json:"slug,omitempty" cborgen:"slug,omitempty"` + Storyline *string `json:"storyline,omitempty" cborgen:"storyline,omitempty"` + Summary *string `json:"summary,omitempty" cborgen:"summary,omitempty"` + Themes []*string `json:"themes,omitempty" cborgen:"themes,omitempty"` + TimeToBeat *Defs_TimeToBeat `json:"timeToBeat,omitempty" cborgen:"timeToBeat,omitempty"` + Uri string `json:"uri" cborgen:"uri"` + Videos []*Defs_ExternalVideo `json:"videos,omitempty" cborgen:"videos,omitempty"` + Websites []*Defs_Website `json:"websites,omitempty" cborgen:"websites,omitempty"` +} + +// Defs_GameFeedViewItem is a "gameFeedViewItem" in the games.gamesgamesgamesgames.defs schema. +type Defs_GameFeedViewItem struct { + FeedContext *string `json:"feedContext,omitempty" cborgen:"feedContext,omitempty"` + Game *Defs_GameView `json:"game" cborgen:"game"` +} + +// Defs_GameSummaryView is a "gameSummaryView" in the games.gamesgamesgamesgames.defs schema. +type Defs_GameSummaryView struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=games.gamesgamesgamesgames.defs#gameSummaryView"` + ApplicationType *string `json:"applicationType,omitempty" cborgen:"applicationType,omitempty"` + // firstReleaseDate: Earliest release date as YYYYMMDD integer. + FirstReleaseDate *int64 `json:"firstReleaseDate,omitempty" cborgen:"firstReleaseDate,omitempty"` + Genres []*string `json:"genres,omitempty" cborgen:"genres,omitempty"` + Media []*Defs_MediaItem `json:"media,omitempty" cborgen:"media,omitempty"` + Name string `json:"name" cborgen:"name"` + Slug *string `json:"slug,omitempty" cborgen:"slug,omitempty"` + Summary *string `json:"summary,omitempty" cborgen:"summary,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_GameView is a "gameView" in the games.gamesgamesgamesgames.defs schema. +type Defs_GameView struct { + ApplicationType *string `json:"applicationType" cborgen:"applicationType"` + Genres []*string `json:"genres,omitempty" cborgen:"genres,omitempty"` + LikeCount *int64 `json:"likeCount,omitempty" cborgen:"likeCount,omitempty"` + Media []*Defs_MediaItem `json:"media,omitempty" cborgen:"media,omitempty"` + Name string `json:"name" cborgen:"name"` + Releases []*Defs_Release `json:"releases,omitempty" cborgen:"releases,omitempty"` + Slug *string `json:"slug,omitempty" cborgen:"slug,omitempty"` + Summary *string `json:"summary,omitempty" cborgen:"summary,omitempty"` + Themes []*string `json:"themes,omitempty" cborgen:"themes,omitempty"` + Uri string `json:"uri" cborgen:"uri"` + Viewer *Defs_ViewerState `json:"viewer,omitempty" cborgen:"viewer,omitempty"` +} + +// Defs_ItchIoId is a "itchIoId" in the games.gamesgamesgamesgames.defs schema. +type Defs_ItchIoId struct { + Developer string `json:"developer" cborgen:"developer"` + Game string `json:"game" cborgen:"game"` +} + +// Defs_LanguageSupport is a "languageSupport" in the games.gamesgamesgamesgames.defs schema. +type Defs_LanguageSupport struct { + Audio *bool `json:"audio,omitempty" cborgen:"audio,omitempty"` + Interface *bool `json:"interface,omitempty" cborgen:"interface,omitempty"` + Language string `json:"language" cborgen:"language"` + Subtitles *bool `json:"subtitles,omitempty" cborgen:"subtitles,omitempty"` +} + +// Defs_MediaItem is a "mediaItem" in the games.gamesgamesgamesgames.defs schema. +type Defs_MediaItem struct { + Blob *lexutil.LexBlob `json:"blob,omitempty" cborgen:"blob,omitempty"` + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + Height *int64 `json:"height,omitempty" cborgen:"height,omitempty"` + Locale *string `json:"locale,omitempty" cborgen:"locale,omitempty"` + MediaType *string `json:"mediaType,omitempty" cborgen:"mediaType,omitempty"` + Title *string `json:"title,omitempty" cborgen:"title,omitempty"` + Width *int64 `json:"width,omitempty" cborgen:"width,omitempty"` +} + +// Defs_MultiplayerMode is a "multiplayerMode" in the games.gamesgamesgamesgames.defs schema. +type Defs_MultiplayerMode struct { + HasCampaignCoop *bool `json:"hasCampaignCoop,omitempty" cborgen:"hasCampaignCoop,omitempty"` + HasDropIn *bool `json:"hasDropIn,omitempty" cborgen:"hasDropIn,omitempty"` + HasLanCoop *bool `json:"hasLanCoop,omitempty" cborgen:"hasLanCoop,omitempty"` + HasSplitscreen *bool `json:"hasSplitscreen,omitempty" cborgen:"hasSplitscreen,omitempty"` + HasSplitscreenOnline *bool `json:"hasSplitscreenOnline,omitempty" cborgen:"hasSplitscreenOnline,omitempty"` + OfflineCoopMax *int64 `json:"offlineCoopMax,omitempty" cborgen:"offlineCoopMax,omitempty"` + OfflineMax *int64 `json:"offlineMax,omitempty" cborgen:"offlineMax,omitempty"` + OnlineCoopMax *int64 `json:"onlineCoopMax,omitempty" cborgen:"onlineCoopMax,omitempty"` + OnlineMax *int64 `json:"onlineMax,omitempty" cborgen:"onlineMax,omitempty"` + Platform *string `json:"platform,omitempty" cborgen:"platform,omitempty"` +} + +// Defs_OrgCreditView is a "orgCreditView" in the games.gamesgamesgamesgames.defs schema. +type Defs_OrgCreditView struct { + DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"` + OrgUri *string `json:"orgUri,omitempty" cborgen:"orgUri,omitempty"` + Roles []*string `json:"roles" cborgen:"roles"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_OrgProfileDetailView is a "orgProfileDetailView" in the games.gamesgamesgamesgames.defs schema. +type Defs_OrgProfileDetailView struct { + Avatar *lexutil.LexBlob `json:"avatar,omitempty" cborgen:"avatar,omitempty"` + Country *string `json:"country,omitempty" cborgen:"country,omitempty"` + CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"` + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + DescriptionFacets []*appbsky.RichtextFacet `json:"descriptionFacets,omitempty" cborgen:"descriptionFacets,omitempty"` + Did string `json:"did" cborgen:"did"` + DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"` + FoundedAt *string `json:"foundedAt,omitempty" cborgen:"foundedAt,omitempty"` + Media []*Defs_MediaItem `json:"media,omitempty" cborgen:"media,omitempty"` + Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` + Status *string `json:"status,omitempty" cborgen:"status,omitempty"` + Uri string `json:"uri" cborgen:"uri"` + Websites []*Defs_Website `json:"websites,omitempty" cborgen:"websites,omitempty"` +} + +// Defs_OrgProfileSummaryView is a "orgProfileSummaryView" in the games.gamesgamesgamesgames.defs schema. +type Defs_OrgProfileSummaryView struct { + Avatar *lexutil.LexBlob `json:"avatar,omitempty" cborgen:"avatar,omitempty"` + Did string `json:"did" cborgen:"did"` + DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_PlatformFeatures is a "platformFeatures" in the games.gamesgamesgamesgames.defs schema. +// +// Features supported by a game on a specific storefront/platform. +type Defs_PlatformFeatures struct { + Features []string `json:"features" cborgen:"features"` + Platform string `json:"platform" cborgen:"platform"` +} + +// Defs_PlatformSummaryView is a "platformSummaryView" in the games.gamesgamesgamesgames.defs schema. +type Defs_PlatformSummaryView struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=games.gamesgamesgamesgames.defs#platformSummaryView"` + Abbreviation *string `json:"abbreviation,omitempty" cborgen:"abbreviation,omitempty"` + Category *string `json:"category,omitempty" cborgen:"category,omitempty"` + Name string `json:"name" cborgen:"name"` + Slug *string `json:"slug,omitempty" cborgen:"slug,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_PlatformVersion is a "platformVersion" in the games.gamesgamesgamesgames.defs schema. +type Defs_PlatformVersion struct { + Connectivity *string `json:"connectivity,omitempty" cborgen:"connectivity,omitempty"` + Cpu *string `json:"cpu,omitempty" cborgen:"cpu,omitempty"` + Gpu *string `json:"gpu,omitempty" cborgen:"gpu,omitempty"` + MaxResolution *string `json:"maxResolution,omitempty" cborgen:"maxResolution,omitempty"` + Media []*Defs_MediaItem `json:"media,omitempty" cborgen:"media,omitempty"` + Memory *string `json:"memory,omitempty" cborgen:"memory,omitempty"` + Name string `json:"name" cborgen:"name"` + Os *string `json:"os,omitempty" cborgen:"os,omitempty"` + Output *string `json:"output,omitempty" cborgen:"output,omitempty"` + Storage *string `json:"storage,omitempty" cborgen:"storage,omitempty"` + Summary *string `json:"summary,omitempty" cborgen:"summary,omitempty"` +} + +// Defs_ProfileSummaryView is a "profileSummaryView" in the games.gamesgamesgamesgames.defs schema. +type Defs_ProfileSummaryView struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=games.gamesgamesgamesgames.defs#profileSummaryView"` + Avatar *lexutil.LexBlob `json:"avatar,omitempty" cborgen:"avatar,omitempty"` + Did string `json:"did" cborgen:"did"` + DisplayName *string `json:"displayName,omitempty" cborgen:"displayName,omitempty"` + ProfileType string `json:"profileType" cborgen:"profileType"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_Release is a "release" in the games.gamesgamesgamesgames.defs schema. +type Defs_Release struct { + // platform: Free-text platform name, used when no platform record exists. + Platform *string `json:"platform,omitempty" cborgen:"platform,omitempty"` + // platformUri: AT URI of a platform record. + PlatformUri *string `json:"platformUri,omitempty" cborgen:"platformUri,omitempty"` + ReleaseDates []*Defs_ReleaseDate `json:"releaseDates,omitempty" cborgen:"releaseDates,omitempty"` +} + +// Defs_ReleaseDate is a "releaseDate" in the games.gamesgamesgamesgames.defs schema. +type Defs_ReleaseDate struct { + Region *string `json:"region,omitempty" cborgen:"region,omitempty"` + ReleasedAt *string `json:"releasedAt,omitempty" cborgen:"releasedAt,omitempty"` + ReleasedAtFormat *string `json:"releasedAtFormat,omitempty" cborgen:"releasedAtFormat,omitempty"` + Status *string `json:"status,omitempty" cborgen:"status,omitempty"` +} + +// Defs_Signature is a "signature" in the games.gamesgamesgamesgames.defs schema. +// +// An inline attestation signature. +type Defs_Signature struct { + // key: DID key reference (e.g., did:web:example.com#signing1). + Key string `json:"key" cborgen:"key"` + // signature: The signature bytes. + Signature lexutil.LexBytes `json:"signature,omitempty" cborgen:"signature,omitempty"` +} + +// Defs_SkeletonGameFeedItem is a "skeletonGameFeedItem" in the games.gamesgamesgamesgames.defs schema. +type Defs_SkeletonGameFeedItem struct { + FeedContext *string `json:"feedContext,omitempty" cborgen:"feedContext,omitempty"` + Game string `json:"game" cborgen:"game"` +} + +// Defs_SystemRequirements is a "systemRequirements" in the games.gamesgamesgamesgames.defs schema. +// +// System requirements for a game on a specific platform. +type Defs_SystemRequirements struct { + Minimum *Defs_SystemSpec `json:"minimum,omitempty" cborgen:"minimum,omitempty"` + Platform string `json:"platform" cborgen:"platform"` + Recommended *Defs_SystemSpec `json:"recommended,omitempty" cborgen:"recommended,omitempty"` +} + +// Defs_SystemSpec is a "systemSpec" in the games.gamesgamesgamesgames.defs schema. +// +// Hardware/software specification for a platform. +type Defs_SystemSpec struct { + AdditionalNotes *string `json:"additionalNotes,omitempty" cborgen:"additionalNotes,omitempty"` + Directx *string `json:"directx,omitempty" cborgen:"directx,omitempty"` + Graphics *string `json:"graphics,omitempty" cborgen:"graphics,omitempty"` + Memory *string `json:"memory,omitempty" cborgen:"memory,omitempty"` + Os *string `json:"os,omitempty" cborgen:"os,omitempty"` + Processor *string `json:"processor,omitempty" cborgen:"processor,omitempty"` + SoundCard *string `json:"soundCard,omitempty" cborgen:"soundCard,omitempty"` + Storage *string `json:"storage,omitempty" cborgen:"storage,omitempty"` +} + +// Defs_TimeToBeat is a "timeToBeat" in the games.gamesgamesgamesgames.defs schema. +type Defs_TimeToBeat struct { + Completely *int64 `json:"completely,omitempty" cborgen:"completely,omitempty"` + Hastily *int64 `json:"hastily,omitempty" cborgen:"hastily,omitempty"` + Normally *int64 `json:"normally,omitempty" cborgen:"normally,omitempty"` +} + +// Defs_ViewerState is a "viewerState" in the games.gamesgamesgamesgames.defs schema. +type Defs_ViewerState struct { + Like *string `json:"like,omitempty" cborgen:"like,omitempty"` +} + +// Defs_Website is a "website" in the games.gamesgamesgamesgames.defs schema. +type Defs_Website struct { + Type *string `json:"type,omitempty" cborgen:"type,omitempty"` + Url string `json:"url" cborgen:"url"` +} diff --git a/pkg/gamesgamesgamesgames/gamesgamesgamesgamessearch.go b/pkg/gamesgamesgamesgames/gamesgamesgamesgamessearch.go new file mode 100644 index 000000000..29b828015 --- /dev/null +++ b/pkg/gamesgamesgamesgames/gamesgamesgamesgamessearch.go @@ -0,0 +1,133 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: games.gamesgamesgamesgames.search + +package gamesgamesgamesgames + +import ( + "context" + "encoding/json" + "fmt" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// Search_Output is the output of a games.gamesgamesgamesgames.search call. +type Search_Output struct { + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` + Results []*Search_Output_Results_Elem `json:"results" cborgen:"results"` + // totalResults: Total number of results matching the query above the relevance threshold. + TotalResults *int64 `json:"totalResults,omitempty" cborgen:"totalResults,omitempty"` +} + +type Search_Output_Results_Elem struct { + Defs_GameSummaryView *Defs_GameSummaryView + Defs_ProfileSummaryView *Defs_ProfileSummaryView + Defs_PlatformSummaryView *Defs_PlatformSummaryView + Defs_CollectionSummaryView *Defs_CollectionSummaryView + Defs_EngineSummaryView *Defs_EngineSummaryView +} + +func (t *Search_Output_Results_Elem) MarshalJSON() ([]byte, error) { + if t.Defs_GameSummaryView != nil { + t.Defs_GameSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#gameSummaryView" + return json.Marshal(t.Defs_GameSummaryView) + } + if t.Defs_ProfileSummaryView != nil { + t.Defs_ProfileSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#profileSummaryView" + return json.Marshal(t.Defs_ProfileSummaryView) + } + if t.Defs_PlatformSummaryView != nil { + t.Defs_PlatformSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#platformSummaryView" + return json.Marshal(t.Defs_PlatformSummaryView) + } + if t.Defs_CollectionSummaryView != nil { + t.Defs_CollectionSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#collectionSummaryView" + return json.Marshal(t.Defs_CollectionSummaryView) + } + if t.Defs_EngineSummaryView != nil { + t.Defs_EngineSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#engineSummaryView" + return json.Marshal(t.Defs_EngineSummaryView) + } + return nil, fmt.Errorf("can not marshal empty union as JSON") +} + +func (t *Search_Output_Results_Elem) UnmarshalJSON(b []byte) error { + typ, err := lexutil.TypeExtract(b) + if err != nil { + return err + } + + switch typ { + case "games.gamesgamesgamesgames.defs#gameSummaryView": + t.Defs_GameSummaryView = new(Defs_GameSummaryView) + return json.Unmarshal(b, t.Defs_GameSummaryView) + case "games.gamesgamesgamesgames.defs#profileSummaryView": + t.Defs_ProfileSummaryView = new(Defs_ProfileSummaryView) + return json.Unmarshal(b, t.Defs_ProfileSummaryView) + case "games.gamesgamesgamesgames.defs#platformSummaryView": + t.Defs_PlatformSummaryView = new(Defs_PlatformSummaryView) + return json.Unmarshal(b, t.Defs_PlatformSummaryView) + case "games.gamesgamesgamesgames.defs#collectionSummaryView": + t.Defs_CollectionSummaryView = new(Defs_CollectionSummaryView) + return json.Unmarshal(b, t.Defs_CollectionSummaryView) + case "games.gamesgamesgamesgames.defs#engineSummaryView": + t.Defs_EngineSummaryView = new(Defs_EngineSummaryView) + return json.Unmarshal(b, t.Defs_EngineSummaryView) + default: + return nil + } +} + +// Search calls the XRPC method "games.gamesgamesgamesgames.search". +// +// ageRatings: Filter game results by age rating in 'organization:rating' format (e.g. esrb:M, pegi:Eighteen). +// applicationTypes: Filter game results by application type (e.g. game, dlc, bundle). Only applies when types includes game. +// includeUnrated: When age rating filters are active, also include games with no age ratings. +func Search(ctx context.Context, c lexutil.LexClient, ageRatings []string, applicationTypes []string, cursor string, genres []string, includeCancelled bool, includeUnrated bool, limit int64, modes []string, playerPerspectives []string, q string, sort string, themes []string, types []string) (*Search_Output, error) { + var out Search_Output + + params := map[string]interface{}{} + if len(ageRatings) != 0 { + params["ageRatings"] = ageRatings + } + if len(applicationTypes) != 0 { + params["applicationTypes"] = applicationTypes + } + if cursor != "" { + params["cursor"] = cursor + } + if len(genres) != 0 { + params["genres"] = genres + } + if includeCancelled { + params["includeCancelled"] = includeCancelled + } + if includeUnrated { + params["includeUnrated"] = includeUnrated + } + if limit != 0 { + params["limit"] = limit + } + if len(modes) != 0 { + params["modes"] = modes + } + if len(playerPerspectives) != 0 { + params["playerPerspectives"] = playerPerspectives + } + params["q"] = q + if sort != "" { + params["sort"] = sort + } + if len(themes) != 0 { + params["themes"] = themes + } + if len(types) != 0 { + params["types"] = types + } + if err := c.LexDo(ctx, lexutil.Query, "", "games.gamesgamesgamesgames.search", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go index c7d892502..1a0548b1c 100644 --- a/pkg/gen/gen.go +++ b/pkg/gen/gen.go @@ -42,6 +42,8 @@ func main() { streamplace.LiveViewerCount{}, streamplace.BadgeDef{}, streamplace.BadgeIssuance{}, + streamplace.Defs_ActivityGame{}, + streamplace.Defs_ActivityLabel{}, ); err != nil { panic(err) } diff --git a/pkg/gen/gen_stubs.go b/pkg/gen/gen_stubs.go new file mode 100644 index 000000000..a9b0cc84e --- /dev/null +++ b/pkg/gen/gen_stubs.go @@ -0,0 +1,28 @@ +//go:build ignore + +package main + +import "os" + +// gen_stubs.go writes temporary bootstrap stubs that allow pkg/streamplace to +// compile before cbor_gen.go exists. These are needed because lexgen generates +// union marshal code (e.g. Livestream_Activity.MarshalCBOR) that calls CBOR +// methods on variant types (Defs_ActivityGame, Defs_ActivityLabel), but those +// methods are only generated by gen.go -- which itself needs the package to +// compile. The stubs break this cycle; gen.go overwrites them in cbor_gen.go +// and the stub file is deleted before the next compilation. +const stubs = `package streamplace + +import "io" + +func (t *Defs_ActivityGame) MarshalCBOR(w io.Writer) error { return nil } +func (t *Defs_ActivityGame) UnmarshalCBOR(r io.Reader) error { return nil } +func (t *Defs_ActivityLabel) MarshalCBOR(w io.Writer) error { return nil } +func (t *Defs_ActivityLabel) UnmarshalCBOR(r io.Reader) error { return nil } +` + +func main() { + if err := os.WriteFile("pkg/streamplace/cbor_stubs.go", []byte(stubs), 0644); err != nil { + panic(err) + } +} diff --git a/pkg/spxrpc/place_stream_game.go b/pkg/spxrpc/place_stream_game.go new file mode 100644 index 000000000..5c5bf5ce4 --- /dev/null +++ b/pkg/spxrpc/place_stream_game.go @@ -0,0 +1,64 @@ +package spxrpc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/labstack/echo/v4" + placestream "stream.place/streamplace/pkg/streamplace" +) + +func (s *Server) handlePlaceStreamGameSearch(ctx context.Context, cursor string, limit int, q string) (*placestream.GameSearch_Output, error) { + if s.cli.GamesAPIURL == "" { + return nil, echo.NewHTTPError(http.StatusServiceUnavailable, "games API not configured") + } + + if limit <= 0 { + limit = 20 + } + + cacheKey := fmt.Sprintf("game_search:%s:%d:%s", q, limit, cursor) + if cached, found := s.GameSearchCache.Get(cacheKey); found { + return cached.(*placestream.GameSearch_Output), nil + } + + params := url.Values{} + params.Set("q", q) + params.Set("limit", fmt.Sprintf("%d", limit)) + if cursor != "" { + params.Set("cursor", cursor) + } + + reqURL := s.cli.GamesAPIURL + "/xrpc/games.gamesgamesgamesgames.search?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to build games request") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadGateway, "games API unreachable") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("games API returned %d", resp.StatusCode)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadGateway, "failed to read games response") + } + + var out placestream.GameSearch_Output + if err := json.Unmarshal(body, &out); err != nil { + return nil, echo.NewHTTPError(http.StatusBadGateway, "failed to parse games response") + } + + s.GameSearchCache.SetDefault(cacheKey, &out) + return &out, nil +} diff --git a/pkg/spxrpc/spxrpc.go b/pkg/spxrpc/spxrpc.go index 4c8652c8f..4061bee5a 100644 --- a/pkg/spxrpc/spxrpc.go +++ b/pkg/spxrpc/spxrpc.go @@ -26,35 +26,37 @@ import ( ) type Server struct { - e *echo.Echo - cli *config.CLI - model model.Model - OGImageCache *cache.Cache - LiveUsersCache *cache.Cache - ATSync *atproto.ATProtoSynchronizer - statefulDB *statedb.StatefulDB - bus *bus.Bus - op *oatproxy.OATProxy - localDB localdb.LocalDB - mm *media.MediaManager - aliases map[string]string + e *echo.Echo + cli *config.CLI + model model.Model + OGImageCache *cache.Cache + LiveUsersCache *cache.Cache + GameSearchCache *cache.Cache + ATSync *atproto.ATProtoSynchronizer + statefulDB *statedb.StatefulDB + bus *bus.Bus + op *oatproxy.OATProxy + localDB localdb.LocalDB + mm *media.MediaManager + aliases map[string]string } func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus, ldb localdb.LocalDB, mm *media.MediaManager, aliases map[string]string) (*Server, error) { e := echo.New() s := &Server{ - e: e, - cli: cli, - model: model, - OGImageCache: cache.New(5*time.Minute, 10*time.Minute), // 5min TTL, 10min cleanup - LiveUsersCache: cache.New(5*time.Second, 10*time.Second), // 5sec TTL, 10sec cleanup - ATSync: atsync, - statefulDB: statefulDB, - bus: bus, - op: op, - localDB: ldb, - mm: mm, - aliases: aliases, + e: e, + cli: cli, + model: model, + OGImageCache: cache.New(5*time.Minute, 10*time.Minute), + LiveUsersCache: cache.New(5*time.Second, 10*time.Second), + GameSearchCache: cache.New(60*time.Second, 2*time.Minute), + ATSync: atsync, + statefulDB: statefulDB, + bus: bus, + op: op, + localDB: ldb, + mm: mm, + aliases: aliases, } e.Use(s.ErrorHandlingMiddleware()) e.Use(s.ContextPreservingMiddleware()) diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index 2cf83fe6b..65704a8df 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -277,6 +277,10 @@ func (s *Server) HandleComAtprotoSyncListRepos(c echo.Context) error { return c.JSON(200, out) } +func (s *Server) RegisterHandlersGamesGamesgamesgamesgames(e *echo.Echo) error { + return nil +} + func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { e.GET("/xrpc/place.stream.badge.getIssuedBadges", s.HandlePlaceStreamBadgeGetIssuedBadges) e.GET("/xrpc/place.stream.badge.getValidBadges", s.HandlePlaceStreamBadgeGetValidBadges) @@ -286,6 +290,7 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { e.POST("/xrpc/place.stream.branding.updateBlob", s.HandlePlaceStreamBrandingUpdateBlob) e.GET("/xrpc/place.stream.broadcast.getBroadcaster", s.HandlePlaceStreamBroadcastGetBroadcaster) e.GET("/xrpc/place.stream.config.getEnv", s.HandlePlaceStreamConfigGetEnv) + e.GET("/xrpc/place.stream.game.search", s.HandlePlaceStreamGameSearch) e.GET("/xrpc/place.stream.graph.getFollowingUser", s.HandlePlaceStreamGraphGetFollowingUser) e.GET("/xrpc/place.stream.ingest.getIngestUrls", s.HandlePlaceStreamIngestGetIngestUrls) e.POST("/xrpc/place.stream.live.denyTeleport", s.HandlePlaceStreamLiveDenyTeleport) @@ -437,6 +442,32 @@ func (s *Server) HandlePlaceStreamConfigGetEnv(c echo.Context) error { return c.JSON(200, out) } +func (s *Server) HandlePlaceStreamGameSearch(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamGameSearch") + defer span.End() + cursor := c.QueryParam("cursor") + + var limit int + if p := c.QueryParam("limit"); p != "" { + var err error + limit, err = strconv.Atoi(p) + if err != nil { + return err + } + } else { + limit = 20 + } + q := c.QueryParam("q") + var out *placestream.GameSearch_Output + var handleErr error + // func (s *Server) handlePlaceStreamGameSearch(ctx context.Context,cursor string,limit int,q string) (*placestream.GameSearch_Output, error) + out, handleErr = s.handlePlaceStreamGameSearch(ctx, cursor, limit, q) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandlePlaceStreamGraphGetFollowingUser(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamGraphGetFollowingUser") defer span.End() diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go index 72f2ff2a5..71dca43b4 100644 --- a/pkg/streamplace/cbor_gen.go +++ b/pkg/streamplace/cbor_gen.go @@ -250,7 +250,11 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 12 + fieldCount := 14 + + if t.Activity == nil { + fieldCount-- + } if t.Agent == nil { fieldCount-- @@ -280,6 +284,10 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.Tags == nil { + fieldCount-- + } + if t.Thumb == nil { fieldCount-- } @@ -343,6 +351,42 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } } + // t.Tags ([]string) (slice) + if t.Tags != nil { + + if len("tags") > 1000000 { + return xerrors.Errorf("Value in field \"tags\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tags"))); err != nil { + return err + } + if _, err := cw.WriteString(string("tags")); err != nil { + return err + } + + if len(t.Tags) > 8192 { + return xerrors.Errorf("Slice value in field t.Tags was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Tags))); err != nil { + return err + } + for _, v := range t.Tags { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + // t.LexiconTypeID (string) (string) if len("$type") > 1000000 { return xerrors.Errorf("Value in field \"$type\" was too long") @@ -468,6 +512,25 @@ func (t *Livestream) MarshalCBOR(w io.Writer) error { } } + // t.Activity (streamplace.Livestream_Activity) (struct) + if t.Activity != nil { + + if len("activity") > 1000000 { + return xerrors.Errorf("Value in field \"activity\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("activity"))); err != nil { + return err + } + if _, err := cw.WriteString(string("activity")); err != nil { + return err + } + + if err := t.Activity.MarshalCBOR(cw); err != nil { + return err + } + } + // t.CreatedAt (string) (string) if len("createdAt") > 1000000 { return xerrors.Errorf("Value in field \"createdAt\" was too long") @@ -690,6 +753,46 @@ func (t *Livestream) UnmarshalCBOR(r io.Reader) (err error) { } } + // t.Tags ([]string) (slice) + case "tags": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Tags: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Tags = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Tags[i] = string(sval) + } + + } + } // t.LexiconTypeID (string) (string) case "$type": @@ -774,6 +877,26 @@ func (t *Livestream) UnmarshalCBOR(r io.Reader) (err error) { t.EndedAt = (*string)(&sval) } } + // t.Activity (streamplace.Livestream_Activity) (struct) + case "activity": + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Activity = new(Livestream_Activity) + if err := t.Activity.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Activity pointer: %w", err) + } + } + + } // t.CreatedAt (string) (string) case "createdAt": @@ -7667,3 +7790,321 @@ func (t *BadgeIssuance) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *Defs_ActivityGame) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 3 + + if t.Name == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Uri (string) (string) + if len("uri") > 1000000 { + return xerrors.Errorf("Value in field \"uri\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("uri"))); err != nil { + return err + } + if _, err := cw.WriteString(string("uri")); err != nil { + return err + } + + if len(t.Uri) > 1000000 { + return xerrors.Errorf("Value in field t.Uri was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Uri))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Uri)); err != nil { + return err + } + + // t.Name (string) (string) + if t.Name != nil { + + if len("name") > 1000000 { + return xerrors.Errorf("Value in field \"name\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { + return err + } + if _, err := cw.WriteString(string("name")); err != nil { + return err + } + + if t.Name == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Name) > 1000000 { + return xerrors.Errorf("Value in field t.Name was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Name))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Name)); err != nil { + return err + } + } + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.defs#activityGame"))); err != nil { + return err + } + if _, err := cw.WriteString(string("place.stream.defs#activityGame")); err != nil { + return err + } + return nil +} + +func (t *Defs_ActivityGame) UnmarshalCBOR(r io.Reader) (err error) { + *t = Defs_ActivityGame{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("Defs_ActivityGame: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 5) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Uri (string) (string) + case "uri": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Uri = string(sval) + } + // t.Name (string) (string) + case "name": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Name = (*string)(&sval) + } + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *Defs_ActivityLabel) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{162}); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.defs#activityLabel"))); err != nil { + return err + } + if _, err := cw.WriteString(string("place.stream.defs#activityLabel")); err != nil { + return err + } + + // t.Label (string) (string) + if len("label") > 1000000 { + return xerrors.Errorf("Value in field \"label\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("label"))); err != nil { + return err + } + if _, err := cw.WriteString(string("label")); err != nil { + return err + } + + if len(t.Label) > 1000000 { + return xerrors.Errorf("Value in field t.Label was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Label))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Label)); err != nil { + return err + } + return nil +} + +func (t *Defs_ActivityLabel) UnmarshalCBOR(r io.Reader) (err error) { + *t = Defs_ActivityLabel{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("Defs_ActivityLabel: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 5) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Label (string) (string) + case "label": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Label = string(sval) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/streamplace/gamesearch.go b/pkg/streamplace/gamesearch.go new file mode 100644 index 000000000..12fd4a7dc --- /dev/null +++ b/pkg/streamplace/gamesearch.go @@ -0,0 +1,99 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.game.search + +package streamplace + +import ( + "context" + "encoding/json" + "fmt" + + lexutil "github.com/bluesky-social/indigo/lex/util" + gamesgamesgamesgamesgames "stream.place/streamplace/pkg/gamesgamesgamesgames" +) + +// GameSearch_Output is the output of a place.stream.game.search call. +type GameSearch_Output struct { + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` + Results []*GameSearch_Output_Results_Elem `json:"results" cborgen:"results"` + TotalResults *int64 `json:"totalResults,omitempty" cborgen:"totalResults,omitempty"` +} + +type GameSearch_Output_Results_Elem struct { + Defs_GameSummaryView *gamesgamesgamesgamesgames.Defs_GameSummaryView + Defs_ProfileSummaryView *gamesgamesgamesgamesgames.Defs_ProfileSummaryView + Defs_PlatformSummaryView *gamesgamesgamesgamesgames.Defs_PlatformSummaryView + Defs_CollectionSummaryView *gamesgamesgamesgamesgames.Defs_CollectionSummaryView + Defs_EngineSummaryView *gamesgamesgamesgamesgames.Defs_EngineSummaryView +} + +func (t *GameSearch_Output_Results_Elem) MarshalJSON() ([]byte, error) { + if t.Defs_GameSummaryView != nil { + t.Defs_GameSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#gameSummaryView" + return json.Marshal(t.Defs_GameSummaryView) + } + if t.Defs_ProfileSummaryView != nil { + t.Defs_ProfileSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#profileSummaryView" + return json.Marshal(t.Defs_ProfileSummaryView) + } + if t.Defs_PlatformSummaryView != nil { + t.Defs_PlatformSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#platformSummaryView" + return json.Marshal(t.Defs_PlatformSummaryView) + } + if t.Defs_CollectionSummaryView != nil { + t.Defs_CollectionSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#collectionSummaryView" + return json.Marshal(t.Defs_CollectionSummaryView) + } + if t.Defs_EngineSummaryView != nil { + t.Defs_EngineSummaryView.LexiconTypeID = "games.gamesgamesgamesgames.defs#engineSummaryView" + return json.Marshal(t.Defs_EngineSummaryView) + } + return nil, fmt.Errorf("can not marshal empty union as JSON") +} + +func (t *GameSearch_Output_Results_Elem) UnmarshalJSON(b []byte) error { + typ, err := lexutil.TypeExtract(b) + if err != nil { + return err + } + + switch typ { + case "games.gamesgamesgamesgames.defs#gameSummaryView": + t.Defs_GameSummaryView = new(gamesgamesgamesgamesgames.Defs_GameSummaryView) + return json.Unmarshal(b, t.Defs_GameSummaryView) + case "games.gamesgamesgamesgames.defs#profileSummaryView": + t.Defs_ProfileSummaryView = new(gamesgamesgamesgamesgames.Defs_ProfileSummaryView) + return json.Unmarshal(b, t.Defs_ProfileSummaryView) + case "games.gamesgamesgamesgames.defs#platformSummaryView": + t.Defs_PlatformSummaryView = new(gamesgamesgamesgamesgames.Defs_PlatformSummaryView) + return json.Unmarshal(b, t.Defs_PlatformSummaryView) + case "games.gamesgamesgamesgames.defs#collectionSummaryView": + t.Defs_CollectionSummaryView = new(gamesgamesgamesgamesgames.Defs_CollectionSummaryView) + return json.Unmarshal(b, t.Defs_CollectionSummaryView) + case "games.gamesgamesgamesgames.defs#engineSummaryView": + t.Defs_EngineSummaryView = new(gamesgamesgamesgamesgames.Defs_EngineSummaryView) + return json.Unmarshal(b, t.Defs_EngineSummaryView) + default: + return nil + } +} + +// GameSearch calls the XRPC method "place.stream.game.search". +func GameSearch(ctx context.Context, c lexutil.LexClient, cursor string, limit int64, q string) (*GameSearch_Output, error) { + var out GameSearch_Output + + params := map[string]interface{}{} + if cursor != "" { + params["cursor"] = cursor + } + if limit != 0 { + params["limit"] = limit + } + params["q"] = q + if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.game.search", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/pkg/streamplace/streamdefs.go b/pkg/streamplace/streamdefs.go index 1224680e2..19a53f78a 100644 --- a/pkg/streamplace/streamdefs.go +++ b/pkg/streamplace/streamdefs.go @@ -8,6 +8,24 @@ import ( appbsky "github.com/bluesky-social/indigo/api/bsky" ) +// Defs_ActivityGame is a "activityGame" in the place.stream.defs schema. +// +// A game from the gamesgamesgamesgames catalog, identified by its AT URI. +type Defs_ActivityGame struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.defs#activityGame"` + // name: Cached display name of the game. + Name *string `json:"name,omitempty" cborgen:"name,omitempty"` + Uri string `json:"uri" cborgen:"uri"` +} + +// Defs_ActivityLabel is a "activityLabel" in the place.stream.defs schema. +// +// A non-game activity with a well-known label. +type Defs_ActivityLabel struct { + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.defs#activityLabel"` + Label string `json:"label" cborgen:"label"` +} + // Defs_BlockView is a "blockView" in the place.stream.defs schema. type Defs_BlockView struct { LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.defs#blockView"` diff --git a/pkg/streamplace/streamlivestream.go b/pkg/streamplace/streamlivestream.go index 11aefb67b..7c81affca 100644 --- a/pkg/streamplace/streamlivestream.go +++ b/pkg/streamplace/streamlivestream.go @@ -5,12 +5,15 @@ package streamplace import ( + "bytes" "encoding/json" "fmt" + "io" comatproto "github.com/bluesky-social/indigo/api/atproto" appbsky "github.com/bluesky-social/indigo/api/bsky" lexutil "github.com/bluesky-social/indigo/lex/util" + cbg "github.com/whyrusleeping/cbor-gen" ) func init() { @@ -19,6 +22,8 @@ func init() { type Livestream struct { LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.livestream"` + // activity: The game or activity being streamed. + Activity *Livestream_Activity `json:"activity,omitempty" cborgen:"activity,omitempty"` // agent: The source of the livestream, if available, in a User Agent format: ` / ` e.g. Streamplace/0.7.5 iOS Agent *string `json:"agent,omitempty" cborgen:"agent,omitempty"` // canonicalUrl: The primary URL where this livestream can be viewed, if available. @@ -33,14 +38,85 @@ type Livestream struct { LastSeenAt *string `json:"lastSeenAt,omitempty" cborgen:"lastSeenAt,omitempty"` NotificationSettings *Livestream_NotificationSettings `json:"notificationSettings,omitempty" cborgen:"notificationSettings,omitempty"` // post: The post that announced this livestream. - Post *comatproto.RepoStrongRef `json:"post,omitempty" cborgen:"post,omitempty"` - Thumb *lexutil.LexBlob `json:"thumb,omitempty" cborgen:"thumb,omitempty"` + Post *comatproto.RepoStrongRef `json:"post,omitempty" cborgen:"post,omitempty"` + // tags: Freeform tags for this stream. + Tags []string `json:"tags,omitempty" cborgen:"tags,omitempty"` + Thumb *lexutil.LexBlob `json:"thumb,omitempty" cborgen:"thumb,omitempty"` // title: The title of the livestream, as it will be announced to followers. Title string `json:"title" cborgen:"title"` // url: The URL where this stream can be found. This is primarily a hint for other Streamplace nodes to locate and replicate the stream. Url *string `json:"url,omitempty" cborgen:"url,omitempty"` } +// The game or activity being streamed. +type Livestream_Activity struct { + Defs_ActivityGame *Defs_ActivityGame + Defs_ActivityLabel *Defs_ActivityLabel +} + +func (t *Livestream_Activity) MarshalJSON() ([]byte, error) { + if t.Defs_ActivityGame != nil { + t.Defs_ActivityGame.LexiconTypeID = "place.stream.defs#activityGame" + return json.Marshal(t.Defs_ActivityGame) + } + if t.Defs_ActivityLabel != nil { + t.Defs_ActivityLabel.LexiconTypeID = "place.stream.defs#activityLabel" + return json.Marshal(t.Defs_ActivityLabel) + } + return nil, fmt.Errorf("can not marshal empty union as JSON") +} + +func (t *Livestream_Activity) UnmarshalJSON(b []byte) error { + typ, err := lexutil.TypeExtract(b) + if err != nil { + return err + } + + switch typ { + case "place.stream.defs#activityGame": + t.Defs_ActivityGame = new(Defs_ActivityGame) + return json.Unmarshal(b, t.Defs_ActivityGame) + case "place.stream.defs#activityLabel": + t.Defs_ActivityLabel = new(Defs_ActivityLabel) + return json.Unmarshal(b, t.Defs_ActivityLabel) + default: + return nil + } +} + +func (t *Livestream_Activity) MarshalCBOR(w io.Writer) error { + + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + if t.Defs_ActivityGame != nil { + return t.Defs_ActivityGame.MarshalCBOR(w) + } + if t.Defs_ActivityLabel != nil { + return t.Defs_ActivityLabel.MarshalCBOR(w) + } + return fmt.Errorf("can not marshal empty union as CBOR") +} + +func (t *Livestream_Activity) UnmarshalCBOR(r io.Reader) error { + typ, b, err := lexutil.CborTypeExtractReader(r) + if err != nil { + return err + } + + switch typ { + case "place.stream.defs#activityGame": + t.Defs_ActivityGame = new(Defs_ActivityGame) + return t.Defs_ActivityGame.UnmarshalCBOR(bytes.NewReader(b)) + case "place.stream.defs#activityLabel": + t.Defs_ActivityLabel = new(Defs_ActivityLabel) + return t.Defs_ActivityLabel.UnmarshalCBOR(bytes.NewReader(b)) + default: + return nil + } +} + // Livestream_LivestreamView is a "livestreamView" in the place.stream.livestream schema. type Livestream_LivestreamView struct { LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.livestream#livestreamView"` diff --git a/util/lexgen-types.json b/util/lexgen-types.json index 14bfad12d..13afdce5e 100644 --- a/util/lexgen-types.json +++ b/util/lexgen-types.json @@ -28,5 +28,11 @@ "prefix": "place.stream", "outdir": "./pkg/streamplace", "import": "stream.place/streamplace" + }, + { + "package": "gamesgamesgamesgames", + "prefix": "games.gamesgamesgamesgames", + "outdir": "./pkg/gamesgamesgamesgames", + "import": "stream.place/streamplace/pkg/gamesgamesgamesgames" } ]