Add download preview flow and search fallback
Some checks failed
build-push / docker (push) Has been cancelled
Some checks failed
build-push / docker (push) Has been cancelled
This commit is contained in:
@@ -64,12 +64,23 @@ func (h *Hub) Remove(conn *websocket.Conn) {
|
|||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreviewResponse struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
|
StartDefault string `json:"startDefault"`
|
||||||
|
EndDefault string `json:"endDefault"`
|
||||||
|
Qualities []map[string]any `json:"qualities"`
|
||||||
|
}
|
||||||
|
|
||||||
func RegisterRoutes(router *gin.Engine, app *App) {
|
func RegisterRoutes(router *gin.Engine, app *App) {
|
||||||
router.GET("/healthz", func(c *gin.Context) {
|
router.GET("/healthz", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
router.GET("/ws", app.handleWS)
|
router.GET("/ws", app.handleWS)
|
||||||
router.GET("/api/history/check", app.checkDuplicate)
|
router.GET("/api/history/check", app.checkDuplicate)
|
||||||
|
router.POST("/api/download/preview", app.previewDownload)
|
||||||
router.POST("/api/upload", app.uploadFile)
|
router.POST("/api/upload", app.uploadFile)
|
||||||
router.POST("/api/download", app.startDownload)
|
router.POST("/api/download", app.startDownload)
|
||||||
router.POST("/api/search", app.searchMedia)
|
router.POST("/api/search", app.searchMedia)
|
||||||
@@ -132,6 +143,7 @@ func (a *App) startDownload(c *gin.Context) {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Start string `json:"start"`
|
Start string `json:"start"`
|
||||||
End string `json:"end"`
|
End string `json:"end"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
Force bool `json:"force"`
|
Force bool `json:"force"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -157,13 +169,46 @@ func (a *App) startDownload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go a.runDownload(recordID, req.URL, req.Start, req.End, outputPath)
|
quality := strings.TrimSpace(req.Quality)
|
||||||
|
if quality == "" {
|
||||||
|
quality = "best"
|
||||||
|
}
|
||||||
|
|
||||||
|
go a.runDownload(recordID, req.URL, req.Start, req.End, quality, outputPath)
|
||||||
c.JSON(http.StatusAccepted, gin.H{"message": "download started", "recordId": recordID})
|
c.JSON(http.StatusAccepted, gin.H{"message": "download started", "recordId": recordID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) runDownload(recordID int64, url, start, end, outputPath string) {
|
func (a *App) previewDownload(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.URL) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("python3", a.WorkerScript, "--mode", "probe", "--url", req.URL, "--output", filepath.Join(a.DownloadsDir, "probe.tmp"))
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": strings.TrimSpace(string(output))})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var preview PreviewResponse
|
||||||
|
if err := json.Unmarshal(output, &preview); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "invalid probe response"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath string) {
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "queued", "progress": 0, "url": url})
|
a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "queued", "progress": 0, "url": url})
|
||||||
cmd := exec.Command("python3", a.WorkerScript, "--url", url, "--start", start, "--end", end, "--output", outputPath)
|
cmd := exec.Command("python3", a.WorkerScript, "--url", url, "--start", start, "--end", end, "--quality", quality, "--output", outputPath)
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 0, "message": err.Error()})
|
a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 0, "message": err.Error()})
|
||||||
@@ -187,6 +232,9 @@ func (a *App) runDownload(recordID int64, url, start, end, outputPath string) {
|
|||||||
a.Hub.Broadcast("progress", msg)
|
a.Hub.Broadcast("progress", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "error", "progress": 100, "message": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
status := "completed"
|
status := "completed"
|
||||||
if err := cmd.Wait(); err != nil {
|
if err := cmd.Wait(); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -45,61 +46,120 @@ func (s *SearchService) SearchMedia(query string) ([]SearchResult, error) {
|
|||||||
values.Set("key", s.APIKey)
|
values.Set("key", s.APIKey)
|
||||||
values.Set("cx", s.CX)
|
values.Set("cx", s.CX)
|
||||||
values.Set("q", fullQuery)
|
values.Set("q", fullQuery)
|
||||||
values.Set("searchType", "image")
|
|
||||||
values.Set("num", "10")
|
values.Set("num", "10")
|
||||||
values.Set("safe", "off")
|
values.Set("safe", "off")
|
||||||
|
|
||||||
results := make([]SearchResult, 0, 30)
|
results := make([]SearchResult, 0, 30)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, start := range []string{"1", "11", "21"} {
|
for _, start := range []string{"1", "11", "21"} {
|
||||||
values.Set("start", start)
|
pageResults, err := s.fetchPage(values, start, true)
|
||||||
endpoint := "https://www.googleapis.com/customsearch/v1?" + values.Encode()
|
|
||||||
resp, err := s.Client.Get(endpoint)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
pageResults, err = s.fetchPage(values, start, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= 300 {
|
for _, item := range pageResults {
|
||||||
resp.Body.Close()
|
if item.Link == "" || item.ThumbnailURL == "" || seen[item.Link] {
|
||||||
return nil, fmt.Errorf("google cse returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload struct {
|
|
||||||
Items []struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
DisplayLink string `json:"displayLink"`
|
|
||||||
Snippet string `json:"snippet"`
|
|
||||||
Image struct {
|
|
||||||
ThumbnailLink string `json:"thumbnailLink"`
|
|
||||||
} `json:"image"`
|
|
||||||
} `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
for _, item := range payload.Items {
|
|
||||||
if item.Link == "" || seen[item.Link] {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[item.Link] = true
|
seen[item.Link] = true
|
||||||
results = append(results, SearchResult{
|
results = append(results, item)
|
||||||
Title: item.Title,
|
|
||||||
Link: item.Link,
|
|
||||||
DisplayLink: item.DisplayLink,
|
|
||||||
Snippet: item.Snippet,
|
|
||||||
ThumbnailURL: item.Image.ThumbnailLink,
|
|
||||||
Source: inferSource(item.DisplayLink),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SearchService) fetchPage(values url.Values, start string, imageSearch bool) ([]SearchResult, error) {
|
||||||
|
pageValues := url.Values{}
|
||||||
|
for key, items := range values {
|
||||||
|
for _, item := range items {
|
||||||
|
pageValues.Add(key, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageValues.Set("start", start)
|
||||||
|
if imageSearch {
|
||||||
|
pageValues.Set("searchType", "image")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := "https://www.googleapis.com/customsearch/v1?" + pageValues.Encode()
|
||||||
|
resp, err := s.Client.Get(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("google cse returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Items []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
DisplayLink string `json:"displayLink"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Image struct {
|
||||||
|
ThumbnailLink string `json:"thumbnailLink"`
|
||||||
|
} `json:"image"`
|
||||||
|
Pagemap struct {
|
||||||
|
CSEImage []struct {
|
||||||
|
Src string `json:"src"`
|
||||||
|
} `json:"cse_image"`
|
||||||
|
CSEThumbnail []struct {
|
||||||
|
Src string `json:"src"`
|
||||||
|
} `json:"cse_thumbnail"`
|
||||||
|
Metatags []map[string]string `json:"metatags"`
|
||||||
|
} `json:"pagemap"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]SearchResult, 0, len(payload.Items))
|
||||||
|
for _, item := range payload.Items {
|
||||||
|
thumb := item.Image.ThumbnailLink
|
||||||
|
if thumb == "" {
|
||||||
|
thumb = extractThumbnail(item.Pagemap)
|
||||||
|
}
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Title: item.Title,
|
||||||
|
Link: item.Link,
|
||||||
|
DisplayLink: item.DisplayLink,
|
||||||
|
Snippet: item.Snippet,
|
||||||
|
ThumbnailURL: thumb,
|
||||||
|
Source: inferSource(item.DisplayLink),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractThumbnail(pagemap struct {
|
||||||
|
CSEImage []struct{ Src string "json:\"src\"" } "json:\"cse_image\""
|
||||||
|
CSEThumbnail []struct{ Src string "json:\"src\"" } "json:\"cse_thumbnail\""
|
||||||
|
Metatags []map[string]string "json:\"metatags\""
|
||||||
|
}) string {
|
||||||
|
if len(pagemap.CSEThumbnail) > 0 && pagemap.CSEThumbnail[0].Src != "" {
|
||||||
|
return pagemap.CSEThumbnail[0].Src
|
||||||
|
}
|
||||||
|
if len(pagemap.CSEImage) > 0 && pagemap.CSEImage[0].Src != "" {
|
||||||
|
return pagemap.CSEImage[0].Src
|
||||||
|
}
|
||||||
|
for _, tag := range pagemap.Metatags {
|
||||||
|
if value := tag["og:image"]; value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if value := tag["twitter:image"]; value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func inferSource(displayLink string) string {
|
func inferSource(displayLink string) string {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(displayLink, "youtube"):
|
case strings.Contains(displayLink, "youtube"):
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ const startTime = document.getElementById("startTime");
|
|||||||
const endTime = document.getElementById("endTime");
|
const endTime = document.getElementById("endTime");
|
||||||
const downloadResult = document.getElementById("downloadResult");
|
const downloadResult = document.getElementById("downloadResult");
|
||||||
const cardTemplate = document.getElementById("searchCardTemplate");
|
const cardTemplate = document.getElementById("searchCardTemplate");
|
||||||
|
const previewModal = document.getElementById("previewModal");
|
||||||
|
const previewTitle = document.getElementById("previewTitle");
|
||||||
|
const previewThumbnail = document.getElementById("previewThumbnail");
|
||||||
|
const previewDuration = document.getElementById("previewDuration");
|
||||||
|
const qualitySelect = document.getElementById("qualitySelect");
|
||||||
|
const confirmDownload = document.getElementById("confirmDownload");
|
||||||
|
const closePreviewModal = document.getElementById("closePreviewModal");
|
||||||
|
|
||||||
|
let pendingDownload = null;
|
||||||
|
|
||||||
function setStatus(label, progress) {
|
function setStatus(label, progress) {
|
||||||
statusLabel.textContent = label;
|
statusLabel.textContent = label;
|
||||||
@@ -97,7 +106,35 @@ async function uploadFile(file) {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
uploadResult.textContent = "uploading...";
|
uploadResult.textContent = "uploading...";
|
||||||
await api("/api/upload", { method: "POST", body: formData });
|
try {
|
||||||
|
await api("/api/upload", { method: "POST", body: formData });
|
||||||
|
} catch (error) {
|
||||||
|
uploadResult.textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreviewModal(preview) {
|
||||||
|
previewTitle.textContent = preview.title;
|
||||||
|
previewThumbnail.src = preview.thumbnail;
|
||||||
|
previewThumbnail.alt = preview.title;
|
||||||
|
previewDuration.textContent = preview.duration;
|
||||||
|
qualitySelect.innerHTML = "";
|
||||||
|
for (const item of preview.qualities || []) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = item.value;
|
||||||
|
option.textContent = item.label;
|
||||||
|
qualitySelect.appendChild(option);
|
||||||
|
}
|
||||||
|
startTime.value = preview.startDefault || "00:00:00";
|
||||||
|
endTime.value = preview.endDefault || "00:00:00";
|
||||||
|
previewModal.classList.remove("hidden");
|
||||||
|
previewModal.classList.add("flex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
previewModal.classList.add("hidden");
|
||||||
|
previewModal.classList.remove("flex");
|
||||||
|
pendingDownload = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
dropzone.addEventListener("dragover", (event) => {
|
dropzone.addEventListener("dragover", (event) => {
|
||||||
@@ -138,21 +175,49 @@ downloadForm.addEventListener("submit", async (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pendingDownload = { url: downloadUrl.value, force };
|
||||||
|
downloadResult.textContent = "loading preview...";
|
||||||
|
const preview = await api("/api/download/preview", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url: downloadUrl.value }),
|
||||||
|
});
|
||||||
|
openPreviewModal(preview);
|
||||||
|
downloadResult.textContent = "preview loaded";
|
||||||
|
} catch (error) {
|
||||||
|
downloadResult.textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmDownload.addEventListener("click", async () => {
|
||||||
|
if (!pendingDownload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
const data = await api("/api/download", {
|
const data = await api("/api/download", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: downloadUrl.value,
|
url: pendingDownload.url,
|
||||||
start: startTime.value,
|
start: startTime.value,
|
||||||
end: endTime.value,
|
end: endTime.value,
|
||||||
force,
|
quality: qualitySelect.value,
|
||||||
|
force: pendingDownload.force,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
closeModal();
|
||||||
downloadResult.textContent = data.message;
|
downloadResult.textContent = data.message;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
downloadResult.textContent = error.message;
|
downloadResult.textContent = error.message;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
closePreviewModal.addEventListener("click", closeModal);
|
||||||
|
previewModal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === previewModal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
connectWS();
|
connectWS();
|
||||||
setStatus("idle", 0);
|
setStatus("idle", 0);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-[0.4em] text-zinc-500">AI Media Asset Ingest Hub</p>
|
<p class="text-xs uppercase tracking-[0.4em] text-zinc-500">AI Media Asset Ingest Hub</p>
|
||||||
<h1 class="mt-3 text-3xl font-semibold tracking-tight text-white">Multimodal Discovery, Drag Upload, Direct Clip Ingest</h1>
|
<h1 class="mt-3 text-3xl font-semibold tracking-tight text-white">SAVE THE NURSE AI Search</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<div class="mb-2 flex items-center justify-between text-xs uppercase tracking-[0.3em] text-zinc-500">
|
<div class="mb-2 flex items-center justify-between text-xs uppercase tracking-[0.3em] text-zinc-500">
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
<input id="downloadUrl" type="url" placeholder="https://..." class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-zinc-500" />
|
<input id="downloadUrl" type="url" placeholder="https://..." class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-zinc-500" />
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<input id="startTime" type="text" value="00:00:00" class="rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
|
<input id="startTime" type="text" value="00:00:00" class="rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
|
||||||
<input id="endTime" type="text" value="00:00:10" class="rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
|
<input id="endTime" type="text" value="00:00:00" class="rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
|
||||||
</div>
|
</div>
|
||||||
<button class="w-full rounded-2xl border border-white px-5 py-3 text-sm font-medium text-white transition hover:bg-white hover:text-black">Queue Clip Download</button>
|
<button class="w-full rounded-2xl border border-white px-5 py-3 text-sm font-medium text-white transition hover:bg-white hover:text-black">Preview & Queue</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="downloadResult" class="mt-3 text-sm text-zinc-400"></p>
|
<p id="downloadResult" class="mt-3 text-sm text-zinc-400"></p>
|
||||||
</article>
|
</article>
|
||||||
@@ -72,6 +72,36 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-4">
|
||||||
|
<div class="w-full max-w-2xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Download Preview</p>
|
||||||
|
<h3 id="previewTitle" class="mt-2 text-xl font-semibold text-white"></h3>
|
||||||
|
</div>
|
||||||
|
<button id="closePreviewModal" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 grid gap-5 md:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div class="overflow-hidden rounded-3xl border border-white/10 bg-black/30">
|
||||||
|
<img id="previewThumbnail" class="aspect-video h-full w-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<div class="flex items-center justify-between text-sm text-zinc-400">
|
||||||
|
<span>Detected duration</span>
|
||||||
|
<span id="previewDuration"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-sm text-zinc-400">Quality</span>
|
||||||
|
<select id="qualitySelect" class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"></select>
|
||||||
|
</label>
|
||||||
|
<button id="confirmDownload" class="w-full rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">Confirm Download</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template id="searchCardTemplate">
|
<template id="searchCardTemplate">
|
||||||
<a target="_blank" rel="noreferrer" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 transition hover:border-white/30">
|
<a target="_blank" rel="noreferrer" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 transition hover:border-white/30">
|
||||||
<div class="relative aspect-video overflow-hidden bg-zinc-900">
|
<div class="relative aspect-video overflow-hidden bg-zinc-900">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
def emit(status, progress, message="", output=""):
|
def emit(status, progress, message="", output=""):
|
||||||
@@ -25,14 +26,74 @@ def run(cmd):
|
|||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration(value):
|
||||||
|
if value is None:
|
||||||
|
return "00:00:10"
|
||||||
|
total = int(float(value))
|
||||||
|
hours = total // 3600
|
||||||
|
minutes = (total % 3600) // 60
|
||||||
|
seconds = total % 60
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_label(height, ext):
|
||||||
|
if height:
|
||||||
|
return f"{height}p ({ext})"
|
||||||
|
return f"Best ({ext})"
|
||||||
|
|
||||||
|
|
||||||
|
def build_quality_options(formats: List[dict]):
|
||||||
|
heights = []
|
||||||
|
for item in formats:
|
||||||
|
height = item.get("height")
|
||||||
|
if isinstance(height, int) and height > 0:
|
||||||
|
heights.append(height)
|
||||||
|
|
||||||
|
unique_heights = sorted(set(heights))
|
||||||
|
options = [{"value": "best", "label": "Best available"}]
|
||||||
|
for height in unique_heights:
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"value": f"bestvideo[height<={height}]+bestaudio/best[height<={height}]",
|
||||||
|
"label": f"Up to {height}p",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def probe(url):
|
||||||
|
cmd = ["yt-dlp", "--dump-single-json", "--no-playlist", url]
|
||||||
|
proc = run(cmd)
|
||||||
|
payload = json.loads(proc.stdout)
|
||||||
|
thumbnail = payload.get("thumbnail") or ""
|
||||||
|
duration = payload.get("duration")
|
||||||
|
formats = payload.get("formats") or []
|
||||||
|
preview = {
|
||||||
|
"title": payload.get("title") or "Untitled",
|
||||||
|
"thumbnail": thumbnail,
|
||||||
|
"durationSeconds": duration or 0,
|
||||||
|
"duration": parse_duration(duration),
|
||||||
|
"startDefault": "00:00:00",
|
||||||
|
"endDefault": parse_duration(duration),
|
||||||
|
"qualities": build_quality_options(formats),
|
||||||
|
}
|
||||||
|
print(json.dumps(preview), flush=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--mode", choices=["probe", "download"], default="download")
|
||||||
parser.add_argument("--url", required=True)
|
parser.add_argument("--url", required=True)
|
||||||
parser.add_argument("--start", default="00:00:00")
|
parser.add_argument("--start", default="00:00:00")
|
||||||
parser.add_argument("--end", default="00:00:10")
|
parser.add_argument("--end", default="00:00:10")
|
||||||
parser.add_argument("--output", required=True)
|
parser.add_argument("--output", required=True)
|
||||||
|
parser.add_argument("--quality", default="best")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.mode == "probe":
|
||||||
|
probe(args.url)
|
||||||
|
return
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||||
emit("starting", 5, "Resolving media stream")
|
emit("starting", 5, "Resolving media stream")
|
||||||
|
|
||||||
@@ -42,7 +103,7 @@ def main():
|
|||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"-f",
|
"-f",
|
||||||
"mp4/bestvideo*+bestaudio/best",
|
args.quality,
|
||||||
"-o",
|
"-o",
|
||||||
source_path,
|
source_path,
|
||||||
args.url,
|
args.url,
|
||||||
|
|||||||
Reference in New Issue
Block a user