This commit is contained in:
+113
-45
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -144,6 +143,7 @@ func RegisterRoutes(router *gin.Engine, app *App) {
|
||||
router.GET("/ws", app.handleWS)
|
||||
router.GET("/api/history/check", app.checkDuplicate)
|
||||
router.GET("/api/preview/stream", app.streamPreview)
|
||||
router.GET("/api/preview/transcode", app.transcodePreview)
|
||||
router.POST("/api/download/preview", app.previewDownload)
|
||||
router.POST("/api/upload", app.uploadFile)
|
||||
router.POST("/api/download", app.startDownload)
|
||||
@@ -236,15 +236,6 @@ func (a *App) streamPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if isCacheablePreview(target, contentType) {
|
||||
if cachedPath, err := a.cachePreviewResponse(target, contentType, resp.Body); err == nil {
|
||||
a.debug("preview:proxy:cache_hit_write", gin.H{"target": target, "cachedPath": cachedPath})
|
||||
c.File(cachedPath)
|
||||
return
|
||||
}
|
||||
a.debug("preview:proxy:cache_write_error", gin.H{"target": target, "error": err.Error()})
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
c.Header("Content-Type", contentType)
|
||||
}
|
||||
@@ -252,6 +243,61 @@ func (a *App) streamPreview(c *gin.Context) {
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func (a *App) transcodePreview(c *gin.Context) {
|
||||
target := strings.TrimSpace(c.Query("url"))
|
||||
if target == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
|
||||
return
|
||||
}
|
||||
|
||||
headers := fmt.Sprintf("User-Agent: Mozilla/5.0\r\nReferer: %s\r\n", inferPreviewReferer(target))
|
||||
cmd := exec.CommandContext(
|
||||
c.Request.Context(),
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-headers", headers,
|
||||
"-i", target,
|
||||
"-an",
|
||||
"-vf", "scale='min(1280,iw)':-2",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "30",
|
||||
"-movflags", "frag_keyframe+empty_moov+faststart",
|
||||
"-f", "mp4",
|
||||
"pipe:1",
|
||||
)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "video/mp4")
|
||||
c.Status(http.StatusOK)
|
||||
_, copyErr := io.Copy(c.Writer, stdout)
|
||||
errOutput, _ := io.ReadAll(io.LimitReader(stderr, 2048))
|
||||
waitErr := cmd.Wait()
|
||||
if copyErr != nil {
|
||||
a.debug("preview:transcode:copy_error", gin.H{"target": target, "error": copyErr.Error()})
|
||||
return
|
||||
}
|
||||
if waitErr != nil {
|
||||
a.debug("preview:transcode:error", gin.H{"target": target, "error": waitErr.Error(), "stderr": strings.TrimSpace(string(errOutput))})
|
||||
return
|
||||
}
|
||||
a.debug("preview:transcode:complete", gin.H{"target": target})
|
||||
}
|
||||
|
||||
func (a *App) uploadFile(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -508,15 +554,45 @@ func (a *App) searchMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
merged := services.MergeRecommendations(recommended, scored, 16)
|
||||
targetCount := 16
|
||||
merged := services.MergeRecommendations(recommended, scored, targetCount)
|
||||
if geminiErr != nil {
|
||||
merged = services.BackfillRecommendations(
|
||||
merged,
|
||||
scored,
|
||||
16,
|
||||
targetCount,
|
||||
"Gemini 배치 일부가 실패해 미리보기 가능한 상위 후보를 보강했습니다.",
|
||||
)
|
||||
}
|
||||
if len(merged) < targetCount && time.Now().Before(deadline.Add(-5*time.Second)) {
|
||||
coverageQueries := buildCoverageQueries(req.Query, queryVariants, recommended, merged)
|
||||
if len(coverageQueries) > 0 {
|
||||
a.debug("search coverage query variants", gin.H{"variants": coverageQueries, "variantCount": len(coverageQueries), "existingCount": len(merged)})
|
||||
extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(coverageQueries, enabledPlatforms, deadline.Add(-5*time.Second))
|
||||
supplementalDeadlineLimited = supplementalDeadlineLimited || extraMeta.PartialDueToDeadline
|
||||
if extraErr == nil && len(extraResults) > 0 {
|
||||
results = mergeSearchResults(results, extraResults)
|
||||
scored = services.RankSearchResults(strings.Join(coverageQueries[:min(len(coverageQueries), 3)], " "), results)
|
||||
reviewedLinks := services.ReviewedRecommendationLinks(recommended)
|
||||
supplementalCandidates := services.SelectUnevaluatedCandidates(scored, reviewedLinks, services.RemainingGeminiCapacity(recommended))
|
||||
if len(supplementalCandidates) > 0 {
|
||||
extraRecommended, extraStats, extraGeminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline(
|
||||
a.GeminiService,
|
||||
req.Query,
|
||||
supplementalCandidates,
|
||||
deadline.Add(-2*time.Second),
|
||||
)
|
||||
recommended = services.MergeUniqueRecommendations(recommended, extraRecommended)
|
||||
geminiStats = services.MergeGeminiBatchStats(geminiStats, extraStats)
|
||||
geminiErr = combineSearchWarnings(geminiErr, extraGeminiErr)
|
||||
}
|
||||
merged = services.MergeRecommendations(recommended, scored, targetCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(merged) < targetCount {
|
||||
merged = services.BackfillRecommendations(merged, scored, targetCount, "추가 검색 후에도 충분한 결과가 부족해 시각 자산이 있는 후보를 제한적으로 보강했습니다.")
|
||||
}
|
||||
merged = services.RandomizeTopRecommendations(merged, 6)
|
||||
for idx := range merged {
|
||||
merged[idx] = services.DecorateRecommendationMedia(merged[idx])
|
||||
@@ -676,6 +752,31 @@ func mergeSearchResults(base, extra []services.SearchResult) []services.SearchRe
|
||||
return merged
|
||||
}
|
||||
|
||||
func buildCoverageQueries(query string, existing []string, reviewed []services.AIRecommendation, merged []services.AIRecommendation) []string {
|
||||
candidates := append([]string{}, existing...)
|
||||
positiveHints := 0
|
||||
for _, item := range reviewed {
|
||||
if item.Assessment == "positive" && item.SearchHint != "" && positiveHints < 3 {
|
||||
candidates = append(candidates, item.SearchHint)
|
||||
positiveHints++
|
||||
}
|
||||
}
|
||||
if len(merged) < 8 {
|
||||
candidates = append(candidates,
|
||||
query+" stock footage",
|
||||
query+" lifestyle footage",
|
||||
query+" candid couple footage",
|
||||
query+" editorial scene",
|
||||
)
|
||||
} else {
|
||||
candidates = append(candidates,
|
||||
query+" establishing shot",
|
||||
query+" cinematic b-roll",
|
||||
)
|
||||
}
|
||||
return mergeSupplementalQuerySets(nil, candidates)
|
||||
}
|
||||
|
||||
func combineSearchWarnings(base, extra error) error {
|
||||
switch {
|
||||
case base == nil:
|
||||
@@ -843,39 +944,6 @@ func rewriteM3U8Playlist(body, target string) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func isCacheablePreview(target, contentType string) bool {
|
||||
lower := strings.ToLower(target + " " + contentType)
|
||||
return strings.Contains(lower, ".mp4") || strings.Contains(lower, "video/mp4")
|
||||
}
|
||||
|
||||
func (a *App) cachePreviewResponse(target, contentType string, body io.Reader) (string, error) {
|
||||
if a.PreviewCacheDir == "" {
|
||||
return "", fmt.Errorf("preview cache dir is not configured")
|
||||
}
|
||||
if err := os.MkdirAll(a.PreviewCacheDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sum := sha1.Sum([]byte(target))
|
||||
ext := ".bin"
|
||||
if strings.Contains(strings.ToLower(target), ".mp4") || strings.Contains(strings.ToLower(contentType), "video/mp4") {
|
||||
ext = ".mp4"
|
||||
}
|
||||
path := filepath.Join(a.PreviewCacheDir, fmt.Sprintf("%x%s", sum, ext))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.Copy(file, body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func summarizeOutput(prefix string, output []byte, err error) string {
|
||||
trimmed := strings.TrimSpace(string(output))
|
||||
if trimmed == "" && err != nil {
|
||||
|
||||
Reference in New Issue
Block a user