diff --git a/.env b/.env
index 9656ef77e0692dd7ab24776b7abffc556d0689cd..6539cf6505dc3ca4fe7a4dfdb4d598766fe0434b 100644
--- a/.env
+++ b/.env
@@ -2,6 +2,11 @@
ADMIN_HUGGING_FACE_API_TOKEN=""
ADMIN_HUGGING_FACE_USERNAME=""
+AI_TUBE_ROBOT_API="https://jbilcke-hf-ai-tube-robot.hf.space"
+
+VIDEOCHAIN_API_URL=""
+VIDEOCHAIN_API_TOKEN=""
+
# ----------- CENSORSHIP -------
ENABLE_CENSORSHIP=
FINGERPRINT_KEY=
diff --git a/declarations.d.ts b/declarations.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..87a5938cc917fe6813f778b8a64d22efb18760f9
--- /dev/null
+++ b/declarations.d.ts
@@ -0,0 +1 @@
+declare module 'markdown-yaml-metadata-parser';
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index ca150a8942c27efe99e47b47498b5789e4bb567a..9010f0a3472e8d0a61a84f26d53d54dc28d7d174 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -40,10 +40,10 @@
"eslint-config-next": "13.4.10",
"hash-wasm": "^4.11.0",
"lucide-react": "^0.260.0",
+ "markdown-yaml-metadata-parser": "^3.0.0",
"next": "^14.0.3",
"pick": "^0.0.1",
"postcss": "8.4.26",
- "pythonia": "^1.0.4",
"qs": "^6.11.2",
"react": "18.2.0",
"react-circular-progressbar": "^2.1.0",
@@ -64,7 +64,7 @@
"type-fest": "^4.8.2",
"typescript": "5.1.6",
"usehooks-ts": "^2.9.1",
- "uuid": "^9.0.0",
+ "uuid": "^9.0.1",
"zustand": "^4.4.1"
},
"devDependencies": {
@@ -95,9 +95,9 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.23.4",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz",
- "integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==",
+ "version": "7.23.5",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz",
+ "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -2361,11 +2361,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/caller": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/caller/-/caller-1.1.0.tgz",
- "integrity": "sha512-n+21IZC3j06YpCWaxmUy5AnVqhmCIM2bQtqQyy00HJlmStRt6kwDX5F9Z97pqwAB+G/tgSz6q/kUBbNyQzIubw=="
- },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3011,6 +3006,14 @@
"node": ">=8"
}
},
+ "node_modules/detect-newline": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
+ "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -3108,9 +3111,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.4.595",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.595.tgz",
- "integrity": "sha512-+ozvXuamBhDOKvMNUQvecxfbyICmIAwS4GpLmR0bsiSBlGnLaOcs2Cj7J8XSbW+YEaN3Xl3ffgpm+srTUWFwFQ=="
+ "version": "1.4.596",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.596.tgz",
+ "integrity": "sha512-zW3zbZ40Icb2BCWjm47nxwcFGYlIgdXkAx85XDO7cyky9J4QQfq8t0W19/TLZqq3JPQXtlv8BPIGmfa9Jb4scg=="
},
"node_modules/emoji-regex": {
"version": "9.2.2",
@@ -3624,6 +3627,18 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/esquery": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
@@ -4717,6 +4732,38 @@
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="
},
+ "node_modules/markdown-yaml-metadata-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/markdown-yaml-metadata-parser/-/markdown-yaml-metadata-parser-3.0.0.tgz",
+ "integrity": "sha512-gRxEfuGIpb9pS1nQyASx3+l99e1hyTaK/+zDuvGcZJvr+OlksZ5O+q7opPcQP25j/z7NoOYEp17Lxgq5Sn4vDg==",
+ "dependencies": {
+ "detect-newline": "^3.1.0",
+ "js-yaml": "^3.14.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/markdown-yaml-metadata-parser/node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/markdown-yaml-metadata-parser/node_modules/js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5409,18 +5456,6 @@
"node": ">=6"
}
},
- "node_modules/pythonia": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/pythonia/-/pythonia-1.0.4.tgz",
- "integrity": "sha512-YciqyN0ii93gmJ1S9GmB873tZPtk6TeF/35DWLHrTn+PxnHCPtaXyvjPucK8gLNgt7XSqawmNxdp6JNFjWQL4g==",
- "dependencies": {
- "caller": "^1.0.1",
- "chalk": "^4.1.2"
- },
- "peerDependencies": {
- "ws": "^7.5.1"
- }
- },
"node_modules/qs": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
@@ -6027,6 +6062,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
+ },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -6849,27 +6889,6 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
- "node_modules/ws": {
- "version": "7.5.9",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
- "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
- "peer": true,
- "engines": {
- "node": ">=8.3.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": "^5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
diff --git a/package.json b/package.json
index 5ebf689735fbe9c9b5919a0953820095bfea49f6..a8e5ea41898e2afcf3d6900968603a67e50ad189 100644
--- a/package.json
+++ b/package.json
@@ -41,10 +41,10 @@
"eslint-config-next": "13.4.10",
"hash-wasm": "^4.11.0",
"lucide-react": "^0.260.0",
+ "markdown-yaml-metadata-parser": "^3.0.0",
"next": "^14.0.3",
"pick": "^0.0.1",
"postcss": "8.4.26",
- "pythonia": "^1.0.4",
"qs": "^6.11.2",
"react": "18.2.0",
"react-circular-progressbar": "^2.1.0",
@@ -65,7 +65,7 @@
"type-fest": "^4.8.2",
"typescript": "5.1.6",
"usehooks-ts": "^2.9.1",
- "uuid": "^9.0.0",
+ "uuid": "^9.0.1",
"zustand": "^4.4.1"
},
"devDependencies": {
diff --git a/src/app/interface/channel-card/index.tsx b/src/app/interface/channel-card/index.tsx
index 698a4803af0debc69d5b25d52e7ef19c8725a69f..c8425434d0aea828f4d20a4ef1844835ee29e2c8 100644
--- a/src/app/interface/channel-card/index.tsx
+++ b/src/app/interface/channel-card/index.tsx
@@ -3,9 +3,11 @@ import { ChannelInfo } from "@/types"
export function ChannelCard({
channel,
+ onClick,
className = "",
}: {
channel: ChannelInfo
+ onClick?: (channel: ChannelInfo) => void
className?: string
}) {
@@ -13,10 +15,19 @@ export function ChannelCard({
+ )}
+ onClick={() => {
+ if (onClick) {
+ onClick(channel)
+ }
+ }}
+ >
-
{channel.label}
+
{channel.likes} likes
)
diff --git a/src/app/interface/channel-list/index.tsx b/src/app/interface/channel-list/index.tsx
index d95e945c3bc874881bb656f20bd9d38c6b01db49..6344a679bbd7527e1dbc5654102a958bbff5cd04 100644
--- a/src/app/interface/channel-list/index.tsx
+++ b/src/app/interface/channel-list/index.tsx
@@ -5,11 +5,14 @@ import { ChannelCard } from "../channel-card"
export function ChannelList({
channels,
+ onSelect,
layout = "flex",
className = "",
}: {
channels: ChannelInfo[]
+ onSelect?: (channel: ChannelInfo) => void
+
/**
* Layout mode
*
@@ -35,6 +38,7 @@ export function ChannelList({
))}
diff --git a/src/app/interface/left-menu/index.tsx b/src/app/interface/left-menu/index.tsx
index bf1da3193eb1c5648c5bdad9fd9cdc31310bd934..f12ddad440824278a7aacb2a8a3d40c6d606ffc6 100644
--- a/src/app/interface/left-menu/index.tsx
+++ b/src/app/interface/left-menu/index.tsx
@@ -1,6 +1,8 @@
import { GrChannel } from "react-icons/gr"
import { MdVideoLibrary } from "react-icons/md"
import { RiHome8Line } from "react-icons/ri"
+import { PiRobot } from "react-icons/pi"
+import { CgProfile } from "react-icons/cg"
import { useStore } from "@/app/state/useStore"
import { cn } from "@/lib/utils"
@@ -11,35 +13,54 @@ export function LeftMenu() {
const setView = useStore(s => s.setView)
return (
-
}
- selected={view === "home"}
- onClick={() => setView("home")}
- >
- Home
-
-
}
- selected={view === "channels_public"}
- onClick={() => setView("channels_public")}
- >
- Channels
-
-
}
- selected={
- view === "channels_admin" ||
- view === "channel_admin"
- }
- onClick={() => setView("channels_admin")}
- >
- My Content
-
+
+ }
+ selected={view === "home"}
+ onClick={() => setView("home")}
+ >
+ Discover
+
+ }
+ selected={view === "public_channels"}
+ onClick={() => setView("public_channels")}
+ >
+ Channels
+
+
+
+ {/*}
+ selected={view === "user_videos"}
+ onClick={() => setView("user_videos")}
+ >
+ My Videos
+
+ */}
+ }
+ selected={view === "user_channels"}
+ onClick={() => setView("user_channels")}
+ >
+ My Robots
+
+ }
+ selected={view === "user_account"}
+ onClick={() => setView("user_account")}
+ >
+ Account
+
+
)
}
\ No newline at end of file
diff --git a/src/app/interface/top-menu/index.tsx b/src/app/interface/top-menu/index.tsx
index 7dd5fa8c2173888a9fe851e6bc2688bd4ed74243..50222f58860935ed60131cb101f978710b26a9ee 100644
--- a/src/app/interface/top-menu/index.tsx
+++ b/src/app/interface/top-menu/index.tsx
@@ -1,4 +1,4 @@
-import { VideoCategory, videoCategoriesWithLabels } from "@/app/state/categories"
+import { videoCategoriesWithLabels } from "@/app/state/categories"
import { useStore } from "@/app/state/useStore"
import { cn } from "@/lib/utils"
@@ -7,8 +7,8 @@ export function TopMenu() {
const setDisplayMode = useStore(s => s.setDisplayMode)
const currentChannel = useStore(s => s.currentChannel)
const setCurrentChannel = useStore(s => s.setCurrentChannel)
- const currentCategory = useStore(s => s.currentCategory)
- const setCurrentCategory = useStore(s => s.setCurrentCategory)
+ const currentTag = useStore(s => s.currentTag)
+ const setCurrentTag = useStore(s => s.setCurrentTag)
const currentVideos = useStore(s => s.currentVideos)
const currentVideo = useStore(s => s.currentVideo)
const setCurrentVideo = useStore(s => s.setCurrentVideo)
@@ -17,26 +17,26 @@ export function TopMenu() {
-
-
🍿
-
HugTube
+
+ 🍿
+ AI Tube
- Search bar goes here
+ [ Search bar goes here ]
{/* unused for now */}
@@ -55,13 +55,13 @@ export function TopMenu() {
`rounded-lg px-3 py-1 h-8`,
`cursor-pointer`,
`transition-all duration-300 ease-in-out`,
- currentCategory === key
+ currentTag === key
? `bg-neutral-100 text-neutral-800`
: `bg-neutral-800 text-neutral-50/90 hover:bg-neutral-700 hover:text-neutral-50/90`,
// `text-clip`
)}
onClick={() => {
- setCurrentCategory(key as VideoCategory)
+ setCurrentTag(key)
}}
>
s.view)
@@ -22,15 +23,21 @@ export function Main() {
- {view === "home" &&
}
- {view === "channels_admin" &&
}
- {view === "channels_public" &&
}
- {view === "channel_public" &&
}
- {view === "channel_admin" &&
}
- {view === "video_public" &&
}
+
+ {view === "home" &&
}
+ {view === "public_video" &&
}
+ {view === "public_channels" &&
}
+ {view === "public_channel" &&
}
+ {view === "user_channels" &&
}
+ {/*view === "user_videos" &&
*/}
+ {view === "user_channel" &&
}
+ {view === "user_account" &&
}
+
+
)
diff --git a/src/app/server/actions/ai-tube-hf/README.md b/src/app/server/actions/ai-tube-hf/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..31a77129c51d8622f38339dcdb0376d05b01ff5d
--- /dev/null
+++ b/src/app/server/actions/ai-tube-hf/README.md
@@ -0,0 +1,3 @@
+# server/actions/ai-tube-hf
+
+Utility functions to manipulate channels hosted as Hugging Face Datasets
\ No newline at end of file
diff --git a/src/app/server/actions/ai-tube-hf/getChannels.ts b/src/app/server/actions/ai-tube-hf/getChannels.ts
new file mode 100644
index 0000000000000000000000000000000000000000..51bfb72531d4cd0becfd713aa74fc00111193223
--- /dev/null
+++ b/src/app/server/actions/ai-tube-hf/getChannels.ts
@@ -0,0 +1,125 @@
+"use server"
+
+import { Credentials, downloadFile, listDatasets, whoAmI } from "@/huggingface/hub/src"
+import { parseDatasetReadme } from "@/app/server/actions/utils/parseDatasetReadme"
+import { ChannelInfo } from "@/types"
+
+import { adminCredentials } from "../config"
+
+export async function getChannels(options: {
+ apiKey?: string
+ owner?: string
+} = {}): Promise
{
+
+ let credentials: Credentials = adminCredentials
+ let owner = options?.owner
+
+ if (options?.apiKey) {
+ try {
+ credentials = { accessToken: options.apiKey }
+ const { name: username } = await whoAmI({ credentials })
+ if (!username) {
+ throw new Error(`couldn't get the username`)
+ }
+ // everything is in order,
+ owner = username
+ } catch (err) {
+ console.error(err)
+ return []
+ }
+ }
+
+ let channels: ChannelInfo[] = []
+
+ const prefix = "ai-tube-"
+
+ let search = owner
+ ? { owner } // search channels of a specific user
+ : prefix // global search (note: might be costly?)
+
+ // console.log("search:", search)
+
+ for await (const { id, name, likes, updatedAt } of listDatasets({
+ search,
+ credentials
+ })) {
+
+ // TODO: need to handle better cases where the username is missing
+
+ const chunks = name.split("/")
+ const [datasetUser, datasetName] = chunks.length === 2
+ ? chunks
+ : [name, name]
+
+ // console.log(`found a candidate dataset "${datasetName}" owned by @${datasetUser}`)
+
+ if (!datasetName.startsWith(prefix)) {
+ continue
+ }
+
+ // ignore the video index
+ if (datasetName === "ai-tube-index") {
+ continue
+ }
+
+ const slug = datasetName.replaceAll(prefix, "")
+
+ // console.log(`found an AI Tube channel: "${slug}"`)
+
+ // TODO parse the README to get the proper label
+ let label = slug.replaceAll("-", " ")
+
+ const thumbnail = ""
+ let prompt = ""
+ let description = ""
+ let tags: string[] = []
+
+ // console.log(`going to read datasets/${name}`)
+ try {
+ const response = await downloadFile({
+ repo: `datasets/${name}`,
+ path: "README.md",
+ credentials
+ })
+ const readme = await response?.text()
+
+ const ParsedDatasetReadme = parseDatasetReadme(readme)
+
+ // console.log("ParsedDatasetReadme: ", ParsedDatasetReadme)
+
+
+ prompt = ParsedDatasetReadme.prompt
+ label = ParsedDatasetReadme.pretty_name
+ description = ParsedDatasetReadme.description
+
+ const prefix = "ai-tube:"
+
+ tags = ParsedDatasetReadme.tags
+ .filter(tag => tag.startsWith(prefix)) // remove any tag not belonging to us
+ .map(tag => tag.replaceAll(prefix, "").trim()) // remove the prefix
+ .filter(tag => tag) // remove empty tags
+
+
+ } catch (err) {
+ console.log("failed to read the readme:", err)
+ }
+
+ const channel: ChannelInfo = {
+ id,
+ datasetUser,
+ datasetName,
+ slug,
+ label,
+ description,
+ thumbnail,
+ prompt,
+ likes,
+ tags,
+ updatedAt: updatedAt.toISOString()
+ }
+
+ channels.push(channel)
+ }
+
+ return channels
+}
diff --git a/src/app/server/actions/ai-tube-hf/getIndex.ts b/src/app/server/actions/ai-tube-hf/getIndex.ts
new file mode 100644
index 0000000000000000000000000000000000000000..26f45ddb7b294b80ccb65f15bb2259d95f1b5287
--- /dev/null
+++ b/src/app/server/actions/ai-tube-hf/getIndex.ts
@@ -0,0 +1,48 @@
+import { downloadFile } from "@/huggingface/hub/src"
+import { VideoInfo, VideoStatus } from "@/types"
+
+import { adminCredentials, adminUsername } from "../config"
+
+export async function getIndex({
+ status,
+ renewCache,
+}: {
+ status: VideoStatus
+
+ /**
+ * Renew the cache
+ *
+ * This is was the batch job daemon will use, as in normal time
+ * we will want to use the cache since the file might be large
+ *
+ * it is also possible that we decide to *never* renew the cache from a user's perspective,
+ * and only renew it manually when a video changes status
+ *
+ * that way user requests will always be snappy!
+ */
+ renewCache?: boolean
+}): Promise> {
+
+ // grab the current video index
+ const response = await downloadFile({
+ credentials: adminCredentials,
+ repo: `datasets/${adminUsername}/ai-tube-index`,
+ path: `${status}.json`,
+
+ })
+
+ // attention, this list might grow, especially the "published" one
+ // published videos should be put in a big dataset folder of files
+ // named ".json" and ".mp4" like in VideoChain
+ const jsonResponse = await response?.json()
+ if (
+ typeof jsonResponse === "undefined" &&
+ typeof jsonResponse !== "object" &&
+ Array.isArray(jsonResponse) ||
+ jsonResponse === null) {
+ throw new Error("index is not an object, admin repair needed")
+ }
+ const videos = jsonResponse as Record
+
+ return videos
+}
diff --git a/src/app/server/actions/ai-tube-hf/getVideoRequestsFromChannel.ts b/src/app/server/actions/ai-tube-hf/getVideoRequestsFromChannel.ts
new file mode 100644
index 0000000000000000000000000000000000000000..732baba58e36f0a61dcbd5a685a76f809a5b91b5
--- /dev/null
+++ b/src/app/server/actions/ai-tube-hf/getVideoRequestsFromChannel.ts
@@ -0,0 +1,112 @@
+"use server"
+
+import { Credentials, downloadFile, listFiles, whoAmI } from "@/huggingface/hub/src"
+import { parseDatasetReadme } from "@/app/server/actions/utils/parseDatasetReadme"
+import { ChannelInfo, VideoRequest } from "@/types"
+
+import { adminCredentials } from "../config"
+
+/**
+ * Return all the videos requests created by a user on their channel
+ *
+ * @param options
+ * @returns
+ */
+export async function getVideoRequestsFromChannel(options: {
+ channel: ChannelInfo,
+ apiKey?: string,
+ renewCache?: boolean
+}): Promise> {
+
+ let credentials: Credentials = adminCredentials
+
+ if (options?.apiKey) {
+ try {
+ credentials = { accessToken: options.apiKey }
+ const { name: username } = await whoAmI({ credentials })
+ if (!username) {
+ throw new Error(`couldn't get the username`)
+ }
+ } catch (err) {
+ console.error(err)
+ return {}
+ }
+ }
+
+ let videos: Record = {}
+
+ const repo = `datasets/${options.channel.datasetUser}/${options.channel.datasetName}`
+
+ console.log(`scanning ${repo}`)
+
+ for await (const file of listFiles({
+ repo,
+ // recursive: true,
+ // expand: true,
+ credentials,
+ requestInit: {
+ // cache invalidation should be called right after adding a new video
+ cache: options?.renewCache ? "no-cache" : "default",
+ next: {
+ revalidate: 10, // otherwise we only update very 10 seconds by default
+ // tags: [] // tags used for cache invalidation (ie. this is added to the cache key)
+ }
+ }
+ })) {
+
+ // TODO we should add some safety mechanisms here:
+ // skip lists of files that are too long
+ // skip files that are too big
+ // skip files with file.security.safe !== true
+
+ console.log("file.path:", file.path)
+ /// { type, oid, size, path }
+ if (file.path === "README.md") {
+ console.log("found the README")
+ // TODO: read this readme
+ } else if (file.path.startsWith("prompt_") && file.path.endsWith(".txt")) {
+ console.log("yes!!")
+ const fileWithoutSuffix = file.path.split(".txt").shift() || ""
+ const words = fileWithoutSuffix.split("_")
+ console.log("debug:", { path: file.path, fileWithoutSuffix, words })
+ if (words.length !== 3) {
+ console.log("found an invalid prompt file format: " + file.path)
+ continue
+ }
+ const [_prefix, date, id] = words
+ console.log("found a prompt:", file.path)
+
+ try {
+ const response = await downloadFile({
+ repo,
+ path: file.path,
+ credentials
+ })
+ const rawMarkdown = await response?.text()
+
+ const parsedDatasetReadme = parseDatasetReadme(rawMarkdown)
+ console.log("prompt parsed markdown:", parsedDatasetReadme)
+ } catch (err) {
+ console.log("failed to parse the prompt file")
+ continue
+ }
+ const video: VideoRequest = {
+ id,
+ label: "",
+ description: "",
+ prompt: "",
+ thumbnailUrl: "",
+
+ updatedAt: file.lastCommit?.date || "",
+ tags: [], // read them from the file?
+ channel: options.channel
+ }
+
+ videos[id] = video
+ } else if (file.path.endsWith(".mp4")) {
+ console.log("found a video:", file.path)
+ }
+ }
+
+ return videos
+}
diff --git a/src/app/server/actions/ai-tube-hf/uploadVideoRequestToDataset.ts b/src/app/server/actions/ai-tube-hf/uploadVideoRequestToDataset.ts
new file mode 100644
index 0000000000000000000000000000000000000000..86b0fa992876ddfe23f55601239b3b9a441cea96
--- /dev/null
+++ b/src/app/server/actions/ai-tube-hf/uploadVideoRequestToDataset.ts
@@ -0,0 +1,114 @@
+"use server"
+
+import { Blob } from "buffer"
+import { v4 as uuidv4 } from "uuid"
+
+import { Credentials, uploadFile, whoAmI } from "@/huggingface/hub/src"
+import { ChannelInfo, VideoInfo, VideoRequest } from "@/types"
+
+/**
+ * Save the video request to the user's own dataset
+ *
+ * @param param0
+ * @returns
+ */
+export async function uploadVideoRequestToDataset({
+ channel,
+ apiKey,
+ title,
+ description,
+ prompt,
+ tags,
+}: {
+ channel: ChannelInfo
+ apiKey: string
+ title: string
+ description: string
+ prompt: string
+ tags: string[]
+}): Promise<{
+ videoRequest: VideoRequest
+ videoInfo: VideoInfo
+}> {
+ if (!apiKey) {
+ throw new Error(`the apiKey is required`)
+ }
+
+ let credentials: Credentials = { accessToken: apiKey }
+
+ const { name: username } = await whoAmI({ credentials })
+ if (!username) {
+ throw new Error(`couldn't get the username`)
+ }
+
+ const date = new Date()
+ const dateSlug = date.toISOString().replace(/[^0-9]/gi, '').slice(0, 12);
+
+ // there is a bug in the [^] maybe, because all characters are removed
+ // const nameSlug = title.replaceAll(/\S+/gi, "-").replaceAll(/[^A-Za-z0-9\-_]/gi, "")
+ // const fileName = `prompt-${dateSlug}-${nameSlug}.txt`
+
+ const videoId = uuidv4()
+
+ const fileName = `prompt_${dateSlug}_${videoId}.txt`
+
+ // Convert string to a Buffer
+ const blob = new Blob([`
+# Title
+${title}
+
+# Description
+${description}
+
+# Tags
+
+${tags.map(tag => `- ${tag}\n`)}
+
+# Prompt
+${prompt}
+`]);
+
+
+ await uploadFile({
+ credentials,
+ repo: `datasets/${channel.datasetUser}/${channel.datasetName}`,
+ file: {
+ path: fileName,
+ content: blob as any,
+ },
+ commitTitle: "Add new video prompt",
+ })
+
+ // TODO: now we ping the robot to come read our prompt
+
+ const newVideoRequest: VideoRequest = {
+ id: videoId,
+ label: title,
+ description,
+ prompt,
+ thumbnailUrl: "",
+ updatedAt: new Date().toISOString(),
+ tags: [...channel.tags],
+ channel,
+ }
+
+ const newVideo: VideoInfo = {
+ id: videoId,
+ status: "submitted",
+ label: title,
+ description,,
+ prompt,
+ thumbnailUrl: "", // will be generated in async
+ assetUrl: "", // will be generated in async
+ numberOfViews: 0,
+ numberOfLikes: 0,
+ updatedAt: new Date().toISOString(),
+ tags: [...channel.tags],
+ channel,
+ }
+
+ return {
+ videoRequest: newVideoRequest,
+ videoInfo: newVideo
+ }
+}
diff --git a/src/app/server/actions/ai-tube-robot/README.md b/src/app/server/actions/ai-tube-robot/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..6e04c7833ab0272a9c77a9a0ad361cf3ecba3a8a
--- /dev/null
+++ b/src/app/server/actions/ai-tube-robot/README.md
@@ -0,0 +1,3 @@
+# server/actions/ai-tube-robot
+
+API client for the AI Tube Robot
diff --git a/src/app/server/actions/ai-tube-robot/updateQueue.ts b/src/app/server/actions/ai-tube-robot/updateQueue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c860f89c0398f56b87d41f1e1db25b5064117c36
--- /dev/null
+++ b/src/app/server/actions/ai-tube-robot/updateQueue.ts
@@ -0,0 +1,42 @@
+"use server"
+
+import { ChannelInfo, UpdateQueueResponse } from "@/types"
+
+import { aiTubeRobotApi } from "../config"
+
+export async function updateQueue({
+ channel,
+ apiKey,
+}: {
+ channel?: ChannelInfo
+ apiKey: string
+}): Promise {
+ if (!apiKey) {
+ throw new Error(`the apiKey is required`)
+ }
+
+ const res = await fetch(`${aiTubeRobotApi}/update-queue`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ // Authorization: `Bearer ${apiToken}`,
+ },
+ body: JSON.stringify({
+ apiKey,
+ channel
+ }),
+ cache: 'no-store',
+ // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache)
+ // next: { revalidate: 1 }
+ })
+
+ if (res.status !== 200) {
+ // This will activate the closest `error.js` Error Boundary
+ throw new Error('Failed to fetch data')
+ }
+
+ const response = (await res.json()) as UpdateQueueResponse
+ // console.log("response:", response)
+ return response.nbUpdated
+}
\ No newline at end of file
diff --git a/src/app/server/actions/api.ts b/src/app/server/actions/api.ts
deleted file mode 100644
index c93b74ea158e1d2f30a691fb87cef39208d269e4..0000000000000000000000000000000000000000
--- a/src/app/server/actions/api.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-"use server"
-
-import { Credentials, listDatasets, whoAmI } from "@/huggingface/hub/src"
-import { ChannelInfo } from "@/types"
-
-const adminApiKey = `${process.env.ADMIN_HUGGING_FACE_API_TOKEN || ""}`
-const adminUsername = `${process.env.ADMIN_HUGGING_FACE_USERNAME || ""}`
-
-const adminCredentials: Credentials = { accessToken: adminApiKey }
-
-export async function getChannels(options: {
- apiKey?: string
- owner?: string
-} = {}): Promise {
-
- let credentials: Credentials = adminCredentials
- let owner = options?.owner
-
- if (options?.apiKey) {
- try {
- credentials = { accessToken: options.apiKey }
- const { name: username } = await whoAmI({ credentials })
- if (!username) {
- throw new Error(`couldn't get the username`)
- }
- // everything is in order,
- owner = username
- } catch (err) {
- console.error(err)
- return []
- }
- }
-
- let channels: ChannelInfo[] = []
-
- const prefix = "ai-tube-"
-
- let search = owner
- ? { owner } // search channels of a specific user
- : prefix // global search (note: might be costly?)
-
- console.log("search:", search)
-
- for await (const { id, name, likes, updatedAt } of listDatasets({
- search,
- credentials
- })) {
-
- const chunks = name.split("/")
- const [datasetUsername, datasetName] = chunks.length === 2
- ? chunks
- : [name, name]
-
- // console.log(`found a candidate dataset "${datasetName}" owned by @${datasetUsername}`)
-
- if (!datasetName.startsWith(prefix)) {
- continue
- }
-
- const slug = datasetName.replaceAll(prefix, "")
-
- console.log(`found an AI Tube channel: "${slug}"`)
-
- // TODO parse the README to get the proper label
- const label = slug.replaceAll("-", " ")
-
-
- // TODO parse the README to get this
- // we could also use the user's avatar by default
- const thumbnail = ""
-
- const prompt = "" // TODO parse the README to get this
-
- const channel: ChannelInfo = {
- id,
- slug,
- label,
- thumbnail,
- prompt,
- likes,
- // updatedAt
- }
-
- channels.push(channel)
- }
-
- return channels
-}
diff --git a/src/app/server/actions/config.ts b/src/app/server/actions/config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22aa111c82b2f5b65d2c6eba2211a48f7f94015c
--- /dev/null
+++ b/src/app/server/actions/config.ts
@@ -0,0 +1,9 @@
+
+import { Credentials } from "@/huggingface/hub/src"
+
+export const adminApiKey = `${process.env.ADMIN_HUGGING_FACE_API_TOKEN || ""}`
+export const adminUsername = `${process.env.ADMIN_HUGGING_FACE_USERNAME || ""}`
+
+export const adminCredentials: Credentials = { accessToken: adminApiKey }
+
+export const aiTubeRobotApi = `${process.env.AI_TUBE_ROBOT_API || ""}`
diff --git a/src/app/server/actions/datasets.ts b/src/app/server/actions/datasets.ts
deleted file mode 100644
index 997e27252771b453ed54af65094ad7047cc02a4a..0000000000000000000000000000000000000000
--- a/src/app/server/actions/datasets.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { ChannelInfo, FullVideoInfo } from "@/types"
-
-export async function getPublicChannels({
- userHuggingFaceApiToken
-}: {
- userHuggingFaceApiToken: string
-}): Promise {
- // search on Hugging Face for
- // TODO: we should probably cache this, and use a fixed list
- return []
-}
-
-export async function getPrivateChannels({
- userHuggingFaceApiToken
-}: {
- userHuggingFaceApiToken: string
-}): Promise {
- return []
-}
-
-export async function getPrivateChannelVideos({
- userHuggingFaceApiToken,
- channel,
-}: {
- userHuggingFaceApiToken: string
- channel: ChannelInfo
-}): Promise {
- // TODO:
- // call the Hugging Face API to grab all the files in the dataset
- // we only get the first 30, that's enough for our demo
- return []
-}
\ No newline at end of file
diff --git a/src/app/server/actions/generateImage.ts b/src/app/server/actions/generation/generateImage.txt
similarity index 100%
rename from src/app/server/actions/generateImage.ts
rename to src/app/server/actions/generation/generateImage.txt
diff --git a/src/app/server/actions/generateStoryLines.txt b/src/app/server/actions/generation/generateStoryLines.txt
similarity index 100%
rename from src/app/server/actions/generateStoryLines.txt
rename to src/app/server/actions/generation/generateStoryLines.txt
diff --git a/src/app/server/actions/generation/videochain.ts b/src/app/server/actions/generation/videochain.ts
new file mode 100644
index 0000000000000000000000000000000000000000..691219f58d1cad2050603f4ed382b8f81e78f0d7
--- /dev/null
+++ b/src/app/server/actions/generation/videochain.ts
@@ -0,0 +1,161 @@
+
+// note: there is no / at the end in the variable
+// so we have to add it ourselves if needed
+const apiUrl = process.env.VIDEOCHAIN_API_URL
+
+export const GET = async (path: string = '', defaultValue: T): Promise => {
+ try {
+ const res = await fetch(`${apiUrl}/${path}`, {
+ method: "GET",
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${process.env.SECRET_ACCESS_TOKEN}`,
+ },
+ cache: 'no-store',
+ // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache)
+ // next: { revalidate: 1 }
+ })
+
+ // The return value is *not* serialized
+ // You can return Date, Map, Set, etc.
+
+ // Recommendation: handle errors
+ if (res.status !== 200) {
+ // This will activate the closest `error.js` Error Boundary
+ throw new Error('Failed to fetch data')
+ }
+
+ const data = await res.json()
+
+ return ((data as T) || defaultValue)
+ } catch (err) {
+ console.error(err)
+ return defaultValue
+ }
+}
+
+
+export const DELETE = async (path: string = '', defaultValue: T): Promise => {
+ try {
+ const res = await fetch(`${apiUrl}/${path}`, {
+ method: "DELETE",
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${process.env.VC_SECRET_ACCESS_TOKEN}`,
+ },
+ cache: 'no-store',
+ // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache)
+ // next: { revalidate: 1 }
+ })
+
+ // The return value is *not* serialized
+ // You can return Date, Map, Set, etc.
+
+ // Recommendation: handle errors
+ if (res.status !== 200) {
+ // This will activate the closest `error.js` Error Boundary
+ throw new Error('Failed to fetch data')
+ }
+
+ const data = await res.json()
+
+ return ((data as T) || defaultValue)
+ } catch (err) {
+ console.error(err)
+ return defaultValue
+ }
+}
+
+export const POST = async (path: string = '', payload: S, defaultValue: T): Promise => {
+ try {
+ const res = await fetch(`${apiUrl}/${path}`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.VC_SECRET_ACCESS_TOKEN}`,
+ },
+ body: JSON.stringify(payload),
+ // cache: 'no-store',
+ // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache)
+ next: { revalidate: 1 }
+ })
+ // The return value is *not* serialized
+ // You can return Date, Map, Set, etc.
+
+ // Recommendation: handle errors
+ if (res.status !== 200) {
+ // This will activate the closest `error.js` Error Boundary
+ throw new Error('Failed to post data')
+ }
+
+ const data = await res.json()
+
+ return ((data as T) || defaultValue)
+ } catch (err) {
+ return defaultValue
+ }
+}
+
+
+export const PUT = async (path: string = '', payload: S, defaultValue: T): Promise => {
+ try {
+ const res = await fetch(`${apiUrl}/${path}`, {
+ method: "PUT",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.VC_SECRET_ACCESS_TOKEN}`,
+ },
+ body: JSON.stringify(payload),
+ // cache: 'no-store',
+ // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache)
+ next: { revalidate: 1 }
+ })
+ // The return value is *not* serialized
+ // You can return Date, Map, Set, etc.
+
+ // Recommendation: handle errors
+ if (res.status !== 200) {
+ // This will activate the closest `error.js` Error Boundary
+ throw new Error('Failed to post data')
+ }
+
+ const data = await res.json()
+
+ return ((data as T) || defaultValue)
+ } catch (err) {
+ return defaultValue
+ }
+}
+
+export const PATCH = async (path: string = '', payload: S, defaultValue: T): Promise => {
+ try {
+ const res = await fetch(`${apiUrl}/${path}`, {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${process.env.VC_SECRET_ACCESS_TOKEN}`,
+ },
+ body: JSON.stringify(payload),
+ // cache: 'no-store',
+ // we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache)
+ next: { revalidate: 1 }
+ })
+ // The return value is *not* serialized
+ // You can return Date, Map, Set, etc.
+
+ // Recommendation: handle errors
+ if (res.status !== 200) {
+ // This will activate the closest `error.js` Error Boundary
+ throw new Error('Failed to post data')
+ }
+
+ const data = await res.json()
+
+ return ((data as T) || defaultValue)
+ } catch (err) {
+ return defaultValue
+ }
+}
\ No newline at end of file
diff --git a/src/app/server/actions/python-api.ts b/src/app/server/actions/python-api.ts
deleted file mode 100644
index b867b6b9897651cd4565721d551d44d854f41932..0000000000000000000000000000000000000000
--- a/src/app/server/actions/python-api.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-"use server"
-
-import { python } from "pythonia"
-
-const apiKey = `${process.env.ADMIN_HUGGING_FACE_API_TOKEN || ""}`
-
-export async function listDatasetCommunityPosts(): Promise {
-
- const { HfApi } = await python("huggingface_hub")
-
- const hf = await HfApi({
- endpoint: "https://huggingface.co",
- token: apiKey
- })
- // TODO
-
- return [] as any[]
-}
-
diff --git a/src/app/server/actions/submitVideoRequest.ts b/src/app/server/actions/submitVideoRequest.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0575b8d667ba6c2656193278f45dd58d2b39e4e3
--- /dev/null
+++ b/src/app/server/actions/submitVideoRequest.ts
@@ -0,0 +1,47 @@
+"use server"
+
+import { ChannelInfo, VideoInfo } from "@/types"
+
+import { uploadVideoRequestToDataset } from "./ai-tube-hf/uploadVideoRequestToDataset"
+import { updateQueue } from "./ai-tube-robot/updateQueue"
+
+export async function submitVideoRequest({
+ channel,
+ apiKey,
+ title,
+ description,
+ prompt,
+ tags,
+}: {
+ channel: ChannelInfo
+ apiKey: string
+ title: string
+ description: string
+ prompt: string
+ tags: string[]
+}): Promise {
+ if (!apiKey) {
+ throw new Error(`the apiKey is required`)
+ }
+
+ const { videoRequest, videoInfo } = await uploadVideoRequestToDataset({
+ channel,
+ apiKey,
+ title,
+ description,
+ prompt,
+ tags
+ })
+
+ try {
+ await updateQueue({ apiKey, channel })
+
+ return {
+ ...videoInfo,
+ status: "queued"
+ }
+ } catch (err) {
+ console.error(`failed to update the queue, but this can be done later :)`)
+ return videoInfo
+ }
+}
\ No newline at end of file
diff --git a/src/app/server/actions/censorship.ts b/src/app/server/actions/utils/censorship.ts
similarity index 100%
rename from src/app/server/actions/censorship.ts
rename to src/app/server/actions/utils/censorship.ts
diff --git a/src/app/server/actions/utils/parseDatasetPrompt.ts b/src/app/server/actions/utils/parseDatasetPrompt.ts
new file mode 100644
index 0000000000000000000000000000000000000000..02460c0801f0c440c10958a42190e418c1bf9ce4
--- /dev/null
+++ b/src/app/server/actions/utils/parseDatasetPrompt.ts
@@ -0,0 +1,49 @@
+
+import { ParsedDatasetPrompt } from "@/types"
+
+export function parseDatasetPrompt(markdown: string = ""): ParsedDatasetPrompt {
+ try {
+ const { title, description, prompt } = parseMarkdown(markdown)
+
+ return {
+ title: typeof title === "string" && title ? title : "",
+ description: typeof description === "string" && description ? description : "",
+ prompt: typeof prompt === "string" && prompt ? prompt : "",
+ }
+ } catch (err) {
+ return {
+ title: "",
+ description: "",
+ prompt: "",
+ }
+ }
+}
+
+/**
+ * Simple Markdown Parser to extract sections into a JSON object
+ * @param markdown A Markdown string containing Description and Prompt sections
+ * @returns A JSON object with { "description": "...", "prompt": "..." }
+ */
+function parseMarkdown(markdown: string): ParsedDatasetPrompt {
+ // Regular expression to find markdown sections based on the provided structure
+ const sectionRegex = /^## (.+?)\n\n([\s\S]+?)(?=\n## |$)/gm;
+
+ let match;
+ const sections: { [key: string]: string } = {};
+
+ // Iterate over each section match to populate the sections object
+ while ((match = sectionRegex.exec(markdown))) {
+ const [, key, value] = match;
+ sections[key.toLowerCase()] = value.trim();
+ }
+
+ // Create the resulting JSON object with "description" and "prompt" keys
+ const result = {
+ title: sections['title'] || '',
+ description: sections['description'] || '',
+ // categories: sections['categories'] || '',
+ prompt: sections['prompt'] || '',
+ };
+
+ return result;
+}
\ No newline at end of file
diff --git a/src/app/server/actions/utils/parseDatasetReadme.ts b/src/app/server/actions/utils/parseDatasetReadme.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d02e3c59c9d0bd86cd3c773ba63d939a3a371b7b
--- /dev/null
+++ b/src/app/server/actions/utils/parseDatasetReadme.ts
@@ -0,0 +1,62 @@
+
+import metadataParser from "markdown-yaml-metadata-parser"
+
+import { ParsedDatasetReadme, ParsedMetadataAndContent } from "@/types"
+
+export function parseDatasetReadme(markdown: string = ""): ParsedDatasetReadme {
+ try {
+ const { metadata, content } = metadataParser(markdown) as ParsedMetadataAndContent
+
+ // console.log("DEBUG README:", { metadata, content })
+
+ const { description, prompt } = parseMarkdown(content)
+
+ return {
+ license: typeof metadata?.license === "string" ? metadata.license : "",
+ pretty_name: typeof metadata?.pretty_name === "string" ? metadata.pretty_name : "",
+ tags: Array.isArray(metadata?.tags) ? metadata.tags : [],
+ description,
+ prompt,
+ }
+ } catch (err) {
+ return {
+ license: "",
+ pretty_name: "",
+ tags: [], // Hugging Face tags
+ description: "",
+ prompt: "",
+ }
+ }
+}
+
+/**
+ * Simple Markdown Parser to extract sections into a JSON object
+ * @param markdown A Markdown string containing Description and Prompt sections
+ * @returns A JSON object with { "description": "...", "prompt": "..." }
+ */
+function parseMarkdown(markdown: string): {
+ description: string
+ prompt: string
+ // categories: string
+} {
+ // Regular expression to find markdown sections based on the provided structure
+ const sectionRegex = /^## (.+?)\n\n([\s\S]+?)(?=\n## |$)/gm;
+
+ let match;
+ const sections: { [key: string]: string } = {};
+
+ // Iterate over each section match to populate the sections object
+ while ((match = sectionRegex.exec(markdown))) {
+ const [, key, value] = match;
+ sections[key.toLowerCase()] = value.trim();
+ }
+
+ // Create the resulting JSON object with "description" and "prompt" keys
+ const result = {
+ description: sections['description'] || '',
+ // categories: sections['categories'] || '',
+ prompt: sections['prompt'] || '',
+ };
+
+ return result;
+}
\ No newline at end of file
diff --git a/src/app/server/config.ts b/src/app/server/config.ts
deleted file mode 100644
index 40bb1c1b11a36abeef494e9fb431c1a84e0256f6..0000000000000000000000000000000000000000
--- a/src/app/server/config.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import path from "node:path"
-
-// see the .env file fore more informations
-export const storagePath = `${process.env.STORAGE_PATH || './sandbox'}`
-
-export const partiesDirFilePath = path.join(storagePath, "parties")
diff --git a/src/app/state/categories.ts b/src/app/state/categories.ts
index 07dec43795ac48c7ab5371b503382d2f88f4f1f1..9cb74f9f54c3b9020b2d970ae619c9176628a515 100644
--- a/src/app/state/categories.ts
+++ b/src/app/state/categories.ts
@@ -1,13 +1,18 @@
+
+
+// TODO:
+// this is obsolete, we should search on the Hugging Face platform instead
+
export const videoCategoriesWithLabels = {
// "random": "Random",
// "lofi": "Lofi Hip-Hop",
- "sports": "Sports",
- "education": "Education",
- "timetravel": "Time Travel", // vlogs etc
- "gaming": "Gaming",
- "trailers": "Trailers",
- "aitubers": "AI tubers",
- "ads": "100% Ads",
+ "Sports": "Sports",
+ "Education": "Education",
+ "Time Travel": "Time Travel", // vlogs etc
+ // "gaming": "Gaming",
+ // "trailers": "Trailers",
+ // "aitubers": "AI tubers",
+ // "ads": "100% Ads",
}
export type VideoCategory = keyof typeof videoCategoriesWithLabels
diff --git a/src/app/state/useStore.ts b/src/app/state/useStore.ts
index e4b2c1cef4c26d923ad9c3d6ef7cc8001fc44064..9f2cbbfcd0e64155e2c20268302884d46d33d70a 100644
--- a/src/app/state/useStore.ts
+++ b/src/app/state/useStore.ts
@@ -2,8 +2,7 @@
import { create } from "zustand"
-import { VideoCategory } from "./categories"
-import { ChannelInfo, FullVideoInfo, InterfaceDisplayMode, InterfaceView } from "@/types"
+import { ChannelInfo, VideoInfo, InterfaceDisplayMode, InterfaceView } from "@/types"
export const useStore = create<{
displayMode: InterfaceDisplayMode
@@ -18,14 +17,17 @@ export const useStore = create<{
currentChannels: ChannelInfo[]
setCurrentChannels: (currentChannels?: ChannelInfo[]) => void
- currentCategory?: VideoCategory
- setCurrentCategory: (currentCategory?: VideoCategory) => void
+ currentTag?: string
+ setCurrentTag: (currentTag?: string) => void
- currentVideos: FullVideoInfo[]
- setCurrentVideos: (currentVideos: FullVideoInfo[]) => void
+ currentVideos: VideoInfo[]
+ setCurrentVideos: (currentVideos: VideoInfo[]) => void
- currentVideo?: FullVideoInfo
- setCurrentVideo: (currentVideo?: FullVideoInfo) => void
+ currentVideo?: VideoInfo
+ setCurrentVideo: (currentVideo?: VideoInfo) => void
+
+ // currentPrompts: VideoInfo[]
+ // setCurrentPrompts: (currentPrompts: VideoInfo[]) => void
}>((set, get) => ({
displayMode: "desktop",
setDisplayMode: (displayMode: InterfaceDisplayMode) => {
@@ -50,18 +52,18 @@ export const useStore = create<{
set({ currentChannels: Array.isArray(currentChannels) ? currentChannels : [] })
},
- currentCategory: undefined,
- setCurrentCategory: (currentCategory?: VideoCategory) => {
- set({ currentCategory })
+ currentTag: undefined,
+ setCurrentTag: (currentTag?: string) => {
+ set({ currentTag })
},
currentVideos: [],
- setCurrentVideos: (currentVideos: FullVideoInfo[] = []) => {
+ setCurrentVideos: (currentVideos: VideoInfo[] = []) => {
set({
currentVideos: Array.isArray(currentVideos) ? currentVideos : []
})
},
currentVideo: undefined,
- setCurrentVideo: (currentVideo?: FullVideoInfo) => { set({ currentVideo }) },
+ setCurrentVideo: (currentVideo?: VideoInfo) => { set({ currentVideo }) },
}))
\ No newline at end of file
diff --git a/src/app/views/channel-admin-view/index.tsx b/src/app/views/channel-admin-view/index.tsx
deleted file mode 100644
index 841c0b687c48c8c0378cacda5e87c040c0383717..0000000000000000000000000000000000000000
--- a/src/app/views/channel-admin-view/index.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useEffect } from "react"
-
-import { useStore } from "@/app/state/useStore"
-import { cn } from "@/lib/utils"
-import { FullVideoInfo } from "@/types"
-import { VideoList } from "@/app/interface/video-list"
-
-export function ChannelAdminView() {
- const displayMode = useStore(s => s.displayMode)
- const setDisplayMode = useStore(s => s.setDisplayMode)
- const currentChannel = useStore(s => s.currentChannel)
- const setCurrentChannel = useStore(s => s.setCurrentChannel)
- const currentCategory = useStore(s => s.currentCategory)
- const setCurrentCategory = useStore(s => s.setCurrentCategory)
- const currentVideos = useStore(s => s.currentVideos)
- const setCurrentVideos = useStore(s => s.setCurrentVideos)
- const currentVideo = useStore(s => s.currentVideo)
- const setCurrentVideo = useStore(s => s.setCurrentVideo)
-
- useEffect(() => {
-
- // we use fake data for now
- // this will be pulled from the Hugging Face API
- const newVideos: FullVideoInfo[] = [
- {
- id: "42",
- label: "Test Julian",
- thumbnailUrl: "",
- assetUrl: "",
- numberOfViews: 0,
- createdAt: "2023-11-27",
- categories: [],
- channelId: "",
- channel: {
- id: "",
- slug: "",
- label: "Hugging Face",
- thumbnail: "",
- prompt: "",
- likes: 0,
- }
- }
- ]
- setCurrentVideos(newVideos)
- }, [currentCategory])
-
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/src/app/views/channel-public-view/index.tsx b/src/app/views/channel-public-view/index.tsx
deleted file mode 100644
index 322f25fceed6ab857c53e4a268dcbd522fd49b84..0000000000000000000000000000000000000000
--- a/src/app/views/channel-public-view/index.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useEffect } from "react"
-
-import { useStore } from "@/app/state/useStore"
-import { cn } from "@/lib/utils"
-import { FullVideoInfo } from "@/types"
-import { VideoList } from "@/app/interface/video-list"
-
-export function ChannelPublicView() {
- const displayMode = useStore(s => s.displayMode)
- const setDisplayMode = useStore(s => s.setDisplayMode)
- const currentChannel = useStore(s => s.currentChannel)
- const setCurrentChannel = useStore(s => s.setCurrentChannel)
- const currentCategory = useStore(s => s.currentCategory)
- const setCurrentCategory = useStore(s => s.setCurrentCategory)
- const currentVideos = useStore(s => s.currentVideos)
- const setCurrentVideos = useStore(s => s.setCurrentVideos)
- const currentVideo = useStore(s => s.currentVideo)
- const setCurrentVideo = useStore(s => s.setCurrentVideo)
-
- useEffect(() => {
-
- // we use fake data for now
- // this will be pulled from the Hugging Face API
- const newVideos: FullVideoInfo[] = [
- {
- id: "42",
- label: "Test Julian",
- thumbnailUrl: "",
- assetUrl: "",
- numberOfViews: 0,
- createdAt: "2023-11-27",
- categories: [],
- channelId: "",
- channel: {
- id: "",
- slug: "",
- label: "Hugging Face",
- thumbnail: "",
- prompt: "",
- likes: 0,
- }
- }
- ]
- setCurrentVideos(newVideos)
- }, [currentCategory])
-
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/src/app/views/home-view/index.tsx b/src/app/views/home-view/index.tsx
index e6563f597862ebd05e8eb86f09155aeac2d13fd5..dc334541942e6f86ae2e3c207936ea921f3c5a72 100644
--- a/src/app/views/home-view/index.tsx
+++ b/src/app/views/home-view/index.tsx
@@ -2,7 +2,7 @@ import { useEffect } from "react"
import { useStore } from "@/app/state/useStore"
import { cn } from "@/lib/utils"
-import { FullVideoInfo } from "@/types"
+import { VideoInfo } from "@/types"
export function HomeView() {
const displayMode = useStore(s => s.displayMode)
@@ -20,7 +20,7 @@ export function HomeView() {
// we use fake data for now
// this will be pulled from the Hugging Face API
- const newCategoryVideos: FullVideoInfo[] = [
+ const newCategoryVideos: VideoInfo[] = [
{
id: "42",
label: "Test Julian",
diff --git a/src/app/views/public-channel-view/index.tsx b/src/app/views/public-channel-view/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..537e71e189316ff5c4710b039c300cf01c73123c
--- /dev/null
+++ b/src/app/views/public-channel-view/index.tsx
@@ -0,0 +1,43 @@
+import { useEffect, useTransition } from "react"
+
+import { useStore } from "@/app/state/useStore"
+import { cn } from "@/lib/utils"
+import { VideoInfo } from "@/types"
+import { VideoList } from "@/app/interface/video-list"
+import { getChannelVideos } from "@/app/server/actions/api"
+import { useLocalStorage } from "usehooks-ts"
+import { localStorageKeys } from "@/app/state/locaStorageKeys"
+import { defaultSettings } from "@/app/state/defaultSettings"
+
+export function PublicChannelView() {
+ const [_isPending, startTransition] = useTransition()
+ const currentChannel = useStore(s => s.currentChannel)
+ const currentVideos = useStore(s => s.currentVideos)
+ const setCurrentVideos = useStore(s => s.setCurrentVideos)
+ const setCurrentVideo = useStore(s => s.setCurrentVideo)
+
+ useEffect(() => {
+ if (!currentChannel) {
+ return
+ }
+
+ startTransition(async () => {
+ const videos = await getChannelVideos({
+ channel: currentChannel,
+ })
+ console.log("videos:", videos)
+ })
+
+ setCurrentVideos([])
+ }, [currentChannel, currentChannel?.id])
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/views/channels-public-view/index.tsx b/src/app/views/public-channels-view/index.tsx
similarity index 96%
rename from src/app/views/channels-public-view/index.tsx
rename to src/app/views/public-channels-view/index.tsx
index 74d969970eccbfcf02300771d1bcd45f4dd91aec..decd31b7cc47fe2cf5950eed6f0ed6b0c811f425 100644
--- a/src/app/views/channels-public-view/index.tsx
+++ b/src/app/views/public-channels-view/index.tsx
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"
import { getChannels } from "@/app/server/actions/api"
import { ChannelList } from "@/app/interface/channel-list"
-export function ChannelsPublicView() {
+export function PublicChannelsView() {
const [_isPending, startTransition] = useTransition()
const currentChannels = useStore(s => s.currentChannels)
diff --git a/src/app/views/video-public-view/index.tsx b/src/app/views/public-video-view/index.tsx
similarity index 80%
rename from src/app/views/video-public-view/index.tsx
rename to src/app/views/public-video-view/index.tsx
index 627166a146d3ee75d6bfb692e77d210f03c19458..a63701d3f2a25265b903570010473b943db7d266 100644
--- a/src/app/views/video-public-view/index.tsx
+++ b/src/app/views/public-video-view/index.tsx
@@ -3,13 +3,13 @@ import { useEffect } from "react"
import { useStore } from "@/app/state/useStore"
import { cn } from "@/lib/utils"
-export function VideoPublicView() {
+export function PublicVideoView() {
const displayMode = useStore(s => s.displayMode)
const setDisplayMode = useStore(s => s.setDisplayMode)
const currentChannel = useStore(s => s.currentChannel)
const setCurrentChannel = useStore(s => s.setCurrentChannel)
- const currentCategory = useStore(s => s.currentCategory)
- const setCurrentCategory = useStore(s => s.setCurrentCategory)
+ const currentTag = useStore(s => s.currentTag)
+ const setCurrentTag = useStore(s => s.setCurrentTag)
const currentVideos = useStore(s => s.currentVideos)
const currentVideo = useStore(s => s.currentVideo)
const setCurrentVideo = useStore(s => s.setCurrentVideo)
diff --git a/src/app/views/user-account-view/index.tsx b/src/app/views/user-account-view/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..881659800137c8c90c6afd1729836a6dab9bf9d6
--- /dev/null
+++ b/src/app/views/user-account-view/index.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import { useTransition } from "react"
+import { useLocalStorage } from "usehooks-ts"
+
+import { cn } from "@/lib/utils"
+import { Input } from "@/components/ui/input"
+import { localStorageKeys } from "@/app/state/locaStorageKeys"
+import { defaultSettings } from "@/app/state/defaultSettings"
+
+export function UserAccountView() {
+ const [huggingfaceApiKey, setHuggingfaceApiKey] = useLocalStorage(
+ localStorageKeys.huggingfaceApiKey,
+ defaultSettings.huggingfaceApiKey
+ )
+
+ return (
+
+
+
+
+ {
+ setHuggingfaceApiKey(x.target.value)
+ }}
+ value={huggingfaceApiKey}
+ />
+
+
+ Note: your Hugging Face token must be a WRITE access token.
+
+
+ {huggingfaceApiKey
+ ?
You are ready to go!
+ :
Please setup your accountabove to get started
}
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/views/user-channel-view/index.tsx b/src/app/views/user-channel-view/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..43d86635befd91aba564ed90122e0336345bb3bf
--- /dev/null
+++ b/src/app/views/user-channel-view/index.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useState, useTransition } from "react"
+
+import { useStore } from "@/app/state/useStore"
+import { cn } from "@/lib/utils"
+import { VideoInfo } from "@/types"
+import { VideoList } from "@/app/interface/video-list"
+import { submitVideoRequest, getChannelVideos } from "@/app/server/actions/api"
+import { useLocalStorage } from "usehooks-ts"
+import { localStorageKeys } from "@/app/state/locaStorageKeys"
+import { defaultSettings } from "@/app/state/defaultSettings"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Button } from "@/components/ui/button"
+
+export function UserChannelView() {
+ const [_isPending, startTransition] = useTransition()
+ const [huggingfaceApiKey, setHuggingfaceApiKey] = useLocalStorage(
+ localStorageKeys.huggingfaceApiKey,
+ defaultSettings.huggingfaceApiKey
+ )
+ const [titleDraft, setTitleDraft] = useState("")
+ const [promptDraft, setPromptDraft] = useState("")
+
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const currentChannel = useStore(s => s.currentChannel)
+ const currentVideos = useStore(s => s.currentVideos)
+ const setCurrentVideos = useStore(s => s.setCurrentVideos)
+ const setCurrentVideo = useStore(s => s.setCurrentVideo)
+
+ useEffect(() => {
+ if (!currentChannel) {
+ return
+ }
+
+ startTransition(async () => {
+ const videos = await getChannelVideos({
+ channel: currentChannel,
+ apiKey: huggingfaceApiKey,
+ })
+ console.log("videos:", videos)
+ })
+
+ setCurrentVideos([])
+ }, [huggingfaceApiKey, currentChannel, currentChannel?.id])
+
+ const handleSubmit = () => {
+ if (!currentChannel) {
+ return
+ }
+ if (!titleDraft || !promptDraft) {
+ console.log("missing title or prompt")
+ return
+ }
+
+ setIsSubmitting(true)
+
+ startTransition(async () => {
+ try {
+ const newVideo = await submitVideoRequest({
+ channel: currentChannel,
+ apiKey: huggingfaceApiKey,
+ title: titleDraft,
+ prompt: promptDraft
+ })
+
+ // in case of success we update the frontend immediately
+ // with our draft video
+ setCurrentVideos([newVideo, ...currentVideos])
+ setPromptDraft("")
+ setTitleDraft("")
+
+ // also renew the cache on Next's side
+ await getChannelVideos({
+ channel: currentChannel,
+ apiKey: huggingfaceApiKey,
+ renewCache: true,
+ })
+ } catch (err) {
+ console.error(err)
+ } finally {
+ setIsSubmitting(false)
+ }
+ })
+ }
+
+ return (
+
+
Robot channel settings:
+
TODO
+
+
Schedule a new prompt:
+
+
+
+
+
{
+ setTitleDraft(x.target.value)
+ }}
+ value={titleDraft}
+ />
+
+ Title of the video, keep it short.
+
+
+
+
+
+
+
+
+
Note: It can take a few hours for the video to be generated.
+
+
+
Current video prompts:
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/views/channels-admin-view/index.tsx b/src/app/views/user-channels-view/index.tsx
similarity index 57%
rename from src/app/views/channels-admin-view/index.tsx
rename to src/app/views/user-channels-view/index.tsx
index 9493e26c8803dedcc515fc7b24430987f43f004c..a6f9c309660dc0acb3e3287c5e7b3f07471e0975 100644
--- a/src/app/views/channels-admin-view/index.tsx
+++ b/src/app/views/user-channels-view/index.tsx
@@ -5,19 +5,21 @@ import { useLocalStorage } from "usehooks-ts"
import { useStore } from "@/app/state/useStore"
import { cn } from "@/lib/utils"
-import { getChannels } from "@/app/server/actions/api"
+import { getChannels } from "@/app/server/actions/ai-tube-hf/getChannels"
import { ChannelList } from "@/app/interface/channel-list"
-import { Input } from "@/components/ui/input"
import { localStorageKeys } from "@/app/state/locaStorageKeys"
import { defaultSettings } from "@/app/state/defaultSettings"
-export function ChannelsAdminView() {
+export function UserChannelsView() {
const [_isPending, startTransition] = useTransition()
- const [huggingfaceApiKey, setHuggingfaceApiKey] = useLocalStorage(
+ const [huggingfaceApiKey,] = useLocalStorage(
localStorageKeys.huggingfaceApiKey,
defaultSettings.huggingfaceApiKey
)
+ const setView = useStore(s => s.setView)
+ const setCurrentChannel = useStore(s => s.setCurrentChannel)
+
const currentChannels = useStore(s => s.currentChannels)
const setCurrentChannels = useStore(s => s.setCurrentChannels)
const [isLoaded, setLoaded] = useState(false)
@@ -36,33 +38,20 @@ export function ChannelsAdminView() {
}
})
}
- }, [isLoaded])
+ }, [isLoaded, huggingfaceApiKey])
return (
-
-
-
- {
- setHuggingfaceApiKey(x.target.value)
- }}
- value={huggingfaceApiKey}
- />
-
-
- Note: your Hugging Face token must be a WRITE access token.
-
-
{huggingfaceApiKey ?
: null}
+ onSelect={(channel) => {
+ setCurrentChannel(channel)
+ setView("user_channel")
+ }}
+ /> :
Please setup your account to get started creating robot channels!
}
)
}
\ No newline at end of file
diff --git a/src/huggingface/hub/src/index.ts b/src/huggingface/hub/src/index.ts
index 9828c77f8446bfd68920a403bbad3966ea280540..2311d88a9483b7f94bd670c602fbd11e9a75f76b 100644
--- a/src/huggingface/hub/src/index.ts
+++ b/src/huggingface/hub/src/index.ts
@@ -1,4 +1,4 @@
-export * from "./lib";
+export * from "./lib/index";
// Typescript 5 will add 'export type *'
export type {
AccessToken,
diff --git a/src/huggingface/hub/src/lib/commit.ts b/src/huggingface/hub/src/lib/commit.ts
index 7b271e5770bede2fd5cc0645e3683c331326f3bb..b3d25e2167f88b87536b985b84cc0d5e75603859 100644
--- a/src/huggingface/hub/src/lib/commit.ts
+++ b/src/huggingface/hub/src/lib/commit.ts
@@ -1,4 +1,4 @@
-import { isFrontend, base64FromBytes } from "../../../shared";
+import { isFrontend, base64FromBytes } from "../../../shared/index";
import { HUB_URL } from "../consts";
import { HubApiError, createApiError, InvalidApiResponseFormatError } from "../error";
import type {
@@ -80,6 +80,7 @@ export interface CommitParams {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
abortSignal?: AbortSignal;
}
@@ -193,6 +194,7 @@ export async function* commitIter(params: CommitParams): AsyncGenerator {
diff --git a/src/huggingface/hub/src/lib/create-repo.ts b/src/huggingface/hub/src/lib/create-repo.ts
index f3ab83134e0ed9ce952121932cfcf658b1e6a65b..20c5fb2487afa21de483f6ceb789f3a58490fdfd 100644
--- a/src/huggingface/hub/src/lib/create-repo.ts
+++ b/src/huggingface/hub/src/lib/create-repo.ts
@@ -2,7 +2,7 @@ import { HUB_URL } from "../consts";
import { createApiError } from "../error";
import type { ApiCreateRepoPayload } from "../types/api/api-create-repo";
import type { Credentials, RepoDesignation, SpaceSdk } from "../types/public";
-import { base64FromBytes } from "../../../shared";
+import { base64FromBytes } from "../../../shared/index";
import { checkCredentials } from "../utils/checkCredentials";
import { toRepoId } from "../utils/toRepoId";
@@ -22,6 +22,7 @@ export async function createRepo(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): Promise<{ repoUrl: string }> {
checkCredentials(params.credentials);
const repoId = toRepoId(params.repo);
@@ -64,6 +65,7 @@ export async function createRepo(params: {
Authorization: `Bearer ${params.credentials.accessToken}`,
"Content-Type": "application/json",
},
+ ...params.requestInit,
});
if (!res.ok) {
diff --git a/src/huggingface/hub/src/lib/delete-repo.ts b/src/huggingface/hub/src/lib/delete-repo.ts
index 1c0b59ef4ebab67ff54ff70fd828a50dd45d3d11..ad55ce53c0bad9761123b404bd4b622658544f0f 100644
--- a/src/huggingface/hub/src/lib/delete-repo.ts
+++ b/src/huggingface/hub/src/lib/delete-repo.ts
@@ -12,6 +12,7 @@ export async function deleteRepo(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): Promise {
checkCredentials(params.credentials);
const repoId = toRepoId(params.repo);
@@ -28,6 +29,7 @@ export async function deleteRepo(params: {
Authorization: `Bearer ${params.credentials.accessToken}`,
"Content-Type": "application/json",
},
+ ...params.requestInit,
});
if (!res.ok) {
diff --git a/src/huggingface/hub/src/lib/download-file.ts b/src/huggingface/hub/src/lib/download-file.ts
index 9394e6a68e78b181048adf09b17b12c85d6b18a9..3011a270d26be156dca4e04f61f04b3e30844bc0 100644
--- a/src/huggingface/hub/src/lib/download-file.ts
+++ b/src/huggingface/hub/src/lib/download-file.ts
@@ -27,6 +27,7 @@ export async function downloadFile(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): Promise {
checkCredentials(params.credentials);
const repoId = toRepoId(params.repo);
@@ -47,6 +48,7 @@ export async function downloadFile(params: {
}
: {}),
},
+ ...params.requestInit,
});
if (resp.status === 404 && resp.headers.get("X-Error-Code") === "EntryNotFound") {
diff --git a/src/huggingface/hub/src/lib/file-download-info.ts b/src/huggingface/hub/src/lib/file-download-info.ts
index 72630728e689b704a28af2cfa7ad9c61b26cb19a..469e1a14424d4b2f51279c9cd8199181606bd087 100644
--- a/src/huggingface/hub/src/lib/file-download-info.ts
+++ b/src/huggingface/hub/src/lib/file-download-info.ts
@@ -25,6 +25,10 @@ export async function fileDownloadInfo(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ /**
+ * Custom fetch parameters
+ */
+ requestInit?: RequestInit;
/**
* To get the raw pointer file behind a LFS file
*/
@@ -54,6 +58,7 @@ export async function fileDownloadInfo(params: {
Range: "bytes=0-0",
}
: {},
+ ...params.requestInit,
});
if (resp.status === 404 && resp.headers.get("X-Error-Code") === "EntryNotFound") {
diff --git a/src/huggingface/hub/src/lib/file-exists.ts b/src/huggingface/hub/src/lib/file-exists.ts
index 47e371287c75909249876457b906ad50f740f5be..531d12eed275bfee3052776c864e5fa7a33e81f1 100644
--- a/src/huggingface/hub/src/lib/file-exists.ts
+++ b/src/huggingface/hub/src/lib/file-exists.ts
@@ -14,6 +14,7 @@ export async function fileExists(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): Promise {
checkCredentials(params.credentials);
const repoId = toRepoId(params.repo);
@@ -26,6 +27,7 @@ export async function fileExists(params: {
const resp = await (params.fetch ?? fetch)(url, {
method: "HEAD",
headers: params.credentials ? { Authorization: `Bearer ${params.credentials.accessToken}` } : {},
+ ...params.requestInit,
});
if (resp.status === 404) {
diff --git a/src/huggingface/hub/src/lib/list-datasets.ts b/src/huggingface/hub/src/lib/list-datasets.ts
index daa74cfb8a425d5a935fff18ecba098ce243d9af..b8e82fd7103196f2325b0a19a4886c87aa93a468 100644
--- a/src/huggingface/hub/src/lib/list-datasets.ts
+++ b/src/huggingface/hub/src/lib/list-datasets.ts
@@ -27,6 +27,7 @@ export async function* listDatasets(params?: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): AsyncGenerator {
checkCredentials(params?.credentials);
const search = new URLSearchParams([
diff --git a/src/huggingface/hub/src/lib/list-files.ts b/src/huggingface/hub/src/lib/list-files.ts
index 4cb6a06a2b68a0ec8ecdac5a0d94f50d2e547dd0..018bc8824624c540d10a786a8be6a8b45bbf9384 100644
--- a/src/huggingface/hub/src/lib/list-files.ts
+++ b/src/huggingface/hub/src/lib/list-files.ts
@@ -57,6 +57,7 @@ export async function* listFiles(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): AsyncGenerator {
checkCredentials(params.credentials);
const repoId = toRepoId(params.repo);
@@ -70,6 +71,7 @@ export async function* listFiles(params: {
accept: "application/json",
...(params.credentials ? { Authorization: `Bearer ${params.credentials.accessToken}` } : undefined),
},
+ ...params.requestInit,
});
if (!res.ok) {
diff --git a/src/huggingface/hub/src/lib/list-models.ts b/src/huggingface/hub/src/lib/list-models.ts
index d0f23cea55eedffe9d8f4e295f852c00e61fea25..d69f64b52bb3faa5fb68c2112d1b899f688b56e5 100644
--- a/src/huggingface/hub/src/lib/list-models.ts
+++ b/src/huggingface/hub/src/lib/list-models.ts
@@ -29,6 +29,7 @@ export async function* listModels(params?: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): AsyncGenerator {
checkCredentials(params?.credentials);
const search = new URLSearchParams([
diff --git a/src/huggingface/hub/src/lib/list-spaces.ts b/src/huggingface/hub/src/lib/list-spaces.ts
index 2e23f2dfbd0fc72eb7b5793869cdf4c28e1d2340..bafb8405d92235d4373653d38af022d51f3bbc59 100644
--- a/src/huggingface/hub/src/lib/list-spaces.ts
+++ b/src/huggingface/hub/src/lib/list-spaces.ts
@@ -28,6 +28,7 @@ export async function* listSpaces(params?: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
/**
* Additional fields to fetch from huggingface.co.
*/
diff --git a/src/huggingface/hub/src/lib/parse-safetensors-metadata.ts b/src/huggingface/hub/src/lib/parse-safetensors-metadata.ts
index cdf7120a7b8a646d5228276c1f3e031fc1415ba0..db98329e4ef03a72b678aa4ba60a2e0c7ca92b24 100644
--- a/src/huggingface/hub/src/lib/parse-safetensors-metadata.ts
+++ b/src/huggingface/hub/src/lib/parse-safetensors-metadata.ts
@@ -64,6 +64,7 @@ async function parseSingleFile(
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}
): Promise {
const firstResp = await downloadFile({
@@ -114,6 +115,7 @@ async function parseShardedIndex(
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}
): Promise<{ index: SafetensorsIndexJson; headers: SafetensorsShardedHeaders }> {
const indexResp = await downloadFile({
@@ -161,6 +163,7 @@ export async function parseSafetensorsMetadata(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): Promise>;
export async function parseSafetensorsMetadata(params: {
/** Only models are supported */
@@ -178,6 +181,7 @@ export async function parseSafetensorsMetadata(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): Promise;
export async function parseSafetensorsMetadata(params: {
repo: RepoDesignation;
@@ -189,6 +193,7 @@ export async function parseSafetensorsMetadata(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): Promise {
checkCredentials(params.credentials);
const repoId = toRepoId(params.repo);
diff --git a/src/huggingface/hub/src/lib/who-am-i.ts b/src/huggingface/hub/src/lib/who-am-i.ts
index 02d5ff95cb64570e77bd5fadd8389e4af2f1f4ca..08d932a7950aa29704e313cd7864ca85927634f3 100644
--- a/src/huggingface/hub/src/lib/who-am-i.ts
+++ b/src/huggingface/hub/src/lib/who-am-i.ts
@@ -74,6 +74,7 @@ export async function whoAmI(params: {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}): Promise {
checkCredentials(params.credentials);
@@ -81,6 +82,7 @@ export async function whoAmI(params: {
headers: {
Authorization: `Bearer ${params.credentials.accessToken}`,
},
+ ...params.requestInit,
});
if (!res.ok) {
diff --git a/src/huggingface/hub/src/utils/FileBlob.spec.ts b/src/huggingface/hub/src/utils/FileBlob.spec.ts
deleted file mode 100644
index 2ed51d8e38e817bc69d0924e3f4b47885e85c5d9..0000000000000000000000000000000000000000
--- a/src/huggingface/hub/src/utils/FileBlob.spec.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { open, stat } from "node:fs/promises";
-import { TextDecoder } from "node:util";
-import { describe, expect, it } from "vitest";
-import { FileBlob } from "./FileBlob";
-
-describe("FileBlob", () => {
- it("should create a FileBlob with a slice on the entire file", async () => {
- const file = await open("package.json", "r");
- const { size } = await stat("package.json");
-
- const fileBlob = await FileBlob.create("package.json");
-
- expect(fileBlob).toMatchObject({
- path: "package.json",
- start: 0,
- end: size,
- });
- expect(fileBlob.size).toBe(size);
- expect(fileBlob.type).toBe("");
- const text = await fileBlob.text();
- const expectedText = (await file.read(Buffer.alloc(size), 0, size)).buffer.toString("utf8");
- expect(text).toBe(expectedText);
- const result = await fileBlob.stream().getReader().read();
- expect(new TextDecoder().decode(result.value)).toBe(expectedText);
- });
-
- it("should create a slice on the file", async () => {
- const file = await open("package.json", "r");
- const fileBlob = await FileBlob.create("package.json");
-
- const slice = fileBlob.slice(10, 20);
-
- expect(slice).toMatchObject({
- path: "package.json",
- start: 10,
- end: 20,
- });
- expect(slice.size).toBe(10);
- const sliceText = await slice.text();
- const expectedText = (await file.read(Buffer.alloc(10), 0, 10, 10)).buffer.toString("utf8");
- expect(sliceText).toBe(expectedText);
- const result = await slice.stream().getReader().read();
- expect(new TextDecoder().decode(result.value)).toBe(expectedText);
- });
-});
diff --git a/src/huggingface/hub/src/utils/WebBlob.spec.ts b/src/huggingface/hub/src/utils/WebBlob.spec.ts
deleted file mode 100644
index d5efd4f5c96c23495cbfd1eb69e1cc3fb54a7cec..0000000000000000000000000000000000000000
--- a/src/huggingface/hub/src/utils/WebBlob.spec.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { describe, expect, it, beforeAll } from "vitest";
-import { base64FromBytes } from "../../../shared";
-import { WebBlob } from "./WebBlob";
-
-describe("WebBlob", () => {
- const resourceUrl = new URL("https://huggingface.co/spaces/aschen/push-model-from-web/raw/main/mobilenet/model.json");
- let fullText: string;
- let size: number;
- let contentType: string;
-
- beforeAll(async () => {
- const response = await fetch(resourceUrl, { method: "HEAD" });
- size = Number(response.headers.get("content-length"));
- contentType = response.headers.get("content-type") || "";
- fullText = await (await fetch(resourceUrl)).text();
- });
-
- it("should create a WebBlob with a slice on the entire resource", async () => {
- const webBlob = await WebBlob.create(resourceUrl, { cacheBelow: 0 });
-
- expect(webBlob).toMatchObject({
- url: resourceUrl,
- start: 0,
- end: size,
- contentType,
- });
- expect(webBlob).toBeInstanceOf(WebBlob);
- expect(webBlob.size).toBe(size);
- expect(webBlob.type).toBe(contentType);
-
- const text = await webBlob.text();
- expect(text).toBe(fullText);
-
- const streamText = await new Response(webBlob.stream()).text();
- expect(streamText).toBe(fullText);
- });
-
- it("should create a WebBlob with a slice on the entire resource, cached", async () => {
- const webBlob = await WebBlob.create(resourceUrl, { cacheBelow: 1_000_000 });
-
- expect(webBlob).not.toBeInstanceOf(WebBlob);
- expect(webBlob.size).toBe(size);
- expect(webBlob.type.replace(/;\s*charset=utf-8/, "")).toBe(contentType.replace(/;\s*charset=utf-8/, ""));
-
- const text = await webBlob.text();
- expect(text).toBe(fullText);
-
- const streamText = await new Response(webBlob.stream()).text();
- expect(streamText).toBe(fullText);
- });
-
- it("should lazy load a LFS file hosted on Hugging Face", async () => {
- const stableDiffusionUrl =
- "https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/39593d5650112b4cc580433f6b0435385882d819/v1-5-pruned.safetensors";
- const url = new URL(stableDiffusionUrl);
- const webBlob = await WebBlob.create(url);
-
- expect(webBlob.size).toBe(7_703_324_286);
- expect(webBlob).toBeInstanceOf(WebBlob);
- expect(webBlob).toMatchObject({ url });
- expect(base64FromBytes(new Uint8Array(await webBlob.slice(6, 12).arrayBuffer()))).toBe("AAB7Il9f");
- expect(base64FromBytes(new Uint8Array(await webBlob.slice(0, 12).arrayBuffer()))).toBe("ytIDAAAAAAB7Il9f");
- });
-
- it("should create a slice on the file", async () => {
- const expectedText = fullText.slice(10, 20);
-
- const slice = (await WebBlob.create(resourceUrl, { cacheBelow: 0 })).slice(10, 20);
-
- expect(slice).toMatchObject({
- url: resourceUrl,
- start: 10,
- end: 20,
- contentType,
- });
- expect(slice.size).toBe(10);
- expect(slice.type).toBe(contentType);
-
- const sliceText = await slice.text();
- expect(sliceText).toBe(expectedText);
-
- const streamText = await new Response(slice.stream()).text();
- expect(streamText).toBe(expectedText);
- });
-});
diff --git a/src/huggingface/hub/src/utils/WebBlob.ts b/src/huggingface/hub/src/utils/WebBlob.ts
index fe35813fe005f8e7bc5c8feba211cf663d7a9c31..adca0a2e747e8f0fd66eae20c9a4121f07a7b53b 100644
--- a/src/huggingface/hub/src/utils/WebBlob.ts
+++ b/src/huggingface/hub/src/utils/WebBlob.ts
@@ -14,6 +14,7 @@ interface WebBlobCreateOptions {
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
*/
fetch?: typeof fetch;
+ requestInit?: RequestInit;
}
export class WebBlob extends Blob {
diff --git a/src/huggingface/hub/src/utils/createBlob.ts b/src/huggingface/hub/src/utils/createBlob.ts
index 35bf5f18b6eab30770915f4315f2a24ed6f1565c..3be953ea878f751240d34f7257491204939756cd 100644
--- a/src/huggingface/hub/src/utils/createBlob.ts
+++ b/src/huggingface/hub/src/utils/createBlob.ts
@@ -11,7 +11,7 @@ import { WebBlob } from "./WebBlob";
* From the frontend:
* - support http resources with absolute or relative URLs
*/
-export async function createBlob(url: URL, opts?: { fetch?: typeof fetch }): Promise {
+export async function createBlob(url: URL, opts?: { fetch?: typeof fetch, requestInit?: RequestInit }): Promise {
if (url.protocol === "http:" || url.protocol === "https:") {
return WebBlob.create(url, { fetch: opts?.fetch });
}
diff --git a/src/huggingface/hub/src/utils/eventToGenerator.spec.ts b/src/huggingface/hub/src/utils/eventToGenerator.spec.ts
deleted file mode 100644
index 59ed182a4a2394f6662afeee1d4f42fe4692e0b5..0000000000000000000000000000000000000000
--- a/src/huggingface/hub/src/utils/eventToGenerator.spec.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { eventToGenerator } from "./eventToGenerator";
-
-describe("eventToGenerator", () => {
- it("should handle synchronous events", async () => {
- const it = eventToGenerator((yieldCallback, returnCallback) => {
- yieldCallback(1);
- yieldCallback(2);
- returnCallback(3);
- });
-
- const results = [];
- let res: IteratorResult;
- do {
- res = await it.next();
- if (!res.done) {
- results.push(res.value);
- }
- } while (!res.done);
-
- expect(results).toEqual([1, 2]);
- expect(res.value).toBe(3);
- });
-
- it("should handle asynchronous events", async () => {
- const it = eventToGenerator((yieldCallback, returnCallback) => {
- setTimeout(() => yieldCallback(1), 100);
- setTimeout(() => yieldCallback(2), 200);
- setTimeout(() => returnCallback(3), 300);
- });
-
- const results = [];
- let res: IteratorResult;
- do {
- res = await it.next();
- if (!res.done) {
- results.push(res.value);
- }
- } while (!res.done);
-
- expect(results).toEqual([1, 2]);
- expect(res.value).toBe(3);
- });
-});
diff --git a/src/huggingface/hub/src/utils/promisesQueue.spec.ts b/src/huggingface/hub/src/utils/promisesQueue.spec.ts
deleted file mode 100644
index 3e9ea6d124ff5cc290eb6aefffa4ce8148b3c46a..0000000000000000000000000000000000000000
--- a/src/huggingface/hub/src/utils/promisesQueue.spec.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { promisesQueue } from "./promisesQueue";
-
-describe("promisesQueue", () => {
- it("should handle multiple errors without triggering an uncaughtException", async () => {
- const factories = [
- () => Promise.reject(new Error("error 1")),
- () => Promise.reject(new Error("error 2")),
- () => Promise.reject(new Error("error 3")),
- ];
-
- try {
- await promisesQueue(factories, 10);
- } catch (err) {
- if (!(err instanceof Error)) {
- throw err;
- }
- }
-
- try {
- await promisesQueue(factories, 1);
- } catch (err) {
- if (!(err instanceof Error)) {
- throw err;
- }
- expect(err.message).toBe("error 1");
- }
- });
-
- it("should return ordered results", async () => {
- const factories = [
- () => Promise.resolve(1),
- () => Promise.resolve(2),
- () => Promise.resolve(3),
- () => Promise.resolve(4),
- () => Promise.resolve(5),
- () => Promise.resolve(6),
- () => Promise.resolve(7),
- () => Promise.resolve(8),
- () => Promise.resolve(9),
- () => Promise.resolve(10),
- ];
-
- const results = await promisesQueue(factories, 3);
-
- expect(results).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
- });
-});
diff --git a/src/huggingface/hub/src/utils/sha256.ts b/src/huggingface/hub/src/utils/sha256.ts
index d531955b95b46d278eee4190e0871d0a4e8dd2c2..dd8fc5d2b5697b3e153e334529a4a09637d0c28f 100644
--- a/src/huggingface/hub/src/utils/sha256.ts
+++ b/src/huggingface/hub/src/utils/sha256.ts
@@ -1,4 +1,4 @@
-import { isFrontend } from "../../../shared";
+import { isFrontend } from "../../../shared/index";
import { eventToGenerator } from "./eventToGenerator";
import { hexFromBytes } from "./hexFromBytes";
diff --git a/src/types.ts b/src/types.ts
index e241f20b91252c7bd308f1d28f89ff37de125083..73912247e9b2df740354f5de35181bd4d996d93f 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,4 +1,3 @@
-import { VideoCategory } from "./app/state/categories"
export type ProjectionMode = 'cartesian' | 'spherical'
@@ -181,31 +180,164 @@ export type VideoOptions = {
export type ChannelInfo = {
/**
* We actually use the dataset ID for the channel ID.
+ *
*/
id: string
/**
* The name used in the URL for the channel
+ *
+ * eg: my-time-travel-journeys
*/
slug: string
+
+ /**
+ * username slug of the Hugging Face dataset
+ *
+ * eg: jbilcke-hf
+ */
+ datasetUser: string
+
+ /**
+ * dataset slug of the Hugging Face dataset
+ *
+ * eg: ai-tube-my-time-travel-journeys
+ */
+ datasetName: string
+
label: string
+
+ description: string
+
thumbnail: string
+
+ /**
+ * The system prompt
+ */
prompt: string
+
likes: number
+
+ tags: string[]
+
+ updatedAt: string
+}
+
+export type VideoStatus =
+ | "submitted" // the prompt has been submitted, but is not added to the index queue yet
+ | "queued" // the prompt has been added to the index queue, but is not processed yet. Once queued it cannot be modified.
+ | "generating" // the video is being generated
+ | "published" // success!
+ | "error" // video failed to generate
+
+/**
+ * A video request, made by a user or robot on a channel
+ */
+export type VideoRequest = {
+ /**
+ * UUID (v4)
+ */
+ id: string
+
+ /**
+ * Human readable title for the video
+ */
+ label: string
+
+ /**
+ * Human readable description for the video
+ */
+ description: string
+
+ /**
+ * Video prompt
+ */
+ prompt: string
+
+ /**
+ * URL to the video thumbnail
+ */
+ thumbnailUrl: string
+
+ /**
+ * When was the video updated
+ */
+ updatedAt: string
+
+ /**
+ * Arbotrary string tags to label the content
+ */
+ tags: string[]
+
+ /**
+ * ID of the channel
+ */
+ channel: ChannelInfo
}
export type VideoInfo = {
+ /**
+ * UUID (v4)
+ */
id: string
+
+ /**
+ * Status of the video
+ */
+ status: VideoStatus
+
+ /**
+ * Human readable title for the video
+ */
label: string
+
+ /**
+ * Human readable description for the video
+ */
+ description: string
+
+ /**
+ * Video prompt
+ */
+ prompt: string
+
+ /**
+ * URL to the video thumbnail
+ */
thumbnailUrl: string
+
+ /**
+ * URL to the binary file
+ */
assetUrl: string
+
+ /**
+ * Counter for the number of views
+ *
+ * Note: should be managed by the index to prevent cheating
+ */
numberOfViews: number
- createdAt: string
- categories: VideoCategory[]
- channelId: string
-}
-export type FullVideoInfo = VideoInfo & {
+ /**
+ * Counter for the number of likes
+ *
+ * Note: should be managed by the index to prevent cheating
+ */
+ numberOfLikes: number
+
+ /**
+ * When was the video updated
+ */
+ updatedAt: string
+
+ /**
+ * Arbotrary string tags to label the content
+ */
+ tags: string[]
+
+ /**
+ * The channel
+ */
channel: ChannelInfo
}
@@ -215,12 +347,49 @@ export type InterfaceDisplayMode =
export type InterfaceView =
| "home"
- | "channels_admin"
- | "channels_public"
- | "channel_admin" // for a user to admin their channels
- | "channel_public" // public view of a channel
- | "video_public" // public view of a video
-
- export type Settings = {
- huggingfaceApiKey: string
- }
\ No newline at end of file
+ | "user_channels"
+ | "user_channel" // for a user to admin their channels
+ | "user_videos"
+ | "user_video"
+ | "user_account"
+ | "public_channels"
+ | "public_channel" // public view of a channel
+ | "public_video" // public view of a video
+
+export type Settings = {
+ huggingfaceApiKey: string
+}
+
+export type ParsedDatasetReadme = {
+ license: string
+ pretty_name: string
+ tags: string[]
+ description: string
+ prompt: string
+}
+
+export type ParsedMetadataAndContent = {
+ metadata: {
+ license: string,
+ pretty_name: string,
+ tags: string[]
+ }
+ content: string
+}
+
+export type ParsedDatasetPrompt = {
+ title: string
+ description: string
+ prompt: string
+}
+
+
+export type UpdateQueueRequest = {
+ channel?: ChannelInfo
+ apiKey: string
+}
+
+export type UpdateQueueResponse = {
+ error?: string
+ nbUpdated: number
+}
\ No newline at end of file