Spaces:
Running
Running
Commit
·
0ad6b1b
1
Parent(s):
febf860
Fix global search
Browse files- app/main.py +35 -2
- app/models/video.py +2 -1
- app/services/video_service.py +3 -1
- app/static/css/style.css +48 -0
- app/static/js/index.js +204 -0
- app/static/js/main.js +157 -3
- app/static/js/video.js +158 -4
- app/templates/base.html +20 -0
- app/templates/video.html +1 -1
- gunicorn.conf.py +2 -2
app/main.py
CHANGED
@@ -34,6 +34,7 @@ def https_url_for(context: dict, name: str, **path_params: Any) -> URL:
|
|
34 |
url: URL = request.url_for(name, **path_params)
|
35 |
return url.replace(scheme="https")
|
36 |
|
|
|
37 |
templates.env.globals["https_url_for"] = https_url_for
|
38 |
|
39 |
|
@@ -54,16 +55,48 @@ async def video_page(request: Request, video_id: str):
|
|
54 |
if video and video.title:
|
55 |
title = video.title
|
56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
57 |
return templates.TemplateResponse(
|
58 |
"video.html",
|
59 |
-
{
|
|
|
|
|
|
|
|
|
|
|
60 |
)
|
61 |
|
62 |
|
63 |
@app.get("/watch")
|
64 |
async def watch_redirect(request: Request, v: str):
|
65 |
# Redirect YouTube-style URLs to our video page
|
66 |
-
|
|
|
|
|
|
|
|
|
67 |
|
68 |
|
69 |
# Include API routers
|
|
|
34 |
url: URL = request.url_for(name, **path_params)
|
35 |
return url.replace(scheme="https")
|
36 |
|
37 |
+
|
38 |
templates.env.globals["https_url_for"] = https_url_for
|
39 |
|
40 |
|
|
|
55 |
if video and video.title:
|
56 |
title = video.title
|
57 |
|
58 |
+
# Get the start time from query parameters if available
|
59 |
+
start_time = 0
|
60 |
+
if "t" in request.query_params:
|
61 |
+
try:
|
62 |
+
# Try to parse the t parameter as seconds
|
63 |
+
t_param = request.query_params.get("t")
|
64 |
+
# First try as a float (seconds)
|
65 |
+
start_time = int(float(t_param))
|
66 |
+
except (ValueError, TypeError):
|
67 |
+
# If that fails, try parsing as MM:SS or HH:MM:SS format
|
68 |
+
try:
|
69 |
+
if ":" in t_param:
|
70 |
+
parts = t_param.split(":")
|
71 |
+
if len(parts) == 2: # MM:SS
|
72 |
+
start_time = int(parts[0]) * 60 + int(parts[1])
|
73 |
+
elif len(parts) == 3: # HH:MM:SS
|
74 |
+
start_time = (
|
75 |
+
int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
|
76 |
+
)
|
77 |
+
except (ValueError, TypeError, IndexError):
|
78 |
+
# If all parsing fails, default to 0
|
79 |
+
start_time = 0
|
80 |
+
|
81 |
return templates.TemplateResponse(
|
82 |
"video.html",
|
83 |
+
{
|
84 |
+
"request": request,
|
85 |
+
"title": title,
|
86 |
+
"video_id": video_id,
|
87 |
+
"start_time": start_time,
|
88 |
+
},
|
89 |
)
|
90 |
|
91 |
|
92 |
@app.get("/watch")
|
93 |
async def watch_redirect(request: Request, v: str):
|
94 |
# Redirect YouTube-style URLs to our video page
|
95 |
+
# If there's a t parameter, include it in the redirect
|
96 |
+
if "t" in request.query_params:
|
97 |
+
return RedirectResponse(url=f"/video/{v}?t={request.query_params.get('t')}")
|
98 |
+
else:
|
99 |
+
return RedirectResponse(url=f"/video/{v}")
|
100 |
|
101 |
|
102 |
# Include API routers
|
app/models/video.py
CHANGED
@@ -21,7 +21,8 @@ class Video(BaseModel):
|
|
21 |
channel: Optional[str] = Field(None, description="Channel name")
|
22 |
processed: bool = Field(False, description="Whether the video has been processed")
|
23 |
created_at: Optional[int] = Field(
|
24 |
-
None,
|
|
|
25 |
)
|
26 |
|
27 |
|
|
|
21 |
channel: Optional[str] = Field(None, description="Channel name")
|
22 |
processed: bool = Field(False, description="Whether the video has been processed")
|
23 |
created_at: Optional[int] = Field(
|
24 |
+
None,
|
25 |
+
description="Unix timestamp (seconds since epoch) when the video was processed",
|
26 |
)
|
27 |
|
28 |
|
app/services/video_service.py
CHANGED
@@ -12,7 +12,9 @@ from app.models.video import VideoSegment, Video, SearchResult
|
|
12 |
from app.services.qdrant_service import qdrant_client
|
13 |
|
14 |
# Initialize the sentence transformer model
|
15 |
-
model = SentenceTransformer(
|
|
|
|
|
16 |
|
17 |
# Collection names
|
18 |
COLLECTION_NAME = "video_segments"
|
|
|
12 |
from app.services.qdrant_service import qdrant_client
|
13 |
|
14 |
# Initialize the sentence transformer model
|
15 |
+
model = SentenceTransformer(
|
16 |
+
"sentence-transformers/static-retrieval-mrl-en-v1", cache_folder="/tmp"
|
17 |
+
)
|
18 |
|
19 |
# Collection names
|
20 |
COLLECTION_NAME = "video_segments"
|
app/static/css/style.css
CHANGED
@@ -135,3 +135,51 @@
|
|
135 |
color: var(--accent-content, #581c87);
|
136 |
white-space: nowrap;
|
137 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
135 |
color: var(--accent-content, #581c87);
|
136 |
white-space: nowrap;
|
137 |
}
|
138 |
+
|
139 |
+
/* Truncate text lines for search results */
|
140 |
+
.truncate-3-lines {
|
141 |
+
display: -webkit-box;
|
142 |
+
-webkit-line-clamp: 3;
|
143 |
+
-webkit-box-orient: vertical;
|
144 |
+
overflow: hidden;
|
145 |
+
text-overflow: ellipsis;
|
146 |
+
}
|
147 |
+
|
148 |
+
/* Modal styles */
|
149 |
+
.modal-box {
|
150 |
+
max-width: 80vw;
|
151 |
+
width: 100%;
|
152 |
+
}
|
153 |
+
|
154 |
+
@media (min-width: 768px) {
|
155 |
+
.modal-box {
|
156 |
+
max-width: 700px;
|
157 |
+
}
|
158 |
+
}
|
159 |
+
|
160 |
+
@media (min-width: 1024px) {
|
161 |
+
.modal-box {
|
162 |
+
max-width: 900px;
|
163 |
+
}
|
164 |
+
}
|
165 |
+
|
166 |
+
/* Search results in modal */
|
167 |
+
#search-results-container {
|
168 |
+
scrollbar-width: thin;
|
169 |
+
scrollbar-color: var(--primary) var(--base-200);
|
170 |
+
}
|
171 |
+
|
172 |
+
#search-results-container::-webkit-scrollbar {
|
173 |
+
width: 8px;
|
174 |
+
}
|
175 |
+
|
176 |
+
#search-results-container::-webkit-scrollbar-track {
|
177 |
+
background: var(--base-200);
|
178 |
+
border-radius: 10px;
|
179 |
+
}
|
180 |
+
|
181 |
+
#search-results-container::-webkit-scrollbar-thumb {
|
182 |
+
background-color: var(--primary);
|
183 |
+
border-radius: 10px;
|
184 |
+
border: 2px solid var(--base-200);
|
185 |
+
}
|
app/static/js/index.js
CHANGED
@@ -7,6 +7,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
7 |
const recentlyProcessedCard = document.getElementById('recently-processed');
|
8 |
const videoListContainer = document.getElementById('video-list');
|
9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
// Example video buttons
|
11 |
const exampleButtons = document.querySelectorAll('.example-video');
|
12 |
|
@@ -265,4 +273,200 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
265 |
|
266 |
// Display recent videos on page load
|
267 |
displayRecentVideos();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
268 |
});
|
|
|
7 |
const recentlyProcessedCard = document.getElementById('recently-processed');
|
8 |
const videoListContainer = document.getElementById('video-list');
|
9 |
|
10 |
+
// Check for search parameter in URL
|
11 |
+
const urlParams = new URLSearchParams(window.location.search);
|
12 |
+
const searchQuery = urlParams.get('search');
|
13 |
+
if (searchQuery) {
|
14 |
+
// Display search results
|
15 |
+
displaySearchResults(searchQuery);
|
16 |
+
}
|
17 |
+
|
18 |
// Example video buttons
|
19 |
const exampleButtons = document.querySelectorAll('.example-video');
|
20 |
|
|
|
273 |
|
274 |
// Display recent videos on page load
|
275 |
displayRecentVideos();
|
276 |
+
|
277 |
+
// Function to display search results
|
278 |
+
function displaySearchResults(query) {
|
279 |
+
// Try to use the modal if available
|
280 |
+
const searchModal = document.getElementById('search-results-modal');
|
281 |
+
const searchResultsContainer = document.getElementById('search-results-container');
|
282 |
+
|
283 |
+
if (searchModal && searchResultsContainer) {
|
284 |
+
// Update modal title with search term
|
285 |
+
const modalTitle = searchModal.querySelector('h3');
|
286 |
+
if (modalTitle) {
|
287 |
+
modalTitle.textContent = `Search Results for "${query}"`;
|
288 |
+
}
|
289 |
+
|
290 |
+
// Show the modal
|
291 |
+
searchModal.showModal();
|
292 |
+
|
293 |
+
// Clear previous results and show loading state
|
294 |
+
searchResultsContainer.innerHTML = `
|
295 |
+
<div class="flex justify-center items-center p-4">
|
296 |
+
<span class="loading loading-spinner loading-md"></span>
|
297 |
+
<span class="ml-2">Searching...</span>
|
298 |
+
</div>
|
299 |
+
`;
|
300 |
+
|
301 |
+
// Fetch and display results in the modal
|
302 |
+
fetchAndDisplayResults(query, searchResultsContainer);
|
303 |
+
} else {
|
304 |
+
// Fallback to the old implementation if modal is not available
|
305 |
+
// Show a search results card with loading indicator
|
306 |
+
processStatus.innerHTML = `
|
307 |
+
<div class="card bg-base-100 shadow-xl mt-4">
|
308 |
+
<div class="card-body">
|
309 |
+
<h2 class="card-title">Search Results for "${query}"</h2>
|
310 |
+
<div class="mt-4">
|
311 |
+
<div class="flex justify-center items-center p-4">
|
312 |
+
<span class="loading loading-spinner loading-md"></span>
|
313 |
+
<span class="ml-2">Searching...</span>
|
314 |
+
</div>
|
315 |
+
</div>
|
316 |
+
</div>
|
317 |
+
</div>
|
318 |
+
`;
|
319 |
+
|
320 |
+
// Fetch and display results in the processStatus element
|
321 |
+
fetchAndDisplayResults(query, null, processStatus);
|
322 |
+
}
|
323 |
+
}
|
324 |
+
|
325 |
+
// Helper function to fetch and display search results
|
326 |
+
function fetchAndDisplayResults(query, container, fallbackContainer) {
|
327 |
+
// Fetch search results from API
|
328 |
+
fetch(`/api/video/search?query=${encodeURIComponent(query)}&limit=10`)
|
329 |
+
.then(response => {
|
330 |
+
if (!response.ok) {
|
331 |
+
throw new Error('Failed to perform search');
|
332 |
+
}
|
333 |
+
return response.json();
|
334 |
+
})
|
335 |
+
.then(results => {
|
336 |
+
if (results && results.length > 0) {
|
337 |
+
// Group results by video
|
338 |
+
const videoGroups = {};
|
339 |
+
// First pass: collect all video IDs that need titles
|
340 |
+
const videoIds = [];
|
341 |
+
results.forEach(result => {
|
342 |
+
const videoId = result.segment.video_id;
|
343 |
+
if (!videoGroups[videoId]) {
|
344 |
+
videoGroups[videoId] = {
|
345 |
+
videoId: videoId,
|
346 |
+
title: `Video ${videoId}`, // Default title, will be updated
|
347 |
+
segments: []
|
348 |
+
};
|
349 |
+
// Add to list of IDs to fetch titles for
|
350 |
+
videoIds.push(videoId);
|
351 |
+
}
|
352 |
+
videoGroups[videoId].segments.push(result);
|
353 |
+
});
|
354 |
+
|
355 |
+
// If we have video IDs, fetch their proper titles
|
356 |
+
const videoPromises = videoIds.map(videoId => {
|
357 |
+
return fetch(`/api/video/info/${videoId}`)
|
358 |
+
.then(response => response.ok ? response.json() : null)
|
359 |
+
.then(videoInfo => {
|
360 |
+
if (videoInfo && videoInfo.title && videoGroups[videoId]) {
|
361 |
+
videoGroups[videoId].title = videoInfo.title;
|
362 |
+
}
|
363 |
+
})
|
364 |
+
.catch(error => console.error(`Error fetching video info for ${videoId}:`, error));
|
365 |
+
});
|
366 |
+
|
367 |
+
// After all video titles are fetched, continue with rendering
|
368 |
+
Promise.all(videoPromises).then(() => {
|
369 |
+
// Generate results HTML
|
370 |
+
const resultsHTML = Object.values(videoGroups).map(group => {
|
371 |
+
const segmentsHTML = group.segments.map(result => {
|
372 |
+
return `
|
373 |
+
<a href="/video/${group.videoId}?t=${Math.floor(result.segment.start)}"
|
374 |
+
class="block p-2 hover:bg-base-200 rounded-md transition-colors duration-150 mb-2">
|
375 |
+
<div class="flex items-start">
|
376 |
+
<div class="text-primary font-mono mr-2">${formatTime(result.segment.start)}</div>
|
377 |
+
<div class="flex-grow">
|
378 |
+
<p class="truncate-3-lines">${result.segment.text}</p>
|
379 |
+
<div class="text-xs opacity-70 mt-1">Score: ${(result.score * 100).toFixed(1)}%</div>
|
380 |
+
</div>
|
381 |
+
</div>
|
382 |
+
</a>
|
383 |
+
`;
|
384 |
+
}).join('');
|
385 |
+
|
386 |
+
return `
|
387 |
+
<div class="mb-6">
|
388 |
+
<div class="flex items-center mb-2">
|
389 |
+
<img src="https://img.youtube.com/vi/${group.videoId}/default.jpg" alt="Thumbnail"
|
390 |
+
class="w-12 h-12 rounded-md mr-2">
|
391 |
+
<h3 class="text-lg font-bold">
|
392 |
+
<a href="/video/${group.videoId}" class="link">${group.title}</a>
|
393 |
+
</h3>
|
394 |
+
</div>
|
395 |
+
<div class="pl-4 border-l-2 border-primary">
|
396 |
+
${segmentsHTML}
|
397 |
+
</div>
|
398 |
+
</div>
|
399 |
+
`;
|
400 |
+
}).join('');
|
401 |
+
|
402 |
+
// Display results in the appropriate container
|
403 |
+
if (container) {
|
404 |
+
container.innerHTML = resultsHTML;
|
405 |
+
} else if (fallbackContainer) {
|
406 |
+
fallbackContainer.innerHTML = `
|
407 |
+
<div class="card bg-base-100 shadow-xl mt-4">
|
408 |
+
<div class="card-body">
|
409 |
+
<h2 class="card-title">Search Results for "${query}"</h2>
|
410 |
+
<div class="mt-4">
|
411 |
+
${resultsHTML}
|
412 |
+
</div>
|
413 |
+
</div>
|
414 |
+
</div>
|
415 |
+
`;
|
416 |
+
}
|
417 |
+
});
|
418 |
+
} else {
|
419 |
+
// No results found - display immediately, no need to wait for titles
|
420 |
+
const noResultsHTML = `
|
421 |
+
<div class="alert alert-info">
|
422 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
423 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
424 |
+
</svg>
|
425 |
+
<span>No results found. Try a different search term or process more videos.</span>
|
426 |
+
</div>
|
427 |
+
`;
|
428 |
+
|
429 |
+
if (container) {
|
430 |
+
container.innerHTML = noResultsHTML;
|
431 |
+
} else if (fallbackContainer) {
|
432 |
+
fallbackContainer.innerHTML = `
|
433 |
+
<div class="card bg-base-100 shadow-xl mt-4">
|
434 |
+
<div class="card-body">
|
435 |
+
<h2 class="card-title">Search Results for "${query}"</h2>
|
436 |
+
<div class="mt-4">
|
437 |
+
${noResultsHTML}
|
438 |
+
</div>
|
439 |
+
</div>
|
440 |
+
</div>
|
441 |
+
`;
|
442 |
+
}
|
443 |
+
}
|
444 |
+
})
|
445 |
+
.catch(error => {
|
446 |
+
console.error('Search error:', error);
|
447 |
+
const errorHTML = `
|
448 |
+
<div class="alert alert-error">
|
449 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
450 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
451 |
+
</svg>
|
452 |
+
<span>Error performing search: ${error.message}</span>
|
453 |
+
</div>
|
454 |
+
`;
|
455 |
+
|
456 |
+
if (container) {
|
457 |
+
container.innerHTML = errorHTML;
|
458 |
+
} else if (fallbackContainer) {
|
459 |
+
fallbackContainer.innerHTML = `
|
460 |
+
<div class="card bg-base-100 shadow-xl mt-4">
|
461 |
+
<div class="card-body">
|
462 |
+
<h2 class="card-title">Search Results for "${query}"</h2>
|
463 |
+
<div class="mt-4">
|
464 |
+
${errorHTML}
|
465 |
+
</div>
|
466 |
+
</div>
|
467 |
+
</div>
|
468 |
+
`;
|
469 |
+
}
|
470 |
+
});
|
471 |
+
}
|
472 |
});
|
app/static/js/main.js
CHANGED
@@ -20,13 +20,35 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
20 |
if (savedTheme) {
|
21 |
document.documentElement.setAttribute('data-theme', savedTheme);
|
22 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
});
|
24 |
|
25 |
-
// Format seconds to MM:SS format
|
26 |
function formatTime(seconds) {
|
27 |
-
const
|
|
|
28 |
const secs = Math.floor(seconds % 60);
|
29 |
-
|
|
|
|
|
|
|
|
|
|
|
30 |
}
|
31 |
|
32 |
// Error handling function
|
@@ -99,6 +121,138 @@ function extractVideoId(url) {
|
|
99 |
return (match && match[7].length === 11) ? match[7] : null;
|
100 |
}
|
101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
102 |
// Load recent videos into the footer from the API
|
103 |
function loadFooterRecentVideos() {
|
104 |
const footerRecentVideos = document.getElementById('footer-recent-videos');
|
|
|
20 |
if (savedTheme) {
|
21 |
document.documentElement.setAttribute('data-theme', savedTheme);
|
22 |
}
|
23 |
+
|
24 |
+
// Handle global search
|
25 |
+
const searchButton = document.getElementById('global-search-button');
|
26 |
+
const searchInput = document.getElementById('global-search');
|
27 |
+
|
28 |
+
if (searchButton && searchInput) {
|
29 |
+
searchButton.addEventListener('click', () => {
|
30 |
+
handleGlobalSearch();
|
31 |
+
});
|
32 |
+
|
33 |
+
searchInput.addEventListener('keypress', (e) => {
|
34 |
+
if (e.key === 'Enter') {
|
35 |
+
handleGlobalSearch();
|
36 |
+
}
|
37 |
+
});
|
38 |
+
}
|
39 |
});
|
40 |
|
41 |
+
// Format seconds to HH:MM:SS format
|
42 |
function formatTime(seconds) {
|
43 |
+
const hours = Math.floor(seconds / 3600);
|
44 |
+
const mins = Math.floor((seconds % 3600) / 60);
|
45 |
const secs = Math.floor(seconds % 60);
|
46 |
+
|
47 |
+
if (hours > 0) {
|
48 |
+
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
49 |
+
} else {
|
50 |
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
51 |
+
}
|
52 |
}
|
53 |
|
54 |
// Error handling function
|
|
|
121 |
return (match && match[7].length === 11) ? match[7] : null;
|
122 |
}
|
123 |
|
124 |
+
// Handle global search
|
125 |
+
function handleGlobalSearch() {
|
126 |
+
const searchInput = document.getElementById('global-search');
|
127 |
+
const searchTerm = searchInput.value.trim();
|
128 |
+
|
129 |
+
if (searchTerm) {
|
130 |
+
// Get modal elements
|
131 |
+
const searchModal = document.getElementById('search-results-modal');
|
132 |
+
const searchResultsContainer = document.getElementById('search-results-container');
|
133 |
+
const modalTitle = searchModal.querySelector('h3');
|
134 |
+
|
135 |
+
// Update modal title with search term
|
136 |
+
modalTitle.textContent = `Search Results for "${searchTerm}"`;
|
137 |
+
|
138 |
+
// Show the modal
|
139 |
+
searchModal.showModal();
|
140 |
+
|
141 |
+
// Clear previous results and show loading state
|
142 |
+
searchResultsContainer.innerHTML = `
|
143 |
+
<div class="flex justify-center items-center p-4">
|
144 |
+
<span class="loading loading-spinner loading-md"></span>
|
145 |
+
<span class="ml-2">Searching...</span>
|
146 |
+
</div>
|
147 |
+
`;
|
148 |
+
|
149 |
+
// Fetch search results from API
|
150 |
+
fetch(`/api/video/search?query=${encodeURIComponent(searchTerm)}&limit=10`)
|
151 |
+
.then(response => {
|
152 |
+
if (!response.ok) {
|
153 |
+
throw new Error('Failed to perform search');
|
154 |
+
}
|
155 |
+
return response.json();
|
156 |
+
})
|
157 |
+
.then(results => {
|
158 |
+
if (results && results.length > 0) {
|
159 |
+
// Group results by video
|
160 |
+
const videoGroups = {};
|
161 |
+
// First pass: collect all video IDs that need titles
|
162 |
+
const videoIds = [];
|
163 |
+
results.forEach(result => {
|
164 |
+
const videoId = result.segment.video_id;
|
165 |
+
if (!videoGroups[videoId]) {
|
166 |
+
videoGroups[videoId] = {
|
167 |
+
videoId: videoId,
|
168 |
+
title: `Video ${videoId}`, // Default title, will be updated
|
169 |
+
segments: []
|
170 |
+
};
|
171 |
+
// Add to list of IDs to fetch titles for
|
172 |
+
videoIds.push(videoId);
|
173 |
+
}
|
174 |
+
videoGroups[videoId].segments.push(result);
|
175 |
+
});
|
176 |
+
|
177 |
+
// If we have video IDs, fetch their proper titles
|
178 |
+
const videoPromises = videoIds.map(videoId => {
|
179 |
+
return fetch(`/api/video/info/${videoId}`)
|
180 |
+
.then(response => response.ok ? response.json() : null)
|
181 |
+
.then(videoInfo => {
|
182 |
+
if (videoInfo && videoInfo.title && videoGroups[videoId]) {
|
183 |
+
videoGroups[videoId].title = videoInfo.title;
|
184 |
+
}
|
185 |
+
})
|
186 |
+
.catch(error => console.error(`Error fetching video info for ${videoId}:`, error));
|
187 |
+
});
|
188 |
+
|
189 |
+
// After all video titles are fetched, continue with rendering
|
190 |
+
Promise.all(videoPromises).then(() => {
|
191 |
+
// Generate results HTML
|
192 |
+
const resultsHTML = Object.values(videoGroups).map(group => {
|
193 |
+
const segmentsHTML = group.segments.map(result => {
|
194 |
+
return `
|
195 |
+
<a href="/video/${group.videoId}?t=${Math.floor(result.segment.start)}"
|
196 |
+
class="block p-2 hover:bg-base-200 rounded-md transition-colors duration-150 mb-2">
|
197 |
+
<div class="flex items-start">
|
198 |
+
<div class="text-primary font-mono mr-2">${formatTime(result.segment.start)}</div>
|
199 |
+
<div class="flex-grow">
|
200 |
+
<p class="truncate-3-lines">${result.segment.text}</p>
|
201 |
+
<div class="text-xs opacity-70 mt-1">Score: ${(result.score * 100).toFixed(1)}%</div>
|
202 |
+
</div>
|
203 |
+
</div>
|
204 |
+
</a>
|
205 |
+
`;
|
206 |
+
}).join('');
|
207 |
+
|
208 |
+
return `
|
209 |
+
<div class="mb-6">
|
210 |
+
<div class="flex items-center mb-2">
|
211 |
+
<img src="https://img.youtube.com/vi/${group.videoId}/default.jpg" alt="Thumbnail"
|
212 |
+
class="w-12 h-12 rounded-md mr-2">
|
213 |
+
<h3 class="text-lg font-bold">
|
214 |
+
<a href="/video/${group.videoId}" class="link">${group.title}</a>
|
215 |
+
</h3>
|
216 |
+
</div>
|
217 |
+
<div class="pl-4 border-l-2 border-primary">
|
218 |
+
${segmentsHTML}
|
219 |
+
</div>
|
220 |
+
</div>
|
221 |
+
`;
|
222 |
+
}).join('');
|
223 |
+
|
224 |
+
// Update search results container
|
225 |
+
searchResultsContainer.innerHTML = resultsHTML;
|
226 |
+
});
|
227 |
+
} else {
|
228 |
+
// No results found - display immediately, no need to wait for titles
|
229 |
+
searchResultsContainer.innerHTML = `
|
230 |
+
<div class="alert alert-info">
|
231 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
232 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
233 |
+
</svg>
|
234 |
+
<span>No results found. Try a different search term or process more videos.</span>
|
235 |
+
</div>
|
236 |
+
`;
|
237 |
+
}
|
238 |
+
})
|
239 |
+
.catch(error => {
|
240 |
+
console.error('Search error:', error);
|
241 |
+
searchResultsContainer.innerHTML = `
|
242 |
+
<div class="alert alert-error">
|
243 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
244 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
245 |
+
</svg>
|
246 |
+
<span>Error performing search: ${error.message}</span>
|
247 |
+
</div>
|
248 |
+
`;
|
249 |
+
});
|
250 |
+
} else {
|
251 |
+
// Show notification if search is empty
|
252 |
+
showToast('Please enter a search term', 'warning');
|
253 |
+
}
|
254 |
+
}
|
255 |
+
|
256 |
// Load recent videos into the footer from the API
|
257 |
function loadFooterRecentVideos() {
|
258 |
const footerRecentVideos = document.getElementById('footer-recent-videos');
|
app/static/js/video.js
CHANGED
@@ -11,10 +11,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
11 |
let ytPlayer = null;
|
12 |
let isProcessingUrl = false;
|
13 |
|
14 |
-
// Check if there's a search query in the URL
|
15 |
const urlParams = new URLSearchParams(window.location.search);
|
16 |
const searchQuery = urlParams.get('q');
|
17 |
const processingUrl = urlParams.get('processing');
|
|
|
18 |
|
19 |
// Format time to display as HH:MM:SS
|
20 |
function formatTime(seconds) {
|
@@ -67,6 +68,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
67 |
// When player is ready
|
68 |
function onPlayerReady(event) {
|
69 |
console.log('Player ready');
|
|
|
70 |
}
|
71 |
|
72 |
// Load transcript segments
|
@@ -107,6 +109,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
107 |
`;
|
108 |
} else {
|
109 |
displayTranscript(segments);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
110 |
}
|
111 |
})
|
112 |
.catch(error => {
|
@@ -162,6 +175,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
162 |
// Ensure seconds is a number
|
163 |
seconds = parseFloat(seconds);
|
164 |
|
|
|
|
|
|
|
|
|
|
|
165 |
// Seek to time
|
166 |
ytPlayer.seekTo(seconds, true);
|
167 |
|
@@ -173,12 +191,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
173 |
}
|
174 |
|
175 |
// Highlight the current segment
|
176 |
-
|
|
|
|
|
177 |
} catch (error) {
|
178 |
console.error('Error seeking to time:', error);
|
179 |
}
|
180 |
} else {
|
181 |
console.error('YouTube player is not ready yet or seekTo method is not available');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
182 |
}
|
183 |
}
|
184 |
|
@@ -189,6 +218,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
189 |
segment.classList.remove('highlight');
|
190 |
});
|
191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
192 |
// Find the segment containing current time
|
193 |
// Need to find by approximate match since floating point exact matches may not work
|
194 |
const segments = document.querySelectorAll('.transcript-segment');
|
@@ -204,16 +241,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
204 |
}
|
205 |
}
|
206 |
|
207 |
-
// If exact time match not found, find the closest segment
|
208 |
-
if (!currentSegment) {
|
|
|
209 |
const exactMatch = document.querySelector(`.transcript-segment[data-start="${time}"]`);
|
210 |
if (exactMatch) {
|
211 |
currentSegment = exactMatch;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
212 |
}
|
213 |
}
|
214 |
|
215 |
if (currentSegment) {
|
216 |
currentSegment.classList.add('highlight');
|
|
|
217 |
currentSegment.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
218 |
}
|
219 |
}
|
@@ -437,4 +490,105 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
437 |
// Set timeout to stop checking after 10 seconds
|
438 |
setTimeout(() => clearInterval(checkTranscriptInterval), 10000);
|
439 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
440 |
});
|
|
|
11 |
let ytPlayer = null;
|
12 |
let isProcessingUrl = false;
|
13 |
|
14 |
+
// Check if there's a search query or timestamp in the URL
|
15 |
const urlParams = new URLSearchParams(window.location.search);
|
16 |
const searchQuery = urlParams.get('q');
|
17 |
const processingUrl = urlParams.get('processing');
|
18 |
+
const startTime = urlParams.get('t');
|
19 |
|
20 |
// Format time to display as HH:MM:SS
|
21 |
function formatTime(seconds) {
|
|
|
68 |
// When player is ready
|
69 |
function onPlayerReady(event) {
|
70 |
console.log('Player ready');
|
71 |
+
// Seeking will be handled by the dedicated timestamp handler code at the bottom
|
72 |
}
|
73 |
|
74 |
// Load transcript segments
|
|
|
109 |
`;
|
110 |
} else {
|
111 |
displayTranscript(segments);
|
112 |
+
|
113 |
+
// If we have a timestamp in the URL, highlight the relevant segment
|
114 |
+
if (startTime) {
|
115 |
+
const timeInSeconds = parseFloat(startTime);
|
116 |
+
if (!isNaN(timeInSeconds)) {
|
117 |
+
// Find segment containing this time
|
118 |
+
setTimeout(() => {
|
119 |
+
highlightSegment(timeInSeconds);
|
120 |
+
}, 500); // Short delay to ensure segments are rendered
|
121 |
+
}
|
122 |
+
}
|
123 |
}
|
124 |
})
|
125 |
.catch(error => {
|
|
|
175 |
// Ensure seconds is a number
|
176 |
seconds = parseFloat(seconds);
|
177 |
|
178 |
+
if (isNaN(seconds)) {
|
179 |
+
console.error('Invalid seconds value:', seconds);
|
180 |
+
return;
|
181 |
+
}
|
182 |
+
|
183 |
// Seek to time
|
184 |
ytPlayer.seekTo(seconds, true);
|
185 |
|
|
|
191 |
}
|
192 |
|
193 |
// Highlight the current segment
|
194 |
+
setTimeout(() => {
|
195 |
+
highlightSegment(seconds);
|
196 |
+
}, 300); // Short delay to ensure seek completes first
|
197 |
} catch (error) {
|
198 |
console.error('Error seeking to time:', error);
|
199 |
}
|
200 |
} else {
|
201 |
console.error('YouTube player is not ready yet or seekTo method is not available');
|
202 |
+
|
203 |
+
// Queue the seek operation for when the player becomes available
|
204 |
+
console.log('Queueing seek operation for later...');
|
205 |
+
setTimeout(() => {
|
206 |
+
if (ytPlayer && typeof ytPlayer.seekTo === 'function') {
|
207 |
+
console.log('Player now ready, executing queued seek');
|
208 |
+
seekToTime(seconds);
|
209 |
+
}
|
210 |
+
}, 1000); // Try again in 1 second
|
211 |
}
|
212 |
}
|
213 |
|
|
|
218 |
segment.classList.remove('highlight');
|
219 |
});
|
220 |
|
221 |
+
// Wait until segments are available in the DOM
|
222 |
+
if (document.querySelectorAll('.transcript-segment').length === 0) {
|
223 |
+
console.log('No transcript segments found, waiting...');
|
224 |
+
// Retry after a short delay to allow transcript to load
|
225 |
+
setTimeout(() => highlightSegment(time), 500);
|
226 |
+
return;
|
227 |
+
}
|
228 |
+
|
229 |
// Find the segment containing current time
|
230 |
// Need to find by approximate match since floating point exact matches may not work
|
231 |
const segments = document.querySelectorAll('.transcript-segment');
|
|
|
241 |
}
|
242 |
}
|
243 |
|
244 |
+
// If exact time match not found, find the closest segment by time
|
245 |
+
if (!currentSegment && segments.length > 0) {
|
246 |
+
// First try exact match
|
247 |
const exactMatch = document.querySelector(`.transcript-segment[data-start="${time}"]`);
|
248 |
if (exactMatch) {
|
249 |
currentSegment = exactMatch;
|
250 |
+
} else {
|
251 |
+
// Find closest segment
|
252 |
+
let closestSegment = segments[0];
|
253 |
+
let closestDistance = Math.abs(parseFloat(closestSegment.dataset.start) - time);
|
254 |
+
|
255 |
+
segments.forEach(segment => {
|
256 |
+
const distance = Math.abs(parseFloat(segment.dataset.start) - time);
|
257 |
+
if (distance < closestDistance) {
|
258 |
+
closestDistance = distance;
|
259 |
+
closestSegment = segment;
|
260 |
+
}
|
261 |
+
});
|
262 |
+
|
263 |
+
currentSegment = closestSegment;
|
264 |
}
|
265 |
}
|
266 |
|
267 |
if (currentSegment) {
|
268 |
currentSegment.classList.add('highlight');
|
269 |
+
// Ensure the segment is visible in the transcript container
|
270 |
currentSegment.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
271 |
}
|
272 |
}
|
|
|
490 |
// Set timeout to stop checking after 10 seconds
|
491 |
setTimeout(() => clearInterval(checkTranscriptInterval), 10000);
|
492 |
}
|
493 |
+
|
494 |
+
// If there's a timestamp in the URL, ensure it will be seeked to after transcript loads
|
495 |
+
if (startTime) { // Handle timestamp regardless of search query
|
496 |
+
let timeInSeconds = parseFloat(startTime);
|
497 |
+
|
498 |
+
// If parsing fails, try to parse as HH:MM:SS format
|
499 |
+
if (isNaN(timeInSeconds) && typeof startTime === 'string') {
|
500 |
+
// Try to parse HH:MM:SS or MM:SS format
|
501 |
+
const timeParts = startTime.split(':').map(part => parseInt(part, 10));
|
502 |
+
if (timeParts.length === 3) {
|
503 |
+
// HH:MM:SS format
|
504 |
+
timeInSeconds = timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2];
|
505 |
+
} else if (timeParts.length === 2) {
|
506 |
+
// MM:SS format
|
507 |
+
timeInSeconds = timeParts[0] * 60 + timeParts[1];
|
508 |
+
}
|
509 |
+
}
|
510 |
+
|
511 |
+
if (!isNaN(timeInSeconds)) {
|
512 |
+
console.log('Will seek to timestamp:', timeInSeconds, 'seconds');
|
513 |
+
|
514 |
+
// Try immediately if player is ready
|
515 |
+
if (ytPlayer && typeof ytPlayer.seekTo === 'function') {
|
516 |
+
console.log('YouTube player is ready, seeking now');
|
517 |
+
seekToTime(timeInSeconds);
|
518 |
+
// Also try to play the video as a fallback if autoplay doesn't work
|
519 |
+
try {
|
520 |
+
ytPlayer.playVideo();
|
521 |
+
// Unmute the video after a short delay
|
522 |
+
setTimeout(() => {
|
523 |
+
try {
|
524 |
+
ytPlayer.unMute();
|
525 |
+
// Set volume to a reasonable level
|
526 |
+
ytPlayer.setVolume(80);
|
527 |
+
} catch (e) {
|
528 |
+
console.warn('Could not unmute video:', e);
|
529 |
+
}
|
530 |
+
}, 1000);
|
531 |
+
} catch (e) {
|
532 |
+
console.warn('Could not autoplay video:', e);
|
533 |
+
}
|
534 |
+
}
|
535 |
+
|
536 |
+
// Also set up a backup interval to ensure we seek once everything is ready
|
537 |
+
const checkReadyInterval = setInterval(() => {
|
538 |
+
if (ytPlayer && typeof ytPlayer.seekTo === 'function') {
|
539 |
+
if (transcriptSegments.length > 0) {
|
540 |
+
clearInterval(checkReadyInterval);
|
541 |
+
console.log('Everything loaded, seeking to timestamp:', timeInSeconds);
|
542 |
+
seekToTime(timeInSeconds);
|
543 |
+
// Also try to play the video
|
544 |
+
try {
|
545 |
+
ytPlayer.playVideo();
|
546 |
+
// Unmute the video after a short delay
|
547 |
+
setTimeout(() => {
|
548 |
+
try {
|
549 |
+
ytPlayer.unMute();
|
550 |
+
ytPlayer.setVolume(80);
|
551 |
+
} catch (e) {
|
552 |
+
console.warn('Could not unmute video after delay:', e);
|
553 |
+
}
|
554 |
+
}, 1000);
|
555 |
+
} catch (e) {
|
556 |
+
console.warn('Could not autoplay video after delay:', e);
|
557 |
+
}
|
558 |
+
} else {
|
559 |
+
console.log('Waiting for transcript segments to load...');
|
560 |
+
}
|
561 |
+
} else {
|
562 |
+
console.log('Waiting for YouTube player to be ready...');
|
563 |
+
}
|
564 |
+
}, 500);
|
565 |
+
|
566 |
+
// Set timeout to stop checking after 10 seconds
|
567 |
+
setTimeout(() => {
|
568 |
+
clearInterval(checkReadyInterval);
|
569 |
+
// Final attempt
|
570 |
+
if (ytPlayer && typeof ytPlayer.seekTo === 'function') {
|
571 |
+
console.log('Final attempt to seek to:', timeInSeconds);
|
572 |
+
seekToTime(timeInSeconds);
|
573 |
+
// One final attempt to play
|
574 |
+
try {
|
575 |
+
ytPlayer.playVideo();
|
576 |
+
// Unmute the video after a short delay
|
577 |
+
setTimeout(() => {
|
578 |
+
try {
|
579 |
+
ytPlayer.unMute();
|
580 |
+
ytPlayer.setVolume(80);
|
581 |
+
} catch (e) {
|
582 |
+
console.warn('Could not unmute video on final attempt:', e);
|
583 |
+
}
|
584 |
+
}, 1000);
|
585 |
+
} catch (e) {
|
586 |
+
console.warn('Could not autoplay video on final attempt:', e);
|
587 |
+
}
|
588 |
+
}
|
589 |
+
}, 10000);
|
590 |
+
} else {
|
591 |
+
console.warn('Could not parse timestamp from URL:', startTime);
|
592 |
+
}
|
593 |
+
}
|
594 |
});
|
app/templates/base.html
CHANGED
@@ -75,6 +75,26 @@
|
|
75 |
</div>
|
76 |
</footer>
|
77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
78 |
<!-- Scripts -->
|
79 |
<script src="{{ https_url_for('static', path='/js/main.js') }}"></script>
|
80 |
{% block scripts %}{% endblock %}
|
|
|
75 |
</div>
|
76 |
</footer>
|
77 |
|
78 |
+
<!-- Search Results Modal -->
|
79 |
+
<dialog id="search-results-modal" class="modal">
|
80 |
+
<div class="modal-box max-w-4xl">
|
81 |
+
<form method="dialog">
|
82 |
+
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
83 |
+
</form>
|
84 |
+
<h3 class="font-bold text-lg mb-4">Search Results</h3>
|
85 |
+
<div id="search-results-container" class="overflow-y-auto max-h-[70vh]">
|
86 |
+
<!-- Search results will be loaded here by JavaScript -->
|
87 |
+
<div class="flex justify-center items-center p-4">
|
88 |
+
<span class="loading loading-spinner loading-md"></span>
|
89 |
+
<span class="ml-2">Searching...</span>
|
90 |
+
</div>
|
91 |
+
</div>
|
92 |
+
</div>
|
93 |
+
<form method="dialog" class="modal-backdrop">
|
94 |
+
<button>close</button>
|
95 |
+
</form>
|
96 |
+
</dialog>
|
97 |
+
|
98 |
<!-- Scripts -->
|
99 |
<script src="{{ https_url_for('static', path='/js/main.js') }}"></script>
|
100 |
{% block scripts %}{% endblock %}
|
app/templates/video.html
CHANGED
@@ -7,7 +7,7 @@
|
|
7 |
<div class="card-body p-4">
|
8 |
<div class="aspect-video">
|
9 |
<iframe id="youtube-player" class="w-full h-full"
|
10 |
-
src="https://www.youtube.com/embed/{{ video_id }}?enablejsapi=1"
|
11 |
frameborder="0"
|
12 |
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
13 |
allowfullscreen>
|
|
|
7 |
<div class="card-body p-4">
|
8 |
<div class="aspect-video">
|
9 |
<iframe id="youtube-player" class="w-full h-full"
|
10 |
+
src="https://www.youtube.com/embed/{{ video_id }}?enablejsapi=1&start={{ start_time }}{% if start_time > 0 %}&autoplay=1&mute=1{% endif %}"
|
11 |
frameborder="0"
|
12 |
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
13 |
allowfullscreen>
|
gunicorn.conf.py
CHANGED
@@ -17,7 +17,7 @@ bind = "0.0.0.0:7860"
|
|
17 |
|
18 |
# Logging
|
19 |
accesslog = "-" # Log to stdout
|
20 |
-
errorlog = "-"
|
21 |
loglevel = "info"
|
22 |
|
23 |
# Timeout configuration
|
@@ -29,4 +29,4 @@ worker_connections = 1000 # Maximum number of connections each worker can handl
|
|
29 |
keepalive = 5 # Seconds to wait between client requests before closing connection
|
30 |
|
31 |
# For better performance with Uvicorn
|
32 |
-
proc_name = "vibe-coding-rag"
|
|
|
17 |
|
18 |
# Logging
|
19 |
accesslog = "-" # Log to stdout
|
20 |
+
errorlog = "-" # Log to stderr
|
21 |
loglevel = "info"
|
22 |
|
23 |
# Timeout configuration
|
|
|
29 |
keepalive = 5 # Seconds to wait between client requests before closing connection
|
30 |
|
31 |
# For better performance with Uvicorn
|
32 |
+
proc_name = "vibe-coding-rag"
|