diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index 997f3b22..037351ab 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -18,6 +18,7 @@ import {
useSegmentDimensions,
VideoProvider,
View,
+ VodSection,
} from "@streamplace/components";
import { gap, h, pt, w } from "@streamplace/components/src/lib/theme/atoms";
import { useLiveUser } from "hooks/useLiveUser";
@@ -349,11 +350,20 @@ export function PlayerInner(
}, [props.showUnavailable]);
// content info
- const { width, height } = usePlayerDimensions();
+ const { width: pwidth, height: pheight } = usePlayerDimensions();
+
+ console.log(pwidth, pheight);
+
+ let width = pwidth > 0 && props.mode !== "vod" ? pwidth : 16;
+ let height = pheight > 0 && props.mode !== "vod" ? pheight : 9;
+
+ console.log(width, height);
// Calculate aspect ratio and determine if we're in desktop mode
const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
+ console.log(aspectRatio, 16 / 9);
+
// on mobile we want to hide the sidebar when going fullscreen
useEffect(() => {
if (Platform.OS !== "web" && width > height) {
@@ -386,6 +396,12 @@ export function PlayerInner(
const isPlayerRatioGreater = aspectRatio >= 16 / 9;
+ const { height: windowHeight } = useWindowDimensions();
+ const vodMobileHeight =
+ props.mode === "vod"
+ ? Math.min(contentWidth / aspectRatio, windowHeight * 0.7)
+ : undefined;
+
// animated style for offline height transition
const animatedHeightStyle = useAnimatedStyle(() => {
return {
@@ -408,11 +424,13 @@ export function PlayerInner(
flexGrow: 1, // This makes content expand to fill available space
minHeight: "100%", // Ensures minimum height
}
- : {
- flex: 1,
- }
+ : props.mode === "vod"
+ ? { flexGrow: 1 }
+ : {
+ flex: 1,
+ }
}
- scrollEnabled={showFullDesktopMode}
+ scrollEnabled={showFullDesktopMode || props.mode === "vod"}
bounces={false}
showsVerticalScrollIndicator={false}
>
@@ -422,10 +440,15 @@ export function PlayerInner(
? {
width: calculatedWidth,
}
- : {
- flex: 1,
- maxHeight: "auto",
- },
+ : props.mode === "vod" && vodMobileHeight
+ ? {
+ width: "100%" as any,
+ height: vodMobileHeight,
+ }
+ : {
+ flex: 1,
+ maxHeight: "auto",
+ },
{
paddingTop:
isPlayerRatioGreater && !isLandscape && !props.showUnavailable
@@ -444,6 +467,7 @@ export function PlayerInner(
) : (
(isLandscape || props.mode === "vod") && (
@@ -473,6 +497,7 @@ export function PlayerInner(
showChat={props.showChat}
/>
)}
+ {props.mode === "vod" && }
);
}
diff --git a/js/components/src/components/mobile-player/video-retry.tsx b/js/components/src/components/mobile-player/video-retry.tsx
index a7e461f0..296aa828 100644
--- a/js/components/src/components/mobile-player/video-retry.tsx
+++ b/js/components/src/components/mobile-player/video-retry.tsx
@@ -5,16 +5,15 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
const retryTimeoutRef = useRef(null);
const [retries, setRetries] = useState(0);
const playing = usePlayerStore((x) => x.status === PlayerStatus.PLAYING);
+ const mode = usePlayerStore((x) => x.mode);
+ const status = usePlayerStore((x) => x.status);
const [lastChange, setLastChange] = useState(Date.now());
const [tick, setTick] = useState(0);
- const status = usePlayerStore((x) => x.status);
useEffect(() => {
setLastChange(Date.now());
}, [status]);
- const mode = usePlayerStore((x) => x.mode);
-
useEffect(() => {
if (mode === "vod") return;
if (!playing) {
@@ -28,7 +27,15 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
const stalledFor5sec = !playing && tick - lastChange > 5000;
useEffect(() => {
- if (!playing) {
+ if (stalledFor5sec) {
+ console.log("Stalled for 5 seconds, retrying...");
+ setRetries((r) => r + 1);
+ }
+ }, [stalledFor5sec]);
+
+ useEffect(() => {
+ if (mode === "vod") return;
+ if (!playing && status !== PlayerStatus.PAUSE) {
const doRetry = () => {
console.log("Retrying video playback...");
setRetries((prevRetries) => prevRetries + 1);
@@ -44,14 +51,7 @@ export default function VideoRetry(props: { children: React.ReactNode }) {
retryTimeoutRef.current = null;
}
};
- }, [!playing, mode]);
-
- useEffect(() => {
- if (stalledFor5sec) {
- console.log("Stalled for 5 seconds, retrying...");
- setRetries((r) => r + 1);
- }
- }, [stalledFor5sec]);
+ }, [!playing, mode, status]);
return {props.children};
}
diff --git a/js/components/src/components/vod/vod-comments.tsx b/js/components/src/components/vod/vod-comments.tsx
new file mode 100644
index 00000000..1d7b99de
--- /dev/null
+++ b/js/components/src/components/vod/vod-comments.tsx
@@ -0,0 +1,173 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import type { PlaceStreamVodDefs } from "streamplace";
+import {
+ borders,
+ flex,
+ gap,
+ layout,
+ mb,
+ mt,
+ pt,
+ px,
+ py,
+ r,
+ useTheme,
+} from "../../ui";
+import {
+ useCreateVodComment,
+ useGetVodComments,
+} from "../../vod-store/vod-interactions";
+import { Text } from "../ui/text";
+import { Textarea } from "../ui/textarea";
+
+export function VodComments({ videoUri }: { videoUri: string }) {
+ const [comments, setComments] = useState(
+ [],
+ );
+ const [loading, setLoading] = useState(true);
+ const [draft, setDraft] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [cursor, setCursor] = useState();
+
+ const getComments = useGetVodComments();
+ const createComment = useCreateVodComment();
+ const { theme } = useTheme();
+
+ const loadComments = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await getComments(videoUri, 50, cursor);
+ setComments((prev) =>
+ cursor ? [...prev, ...result.comments] : result.comments,
+ );
+ setCursor(result.cursor);
+ } catch (e) {
+ console.error("Failed to load comments", e);
+ } finally {
+ setLoading(false);
+ }
+ }, [videoUri, cursor, getComments]);
+
+ useEffect(() => {
+ setCursor(undefined);
+ loadComments();
+ }, [videoUri]);
+
+ const handleSubmit = useCallback(async () => {
+ if (!draft.trim()) return;
+ setSubmitting(true);
+ try {
+ await createComment({ text: draft.trim(), video: videoUri });
+ setDraft("");
+ setCursor(undefined);
+ await loadComments();
+ } catch (e) {
+ console.error("Failed to submit comment", e);
+ } finally {
+ setSubmitting(false);
+ }
+ }, [draft, videoUri, createComment, loadComments]);
+
+ const renderComment = useCallback(
+ ({ item }: { item: PlaceStreamVodDefs.CommentView }) => {
+ const record = item.record as any;
+ return (
+
+
+
+ {item.author.handle || item.author.did}
+
+
+ {new Date(item.indexedAt).toLocaleDateString()}
+
+
+ {record.text}
+
+
+ {item.likeCount > 0 ? `${item.likeCount} likes` : ""}
+
+
+
+ );
+ },
+ [theme],
+ );
+
+ return (
+
+
+ Comments
+
+
+ {loading && comments.length === 0 ? (
+
+ ) : (
+ item.uri}
+ onEndReached={cursor ? loadComments : undefined}
+ onEndReachedThreshold={0.5}
+ style={flex.values[1]}
+ />
+ )}
+
+
+
+
+ );
+}
diff --git a/js/components/src/components/vod/vod-like-button.tsx b/js/components/src/components/vod/vod-like-button.tsx
new file mode 100644
index 00000000..3d381661
--- /dev/null
+++ b/js/components/src/components/vod/vod-like-button.tsx
@@ -0,0 +1,77 @@
+import { useCallback, useEffect, useState } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { useDID } from "../../streamplace-store/streamplace-store";
+import { gap, layout, p, useTheme } from "../../ui";
+import {
+ useCreateVodLike,
+ useDeleteVodLike,
+ useGetVodLikes,
+} from "../../vod-store/vod-interactions";
+import { Text } from "../ui/text";
+
+export function VodLikeButton({ subjectUri }: { subjectUri: string }) {
+ const [likeCount, setLikeCount] = useState(0);
+ const [userLiked, setUserLiked] = useState(false);
+ const [userLikeUri, setUserLikeUri] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const userDID = useDID();
+ const getLikes = useGetVodLikes();
+ const createLike = useCreateVodLike();
+ const deleteLike = useDeleteVodLike();
+ const { theme } = useTheme();
+
+ const loadLikes = useCallback(async () => {
+ try {
+ const result = await getLikes(subjectUri, 50);
+ setLikeCount(result.count);
+ if (userDID && result.likes) {
+ const myLike = result.likes.find((l) => l.author.did === userDID);
+ setUserLiked(!!myLike);
+ setUserLikeUri(myLike?.uri || null);
+ }
+ } catch (e) {
+ console.error("Failed to load likes", e);
+ }
+ }, [subjectUri, getLikes, userDID]);
+
+ useEffect(() => {
+ loadLikes();
+ }, [loadLikes]);
+
+ const toggleLike = useCallback(async () => {
+ setLoading(true);
+ try {
+ if (userLiked && userLikeUri) {
+ await deleteLike(userLikeUri);
+ setUserLiked(false);
+ setUserLikeUri(null);
+ setLikeCount((c) => Math.max(0, c - 1));
+ } else {
+ const result = await createLike(subjectUri);
+ setUserLiked(true);
+ setUserLikeUri(result.data.uri);
+ setLikeCount((c) => c + 1);
+ }
+ } catch (e) {
+ console.error("Failed to toggle like", e);
+ } finally {
+ setLoading(false);
+ }
+ }, [userLiked, userLikeUri, subjectUri, createLike, deleteLike]);
+
+ return (
+
+
+
+ {userLiked ? "\u2764\uFE0F" : "\u2661"}
+
+
+ {likeCount}
+
+
+
+ );
+}
diff --git a/js/components/src/components/vod/vod-mobile-metadata.tsx b/js/components/src/components/vod/vod-mobile-metadata.tsx
new file mode 100644
index 00000000..a8fa26b2
--- /dev/null
+++ b/js/components/src/components/vod/vod-mobile-metadata.tsx
@@ -0,0 +1,56 @@
+import { View } from "react-native";
+import { zero } from "../..";
+import { useTitle } from "../../hooks/useTitle";
+import { borders, gap, layout, mb, pb, useTheme } from "../../ui";
+import { useVideoStore } from "../../video-store/video-store";
+import { Text } from "../ui/text";
+import { VodLikeButton } from "./vod-like-button";
+
+export function VodMobileMetadata() {
+ const video = useVideoStore((x) => x.video);
+ const aturi = useVideoStore((x) => x.aturi);
+ const title = useTitle();
+ const { theme } = useTheme();
+
+ if (!video || !aturi) return null;
+
+ const author = video.author;
+ const viewCount = video.viewCounts?.count ?? 0;
+
+ return (
+
+
+
+
+ {title || "Untitled"}
+
+
+ {author.handle ? `@${author.handle}` : author.did}
+
+
+
+
+
+
+
+ {viewCount.toLocaleString()} view{viewCount !== 1 ? "s" : ""}
+
+
+
+ );
+}
diff --git a/js/components/src/components/vod/vod-section.tsx b/js/components/src/components/vod/vod-section.tsx
new file mode 100644
index 00000000..b456d877
--- /dev/null
+++ b/js/components/src/components/vod/vod-section.tsx
@@ -0,0 +1,28 @@
+import { useWindowDimensions, View } from "react-native";
+import { px, py } from "../../ui";
+import { useVideoStore } from "../../video-store/video-store";
+import { VodComments } from "./vod-comments";
+import { VodMobileMetadata } from "./vod-mobile-metadata";
+
+export function VodSection() {
+ const aturi = useVideoStore((x) => x.aturi);
+ const { width } = useWindowDimensions();
+ const isNarrow = width < 768;
+
+ if (!aturi) {
+ return null;
+ }
+
+ return (
+
+ {isNarrow && }
+
+
+ );
+}
diff --git a/js/components/src/index.tsx b/js/components/src/index.tsx
index 55599cdd..1ed375d8 100644
--- a/js/components/src/index.tsx
+++ b/js/components/src/index.tsx
@@ -81,3 +81,10 @@ export {
ACTIVITY_LABEL_DISPLAY,
formatActivity,
} from "./lib/metadata-constants";
+
+// VOD components and interaction hooks
+export * from "./components/vod/vod-comments";
+export * from "./components/vod/vod-like-button";
+export * from "./components/vod/vod-mobile-metadata";
+export * from "./components/vod/vod-section";
+export * from "./vod-store/vod-interactions";
diff --git a/js/components/src/vod-store/vod-interactions.tsx b/js/components/src/vod-store/vod-interactions.tsx
new file mode 100644
index 00000000..11d8d123
--- /dev/null
+++ b/js/components/src/vod-store/vod-interactions.tsx
@@ -0,0 +1,173 @@
+import { useCallback } from "react";
+import {
+ PlaceStreamVodComment,
+ PlaceStreamVodDefs,
+ PlaceStreamVodGetComments,
+ PlaceStreamVodGetLikes,
+ PlaceStreamVodLike,
+} from "streamplace";
+import { useDID } from "../streamplace-store/streamplace-store";
+import { usePDSAgent } from "../streamplace-store/xrpc";
+
+export const useCreateVodComment = () => {
+ const pdsAgent = usePDSAgent();
+ const userDID = useDID();
+
+ return useCallback(
+ async (params: { text: string; video: string }) => {
+ if (!pdsAgent || !userDID) {
+ throw new Error("No PDS agent or user DID found");
+ }
+
+ const record: PlaceStreamVodComment.Record = {
+ $type: "place.stream.vod.comment",
+ text: params.text,
+ createdAt: new Date().toISOString(),
+ video: params.video,
+ };
+
+ return await pdsAgent.com.atproto.repo.createRecord({
+ repo: userDID,
+ collection: "place.stream.vod.comment",
+ record,
+ });
+ },
+ [pdsAgent, userDID],
+ );
+};
+
+export const useDeleteVodComment = () => {
+ const pdsAgent = usePDSAgent();
+ const userDID = useDID();
+
+ return useCallback(
+ async (uri: string) => {
+ if (!pdsAgent || !userDID) {
+ throw new Error("No PDS agent or user DID found");
+ }
+ const rkey = uri.split("/").pop();
+ if (!rkey) throw new Error("No rkey found");
+ return await pdsAgent.com.atproto.repo.deleteRecord({
+ repo: userDID,
+ collection: "place.stream.vod.comment",
+ rkey,
+ });
+ },
+ [pdsAgent, userDID],
+ );
+};
+
+export const useCreateVodLike = () => {
+ const pdsAgent = usePDSAgent();
+ const userDID = useDID();
+
+ return useCallback(
+ async (subject: string) => {
+ if (!pdsAgent || !userDID) {
+ throw new Error("No PDS agent or user DID found");
+ }
+
+ const record: PlaceStreamVodLike.Record = {
+ $type: "place.stream.vod.like",
+ subject,
+ createdAt: new Date().toISOString(),
+ };
+
+ return await pdsAgent.com.atproto.repo.createRecord({
+ repo: userDID,
+ collection: "place.stream.vod.like",
+ record,
+ });
+ },
+ [pdsAgent, userDID],
+ );
+};
+
+export const useDeleteVodLike = () => {
+ const pdsAgent = usePDSAgent();
+ const userDID = useDID();
+
+ return useCallback(
+ async (uri: string) => {
+ if (!pdsAgent || !userDID) {
+ throw new Error("No PDS agent or user DID found");
+ }
+ const rkey = uri.split("/").pop();
+ if (!rkey) throw new Error("No rkey found");
+ return await pdsAgent.com.atproto.repo.deleteRecord({
+ repo: userDID,
+ collection: "place.stream.vod.like",
+ rkey,
+ });
+ },
+ [pdsAgent, userDID],
+ );
+};
+
+export const useGetVodComments = () => {
+ const pdsAgent = usePDSAgent();
+
+ return useCallback(
+ async (
+ video: string,
+ limit?: number,
+ cursor?: string,
+ ): Promise => {
+ if (!pdsAgent) {
+ throw new Error("No PDS agent found");
+ }
+ const res = await pdsAgent.place.stream.vod.getComments({
+ video,
+ limit,
+ cursor,
+ });
+ return res.data;
+ },
+ [pdsAgent],
+ );
+};
+
+export const useGetVodLikes = () => {
+ const pdsAgent = usePDSAgent();
+
+ return useCallback(
+ async (
+ subject: string,
+ limit?: number,
+ cursor?: string,
+ ): Promise => {
+ if (!pdsAgent) {
+ throw new Error("No PDS agent found");
+ }
+ const res = await pdsAgent.place.stream.vod.getLikes({
+ subject,
+ limit,
+ cursor,
+ });
+ return res.data;
+ },
+ [pdsAgent],
+ );
+};
+
+export const useVodLikeCount = () => {
+ const pdsAgent = usePDSAgent();
+
+ return useCallback(
+ async (subject: string): Promise => {
+ if (!pdsAgent) {
+ throw new Error("No PDS agent found");
+ }
+ const res = await pdsAgent.place.stream.vod.getLikes({
+ subject,
+ limit: 1,
+ });
+ return res.data.count;
+ },
+ [pdsAgent],
+ );
+};
+
+export type VodCommentHydrated = PlaceStreamVodDefs.CommentView & {
+ record: PlaceStreamVodComment.Record;
+};
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createvodgate.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createvodgate.md
new file mode 100644
index 00000000..f6bb4c8e
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createvodgate.md
@@ -0,0 +1,117 @@
+---
+title: place.stream.moderation.createVodGate
+description: Reference for the place.stream.moderation.createVodGate lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Create a gate (hide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Creates a place.stream.vod.gate record in the streamer's repository.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ------------ | -------- | ----- | -------------------------------------- | ---------------- |
+| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` |
+| `commentUri` | `string` | ✅ | The AT-URI of the VOD comment to hide. | Format: `at-uri` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ----- | -------- | ----- | -------------------------------------- | ---------------- |
+| `uri` | `string` | ✅ | The AT-URI of the created gate record. | Format: `at-uri` |
+| `cid` | `string` | ✅ | The CID of the created gate record. | Format: `cid` |
+
+**Possible Errors:**
+
+- `Unauthorized`: The request lacks valid authentication credentials.
+- `Forbidden`: The caller does not have permission to hide VOD comments for this streamer.
+- `SessionNotFound`: The streamer's OAuth session could not be found or is invalid.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.createVodGate",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Create a gate (hide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Creates a place.stream.vod.gate record in the streamer's repository.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "commentUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "commentUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the VOD comment to hide."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the created gate record."
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "The CID of the created gate record."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to hide VOD comments for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletevodgate.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletevodgate.md
new file mode 100644
index 00000000..bb1ed4b3
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletevodgate.md
@@ -0,0 +1,101 @@
+---
+title: place.stream.moderation.deleteVodGate
+description: Reference for the place.stream.moderation.deleteVodGate lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Delete a gate (unhide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Deletes a place.stream.vod.gate record from the streamer's repository.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ---------- | -------- | ----- | ---------------------------------------- | ---------------- |
+| `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` |
+| `gateUri` | `string` | ✅ | The AT-URI of the gate record to delete. | Format: `at-uri` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+_(No properties defined)_
+**Possible Errors:**
+
+- `Unauthorized`: The request lacks valid authentication credentials.
+- `Forbidden`: The caller does not have permission to unhide VOD comments for this streamer.
+- `SessionNotFound`: The streamer's OAuth session could not be found or is invalid.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.deleteVodGate",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Delete a gate (unhide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Deletes a place.stream.vod.gate record from the streamer's repository.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "gateUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "gateUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the gate record to delete."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to unhide VOD comments for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
index c45f3a9b..c14e61ee 100644
--- a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
+++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
@@ -52,7 +52,13 @@ Record granting moderation permissions to a user for this streamer's content.
"type": "array",
"items": {
"type": "string",
- "enum": ["ban", "hide", "livestream.manage", "message.pin"]
+ "enum": [
+ "ban",
+ "hide",
+ "livestream.manage",
+ "message.pin",
+ "vod.comment.hide"
+ ]
},
"description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata."
},
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index 9a5e5efd..873bb263 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -6,6 +6,149 @@
"description": "Autogenerated using lexmd"
},
"paths": {
+ "/xrpc/place.stream.vod.getComments": {
+ "get": {
+ "summary": "Get comments for a place.stream.video record.",
+ "operationId": "place.stream.vod.getComments",
+ "tags": ["place.stream.vod"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "cursor": {
+ "type": "string"
+ },
+ "comments": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.vod.defs_commentView"
+ }
+ }
+ },
+ "required": ["comments"]
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "video",
+ "in": "query",
+ "required": true,
+ "description": "AT-URI of the place.stream.video record.",
+ "schema": {
+ "type": "string",
+ "description": "AT-URI of the place.stream.video record.",
+ "format": "uri"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Optional pagination cursor.",
+ "schema": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of comments to return.",
+ "schema": {
+ "type": "integer",
+ "description": "Maximum number of comments to return.",
+ "default": 50,
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ ]
+ }
+ },
+ "/xrpc/place.stream.vod.getLikes": {
+ "get": {
+ "summary": "Get likes for a subject (video or comment).",
+ "operationId": "place.stream.vod.getLikes",
+ "tags": ["place.stream.vod"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "uri"
+ },
+ "count": {
+ "type": "integer",
+ "description": "Total number of likes for this subject.",
+ "minimum": 0
+ },
+ "cursor": {
+ "type": "string"
+ },
+ "likes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.vod.getLikes_likeView"
+ }
+ }
+ },
+ "required": ["subject", "count"]
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "subject",
+ "in": "query",
+ "required": true,
+ "description": "AT-URI of the subject (video or comment).",
+ "schema": {
+ "type": "string",
+ "description": "AT-URI of the subject (video or comment).",
+ "format": "uri"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "required": false,
+ "description": "Optional pagination cursor.",
+ "schema": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "description": "Maximum number of likes to return.",
+ "schema": {
+ "type": "integer",
+ "description": "Maximum number of likes to return.",
+ "default": 50,
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ ]
+ }
+ },
"/xrpc/place.stream.server.createWebhook": {
"post": {
"summary": "Create a new webhook for receiving Streamplace events.",
@@ -1576,10 +1719,10 @@
}
}
},
- "/xrpc/place.stream.moderation.deleteBlock": {
+ "/xrpc/place.stream.moderation.createVodGate": {
"post": {
- "summary": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.",
- "operationId": "place.stream.moderation.deleteBlock",
+ "summary": "Create a gate (hide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Creates a place.stream.vod.gate record in the streamer's repository.",
+ "operationId": "place.stream.moderation.createVodGate",
"tags": ["place.stream.moderation"],
"responses": {
"200": {
@@ -1588,7 +1731,19 @@
"application/json": {
"schema": {
"type": "object",
- "properties": {}
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "The AT-URI of the created gate record.",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "description": "The CID of the created gate record.",
+ "format": "cid"
+ }
+ },
+ "required": ["uri", "cid"]
}
}
}
@@ -1636,23 +1791,23 @@
"description": "The DID of the streamer.",
"format": "did"
},
- "blockUri": {
+ "commentUri": {
"type": "string",
- "description": "The AT-URI of the block record to delete.",
+ "description": "The AT-URI of the VOD comment to hide.",
"format": "uri"
}
},
- "required": ["streamer", "blockUri"]
+ "required": ["streamer", "commentUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.moderation.deleteGate": {
+ "/xrpc/place.stream.moderation.deleteBlock": {
"post": {
- "summary": "Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository.",
- "operationId": "place.stream.moderation.deleteGate",
+ "summary": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.",
+ "operationId": "place.stream.moderation.deleteBlock",
"tags": ["place.stream.moderation"],
"responses": {
"200": {
@@ -1709,23 +1864,23 @@
"description": "The DID of the streamer.",
"format": "did"
},
- "gateUri": {
+ "blockUri": {
"type": "string",
- "description": "The AT-URI of the gate record to delete.",
+ "description": "The AT-URI of the block record to delete.",
"format": "uri"
}
},
- "required": ["streamer", "gateUri"]
+ "required": ["streamer", "blockUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.moderation.deletePin": {
+ "/xrpc/place.stream.moderation.deleteGate": {
"post": {
- "summary": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.",
- "operationId": "place.stream.moderation.deletePin",
+ "summary": "Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository.",
+ "operationId": "place.stream.moderation.deleteGate",
"tags": ["place.stream.moderation"],
"responses": {
"200": {
@@ -1782,23 +1937,23 @@
"description": "The DID of the streamer.",
"format": "did"
},
- "pinUri": {
+ "gateUri": {
"type": "string",
- "description": "The AT-URI of the pinned record to delete.",
+ "description": "The AT-URI of the gate record to delete.",
"format": "uri"
}
},
- "required": ["streamer", "pinUri"]
+ "required": ["streamer", "gateUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.moderation.updateLivestream": {
+ "/xrpc/place.stream.moderation.deletePin": {
"post": {
- "summary": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.",
- "operationId": "place.stream.moderation.updateLivestream",
+ "summary": "Unpin a pinned chat message on behalf of a streamer. Requires 'message.pin' permission. Deletes the place.stream.chat.pinnedRecord from the streamer's repo.",
+ "operationId": "place.stream.moderation.deletePin",
"tags": ["place.stream.moderation"],
"responses": {
"200": {
@@ -1807,19 +1962,7 @@
"application/json": {
"schema": {
"type": "object",
- "properties": {
- "uri": {
- "type": "string",
- "description": "The AT-URI of the updated livestream record.",
- "format": "uri"
- },
- "cid": {
- "type": "string",
- "description": "The CID of the updated livestream record.",
- "format": "cid"
- }
- },
- "required": ["uri", "cid"]
+ "properties": {}
}
}
}
@@ -1843,9 +1986,6 @@
},
{
"const": "SessionNotFound"
- },
- {
- "const": "RecordNotFound"
}
]
},
@@ -1870,29 +2010,24 @@
"description": "The DID of the streamer.",
"format": "did"
},
- "livestreamUri": {
+ "pinUri": {
"type": "string",
- "description": "The AT-URI of the livestream record to update.",
+ "description": "The AT-URI of the pinned record to delete.",
"format": "uri"
- },
- "title": {
- "type": "string",
- "description": "New title for the livestream.",
- "maxLength": 1400
}
},
- "required": ["streamer", "livestreamUri"]
+ "required": ["streamer", "pinUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.media.createUpload": {
+ "/xrpc/place.stream.moderation.deleteVodGate": {
"post": {
- "summary": "Start a resumable upload of arbitrary media content. Returns a TUS upload URL and a short-lived bearer token used to authenticate subsequent chunk requests (no DPoP required on chunks). The DID of the authenticated user is recorded against the upload; on completion a VOD processing task is enqueued.",
- "operationId": "place.stream.media.createUpload",
- "tags": ["place.stream.media"],
+ "summary": "Delete a gate (unhide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Deletes a place.stream.vod.gate record from the streamer's repository.",
+ "operationId": "place.stream.moderation.deleteVodGate",
+ "tags": ["place.stream.moderation"],
"responses": {
"200": {
"description": "Success",
@@ -1900,32 +2035,7 @@
"application/json": {
"schema": {
"type": "object",
- "properties": {
- "uploadId": {
- "type": "string",
- "description": "Server-side identifier for this upload."
- },
- "uploadUrl": {
- "type": "string",
- "description": "Absolute URL to PATCH/HEAD per the TUS protocol.",
- "format": "uri"
- },
- "uploadToken": {
- "type": "string",
- "description": "Short-lived bearer token bound to this upload. Send as 'Authorization: Bearer ' on TUS requests."
- },
- "expiresAt": {
- "type": "string",
- "description": "When the upload token expires.",
- "format": "date-time"
- }
- },
- "required": [
- "uploadUrl",
- "uploadToken",
- "expiresAt",
- "uploadId"
- ]
+ "properties": {}
}
}
}
@@ -1942,10 +2052,13 @@
"type": "string",
"oneOf": [
{
- "const": "UploadTooLarge"
+ "const": "Unauthorized"
},
{
- "const": "UnsupportedMimeType"
+ "const": "Forbidden"
+ },
+ {
+ "const": "SessionNotFound"
}
]
},
@@ -1965,35 +2078,223 @@
"schema": {
"type": "object",
"properties": {
- "size": {
- "type": "integer",
- "description": "Total size of the upload in bytes."
- },
- "mimeType": {
+ "streamer": {
"type": "string",
- "description": "MIME type of the content being uploaded (e.g. video/mp4)."
+ "description": "The DID of the streamer.",
+ "format": "did"
},
- "filename": {
+ "gateUri": {
"type": "string",
- "description": "Optional filename hint to attach as upload metadata."
+ "description": "The AT-URI of the gate record to delete.",
+ "format": "uri"
}
},
- "required": ["size", "mimeType"]
+ "required": ["streamer", "gateUri"]
}
}
}
}
}
},
- "/xrpc/place.stream.media.getUploadStatus": {
- "get": {
- "summary": "Get the processing status of a previously created upload. Only accessible by the DID that created the upload.",
- "operationId": "place.stream.media.getUploadStatus",
- "tags": ["place.stream.media"],
- "responses": {
- "200": {
- "description": "Success",
- "content": {
+ "/xrpc/place.stream.moderation.updateLivestream": {
+ "post": {
+ "summary": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.",
+ "operationId": "place.stream.moderation.updateLivestream",
+ "tags": ["place.stream.moderation"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "The AT-URI of the updated livestream record.",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "description": "The CID of the updated livestream record.",
+ "format": "cid"
+ }
+ },
+ "required": ["uri", "cid"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "Unauthorized"
+ },
+ {
+ "const": "Forbidden"
+ },
+ {
+ "const": "SessionNotFound"
+ },
+ {
+ "const": "RecordNotFound"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "description": "The DID of the streamer.",
+ "format": "did"
+ },
+ "livestreamUri": {
+ "type": "string",
+ "description": "The AT-URI of the livestream record to update.",
+ "format": "uri"
+ },
+ "title": {
+ "type": "string",
+ "description": "New title for the livestream.",
+ "maxLength": 1400
+ }
+ },
+ "required": ["streamer", "livestreamUri"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/xrpc/place.stream.media.createUpload": {
+ "post": {
+ "summary": "Start a resumable upload of arbitrary media content. Returns a TUS upload URL and a short-lived bearer token used to authenticate subsequent chunk requests (no DPoP required on chunks). The DID of the authenticated user is recorded against the upload; on completion a VOD processing task is enqueued.",
+ "operationId": "place.stream.media.createUpload",
+ "tags": ["place.stream.media"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "uploadId": {
+ "type": "string",
+ "description": "Server-side identifier for this upload."
+ },
+ "uploadUrl": {
+ "type": "string",
+ "description": "Absolute URL to PATCH/HEAD per the TUS protocol.",
+ "format": "uri"
+ },
+ "uploadToken": {
+ "type": "string",
+ "description": "Short-lived bearer token bound to this upload. Send as 'Authorization: Bearer ' on TUS requests."
+ },
+ "expiresAt": {
+ "type": "string",
+ "description": "When the upload token expires.",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "uploadUrl",
+ "uploadToken",
+ "expiresAt",
+ "uploadId"
+ ]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "UploadTooLarge"
+ },
+ {
+ "const": "UnsupportedMimeType"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "size": {
+ "type": "integer",
+ "description": "Total size of the upload in bytes."
+ },
+ "mimeType": {
+ "type": "string",
+ "description": "MIME type of the content being uploaded (e.g. video/mp4)."
+ },
+ "filename": {
+ "type": "string",
+ "description": "Optional filename hint to attach as upload metadata."
+ }
+ },
+ "required": ["size", "mimeType"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/xrpc/place.stream.media.getUploadStatus": {
+ "get": {
+ "summary": "Get the processing status of a previously created upload. Only accessible by the DID that created the upload.",
+ "operationId": "place.stream.media.getUploadStatus",
+ "tags": ["place.stream.media"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
"application/json": {
"schema": {
"type": "object",
@@ -4549,225 +4850,66 @@
},
"components": {
"schemas": {
- "place.stream.server.defs_rewriteRule": {
+ "place.stream.vod.defs_commentView": {
"type": "object",
"properties": {
- "from": {
+ "uri": {
"type": "string",
- "description": "Text to search for and replace.",
- "minLength": 1,
- "maxLength": 100
+ "format": "uri"
},
- "to": {
+ "cid": {
"type": "string",
- "description": "Text to replace with.",
- "maxLength": 100
+ "format": "cid"
+ },
+ "author": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
+ },
+ "record": {},
+ "indexedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "replyTo": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/place.stream.vod.defs_commentView"
+ }
+ ]
+ },
+ "likeCount": {
+ "type": "integer",
+ "description": "Number of likes on this comment.",
+ "minimum": 0
}
},
- "required": ["from", "to"]
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"]
},
- "place.stream.server.defs_webhook": {
+ "app.bsky.actor.defs_profileViewBasic": {
"type": "object",
- "description": "A webhook configuration for receiving Streamplace events.",
"properties": {
- "id": {
+ "did": {
"type": "string",
- "description": "Unique identifier for this webhook."
+ "format": "did"
},
- "url": {
+ "handle": {
+ "type": "string",
+ "format": "handle"
+ },
+ "displayName": {
+ "type": "string",
+ "maxLength": 640
+ },
+ "avatar": {
"type": "string",
- "description": "The webhook URL where events will be sent.",
"format": "uri"
},
- "events": {
- "type": "array",
- "description": "The types of events this webhook should receive.",
- "items": {
- "type": "string",
- "enum": ["chat", "livestream", "follow", "mention"]
- }
- },
- "active": {
- "type": "boolean",
- "description": "Whether this webhook is currently active."
- },
- "prefix": {
- "type": "string",
- "description": "Text to prepend to webhook messages.",
- "maxLength": 100
- },
- "suffix": {
- "type": "string",
- "description": "Text to append to webhook messages.",
- "maxLength": 100
- },
- "rewrite": {
- "type": "array",
- "description": "Text replacement rules for webhook messages.",
- "items": {
- "$ref": "#/components/schemas/place.stream.server.defs_rewriteRule"
- }
- },
- "createdAt": {
- "type": "string",
- "description": "When this webhook was created.",
- "format": "date-time"
- },
- "updatedAt": {
- "type": "string",
- "description": "When this webhook was last updated.",
- "format": "date-time"
- },
- "name": {
- "type": "string",
- "description": "A user-friendly name for this webhook.",
- "maxLength": 100
- },
- "description": {
- "type": "string",
- "description": "A description of what this webhook is used for.",
- "maxLength": 500
- },
- "lastTriggered": {
- "type": "string",
- "description": "When this webhook was last triggered.",
- "format": "date-time"
- },
- "errorCount": {
- "type": "integer",
- "description": "Number of consecutive errors for this webhook."
- },
- "muteWords": {
- "type": "array",
- "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded.",
- "items": {
- "type": "string",
- "maxLength": 100
- }
- }
- },
- "required": ["id", "url", "events", "active", "createdAt"]
- },
- "place.stream.server.defs_storage": {
- "type": "object",
- "description": "S3 storage configuration for backups.",
- "properties": {
- "url": {
- "type": "string",
- "description": "S3 storage URL with masked secret key in format: s3+https://ACCESS_KEY:***@endpoint/bucket"
- },
- "isActive": {
- "type": "boolean",
- "description": "Whether backup storage is currently active."
- }
- },
- "required": ["url", "isActive"]
- },
- "place.stream.multistream.target": {
- "description": "Unknown type"
- },
- "place.stream.multistream.defs_targetView": {
- "type": "object",
- "properties": {
- "uri": {
- "type": "string",
- "format": "uri"
- },
- "cid": {
- "type": "string",
- "format": "cid"
- },
- "record": {},
- "latestEvent": {
- "$ref": "#/components/schemas/place.stream.multistream.defs_event"
- }
- },
- "required": ["uri", "cid", "record"]
- },
- "place.stream.multistream.defs_event": {
- "type": "object",
- "properties": {
- "message": {
- "type": "string"
- },
- "status": {
- "type": "string",
- "enum": ["inactive", "pending", "active", "error"]
- },
- "createdAt": {
- "type": "string",
- "format": "date-time"
- }
- },
- "required": ["message", "status", "createdAt"]
- },
- "place.stream.media.getUploadStatus_trackRef": {
- "type": "object",
- "properties": {
- "uri": {
- "type": "string",
- "format": "uri"
- },
- "cid": {
- "type": "string"
- }
- },
- "required": ["uri", "cid"]
- },
- "place.stream.media.getVideo_videoView": {
- "type": "object",
- "properties": {
- "uri": {
- "type": "string",
- "format": "uri"
- },
- "cid": {
- "type": "string",
- "format": "cid"
- },
- "author": {
- "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
- },
- "record": {},
- "viewCounts": {
- "$ref": "#/components/schemas/place.stream.media.getVideo_viewCountSummary",
- "description": "Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally."
- },
- "tracks": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/place.stream.media.track_trackView"
- }
- }
- },
- "required": ["uri", "cid", "author", "record", "viewCounts"]
- },
- "app.bsky.actor.defs_profileViewBasic": {
- "type": "object",
- "properties": {
- "did": {
- "type": "string",
- "format": "did"
- },
- "handle": {
- "type": "string",
- "format": "handle"
- },
- "displayName": {
- "type": "string",
- "maxLength": 640
- },
- "avatar": {
- "type": "string",
- "format": "uri"
- },
- "associated": {
- "$ref": "#/components/schemas/app.bsky.actor.defs_profileAssociated"
- },
- "viewer": {
- "$ref": "#/components/schemas/app.bsky.actor.defs_viewerState"
- },
- "labels": {
+ "associated": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileAssociated"
+ },
+ "viewer": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_viewerState"
+ },
+ "labels": {
"type": "array",
"items": {
"$ref": "#/components/schemas/com.atproto.label.defs_label"
@@ -5079,6 +5221,221 @@
},
"required": ["uri", "title", "description"]
},
+ "place.stream.vod.getLikes_likeView": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
+ },
+ "record": {},
+ "indexedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": ["uri", "cid", "author", "record", "indexedAt"]
+ },
+ "place.stream.server.defs_rewriteRule": {
+ "type": "object",
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "Text to search for and replace.",
+ "minLength": 1,
+ "maxLength": 100
+ },
+ "to": {
+ "type": "string",
+ "description": "Text to replace with.",
+ "maxLength": 100
+ }
+ },
+ "required": ["from", "to"]
+ },
+ "place.stream.server.defs_webhook": {
+ "type": "object",
+ "description": "A webhook configuration for receiving Streamplace events.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier for this webhook."
+ },
+ "url": {
+ "type": "string",
+ "description": "The webhook URL where events will be sent.",
+ "format": "uri"
+ },
+ "events": {
+ "type": "array",
+ "description": "The types of events this webhook should receive.",
+ "items": {
+ "type": "string",
+ "enum": ["chat", "livestream", "follow", "mention"]
+ }
+ },
+ "active": {
+ "type": "boolean",
+ "description": "Whether this webhook is currently active."
+ },
+ "prefix": {
+ "type": "string",
+ "description": "Text to prepend to webhook messages.",
+ "maxLength": 100
+ },
+ "suffix": {
+ "type": "string",
+ "description": "Text to append to webhook messages.",
+ "maxLength": 100
+ },
+ "rewrite": {
+ "type": "array",
+ "description": "Text replacement rules for webhook messages.",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.server.defs_rewriteRule"
+ }
+ },
+ "createdAt": {
+ "type": "string",
+ "description": "When this webhook was created.",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "description": "When this webhook was last updated.",
+ "format": "date-time"
+ },
+ "name": {
+ "type": "string",
+ "description": "A user-friendly name for this webhook.",
+ "maxLength": 100
+ },
+ "description": {
+ "type": "string",
+ "description": "A description of what this webhook is used for.",
+ "maxLength": 500
+ },
+ "lastTriggered": {
+ "type": "string",
+ "description": "When this webhook was last triggered.",
+ "format": "date-time"
+ },
+ "errorCount": {
+ "type": "integer",
+ "description": "Number of consecutive errors for this webhook."
+ },
+ "muteWords": {
+ "type": "array",
+ "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded.",
+ "items": {
+ "type": "string",
+ "maxLength": 100
+ }
+ }
+ },
+ "required": ["id", "url", "events", "active", "createdAt"]
+ },
+ "place.stream.server.defs_storage": {
+ "type": "object",
+ "description": "S3 storage configuration for backups.",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "S3 storage URL with masked secret key in format: s3+https://ACCESS_KEY:***@endpoint/bucket"
+ },
+ "isActive": {
+ "type": "boolean",
+ "description": "Whether backup storage is currently active."
+ }
+ },
+ "required": ["url", "isActive"]
+ },
+ "place.stream.multistream.target": {
+ "description": "Unknown type"
+ },
+ "place.stream.multistream.defs_targetView": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "record": {},
+ "latestEvent": {
+ "$ref": "#/components/schemas/place.stream.multistream.defs_event"
+ }
+ },
+ "required": ["uri", "cid", "record"]
+ },
+ "place.stream.multistream.defs_event": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["inactive", "pending", "active", "error"]
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": ["message", "status", "createdAt"]
+ },
+ "place.stream.media.getUploadStatus_trackRef": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string"
+ }
+ },
+ "required": ["uri", "cid"]
+ },
+ "place.stream.media.getVideo_videoView": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "$ref": "#/components/schemas/app.bsky.actor.defs_profileViewBasic"
+ },
+ "record": {},
+ "viewCounts": {
+ "$ref": "#/components/schemas/place.stream.media.getVideo_viewCountSummary",
+ "description": "Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally."
+ },
+ "tracks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/place.stream.media.track_trackView"
+ }
+ }
+ },
+ "required": ["uri", "cid", "author", "record", "viewCounts"]
+ },
"place.stream.media.getVideo_viewCountSummary": {
"type": "object",
"description": "Sums across every place.stream.media.viewCount record indexed for this video, regardless of reporter or window. The underlying records are window-bounded; the consumer sees a running total.",
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-comment.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-comment.md
new file mode 100644
index 00000000..0f1cc755
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-comment.md
@@ -0,0 +1,109 @@
+---
+title: place.stream.vod.comment
+description: Reference for the place.stream.vod.comment lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Record containing a comment on a VOD.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------ | ----- | ---------------------------------------------------------------- | --------------------------------------- |
+| `text` | `string` | ✅ | The comment text content. | Max Length: 3000
Max Graphemes: 300 |
+| `createdAt` | `string` | ✅ | Client-declared timestamp when this comment was created. | Format: `datetime` |
+| `video` | `string` | ✅ | AT-URI of the place.stream.video record this comment belongs to. | Format: `at-uri` |
+| `facets` | Array of [`place.stream.richtext.facet`](/lex-reference/place-stream-richtext-facet) | ❌ | Annotations of text (mentions, URLs, etc) | |
+| `reply` | [`#replyRef`](#replyref) | ❌ | | |
+
+---
+
+
+
+### `replyRef`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| -------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- |
+| `root` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ✅ | | |
+| `parent` | [`com.atproto.repo.strongRef`](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo/strongref.json#undefined) | ✅ | | |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.comment",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Record containing a comment on a VOD.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["text", "createdAt", "video"],
+ "properties": {
+ "text": {
+ "type": "string",
+ "maxLength": 3000,
+ "maxGraphemes": 300,
+ "description": "The comment text content."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this comment was created."
+ },
+ "video": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.video record this comment belongs to."
+ },
+ "facets": {
+ "type": "array",
+ "description": "Annotations of text (mentions, URLs, etc)",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.richtext.facet"
+ }
+ },
+ "reply": {
+ "type": "ref",
+ "ref": "#replyRef"
+ }
+ }
+ }
+ },
+ "replyRef": {
+ "type": "object",
+ "required": ["root", "parent"],
+ "properties": {
+ "root": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef"
+ },
+ "parent": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef"
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md
new file mode 100644
index 00000000..f9186b94
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-defs.md
@@ -0,0 +1,73 @@
+---
+title: place.stream.vod.defs
+description: Reference for the place.stream.vod.defs lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `commentView`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | -------------------------------- | ------------------ |
+| `uri` | `string` | ✅ | | Format: `at-uri` |
+| `cid` | `string` | ✅ | | Format: `cid` |
+| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | |
+| `record` | `unknown` | ✅ | | |
+| `indexedAt` | `string` | ✅ | | Format: `datetime` |
+| `replyTo` | Union of:
[`#commentView`](#commentview) | ❌ | | |
+| `likeCount` | `integer` | ✅ | Number of likes on this comment. | Min: 0 |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.defs",
+ "defs": {
+ "commentView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": {
+ "type": "unknown"
+ },
+ "indexedAt": {
+ "type": "string",
+ "format": "datetime"
+ },
+ "replyTo": {
+ "type": "union",
+ "refs": ["#commentView"]
+ },
+ "likeCount": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of likes on this comment."
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-gate.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-gate.md
new file mode 100644
index 00000000..d34ff1e7
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-gate.md
@@ -0,0 +1,53 @@
+---
+title: place.stream.vod.gate
+description: Reference for the place.stream.vod.gate lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Record defining a single gated VOD comment.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| --------------- | -------- | ----- | ------------------------------ | ---------------- |
+| `hiddenComment` | `string` | ✅ | URI of the hidden VOD comment. | Format: `at-uri` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.gate",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record defining a single gated VOD comment.",
+ "record": {
+ "type": "object",
+ "required": ["hiddenComment"],
+ "properties": {
+ "hiddenComment": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "URI of the hidden VOD comment."
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getcomments.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getcomments.md
new file mode 100644
index 00000000..469b55f7
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getcomments.md
@@ -0,0 +1,94 @@
+---
+title: place.stream.vod.getComments
+description: Reference for the place.stream.vod.getComments lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `query`
+
+Get comments for a place.stream.video record.
+
+**Parameters:**
+
+| Name | Type | Req'd | Description | Constraints |
+| -------- | --------- | ----- | ---------------------------------------- | ------------------------------------- |
+| `video` | `string` | ✅ | AT-URI of the place.stream.video record. | Format: `at-uri` |
+| `cursor` | `string` | ❌ | Optional pagination cursor. | |
+| `limit` | `integer` | ❌ | Maximum number of comments to return. | Min: 1
Max: 100
Default: `50` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ---------- | ------------------------------------------------------------------------------------------------ | ----- | ----------- | ----------- |
+| `cursor` | `string` | ❌ | | |
+| `comments` | Array of [`place.stream.vod.defs#commentView`](/lex-reference/place-stream-vod-defs#commentview) | ✅ | | |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.getComments",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get comments for a place.stream.video record.",
+ "parameters": {
+ "type": "params",
+ "required": ["video"],
+ "properties": {
+ "video": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.video record."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Maximum number of comments to return."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["comments"],
+ "properties": {
+ "cursor": {
+ "type": "string"
+ },
+ "comments": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.vod.defs#commentView"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getlikes.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getlikes.md
new file mode 100644
index 00000000..dae716a1
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-getlikes.md
@@ -0,0 +1,148 @@
+---
+title: place.stream.vod.getLikes
+description: Reference for the place.stream.vod.getLikes lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `query`
+
+Get likes for a subject (video or comment).
+
+**Parameters:**
+
+| Name | Type | Req'd | Description | Constraints |
+| --------- | --------- | ----- | ----------------------------------------- | ------------------------------------- |
+| `subject` | `string` | ✅ | AT-URI of the subject (video or comment). | Format: `at-uri` |
+| `cursor` | `string` | ❌ | Optional pagination cursor. | |
+| `limit` | `integer` | ❌ | Maximum number of likes to return. | Min: 1
Max: 100
Default: `50` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| --------- | --------------------------------- | ----- | --------------------------------------- | ---------------- |
+| `subject` | `string` | ✅ | | Format: `at-uri` |
+| `count` | `integer` | ✅ | Total number of likes for this subject. | Min: 0 |
+| `cursor` | `string` | ❌ | | |
+| `likes` | Array of [`#likeView`](#likeview) | ❌ | | |
+
+---
+
+
+
+### `likeView`
+
+**Type:** `object`
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------- | ------------------ |
+| `uri` | `string` | ✅ | | Format: `at-uri` |
+| `cid` | `string` | ✅ | | Format: `cid` |
+| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | |
+| `record` | `unknown` | ✅ | | |
+| `indexedAt` | `string` | ✅ | | Format: `datetime` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.getLikes",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get likes for a subject (video or comment).",
+ "parameters": {
+ "type": "params",
+ "required": ["subject"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the subject (video or comment)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Maximum number of likes to return."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["subject", "count"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Total number of likes for this subject."
+ },
+ "cursor": {
+ "type": "string"
+ },
+ "likes": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "#likeView"
+ }
+ }
+ }
+ }
+ }
+ },
+ "likeView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record", "indexedAt"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri"
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid"
+ },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": {
+ "type": "unknown"
+ },
+ "indexedAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-like.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-like.md
new file mode 100644
index 00000000..65c6bf40
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-like.md
@@ -0,0 +1,59 @@
+---
+title: place.stream.vod.like
+description: Reference for the place.stream.vod.like lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Record indicating a like on a VOD or VOD comment.
+
+**Record Key:** `tid`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------- | -------- | ----- | ------------------------------------------------------------------------------------ | ------------------ |
+| `subject` | `string` | ✅ | AT-URI of the record being liked (a place.stream.video or place.stream.vod.comment). | Format: `at-uri` |
+| `createdAt` | `string` | ✅ | Client-declared timestamp when this like was created. | Format: `datetime` |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.like",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record indicating a like on a VOD or VOD comment.",
+ "record": {
+ "type": "object",
+ "required": ["subject", "createdAt"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the record being liked (a place.stream.video or place.stream.vod.comment)."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this like was created."
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/lexicons/place/stream/moderation/createVodGate.json b/lexicons/place/stream/moderation/createVodGate.json
new file mode 100644
index 00000000..07a02982
--- /dev/null
+++ b/lexicons/place/stream/moderation/createVodGate.json
@@ -0,0 +1,62 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.createVodGate",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Create a gate (hide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Creates a place.stream.vod.gate record in the streamer's repository.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "commentUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "commentUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the VOD comment to hide."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri", "cid"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the created gate record."
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "The CID of the created gate record."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to hide VOD comments for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
diff --git a/lexicons/place/stream/moderation/deleteVodGate.json b/lexicons/place/stream/moderation/deleteVodGate.json
new file mode 100644
index 00000000..fb371a2a
--- /dev/null
+++ b/lexicons/place/stream/moderation/deleteVodGate.json
@@ -0,0 +1,50 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.moderation.deleteVodGate",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Delete a gate (unhide VOD comment) on behalf of a streamer. Requires 'vod.comment.hide' permission. Deletes a place.stream.vod.gate record from the streamer's repository.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["streamer", "gateUri"],
+ "properties": {
+ "streamer": {
+ "type": "string",
+ "format": "did",
+ "description": "The DID of the streamer."
+ },
+ "gateUri": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The AT-URI of the gate record to delete."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "errors": [
+ {
+ "name": "Unauthorized",
+ "description": "The request lacks valid authentication credentials."
+ },
+ {
+ "name": "Forbidden",
+ "description": "The caller does not have permission to unhide VOD comments for this streamer."
+ },
+ {
+ "name": "SessionNotFound",
+ "description": "The streamer's OAuth session could not be found or is invalid."
+ }
+ ]
+ }
+ }
+}
diff --git a/lexicons/place/stream/moderation/permission.json b/lexicons/place/stream/moderation/permission.json
index 68a3d3ce..d269ed99 100644
--- a/lexicons/place/stream/moderation/permission.json
+++ b/lexicons/place/stream/moderation/permission.json
@@ -19,7 +19,13 @@
"type": "array",
"items": {
"type": "string",
- "enum": ["ban", "hide", "livestream.manage", "message.pin"]
+ "enum": [
+ "ban",
+ "hide",
+ "livestream.manage",
+ "message.pin",
+ "vod.comment.hide"
+ ]
},
"description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata."
},
diff --git a/lexicons/place/stream/vod/comment.json b/lexicons/place/stream/vod/comment.json
new file mode 100644
index 00000000..4a2ea391
--- /dev/null
+++ b/lexicons/place/stream/vod/comment.json
@@ -0,0 +1,56 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.comment",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Record containing a comment on a VOD.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["text", "createdAt", "video"],
+ "properties": {
+ "text": {
+ "type": "string",
+ "maxLength": 3000,
+ "maxGraphemes": 300,
+ "description": "The comment text content."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this comment was created."
+ },
+ "video": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.video record this comment belongs to."
+ },
+ "facets": {
+ "type": "array",
+ "description": "Annotations of text (mentions, URLs, etc)",
+ "items": { "type": "ref", "ref": "place.stream.richtext.facet" }
+ },
+ "reply": {
+ "type": "ref",
+ "ref": "#replyRef"
+ }
+ }
+ }
+ },
+ "replyRef": {
+ "type": "object",
+ "required": ["root", "parent"],
+ "properties": {
+ "root": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef"
+ },
+ "parent": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef"
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/defs.json b/lexicons/place/stream/vod/defs.json
new file mode 100644
index 00000000..fd0c285a
--- /dev/null
+++ b/lexicons/place/stream/vod/defs.json
@@ -0,0 +1,29 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.defs",
+ "defs": {
+ "commentView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record", "indexedAt", "likeCount"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": { "type": "unknown" },
+ "indexedAt": { "type": "string", "format": "datetime" },
+ "replyTo": {
+ "type": "union",
+ "refs": ["#commentView"]
+ },
+ "likeCount": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of likes on this comment."
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/gate.json b/lexicons/place/stream/vod/gate.json
new file mode 100644
index 00000000..e996e4af
--- /dev/null
+++ b/lexicons/place/stream/vod/gate.json
@@ -0,0 +1,22 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.gate",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record defining a single gated VOD comment.",
+ "record": {
+ "type": "object",
+ "required": ["hiddenComment"],
+ "properties": {
+ "hiddenComment": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "URI of the hidden VOD comment."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/getComments.json b/lexicons/place/stream/vod/getComments.json
new file mode 100644
index 00000000..e17c9950
--- /dev/null
+++ b/lexicons/place/stream/vod/getComments.json
@@ -0,0 +1,49 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.getComments",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get comments for a place.stream.video record.",
+ "parameters": {
+ "type": "params",
+ "required": ["video"],
+ "properties": {
+ "video": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.video record."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Maximum number of comments to return."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["comments"],
+ "properties": {
+ "cursor": { "type": "string" },
+ "comments": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "place.stream.vod.defs#commentView"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/getLikes.json b/lexicons/place/stream/vod/getLikes.json
new file mode 100644
index 00000000..b63c64fc
--- /dev/null
+++ b/lexicons/place/stream/vod/getLikes.json
@@ -0,0 +1,69 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.getLikes",
+ "defs": {
+ "main": {
+ "type": "query",
+ "description": "Get likes for a subject (video or comment).",
+ "parameters": {
+ "type": "params",
+ "required": ["subject"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the subject (video or comment)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Optional pagination cursor."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 50,
+ "description": "Maximum number of likes to return."
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["subject", "count"],
+ "properties": {
+ "subject": { "type": "string", "format": "at-uri" },
+ "count": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Total number of likes for this subject."
+ },
+ "cursor": { "type": "string" },
+ "likes": {
+ "type": "array",
+ "items": {
+ "type": "ref",
+ "ref": "#likeView"
+ }
+ }
+ }
+ }
+ }
+ },
+ "likeView": {
+ "type": "object",
+ "required": ["uri", "cid", "author", "record", "indexedAt"],
+ "properties": {
+ "uri": { "type": "string", "format": "at-uri" },
+ "cid": { "type": "string", "format": "cid" },
+ "author": {
+ "type": "ref",
+ "ref": "app.bsky.actor.defs#profileViewBasic"
+ },
+ "record": { "type": "unknown" },
+ "indexedAt": { "type": "string", "format": "datetime" }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/vod/like.json b/lexicons/place/stream/vod/like.json
new file mode 100644
index 00000000..51632721
--- /dev/null
+++ b/lexicons/place/stream/vod/like.json
@@ -0,0 +1,27 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.like",
+ "defs": {
+ "main": {
+ "type": "record",
+ "key": "tid",
+ "description": "Record indicating a like on a VOD or VOD comment.",
+ "record": {
+ "type": "object",
+ "required": ["subject", "createdAt"],
+ "properties": {
+ "subject": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the record being liked (a place.stream.video or place.stream.vod.comment)."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Client-declared timestamp when this like was created."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/mise.toml b/mise.toml
index c5c08b9c..107d7636 100644
--- a/mise.toml
+++ b/mise.toml
@@ -5,3 +5,4 @@ node = "22"
meson = "latest"
ninja = "latest"
pnpm = "latest"
+java = "17"
diff --git a/pkg/atproto/firehose.go b/pkg/atproto/firehose.go
index 36df1e56..9867cd23 100644
--- a/pkg/atproto/firehose.go
+++ b/pkg/atproto/firehose.go
@@ -405,6 +405,27 @@ func (atsync *ATProtoSynchronizer) handleCommitEventOps(ctx context.Context, evt
}
}
+ if collection.String() == constants.PLACE_STREAM_VOD_COMMENT {
+ log.Debug(ctx, "deleting VOD comment", "uri", uri)
+ if err := atsync.Model.DeleteVodComment(ctx, uri, &opTime); err != nil {
+ log.Error(ctx, "failed to delete VOD comment", "err", err)
+ }
+ }
+
+ if collection.String() == constants.PLACE_STREAM_VOD_LIKE {
+ log.Debug(ctx, "deleting VOD like", "uri", uri)
+ if err := atsync.Model.DeleteVodLike(ctx, uri); err != nil {
+ log.Error(ctx, "failed to delete VOD like", "err", err)
+ }
+ }
+
+ if collection.String() == constants.PLACE_STREAM_VOD_GATE {
+ log.Debug(ctx, "deleting VOD gate", "userDID", evt.Repo, "rkey", rkey.String())
+ if err := atsync.Model.DeleteVodGate(ctx, rkey.String()); err != nil {
+ log.Error(ctx, "failed to delete VOD gate", "err", err)
+ }
+ }
+
default:
log.Error(ctx, "unexpected record op kind")
}
diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go
index b45e677c..7649394e 100644
--- a/pkg/atproto/sync.go
+++ b/pkg/atproto/sync.go
@@ -781,6 +781,133 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD
log.Debug(ctx, "indexed media view count",
"uri", aturi.String(), "video", rec.Video, "count", rec.Count, "reporter", userDID)
+ case *streamplace.VodComment:
+ repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID)
+ if err != nil {
+ return fmt.Errorf("failed to sync bluesky repo: %w", err)
+ }
+
+ log.Debug(ctx, "place.stream.vod.comment detected", "video", rec.Video, "repo", repo.Handle)
+
+ // Check if the video author has blocked the commenter
+ videoATURI, parseErr := syntax.ParseATURI(rec.Video)
+ var videoAuthor string
+ if parseErr == nil {
+ videoAuthor = videoATURI.Authority().String()
+ block, err := atsync.Model.GetUserBlock(ctx, videoAuthor, userDID)
+ if err != nil {
+ log.Warn(ctx, "failed to check user block for VOD comment", "err", err)
+ } else if block != nil {
+ log.Debug(ctx, "excluding VOD comment from blocked user", "userDID", userDID, "videoAuthor", videoAuthor)
+ return nil
+ }
+ } else {
+ log.Warn(ctx, "failed to parse video URI for block check", "video", rec.Video, "err", err)
+ }
+
+ vc := &model.VodComment{
+ CID: cid,
+ URI: aturi.String(),
+ CreatedAt: now,
+ Comment: recCBOR,
+ RepoDID: userDID,
+ Repo: repo,
+ VideoURI: rec.Video,
+ VideoAuthorDID: videoAuthor,
+ IndexedAt: &now,
+ }
+ if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil {
+ vc.ReplyToCID = &rec.Reply.Parent.Cid
+ }
+
+ // check for javascript: links in facets
+ for _, facet := range rec.Facets {
+ for _, feature := range facet.Features {
+ if link := feature.RichtextFacet_Link; link != nil {
+ if link.Uri != "" && strings.HasPrefix(strings.ToLower(link.Uri), "javascript:") {
+ log.Warn(ctx, "excluding comment with javascript: link", "uri", aturi.String(), "link", link.Uri)
+ return nil
+ }
+ }
+ }
+ }
+
+ err = atsync.Model.CreateVodComment(ctx, vc)
+ if err != nil {
+ log.Error(ctx, "failed to create VOD comment", "err", err)
+ return nil
+ }
+ vc, err = atsync.Model.GetVodComment(aturi.String())
+ if err != nil {
+ log.Error(ctx, "failed to get just-saved VOD comment", "err", err)
+ return nil
+ }
+ if vc == nil {
+ log.Error(ctx, "failed to retrieve just-saved VOD comment")
+ return nil
+ }
+ sc, err := vc.ToStreamplaceCommentView()
+ if err != nil {
+ log.Error(ctx, "failed to convert VOD comment to view", "err", err)
+ return nil
+ }
+
+ if sc.Author.Handle == "" || sc.Author.Handle == "handle.invalid" {
+ sc.Author.Handle = atsync.ResolveAuthorHandle(ctx, sc.Author.Did)
+ }
+
+ if videoAuthor != "" {
+ go atsync.Bus.Publish(videoAuthor, sc)
+ } else {
+ go atsync.Bus.Publish(userDID, sc)
+ }
+
+ case *streamplace.VodLike:
+ repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID)
+ if err != nil {
+ return fmt.Errorf("failed to sync bluesky repo: %w", err)
+ }
+
+ log.Debug(ctx, "place.stream.vod.like detected", "subject", rec.Subject, "repo", repo.Handle)
+
+ like := &model.VodLike{
+ CID: cid,
+ URI: aturi.String(),
+ Subject: rec.Subject,
+ RepoDID: userDID,
+ Repo: repo,
+ IndexedAt: &now,
+ CreatedAt: now,
+ }
+ err = atsync.Model.CreateVodLike(ctx, like)
+ if err != nil {
+ log.Error(ctx, "failed to create VOD like", "err", err)
+ return nil
+ }
+
+ case *streamplace.VodGate:
+ repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID)
+ if err != nil {
+ return fmt.Errorf("failed to sync bluesky repo: %w", err)
+ }
+ if r == nil {
+ // someone we don't know about
+ return nil
+ }
+ log.Debug(ctx, "creating VOD gate", "userDID", userDID, "hiddenComment", rec.HiddenComment)
+ gate := &model.VodGate{
+ RKey: rkey.String(),
+ RepoDID: userDID,
+ HiddenComment: rec.HiddenComment,
+ CID: cid,
+ CreatedAt: now,
+ Repo: repo,
+ }
+ err = atsync.Model.CreateVodGate(ctx, gate)
+ if err != nil {
+ return fmt.Errorf("failed to create VOD gate: %w", err)
+ }
+
default:
log.Debug(ctx, "unhandled record type", "type", reflect.TypeOf(rec))
}
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 50a15871..6880168c 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -26,6 +26,9 @@ var PLACE_STREAM_MEDIA_TRACK = "place.stream.media.track" //
var PLACE_STREAM_MEDIA_ORIGIN = "place.stream.media.origin" //nolint:all
var PLACE_STREAM_MEDIA_VIEW_COUNT = "place.stream.media.viewCount" //nolint:all
var PLACE_STREAM_BETA_INVITE = "place.stream.beta.invite" //nolint:all
+var PLACE_STREAM_VOD_COMMENT = "place.stream.vod.comment" //nolint:all
+var PLACE_STREAM_VOD_LIKE = "place.stream.vod.like" //nolint:all
+var PLACE_STREAM_VOD_GATE = "place.stream.vod.gate" //nolint:all
// Streamplace badge types
const (
diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go
index 822b88bc..a108d300 100644
--- a/pkg/gen/gen.go
+++ b/pkg/gen/gen.go
@@ -56,6 +56,10 @@ func main() {
streamplace.MediaViewCount_TrackUsage{},
streamplace.Video_Connection{},
streamplace.BetaInvite{},
+ streamplace.VodComment{},
+ streamplace.VodComment_ReplyRef{},
+ streamplace.VodLike{},
+ streamplace.VodGate{},
); err != nil {
panic(err)
}
diff --git a/pkg/model/model.go b/pkg/model/model.go
index 98a6fa35..ddc01182 100644
--- a/pkg/model/model.go
+++ b/pkg/model/model.go
@@ -157,8 +157,27 @@ type Model interface {
GetMediaViewCountByURI(ctx context.Context, uri string) (*streamplace.MediaViewCount, error)
GetVideoView(ctx context.Context, uri string) (*streamplace.MediaGetVideo_VideoView, error)
GetVideoList(ctx context.Context, repoDID string, limit int, cursor string) (*streamplace.MediaGetVideoList_Output, error)
+
+ CreateVodComment(ctx context.Context, comment *VodComment) error
+ DeleteVodComment(ctx context.Context, uri string, deletedAt *time.Time) error
+ GetVodComment(uri string) (*VodComment, error)
+ GetCommentsForVideo(ctx context.Context, videoURI string, limit int, cursor *time.Time) ([]*streamplace.VodDefs_CommentView, *time.Time, error)
+
+ CreateVodLike(ctx context.Context, like *VodLike) error
+ DeleteVodLike(ctx context.Context, uri string) error
+ GetVodLike(uri string) (*VodLike, error)
+ GetVodLikeBySubjectAndUser(ctx context.Context, subject string, repoDID string) (*VodLike, error)
+ GetLikesForSubject(ctx context.Context, subject string, limit int, cursor *time.Time) ([]*streamplace.VodGetLikes_LikeView, int64, *time.Time, error)
+ GetLikeCount(ctx context.Context, subject string) (int64, error)
+
+ CreateVodGate(ctx context.Context, gate *VodGate) error
+ DeleteVodGate(ctx context.Context, rkey string) error
+ GetVodGate(ctx context.Context, rkey string) (*VodGate, error)
+ GetUserVodGates(ctx context.Context, userDID string) ([]*VodGate, error)
}
+// DO NOT UPDATE THIS UNLESS A BREAKING CHANGE IS MADE
+// WHICH ALSO SHOULD NOT HAPPEN
var DBRevision = 4
func MakeDB(dbURL string) (Model, error) {
@@ -235,6 +254,9 @@ func MakeDB(dbURL string) (Model, error) {
MediaOrigin{},
MediaViewCount{},
BetaInvite{},
+ VodComment{},
+ VodLike{},
+ VodGate{},
} {
err = db.AutoMigrate(model)
if err != nil {
diff --git a/pkg/model/vod_comment.go b/pkg/model/vod_comment.go
new file mode 100644
index 00000000..c9ff7788
--- /dev/null
+++ b/pkg/model/vod_comment.go
@@ -0,0 +1,141 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "github.com/rivo/uniseg"
+ "gorm.io/gorm"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+type VodComment struct {
+ CID string `json:"cid" gorm:"primaryKey;column:cid"`
+ URI string `json:"uri" gorm:"column:uri"`
+ CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;index:idx_vod_comments_video,priority:2"`
+ Comment *[]byte `json:"comment" gorm:"column:comment"`
+ RepoDID string `json:"repoDID" gorm:"column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ IndexedAt *time.Time `json:"indexedAt" gorm:"column:indexed_at"`
+ VideoURI string `json:"videoURI" gorm:"column:video_uri;index:idx_vod_comments_video,priority:1"`
+ VideoAuthorDID string `json:"videoAuthorDID" gorm:"column:video_author_did"`
+ ReplyToCID *string `json:"replyToCID" gorm:"column:reply_to_cid"`
+ ReplyTo *VodComment `json:"replyTo,omitempty" gorm:"foreignKey:ReplyToCID;references:CID"`
+ DeletedAt *time.Time `json:"deletedAt" gorm:"column:deleted_at"`
+}
+
+func (c *VodComment) ToStreamplaceCommentView() (*streamplace.VodDefs_CommentView, error) {
+ rec, err := lexutil.CborDecodeValue(*c.Comment)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding comment: %w", err)
+ }
+ if msg, ok := rec.(*streamplace.VodComment); ok {
+ graphemeCount := uniseg.GraphemeClusterCount(msg.Text)
+ if graphemeCount > 300 {
+ gr := uniseg.NewGraphemes(msg.Text)
+ var result strings.Builder
+ for count := 0; count < 300 && gr.Next(); count++ {
+ result.WriteString(gr.Str())
+ }
+ msg.Text = result.String()
+ }
+ }
+ commentView := &streamplace.VodDefs_CommentView{
+ LexiconTypeID: "place.stream.vod.defs#commentView",
+ }
+ commentView.Uri = c.URI
+ commentView.Cid = c.CID
+ commentView.Author = &bsky.ActorDefs_ProfileViewBasic{Did: c.RepoDID}
+ if c.Repo != nil {
+ commentView.Author.Handle = c.Repo.Handle
+ }
+ commentView.Record = &lexutil.LexiconTypeDecoder{Val: rec}
+ commentView.IndexedAt = c.IndexedAt.UTC().Format(time.RFC3339Nano)
+ commentView.LikeCount = 0
+ if c.ReplyTo != nil {
+ replyTo, err := c.ReplyTo.ToStreamplaceCommentView()
+ if err != nil {
+ return nil, fmt.Errorf("error converting reply to comment view: %w", err)
+ }
+ commentView.ReplyTo = &streamplace.VodDefs_CommentView_ReplyTo{
+ VodDefs_CommentView: replyTo,
+ }
+ }
+ return commentView, nil
+}
+
+func (m *DBModel) CreateVodComment(ctx context.Context, comment *VodComment) error {
+ return m.DB.Create(comment).Error
+}
+
+func (m *DBModel) DeleteVodComment(ctx context.Context, uri string, deletedAt *time.Time) error {
+ tx := m.DB.Model(&VodComment{}).Where("uri = ?", uri).Update("deleted_at", deletedAt)
+ if tx.Error != nil {
+ return tx.Error
+ }
+ if tx.RowsAffected == 0 {
+ return fmt.Errorf("no VOD comment found for uri: %s", uri)
+ }
+ return nil
+}
+
+func (m *DBModel) GetVodComment(uri string) (*VodComment, error) {
+ var comment VodComment
+ err := m.DB.
+ Preload("Repo").
+ Preload("ReplyTo").
+ Preload("ReplyTo.Repo").
+ Where("uri = ?", uri).
+ Where("deleted_at IS NULL").
+ First(&comment).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("error retrieving VOD comment: %w", err)
+ }
+ return &comment, nil
+}
+
+func (m *DBModel) GetCommentsForVideo(ctx context.Context, videoURI string, limit int, cursor *time.Time) ([]*streamplace.VodDefs_CommentView, *time.Time, error) {
+ dbcomments := []VodComment{}
+ query := m.DB.
+ Preload("Repo").
+ Preload("ReplyTo").
+ Preload("ReplyTo.Repo").
+ Where("video_uri = ?", videoURI).
+ Where("vod_comments.deleted_at IS NULL").
+ Joins("LEFT JOIN blocks ON blocks.repo_did = vod_comments.video_author_did AND blocks.subject_did = vod_comments.repo_did").
+ Where("blocks.rkey IS NULL").
+ Joins("LEFT JOIN vod_gates ON vod_gates.hidden_comment = vod_comments.uri").
+ Where("vod_gates.rkey IS NULL")
+ if cursor != nil {
+ query = query.Where("vod_comments.created_at < ?", *cursor)
+ }
+ err := query.
+ Limit(limit + 1).
+ Order("vod_comments.created_at DESC").
+ Find(&dbcomments).Error
+ if err != nil {
+ return nil, nil, fmt.Errorf("error retrieving comments: %w", err)
+ }
+ var nextCursor *time.Time
+ if len(dbcomments) > limit {
+ nextCursor = &dbcomments[limit-1].CreatedAt
+ dbcomments = dbcomments[:limit]
+ }
+ spcomments := []*streamplace.VodDefs_CommentView{}
+ for _, c := range dbcomments {
+ spcomment, err := c.ToStreamplaceCommentView()
+ if err != nil {
+ return nil, nil, fmt.Errorf("error converting comment to view: %w", err)
+ }
+ spcomments = append(spcomments, spcomment)
+ }
+ return spcomments, nextCursor, nil
+}
diff --git a/pkg/model/vod_gate.go b/pkg/model/vod_gate.go
new file mode 100644
index 00000000..286da354
--- /dev/null
+++ b/pkg/model/vod_gate.go
@@ -0,0 +1,55 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "gorm.io/gorm"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+type VodGate struct {
+ RKey string `gorm:"primaryKey;column:rkey"`
+ CID string `gorm:"column:cid"`
+ RepoDID string `json:"repoDID" gorm:"column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ HiddenComment string `gorm:"column:hidden_comment" json:"hiddenComment"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+}
+
+func (g *VodGate) ToStreamplaceVodGate() (*streamplace.VodGate, error) {
+ return &streamplace.VodGate{
+ LexiconTypeID: "place.stream.vod.gate",
+ HiddenComment: g.HiddenComment,
+ }, nil
+}
+
+func (m *DBModel) CreateVodGate(ctx context.Context, gate *VodGate) error {
+ return m.DB.Create(gate).Error
+}
+
+func (m *DBModel) GetVodGate(ctx context.Context, rkey string) (*VodGate, error) {
+ var gate VodGate
+ err := m.DB.Preload("Repo").Where("rkey = ?", rkey).First(&gate).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &gate, nil
+}
+
+func (m *DBModel) DeleteVodGate(ctx context.Context, rkey string) error {
+ return m.DB.Where("rkey = ?", rkey).Delete(&VodGate{}).Error
+}
+
+func (m *DBModel) GetUserVodGates(ctx context.Context, userDID string) ([]*VodGate, error) {
+ var gates []*VodGate
+ err := m.DB.Where("repo_did = ?", userDID).Find(&gates).Error
+ if err != nil {
+ return nil, err
+ }
+ return gates, nil
+}
diff --git a/pkg/model/vod_like.go b/pkg/model/vod_like.go
new file mode 100644
index 00000000..a45b2636
--- /dev/null
+++ b/pkg/model/vod_like.go
@@ -0,0 +1,119 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "gorm.io/gorm"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+type VodLike struct {
+ CID string `json:"cid" gorm:"primaryKey;column:cid"`
+ URI string `json:"uri" gorm:"column:uri"`
+ Subject string `json:"subject" gorm:"column:subject;index:idx_vod_likes_subject"`
+ RepoDID string `json:"repoDID" gorm:"column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ IndexedAt *time.Time `json:"indexedAt" gorm:"column:indexed_at"`
+ CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
+}
+
+func (l *VodLike) ToStreamplaceLikeView() (*streamplace.VodGetLikes_LikeView, error) {
+ rec, err := lexutil.CborDecodeValue([]byte{})
+ if err != nil {
+ rec = &streamplace.VodLike{Subject: l.Subject, CreatedAt: l.CreatedAt.Format(time.RFC3339)}
+ }
+ return &streamplace.VodGetLikes_LikeView{
+ Uri: l.URI,
+ Cid: l.CID,
+ Record: &lexutil.LexiconTypeDecoder{Val: rec},
+ Author: &bsky.ActorDefs_ProfileViewBasic{
+ Did: l.RepoDID,
+ Handle: func() string {
+ if l.Repo != nil {
+ return l.Repo.Handle
+ }
+ return ""
+ }(),
+ },
+ IndexedAt: l.IndexedAt.UTC().Format(time.RFC3339Nano),
+ }, nil
+}
+
+func (m *DBModel) CreateVodLike(ctx context.Context, like *VodLike) error {
+ return m.DB.Create(like).Error
+}
+
+func (m *DBModel) DeleteVodLike(ctx context.Context, uri string) error {
+ tx := m.DB.Where("uri = ?", uri).Delete(&VodLike{})
+ if tx.Error != nil {
+ return tx.Error
+ }
+ return nil
+}
+
+func (m *DBModel) GetVodLike(uri string) (*VodLike, error) {
+ var like VodLike
+ err := m.DB.Preload("Repo").Where("uri = ?", uri).First(&like).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &like, nil
+}
+
+func (m *DBModel) GetVodLikeBySubjectAndUser(ctx context.Context, subject string, repoDID string) (*VodLike, error) {
+ var like VodLike
+ err := m.DB.Preload("Repo").Where("subject = ? AND repo_did = ?", subject, repoDID).First(&like).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ return &like, nil
+}
+
+func (m *DBModel) GetLikesForSubject(ctx context.Context, subject string, limit int, cursor *time.Time) ([]*streamplace.VodGetLikes_LikeView, int64, *time.Time, error) {
+ count, err := m.GetLikeCount(ctx, subject)
+ if err != nil {
+ return nil, 0, nil, err
+ }
+ dblikes := []VodLike{}
+ query := m.DB.
+ Preload("Repo").
+ Where("subject = ?", subject)
+ if cursor != nil {
+ query = query.Where("vod_likes.created_at < ?", *cursor)
+ }
+ err = query.
+ Limit(limit + 1).
+ Order("vod_likes.created_at DESC").
+ Find(&dblikes).Error
+ if err != nil {
+ return nil, 0, nil, fmt.Errorf("error retrieving likes: %w", err)
+ }
+ var nextCursor *time.Time
+ if len(dblikes) > limit {
+ nextCursor = &dblikes[limit-1].CreatedAt
+ dblikes = dblikes[:limit]
+ }
+ views := []*streamplace.VodGetLikes_LikeView{}
+ for _, l := range dblikes {
+ view, err := l.ToStreamplaceLikeView()
+ if err != nil {
+ return nil, 0, nil, err
+ }
+ views = append(views, view)
+ }
+ return views, count, nextCursor, nil
+}
+
+func (m *DBModel) GetLikeCount(ctx context.Context, subject string) (int64, error) {
+ var count int64
+ err := m.DB.Model(&VodLike{}).Where("subject = ?", subject).Count(&count).Error
+ return count, err
+}
diff --git a/pkg/moderation/permissions.go b/pkg/moderation/permissions.go
index 92f2b8e7..47d83efa 100644
--- a/pkg/moderation/permissions.go
+++ b/pkg/moderation/permissions.go
@@ -18,6 +18,7 @@ const (
PermissionHide = "hide"
PermissionLivestreamManage = "livestream.manage"
PermissionMessagePin = "message.pin"
+ PermissionVodCommentHide = "vod.comment.hide"
)
// ActionPermissions maps moderation actions to required permissions
@@ -26,6 +27,8 @@ var ActionPermissions = map[string]string{
"deleteBlock": PermissionBan,
"createGate": PermissionHide,
"deleteGate": PermissionHide,
+ "createVodGate": PermissionVodCommentHide,
+ "deleteVodGate": PermissionVodCommentHide,
"updateLivestream": PermissionLivestreamManage,
"createPin": PermissionMessagePin,
"deletePin": PermissionMessagePin,
diff --git a/pkg/spxrpc/place_stream_moderation.go b/pkg/spxrpc/place_stream_moderation.go
index f621f8e3..0bd453c8 100644
--- a/pkg/spxrpc/place_stream_moderation.go
+++ b/pkg/spxrpc/place_stream_moderation.go
@@ -503,3 +503,99 @@ func (s *Server) handlePlaceStreamModerationDeletePin(ctx context.Context, input
return &streamplace.ModerationDeletePin_Output{}, nil
}
+
+// handlePlaceStreamModerationCreateVodGate creates a gate (hide VOD comment) on behalf of a streamer
+func (s *Server) handlePlaceStreamModerationCreateVodGate(ctx context.Context, input *streamplace.ModerationCreateVodGate_Input) (*streamplace.ModerationCreateVodGate_Output, error) {
+ // Validate input
+ if err := validateDID(input.Streamer); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err))
+ }
+ if err := validateATURI(input.CommentUri); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid comment URI: %v", err))
+ }
+
+ // Get delegated moderation context (validates OAuth, permission, and returns client)
+ modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "createVodGate")
+ if err != nil {
+ return nil, err
+ }
+
+ // Create gate record in streamer's repo
+ gate := &streamplace.VodGate{
+ HiddenComment: input.CommentUri,
+ }
+
+ createInput := comatproto.RepoCreateRecord_Input{
+ Collection: constants.PLACE_STREAM_VOD_GATE,
+ Record: &lexutil.LexiconTypeDecoder{Val: gate},
+ Repo: input.Streamer,
+ }
+ createOutput := comatproto.RepoCreateRecord_Output{}
+
+ err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, createInput, &createOutput)
+ if err != nil {
+ log.Error(ctx, "failed to create VOD gate record", "err", err)
+ if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createVodGate", input.CommentUri, "", "", false, err.Error()); auditErr != nil {
+ log.Error(ctx, "failed to create audit log", "error", auditErr)
+ }
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create VOD gate: %v", err))
+ }
+
+ // Log successful audit entry
+ if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createVodGate", input.CommentUri, "", createOutput.Uri, true, ""); err != nil {
+ log.Error(ctx, "failed to create audit log", "error", err)
+ }
+
+ return &streamplace.ModerationCreateVodGate_Output{
+ Uri: createOutput.Uri,
+ Cid: createOutput.Cid,
+ }, nil
+}
+
+// handlePlaceStreamModerationDeleteVodGate deletes a gate (unhide VOD comment) on behalf of a streamer
+func (s *Server) handlePlaceStreamModerationDeleteVodGate(ctx context.Context, input *streamplace.ModerationDeleteVodGate_Input) (*streamplace.ModerationDeleteVodGate_Output, error) {
+ // Validate input
+ if err := validateDID(input.Streamer); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err))
+ }
+ if err := validateATURI(input.GateUri); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid gate URI: %v", err))
+ }
+
+ // Get delegated moderation context (validates OAuth, permission, and returns client)
+ modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "deleteVodGate")
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse gateUri to extract rkey
+ rkey, err := extractRKey(input.GateUri)
+ if err != nil {
+ log.Error(ctx, "failed to extract rkey from gateUri", "uri", input.GateUri, "err", err)
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid gateUri format")
+ }
+
+ // Delete gate record from streamer's repo
+ deleteInput := comatproto.RepoDeleteRecord_Input{
+ Collection: constants.PLACE_STREAM_VOD_GATE,
+ Rkey: rkey,
+ Repo: input.Streamer,
+ }
+ deleteOutput := comatproto.RepoDeleteRecord_Output{}
+
+ err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput)
+ if err != nil {
+ log.Error(ctx, "failed to delete VOD gate record", "err", err)
+ if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deleteVodGate", input.GateUri, "", "", false, err.Error()); auditErr != nil {
+ log.Error(ctx, "failed to create audit log", "error", auditErr)
+ }
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete VOD gate: %v", err))
+ }
+
+ // Log successful audit entry
+ if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deleteVodGate", input.GateUri, "", "", true, ""); err != nil {
+ log.Error(ctx, "failed to create audit log", "error", err)
+ }
+
+ return &streamplace.ModerationDeleteVodGate_Output{}, nil
+}
diff --git a/pkg/spxrpc/place_stream_vod.go b/pkg/spxrpc/place_stream_vod.go
new file mode 100644
index 00000000..9dd074f4
--- /dev/null
+++ b/pkg/spxrpc/place_stream_vod.go
@@ -0,0 +1,80 @@
+package spxrpc
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/labstack/echo/v4"
+
+ placestream "stream.place/streamplace/pkg/streamplace"
+)
+
+func (s *Server) handlePlaceStreamVodGetComments(ctx context.Context, cursor string, limit int, video string) (*placestream.VodGetComments_Output, error) {
+ if video == "" {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "video is required")
+ }
+ if _, err := syntax.ParseATURI(video); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "video must be a valid AT-URI: "+err.Error())
+ }
+ l := 50
+ if limit > 0 && limit <= 100 {
+ l = limit
+ }
+ var c *time.Time
+ if cursor != "" {
+ t, err := time.Parse(time.RFC3339Nano, cursor)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid cursor: "+err.Error())
+ }
+ c = &t
+ }
+ comments, nextCursor, err := s.model.GetCommentsForVideo(ctx, video, l, c)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ out := &placestream.VodGetComments_Output{
+ Comments: comments,
+ }
+ if nextCursor != nil {
+ cs := nextCursor.Format(time.RFC3339Nano)
+ out.Cursor = &cs
+ }
+ return out, nil
+}
+
+func (s *Server) handlePlaceStreamVodGetLikes(ctx context.Context, cursor string, limit int, subject string) (*placestream.VodGetLikes_Output, error) {
+ if subject == "" {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "subject is required")
+ }
+ if _, err := syntax.ParseATURI(subject); err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "subject must be a valid AT-URI: "+err.Error())
+ }
+ l := 50
+ if limit > 0 && limit <= 100 {
+ l = limit
+ }
+ var c *time.Time
+ if cursor != "" {
+ t, err := time.Parse(time.RFC3339Nano, cursor)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid cursor: "+err.Error())
+ }
+ c = &t
+ }
+ likes, count, nextCursor, err := s.model.GetLikesForSubject(ctx, subject, l, c)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ out := &placestream.VodGetLikes_Output{
+ Subject: subject,
+ Count: count,
+ Likes: likes,
+ }
+ if nextCursor != nil {
+ cs := nextCursor.Format(time.RFC3339Nano)
+ out.Cursor = &cs
+ }
+ return out, nil
+}
diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go
index 1bc0504a..010c9a6e 100644
--- a/pkg/spxrpc/stubs.go
+++ b/pkg/spxrpc/stubs.go
@@ -310,9 +310,11 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error {
e.POST("/xrpc/place.stream.moderation.createBlock", s.HandlePlaceStreamModerationCreateBlock)
e.POST("/xrpc/place.stream.moderation.createGate", s.HandlePlaceStreamModerationCreateGate)
e.POST("/xrpc/place.stream.moderation.createPin", s.HandlePlaceStreamModerationCreatePin)
+ e.POST("/xrpc/place.stream.moderation.createVodGate", s.HandlePlaceStreamModerationCreateVodGate)
e.POST("/xrpc/place.stream.moderation.deleteBlock", s.HandlePlaceStreamModerationDeleteBlock)
e.POST("/xrpc/place.stream.moderation.deleteGate", s.HandlePlaceStreamModerationDeleteGate)
e.POST("/xrpc/place.stream.moderation.deletePin", s.HandlePlaceStreamModerationDeletePin)
+ e.POST("/xrpc/place.stream.moderation.deleteVodGate", s.HandlePlaceStreamModerationDeleteVodGate)
e.POST("/xrpc/place.stream.moderation.updateLivestream", s.HandlePlaceStreamModerationUpdateLivestream)
e.POST("/xrpc/place.stream.multistream.createTarget", s.HandlePlaceStreamMultistreamCreateTarget)
e.POST("/xrpc/place.stream.multistream.deleteTarget", s.HandlePlaceStreamMultistreamDeleteTarget)
@@ -333,6 +335,8 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error {
e.GET("/xrpc/place.stream.server.listWebhooks", s.HandlePlaceStreamServerListWebhooks)
e.POST("/xrpc/place.stream.server.updateWebhook", s.HandlePlaceStreamServerUpdateWebhook)
e.POST("/xrpc/place.stream.server.upsertStorage", s.HandlePlaceStreamServerUpsertStorage)
+ e.GET("/xrpc/place.stream.vod.getComments", s.HandlePlaceStreamVodGetComments)
+ e.GET("/xrpc/place.stream.vod.getLikes", s.HandlePlaceStreamVodGetLikes)
return nil
}
@@ -825,6 +829,24 @@ func (s *Server) HandlePlaceStreamModerationCreatePin(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamModerationCreateVodGate(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationCreateVodGate")
+ defer span.End()
+
+ var body placestream.ModerationCreateVodGate_Input
+ if err := c.Bind(&body); err != nil {
+ return err
+ }
+ var out *placestream.ModerationCreateVodGate_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamModerationCreateVodGate(ctx context.Context,body *placestream.ModerationCreateVodGate_Input) (*placestream.ModerationCreateVodGate_Output, error)
+ out, handleErr = s.handlePlaceStreamModerationCreateVodGate(ctx, &body)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamModerationDeleteBlock(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeleteBlock")
defer span.End()
@@ -879,6 +901,24 @@ func (s *Server) HandlePlaceStreamModerationDeletePin(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamModerationDeleteVodGate(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeleteVodGate")
+ defer span.End()
+
+ var body placestream.ModerationDeleteVodGate_Input
+ if err := c.Bind(&body); err != nil {
+ return err
+ }
+ var out *placestream.ModerationDeleteVodGate_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamModerationDeleteVodGate(ctx context.Context,body *placestream.ModerationDeleteVodGate_Input) (*placestream.ModerationDeleteVodGate_Output, error)
+ out, handleErr = s.handlePlaceStreamModerationDeleteVodGate(ctx, &body)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamModerationUpdateLivestream(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationUpdateLivestream")
defer span.End()
@@ -1250,6 +1290,58 @@ func (s *Server) HandlePlaceStreamServerUpsertStorage(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamVodGetComments(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamVodGetComments")
+ 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 = 50
+ }
+ video := c.QueryParam("video")
+ var out *placestream.VodGetComments_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamVodGetComments(ctx context.Context,cursor string,limit int,video string) (*placestream.VodGetComments_Output, error)
+ out, handleErr = s.handlePlaceStreamVodGetComments(ctx, cursor, limit, video)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
+func (s *Server) HandlePlaceStreamVodGetLikes(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamVodGetLikes")
+ 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 = 50
+ }
+ subject := c.QueryParam("subject")
+ var out *placestream.VodGetLikes_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamVodGetLikes(ctx context.Context,cursor string,limit int,subject string) (*placestream.VodGetLikes_Output, error)
+ out, handleErr = s.handlePlaceStreamVodGetLikes(ctx, cursor, limit, subject)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) RegisterHandlersToolsOzone(e *echo.Echo) error {
return nil
}
diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go
index 2d4310d6..f47b0b30 100644
--- a/pkg/streamplace/cbor_gen.go
+++ b/pkg/streamplace/cbor_gen.go
@@ -11455,3 +11455,759 @@ func (t *BetaInvite) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
+func (t *VodComment) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+ fieldCount := 6
+
+ if t.Facets == nil {
+ fieldCount--
+ }
+
+ if t.Reply == nil {
+ fieldCount--
+ }
+
+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+ return err
+ }
+
+ // t.Text (string) (string)
+ if len("text") > 1000000 {
+ return xerrors.Errorf("Value in field \"text\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("text"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("text")); err != nil {
+ return err
+ }
+
+ if len(t.Text) > 1000000 {
+ return xerrors.Errorf("Value in field t.Text was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Text))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.Text)); 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.vod.comment"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.vod.comment")); err != nil {
+ return err
+ }
+
+ // t.Reply (streamplace.VodComment_ReplyRef) (struct)
+ if t.Reply != nil {
+
+ if len("reply") > 1000000 {
+ return xerrors.Errorf("Value in field \"reply\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reply"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("reply")); err != nil {
+ return err
+ }
+
+ if err := t.Reply.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ }
+
+ // t.Video (string) (string)
+ if len("video") > 1000000 {
+ return xerrors.Errorf("Value in field \"video\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("video"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("video")); err != nil {
+ return err
+ }
+
+ if len(t.Video) > 1000000 {
+ return xerrors.Errorf("Value in field t.Video was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Video))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.Video)); err != nil {
+ return err
+ }
+
+ // t.Facets ([]*streamplace.RichtextFacet) (slice)
+ if t.Facets != nil {
+
+ if len("facets") > 1000000 {
+ return xerrors.Errorf("Value in field \"facets\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("facets"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("facets")); err != nil {
+ return err
+ }
+
+ if len(t.Facets) > 8192 {
+ return xerrors.Errorf("Slice value in field t.Facets was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Facets))); err != nil {
+ return err
+ }
+ for _, v := range t.Facets {
+ if err := v.MarshalCBOR(cw); err != nil {
+ return err
+ }
+
+ }
+ }
+
+ // t.CreatedAt (string) (string)
+ if len("createdAt") > 1000000 {
+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("createdAt")); err != nil {
+ return err
+ }
+
+ if len(t.CreatedAt) > 1000000 {
+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *VodComment) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = VodComment{}
+
+ 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("VodComment: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 9)
+ 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.Text (string) (string)
+ case "text":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Text = string(sval)
+ }
+ // t.LexiconTypeID (string) (string)
+ case "$type":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.LexiconTypeID = string(sval)
+ }
+ // t.Reply (streamplace.VodComment_ReplyRef) (struct)
+ case "reply":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.Reply = new(VodComment_ReplyRef)
+ if err := t.Reply.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.Reply pointer: %w", err)
+ }
+ }
+
+ }
+ // t.Video (string) (string)
+ case "video":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Video = string(sval)
+ }
+ // t.Facets ([]*streamplace.RichtextFacet) (slice)
+ case "facets":
+
+ maj, extra, err = cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+
+ if extra > 8192 {
+ return fmt.Errorf("t.Facets: array too large (%d)", extra)
+ }
+
+ if maj != cbg.MajArray {
+ return fmt.Errorf("expected cbor array")
+ }
+
+ if extra > 0 {
+ t.Facets = make([]*RichtextFacet, extra)
+ }
+
+ for i := 0; i < int(extra); i++ {
+ {
+ var maj byte
+ var extra uint64
+ var err error
+ _ = maj
+ _ = extra
+ _ = err
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.Facets[i] = new(RichtextFacet)
+ if err := t.Facets[i].UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.Facets[i] pointer: %w", err)
+ }
+ }
+
+ }
+
+ }
+ }
+ // t.CreatedAt (string) (string)
+ case "createdAt":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CreatedAt = 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 *VodComment_ReplyRef) 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.Root (atproto.RepoStrongRef) (struct)
+ if len("root") > 1000000 {
+ return xerrors.Errorf("Value in field \"root\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("root"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("root")); err != nil {
+ return err
+ }
+
+ if err := t.Root.MarshalCBOR(cw); err != nil {
+ return err
+ }
+
+ // t.Parent (atproto.RepoStrongRef) (struct)
+ if len("parent") > 1000000 {
+ return xerrors.Errorf("Value in field \"parent\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("parent"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("parent")); err != nil {
+ return err
+ }
+
+ if err := t.Parent.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *VodComment_ReplyRef) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = VodComment_ReplyRef{}
+
+ 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("VodComment_ReplyRef: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 6)
+ 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.Root (atproto.RepoStrongRef) (struct)
+ case "root":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.Root = new(atproto.RepoStrongRef)
+ if err := t.Root.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.Root pointer: %w", err)
+ }
+ }
+
+ }
+ // t.Parent (atproto.RepoStrongRef) (struct)
+ case "parent":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.Parent = new(atproto.RepoStrongRef)
+ if err := t.Parent.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.Parent pointer: %w", err)
+ }
+ }
+
+ }
+
+ 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 *VodLike) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+
+ if _, err := cw.Write([]byte{163}); 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.vod.like"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.vod.like")); err != nil {
+ return err
+ }
+
+ // t.Subject (string) (string)
+ if len("subject") > 1000000 {
+ return xerrors.Errorf("Value in field \"subject\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("subject")); err != nil {
+ return err
+ }
+
+ if len(t.Subject) > 1000000 {
+ return xerrors.Errorf("Value in field t.Subject was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.Subject)); err != nil {
+ return err
+ }
+
+ // t.CreatedAt (string) (string)
+ if len("createdAt") > 1000000 {
+ return xerrors.Errorf("Value in field \"createdAt\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("createdAt")); err != nil {
+ return err
+ }
+
+ if len(t.CreatedAt) > 1000000 {
+ return xerrors.Errorf("Value in field t.CreatedAt was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *VodLike) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = VodLike{}
+
+ 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("VodLike: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 9)
+ 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.Subject (string) (string)
+ case "subject":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Subject = string(sval)
+ }
+ // t.CreatedAt (string) (string)
+ case "createdAt":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CreatedAt = 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 *VodGate) 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.vod.gate"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.vod.gate")); err != nil {
+ return err
+ }
+
+ // t.HiddenComment (string) (string)
+ if len("hiddenComment") > 1000000 {
+ return xerrors.Errorf("Value in field \"hiddenComment\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("hiddenComment"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("hiddenComment")); err != nil {
+ return err
+ }
+
+ if len(t.HiddenComment) > 1000000 {
+ return xerrors.Errorf("Value in field t.HiddenComment was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.HiddenComment))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.HiddenComment)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (t *VodGate) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = VodGate{}
+
+ 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("VodGate: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 13)
+ 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.HiddenComment (string) (string)
+ case "hiddenComment":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.HiddenComment = 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/moderationcreateVodGate.go b/pkg/streamplace/moderationcreateVodGate.go
new file mode 100644
index 00000000..291d8196
--- /dev/null
+++ b/pkg/streamplace/moderationcreateVodGate.go
@@ -0,0 +1,37 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.moderation.createVodGate
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// ModerationCreateVodGate_Input is the input argument to a place.stream.moderation.createVodGate call.
+type ModerationCreateVodGate_Input struct {
+ // commentUri: The AT-URI of the VOD comment to hide.
+ CommentUri string `json:"commentUri" cborgen:"commentUri"`
+ // streamer: The DID of the streamer.
+ Streamer string `json:"streamer" cborgen:"streamer"`
+}
+
+// ModerationCreateVodGate_Output is the output of a place.stream.moderation.createVodGate call.
+type ModerationCreateVodGate_Output struct {
+ // cid: The CID of the created gate record.
+ Cid string `json:"cid" cborgen:"cid"`
+ // uri: The AT-URI of the created gate record.
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+// ModerationCreateVodGate calls the XRPC method "place.stream.moderation.createVodGate".
+func ModerationCreateVodGate(ctx context.Context, c lexutil.LexClient, input *ModerationCreateVodGate_Input) (*ModerationCreateVodGate_Output, error) {
+ var out ModerationCreateVodGate_Output
+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.createVodGate", nil, input, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/moderationdeleteVodGate.go b/pkg/streamplace/moderationdeleteVodGate.go
new file mode 100644
index 00000000..baefcf1a
--- /dev/null
+++ b/pkg/streamplace/moderationdeleteVodGate.go
@@ -0,0 +1,33 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.moderation.deleteVodGate
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// ModerationDeleteVodGate_Input is the input argument to a place.stream.moderation.deleteVodGate call.
+type ModerationDeleteVodGate_Input struct {
+ // gateUri: The AT-URI of the gate record to delete.
+ GateUri string `json:"gateUri" cborgen:"gateUri"`
+ // streamer: The DID of the streamer.
+ Streamer string `json:"streamer" cborgen:"streamer"`
+}
+
+// ModerationDeleteVodGate_Output is the output of a place.stream.moderation.deleteVodGate call.
+type ModerationDeleteVodGate_Output struct {
+}
+
+// ModerationDeleteVodGate calls the XRPC method "place.stream.moderation.deleteVodGate".
+func ModerationDeleteVodGate(ctx context.Context, c lexutil.LexClient, input *ModerationDeleteVodGate_Input) (*ModerationDeleteVodGate_Output, error) {
+ var out ModerationDeleteVodGate_Output
+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.deleteVodGate", nil, input, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/vodcomment.go b/pkg/streamplace/vodcomment.go
new file mode 100644
index 00000000..b4b693e9
--- /dev/null
+++ b/pkg/streamplace/vodcomment.go
@@ -0,0 +1,33 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.comment
+
+package streamplace
+
+import (
+ comatproto "github.com/bluesky-social/indigo/api/atproto"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ lexutil.RegisterType("place.stream.vod.comment", &VodComment{})
+}
+
+type VodComment struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.comment"`
+ // createdAt: Client-declared timestamp when this comment was created.
+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+ // facets: Annotations of text (mentions, URLs, etc)
+ Facets []*RichtextFacet `json:"facets,omitempty" cborgen:"facets,omitempty"`
+ Reply *VodComment_ReplyRef `json:"reply,omitempty" cborgen:"reply,omitempty"`
+ // text: The comment text content.
+ Text string `json:"text" cborgen:"text"`
+ // video: AT-URI of the place.stream.video record this comment belongs to.
+ Video string `json:"video" cborgen:"video"`
+}
+
+// VodComment_ReplyRef is a "replyRef" in the place.stream.vod.comment schema.
+type VodComment_ReplyRef struct {
+ Parent *comatproto.RepoStrongRef `json:"parent" cborgen:"parent"`
+ Root *comatproto.RepoStrongRef `json:"root" cborgen:"root"`
+}
diff --git a/pkg/streamplace/voddefs.go b/pkg/streamplace/voddefs.go
new file mode 100644
index 00000000..ce229064
--- /dev/null
+++ b/pkg/streamplace/voddefs.go
@@ -0,0 +1,53 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.defs
+
+package streamplace
+
+import (
+ "encoding/json"
+ "fmt"
+
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// VodDefs_CommentView is a "commentView" in the place.stream.vod.defs schema.
+type VodDefs_CommentView struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.defs#commentView"`
+ Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"`
+ Cid string `json:"cid" cborgen:"cid"`
+ IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+ // likeCount: Number of likes on this comment.
+ LikeCount int64 `json:"likeCount" cborgen:"likeCount"`
+ Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
+ ReplyTo *VodDefs_CommentView_ReplyTo `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+type VodDefs_CommentView_ReplyTo struct {
+ VodDefs_CommentView *VodDefs_CommentView
+}
+
+func (t *VodDefs_CommentView_ReplyTo) MarshalJSON() ([]byte, error) {
+ if t.VodDefs_CommentView != nil {
+ t.VodDefs_CommentView.LexiconTypeID = "place.stream.vod.defs#commentView"
+ return json.Marshal(t.VodDefs_CommentView)
+ }
+ return nil, fmt.Errorf("can not marshal empty union as JSON")
+}
+
+func (t *VodDefs_CommentView_ReplyTo) UnmarshalJSON(b []byte) error {
+ typ, err := lexutil.TypeExtract(b)
+ if err != nil {
+ return err
+ }
+
+ switch typ {
+ case "place.stream.vod.defs#commentView":
+ t.VodDefs_CommentView = new(VodDefs_CommentView)
+ return json.Unmarshal(b, t.VodDefs_CommentView)
+ default:
+ return nil
+ }
+}
diff --git a/pkg/streamplace/vodgate.go b/pkg/streamplace/vodgate.go
new file mode 100644
index 00000000..dff664b7
--- /dev/null
+++ b/pkg/streamplace/vodgate.go
@@ -0,0 +1,19 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.gate
+
+package streamplace
+
+import (
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ lexutil.RegisterType("place.stream.vod.gate", &VodGate{})
+}
+
+type VodGate struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.gate"`
+ // hiddenComment: URI of the hidden VOD comment.
+ HiddenComment string `json:"hiddenComment" cborgen:"hiddenComment"`
+}
diff --git a/pkg/streamplace/vodgetComments.go b/pkg/streamplace/vodgetComments.go
new file mode 100644
index 00000000..65eccbb5
--- /dev/null
+++ b/pkg/streamplace/vodgetComments.go
@@ -0,0 +1,40 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.getComments
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// VodGetComments_Output is the output of a place.stream.vod.getComments call.
+type VodGetComments_Output struct {
+ Comments []*VodDefs_CommentView `json:"comments" cborgen:"comments"`
+ Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
+}
+
+// VodGetComments calls the XRPC method "place.stream.vod.getComments".
+//
+// cursor: Optional pagination cursor.
+// limit: Maximum number of comments to return.
+// video: AT-URI of the place.stream.video record.
+func VodGetComments(ctx context.Context, c lexutil.LexClient, cursor string, limit int64, video string) (*VodGetComments_Output, error) {
+ var out VodGetComments_Output
+
+ params := map[string]interface{}{}
+ if cursor != "" {
+ params["cursor"] = cursor
+ }
+ if limit != 0 {
+ params["limit"] = limit
+ }
+ params["video"] = video
+ if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.vod.getComments", params, nil, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/vodgetLikes.go b/pkg/streamplace/vodgetLikes.go
new file mode 100644
index 00000000..679d7b12
--- /dev/null
+++ b/pkg/streamplace/vodgetLikes.go
@@ -0,0 +1,53 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.getLikes
+
+package streamplace
+
+import (
+ "context"
+
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// VodGetLikes_LikeView is a "likeView" in the place.stream.vod.getLikes schema.
+type VodGetLikes_LikeView struct {
+ Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"`
+ Cid string `json:"cid" cborgen:"cid"`
+ IndexedAt string `json:"indexedAt" cborgen:"indexedAt"`
+ Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"`
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+// VodGetLikes_Output is the output of a place.stream.vod.getLikes call.
+type VodGetLikes_Output struct {
+ // count: Total number of likes for this subject.
+ Count int64 `json:"count" cborgen:"count"`
+ Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
+ Likes []*VodGetLikes_LikeView `json:"likes,omitempty" cborgen:"likes,omitempty"`
+ Subject string `json:"subject" cborgen:"subject"`
+}
+
+// VodGetLikes calls the XRPC method "place.stream.vod.getLikes".
+//
+// cursor: Optional pagination cursor.
+// limit: Maximum number of likes to return.
+// subject: AT-URI of the subject (video or comment).
+func VodGetLikes(ctx context.Context, c lexutil.LexClient, cursor string, limit int64, subject string) (*VodGetLikes_Output, error) {
+ var out VodGetLikes_Output
+
+ params := map[string]interface{}{}
+ if cursor != "" {
+ params["cursor"] = cursor
+ }
+ if limit != 0 {
+ params["limit"] = limit
+ }
+ params["subject"] = subject
+ if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.vod.getLikes", params, nil, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/streamplace/vodlike.go b/pkg/streamplace/vodlike.go
new file mode 100644
index 00000000..aae9c58d
--- /dev/null
+++ b/pkg/streamplace/vodlike.go
@@ -0,0 +1,21 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.like
+
+package streamplace
+
+import (
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ lexutil.RegisterType("place.stream.vod.like", &VodLike{})
+}
+
+type VodLike struct {
+ LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.vod.like"`
+ // createdAt: Client-declared timestamp when this like was created.
+ CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+ // subject: AT-URI of the record being liked (a place.stream.video or place.stream.vod.comment).
+ Subject string `json:"subject" cborgen:"subject"`
+}