Spaces:
Running
Running
Commit
•
f70dd7e
1
Parent(s):
d797bbb
added a view counter
Browse files- .env +4 -0
- src/app/config.ts +5 -1
- src/app/interface/video-card/index.tsx +1 -1
- src/app/server/actions/ai-tube-hf/extendVideosWithStats.ts +14 -0
- src/app/server/actions/ai-tube-hf/getChannelVideos.ts +8 -1
- src/app/server/actions/ai-tube-hf/getVideo.ts +3 -0
- src/app/server/actions/ai-tube-hf/getVideos.ts +8 -1
- src/app/server/actions/config.ts +7 -0
- src/app/server/actions/stats.ts +68 -0
- src/app/views/public-video-view/index.tsx +20 -2
- src/lib/getChannelRating.ts +12 -0
.env
CHANGED
@@ -1,6 +1,8 @@
|
|
1 |
|
2 |
NEXT_PUBLIC_SHOW_BETA_FEATURES="false"
|
3 |
|
|
|
|
|
4 |
ADMIN_HUGGING_FACE_API_TOKEN=""
|
5 |
ADMIN_HUGGING_FACE_USERNAME=""
|
6 |
|
@@ -9,6 +11,8 @@ AI_TUBE_ROBOT_API="https://jbilcke-hf-ai-tube-robot.hf.space"
|
|
9 |
UPSTASH_REDIS_REST_URL=""
|
10 |
UPSTASH_REDIS_REST_TOKEN=""
|
11 |
|
|
|
|
|
12 |
# ----------- CENSORSHIP -------
|
13 |
ENABLE_CENSORSHIP=
|
14 |
FINGERPRINT_KEY=
|
|
|
1 |
|
2 |
NEXT_PUBLIC_SHOW_BETA_FEATURES="false"
|
3 |
|
4 |
+
NEXT_PUBLIC_DEVELOPER_MODE="false"
|
5 |
+
|
6 |
ADMIN_HUGGING_FACE_API_TOKEN=""
|
7 |
ADMIN_HUGGING_FACE_USERNAME=""
|
8 |
|
|
|
11 |
UPSTASH_REDIS_REST_URL=""
|
12 |
UPSTASH_REDIS_REST_TOKEN=""
|
13 |
|
14 |
+
WINNERS=""
|
15 |
+
x
|
16 |
# ----------- CENSORSHIP -------
|
17 |
ENABLE_CENSORSHIP=
|
18 |
FINGERPRINT_KEY=
|
src/app/config.ts
CHANGED
@@ -4,4 +4,8 @@ export const showBetaFeatures = `${
|
|
4 |
|
5 |
|
6 |
export const defaultVideoModel = "SVD"
|
7 |
-
export const defaultVoice = "Julian"
|
|
|
|
|
|
|
|
|
|
4 |
|
5 |
|
6 |
export const defaultVideoModel = "SVD"
|
7 |
+
export const defaultVoice = "Julian"
|
8 |
+
|
9 |
+
export const developerMode = `${
|
10 |
+
process.env.NEXT_PUBLIC_DEVELOPER_MODE || ""
|
11 |
+
}`.trim().toLowerCase() === "true"
|
src/app/interface/video-card/index.tsx
CHANGED
@@ -213,7 +213,7 @@ export function VideoCard({
|
|
213 |
isCompact ? `text-2xs lg:text-xs` : `text-sm`,
|
214 |
`space-x-1`
|
215 |
)}>
|
216 |
-
<div>
|
217 |
<div className="font-semibold scale-125">·</div>
|
218 |
<div>{formatTimeAgo(video.updatedAt)}</div>
|
219 |
</div>
|
|
|
213 |
isCompact ? `text-2xs lg:text-xs` : `text-sm`,
|
214 |
`space-x-1`
|
215 |
)}>
|
216 |
+
<div>{video.numberOfViews} views</div>
|
217 |
<div className="font-semibold scale-125">·</div>
|
218 |
<div>{formatTimeAgo(video.updatedAt)}</div>
|
219 |
</div>
|
src/app/server/actions/ai-tube-hf/extendVideosWithStats.ts
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use server"
|
2 |
+
|
3 |
+
import { VideoInfo } from "@/types"
|
4 |
+
import { getNumberOfViewsForVideos } from "../stats"
|
5 |
+
|
6 |
+
export async function extendVideosWithStats(videos: VideoInfo[]): Promise<VideoInfo[]> {
|
7 |
+
|
8 |
+
const stats = await getNumberOfViewsForVideos(videos.map(v => v.id))
|
9 |
+
|
10 |
+
return videos.map(v => {
|
11 |
+
v.numberOfViews = stats[v.id] || 0
|
12 |
+
return v
|
13 |
+
})
|
14 |
+
}
|
src/app/server/actions/ai-tube-hf/getChannelVideos.ts
CHANGED
@@ -5,6 +5,7 @@ import { ChannelInfo, VideoInfo, VideoStatus } from "@/types"
|
|
5 |
import { getVideoRequestsFromChannel } from "./getVideoRequestsFromChannel"
|
6 |
import { adminApiKey } from "../config"
|
7 |
import { getVideoIndex } from "./getVideoIndex"
|
|
|
8 |
|
9 |
// return
|
10 |
export async function getChannelVideos({
|
@@ -28,7 +29,7 @@ export async function getChannelVideos({
|
|
28 |
const queued = await getVideoIndex({ status: "queued" })
|
29 |
const published = await getVideoIndex({ status: "published" })
|
30 |
|
31 |
-
|
32 |
let video: VideoInfo = {
|
33 |
id: v.id,
|
34 |
status: "submitted",
|
@@ -57,10 +58,16 @@ export async function getChannelVideos({
|
|
57 |
|
58 |
return video
|
59 |
}).filter(video => {
|
|
|
60 |
if (!status || typeof status === "undefined") {
|
61 |
return true
|
62 |
}
|
63 |
|
64 |
return video.status === status
|
65 |
})
|
|
|
|
|
|
|
|
|
|
|
66 |
}
|
|
|
5 |
import { getVideoRequestsFromChannel } from "./getVideoRequestsFromChannel"
|
6 |
import { adminApiKey } from "../config"
|
7 |
import { getVideoIndex } from "./getVideoIndex"
|
8 |
+
import { extendVideosWithStats } from "./extendVideosWithStats"
|
9 |
|
10 |
// return
|
11 |
export async function getChannelVideos({
|
|
|
29 |
const queued = await getVideoIndex({ status: "queued" })
|
30 |
const published = await getVideoIndex({ status: "published" })
|
31 |
|
32 |
+
const validVideos = videos.map(v => {
|
33 |
let video: VideoInfo = {
|
34 |
id: v.id,
|
35 |
status: "submitted",
|
|
|
58 |
|
59 |
return video
|
60 |
}).filter(video => {
|
61 |
+
// if no filter is requested, we always return the video
|
62 |
if (!status || typeof status === "undefined") {
|
63 |
return true
|
64 |
}
|
65 |
|
66 |
return video.status === status
|
67 |
})
|
68 |
+
|
69 |
+
// ask Redis for the freshest stats
|
70 |
+
const results = await extendVideosWithStats(validVideos)
|
71 |
+
|
72 |
+
return results
|
73 |
}
|
src/app/server/actions/ai-tube-hf/getVideo.ts
CHANGED
@@ -3,6 +3,7 @@
|
|
3 |
import { VideoInfo } from "@/types"
|
4 |
|
5 |
import { getVideoIndex } from "./getVideoIndex"
|
|
|
6 |
|
7 |
export async function getVideo({
|
8 |
videoId,
|
@@ -25,6 +26,8 @@ export async function getVideo({
|
|
25 |
throw new Error(`cannot get the video, nothing found for id "${id}"`)
|
26 |
}
|
27 |
|
|
|
|
|
28 |
return video
|
29 |
} catch (err) {
|
30 |
if (neverThrow) {
|
|
|
3 |
import { VideoInfo } from "@/types"
|
4 |
|
5 |
import { getVideoIndex } from "./getVideoIndex"
|
6 |
+
import { getNumberOfViewsForVideo } from "../stats"
|
7 |
|
8 |
export async function getVideo({
|
9 |
videoId,
|
|
|
26 |
throw new Error(`cannot get the video, nothing found for id "${id}"`)
|
27 |
}
|
28 |
|
29 |
+
video.numberOfViews = await getNumberOfViewsForVideo(video.id)
|
30 |
+
|
31 |
return video
|
32 |
} catch (err) {
|
33 |
if (neverThrow) {
|
src/app/server/actions/ai-tube-hf/getVideos.ts
CHANGED
@@ -3,6 +3,7 @@
|
|
3 |
import { VideoInfo } from "@/types"
|
4 |
|
5 |
import { getVideoIndex } from "./getVideoIndex"
|
|
|
6 |
|
7 |
const HARD_LIMIT = 100
|
8 |
|
@@ -90,5 +91,11 @@ export async function getVideos({
|
|
90 |
|
91 |
|
92 |
// we enforce the max limit of HARD_LIMIT (eg. 100)
|
93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
94 |
}
|
|
|
3 |
import { VideoInfo } from "@/types"
|
4 |
|
5 |
import { getVideoIndex } from "./getVideoIndex"
|
6 |
+
import { extendVideosWithStats } from "./extendVideosWithStats"
|
7 |
|
8 |
const HARD_LIMIT = 100
|
9 |
|
|
|
91 |
|
92 |
|
93 |
// we enforce the max limit of HARD_LIMIT (eg. 100)
|
94 |
+
const cappedVideos = videosMatchingFilters.slice(0, Math.min(HARD_LIMIT, maxVideos))
|
95 |
+
|
96 |
+
|
97 |
+
// finally, we ask Redis for the freshest stats
|
98 |
+
const videosWithStats = await extendVideosWithStats(cappedVideos)
|
99 |
+
|
100 |
+
return videosWithStats
|
101 |
}
|
src/app/server/actions/config.ts
CHANGED
@@ -7,3 +7,10 @@ export const adminUsername = `${process.env.ADMIN_HUGGING_FACE_USERNAME || ""}`
|
|
7 |
export const adminCredentials: Credentials = { accessToken: adminApiKey }
|
8 |
|
9 |
export const aiTubeRobotApi = `${process.env.AI_TUBE_ROBOT_API || ""}`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
export const adminCredentials: Credentials = { accessToken: adminApiKey }
|
8 |
|
9 |
export const aiTubeRobotApi = `${process.env.AI_TUBE_ROBOT_API || ""}`
|
10 |
+
|
11 |
+
export const redisUrl = `${process.env.UPSTASH_REDIS_REST_URL || ""}`
|
12 |
+
export const redisToken = `${process.env.UPSTASH_REDIS_REST_TOKEN || ""}`
|
13 |
+
|
14 |
+
export const developerMode = `${
|
15 |
+
process.env.NEXT_PUBLIC_DEVELOPER_MODE || ""
|
16 |
+
}`.trim().toLowerCase() === "true"
|
src/app/server/actions/stats.ts
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use server"
|
2 |
+
|
3 |
+
import { Redis } from "@upstash/redis"
|
4 |
+
|
5 |
+
import { redisToken, redisUrl } from "./config"
|
6 |
+
import { developerMode } from "@/app/config"
|
7 |
+
|
8 |
+
const redis = new Redis({
|
9 |
+
url: redisUrl,
|
10 |
+
token: redisToken
|
11 |
+
})
|
12 |
+
|
13 |
+
export async function getNumberOfViewsForVideos(videoIds: string[]): Promise<Record<string, number>> {
|
14 |
+
if (!Array.isArray(videoIds)) {
|
15 |
+
return {}
|
16 |
+
}
|
17 |
+
|
18 |
+
try {
|
19 |
+
|
20 |
+
const stats: Record<string, number> = {}
|
21 |
+
|
22 |
+
const ids: string[] = []
|
23 |
+
|
24 |
+
for (const videoId of videoIds) {
|
25 |
+
ids.push(`videos:${videoId}:stats:views`)
|
26 |
+
stats[videoId] = 0
|
27 |
+
}
|
28 |
+
|
29 |
+
|
30 |
+
const values = await redis.mget<number[]>(...ids)
|
31 |
+
|
32 |
+
values.forEach((nbViews, i) => {
|
33 |
+
const videoId = ids[i]
|
34 |
+
stats[videoId] = nbViews || 0
|
35 |
+
})
|
36 |
+
|
37 |
+
return stats
|
38 |
+
} catch (err) {
|
39 |
+
return {}
|
40 |
+
}
|
41 |
+
}
|
42 |
+
|
43 |
+
export async function getNumberOfViewsForVideo(videoId: string): Promise<number> {
|
44 |
+
try {
|
45 |
+
const key = `videos:${videoId}:stats`
|
46 |
+
|
47 |
+
const result = await redis.get<number>(key) || 0
|
48 |
+
|
49 |
+
return result
|
50 |
+
} catch (err) {
|
51 |
+
return 0
|
52 |
+
}
|
53 |
+
}
|
54 |
+
|
55 |
+
|
56 |
+
export async function watchVideo(videoId: string): Promise<number> {
|
57 |
+
if (developerMode) {
|
58 |
+
return getNumberOfViewsForVideo(videoId)
|
59 |
+
}
|
60 |
+
|
61 |
+
try {
|
62 |
+
const result = await redis.incr(`videos:${videoId}:stats:views`)
|
63 |
+
|
64 |
+
return result
|
65 |
+
} catch (err) {
|
66 |
+
return 0
|
67 |
+
}
|
68 |
+
}
|
src/app/views/public-video-view/index.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
"use client"
|
2 |
|
3 |
-
import { useEffect, useState } from "react"
|
4 |
import dynamic from "next/dynamic"
|
5 |
import { RiCheckboxCircleFill } from "react-icons/ri"
|
6 |
import { PiShareFatLight } from "react-icons/pi"
|
@@ -16,12 +16,15 @@ import { VideoInfo } from "@/types"
|
|
16 |
import { ActionButton, actionButtonClassName } from "@/app/interface/action-button"
|
17 |
import { RecommendedVideos } from "@/app/interface/recommended-videos"
|
18 |
import { isCertifiedUser } from "@/app/certification"
|
|
|
|
|
19 |
|
20 |
const DefaultAvatar = dynamic(() => import("../../interface/default-avatar"), {
|
21 |
loading: () => null,
|
22 |
})
|
23 |
|
24 |
export function PublicVideoView() {
|
|
|
25 |
const video = useStore(s => s.publicVideo)
|
26 |
|
27 |
const videoId = `${video?.id || ""}`
|
@@ -67,6 +70,17 @@ export function PublicVideoView() {
|
|
67 |
}
|
68 |
}
|
69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
70 |
if (!video) { return null }
|
71 |
|
72 |
return (
|
@@ -244,8 +258,12 @@ export function PublicVideoView() {
|
|
244 |
`transition-all duration-200 ease-in-out`,
|
245 |
`rounded-xl`,
|
246 |
`bg-neutral-700/50`,
|
247 |
-
`text-sm`,
|
248 |
)}>
|
|
|
|
|
|
|
|
|
249 |
<p>{video.description}</p>
|
250 |
</div>
|
251 |
</div>
|
|
|
1 |
"use client"
|
2 |
|
3 |
+
import { useEffect, useState, useTransition } from "react"
|
4 |
import dynamic from "next/dynamic"
|
5 |
import { RiCheckboxCircleFill } from "react-icons/ri"
|
6 |
import { PiShareFatLight } from "react-icons/pi"
|
|
|
16 |
import { ActionButton, actionButtonClassName } from "@/app/interface/action-button"
|
17 |
import { RecommendedVideos } from "@/app/interface/recommended-videos"
|
18 |
import { isCertifiedUser } from "@/app/certification"
|
19 |
+
import { watchVideo } from "@/app/server/actions/stats"
|
20 |
+
import { formatTimeAgo } from "@/lib/formatTimeAgo"
|
21 |
|
22 |
const DefaultAvatar = dynamic(() => import("../../interface/default-avatar"), {
|
23 |
loading: () => null,
|
24 |
})
|
25 |
|
26 |
export function PublicVideoView() {
|
27 |
+
const [_pending, startTransition] = useTransition()
|
28 |
const video = useStore(s => s.publicVideo)
|
29 |
|
30 |
const videoId = `${video?.id || ""}`
|
|
|
70 |
}
|
71 |
}
|
72 |
|
73 |
+
useEffect(() => {
|
74 |
+
if (!videoId) {
|
75 |
+
return
|
76 |
+
}
|
77 |
+
|
78 |
+
startTransition(async () => {
|
79 |
+
await watchVideo(videoId)
|
80 |
+
})
|
81 |
+
|
82 |
+
}, [videoId])
|
83 |
+
|
84 |
if (!video) { return null }
|
85 |
|
86 |
return (
|
|
|
258 |
`transition-all duration-200 ease-in-out`,
|
259 |
`rounded-xl`,
|
260 |
`bg-neutral-700/50`,
|
261 |
+
`text-sm text-zinc-100`,
|
262 |
)}>
|
263 |
+
<div className="flex flex-row space-x-2 font-medium mb-1">
|
264 |
+
<div>{video.numberOfViews} views</div>
|
265 |
+
<div>{formatTimeAgo(video.updatedAt).replace("about ", "")}</div>
|
266 |
+
</div>
|
267 |
<p>{video.description}</p>
|
268 |
</div>
|
269 |
</div>
|
src/lib/getChannelRating.ts
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ChannelInfo } from "@/types"
|
2 |
+
|
3 |
+
const winners = new Set(`${process.env.WINNERS || ""}`.toLowerCase().split(",").map(x => x.trim()).filter(x => x))
|
4 |
+
|
5 |
+
// TODO: replace by a better algorithm
|
6 |
+
export function getChannelRating(channel: ChannelInfo) {
|
7 |
+
if (winners.has(channel.datasetUser.toLowerCase())) { return 0 }
|
8 |
+
|
9 |
+
// TODO check views statistics to determine clusters
|
10 |
+
|
11 |
+
return 5
|
12 |
+
}
|