Add AI-powered description generation for video content
Integrates OpenAI API to generate video descriptions using AI, with new API endpoints for single and bulk generation, and a UI button in the admin panel to trigger the process. Includes OpenAI dependency and a new `aiService.ts` file. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 170e18f0-0f13-4eca-8643-546bba1dd8cc Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/170e18f0-0f13-4eca-8643-546bba1dd8cc/td5Y4HG
This commit is contained in:
parent
a9d7322fa8
commit
475534134c
1
.replit
1
.replit
@ -40,3 +40,4 @@ args = "npm run dev"
|
||||
waitForPort = 5000
|
||||
|
||||
[agent]
|
||||
integrations = ["javascript_openai==1.0.0"]
|
||||
|
||||
@ -13,7 +13,7 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { LoadingSpinner } from "@/components/loading-spinner";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { Video } from "@shared/schema";
|
||||
import { Shield, Edit, Upload, Search, Filter, Save, X } from "lucide-react";
|
||||
import { Shield, Edit, Upload, Search, Filter, Save, X, Sparkles, Loader2 } from "lucide-react";
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, isLoading: authLoading, isAuthenticated, isAdmin } = useAuth();
|
||||
@ -216,6 +216,7 @@ function EditVideoDialog({
|
||||
genre: video.genre,
|
||||
customThumbnailUrl: video.customThumbnailUrl || "",
|
||||
});
|
||||
const [isGeneratingAI, setIsGeneratingAI] = useState(false);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest("PATCH", `/api/admin/videos/${video.id}`, data),
|
||||
@ -240,6 +241,42 @@ function EditVideoDialog({
|
||||
updateMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const generateAIDescription = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please enter a title first",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAI(true);
|
||||
try {
|
||||
const response = await apiRequest("POST", `/api/admin/videos/${video.id}/generate-description`, {
|
||||
maxCharacters: 500,
|
||||
includeArtistInfo: true,
|
||||
includeLabelInfo: true
|
||||
});
|
||||
|
||||
if (response.description) {
|
||||
setFormData(prev => ({ ...prev, description: response.description }));
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `AI description generated (${response.characterCount}/${response.maxCharacters} characters)`,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to generate AI description",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsGeneratingAI(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl bg-[#2D1B69] border-white/20 text-white">
|
||||
@ -263,13 +300,34 @@ function EditVideoDialog({
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-white/90">Description</Label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-white/90">Description</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={generateAIDescription}
|
||||
disabled={isGeneratingAI || !formData.title.trim()}
|
||||
className="border-white/20 text-white hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
{isGeneratingAI ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isGeneratingAI ? "Generating..." : "Generate AI Description"}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="bg-white/10 border-white/20 text-white min-h-[100px]"
|
||||
rows={3}
|
||||
placeholder="Enter description or use AI to generate one from the title..."
|
||||
/>
|
||||
<div className="text-xs text-white/60 mt-1">
|
||||
{formData.description.length}/500 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@ -80,6 +80,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^2.0.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"openai": "^5.16.0",
|
||||
"openid-client": "^6.7.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@ -9481,6 +9482,27 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "5.16.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-5.16.0.tgz",
|
||||
"integrity": "sha512-hoEH8ZNvg1HXjU9mp88L/ZH8O082Z8r6FHCXGiWAzVRrEv443aI57qhch4snu07yQydj+AUAWLenAiBXhu89Tw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.7.1.tgz",
|
||||
|
||||
@ -82,6 +82,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^2.0.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"openai": "^5.16.0",
|
||||
"openid-client": "^6.7.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
|
||||
101
server/aiService.ts
Normal file
101
server/aiService.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import OpenAI from "openai";
|
||||
|
||||
// the newest OpenAI model is "gpt-5" which was released August 7, 2025. do not change this unless explicitly requested by the user
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
export interface DescriptionGenerationOptions {
|
||||
maxCharacters?: number;
|
||||
language?: string;
|
||||
includeArtistInfo?: boolean;
|
||||
includeLabelInfo?: boolean;
|
||||
}
|
||||
|
||||
export async function generateVideoDescription(
|
||||
title: string,
|
||||
options: DescriptionGenerationOptions = {}
|
||||
): Promise<string> {
|
||||
const {
|
||||
maxCharacters = 500,
|
||||
language = "slovenian",
|
||||
includeArtistInfo = true,
|
||||
includeLabelInfo = true
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const prompt = `Analiziraj naslov videoisranja: "${title}"
|
||||
|
||||
Nalogo izvršit v slovenskem jeziku.
|
||||
|
||||
Iz naslova izvleci:
|
||||
- Ime izvajalca/umetnika
|
||||
- Naslov skladbe/komada
|
||||
- Tip vsebine (pesem, instrumental, live nastop, itd.)
|
||||
|
||||
Ustvari informativen opis, ki vključuje:
|
||||
${includeArtistInfo ? '- Informacije o izvajalcu (stil glasbe, kratka zgodovina, znani komadi)' : ''}
|
||||
${includeLabelInfo ? '- Informacije o založbi ali labelu, če je znan' : ''}
|
||||
- Opis stila glasbe in žanra
|
||||
- Kratko ozadje o komadu, če je znan
|
||||
- Čemu je namenjen (ples, poslušanje, koncert, itd.)
|
||||
|
||||
Opis naj bo dolg maksimalno ${maxCharacters} znakov.
|
||||
Opis naj bo napisan v prijaznem, informativnem tonu.
|
||||
Ne uporabljaj besed "video" ali "posnetek" - piši o glasbi sami.
|
||||
|
||||
Odgovori samo z opisom, brez dodatnih pojasnil.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-5", // the newest OpenAI model is "gpt-5" which was released August 7, 2025
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "Si strokovnjak za glasbo in pomagaš ustvarjati kakovostne opise za glasbene vsebine. Odgovarjaš vedno v slovenskem jeziku."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
max_tokens: Math.ceil(maxCharacters / 2), // Rough estimate: 2 characters per token
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const description = response.choices[0].message.content?.trim() || "";
|
||||
|
||||
// Ensure we don't exceed character limit
|
||||
if (description.length > maxCharacters) {
|
||||
return description.substring(0, maxCharacters - 3) + "...";
|
||||
}
|
||||
|
||||
return description;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating video description:", error);
|
||||
throw new Error("Napaka pri generiranju opisa s strani AI");
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateBulkDescriptions(
|
||||
videos: Array<{ id: string; title: string }>,
|
||||
options: DescriptionGenerationOptions = {}
|
||||
): Promise<Array<{ id: string; description: string; error?: string }>> {
|
||||
const results = [];
|
||||
|
||||
for (const video of videos) {
|
||||
try {
|
||||
const description = await generateVideoDescription(video.title, options);
|
||||
results.push({ id: video.id, description });
|
||||
|
||||
// Add small delay to respect API rate limits
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
results.push({
|
||||
id: video.id,
|
||||
description: "",
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@ -18,6 +18,7 @@ import sharp from "sharp";
|
||||
import fetch from "node-fetch";
|
||||
import { setupAuth, isAuthenticated, isAdmin } from "./replitAuth";
|
||||
import { ObjectStorageService, ObjectNotFoundError } from "./objectStorage";
|
||||
import { generateVideoDescription, generateBulkDescriptions } from "./aiService";
|
||||
|
||||
// Extend express session
|
||||
declare module "express-session" {
|
||||
@ -1080,6 +1081,88 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== AI DESCRIPTION ROUTES =====
|
||||
|
||||
// Generate AI description for single video (admin only)
|
||||
app.post('/api/admin/videos/:id/generate-description', isAdmin, async (req, res) => {
|
||||
try {
|
||||
const videoId = req.params.id;
|
||||
const { maxCharacters = 500, includeArtistInfo = true, includeLabelInfo = true } = req.body;
|
||||
|
||||
// Get video details
|
||||
const video = await storage.getVideo(videoId);
|
||||
if (!video) {
|
||||
return res.status(404).json({ message: "Video not found" });
|
||||
}
|
||||
|
||||
// Generate description using AI
|
||||
const description = await generateVideoDescription(video.title, {
|
||||
maxCharacters,
|
||||
language: "slovenian",
|
||||
includeArtistInfo,
|
||||
includeLabelInfo
|
||||
});
|
||||
|
||||
res.json({
|
||||
description,
|
||||
title: video.title,
|
||||
characterCount: description.length,
|
||||
maxCharacters
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating AI description:", error);
|
||||
res.status(500).json({
|
||||
message: error instanceof Error ? error.message : "Failed to generate description"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Generate AI descriptions for multiple videos (admin only)
|
||||
app.post('/api/admin/videos/generate-descriptions-bulk', isAdmin, async (req, res) => {
|
||||
try {
|
||||
const { videoIds, maxCharacters = 500, includeArtistInfo = true, includeLabelInfo = true } = req.body;
|
||||
|
||||
if (!Array.isArray(videoIds) || videoIds.length === 0) {
|
||||
return res.status(400).json({ message: "Video IDs array is required" });
|
||||
}
|
||||
|
||||
if (videoIds.length > 50) {
|
||||
return res.status(400).json({ message: "Maximum 50 videos can be processed at once" });
|
||||
}
|
||||
|
||||
// Get video details for all requested videos
|
||||
const videos = [];
|
||||
for (const id of videoIds) {
|
||||
const video = await storage.getVideo(id);
|
||||
if (video) {
|
||||
videos.push({ id: video.id, title: video.title });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate descriptions using AI
|
||||
const results = await generateBulkDescriptions(videos, {
|
||||
maxCharacters,
|
||||
language: "slovenian",
|
||||
includeArtistInfo,
|
||||
includeLabelInfo
|
||||
});
|
||||
|
||||
res.json({
|
||||
results,
|
||||
processed: results.length,
|
||||
successful: results.filter(r => !r.error).length,
|
||||
failed: results.filter(r => r.error).length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating bulk AI descriptions:", error);
|
||||
res.status(500).json({
|
||||
message: error instanceof Error ? error.message : "Failed to generate descriptions"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user