diff --git a/content-filters-example.json b/content-filters-example.json
new file mode 100644
index 00000000..bf756948
--- /dev/null
+++ b/content-filters-example.json
@@ -0,0 +1,9 @@
+{
+ "content_warnings": {
+ "enabled": true,
+ "blocked_warnings": ["cwarn:violence", "cwarn:nudity", "cwarn:death"]
+ },
+ "distribution_policy": {
+ "enabled": true
+ }
+}
diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx
index a19565f2..35bc5ec1 100644
--- a/js/app/components/live-dashboard/livestream-panel.tsx
+++ b/js/app/components/live-dashboard/livestream-panel.tsx
@@ -1,4 +1,7 @@
import {
+ Button,
+ ContentMetadataForm,
+ Textarea,
useCreateStreamRecord,
useLivestream,
useToast,
@@ -15,8 +18,6 @@ import {
TouchableOpacity,
View,
} from "react-native";
-import { Button } from "../../../components/src/components/ui/button";
-import { Textarea } from "../../../components/src/components/ui/textarea";
import { selectUserProfile } from "../../features/bluesky/blueskySlice";
import { useCaptureVideoFrame } from "../../hooks/useCaptureVideoFrame";
import { useLiveUser } from "../../hooks/useLiveUser";
@@ -170,13 +171,16 @@ function LivestreamPanel() {
const [selectedImage, setSelectedImage] = useState<
string | File | Blob | undefined
>();
- const [mode, setMode] = useState<"create" | "edit">(
+ const [mode, setMode] = useState<"create" | "edit" | "metadata">(
livestream ? "edit" : "create",
);
- const handleModeChange = useCallback((newMode: "create" | "edit") => {
- setMode(newMode);
- }, []);
+ const handleModeChange = useCallback(
+ (newMode: "create" | "edit" | "metadata") => {
+ setMode(newMode);
+ },
+ [],
+ );
const handleSubmit = useCallback(async () => {
if (!title.trim()) return;
@@ -327,6 +331,7 @@ function LivestreamPanel() {
values={[
{ label: "Create", value: "create" },
{ label: "Edit", value: "edit" },
+ { label: "Metadata", value: "metadata" },
]}
style={[{ marginVertical: -2 }]}
selectedValue={mode}
@@ -349,7 +354,16 @@ function LivestreamPanel() {
No active livestream to edit. Start a livestream first!
+ ) : mode === "metadata" ? (
+ // Metadata view
+
+
+
) : (
+ // Create/Edit view
x.livestream);
+ const segment = useLivestreamStore((x) => x.segment);
const did = useDID();
+ // Get content warnings and rights directly from the latest segment
+ const contentWarnings =
+ (segment?.contentWarnings?.warnings as string[]) || [];
+ const contentRights = segment?.contentRights;
+
return (
+
+ {/* Content Metadata - Below the main profile/controls bar */}
+ {(contentWarnings.length > 0 ||
+ (contentRights && Object.keys(contentRights).length > 0)) && (
+
+
+ {contentRights && (
+
+ )}
+
+ )}
);
}
diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx
index f318e567..ce5f7685 100644
--- a/js/app/components/mobile/ui.tsx
+++ b/js/app/components/mobile/ui.tsx
@@ -1,5 +1,7 @@
import { useNavigation } from "@react-navigation/native";
import {
+ ContentRights,
+ ContentWarnings,
PlayerUI,
Slider,
Text,
@@ -7,6 +9,7 @@ import {
useAvatars,
useCameraToggle,
useLivestreamInfo,
+ useLivestreamStore,
useMuted,
usePlayerDimensions,
usePlayerStore,
@@ -67,6 +70,12 @@ export function MobileUi({
const { isPlayerRatioGreater } = useSegmentDimensions();
const { doSetIngestCamera } = useCameraToggle();
const avatars = useAvatars(profile?.did ? [profile?.did] : []);
+ const segment = useLivestreamStore((x) => x.segment);
+
+ // Get content warnings and rights directly from the latest segment
+ const contentWarnings =
+ (segment?.contentWarnings?.warnings as string[]) || [];
+ const contentRights = segment?.contentRights;
const muteWasForced = usePlayerStore((state) => state.muteWasForced);
const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced);
@@ -166,51 +175,87 @@ export function MobileUi({
{/* Top Left - Back Button and Profile */}
-
- {
- navigation.canGoBack()
- ? navigation.goBack()
- : navigation.navigate("Home", { screen: "StreamList" });
- }}
+
+
-
-
-
- {profile?.handle}
+ {
+ navigation.canGoBack()
+ ? navigation.goBack()
+ : navigation.navigate("Home", {
+ screen: "StreamList",
+ });
+ }}
+ >
+
+
+
+ {profile?.handle}
+
+ {/* Content Metadata - Below mute button */}
+ {(contentWarnings.length > 0 ||
+ (contentRights && Object.keys(contentRights).length > 0)) && (
+
+
+ {contentRights && (
+
+ )}
+
+ )}
+
{/* Right Controls Column */}
({
+ createContentMetadata: create.asyncThunk(
+ async (
+ {
+ contentWarnings = [],
+ distributionPolicy = {
+ deleteAfter: undefined,
+ },
+ contentRights = {},
+ }: {
+ contentWarnings?: string[];
+ distributionPolicy?: {
+ deleteAfter?: number;
+ };
+ contentRights?: {
+ creator?: string;
+ copyrightNotice?: string;
+ copyrightYear?: number;
+ license?: string;
+ creditLine?: string;
+ };
+ },
+ thunkAPI,
+ ) => {
+ const { bluesky } = thunkAPI.getState() as {
+ bluesky: BlueskyState;
+ };
+
+ if (!bluesky.pdsAgent) {
+ throw new Error("No agent");
+ }
+
+ const did = bluesky.oauthSession?.did;
+ if (!did) {
+ throw new Error("No DID");
+ }
+
+ const metadataRecord = {
+ $type: "place.stream.metadata.configuration",
+ createdAt: new Date().toISOString(),
+ ...(contentWarnings.length > 0 && {
+ contentWarnings: { warnings: contentWarnings },
+ }),
+ ...(distributionPolicy.deleteAfter && { distributionPolicy }),
+ ...(contentRights &&
+ Object.keys(contentRights).length > 0 && {
+ contentRights,
+ }),
+ };
+
+ const result = await bluesky.pdsAgent.com.atproto.repo.createRecord({
+ repo: did,
+ collection: "place.stream.metadata.configuration",
+ rkey: "self",
+ record: metadataRecord,
+ });
+
+ // Extract rkey from the URI
+ const rkey = result.data.uri.split("/").pop();
+
+ return {
+ record: metadataRecord,
+ uri: result.data.uri,
+ cid: result.data.cid,
+ rkey,
+ };
+ },
+ {
+ pending: (state) => {
+ return {
+ ...state,
+ creating: true,
+ error: null,
+ };
+ },
+ fulfilled: (state, action) => {
+ return {
+ ...state,
+ creating: false,
+ error: null,
+ lastCreatedRecord: action.payload,
+ };
+ },
+ rejected: (state, action) => {
+ return {
+ ...state,
+ creating: false,
+ error: action.error?.message ?? "Failed to create content metadata",
+ };
+ },
+ },
+ ),
+
+ updateContentMetadata: create.asyncThunk(
+ async (
+ {
+ rkey,
+ livestreamRef,
+ contentWarnings = [],
+ distributionPolicy = {
+ deleteAfter: undefined, // No expiration means forever
+ },
+ contentRights = {},
+ }: {
+ rkey?: string;
+ livestreamRef?: {
+ uri: string;
+ cid: string;
+ };
+ contentWarnings?: string[];
+ distributionPolicy?: {
+ deleteAfter?: number;
+ };
+ contentRights?: {
+ creator?: string;
+ copyrightNotice?: string;
+ copyrightYear?: number;
+ license?: string;
+ creditLine?: string;
+ };
+ },
+ thunkAPI,
+ ) => {
+ const { bluesky } = thunkAPI.getState() as {
+ bluesky: BlueskyState;
+ };
+
+ if (!bluesky.pdsAgent) {
+ throw new Error("No agent");
+ }
+
+ const did = bluesky.oauthSession?.did;
+ if (!did) {
+ throw new Error("No DID");
+ }
+
+ const metadataRecord = {
+ $type: "place.stream.metadata.configuration",
+ ...(livestreamRef && { livestreamRef }),
+ createdAt: new Date().toISOString(),
+ ...(contentWarnings.length > 0 && {
+ contentWarnings: { warnings: contentWarnings },
+ }),
+ ...(distributionPolicy.deleteAfter && { distributionPolicy }),
+ ...(contentRights &&
+ Object.keys(contentRights).length > 0 && {
+ contentRights,
+ }),
+ };
+
+ const result = await bluesky.pdsAgent.com.atproto.repo.putRecord({
+ repo: did,
+ collection: "place.stream.metadata.configuration",
+ rkey: "self",
+ record: metadataRecord,
+ });
+
+ return {
+ record: metadataRecord,
+ uri: `at://${did}/place.stream.metadata.configuration/self`,
+ cid: result.data.cid,
+ };
+ },
+ {
+ pending: (state) => {
+ return {
+ ...state,
+ updating: true,
+ error: null,
+ };
+ },
+ fulfilled: (state, action) => {
+ return {
+ ...state,
+ updating: false,
+ error: null,
+ lastCreatedRecord: action.payload,
+ };
+ },
+ rejected: (state, action) => {
+ return {
+ ...state,
+ updating: false,
+ error: action.error?.message ?? "Failed to update content metadata",
+ };
+ },
+ },
+ ),
+
+ getContentMetadata: create.asyncThunk(
+ async (
+ { userDid, rkey = "self" }: { userDid?: string; rkey?: string } = {},
+ thunkAPI,
+ ) => {
+ const { bluesky } = thunkAPI.getState() as {
+ bluesky: BlueskyState;
+ };
+
+ if (!bluesky.pdsAgent) {
+ throw new Error("No agent");
+ }
+
+ // Use provided userDid or fall back to current user's DID
+ const targetDid = userDid || bluesky.oauthSession?.did;
+ if (!targetDid) {
+ throw new Error("No DID provided or user not authenticated");
+ }
+
+ // Add debugging information
+ console.log(`[getContentMetadata] Debug info:`, {
+ targetDid,
+ rkey,
+ pdsAgentType: bluesky.pdsAgent.constructor.name,
+ hasOAuthSession: !!bluesky.oauthSession,
+ currentUserDid: bluesky.oauthSession?.did,
+ pdsAgentHost:
+ (bluesky.pdsAgent as any)?.host ||
+ (bluesky.pdsAgent as any)?.service?.host ||
+ "unknown",
+ pdsAgentUrl:
+ (bluesky.pdsAgent as any)?.url ||
+ (bluesky.pdsAgent as any)?.service?.url ||
+ "unknown",
+ });
+
+ try {
+ // First, try to resolve the correct PDS for the target user
+ let targetPDS = null;
+ try {
+ const didResponse = await fetch(
+ `https://plc.directory/${targetDid}`,
+ );
+ if (didResponse.ok) {
+ const didDoc = await didResponse.json();
+ const pdsService = didDoc.service?.find(
+ (s: any) => s.id === "#atproto_pds",
+ );
+ if (pdsService) {
+ targetPDS = pdsService.serviceEndpoint;
+ console.log(
+ `[getContentMetadata] Resolved PDS for ${targetDid}:`,
+ targetPDS,
+ );
+ }
+ }
+ } catch (pdsResolveError) {
+ console.log(
+ `[getContentMetadata] Failed to resolve PDS for ${targetDid}:`,
+ pdsResolveError,
+ );
+ }
+
+ // Use the target PDS if available, otherwise fall back to the current agent
+ let agent = bluesky.pdsAgent;
+ if (targetPDS && targetPDS !== (bluesky.pdsAgent as any)?.host) {
+ // Create a new agent pointing to the target PDS
+ const { StreamplaceAgent } = await import("streamplace");
+ agent = new StreamplaceAgent(targetPDS) as any;
+ console.log(
+ `[getContentMetadata] Created new agent for PDS:`,
+ targetPDS,
+ );
+ }
+
+ console.log(`[getContentMetadata] Attempting to fetch record from:`, {
+ repo: targetDid,
+ collection: "place.stream.metadata.configuration",
+ rkey,
+ usingPDS: targetPDS || "default",
+ });
+
+ const result = await agent.com.atproto.repo.getRecord({
+ repo: targetDid,
+ collection: "place.stream.metadata.configuration",
+ rkey,
+ });
+
+ console.log(`[getContentMetadata] API response:`, result);
+
+ if (!result.success) {
+ throw new Error("Failed to get content metadata record");
+ }
+
+ return {
+ userDid: targetDid,
+ record: result.data.value,
+ uri: result.data.uri,
+ cid: result.data.cid,
+ };
+ } catch (error) {
+ console.log(`[getContentMetadata] Error details:`, {
+ error: error.message,
+ errorType: error.constructor.name,
+ errorStack: error.stack,
+ });
+
+ // If user doesn't have metadata record, return null instead of throwing
+ if (
+ error.message?.includes("not found") ||
+ error.message?.includes("RecordNotFound")
+ ) {
+ return {
+ userDid: targetDid,
+ record: null,
+ uri: null,
+ cid: null,
+ };
+ }
+ throw error;
+ }
+ },
+ {
+ pending: (state) => {
+ return {
+ ...state,
+ error: null,
+ };
+ },
+ fulfilled: (state, action) => {
+ return {
+ ...state,
+ error: null,
+ lastCreatedRecord: action.payload,
+ };
+ },
+ rejected: (state, action) => {
+ return {
+ ...state,
+ error: action.error?.message ?? "Failed to get content metadata",
+ };
+ },
+ },
+ ),
+
+ clearError: create.reducer((state) => {
+ return {
+ ...state,
+ error: null,
+ };
+ }),
+ }),
+
+ selectors: {
+ selectContentMetadata: (state) => state,
+ selectIsCreating: (state) => state.creating,
+ selectIsUpdating: (state) => state.updating,
+ selectError: (state) => state.error,
+ selectLastCreatedRecord: (state) => state.lastCreatedRecord,
+ },
+});
+
+export const {
+ createContentMetadata,
+ updateContentMetadata,
+ getContentMetadata,
+ clearError,
+} = contentMetadataSlice.actions;
+
+export const {
+ selectContentMetadata,
+ selectIsCreating,
+ selectIsUpdating,
+ selectError,
+ selectLastCreatedRecord,
+} = contentMetadataSlice.selectors;
diff --git a/js/app/store/store.tsx b/js/app/store/store.tsx
index 1d06cfc4..293c156b 100644
--- a/js/app/store/store.tsx
+++ b/js/app/store/store.tsx
@@ -4,6 +4,7 @@ import { setupListeners } from "@reduxjs/toolkit/query";
import { baseSlice } from "features/base/baseSlice";
import { sidebarSlice } from "features/base/sidebarSlice";
import { blueskySlice } from "features/bluesky/blueskySlice";
+import { contentMetadataSlice } from "features/bluesky/contentMetadataSlice";
import { platformSlice } from "features/platform/platformSlice";
import { streamplaceSlice } from "features/streamplace/streamplaceSlice";
@@ -11,6 +12,7 @@ import { listenerMiddleware } from "./listener";
const rootReducer = combineSlices(
blueskySlice,
+ contentMetadataSlice,
streamplaceSlice,
platformSlice,
sidebarSlice,
diff --git a/js/components/src/components/content-metadata/content-metadata-form.tsx b/js/components/src/components/content-metadata/content-metadata-form.tsx
new file mode 100644
index 00000000..41061e48
--- /dev/null
+++ b/js/components/src/components/content-metadata/content-metadata-form.tsx
@@ -0,0 +1,753 @@
+import { forwardRef, useCallback, useEffect, useState } from "react";
+import { ScrollView, View } from "react-native";
+import {
+ CONTENT_WARNINGS,
+ LICENSE_OPTIONS,
+} from "../../lib/metadata-constants";
+
+import {
+ useGetContentMetadata,
+ useSaveContentMetadata,
+} from "../../streamplace-store/content-metadata-actions";
+import { useDID } from "../../streamplace-store/streamplace-store";
+import { usePDSAgent } from "../../streamplace-store/xrpc";
+import * as zero from "../../ui";
+import { Button } from "../ui/button";
+import { Checkbox } from "../ui/checkbox";
+import { Input } from "../ui/input";
+import { Select } from "../ui/select";
+import { Text } from "../ui/text";
+import { Textarea } from "../ui/textarea";
+import { toast, useToast } from "../ui/toast";
+import { Tooltip } from "../ui/tooltip";
+
+const { p, r, bg, borders, w, text, layout, gap, flex } = zero;
+
+// Types
+export interface DistributionPolicy {
+ deleteAfter?: number;
+}
+
+export interface Rights {
+ creator?: string;
+ copyrightNotice?: string;
+ copyrightYear?: string | number;
+ license?: string;
+ creditLine?: string;
+}
+
+export interface ContentMetadata {
+ contentWarnings: { warnings: string[] };
+ distributionPolicy: DistributionPolicy;
+ contentRights: Rights;
+}
+
+export interface ContentMetadataFormProps {
+ showUpdateButton?: boolean;
+ onMetadataChange?: (metadata: ContentMetadata) => void;
+ initialMetadata?: ContentMetadata;
+ style?: any;
+}
+
+// ButtonSelector component (same as in livestream-panel)
+const ButtonSelector = ({
+ values,
+ selectedValue,
+ setSelectedValue,
+ disabledValues = [],
+ style = [],
+}: {
+ values: { label: string; value: string }[];
+ selectedValue: string;
+ setSelectedValue: (value: string) => void;
+ disabledValues?: string[];
+ style?: any[];
+}) => (
+
+ {values.map(({ label, value }) => (
+
+ ))}
+
+);
+
+export const ContentMetadataForm = forwardRef(
+ (
+ { showUpdateButton = false, onMetadataChange, initialMetadata, style },
+ ref,
+ ) => {
+ const pdsAgent = usePDSAgent();
+ const did = useDID();
+ const getContentMetadata = useGetContentMetadata();
+ const saveContentMetadata = useSaveContentMetadata();
+ const { toast: toastState } = useToast();
+
+ // Local state for metadata
+ const [contentWarnings, setContentWarnings] = useState([]);
+ const [distributionPolicy, setDistributionPolicy] =
+ useState({});
+ const [contentRights, setContentRights] = useState({});
+ const [selectedLicense, setSelectedLicense] = useState("");
+ const [customLicenseText, setCustomLicenseText] = useState("");
+ const [customDateTime, setCustomDateTime] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [hasMetadata, setHasMetadata] = useState(false);
+
+ // State for section toggles
+ const [activeSection, setActiveSection] =
+ useState("contentWarnings");
+
+ const currentYear = new Date().getFullYear();
+
+ // Load existing metadata on mount or from initialMetadata prop
+ useEffect(() => {
+ if (initialMetadata) {
+ // Use provided initial metadata
+ if (initialMetadata.contentWarnings?.warnings) {
+ setContentWarnings(initialMetadata.contentWarnings.warnings);
+ }
+ if (initialMetadata.distributionPolicy) {
+ setDistributionPolicy(initialMetadata.distributionPolicy);
+ setCustomDateTime(
+ initialMetadata.distributionPolicy.deleteAfter
+ ? String(initialMetadata.distributionPolicy.deleteAfter)
+ : "",
+ );
+ }
+ if (initialMetadata.contentRights) {
+ setContentRights(initialMetadata.contentRights);
+ setSelectedLicense(initialMetadata.contentRights.license || "");
+ }
+ return;
+ }
+
+ const loadMetadata = async () => {
+ if (!pdsAgent || !did) return;
+
+ try {
+ const metadata = await getContentMetadata();
+ if (metadata?.record) {
+ setHasMetadata(true);
+ if (metadata.record.contentWarnings?.warnings) {
+ setContentWarnings(
+ metadata.record.contentWarnings.warnings as string[],
+ );
+ }
+ if (metadata.record.distributionPolicy) {
+ setDistributionPolicy(metadata.record.distributionPolicy);
+ setCustomDateTime(
+ metadata.record.distributionPolicy.deleteAfter
+ ? String(metadata.record.distributionPolicy.deleteAfter)
+ : "",
+ );
+ }
+ if (metadata.record.contentRights) {
+ setContentRights(metadata.record.contentRights);
+ setSelectedLicense(metadata.record.contentRights.license || "");
+ }
+ }
+ } catch (error) {
+ // No existing metadata is fine
+ console.log("No existing metadata found");
+ }
+ };
+
+ loadMetadata();
+ }, [pdsAgent, did, initialMetadata]);
+
+ const handleContentWarningChange = useCallback(
+ (warning: string, checked: boolean) => {
+ const newWarnings = checked
+ ? [...contentWarnings, warning]
+ : contentWarnings.filter((w) => w !== warning);
+
+ setContentWarnings(newWarnings);
+
+ if (onMetadataChange) {
+ onMetadataChange({
+ contentWarnings: { warnings: newWarnings },
+ distributionPolicy,
+ contentRights,
+ });
+ }
+ },
+ [contentWarnings, distributionPolicy, contentRights, onMetadataChange],
+ );
+
+ // Notify parent component when metadata changes
+ useEffect(() => {
+ if (onMetadataChange) {
+ onMetadataChange({
+ contentWarnings: { warnings: contentWarnings },
+ distributionPolicy,
+ contentRights,
+ });
+ }
+ }, [contentWarnings, distributionPolicy, contentRights, onMetadataChange]);
+
+ // Handle distribution policy changes
+ const handleDistributionPolicyChange = useCallback(
+ (deleteAfter: string) => {
+ const duration = parseInt(deleteAfter, 10);
+ const newPolicy =
+ deleteAfter.trim() !== "" && !isNaN(duration)
+ ? { deleteAfter: duration }
+ : {};
+ setDistributionPolicy(newPolicy);
+
+ if (onMetadataChange) {
+ onMetadataChange({
+ contentWarnings: { warnings: contentWarnings },
+ distributionPolicy: newPolicy,
+ contentRights,
+ });
+ }
+ },
+ [contentWarnings, contentRights, onMetadataChange],
+ );
+
+ // Handle content rights changes
+ const handleContentRightsChange = useCallback(
+ (field: string, value: any) => {
+ const newRights = { ...contentRights, [field]: value };
+ setContentRights(newRights);
+
+ if (onMetadataChange) {
+ onMetadataChange({
+ contentWarnings: { warnings: contentWarnings },
+ distributionPolicy,
+ contentRights: newRights,
+ });
+ }
+ },
+ [contentWarnings, distributionPolicy, contentRights, onMetadataChange],
+ );
+
+ const handleSave = useCallback(async () => {
+ setLoading(true);
+ try {
+ // Build the metadata object, only including non-empty fields
+ const metadata: any = {};
+
+ // Only include contentWarnings if it has values
+ if (contentWarnings && contentWarnings.length > 0) {
+ metadata.contentWarnings = contentWarnings;
+ }
+
+ // Only include distributionPolicy if it has a deleteAfter value
+ if (customDateTime && customDateTime.trim() !== "") {
+ const duration = parseInt(customDateTime, 10);
+ if (!isNaN(duration)) {
+ metadata.distributionPolicy = { deleteAfter: duration };
+ }
+ }
+
+ // Only include contentRights if it has actual values
+ const rightsWithLicense = {
+ ...contentRights,
+ license:
+ selectedLicense === "custom"
+ ? customLicenseText
+ : selectedLicense || undefined,
+ };
+
+ // Filter out empty values from contentRights and convert copyrightYear to number
+ const filteredRights = Object.fromEntries(
+ Object.entries(rightsWithLicense)
+ .filter(
+ ([_, value]) =>
+ value !== undefined && value !== null && value !== "",
+ )
+ .map(([key, value]) => {
+ // Convert copyrightYear to integer as per lexicon
+ if (key === "copyrightYear" && typeof value === "string") {
+ const year = parseInt(value, 10);
+ return [key, isNaN(year) ? undefined : year];
+ }
+ return [key, value];
+ })
+ .filter(([_, value]) => value !== undefined),
+ );
+
+ if (Object.keys(filteredRights).length > 0) {
+ metadata.contentRights = filteredRights;
+ }
+
+ await saveContentMetadata(metadata);
+ setHasMetadata(true);
+ // Show success toast
+ toast.show(
+ hasMetadata ? "Content metadata updated" : "Content metadata created",
+ "Your settings have been saved successfully",
+ );
+ } catch (error) {
+ console.error("Failed to save metadata:", error);
+ // Show error toast
+ toast.show("Failed to save metadata", "Please try again later");
+ } finally {
+ setLoading(false);
+ }
+ }, [
+ contentWarnings,
+ contentRights,
+ selectedLicense,
+ customLicenseText,
+ customDateTime,
+ hasMetadata,
+ saveContentMetadata,
+ ]);
+
+ return (
+ <>
+
+
+ {/* Section Selector */}
+
+
+
+
+ {/* Content Warnings Section */}
+ {activeSection === "contentWarnings" && (
+
+
+
+ Content Warnings
+
+
+ optional
+
+
+
+ {CONTENT_WARNINGS.map((warning) => (
+
+
+
+ handleContentWarningChange(warning.value, checked)
+ }
+ label={warning.label}
+ style={[{ fontSize: 12 }]}
+ />
+
+
+ ))}
+
+
+ )}
+
+ {/* Content Rights Section */}
+ {activeSection === "contentRights" && (
+
+
+
+ Content Rights
+
+
+ optional
+
+
+
+
+
+
+ Copyright Year
+
+
+
+ handleContentRightsChange("copyrightYear", value)
+ }
+ placeholder={currentYear.toString()}
+ variant="filled"
+ inputStyle={[
+ p[3],
+ r.md,
+ bg.neutral[800],
+ text.white,
+ borders.width.thin,
+ borders.color.neutral[600],
+ w.percent[100],
+ ]}
+ />
+
+
+
+
+
+ License
+
+
+
+
+
+ {/* Custom License Text Input */}
+ {selectedLicense === "custom" && (
+
+
+ Custom License
+
+
+
+
+ )}
+
+
+
+ Copyright Notice
+
+
+
+
+
+
+
+ Credit Line
+
+
+
+
+
+
+ )}
+
+ {/* Distribution Section */}
+ {activeSection === "distribution" && (
+
+
+
+ Distribution
+
+
+ optional
+
+
+
+
+
+ Delete After
+
+
+
+ Duration in seconds (e.g., 300 for 5 minutes)
+
+ {
+ setCustomDateTime(value);
+ handleDistributionPolicyChange(value);
+ }}
+ placeholder="300"
+ keyboardType="numeric"
+ variant="filled"
+ inputStyle={[
+ p[3],
+ r.md,
+ bg.neutral[800],
+ text.white,
+ borders.width.thin,
+ borders.color.neutral[600],
+ w.percent[100],
+ ]}
+ />
+
+
+
+
+ )}
+
+ {/* Save Button - Always visible */}
+
+
+
+
+
+ >
+ );
+ },
+);
+
+ContentMetadataForm.displayName = "ContentMetadataForm";
diff --git a/js/components/src/components/content-metadata/content-rights.tsx b/js/components/src/components/content-metadata/content-rights.tsx
new file mode 100644
index 00000000..e3b4b6a5
--- /dev/null
+++ b/js/components/src/components/content-metadata/content-rights.tsx
@@ -0,0 +1,104 @@
+import { forwardRef } from "react";
+import { StyleSheet, View } from "react-native";
+import { LICENSE_URL_LABELS } from "../../lib/metadata-constants";
+import { useTheme } from "../../lib/theme/theme";
+import { Text } from "../ui/text";
+
+export interface ContentRightsProps {
+ contentRights: {
+ creator?: string;
+ copyrightNotice?: string;
+ copyrightYear?: string | number;
+ license?: string;
+ creditLine?: string;
+ };
+ compact?: boolean;
+}
+
+export const ContentRights = forwardRef(
+ ({ contentRights }, ref) => {
+ const { theme } = useTheme();
+
+ if (!contentRights || Object.keys(contentRights).length === 0) {
+ return null;
+ }
+
+ const styles = createStyles(theme);
+
+ const formatLicense = (license: string) => {
+ return LICENSE_URL_LABELS[license] || license;
+ };
+
+ // Display rights in bottom metadata view
+ const elements: string[] = [];
+
+ // TODO: Map DID to handle creator
+ // if (contentRights.creator) {
+ // elements.push(`Creator: ${contentRights.creator}`);
+ // }
+
+ if (contentRights.copyrightYear) {
+ elements.push(`© ${contentRights.copyrightYear.toString()}`);
+ }
+
+ if (contentRights.license) {
+ elements.push(formatLicense(contentRights.license));
+ }
+
+ if (contentRights.copyrightNotice) {
+ elements.push(contentRights.copyrightNotice);
+ }
+
+ if (contentRights.creditLine) {
+ elements.push(contentRights.creditLine);
+ }
+
+ return (
+
+ {elements.join(" • ")}
+
+ );
+ },
+);
+
+ContentRights.displayName = "ContentRights";
+
+function createStyles(theme: any) {
+ return StyleSheet.create({
+ container: {
+ paddingVertical: theme.spacing[3],
+ },
+ title: {
+ fontSize: 14,
+ fontWeight: "600",
+ color: theme.colors.text,
+ marginBottom: theme.spacing[2],
+ },
+ content: {
+ gap: theme.spacing[2],
+ },
+ row: {
+ flexDirection: "row",
+ gap: theme.spacing[2],
+ },
+ label: {
+ fontSize: 13,
+ color: theme.colors.textMuted,
+ },
+ value: {
+ fontSize: 13,
+ color: theme.colors.text,
+ },
+ compactContainer: {
+ flexDirection: "row",
+ gap: theme.spacing[2],
+ flexWrap: "wrap",
+ marginTop: theme.spacing[1],
+ },
+ compactText: {
+ fontSize: 14,
+ fontWeight: "500",
+ color: theme.colors.text,
+ },
+ });
+}
diff --git a/js/components/src/components/content-metadata/content-warnings.tsx b/js/components/src/components/content-metadata/content-warnings.tsx
new file mode 100644
index 00000000..0318391d
--- /dev/null
+++ b/js/components/src/components/content-metadata/content-warnings.tsx
@@ -0,0 +1,100 @@
+import { forwardRef } from "react";
+import { StyleSheet, View } from "react-native";
+import { C2PA_WARNING_LABELS } from "../../lib/metadata-constants";
+import { useTheme } from "../../lib/theme/theme";
+import { Text } from "../ui/text";
+
+export interface ContentWarningsProps {
+ warnings: string[];
+ compact?: boolean;
+}
+
+export const ContentWarnings = forwardRef(
+ ({ warnings, compact = false }, ref) => {
+ const { theme } = useTheme();
+
+ if (!warnings || warnings.length === 0) {
+ return null;
+ }
+
+ const styles = createStyles(theme, compact);
+
+ const getWarningLabel = (warning: string): string => {
+ return C2PA_WARNING_LABELS[warning] || warning;
+ };
+
+ if (compact) {
+ return (
+
+ {warnings.map((warning, index) => (
+
+
+ {getWarningLabel(warning)}
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ Content Warnings
+
+ {warnings.map((warning, index) => (
+
+ {getWarningLabel(warning)}
+
+ ))}
+
+
+ );
+ },
+);
+
+ContentWarnings.displayName = "ContentWarnings";
+
+function createStyles(theme: any, compact: boolean) {
+ return StyleSheet.create({
+ container: {
+ flexDirection: "column",
+ gap: theme.spacing[2],
+ },
+ title: {
+ fontSize: 14,
+ fontWeight: "600",
+ color: theme.colors.text,
+ },
+ warningsContainer: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: theme.spacing[2],
+ },
+ warning: {
+ backgroundColor: theme.colors.warning,
+ borderRadius: theme.borderRadius.md,
+ padding: theme.spacing[2],
+ },
+ warningText: {
+ color: theme.colors.warningForeground,
+ fontSize: 12,
+ fontWeight: "500",
+ },
+ compactContainer: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: theme.spacing[1],
+ },
+ compactWarning: {
+ backgroundColor: theme.colors.warning,
+ borderRadius: theme.borderRadius.full,
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ },
+ compactWarningText: {
+ color: theme.colors.warningForeground,
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ });
+}
diff --git a/js/components/src/components/content-metadata/index.tsx b/js/components/src/components/content-metadata/index.tsx
new file mode 100644
index 00000000..18ee6408
--- /dev/null
+++ b/js/components/src/components/content-metadata/index.tsx
@@ -0,0 +1,18 @@
+// Main form component
+export { ContentMetadataForm } from "./content-metadata-form";
+
+// Display components
+export { ContentRights } from "./content-rights";
+export { ContentWarnings } from "./content-warnings";
+
+// Types
+export type {
+ ContentMetadata,
+ ContentMetadataFormProps,
+ DistributionPolicy,
+ Rights,
+} from "./content-metadata-form";
+
+export type { ContentRightsProps } from "./content-rights";
+
+export type { ContentWarningsProps } from "./content-warnings";
diff --git a/js/components/src/components/ui/checkbox.tsx b/js/components/src/components/ui/checkbox.tsx
new file mode 100644
index 00000000..1f2141ed
--- /dev/null
+++ b/js/components/src/components/ui/checkbox.tsx
@@ -0,0 +1,147 @@
+import { Check } from "lucide-react-native";
+import { forwardRef } from "react";
+import { StyleSheet, TouchableOpacity, View } from "react-native";
+import { useTheme } from "../../lib/theme/theme";
+import { Text } from "./text";
+
+export interface CheckboxProps {
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+ disabled?: boolean;
+ size?: "sm" | "md" | "lg";
+ label?: string;
+ description?: string;
+ style?: any;
+}
+
+export const Checkbox = forwardRef(
+ (
+ {
+ checked,
+ onCheckedChange,
+ disabled = false,
+ size = "md",
+ label,
+ description,
+ style,
+ ...props
+ },
+ ref,
+ ) => {
+ const { theme } = useTheme();
+
+ const handlePress = () => {
+ if (!disabled) {
+ onCheckedChange(!checked);
+ }
+ };
+
+ const styles = createStyles(theme, size, disabled, checked);
+
+ return (
+
+
+ {checked && (
+
+ )}
+
+ {(label || description) && (
+
+ {label && {label}}
+ {description && (
+ {description}
+ )}
+
+ )}
+
+ );
+ },
+);
+
+Checkbox.displayName = "Checkbox";
+
+function createStyles(
+ theme: any,
+ size: string,
+ disabled: boolean,
+ checked: boolean,
+) {
+ const sizeStyles = {
+ sm: {
+ checkboxSize: 16,
+ borderRadius: 2,
+ padding: theme.spacing[1],
+ gap: theme.spacing[1],
+ },
+ md: {
+ checkboxSize: 20,
+ borderRadius: 4,
+ padding: theme.spacing[1],
+ gap: theme.spacing[2],
+ },
+ lg: {
+ checkboxSize: 24,
+ borderRadius: 6,
+ padding: theme.spacing[2],
+ gap: theme.spacing[3],
+ },
+ };
+
+ const currentSize = sizeStyles[size as keyof typeof sizeStyles];
+
+ return StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ opacity: disabled ? 0.5 : 1,
+ },
+ checkbox: {
+ width: currentSize.checkboxSize,
+ height: currentSize.checkboxSize,
+ borderWidth: 1.5,
+ borderColor: disabled
+ ? theme.colors.border
+ : checked
+ ? theme.colors.primary
+ : theme.colors.border,
+ borderRadius: currentSize.borderRadius,
+ backgroundColor: disabled
+ ? theme.colors.muted
+ : checked
+ ? theme.colors.primary
+ : "transparent",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ content: {
+ flex: 1,
+ paddingTop: currentSize.padding * 0.5,
+ paddingLeft: theme.spacing[2],
+ },
+ label: {
+ fontSize: size === "sm" ? 14 : size === "lg" ? 18 : 16,
+ fontWeight: "500",
+ color: disabled ? theme.colors.textDisabled : theme.colors.text,
+ lineHeight: size === "sm" ? 18 : size === "lg" ? 22 : 20,
+ },
+ description: {
+ fontSize: size === "sm" ? 12 : size === "lg" ? 16 : 14,
+ color: disabled ? theme.colors.textDisabled : theme.colors.textMuted,
+ marginTop: theme.spacing[1],
+ lineHeight: size === "sm" ? 16 : size === "lg" ? 20 : 18,
+ },
+ });
+}
diff --git a/js/components/src/components/ui/select.tsx b/js/components/src/components/ui/select.tsx
new file mode 100644
index 00000000..115dbb70
--- /dev/null
+++ b/js/components/src/components/ui/select.tsx
@@ -0,0 +1,175 @@
+import { ChevronDown } from "lucide-react-native";
+import { forwardRef, useState } from "react";
+import {
+ FlatList,
+ Modal,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useTheme } from "../../lib/theme/theme";
+import { Text } from "./text";
+
+export interface SelectItem {
+ label: string;
+ value: string;
+ description?: string;
+}
+
+export interface SelectProps {
+ value?: string;
+ onValueChange: (value: string) => void;
+ placeholder?: string;
+ items: SelectItem[];
+ disabled?: boolean;
+ style?: any;
+}
+
+export const Select = forwardRef(
+ (
+ {
+ value,
+ onValueChange,
+ placeholder = "Select...",
+ items,
+ disabled = false,
+ style,
+ },
+ ref,
+ ) => {
+ const { theme } = useTheme();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const selectedItem = items.find((item) => item.value === value);
+
+ const handleSelect = (itemValue: string) => {
+ onValueChange(itemValue);
+ setIsOpen(false);
+ };
+
+ const styles = createStyles(theme, disabled);
+
+ return (
+ <>
+ !disabled && setIsOpen(true)}
+ disabled={disabled}
+ >
+ {selectedItem?.label || placeholder}
+
+
+
+ setIsOpen(false)}
+ >
+ setIsOpen(false)}
+ >
+
+ item.value}
+ renderItem={({ item }) => (
+ handleSelect(item.value)}
+ >
+
+ {item.label}
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+ )}
+ style={styles.list}
+ />
+
+
+
+ >
+ );
+ },
+);
+
+Select.displayName = "Select";
+
+function createStyles(theme: any, disabled: boolean) {
+ return StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[3],
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ borderRadius: theme.borderRadius.md,
+ backgroundColor: disabled ? theme.colors.muted : theme.colors.card,
+ minHeight: theme.touchTargets.minimum,
+ },
+ value: {
+ fontSize: 16,
+ color: disabled ? theme.colors.textDisabled : theme.colors.text,
+ flex: 1,
+ },
+ overlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ dropdown: {
+ backgroundColor: theme.colors.background,
+ borderRadius: theme.borderRadius.md,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ maxHeight: 300,
+ width: "90%",
+ maxWidth: 400,
+ ...theme.shadows.lg,
+ },
+ list: {
+ maxHeight: 300,
+ },
+ item: {
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[3],
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ },
+ selectedItem: {
+ backgroundColor: theme.colors.primary,
+ },
+ itemText: {
+ fontSize: 16,
+ color: theme.colors.text,
+ },
+ selectedItemText: {
+ color: theme.colors.primaryForeground,
+ fontWeight: "500",
+ },
+ itemDescription: {
+ fontSize: 14,
+ color: theme.colors.textMuted,
+ marginTop: theme.spacing[1],
+ },
+ });
+}
diff --git a/js/components/src/components/ui/tooltip.tsx b/js/components/src/components/ui/tooltip.tsx
new file mode 100644
index 00000000..9d3c5834
--- /dev/null
+++ b/js/components/src/components/ui/tooltip.tsx
@@ -0,0 +1,131 @@
+import { forwardRef, useState } from "react";
+import { StyleSheet, View } from "react-native";
+import { useTheme } from "../../lib/theme/theme";
+import { Text } from "../ui/text";
+
+export interface TooltipProps {
+ content: string;
+ children: React.ReactNode;
+ position?: "top" | "bottom" | "left" | "right";
+ style?: any;
+}
+
+export const Tooltip = forwardRef(
+ ({ content, children, position = "top", style }, ref) => {
+ const { theme } = useTheme();
+ const [isVisible, setIsVisible] = useState(false);
+ const styles = createStyles(theme, position);
+
+ const handleHoverIn = () => {
+ setIsVisible(true);
+ };
+
+ const handleHoverOut = () => {
+ setIsVisible(false);
+ };
+
+ return (
+
+ {children}
+ {isVisible && (
+
+ {content}
+
+ )}
+
+ );
+ },
+);
+
+Tooltip.displayName = "Tooltip";
+
+function createStyles(theme: any, position: string) {
+ const positionStyles = {
+ top: {
+ tooltip: {
+ bottom: "100%",
+ left: "50%",
+ transform: [{ translateX: -50 }],
+ marginBottom: theme.spacing[1],
+ },
+ arrow: {
+ top: "100%",
+ left: "50%",
+ transform: [{ translateX: -50 }],
+ borderTopColor: theme.colors.card,
+ },
+ },
+ bottom: {
+ tooltip: {
+ top: "100%",
+ left: "50%",
+ transform: [{ translateX: -50 }],
+ marginTop: theme.spacing[1],
+ },
+ arrow: {
+ bottom: "100%",
+ left: "50%",
+ transform: [{ translateX: -50 }],
+ borderBottomColor: theme.colors.card,
+ },
+ },
+ left: {
+ tooltip: {
+ right: "100%",
+ top: "50%",
+ transform: [{ translateY: -50 }],
+ marginRight: theme.spacing[1],
+ },
+ arrow: {
+ left: "100%",
+ top: "50%",
+ transform: [{ translateY: -50 }],
+ borderLeftColor: theme.colors.card,
+ },
+ },
+ right: {
+ tooltip: {
+ left: "100%",
+ top: "50%",
+ transform: [{ translateY: -50 }],
+ marginLeft: theme.spacing[1],
+ },
+ arrow: {
+ right: "100%",
+ top: "50%",
+ transform: [{ translateY: -50 }],
+ borderRightColor: theme.colors.card,
+ },
+ },
+ };
+
+ const currentPosition =
+ positionStyles[position as keyof typeof positionStyles];
+
+ return StyleSheet.create({
+ container: {
+ position: "relative",
+ },
+ tooltip: {
+ position: "absolute",
+ backgroundColor: theme.colors.card,
+ borderRadius: theme.borderRadius.md,
+ padding: theme.spacing[2],
+ maxWidth: 200,
+ ...theme.shadows.lg,
+ ...currentPosition.tooltip,
+ zIndex: 1000,
+ },
+ tooltipText: {
+ color: theme.colors.text,
+ fontSize: 12,
+ lineHeight: 16,
+ textAlign: "left",
+ },
+ });
+}
diff --git a/js/components/src/index.tsx b/js/components/src/index.tsx
index 321e6dd6..c2d8d4e2 100644
--- a/js/components/src/index.tsx
+++ b/js/components/src/index.tsx
@@ -43,3 +43,6 @@ export * as Dashboard from "./components/dashboard";
// Storage exports
export { default as storage } from "./storage";
export type { AQStorage } from "./storage/storage.shared";
+
+// Content metadata components
+export * from "./components/content-metadata";
diff --git a/js/components/src/lib/metadata-constants.ts b/js/components/src/lib/metadata-constants.ts
new file mode 100644
index 00000000..c2e73d46
--- /dev/null
+++ b/js/components/src/lib/metadata-constants.ts
@@ -0,0 +1,180 @@
+import { schemas } from "streamplace";
+
+// Content warnings derived from lexicon schema
+export const CONTENT_WARNINGS = (() => {
+ // Find the content warnings schema
+ const contentWarningsSchema = schemas.find(
+ (schema) => schema.id === "place.stream.metadata.contentWarnings",
+ );
+ if (!contentWarningsSchema?.defs) {
+ throw new Error(
+ "Could not find place.stream.metadata.contentWarnings schema",
+ );
+ }
+
+ const contentWarningConstants = [
+ { constant: "place.stream.metadata.contentWarnings#death", label: "Death" },
+ {
+ constant: "place.stream.metadata.contentWarnings#drugUse",
+ label: "Drug Use",
+ },
+ {
+ constant: "place.stream.metadata.contentWarnings#fantasyViolence",
+ label: "Fantasy Violence",
+ },
+ {
+ constant: "place.stream.metadata.contentWarnings#flashingLights",
+ label: "Flashing Lights",
+ },
+ {
+ constant: "place.stream.metadata.contentWarnings#language",
+ label: "Language",
+ },
+ {
+ constant: "place.stream.metadata.contentWarnings#nudity",
+ label: "Nudity",
+ },
+ {
+ constant: "place.stream.metadata.contentWarnings#PII",
+ label: "Personally Identifiable Information",
+ },
+ {
+ constant: "place.stream.metadata.contentWarnings#sexuality",
+ label: "Sexuality",
+ },
+ {
+ constant: "place.stream.metadata.contentWarnings#suffering",
+ label: "Upsetting or Disturbing",
+ },
+ {
+ constant: "place.stream.metadata.contentWarnings#violence",
+ label: "Violence",
+ },
+ ];
+
+ return contentWarningConstants.map(({ constant, label }) => {
+ // Extract the key from the constant by splitting on '#'
+ const key = constant.split("#")[1];
+ const def = contentWarningsSchema.defs[key];
+ const description = def?.description || `Description for ${label}`;
+ return {
+ value: constant,
+ label: label,
+ description: description,
+ };
+ });
+})();
+
+// License options derived from lexicon schema
+export const LICENSE_OPTIONS = (() => {
+ // Find the content rights schema
+ const contentRightsSchema = schemas.find(
+ (schema) => schema.id === "place.stream.metadata.contentRights",
+ );
+ if (!contentRightsSchema?.defs) {
+ throw new Error(
+ "Could not find place.stream.metadata.contentRights schema",
+ );
+ }
+
+ const licenseConstants = [
+ {
+ constant: "place.stream.metadata.contentRights#all-rights-reserved",
+ label: "All Rights Reserved",
+ },
+ {
+ constant: "place.stream.metadata.contentRights#cc0_1__0",
+ label: "CC0 (Public Domain) 1.0",
+ },
+ {
+ constant: "place.stream.metadata.contentRights#cc-by_4__0",
+ label: "CC BY 4.0",
+ },
+ {
+ constant: "place.stream.metadata.contentRights#cc-by-sa_4__0",
+ label: "CC BY-SA 4.0",
+ },
+ {
+ constant: "place.stream.metadata.contentRights#cc-by-nc_4__0",
+ label: "CC BY-NC 4.0",
+ },
+ {
+ constant: "place.stream.metadata.contentRights#cc-by-nc-sa_4__0",
+ label: "CC BY-NC-SA 4.0",
+ },
+ {
+ constant: "place.stream.metadata.contentRights#cc-by-nd_4__0",
+ label: "CC BY-ND 4.0",
+ },
+ {
+ constant: "place.stream.metadata.contentRights#cc-by-nc-nd_4__0",
+ label: "CC BY-NC-ND 4.0",
+ },
+ ];
+
+ const options = licenseConstants.map(({ constant, label }) => {
+ // Extract the key from the constant by splitting on '#'
+ const key = constant.split("#")[1];
+ const def = contentRightsSchema.defs[key];
+ const description = def?.description || `Description for ${label}`;
+ return {
+ value: constant,
+ label: label,
+ description: description,
+ };
+ });
+
+ // Add custom license option
+ options.push({
+ value: "custom",
+ label: "Custom License",
+ description:
+ "Custom license. Define your own terms for how others can use, adapt, or share your content.",
+ });
+
+ return options;
+})();
+
+// License URL labels for C2PA manifests
+export const LICENSE_URL_LABELS: Record = {
+ "http://creativecommons.org/publicdomain/zero/1.0/":
+ "CC0 - Public Domain 1.0",
+ "http://creativecommons.org/licenses/by/4.0/": "CC BY - Attribution 4.0",
+ "http://creativecommons.org/licenses/by-sa/4.0/":
+ "CC BY-SA - Attribution ShareAlike 4.0",
+ "http://creativecommons.org/licenses/by-nc/4.0/":
+ "CC BY-NC - Attribution NonCommercial 4.0",
+ "http://creativecommons.org/licenses/by-nc-sa/4.0/":
+ "CC BY-NC-SA - Attribution NonCommercial ShareAlike 4.0",
+ "http://creativecommons.org/licenses/by-nd/4.0/":
+ "CC BY-ND - Attribution NoDerivatives 4.0",
+ "http://creativecommons.org/licenses/by-nc-nd/4.0/":
+ "CC BY-NC-ND - Attribution NonCommercial NoDerivatives 4.0",
+ "All rights reserved": "All Rights Reserved",
+} as const;
+
+// C2PA warning labels for content warnings
+export const C2PA_WARNING_LABELS: Record = {
+ "cwarn:death": "Death",
+ "cwarn:drugUse": "Drug Use",
+ "cwarn:fantasyViolence": "Fantasy Violence",
+ "cwarn:flashingLights": "Flashing Lights",
+ "cwarn:language": "Language",
+ "cwarn:nudity": "Nudity",
+ "cwarn:PII": "Personally Identifiable Information",
+ "cwarn:sexuality": "Sexuality",
+ "cwarn:suffering": "Upsetting or Disturbing",
+ "cwarn:violence": "Violence",
+ // Also support lexicon constants for backward compatibility
+ "place.stream.metadata.contentWarnings#death": "Death",
+ "place.stream.metadata.contentWarnings#drugUse": "Drug Use",
+ "place.stream.metadata.contentWarnings#fantasyViolence": "Fantasy Violence",
+ "place.stream.metadata.contentWarnings#flashingLights": "Flashing Lights",
+ "place.stream.metadata.contentWarnings#language": "Language",
+ "place.stream.metadata.contentWarnings#nudity": "Nudity",
+ "place.stream.metadata.contentWarnings#PII":
+ "Personally Identifiable Information",
+ "place.stream.metadata.contentWarnings#sexuality": "Sexuality",
+ "place.stream.metadata.contentWarnings#suffering": "Upsetting or Disturbing",
+ "place.stream.metadata.contentWarnings#violence": "Violence",
+} as const;
diff --git a/js/components/src/streamplace-store/content-metadata-actions.tsx b/js/components/src/streamplace-store/content-metadata-actions.tsx
new file mode 100644
index 00000000..015bcba2
--- /dev/null
+++ b/js/components/src/streamplace-store/content-metadata-actions.tsx
@@ -0,0 +1,142 @@
+import {
+ ContentMetadataResult,
+ useDID,
+ useSetContentMetadata,
+} from "./streamplace-store";
+import { usePDSAgent } from "./xrpc";
+
+export const useSaveContentMetadata = () => {
+ const pdsAgent = usePDSAgent();
+ const did = useDID();
+ const setContentMetadata = useSetContentMetadata();
+
+ return async (params: {
+ contentWarnings?: string[];
+ distributionPolicy?: { deleteAfter?: number };
+ contentRights?: Record;
+ rkey?: string;
+ }) => {
+ if (!pdsAgent || !did) {
+ throw new Error("No PDS agent or DID available");
+ }
+
+ const metadataRecord = {
+ $type: "place.stream.metadata.configuration",
+ createdAt: new Date().toISOString(),
+ ...(params.contentWarnings &&
+ params.contentWarnings.length > 0 && {
+ contentWarnings: { warnings: params.contentWarnings },
+ }),
+ ...(params.distributionPolicy &&
+ params.distributionPolicy.deleteAfter && {
+ distributionPolicy: params.distributionPolicy,
+ }),
+ ...(params.contentRights &&
+ Object.keys(params.contentRights).length > 0 && {
+ contentRights: params.contentRights,
+ }),
+ };
+
+ const rkey = params.rkey || "self";
+
+ try {
+ // Try to update existing record first
+ const result = await (pdsAgent as any).com.atproto.repo.putRecord({
+ repo: did,
+ collection: "place.stream.metadata.configuration",
+ rkey,
+ record: metadataRecord,
+ });
+
+ const contentMetadata: ContentMetadataResult = {
+ record: metadataRecord as any,
+ uri: result.data.uri,
+ cid: result.data.cid || "",
+ rkey,
+ };
+
+ setContentMetadata(contentMetadata);
+ return contentMetadata;
+ } catch (error) {
+ // If record doesn't exist, create it
+ if (
+ error instanceof Error &&
+ (error.message?.includes("not found") ||
+ error.message?.includes("RecordNotFound") ||
+ error.message?.includes("mst: not found") ||
+ (error as any)?.status === 404)
+ ) {
+ const createResult = await (
+ pdsAgent as any
+ ).com.atproto.repo.createRecord({
+ repo: did,
+ collection: "place.stream.metadata.configuration",
+ rkey,
+ record: metadataRecord,
+ });
+
+ const contentMetadata: ContentMetadataResult = {
+ record: metadataRecord as any,
+ uri: createResult.data.uri,
+ cid: createResult.data.cid || "",
+ rkey,
+ };
+
+ setContentMetadata(contentMetadata);
+ return contentMetadata;
+ }
+ throw error;
+ }
+ };
+};
+
+// Simple get function
+export const useGetContentMetadata = () => {
+ const pdsAgent = usePDSAgent();
+ const did = useDID();
+ const setContentMetadata = useSetContentMetadata();
+
+ return async (params?: { userDid?: string; rkey?: string }) => {
+ if (!pdsAgent) {
+ throw new Error("No PDS agent available");
+ }
+
+ const targetDid = params?.userDid || did;
+ if (!targetDid) {
+ throw new Error("No DID provided or user not authenticated");
+ }
+
+ try {
+ const result = await (pdsAgent as any).com.atproto.repo.getRecord({
+ repo: targetDid,
+ collection: "place.stream.metadata.configuration",
+ rkey: params?.rkey || "self",
+ });
+
+ if (!result.success) {
+ throw new Error("Failed to get content metadata record");
+ }
+
+ const contentMetadata: ContentMetadataResult = {
+ record: result.data.value,
+ uri: result.data.uri,
+ cid: result.data.cid || "",
+ };
+
+ setContentMetadata(contentMetadata);
+ return contentMetadata;
+ } catch (error) {
+ // Handle record not found - this is normal for new users
+ if (
+ error instanceof Error &&
+ (error.message?.includes("not found") ||
+ error.message?.includes("RecordNotFound") ||
+ error.message?.includes("mst: not found") ||
+ (error as any)?.status === 404)
+ ) {
+ return null;
+ }
+ throw error;
+ }
+ };
+};
diff --git a/js/components/src/streamplace-store/streamplace-store.tsx b/js/components/src/streamplace-store/streamplace-store.tsx
index 8512f812..7e9bc2d9 100644
--- a/js/components/src/streamplace-store/streamplace-store.tsx
+++ b/js/components/src/streamplace-store/streamplace-store.tsx
@@ -5,6 +5,13 @@ import { createStore, StoreApi, useStore } from "zustand";
import storage from "../storage";
import { StreamplaceContext } from "../streamplace-provider/context";
+export interface ContentMetadataResult {
+ record: any;
+ uri: string;
+ cid: string;
+ rkey?: string;
+}
+
// there are three categories of XRPC that we need to handle:
// 1. Public (probably) OAuth XRPC to the users' PDS for apps that use this API.
// 2. Confidental OAuth to the Streamplace server for doing things that require
@@ -33,6 +40,10 @@ export interface StreamplaceState {
handle: string | null;
chatProfile: PlaceStreamChatProfile.Record | null;
+ // Content metadata state
+ contentMetadata: ContentMetadataResult | null;
+ setContentMetadata: (metadata: ContentMetadataResult | null) => void;
+
// Volume state
volume: number;
muted: boolean;
@@ -70,6 +81,10 @@ export const makeStreamplaceStore = ({
handle: null,
chatProfile: null,
+ // Content metadata
+ contentMetadata: null,
+ setContentMetadata: (metadata) => set({ contentMetadata: metadata }),
+
// Volume state - start with defaults
volume: 1.0,
muted: false,
@@ -125,13 +140,12 @@ export const makeStreamplaceStore = ({
initialMuted = storedMuted === "true";
}
- // Update the store with loaded values
store.setState({
volume: initialVolume,
muted: initialMuted,
});
- } catch (e) {
- console.warn("Failed to load volume settings from storage:", e);
+ } catch (error) {
+ console.error("Failed to load volume state from storage:", error);
}
})();
@@ -164,7 +178,17 @@ export const useSetHandle = (): ((handle: string) => void) => {
return (handle: string) => store.setState({ handle });
};
-// Volume convenience hooks
+// Content metadata hooks
+export const useContentMetadata = () =>
+ useStreamplaceStore((x) => x.contentMetadata);
+
+export const useSetContentMetadata = () => {
+ const store = getStreamplaceStoreFromContext();
+ return (metadata: ContentMetadataResult | null) =>
+ store.setState({ contentMetadata: metadata });
+};
+
+// Volume/muted hooks
export const useVolume = () => useStreamplaceStore((x) => x.volume);
export const useMuted = () => useStreamplaceStore((x) => x.muted);
export const useSetVolume = () => useStreamplaceStore((x) => x.setVolume);
@@ -177,3 +201,5 @@ export const useEffectiveVolume = () =>
// Ensure we always return a finite number for HTMLMediaElement.volume
return Number.isFinite(effectiveVolume) ? effectiveVolume : 1.0;
});
+
+export { useCreateStreamRecord, useUpdateStreamRecord } from "./stream";
diff --git a/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-configuration.md b/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-configuration.md
new file mode 100644
index 00000000..0aa793bb
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-configuration.md
@@ -0,0 +1,62 @@
+---
+title: place.stream.metadata.configuration
+description: Reference for the place.stream.metadata.configuration lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `record`
+
+Default metadata record for livestream including content warnings, rights, and
+distribution policy
+
+**Record Key:** `literal:self`
+
+**Record Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| -------------------- | ----------------------------------------------------------------------------------------------------- | ----- | ----------- | ----------- |
+| `contentWarnings` | [`place.stream.metadata.contentWarnings`](/lex-reference/place-stream-metadata-contentwarnings) | ❌ | | |
+| `contentRights` | [`place.stream.metadata.contentRights`](/lex-reference/place-stream-metadata-contentrights) | ❌ | | |
+| `distributionPolicy` | [`place.stream.metadata.distributionPolicy`](/lex-reference/place-stream-metadata-distributionpolicy) | ❌ | | |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.metadata.configuration",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Default metadata record for livestream including content warnings, rights, and distribution policy",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "properties": {
+ "contentWarnings": {
+ "type": "ref",
+ "ref": "place.stream.metadata.contentWarnings"
+ },
+ "contentRights": {
+ "type": "ref",
+ "ref": "place.stream.metadata.contentRights"
+ },
+ "distributionPolicy": {
+ "type": "ref",
+ "ref": "place.stream.metadata.distributionPolicy"
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentrights.md b/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentrights.md
new file mode 100644
index 00000000..fbfc66c3
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentrights.md
@@ -0,0 +1,198 @@
+---
+title: place.stream.metadata.contentRights
+description: Reference for the place.stream.metadata.contentRights lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `object`
+
+Content rights and attribution information.
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ----------------- | --------- | ----- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `creator` | `string` | ❌ | Name of the creator of the work. | |
+| `copyrightNotice` | `string` | ❌ | Copyright notice for the work. | |
+| `copyrightYear` | `integer` | ❌ | Year of creation or publication. | |
+| `license` | `string` | ❌ | License URL or identifier. | Known Values: `place.stream.metadata.contentRights#all-rights-reserved`, `place.stream.metadata.contentRights#cc0_1__0`, `place.stream.metadata.contentRights#cc-by_4__0`, `place.stream.metadata.contentRights#cc-by-sa_4__0`, `place.stream.metadata.contentRights#cc-by-nc_4__0`, `place.stream.metadata.contentRights#cc-by-nc-sa_4__0`, `place.stream.metadata.contentRights#cc-by-nd_4__0`, `place.stream.metadata.contentRights#cc-by-nc-nd_4__0` |
+| `creditLine` | `string` | ❌ | Credit line for the work. | |
+
+---
+
+
+
+### `all-rights-reserved`
+
+**Type:** `token`
+
+All rights reserved to the creator — others cannot use, modify, or share without
+explicit authorization.
+
+---
+
+
+
+### `cc0_1__0`
+
+**Type:** `token`
+
+Public domain dedication. You waive all copyright and related rights where
+possible. Others may copy, modify, distribute, or perform your work for any
+purpose without attribution.
+
+---
+
+
+
+### `cc-by_4__0`
+
+**Type:** `token`
+
+Attribution required. Others may copy, distribute, remix, and build upon your
+work, even commercially, if they credit you.
+
+---
+
+
+
+### `cc-by-sa_4__0`
+
+**Type:** `token`
+
+Attribution + share-alike. Others may adapt and build upon your work, even
+commercially, if they credit you and license their new creations under identical
+terms.
+
+---
+
+
+
+### `cc-by-nc_4__0`
+
+**Type:** `token`
+
+Attribution + non-commercial. Others may adapt and build upon your work for
+non-commercial purposes only, and must credit you.
+
+---
+
+
+
+### `cc-by-nc-sa_4__0`
+
+**Type:** `token`
+
+Attribution + non-commercial + share-alike. Others may adapt and build upon your
+work for non-commercial purposes only, must credit you, and must license their
+new creations under identical terms.
+
+---
+
+
+
+### `cc-by-nd_4__0`
+
+**Type:** `token`
+
+Attribution + no derivatives. Others may reuse your work, even commercially, but
+it must remain unchanged and you must be credited.
+
+---
+
+
+
+### `cc-by-nc-nd_4__0`
+
+**Type:** `token`
+
+Attribution + non-commercial + no derivatives. Others may download and share
+your work with credit, but cannot change it or use it commercially.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.metadata.contentRights",
+ "defs": {
+ "main": {
+ "type": "object",
+ "description": "Content rights and attribution information.",
+ "properties": {
+ "creator": {
+ "type": "string",
+ "description": "Name of the creator of the work."
+ },
+ "copyrightNotice": {
+ "type": "string",
+ "description": "Copyright notice for the work."
+ },
+ "copyrightYear": {
+ "type": "integer",
+ "description": "Year of creation or publication."
+ },
+ "license": {
+ "type": "string",
+ "description": "License URL or identifier.",
+ "knownValues": [
+ "place.stream.metadata.contentRights#all-rights-reserved",
+ "place.stream.metadata.contentRights#cc0_1__0",
+ "place.stream.metadata.contentRights#cc-by_4__0",
+ "place.stream.metadata.contentRights#cc-by-sa_4__0",
+ "place.stream.metadata.contentRights#cc-by-nc_4__0",
+ "place.stream.metadata.contentRights#cc-by-nc-sa_4__0",
+ "place.stream.metadata.contentRights#cc-by-nd_4__0",
+ "place.stream.metadata.contentRights#cc-by-nc-nd_4__0"
+ ]
+ },
+ "creditLine": {
+ "type": "string",
+ "description": "Credit line for the work."
+ }
+ }
+ },
+ "all-rights-reserved": {
+ "type": "token",
+ "description": "All rights reserved to the creator — others cannot use, modify, or share without explicit authorization."
+ },
+ "cc0_1__0": {
+ "type": "token",
+ "description": "Public domain dedication. You waive all copyright and related rights where possible. Others may copy, modify, distribute, or perform your work for any purpose without attribution."
+ },
+ "cc-by_4__0": {
+ "type": "token",
+ "description": "Attribution required. Others may copy, distribute, remix, and build upon your work, even commercially, if they credit you."
+ },
+ "cc-by-sa_4__0": {
+ "type": "token",
+ "description": "Attribution + share-alike. Others may adapt and build upon your work, even commercially, if they credit you and license their new creations under identical terms."
+ },
+ "cc-by-nc_4__0": {
+ "type": "token",
+ "description": "Attribution + non-commercial. Others may adapt and build upon your work for non-commercial purposes only, and must credit you."
+ },
+ "cc-by-nc-sa_4__0": {
+ "type": "token",
+ "description": "Attribution + non-commercial + share-alike. Others may adapt and build upon your work for non-commercial purposes only, must credit you, and must license their new creations under identical terms."
+ },
+ "cc-by-nd_4__0": {
+ "type": "token",
+ "description": "Attribution + no derivatives. Others may reuse your work, even commercially, but it must remain unchanged and you must be credited."
+ },
+ "cc-by-nc-nd_4__0": {
+ "type": "token",
+ "description": "Attribution + non-commercial + no derivatives. Others may download and share your work with credit, but cannot change it or use it commercially."
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentwarnings.md b/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentwarnings.md
new file mode 100644
index 00000000..759313ed
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-contentwarnings.md
@@ -0,0 +1,209 @@
+---
+title: place.stream.metadata.contentWarnings
+description: Reference for the place.stream.metadata.contentWarnings lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `object`
+
+Content warnings for a stream.
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ---------- | ----------------- | ----- | ----------- | ----------- |
+| `warnings` | Array of `string` | ❌ | | |
+
+---
+
+
+
+### `death`
+
+**Type:** `token`
+
+The content could be perceived as offensive due to the discussion or display of
+death.
+
+---
+
+
+
+### `drugUse`
+
+**Type:** `token`
+
+The content contains a portrayal of the use or abuse of mind altering
+substances.
+
+---
+
+
+
+### `fantasyViolence`
+
+**Type:** `token`
+
+The content contains violent actions of a fantasy nature, involving human or
+non-human characters in situations easily distinguishable from real life.
+
+---
+
+
+
+### `flashingLights`
+
+**Type:** `token`
+
+The content contains flashing lights that could be harmful to viewers with
+seizure disorders such as photosensitive epilepsy.
+
+---
+
+
+
+### `language`
+
+**Type:** `token`
+
+The content could be perceived as offensive due to the language used.
+
+---
+
+
+
+### `nudity`
+
+**Type:** `token`
+
+The content could be perceived as offensive due to nudity.
+
+---
+
+
+
+### `PII`
+
+**Type:** `token`
+
+The content contains information that can be used to identify a particular
+individual, such as a name, phone number, email address, physical address, or IP
+address.
+
+---
+
+
+
+### `sexuality`
+
+**Type:** `token`
+
+The content could be perceived as offensive due to the discussion or display of
+sexuality.
+
+---
+
+
+
+### `suffering`
+
+**Type:** `token`
+
+The content could be perceived as distressing due to the discussion or display
+of suffering or triggering topics, including suicide, eating disorders or self
+harm.
+
+---
+
+
+
+### `violence`
+
+**Type:** `token`
+
+The content could be perceived as offensive due to the discussion or display of
+violence.
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.metadata.contentWarnings",
+ "defs": {
+ "main": {
+ "type": "object",
+ "description": "Content warnings for a stream.",
+ "properties": {
+ "warnings": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "knownValues": [
+ "place.stream.metadata.contentWarnings#death",
+ "place.stream.metadata.contentWarnings#drugUse",
+ "place.stream.metadata.contentWarnings#fantasyViolence",
+ "place.stream.metadata.contentWarnings#flashingLights",
+ "place.stream.metadata.contentWarnings#language",
+ "place.stream.metadata.contentWarnings#nudity",
+ "place.stream.metadata.contentWarnings#PII",
+ "place.stream.metadata.contentWarnings#sexuality",
+ "place.stream.metadata.contentWarnings#suffering",
+ "place.stream.metadata.contentWarnings#violence"
+ ]
+ }
+ }
+ }
+ },
+ "death": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to the discussion or display of death."
+ },
+ "drugUse": {
+ "type": "token",
+ "description": "The content contains a portrayal of the use or abuse of mind altering substances."
+ },
+ "fantasyViolence": {
+ "type": "token",
+ "description": "The content contains violent actions of a fantasy nature, involving human or non-human characters in situations easily distinguishable from real life."
+ },
+ "flashingLights": {
+ "type": "token",
+ "description": "The content contains flashing lights that could be harmful to viewers with seizure disorders such as photosensitive epilepsy."
+ },
+ "language": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to the language used."
+ },
+ "nudity": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to nudity."
+ },
+ "PII": {
+ "type": "token",
+ "description": "The content contains information that can be used to identify a particular individual, such as a name, phone number, email address, physical address, or IP address."
+ },
+ "sexuality": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to the discussion or display of sexuality."
+ },
+ "suffering": {
+ "type": "token",
+ "description": "The content could be perceived as distressing due to the discussion or display of suffering or triggering topics, including suicide, eating disorders or self harm."
+ },
+ "violence": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to the discussion or display of violence."
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-distributionpolicy.md b/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-distributionpolicy.md
new file mode 100644
index 00000000..121e791f
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/metadata/place-stream-metadata-distributionpolicy.md
@@ -0,0 +1,45 @@
+---
+title: place.stream.metadata.distributionPolicy
+description: Reference for the place.stream.metadata.distributionPolicy lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `object`
+
+Distribution and rebroadcast policy.
+
+**Properties:**
+
+| Name | Type | Req'd | Description | Constraints |
+| ------------- | --------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | ----------- |
+| `deleteAfter` | `integer` | ❌ | Duration in seconds after which segments should be deleted. Each segment will expire N seconds after its creation time. | |
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.metadata.distributionPolicy",
+ "defs": {
+ "main": {
+ "type": "object",
+ "description": "Distribution and rebroadcast policy.",
+ "properties": {
+ "deleteAfter": {
+ "type": "integer",
+ "description": "Duration in seconds after which segments should be deleted. Each segment will expire N seconds after its creation time."
+ }
+ }
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/place-stream-segment.md b/js/docs/src/content/docs/lex-reference/place-stream-segment.md
index a4fa6e33..05e70d45 100644
--- a/js/docs/src/content/docs/lex-reference/place-stream-segment.md
+++ b/js/docs/src/content/docs/lex-reference/place-stream-segment.md
@@ -19,16 +19,19 @@ Media file representing a segment of a livestream
**Record Properties:**
-| Name | Type | Req'd | Description | Constraints |
-| ------------ | --------------------------- | ----- | ------------------------------------------------ | ------------------ |
-| `id` | `string` | ✅ | Unique identifier for the segment | |
-| `signingKey` | `string` | ✅ | The DID of the signing key used for this segment | |
-| `startTime` | `string` | ✅ | When this segment started | Format: `datetime` |
-| `duration` | `integer` | ❌ | The duration of the segment in nanoseconds | |
-| `creator` | `string` | ✅ | | Format: `did` |
-| `video` | Array of [`#video`](#video) | ❌ | | |
-| `audio` | Array of [`#audio`](#audio) | ❌ | | |
-| `size` | `integer` | ❌ | The size of the segment in bytes | |
+| Name | Type | Req'd | Description | Constraints |
+| -------------------- | ----------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------ | ------------------ |
+| `id` | `string` | ✅ | Unique identifier for the segment | |
+| `signingKey` | `string` | ✅ | The DID of the signing key used for this segment | |
+| `startTime` | `string` | ✅ | When this segment started | Format: `datetime` |
+| `duration` | `integer` | ❌ | The duration of the segment in nanoseconds | |
+| `creator` | `string` | ✅ | | Format: `did` |
+| `video` | Array of [`#video`](#video) | ❌ | | |
+| `audio` | Array of [`#audio`](#audio) | ❌ | | |
+| `size` | `integer` | ❌ | The size of the segment in bytes | |
+| `contentWarnings` | [`place.stream.metadata.contentWarnings`](/lex-reference/place-stream-metadata-contentwarnings) | ❌ | | |
+| `contentRights` | [`place.stream.metadata.contentRights`](/lex-reference/place-stream-metadata-contentrights) | ❌ | | |
+| `distributionPolicy` | [`place.stream.metadata.distributionPolicy`](/lex-reference/place-stream-metadata-distributionpolicy) | ❌ | | |
---
@@ -149,6 +152,18 @@ Media file representing a segment of a livestream
"size": {
"type": "integer",
"description": "The size of the segment in bytes"
+ },
+ "contentWarnings": {
+ "type": "ref",
+ "ref": "place.stream.metadata.contentWarnings"
+ },
+ "contentRights": {
+ "type": "ref",
+ "ref": "place.stream.metadata.contentRights"
+ },
+ "distributionPolicy": {
+ "type": "ref",
+ "ref": "place.stream.metadata.distributionPolicy"
}
}
}
diff --git a/lexicons/place/stream/metadata/configuration.json b/lexicons/place/stream/metadata/configuration.json
new file mode 100644
index 00000000..283c8bac
--- /dev/null
+++ b/lexicons/place/stream/metadata/configuration.json
@@ -0,0 +1,28 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.metadata.configuration",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Default metadata record for livestream including content warnings, rights, and distribution policy",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "properties": {
+ "contentWarnings": {
+ "type": "ref",
+ "ref": "place.stream.metadata.contentWarnings"
+ },
+ "contentRights": {
+ "type": "ref",
+ "ref": "place.stream.metadata.contentRights"
+ },
+ "distributionPolicy": {
+ "type": "ref",
+ "ref": "place.stream.metadata.distributionPolicy"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/metadata/contentRights.json b/lexicons/place/stream/metadata/contentRights.json
new file mode 100644
index 00000000..87bb4a55
--- /dev/null
+++ b/lexicons/place/stream/metadata/contentRights.json
@@ -0,0 +1,74 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.metadata.contentRights",
+ "defs": {
+ "main": {
+ "type": "object",
+ "description": "Content rights and attribution information.",
+ "properties": {
+ "creator": {
+ "type": "string",
+ "description": "Name of the creator of the work."
+ },
+ "copyrightNotice": {
+ "type": "string",
+ "description": "Copyright notice for the work."
+ },
+ "copyrightYear": {
+ "type": "integer",
+ "description": "Year of creation or publication."
+ },
+ "license": {
+ "type": "string",
+ "description": "License URL or identifier.",
+ "knownValues": [
+ "place.stream.metadata.contentRights#all-rights-reserved",
+ "place.stream.metadata.contentRights#cc0_1__0",
+ "place.stream.metadata.contentRights#cc-by_4__0",
+ "place.stream.metadata.contentRights#cc-by-sa_4__0",
+ "place.stream.metadata.contentRights#cc-by-nc_4__0",
+ "place.stream.metadata.contentRights#cc-by-nc-sa_4__0",
+ "place.stream.metadata.contentRights#cc-by-nd_4__0",
+ "place.stream.metadata.contentRights#cc-by-nc-nd_4__0"
+ ]
+ },
+ "creditLine": {
+ "type": "string",
+ "description": "Credit line for the work."
+ }
+ }
+ },
+ "all-rights-reserved": {
+ "type": "token",
+ "description": "All rights reserved to the creator — others cannot use, modify, or share without explicit authorization."
+ },
+ "cc0_1__0": {
+ "type": "token",
+ "description": "Public domain dedication. You waive all copyright and related rights where possible. Others may copy, modify, distribute, or perform your work for any purpose without attribution."
+ },
+ "cc-by_4__0": {
+ "type": "token",
+ "description": "Attribution required. Others may copy, distribute, remix, and build upon your work, even commercially, if they credit you."
+ },
+ "cc-by-sa_4__0": {
+ "type": "token",
+ "description": "Attribution + share-alike. Others may adapt and build upon your work, even commercially, if they credit you and license their new creations under identical terms."
+ },
+ "cc-by-nc_4__0": {
+ "type": "token",
+ "description": "Attribution + non-commercial. Others may adapt and build upon your work for non-commercial purposes only, and must credit you."
+ },
+ "cc-by-nc-sa_4__0": {
+ "type": "token",
+ "description": "Attribution + non-commercial + share-alike. Others may adapt and build upon your work for non-commercial purposes only, must credit you, and must license their new creations under identical terms."
+ },
+ "cc-by-nd_4__0": {
+ "type": "token",
+ "description": "Attribution + no derivatives. Others may reuse your work, even commercially, but it must remain unchanged and you must be credited."
+ },
+ "cc-by-nc-nd_4__0": {
+ "type": "token",
+ "description": "Attribution + non-commercial + no derivatives. Others may download and share your work with credit, but cannot change it or use it commercially."
+ }
+ }
+}
diff --git a/lexicons/place/stream/metadata/contentWarnings.json b/lexicons/place/stream/metadata/contentWarnings.json
new file mode 100644
index 00000000..cb51decf
--- /dev/null
+++ b/lexicons/place/stream/metadata/contentWarnings.json
@@ -0,0 +1,70 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.metadata.contentWarnings",
+ "defs": {
+ "main": {
+ "type": "object",
+ "description": "Content warnings for a stream.",
+ "properties": {
+ "warnings": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "knownValues": [
+ "place.stream.metadata.contentWarnings#death",
+ "place.stream.metadata.contentWarnings#drugUse",
+ "place.stream.metadata.contentWarnings#fantasyViolence",
+ "place.stream.metadata.contentWarnings#flashingLights",
+ "place.stream.metadata.contentWarnings#language",
+ "place.stream.metadata.contentWarnings#nudity",
+ "place.stream.metadata.contentWarnings#PII",
+ "place.stream.metadata.contentWarnings#sexuality",
+ "place.stream.metadata.contentWarnings#suffering",
+ "place.stream.metadata.contentWarnings#violence"
+ ]
+ }
+ }
+ }
+ },
+ "death": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to the discussion or display of death."
+ },
+ "drugUse": {
+ "type": "token",
+ "description": "The content contains a portrayal of the use or abuse of mind altering substances."
+ },
+ "fantasyViolence": {
+ "type": "token",
+ "description": "The content contains violent actions of a fantasy nature, involving human or non-human characters in situations easily distinguishable from real life."
+ },
+ "flashingLights": {
+ "type": "token",
+ "description": "The content contains flashing lights that could be harmful to viewers with seizure disorders such as photosensitive epilepsy."
+ },
+ "language": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to the language used."
+ },
+ "nudity": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to nudity."
+ },
+ "PII": {
+ "type": "token",
+ "description": "The content contains information that can be used to identify a particular individual, such as a name, phone number, email address, physical address, or IP address."
+ },
+ "sexuality": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to the discussion or display of sexuality."
+ },
+ "suffering": {
+ "type": "token",
+ "description": "The content could be perceived as distressing due to the discussion or display of suffering or triggering topics, including suicide, eating disorders or self harm."
+ },
+ "violence": {
+ "type": "token",
+ "description": "The content could be perceived as offensive due to the discussion or display of violence."
+ }
+ }
+}
diff --git a/lexicons/place/stream/metadata/distributionPolicy.json b/lexicons/place/stream/metadata/distributionPolicy.json
new file mode 100644
index 00000000..a289e2dc
--- /dev/null
+++ b/lexicons/place/stream/metadata/distributionPolicy.json
@@ -0,0 +1,16 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.metadata.distributionPolicy",
+ "defs": {
+ "main": {
+ "type": "object",
+ "description": "Distribution and rebroadcast policy.",
+ "properties": {
+ "deleteAfter": {
+ "type": "integer",
+ "description": "Duration in seconds after which segments should be deleted. Each segment will expire N seconds after its creation time."
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/place/stream/segment.json b/lexicons/place/stream/segment.json
index 20fb5017..3c270dc3 100644
--- a/lexicons/place/stream/segment.json
+++ b/lexicons/place/stream/segment.json
@@ -48,6 +48,18 @@
"size": {
"type": "integer",
"description": "The size of the segment in bytes"
+ },
+ "contentWarnings": {
+ "type": "ref",
+ "ref": "place.stream.metadata.contentWarnings"
+ },
+ "contentRights": {
+ "type": "ref",
+ "ref": "place.stream.metadata.contentRights"
+ },
+ "distributionPolicy": {
+ "type": "ref",
+ "ref": "place.stream.metadata.distributionPolicy"
}
}
}
diff --git a/pkg/api/stream_key.go b/pkg/api/stream_key.go
index 384f139f..747c53a1 100644
--- a/pkg/api/stream_key.go
+++ b/pkg/api/stream_key.go
@@ -83,16 +83,13 @@ func (a *StreamplaceAPI) MakeMediaSigner(ctx context.Context, keyStr string) (me
}
var mediaSigner media.MediaSigner
- if !a.CLI.ExternalSigning {
- mediaSigner, err = media.MakeMediaSigner(ctx, a.CLI, did, signer)
- if err != nil {
- return nil, fmt.Errorf("invalid authorization key (not valid secp256k1): %w", err)
- }
+ if a.CLI.ExternalSigning {
+ mediaSigner, err = media.MakeMediaSignerExt(ctx, a.CLI, did, addrBytes, a.Model)
} else {
- mediaSigner, err = media.MakeMediaSignerExt(ctx, a.CLI, did, addrBytes)
- if err != nil {
- return nil, fmt.Errorf("invalid authorization key (not valid secp256k1): %w", err)
- }
+ mediaSigner, err = media.MakeMediaSigner(ctx, a.CLI, did, signer, a.Model)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("invalid authorization key (not valid secp256k1): %w", err)
}
return mediaSigner, nil
diff --git a/pkg/atproto/firehose.go b/pkg/atproto/firehose.go
index e3459be2..468cc238 100644
--- a/pkg/atproto/firehose.go
+++ b/pkg/atproto/firehose.go
@@ -166,6 +166,7 @@ var CollectionFilter = []string{
constants.APP_BSKY_GRAPH_BLOCK,
constants.PLACE_STREAM_SERVER_SETTINGS,
constants.PLACE_STREAM_CHAT_GATE,
+ constants.PLACE_STREAM_DEFAULT_METADATA,
}
func (atsync *ATProtoSynchronizer) handleCommitEventOps(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit) {
diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go
index db4b21a1..12b7c0ba 100644
--- a/pkg/atproto/sync.go
+++ b/pkg/atproto/sync.go
@@ -389,6 +389,21 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD
log.Error(ctx, "failed to create signing key", "err", err)
}
+ case *streamplace.MetadataConfiguration:
+ repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model)
+ if err != nil {
+ return fmt.Errorf("failed to sync bluesky repo: %w", err)
+ }
+ log.Debug(ctx, "creating metadata configuration", "metadata", rec)
+ metadata := &model.MetadataConfiguration{
+ RepoDID: userDID,
+ Record: recCBOR,
+ Repo: repo,
+ }
+ err = atsync.Model.CreateMetadataConfiguration(ctx, metadata)
+ if err != nil {
+ log.Error(ctx, "failed to create metadata configuration", "err", err)
+ }
default:
log.Debug(ctx, "unhandled record type", "type", reflect.TypeOf(rec))
}
diff --git a/pkg/cmd/sign.go b/pkg/cmd/sign.go
index 3ae938f1..6f5c1f05 100644
--- a/pkg/cmd/sign.go
+++ b/pkg/cmd/sign.go
@@ -12,6 +12,7 @@ import (
"github.com/decred/dcrd/dcrec/secp256k1"
"github.com/mr-tron/base58"
"stream.place/streamplace/pkg/crypto/aqpub"
+ "stream.place/streamplace/pkg/log"
"stream.place/streamplace/pkg/media"
)
@@ -22,10 +23,16 @@ func Sign(ctx context.Context) error {
streamerName := fs.String("streamer", "", "streamer name")
taURL := fs.String("ta-url", "http://timestamp.digicert.com", "timestamp authority server for signing")
startTime := fs.Int64("start-time", 0, "start time of the stream")
+ manifestJSON := fs.String("manifest", "", "JSON manifest to use for signing")
if err := fs.Parse(os.Args[2:]); err != nil {
return err
}
+ log.Debug(ctx, "Sign command: starting",
+ "streamer", *streamerName,
+ "startTime", *startTime,
+ "hasManifest", len(*manifestJSON) > 0)
+
keyBs, err := base58.Decode(*key)
if err != nil {
return err
@@ -52,11 +59,16 @@ func Sign(ctx context.Context) error {
}
ms := &media.MediaSignerLocal{
- Signer: signer,
- Cert: certBs,
- StreamerName: *streamerName,
- TAURL: *taURL,
- AQPub: pub,
+ Signer: signer,
+ Cert: certBs,
+ StreamerName: *streamerName,
+ TAURL: *taURL,
+ AQPub: pub,
+ PrebuiltManifest: []byte(*manifestJSON), // Pass the manifest from parent process
+ }
+
+ if len(*manifestJSON) > 0 {
+ log.Debug(ctx, "Sign command: using provided manifest", "manifestLength", len(*manifestJSON))
}
inputBs, err := io.ReadAll(os.Stdin)
diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go
index d3ad3589..33b9097f 100644
--- a/pkg/cmd/streamplace.go
+++ b/pkg/cmd/streamplace.go
@@ -189,6 +189,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error {
if err != nil {
return err
}
+
err = flag.CommandLine.Parse(nil)
if err != nil {
return err
@@ -381,7 +382,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error {
return err
}
- ms, err := media.MakeMediaSigner(ctx, &cli, cli.StreamerName, signer)
+ ms, err := media.MakeMediaSigner(ctx, &cli, cli.StreamerName, signer, mod)
if err != nil {
return err
}
@@ -502,7 +503,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error {
return err
}
did := atkey.DIDKey()
- testMediaSigner, err := media.MakeMediaSigner(ctx, &cli, did, testSigner)
+ testMediaSigner, err := media.MakeMediaSigner(ctx, &cli, did, testSigner, mod)
if err != nil {
return err
}
@@ -533,7 +534,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error {
return err
}
did2 := atkey2.DIDKey()
- intermittentMediaSigner, err := media.MakeMediaSigner(ctx, &cli, did2, intermittentSigner)
+ intermittentMediaSigner, err := media.MakeMediaSigner(ctx, &cli, did2, intermittentSigner, mod)
if err != nil {
return err
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index f692e80d..a7c5bbf3 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -115,11 +115,23 @@ type CLI struct {
AtprotoDID string
LivepeerHelp bool
PLCURL string
+ ContentFilters *ContentFilters
SQLLogging bool
SentryDSN string
LivepeerDebug bool
}
+// ContentFilters represents the content filtering configuration
+type ContentFilters struct {
+ ContentWarnings struct {
+ Enabled bool `json:"enabled"`
+ BlockedWarnings []string `json:"blocked_warnings"`
+ } `json:"content_warnings"`
+ DistributionPolicy struct {
+ Enabled bool `json:"enabled"`
+ } `json:"distribution_policy"`
+}
+
func (cli *CLI) NewFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet("streamplace", flag.ExitOnError)
fs.StringVar(&cli.DataDir, "data-dir", DefaultDataDir(), "directory for keeping all streamplace data")
@@ -179,6 +191,7 @@ func (cli *CLI) NewFlagSet(name string) *flag.FlagSet {
fs.StringVar(&cli.AndroidCertFingerprint, "android-cert-fingerprint", "", "android cert fingerprint for deep linking")
cli.StringSliceFlag(fs, &cli.Labelers, "labelers", "", "did of labelers that this instance should subscribe to")
fs.StringVar(&cli.AtprotoDID, "atproto-did", "", "atproto did to respond to on /.well-known/atproto-did (default did:web:PUBLIC_HOST)")
+ cli.JSONFlag(fs, &cli.ContentFilters, "content-filters", "{}", "JSON content filtering rules")
fs.BoolVar(&cli.LivepeerHelp, "livepeer-help", false, "print help for livepeer flags and exit")
fs.StringVar(&cli.PLCURL, "plc-url", "https://plc.directory", "url of the plc directory")
fs.BoolVar(&cli.SQLLogging, "sql-logging", false, "enable sql logging")
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 04d87004..f567b10c 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -1,15 +1,70 @@
package constants
-var PLACE_STREAM_KEY = "place.stream.key" //nolint:all
-var PLACE_STREAM_LIVESTREAM = "place.stream.livestream" //nolint:all
-var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all
-var PLACE_STREAM_CHAT_PROFILE = "place.stream.chat.profile" //nolint:all
-var PLACE_STREAM_SERVER_SETTINGS = "place.stream.server.settings" //nolint:all
-var STREAMPLACE_SIGNING_KEY = "signingKey" //nolint:all
-var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all
-var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all
-var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all
-var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all
+var PLACE_STREAM_KEY = "place.stream.key" //nolint:all
+var PLACE_STREAM_LIVESTREAM = "place.stream.livestream" //nolint:all
+var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all
+var PLACE_STREAM_CHAT_PROFILE = "place.stream.chat.profile" //nolint:all
+var PLACE_STREAM_SERVER_SETTINGS = "place.stream.server.settings" //nolint:all
+var STREAMPLACE_SIGNING_KEY = "signingKey" //nolint:all
+var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all
+var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all
+var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all
+var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all
+var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all
const DID_KEY_PREFIX = "did:key" //nolint:all
const ADDRESS_KEY_PREFIX = "0x" //nolint:all
+
+// Streamplace metadata constant
+const StreamplaceMetadata = "place.stream.metadata" //nolint:all
+
+// Streamplace metadata license values
+const (
+ LicenseCC0_1_0 = "place.stream.metadata.contentRights#cc0_1__0"
+ LicenseCCBy_4_0 = "place.stream.metadata.contentRights#cc-by_4__0"
+ LicenseCCBySA_4_0 = "place.stream.metadata.contentRights#cc-by-sa_4__0"
+ LicenseCCByNC_4_0 = "place.stream.metadata.contentRights#cc-by-nc_4__0"
+ LicenseCCByNCSA_4_0 = "place.stream.metadata.contentRights#cc-by-nc-sa_4__0"
+ LicenseCCByND_4_0 = "place.stream.metadata.contentRights#cc-by-nd_4__0"
+ LicenseCCByNCND_4_0 = "place.stream.metadata.contentRights#cc-by-nc-nd_4__0"
+ LicenseAllRightsReserved = "place.stream.metadata.contentRights#all-rights-reserved"
+)
+
+// License URLs for C2PA manifests
+const (
+ LicenseURLCC0_1_0 = "http://creativecommons.org/publicdomain/zero/1.0/"
+ LicenseURLCCBy_4_0 = "http://creativecommons.org/licenses/by/4.0/"
+ LicenseURLCCBySA_4_0 = "http://creativecommons.org/licenses/by-sa/4.0/"
+ LicenseURLCCByNC_4_0 = "http://creativecommons.org/licenses/by-nc/4.0/"
+ LicenseURLCCByNCSA_4_0 = "http://creativecommons.org/licenses/by-nc-sa/4.0/"
+ LicenseURLCCByND_4_0 = "http://creativecommons.org/licenses/by-nd/4.0/"
+ LicenseURLCCByNCND_4_0 = "http://creativecommons.org/licenses/by-nc-nd/4.0/"
+)
+
+// Streamplace metadata warning labels
+const (
+ WarningDeath = "place.stream.metadata.contentWarnings#death"
+ WarningDrugUse = "place.stream.metadata.contentWarnings#drugUse"
+ WarningFantasyViolence = "place.stream.metadata.contentWarnings#fantasyViolence"
+ WarningFlashingLights = "place.stream.metadata.contentWarnings#flashingLights"
+ WarningLanguage = "place.stream.metadata.contentWarnings#language"
+ WarningNudity = "place.stream.metadata.contentWarnings#nudity"
+ WarningPII = "place.stream.metadata.contentWarnings#PII"
+ WarningSexuality = "place.stream.metadata.contentWarnings#sexuality"
+ WarningSuffering = "place.stream.metadata.contentWarnings#suffering"
+ WarningViolence = "place.stream.metadata.contentWarnings#violence"
+)
+
+// Content warning C2PA codes for manifests
+const (
+ WarningC2PADeath = "cwarn:death"
+ WarningC2PADrugUse = "cwarn:drugUse"
+ WarningC2PAFantasyViolence = "cwarn:fantasyViolence"
+ WarningC2PAFlashingLights = "cwarn:flashingLights"
+ WarningC2PALanguage = "cwarn:language"
+ WarningC2PANudity = "cwarn:nudity"
+ WarningC2PAPII = "cwarn:PII"
+ WarningC2PASexuality = "cwarn:sexuality"
+ WarningC2PASuffering = "cwarn:suffering"
+ WarningC2PAViolence = "cwarn:violence"
+)
diff --git a/pkg/gen/gen.go b/pkg/gen/gen.go
index 9ba9402d..6c578fc1 100644
--- a/pkg/gen/gen.go
+++ b/pkg/gen/gen.go
@@ -25,6 +25,10 @@ func main() {
streamplace.ChatMessage_ReplyRef{},
streamplace.ServerSettings{},
streamplace.ChatGate{},
+ streamplace.MetadataConfiguration{},
+ streamplace.MetadataDistributionPolicy{},
+ streamplace.MetadataContentRights{},
+ streamplace.MetadataContentWarnings{},
); err != nil {
panic(err)
}
diff --git a/pkg/media/manifest_builder.go b/pkg/media/manifest_builder.go
new file mode 100644
index 00000000..934c7725
--- /dev/null
+++ b/pkg/media/manifest_builder.go
@@ -0,0 +1,210 @@
+package media
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "stream.place/streamplace/pkg/aqtime"
+ "stream.place/streamplace/pkg/constants"
+ "stream.place/streamplace/pkg/log"
+ "stream.place/streamplace/pkg/model"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+// ManifestBuilder is responsible for creating C2PA (Content Credentials) manifests
+// for livestream segments.
+// The builder creates manifests that include:
+// - Basic livestream information (title, creator, date)
+// - Content rights and copyright information
+// - Content warnings for sensitive material
+// - Distribution policies
+// - C2PA action history (created, published)
+// The manifest is meant to align closely with the IPTC Video Metadata Recommendations.
+// See https://iptc.org/std/videometadatahub/recommendation/IPTC-VideoMetadataHub-props-Rec_1.6.html
+type ManifestBuilder struct {
+ model model.Model
+}
+
+func NewManifestBuilder(model model.Model) *ManifestBuilder {
+ return &ManifestBuilder{
+ model: model,
+ }
+}
+
+func (mb *ManifestBuilder) BuildManifest(ctx context.Context, streamerName string, start int64) ([]byte, error) {
+ fmt.Printf("🔍 BuildManifest CALLED for %s at %d\n", streamerName, start)
+ log.Warn(ctx, "🔍 BuildManifest ENTRY", "streamer", streamerName, "start", start)
+ // Start with base manifest
+ mani := obj{
+ "title": fmt.Sprintf("Livestream Segment at %s", aqtime.FromMillis(start)),
+ "assertions": []obj{
+ {
+ "label": "c2pa.actions",
+ "data": obj{
+ "actions": []obj{
+ {"action": "c2pa.created"},
+ {"action": "c2pa.published"},
+ },
+ },
+ },
+ {
+ "label": constants.StreamplaceMetadata,
+ "data": obj{
+ "@context": obj{
+ "dc": "http://purl.org/dc/elements/1.1/",
+ "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/",
+ "photoshop": "http://ns.adobe.com/photoshop/1.0/",
+ "xmpRights": "http://ns.adobe.com/xap/1.0/rights/",
+ },
+ "dc:creator": streamerName,
+ // TODO: Add the title of the livestream. This should come from the livestream record.
+ "dc:title": []string{"livestream"},
+ "dc:date": []string{aqtime.FromMillis(start).String()},
+ },
+ },
+ },
+ }
+
+ // Add database metadata if available
+ if mb.model != nil {
+ metadata, err := mb.model.GetMetadataConfiguration(ctx, streamerName)
+ if err != nil {
+ log.Warn(ctx, "ManifestBuilder: failed to retrieve metadata", "error", err, "did", streamerName)
+ return nil, fmt.Errorf("failed to retrieve metadata: %w", err)
+ } else if metadata != nil {
+ log.Warn(ctx, "ManifestBuilder: found metadata configuration", "did", streamerName, "metadata", metadata)
+ streamplaceMetadata, err := metadata.ToStreamplaceMetadataConfiguration()
+ if err != nil {
+ log.Warn(ctx, "ManifestBuilder: failed to convert metadata, using defaults", "error", err, "did", streamerName)
+ } else {
+ log.Warn(ctx, "ManifestBuilder: enhancing manifest with metadata", "did", streamerName, "contentWarnings", streamplaceMetadata.ContentWarnings, "contentRights", streamplaceMetadata.ContentRights)
+ mani = mb.enhanceManifestWithMetadata(mani, streamplaceMetadata)
+ }
+ } else {
+ log.Warn(ctx, "ManifestBuilder: no metadata configuration found for streamer", "did", streamerName)
+ }
+ }
+
+ // Add livestream title if available
+ livestreamTitle := "livestream" // default fallback
+ if mb.model != nil {
+ livestream, err := mb.model.GetLatestLivestreamForRepo(streamerName)
+ if err != nil {
+ log.Warn(ctx, "ManifestBuilder: failed to retrieve livestream, using default title", "error", err, "did", streamerName)
+ } else if livestream != nil {
+ // Extract title from livestream record
+ livestreamRecord, err := livestream.ToLivestreamView()
+ if err != nil {
+ log.Warn(ctx, "ManifestBuilder: failed to convert livestream to view, using default title", "error", err, "did", streamerName)
+ } else {
+ if ls, ok := livestreamRecord.Record.Val.(*streamplace.Livestream); ok {
+ livestreamTitle = ls.Title
+ }
+ }
+ }
+ }
+
+ // Update the manifest title with the retrieved livestream title
+ mani["assertions"].([]obj)[1]["data"].(obj)["dc:title"] = []string{livestreamTitle}
+
+ // Convert manifest to JSON bytes for use with Rust c2pa library
+ manifestBs, err := json.Marshal(mani)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal manifest: %w", err)
+ }
+
+ return manifestBs, nil
+}
+
+// getLicenseCodeMap returns a map of internal license codes to their corresponding URLs
+func getLicenseCodeMap() map[string]string {
+ return map[string]string{
+ constants.LicenseCC0_1_0: constants.LicenseURLCC0_1_0,
+ constants.LicenseCCBy_4_0: constants.LicenseURLCCBy_4_0,
+ constants.LicenseCCBySA_4_0: constants.LicenseURLCCBySA_4_0,
+ constants.LicenseCCByNC_4_0: constants.LicenseURLCCByNC_4_0,
+ constants.LicenseCCByNCSA_4_0: constants.LicenseURLCCByNCSA_4_0,
+ constants.LicenseCCByND_4_0: constants.LicenseURLCCByND_4_0,
+ constants.LicenseCCByNCND_4_0: constants.LicenseURLCCByNCND_4_0,
+ }
+}
+
+// getWarningCodeMap returns a map of internal warning codes to their corresponding C2PA codes
+func getWarningCodeMap() map[string]string {
+ return map[string]string{
+ constants.WarningDeath: constants.WarningC2PADeath,
+ constants.WarningDrugUse: constants.WarningC2PADrugUse,
+ constants.WarningFantasyViolence: constants.WarningC2PAFantasyViolence,
+ constants.WarningFlashingLights: constants.WarningC2PAFlashingLights,
+ constants.WarningLanguage: constants.WarningC2PALanguage,
+ constants.WarningNudity: constants.WarningC2PANudity,
+ constants.WarningPII: constants.WarningC2PAPII,
+ constants.WarningSexuality: constants.WarningC2PASexuality,
+ constants.WarningSuffering: constants.WarningC2PASuffering,
+ constants.WarningViolence: constants.WarningC2PAViolence,
+ }
+}
+
+func (mb *ManifestBuilder) enhanceManifestWithMetadata(mani obj, metadata *streamplace.MetadataConfiguration) obj {
+ if metadata.ContentRights != nil {
+ // TODO: We are currently validating the creator in the ValidateMP4 function to be the streamer DID
+ // if metadata.ContentRights.Creator != nil {
+ // mani["assertions"].([]obj)[1]["data"].(obj)["dc:creator"] = *metadata.ContentRights.Creator
+ // }
+
+ // Copyright Notice
+ if metadata.ContentRights.CopyrightNotice != nil {
+ mani["assertions"].([]obj)[1]["data"].(obj)["dc:rights"] = *metadata.ContentRights.CopyrightNotice
+ }
+
+ // Copyright Year
+ if metadata.ContentRights.CopyrightYear != nil {
+ mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:CopyrightYear"] = *metadata.ContentRights.CopyrightYear
+ }
+
+ // Credit Line
+ if metadata.ContentRights.CreditLine != nil {
+ mani["assertions"].([]obj)[1]["data"].(obj)["photoshop:Credit"] = *metadata.ContentRights.CreditLine
+ }
+
+ // Build the license field
+ if metadata.ContentRights.License != nil {
+ // Map internal license codes to known licenses
+ licenseCodeMap := getLicenseCodeMap()
+ if mappedCode, exists := licenseCodeMap[*metadata.ContentRights.License]; exists {
+ // it's a known linked license, so we can use the mapped code
+ mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:LinkedEncRightsExpr"] = mappedCode
+ } else {
+ // This is either an unknown or an unlinked license, so we need to put it in the UsageTerms field
+ // which allows for licensing terms expressed in free text
+ if *metadata.ContentRights.License == constants.LicenseAllRightsReserved {
+ // if all rights reserved, we can put the string "All rights reserved" in the UsageTerms field
+ mani["assertions"].([]obj)[1]["data"].(obj)["xmpRights:UsageTerms"] = "All rights reserved"
+ } else {
+ // it's an unknown license, so we need to put it directly in the UsageTerms field
+ mani["assertions"].([]obj)[1]["data"].(obj)["xmpRights:UsageTerms"] = *metadata.ContentRights.License
+ }
+ }
+ }
+ }
+
+ if metadata.ContentWarnings != nil && len(metadata.ContentWarnings.Warnings) > 0 {
+ // Map internal warning codes to C2PA warning codes
+ warningCodeMap := getWarningCodeMap()
+
+ for i, warning := range metadata.ContentWarnings.Warnings {
+ if mappedCode, exists := warningCodeMap[warning]; exists {
+ metadata.ContentWarnings.Warnings[i] = mappedCode
+ }
+ // Unknown warnings remain unchanged
+ }
+ mani["assertions"].([]obj)[1]["data"].(obj)["Iptc4xmpExt:ContentWarning"] = metadata.ContentWarnings.Warnings
+ }
+
+ if metadata.DistributionPolicy != nil {
+ mani["assertions"].([]obj)[1]["data"].(obj)["distributionPolicy"] = metadata.DistributionPolicy
+ }
+
+ return mani
+}
diff --git a/pkg/media/media.go b/pkg/media/media.go
index a4240927..684f9ac8 100644
--- a/pkg/media/media.go
+++ b/pkg/media/media.go
@@ -118,7 +118,6 @@ func MakeMediaManager(ctx context.Context, cli *config.CLI, signer crypto.Signer
},
},
}
-
return &MediaManager{
cli: cli,
replicator: rep,
@@ -188,9 +187,12 @@ type ExpandedSchemaOrg []struct {
}
type SegmentMetadata struct {
- StartTime aqtime.AQTime
- Title string
- Creator string
+ StartTime aqtime.AQTime
+ Title string
+ Creator string
+ ContentWarnings []string
+ ContentRights *model.ContentRights
+ DistributionPolicy *model.DistributionPolicy
}
var ErrInvalidMetadata = errors.New("invalid segment metadata")
@@ -240,10 +242,168 @@ func ParseSegmentAssertions(ctx context.Context, mani *c2patypes.Manifest) (*Seg
if err != nil {
return nil, err
}
+
+ contentWarnings := extractContentWarnings(mani)
+ contentRights := extractContentRights(mani)
+ distributionPolicy := extractDistributionPolicy(mani, start)
+
out := SegmentMetadata{
- StartTime: start,
- Title: meta.Title[0].Value,
- Creator: meta.Creator[0].Value,
+ StartTime: start,
+ Title: meta.Title[0].Value,
+ Creator: meta.Creator[0].Value,
+ ContentWarnings: contentWarnings,
+ ContentRights: contentRights,
+ DistributionPolicy: distributionPolicy,
}
return &out, nil
}
+
+// findAssertion finds an assertion by label
+func findAssertion(mani *c2patypes.Manifest, label string) *c2patypes.ManifestAssertion {
+ for _, a := range mani.Assertions {
+ if a.Label == label {
+ return &a
+ }
+ }
+ return nil
+}
+
+// extractContentWarnings extracts content warnings from the C2PA manifest
+func extractContentWarnings(mani *c2patypes.Manifest) []string {
+ ass := findAssertion(mani, StreamplaceMetadata)
+ if ass == nil {
+ return nil
+ }
+
+ data, ok := ass.Data.(map[string]interface{})
+ if !ok {
+ return nil
+ }
+
+ warnings, ok := data["Iptc4xmpExt:ContentWarning"]
+ if !ok {
+ return nil
+ }
+
+ warningList, ok := warnings.([]interface{})
+ if !ok {
+ return nil
+ }
+
+ result := make([]string, 0, len(warningList))
+ for _, warning := range warningList {
+ if warningStr, ok := warning.(string); ok {
+ result = append(result, warningStr)
+ }
+ }
+
+ return result
+}
+
+// extractContentRights extracts content rights from the C2PA manifest
+func extractContentRights(mani *c2patypes.Manifest) *model.ContentRights {
+ ass := findAssertion(mani, StreamplaceMetadata)
+ if ass == nil {
+ return nil
+ }
+
+ data, ok := ass.Data.(map[string]interface{})
+ if !ok {
+ return nil
+ }
+
+ rights := &model.ContentRights{}
+
+ // Extract copyright notice
+ if notice, ok := data["dc:rights"]; ok {
+ if noticeStr, ok := notice.(string); ok {
+ rights.CopyrightNotice = ¬iceStr
+ }
+ }
+
+ // Extract copyright year
+ if year, ok := data["Iptc4xmpExt:CopyrightYear"]; ok {
+ if yearNum, ok := year.(float64); ok {
+ yearInt := int64(yearNum)
+ rights.CopyrightYear = &yearInt
+ }
+ }
+
+ // Extract creator
+ if creator, ok := data["dc:creator"]; ok {
+ if creatorStr, ok := creator.(string); ok {
+ rights.Creator = &creatorStr
+ }
+ }
+
+ // Extract credit line
+ if credit, ok := data["photoshop:Credit"]; ok {
+ if creditStr, ok := credit.(string); ok {
+ rights.CreditLine = &creditStr
+ }
+ }
+
+ // Extract license information
+ if license, ok := data["Iptc4xmpExt:LinkedEncRightsExpr"]; ok {
+ if licenseStr, ok := license.(string); ok {
+ rights.License = &licenseStr
+ }
+ } else if usageTerms, ok := data["xmpRights:UsageTerms"]; ok {
+ if usageStr, ok := usageTerms.(string); ok {
+ rights.License = &usageStr
+ }
+ }
+
+ // Return nil if no rights information was found
+ if rights.CopyrightNotice == nil && rights.CopyrightYear == nil &&
+ rights.Creator == nil && rights.CreditLine == nil && rights.License == nil {
+ return nil
+ }
+
+ return rights
+}
+
+// extractDistributionPolicy extracts distribution policy from the C2PA manifest
+func extractDistributionPolicy(mani *c2patypes.Manifest, segmentStart aqtime.AQTime) *model.DistributionPolicy {
+ ass := findAssertion(mani, StreamplaceMetadata)
+ if ass == nil {
+ return nil
+ }
+
+ data, ok := ass.Data.(map[string]interface{})
+ if !ok {
+ return nil
+ }
+
+ policy, ok := data["distributionPolicy"]
+ if !ok {
+ return nil
+ }
+
+ policyMap, ok := policy.(map[string]interface{})
+ if !ok {
+ return nil
+ }
+
+ deleteAfter, ok := policyMap["deleteAfter"]
+ if !ok {
+ return nil
+ }
+
+ // deleteAfter is an integer (duration in seconds)
+ var durationSecs int64
+ switch v := deleteAfter.(type) {
+ case float64:
+ durationSecs = int64(v)
+ case int64:
+ durationSecs = v
+ case int:
+ durationSecs = int64(v)
+ default:
+ return nil
+ }
+
+ return &model.DistributionPolicy{
+ DurationSeconds: &durationSecs,
+ }
+}
diff --git a/pkg/media/media_signer.go b/pkg/media/media_signer.go
index e49afae6..129e5ea6 100644
--- a/pkg/media/media_signer.go
+++ b/pkg/media/media_signer.go
@@ -19,6 +19,8 @@ import (
"stream.place/streamplace/pkg/crypto/aqpub"
"stream.place/streamplace/pkg/crypto/signers"
"stream.place/streamplace/pkg/iroh/generated/iroh_streamplace"
+ "stream.place/streamplace/pkg/log"
+ "stream.place/streamplace/pkg/model"
"stream.place/streamplace/pkg/spmetrics"
)
@@ -30,12 +32,14 @@ type MediaSigner interface {
}
type MediaSignerLocal struct {
- StreamerName string
- Signer crypto.Signer
- AQPub aqpub.Pub
- Cert []byte
- TAURL string
- did string
+ StreamerName string
+ Signer crypto.Signer
+ AQPub aqpub.Pub
+ Cert []byte
+ TAURL string
+ did string
+ manifestBuilder *ManifestBuilder
+ PrebuiltManifest []byte // Optional: use this manifest instead of building one
}
func prepareCert(ctx context.Context, cli *config.CLI, signer crypto.Signer) ([]byte, error) {
@@ -48,7 +52,7 @@ func prepareCert(ctx context.Context, cli *config.CLI, signer crypto.Signer) ([]
return cert, nil
}
-func MakeMediaSigner(ctx context.Context, cli *config.CLI, streamer string, signer crypto.Signer) (MediaSigner, error) {
+func MakeMediaSigner(ctx context.Context, cli *config.CLI, streamer string, signer crypto.Signer, model model.Model) (MediaSigner, error) {
cert, err := prepareCert(ctx, cli, signer)
if err != nil {
return nil, err
@@ -62,12 +66,13 @@ func MakeMediaSigner(ctx context.Context, cli *config.CLI, streamer string, sign
return nil, err
}
return &MediaSignerLocal{
- Signer: signer,
- Cert: cert,
- StreamerName: streamer,
- TAURL: cli.TAURL,
- AQPub: pub,
- did: did.DIDKey(),
+ Signer: signer,
+ Cert: cert,
+ StreamerName: streamer,
+ TAURL: cli.TAURL,
+ AQPub: pub,
+ did: did.DIDKey(),
+ manifestBuilder: NewManifestBuilder(model),
}, nil
}
@@ -79,37 +84,62 @@ func (ms *MediaSignerLocal) SignMP4(ctx context.Context, input io.ReadSeeker, st
startTime := time.Now()
ctx, span := otel.Tracer("signer").Start(ctx, "SignMP4")
defer span.End()
- title := "livestream"
- mani := obj{
- "title": fmt.Sprintf("Livestream Segment at %s", aqtime.FromMillis(start)),
- "assertions": []obj{
- {
- "label": "c2pa.actions",
- "data": obj{
- "actions": []obj{
- {"action": "c2pa.created"},
- {"action": "c2pa.published"},
+
+ // Build manifest with metadata from database
+ var manifestBs []byte
+ var err error
+ if len(ms.PrebuiltManifest) > 0 {
+ // Use prebuilt manifest (from external signing subprocess)
+ manifestBs = ms.PrebuiltManifest
+ log.Debug(ctx, "SignMP4: using prebuilt manifest", "manifestLength", len(manifestBs))
+ } else if ms.manifestBuilder != nil {
+ ctx, span = otel.Tracer("signer").Start(ctx, "SignMP4_BuildManifest")
+ manifestBs, err = ms.manifestBuilder.BuildManifest(ctx, ms.StreamerName, start)
+ if err != nil {
+ span.End()
+ return nil, fmt.Errorf("failed to build manifest: %w", err)
+ }
+ span.End()
+ } else {
+ // This should NOT happen in production - manifestBuilder should always be initialized
+ log.Warn(ctx, "SignMP4: manifestBuilder is nil, using fallback manifest - this indicates model was not passed to MakeMediaSigner", "streamer", ms.StreamerName)
+ // Fallback to basic manifest without metadata
+ ctx, span = otel.Tracer("signer").Start(ctx, "SignMP4_BasicManifest")
+ title := "livestream"
+ mani := obj{
+ "title": fmt.Sprintf("Livestream Segment at %s", aqtime.FromMillis(start)),
+ "assertions": []obj{
+ {
+ "label": "c2pa.actions",
+ "data": obj{
+ "actions": []obj{
+ {"action": "c2pa.created"},
+ {"action": "c2pa.published"},
+ },
},
},
- },
- {
- "label": StreamplaceMetadata,
- "data": obj{
- "@context": obj{
- "dc": "http://purl.org/dc/elements/1.1/",
+ {
+ "label": StreamplaceMetadata,
+ "data": obj{
+ "@context": obj{
+ "dc": "http://purl.org/dc/elements/1.1/",
+ },
+ "dc:creator": ms.StreamerName,
+ "dc:title": []string{title},
+ "dc:date": []string{aqtime.FromMillis(start).String()},
},
- "dc:creator": ms.StreamerName,
- "dc:title": []string{title},
- "dc:date": []string{aqtime.FromMillis(start).String()},
},
},
- },
+ }
+ manifestBs, err = json.Marshal(mani)
+ if err != nil {
+ span.End()
+ return nil, fmt.Errorf("failed to marshal basic manifest: %w", err)
+ }
+ span.End()
}
+
ctx, span = otel.Tracer("signer").Start(ctx, "SignMP4_MarshalManifest")
- manifestBs, err := json.Marshal(mani)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal manifest: %w", err)
- }
var manifest c2patypes.ManifestDefinition
err = json.Unmarshal(manifestBs, &manifest)
if err != nil {
diff --git a/pkg/media/media_signer_ext.go b/pkg/media/media_signer_ext.go
index 4c9a20c0..15736fcf 100644
--- a/pkg/media/media_signer_ext.go
+++ b/pkg/media/media_signer_ext.go
@@ -17,21 +17,24 @@ import (
"stream.place/streamplace/pkg/atproto"
"stream.place/streamplace/pkg/config"
"stream.place/streamplace/pkg/crypto/aqpub"
+ "stream.place/streamplace/pkg/log"
+ "stream.place/streamplace/pkg/model"
"stream.place/streamplace/pkg/spmetrics"
)
type MediaSignerExt struct {
- cli *config.CLI
- signer crypto.Signer
- pub aqpub.Pub
- certPath string
- streamer string
- keyBs []byte
- taURL string
- did string
+ cli *config.CLI
+ signer crypto.Signer
+ pub aqpub.Pub
+ certPath string
+ streamer string
+ keyBs []byte
+ taURL string
+ did string
+ manifestBuilder *ManifestBuilder
}
-func MakeMediaSignerExt(ctx context.Context, cli *config.CLI, streamer string, keyBs []byte) (MediaSigner, error) {
+func MakeMediaSignerExt(ctx context.Context, cli *config.CLI, streamer string, keyBs []byte, model model.Model) (MediaSigner, error) {
key, _ := secp256k1.PrivKeyFromBytes(keyBs)
if key == nil {
return nil, fmt.Errorf("invalid authorization key (not valid secp256k1)")
@@ -62,14 +65,14 @@ func MakeMediaSignerExt(ctx context.Context, cli *config.CLI, streamer string, k
return nil, err
}
return &MediaSignerExt{
- // cli: cli,
- signer: signer,
- certPath: certPath,
- streamer: streamer,
- pub: pub,
- keyBs: keyBs,
- taURL: cli.TAURL,
- did: did.DIDKey(),
+ signer: signer,
+ certPath: certPath,
+ streamer: streamer,
+ pub: pub,
+ keyBs: keyBs,
+ taURL: cli.TAURL,
+ did: did.DIDKey(),
+ manifestBuilder: NewManifestBuilder(model),
}, nil
}
@@ -77,6 +80,14 @@ func (ms *MediaSignerExt) SignMP4(ctx context.Context, input io.ReadSeeker, star
startTime := time.Now()
_, span := otel.Tracer("signer").Start(ctx, "SignMP4_Ext")
defer span.End()
+
+ // Build manifest with metadata from database
+ manifestBs, err := ms.manifestBuilder.BuildManifest(ctx, ms.streamer, start)
+ if err != nil {
+ log.Error(ctx, "MediaSignerExt: failed to build manifest", "error", err)
+ return nil, fmt.Errorf("failed to build manifest: %w", err)
+ }
+
// Get the path to the current executable
execPath, err := os.Executable()
if err != nil {
@@ -85,13 +96,14 @@ func (ms *MediaSignerExt) SignMP4(ctx context.Context, input io.ReadSeeker, star
enc := base58.Encode(ms.keyBs)
- // Prepare command
+ // Prepare command with manifest JSON
cmd := exec.Command(execPath, "sign",
"--key", enc,
"--cert", ms.certPath,
"--ta-url", ms.taURL,
"--streamer", ms.streamer,
- "--start-time", fmt.Sprintf("%d", start))
+ "--start-time", fmt.Sprintf("%d", start),
+ "--manifest", string(manifestBs))
// overwrite so that our subprocesses don't do their own leak checking
cmd.Env = append(os.Environ(), "LD_PRELOAD=")
@@ -121,8 +133,10 @@ func (ms *MediaSignerExt) SignMP4(ctx context.Context, input io.ReadSeeker, star
// Wait for the command to complete
if err := cmd.Wait(); err != nil {
+ log.Error(ctx, "MediaSignerExt: subprocess failed", "error", err, "stderr", stderr.String())
return nil, fmt.Errorf("command failed: %w, stderr: %s", err, stderr.String())
}
+
spmetrics.SigningDuration.WithLabelValues(ms.streamer).Observe(float64(time.Since(startTime).Milliseconds()))
return stdout.Bytes(), nil
}
diff --git a/pkg/media/segmenter.go b/pkg/media/segmenter.go
index 485e2a21..c7f5d985 100644
--- a/pkg/media/segmenter.go
+++ b/pkg/media/segmenter.go
@@ -91,6 +91,8 @@ func (mm *MediaManager) SegmentAndSignElem(ctx context.Context, ms MediaSigner)
if err != nil {
log.Error(ctx, "error validating segment", "error", err)
globalerror.GlobalError(err)
+ // Stop the pipeline to end the stream (removes from feed)
+ elem.ErrorMessage(gst.DomainCore, gst.CoreErrorFailed, err.Error(), fmt.Sprintf("Segment validation failed: %s", err.Error()))
return
}
},
diff --git a/pkg/media/validate.go b/pkg/media/validate.go
index a5f027a5..8b0cc0e6 100644
--- a/pkg/media/validate.go
+++ b/pkg/media/validate.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"strings"
+ "time"
"go.opentelemetry.io/otel"
"stream.place/streamplace/pkg/aqtime"
@@ -77,6 +78,14 @@ func (mm *MediaManager) ValidateMP4(ctx context.Context, input io.Reader) error
if err != nil {
return fmt.Errorf("got valid segment, but user %s is not allowed: %w", repoDID, err)
}
+
+ // Apply content filtering after metadata is parsed
+ if mm.cli.ContentFilters != nil {
+ if err := mm.applyContentFilters(ctx, meta); err != nil {
+ return err
+ }
+ }
+
fd, err := mm.cli.SegmentFileCreate(repoDID, meta.StartTime, "mp4")
if err != nil {
return err
@@ -88,13 +97,16 @@ func (mm *MediaManager) ValidateMP4(ctx context.Context, input io.Reader) error
return err
}
seg := &model.Segment{
- ID: *maniCert.Manifest.Label,
- SigningKeyDID: signingKeyDID,
- RepoDID: repoDID,
- StartTime: meta.StartTime.Time(),
- Title: meta.Title,
- Size: len(buf),
- MediaData: mediaData,
+ ID: *maniCert.Manifest.Label,
+ SigningKeyDID: signingKeyDID,
+ RepoDID: repoDID,
+ StartTime: meta.StartTime.Time(),
+ Title: meta.Title,
+ Size: len(buf),
+ MediaData: mediaData,
+ ContentWarnings: model.ContentWarningsSlice(meta.ContentWarnings),
+ ContentRights: meta.ContentRights,
+ DistributionPolicy: meta.DistributionPolicy,
}
mm.newSegmentSubsMutex.RLock()
defer mm.newSegmentSubsMutex.RUnlock()
@@ -110,3 +122,50 @@ func (mm *MediaManager) ValidateMP4(ctx context.Context, input io.Reader) error
log.Log(ctx, "successfully ingested segment", "user", repoDID, "signingKey", signingKeyDID, "timestamp", aqt.FileSafeString(), "segmentID", *maniCert.Manifest.Label)
return nil
}
+
+// applyContentFilters applies content filtering based on configured rules
+func (mm *MediaManager) applyContentFilters(ctx context.Context, meta *SegmentMetadata) error {
+ // Check content warnings (if enabled)
+ if mm.cli.ContentFilters.ContentWarnings.Enabled {
+ for _, warning := range meta.ContentWarnings {
+ if mm.isWarningBlocked(warning) {
+ reason := fmt.Sprintf("content warning blocked: %s", warning)
+ log.Log(ctx, "content filtered",
+ "reason", reason,
+ "filter_type", "content_warning",
+ "creator", meta.Creator,
+ "warning", warning)
+ return fmt.Errorf("content filtered: %s", reason)
+ }
+ }
+ }
+
+ // Check distribution policy (if enabled)
+ if mm.cli.ContentFilters.DistributionPolicy.Enabled && meta.DistributionPolicy != nil {
+ if meta.DistributionPolicy.DurationSeconds != nil {
+ expiresAt := meta.StartTime.Time().Add(time.Duration(*meta.DistributionPolicy.DurationSeconds) * time.Second)
+ if time.Now().After(expiresAt) {
+ reason := fmt.Sprintf("distribution policy expired: segment started at %s, duration %ds", meta.StartTime, *meta.DistributionPolicy.DurationSeconds)
+ log.Log(ctx, "content filtered",
+ "reason", reason,
+ "filter_type", "distribution_policy",
+ "creator", meta.Creator,
+ "start_time", meta.StartTime,
+ "duration_seconds", *meta.DistributionPolicy.DurationSeconds)
+ return fmt.Errorf("content filtered: %s", reason)
+ }
+ }
+ }
+
+ return nil
+}
+
+// isWarningBlocked checks if a content warning is in the blocked list
+func (mm *MediaManager) isWarningBlocked(warning string) bool {
+ for _, blocked := range mm.cli.ContentFilters.ContentWarnings.BlockedWarnings {
+ if warning == blocked {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/model/default_metadata.go b/pkg/model/default_metadata.go
new file mode 100644
index 00000000..0341027f
--- /dev/null
+++ b/pkg/model/default_metadata.go
@@ -0,0 +1,57 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "gorm.io/gorm"
+ "stream.place/streamplace/pkg/streamplace"
+)
+
+type MetadataConfiguration struct {
+ RepoDID string `json:"repoDID" gorm:"primarykey;column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ Record *[]byte
+}
+
+func (m *MetadataConfiguration) ToStreamplaceMetadataConfiguration() (*streamplace.MetadataConfiguration, error) {
+ rec, err := lexutil.CborDecodeValue(*m.Record)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding feed post: %w", err)
+ }
+ sdm, ok := rec.(*streamplace.MetadataConfiguration)
+ if !ok {
+ return nil, fmt.Errorf("invalid metadata configuration")
+ }
+ return sdm, nil
+}
+
+func (m *DBModel) CreateMetadataConfiguration(ctx context.Context, metadata *MetadataConfiguration) error {
+ err := m.DB.Save(metadata).Error
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (m *DBModel) GetMetadataConfiguration(ctx context.Context, repoDID string) (*MetadataConfiguration, error) {
+ var metadata MetadataConfiguration
+ err := m.DB.Where("repo_did = ?", repoDID).First(&metadata).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &metadata, nil
+}
+
+func (m *DBModel) DeleteMetadataConfiguration(ctx context.Context, repoDID string) error {
+ err := m.DB.Where("repo_did = ?", repoDID).Delete(&MetadataConfiguration{}).Error
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/pkg/model/model.go b/pkg/model/model.go
index f5251953..d2c48794 100644
--- a/pkg/model/model.go
+++ b/pkg/model/model.go
@@ -96,6 +96,10 @@ type Model interface {
CreateLabel(label *Label) error
GetActiveLabels(uri string) ([]*comatproto.LabelDefs_Label, error)
+
+ CreateMetadataConfiguration(ctx context.Context, metadata *MetadataConfiguration) error
+ GetMetadataConfiguration(ctx context.Context, repoDID string) (*MetadataConfiguration, error)
+ DeleteMetadataConfiguration(ctx context.Context, repoDID string) error
}
var DBRevision = 2
@@ -152,6 +156,7 @@ func MakeDB(dbURL string) (Model, error) {
ServerSettings{},
Labeler{},
Label{},
+ MetadataConfiguration{},
} {
err = db.AutoMigrate(model)
if err != nil {
diff --git a/pkg/model/segment.go b/pkg/model/segment.go
index 52033b9c..e0a402b7 100644
--- a/pkg/model/segment.go
+++ b/pkg/model/segment.go
@@ -52,16 +52,102 @@ func (j SegmentMediaData) Value() (driver.Value, error) {
return json.Marshal(j)
}
+// ContentRights represents content rights and attribution information
+type ContentRights struct {
+ CopyrightNotice *string `json:"copyrightNotice,omitempty"`
+ CopyrightYear *int64 `json:"copyrightYear,omitempty"`
+ Creator *string `json:"creator,omitempty"`
+ CreditLine *string `json:"creditLine,omitempty"`
+ License *string `json:"license,omitempty"`
+}
+
+// Scan scan value into ContentRights, implements sql.Scanner interface
+func (c *ContentRights) Scan(value any) error {
+ if value == nil {
+ *c = ContentRights{}
+ return nil
+ }
+ bytes, ok := value.([]byte)
+ if !ok {
+ return errors.New(fmt.Sprint("Failed to unmarshal ContentRights value:", value))
+ }
+
+ result := ContentRights{}
+ err := json.Unmarshal(bytes, &result)
+ *c = ContentRights(result)
+ return err
+}
+
+// Value return json value, implement driver.Valuer interface
+func (c ContentRights) Value() (driver.Value, error) {
+ return json.Marshal(c)
+}
+
+// DistributionPolicy represents distribution policy information
+type DistributionPolicy struct {
+ DurationSeconds *int64 `json:"durationSeconds,omitempty"`
+}
+
+// Scan scan value into DistributionPolicy, implements sql.Scanner interface
+func (d *DistributionPolicy) Scan(value any) error {
+ if value == nil {
+ *d = DistributionPolicy{}
+ return nil
+ }
+ bytes, ok := value.([]byte)
+ if !ok {
+ return errors.New(fmt.Sprint("Failed to unmarshal DistributionPolicy value:", value))
+ }
+
+ result := DistributionPolicy{}
+ err := json.Unmarshal(bytes, &result)
+ *d = DistributionPolicy(result)
+ return err
+}
+
+// Value return json value, implement driver.Valuer interface
+func (d DistributionPolicy) Value() (driver.Value, error) {
+ return json.Marshal(d)
+}
+
+// ContentWarningsSlice is a custom type for storing content warnings as JSON in the database
+type ContentWarningsSlice []string
+
+// Scan scan value into ContentWarningsSlice, implements sql.Scanner interface
+func (c *ContentWarningsSlice) Scan(value any) error {
+ if value == nil {
+ *c = ContentWarningsSlice{}
+ return nil
+ }
+ bytes, ok := value.([]byte)
+ if !ok {
+ return errors.New(fmt.Sprint("Failed to unmarshal ContentWarningsSlice value:", value))
+ }
+
+ result := ContentWarningsSlice{}
+ err := json.Unmarshal(bytes, &result)
+ *c = ContentWarningsSlice(result)
+ return err
+}
+
+// Value return json value, implement driver.Valuer interface
+func (c ContentWarningsSlice) Value() (driver.Value, error) {
+ return json.Marshal(c)
+}
+
type Segment struct {
- ID string `json:"id" gorm:"primaryKey"`
- SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"`
- SigningKey *SigningKey `json:"signingKey,omitempty" gorm:"foreignKey:DID;references:SigningKeyDID"`
- StartTime time.Time `json:"startTime" gorm:"index:latest_segments"`
- RepoDID string `json:"repoDID" gorm:"index:latest_segments;column:repo_did"`
- Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
- Title string `json:"title"`
- Size int `json:"size" gorm:"column:size"`
- MediaData *SegmentMediaData `json:"mediaData,omitempty"`
+ ID string `json:"id" gorm:"primaryKey"`
+ SigningKeyDID string `json:"signingKeyDID" gorm:"column:signing_key_did"`
+ SigningKey *SigningKey `json:"signingKey,omitempty" gorm:"foreignKey:DID;references:SigningKeyDID"`
+ StartTime time.Time `json:"startTime" gorm:"index:latest_segments"`
+ RepoDID string `json:"repoDID" gorm:"index:latest_segments;column:repo_did"`
+ Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"`
+ Title string `json:"title"`
+ Size int `json:"size" gorm:"column:size"`
+ MediaData *SegmentMediaData `json:"mediaData,omitempty"`
+ ContentWarnings ContentWarningsSlice `json:"contentWarnings,omitempty"`
+ ContentRights *ContentRights `json:"contentRights,omitempty"`
+ DistributionPolicy *DistributionPolicy `json:"distributionPolicy,omitempty"`
}
func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) {
@@ -77,14 +163,44 @@ func (s *Segment) ToStreamplaceSegment() (*streamplace.Segment, error) {
}
duration := s.MediaData.Duration
sizei64 := int64(s.Size)
+
+ // Convert model metadata to streamplace metadata
+ var contentRights *streamplace.MetadataContentRights
+ if s.ContentRights != nil {
+ contentRights = &streamplace.MetadataContentRights{
+ CopyrightNotice: s.ContentRights.CopyrightNotice,
+ CopyrightYear: s.ContentRights.CopyrightYear,
+ Creator: s.ContentRights.Creator,
+ CreditLine: s.ContentRights.CreditLine,
+ License: s.ContentRights.License,
+ }
+ }
+
+ var contentWarnings *streamplace.MetadataContentWarnings
+ if len(s.ContentWarnings) > 0 {
+ contentWarnings = &streamplace.MetadataContentWarnings{
+ Warnings: []string(s.ContentWarnings),
+ }
+ }
+
+ var distributionPolicy *streamplace.MetadataDistributionPolicy
+ if s.DistributionPolicy != nil && s.DistributionPolicy.DurationSeconds != nil {
+ distributionPolicy = &streamplace.MetadataDistributionPolicy{
+ DeleteAfter: s.DistributionPolicy.DurationSeconds,
+ }
+ }
+
return &streamplace.Segment{
- LexiconTypeID: "place.stream.segment",
- Creator: s.RepoDID,
- Id: s.ID,
- SigningKey: s.SigningKeyDID,
- StartTime: string(aqt),
- Duration: &duration,
- Size: &sizei64,
+ LexiconTypeID: "place.stream.segment",
+ Creator: s.RepoDID,
+ Id: s.ID,
+ SigningKey: s.SigningKeyDID,
+ StartTime: string(aqt),
+ Duration: &duration,
+ Size: &sizei64,
+ ContentRights: contentRights,
+ ContentWarnings: contentWarnings,
+ DistributionPolicy: distributionPolicy,
Video: []*streamplace.Segment_Video{
{
Codec: "h264",
diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go
index df32ed41..970c71c0 100644
--- a/pkg/streamplace/cbor_gen.go
+++ b/pkg/streamplace/cbor_gen.go
@@ -672,12 +672,24 @@ func (t *Segment) MarshalCBOR(w io.Writer) error {
}
cw := cbg.NewCborWriter(w)
- fieldCount := 9
+ fieldCount := 12
if t.Audio == nil {
fieldCount--
}
+ if t.ContentRights == nil {
+ fieldCount--
+ }
+
+ if t.ContentWarnings == nil {
+ fieldCount--
+ }
+
+ if t.DistributionPolicy == nil {
+ fieldCount--
+ }
+
if t.Duration == nil {
fieldCount--
}
@@ -926,6 +938,63 @@ func (t *Segment) MarshalCBOR(w io.Writer) error {
if _, err := cw.WriteString(string(t.SigningKey)); err != nil {
return err
}
+
+ // t.ContentRights (streamplace.MetadataContentRights) (struct)
+ if t.ContentRights != nil {
+
+ if len("contentRights") > 1000000 {
+ return xerrors.Errorf("Value in field \"contentRights\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contentRights"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("contentRights")); err != nil {
+ return err
+ }
+
+ if err := t.ContentRights.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ }
+
+ // t.ContentWarnings (streamplace.MetadataContentWarnings) (struct)
+ if t.ContentWarnings != nil {
+
+ if len("contentWarnings") > 1000000 {
+ return xerrors.Errorf("Value in field \"contentWarnings\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contentWarnings"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("contentWarnings")); err != nil {
+ return err
+ }
+
+ if err := t.ContentWarnings.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ }
+
+ // t.DistributionPolicy (streamplace.MetadataDistributionPolicy) (struct)
+ if t.DistributionPolicy != nil {
+
+ if len("distributionPolicy") > 1000000 {
+ return xerrors.Errorf("Value in field \"distributionPolicy\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("distributionPolicy"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("distributionPolicy")); err != nil {
+ return err
+ }
+
+ if err := t.DistributionPolicy.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ }
return nil
}
@@ -954,7 +1023,7 @@ func (t *Segment) UnmarshalCBOR(r io.Reader) (err error) {
n := extra
- nameBuf := make([]byte, 10)
+ nameBuf := make([]byte, 18)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
@@ -1195,6 +1264,66 @@ func (t *Segment) UnmarshalCBOR(r io.Reader) (err error) {
t.SigningKey = string(sval)
}
+ // t.ContentRights (streamplace.MetadataContentRights) (struct)
+ case "contentRights":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.ContentRights = new(MetadataContentRights)
+ if err := t.ContentRights.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.ContentRights pointer: %w", err)
+ }
+ }
+
+ }
+ // t.ContentWarnings (streamplace.MetadataContentWarnings) (struct)
+ case "contentWarnings":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.ContentWarnings = new(MetadataContentWarnings)
+ if err := t.ContentWarnings.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.ContentWarnings pointer: %w", err)
+ }
+ }
+
+ }
+ // t.DistributionPolicy (streamplace.MetadataDistributionPolicy) (struct)
+ case "distributionPolicy":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.DistributionPolicy = new(MetadataDistributionPolicy)
+ if err := t.DistributionPolicy.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.DistributionPolicy pointer: %w", err)
+ }
+ }
+
+ }
default:
// Field doesn't exist on this type, so ignore it
@@ -3147,3 +3276,882 @@ func (t *ChatGate) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
+func (t *MetadataConfiguration) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+ fieldCount := 4
+
+ if t.ContentRights == nil {
+ fieldCount--
+ }
+
+ if t.ContentWarnings == nil {
+ fieldCount--
+ }
+
+ if t.DistributionPolicy == nil {
+ fieldCount--
+ }
+
+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); 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.metadata.configuration"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("place.stream.metadata.configuration")); err != nil {
+ return err
+ }
+
+ // t.ContentRights (streamplace.MetadataContentRights) (struct)
+ if t.ContentRights != nil {
+
+ if len("contentRights") > 1000000 {
+ return xerrors.Errorf("Value in field \"contentRights\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contentRights"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("contentRights")); err != nil {
+ return err
+ }
+
+ if err := t.ContentRights.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ }
+
+ // t.ContentWarnings (streamplace.MetadataContentWarnings) (struct)
+ if t.ContentWarnings != nil {
+
+ if len("contentWarnings") > 1000000 {
+ return xerrors.Errorf("Value in field \"contentWarnings\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contentWarnings"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("contentWarnings")); err != nil {
+ return err
+ }
+
+ if err := t.ContentWarnings.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ }
+
+ // t.DistributionPolicy (streamplace.MetadataDistributionPolicy) (struct)
+ if t.DistributionPolicy != nil {
+
+ if len("distributionPolicy") > 1000000 {
+ return xerrors.Errorf("Value in field \"distributionPolicy\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("distributionPolicy"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("distributionPolicy")); err != nil {
+ return err
+ }
+
+ if err := t.DistributionPolicy.MarshalCBOR(cw); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (t *MetadataConfiguration) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = MetadataConfiguration{}
+
+ 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("MetadataConfiguration: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 18)
+ 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.ContentRights (streamplace.MetadataContentRights) (struct)
+ case "contentRights":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.ContentRights = new(MetadataContentRights)
+ if err := t.ContentRights.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.ContentRights pointer: %w", err)
+ }
+ }
+
+ }
+ // t.ContentWarnings (streamplace.MetadataContentWarnings) (struct)
+ case "contentWarnings":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.ContentWarnings = new(MetadataContentWarnings)
+ if err := t.ContentWarnings.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.ContentWarnings pointer: %w", err)
+ }
+ }
+
+ }
+ // t.DistributionPolicy (streamplace.MetadataDistributionPolicy) (struct)
+ case "distributionPolicy":
+
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ t.DistributionPolicy = new(MetadataDistributionPolicy)
+ if err := t.DistributionPolicy.UnmarshalCBOR(cr); err != nil {
+ return xerrors.Errorf("unmarshaling t.DistributionPolicy 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 *MetadataDistributionPolicy) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+ fieldCount := 1
+
+ if t.DeleteAfter == nil {
+ fieldCount--
+ }
+
+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+ return err
+ }
+
+ // t.DeleteAfter (int64) (int64)
+ if t.DeleteAfter != nil {
+
+ if len("deleteAfter") > 1000000 {
+ return xerrors.Errorf("Value in field \"deleteAfter\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("deleteAfter"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("deleteAfter")); err != nil {
+ return err
+ }
+
+ if t.DeleteAfter == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if *t.DeleteAfter >= 0 {
+ if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.DeleteAfter)); err != nil {
+ return err
+ }
+ } else {
+ if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.DeleteAfter-1)); err != nil {
+ return err
+ }
+ }
+ }
+
+ }
+ return nil
+}
+
+func (t *MetadataDistributionPolicy) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = MetadataDistributionPolicy{}
+
+ 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("MetadataDistributionPolicy: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 11)
+ 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.DeleteAfter (int64) (int64)
+ case "deleteAfter":
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ maj, extra, err := cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ var extraI int64
+ switch maj {
+ case cbg.MajUnsignedInt:
+ extraI = int64(extra)
+ if extraI < 0 {
+ return fmt.Errorf("int64 positive overflow")
+ }
+ case cbg.MajNegativeInt:
+ extraI = int64(extra)
+ if extraI < 0 {
+ return fmt.Errorf("int64 negative overflow")
+ }
+ extraI = -1 - extraI
+ default:
+ return fmt.Errorf("wrong type for int64 field: %d", maj)
+ }
+
+ t.DeleteAfter = (*int64)(&extraI)
+ }
+ }
+
+ 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 *MetadataContentRights) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+ fieldCount := 5
+
+ if t.CopyrightNotice == nil {
+ fieldCount--
+ }
+
+ if t.CopyrightYear == nil {
+ fieldCount--
+ }
+
+ if t.Creator == nil {
+ fieldCount--
+ }
+
+ if t.CreditLine == nil {
+ fieldCount--
+ }
+
+ if t.License == nil {
+ fieldCount--
+ }
+
+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+ return err
+ }
+
+ // t.Creator (string) (string)
+ if t.Creator != nil {
+
+ if len("creator") > 1000000 {
+ return xerrors.Errorf("Value in field \"creator\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("creator"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("creator")); err != nil {
+ return err
+ }
+
+ if t.Creator == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if len(*t.Creator) > 1000000 {
+ return xerrors.Errorf("Value in field t.Creator was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Creator))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(*t.Creator)); err != nil {
+ return err
+ }
+ }
+ }
+
+ // t.License (string) (string)
+ if t.License != nil {
+
+ if len("license") > 1000000 {
+ return xerrors.Errorf("Value in field \"license\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("license"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("license")); err != nil {
+ return err
+ }
+
+ if t.License == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if len(*t.License) > 1000000 {
+ return xerrors.Errorf("Value in field t.License was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.License))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(*t.License)); err != nil {
+ return err
+ }
+ }
+ }
+
+ // t.CreditLine (string) (string)
+ if t.CreditLine != nil {
+
+ if len("creditLine") > 1000000 {
+ return xerrors.Errorf("Value in field \"creditLine\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("creditLine"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("creditLine")); err != nil {
+ return err
+ }
+
+ if t.CreditLine == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if len(*t.CreditLine) > 1000000 {
+ return xerrors.Errorf("Value in field t.CreditLine was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreditLine))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(*t.CreditLine)); err != nil {
+ return err
+ }
+ }
+ }
+
+ // t.CopyrightYear (int64) (int64)
+ if t.CopyrightYear != nil {
+
+ if len("copyrightYear") > 1000000 {
+ return xerrors.Errorf("Value in field \"copyrightYear\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("copyrightYear"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("copyrightYear")); err != nil {
+ return err
+ }
+
+ if t.CopyrightYear == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if *t.CopyrightYear >= 0 {
+ if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CopyrightYear)); err != nil {
+ return err
+ }
+ } else {
+ if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CopyrightYear-1)); err != nil {
+ return err
+ }
+ }
+ }
+
+ }
+
+ // t.CopyrightNotice (string) (string)
+ if t.CopyrightNotice != nil {
+
+ if len("copyrightNotice") > 1000000 {
+ return xerrors.Errorf("Value in field \"copyrightNotice\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("copyrightNotice"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("copyrightNotice")); err != nil {
+ return err
+ }
+
+ if t.CopyrightNotice == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if len(*t.CopyrightNotice) > 1000000 {
+ return xerrors.Errorf("Value in field t.CopyrightNotice was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CopyrightNotice))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(*t.CopyrightNotice)); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func (t *MetadataContentRights) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = MetadataContentRights{}
+
+ 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("MetadataContentRights: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 15)
+ 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.Creator (string) (string)
+ case "creator":
+
+ {
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Creator = (*string)(&sval)
+ }
+ }
+ // t.License (string) (string)
+ case "license":
+
+ {
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.License = (*string)(&sval)
+ }
+ }
+ // t.CreditLine (string) (string)
+ case "creditLine":
+
+ {
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CreditLine = (*string)(&sval)
+ }
+ }
+ // t.CopyrightYear (int64) (int64)
+ case "copyrightYear":
+ {
+
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+ maj, extra, err := cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ var extraI int64
+ switch maj {
+ case cbg.MajUnsignedInt:
+ extraI = int64(extra)
+ if extraI < 0 {
+ return fmt.Errorf("int64 positive overflow")
+ }
+ case cbg.MajNegativeInt:
+ extraI = int64(extra)
+ if extraI < 0 {
+ return fmt.Errorf("int64 negative overflow")
+ }
+ extraI = -1 - extraI
+ default:
+ return fmt.Errorf("wrong type for int64 field: %d", maj)
+ }
+
+ t.CopyrightYear = (*int64)(&extraI)
+ }
+ }
+ // t.CopyrightNotice (string) (string)
+ case "copyrightNotice":
+
+ {
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.CopyrightNotice = (*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 *MetadataContentWarnings) MarshalCBOR(w io.Writer) error {
+ if t == nil {
+ _, err := w.Write(cbg.CborNull)
+ return err
+ }
+
+ cw := cbg.NewCborWriter(w)
+ fieldCount := 1
+
+ if t.Warnings == nil {
+ fieldCount--
+ }
+
+ if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+ return err
+ }
+
+ // t.Warnings ([]string) (slice)
+ if t.Warnings != nil {
+
+ if len("warnings") > 1000000 {
+ return xerrors.Errorf("Value in field \"warnings\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("warnings"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("warnings")); err != nil {
+ return err
+ }
+
+ if len(t.Warnings) > 8192 {
+ return xerrors.Errorf("Slice value in field t.Warnings was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Warnings))); err != nil {
+ return err
+ }
+ for _, v := range t.Warnings {
+ if len(v) > 1000000 {
+ return xerrors.Errorf("Value in field v was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(v)); err != nil {
+ return err
+ }
+
+ }
+ }
+ return nil
+}
+
+func (t *MetadataContentWarnings) UnmarshalCBOR(r io.Reader) (err error) {
+ *t = MetadataContentWarnings{}
+
+ 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("MetadataContentWarnings: map struct too large (%d)", extra)
+ }
+
+ n := extra
+
+ nameBuf := make([]byte, 8)
+ 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.Warnings ([]string) (slice)
+ case "warnings":
+
+ maj, extra, err = cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+
+ if extra > 8192 {
+ return fmt.Errorf("t.Warnings: array too large (%d)", extra)
+ }
+
+ if maj != cbg.MajArray {
+ return fmt.Errorf("expected cbor array")
+ }
+
+ if extra > 0 {
+ t.Warnings = make([]string, extra)
+ }
+
+ for i := 0; i < int(extra); i++ {
+ {
+ var maj byte
+ var extra uint64
+ var err error
+ _ = maj
+ _ = extra
+ _ = err
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Warnings[i] = 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/metadataconfiguration.go b/pkg/streamplace/metadataconfiguration.go
new file mode 100644
index 00000000..e6a5d1f0
--- /dev/null
+++ b/pkg/streamplace/metadataconfiguration.go
@@ -0,0 +1,20 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+package streamplace
+
+// schema: place.stream.metadata.configuration
+
+import (
+ "github.com/bluesky-social/indigo/lex/util"
+)
+
+func init() {
+ util.RegisterType("place.stream.metadata.configuration", &MetadataConfiguration{})
+} //
+// RECORDTYPE: MetadataConfiguration
+type MetadataConfiguration struct {
+ LexiconTypeID string `json:"$type,const=place.stream.metadata.configuration" cborgen:"$type,const=place.stream.metadata.configuration"`
+ ContentRights *MetadataContentRights `json:"contentRights,omitempty" cborgen:"contentRights,omitempty"`
+ ContentWarnings *MetadataContentWarnings `json:"contentWarnings,omitempty" cborgen:"contentWarnings,omitempty"`
+ DistributionPolicy *MetadataDistributionPolicy `json:"distributionPolicy,omitempty" cborgen:"distributionPolicy,omitempty"`
+}
diff --git a/pkg/streamplace/metadatacontentRights.go b/pkg/streamplace/metadatacontentRights.go
new file mode 100644
index 00000000..1017c391
--- /dev/null
+++ b/pkg/streamplace/metadatacontentRights.go
@@ -0,0 +1,21 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+package streamplace
+
+// schema: place.stream.metadata.contentRights
+
+// MetadataContentRights is a "main" in the place.stream.metadata.contentRights schema.
+//
+// Content rights and attribution information.
+type MetadataContentRights struct {
+ // copyrightNotice: Copyright notice for the work.
+ CopyrightNotice *string `json:"copyrightNotice,omitempty" cborgen:"copyrightNotice,omitempty"`
+ // copyrightYear: Year of creation or publication.
+ CopyrightYear *int64 `json:"copyrightYear,omitempty" cborgen:"copyrightYear,omitempty"`
+ // creator: Name of the creator of the work.
+ Creator *string `json:"creator,omitempty" cborgen:"creator,omitempty"`
+ // creditLine: Credit line for the work.
+ CreditLine *string `json:"creditLine,omitempty" cborgen:"creditLine,omitempty"`
+ // license: License URL or identifier.
+ License *string `json:"license,omitempty" cborgen:"license,omitempty"`
+}
diff --git a/pkg/streamplace/metadatacontentWarnings.go b/pkg/streamplace/metadatacontentWarnings.go
new file mode 100644
index 00000000..fddd33ec
--- /dev/null
+++ b/pkg/streamplace/metadatacontentWarnings.go
@@ -0,0 +1,12 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+package streamplace
+
+// schema: place.stream.metadata.contentWarnings
+
+// MetadataContentWarnings is a "main" in the place.stream.metadata.contentWarnings schema.
+//
+// Content warnings for a stream.
+type MetadataContentWarnings struct {
+ Warnings []string `json:"warnings,omitempty" cborgen:"warnings,omitempty"`
+}
diff --git a/pkg/streamplace/metadatadistributionPolicy.go b/pkg/streamplace/metadatadistributionPolicy.go
new file mode 100644
index 00000000..94e75e6b
--- /dev/null
+++ b/pkg/streamplace/metadatadistributionPolicy.go
@@ -0,0 +1,13 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+package streamplace
+
+// schema: place.stream.metadata.distributionPolicy
+
+// MetadataDistributionPolicy is a "main" in the place.stream.metadata.distributionPolicy schema.
+//
+// Distribution and rebroadcast policy.
+type MetadataDistributionPolicy struct {
+ // deleteAfter: Duration in seconds after which segments should be deleted. Each segment will expire N seconds after its creation time.
+ DeleteAfter *int64 `json:"deleteAfter,omitempty" cborgen:"deleteAfter,omitempty"`
+}
diff --git a/pkg/streamplace/streamsegment.go b/pkg/streamplace/streamsegment.go
index f3818461..a18fcef5 100644
--- a/pkg/streamplace/streamsegment.go
+++ b/pkg/streamplace/streamsegment.go
@@ -13,9 +13,12 @@ func init() {
} //
// RECORDTYPE: Segment
type Segment struct {
- LexiconTypeID string `json:"$type,const=place.stream.segment" cborgen:"$type,const=place.stream.segment"`
- Audio []*Segment_Audio `json:"audio,omitempty" cborgen:"audio,omitempty"`
- Creator string `json:"creator" cborgen:"creator"`
+ LexiconTypeID string `json:"$type,const=place.stream.segment" cborgen:"$type,const=place.stream.segment"`
+ Audio []*Segment_Audio `json:"audio,omitempty" cborgen:"audio,omitempty"`
+ ContentRights *MetadataContentRights `json:"contentRights,omitempty" cborgen:"contentRights,omitempty"`
+ ContentWarnings *MetadataContentWarnings `json:"contentWarnings,omitempty" cborgen:"contentWarnings,omitempty"`
+ Creator string `json:"creator" cborgen:"creator"`
+ DistributionPolicy *MetadataDistributionPolicy `json:"distributionPolicy,omitempty" cborgen:"distributionPolicy,omitempty"`
// duration: The duration of the segment in nanoseconds
Duration *int64 `json:"duration,omitempty" cborgen:"duration,omitempty"`
// id: Unique identifier for the segment