taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

commit 1895a95ade79f08fe9c4bced6e9262e0185ebb9b
parent 7ec6eb2b63b42963872c2173f1765fe46a83fd92
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Fri, 28 Nov 2025 12:42:18 +0000

Initial release of in-app version of backfill.sh

Diffstat:
Dbackfill.sh | 56--------------------------------------------------------
Mmain.go | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/thumbnails.js | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atemplates/thumbnails.html | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 370 insertions(+), 56 deletions(-)

diff --git a/backfill.sh b/backfill.sh @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -upload_dir="uploads" -thumb_dir="$upload_dir/thumbnails" - -mkdir -p "$thumb_dir" - -target_file="${1:-}" # optional file to override -override_time="${2:-00:00:05}" # default timestamp - -generate_thumbnail() { - local file="$1" - local thumb="$2" - local timestamp="$3" - - echo "Generating thumbnail for $(basename "$file") at $timestamp as $thumb" - if ! ffmpeg -y -ss "$timestamp" -i "$file" -vframes 1 -vf scale=400:-1 "$thumb" 2>/dev/null; then - echo "Failed at $timestamp, retrying from start..." - ffmpeg -y -i "$file" -vframes 1 -vf scale=400:-1 "$thumb" - fi -} - -normalize_path() { - local file="$1" - if [[ "$file" == "$upload_dir/"* ]]; then - echo "$file" - else - echo "$upload_dir/$file" - fi -} - -if [[ -n "$target_file" ]]; then - file_path=$(normalize_path "$target_file") - filename=$(basename "$file_path") - thumb="$thumb_dir/${filename}.jpg" - - if [[ ! -f "$file_path" ]]; then - echo "File $file_path not found" - exit 1 - fi - - generate_thumbnail "$file_path" "$thumb" "$override_time" -else - find "$upload_dir" -maxdepth 1 -type f \( -iname "*.mp4" -o -iname "*.webm" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.m4v" \) | while read -r file; do - filename=$(basename "$file") - thumb="$thumb_dir/${filename}.jpg" - - if [[ -f "$thumb" ]]; then - echo "Skipping $filename as thumbnail already exists" - continue - fi - - generate_thumbnail "$file" "$thumb" "$override_time" - done -fi diff --git a/main.go b/main.go @@ -78,6 +78,15 @@ type Pagination struct { PerPage int } +type VideoFile struct { + ID int + Filename string + Path string + HasThumbnail bool + ThumbnailPath string + EscapedFilename string +} + func expandTagWithAliases(category, value string) []string { values := []string{value} @@ -371,6 +380,8 @@ func main() { http.HandleFunc("/bulk-tag", bulkTagHandler) http.HandleFunc("/admin", adminHandler) http.HandleFunc("/orphans", orphansHandler) + http.HandleFunc("/thumbnails", thumbnailsHandler) + http.HandleFunc("/thumbnails/generate", generateThumbnailHandler) http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(config.UploadDir)))) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) @@ -1875,6 +1886,175 @@ func orphansHandler(w http.ResponseWriter, r *http.Request) { renderTemplate(w, "orphans.html", pageData) } +func generateThumbnailAtTime(videoPath, uploadDir, filename, timestamp string) error { + thumbDir := filepath.Join(uploadDir, "thumbnails") + if err := os.MkdirAll(thumbDir, 0755); err != nil { + return fmt.Errorf("failed to create thumbnails directory: %v", err) + } + + thumbPath := filepath.Join(thumbDir, filename+".jpg") + + cmd := exec.Command("ffmpeg", "-y", "-ss", timestamp, "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to generate thumbnail at %s: %v", timestamp, err) + } + + return nil +} + +func getVideoFiles() ([]VideoFile, error) { + videoExts := []string{".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"} + + rows, err := db.Query(`SELECT id, filename, path FROM files ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var videos []VideoFile + for rows.Next() { + var v VideoFile + if err := rows.Scan(&v.ID, &v.Filename, &v.Path); err != nil { + continue + } + + // Check if it's a video file + isVideo := false + ext := strings.ToLower(filepath.Ext(v.Filename)) + for _, vidExt := range videoExts { + if ext == vidExt { + isVideo = true + break + } + } + + if !isVideo { + continue + } + + v.EscapedFilename = url.PathEscape(v.Filename) + thumbPath := filepath.Join(config.UploadDir, "thumbnails", v.Filename+".jpg") + v.ThumbnailPath = "/uploads/thumbnails/" + v.EscapedFilename + ".jpg" + + if _, err := os.Stat(thumbPath); err == nil { + v.HasThumbnail = true + } + + videos = append(videos, v) + } + + return videos, nil +} + +func getMissingThumbnailVideos() ([]VideoFile, error) { + allVideos, err := getVideoFiles() + if err != nil { + return nil, err + } + + var missing []VideoFile + for _, v := range allVideos { + if !v.HasThumbnail { + missing = append(missing, v) + } + } + + return missing, nil +} + +func thumbnailsHandler(w http.ResponseWriter, r *http.Request) { + allVideos, err := getVideoFiles() + if err != nil { + renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError) + return + } + + missing, err := getMissingThumbnailVideos() + if err != nil { + renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError) + return + } + + pageData := buildPageData("Thumbnail Management", struct { + AllVideos []VideoFile + MissingThumbnails []VideoFile + Error string + Success string + }{ + AllVideos: allVideos, + MissingThumbnails: missing, + Error: r.URL.Query().Get("error"), + Success: r.URL.Query().Get("success"), + }) + + renderTemplate(w, "thumbnails.html", pageData) +} + +func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/thumbnails", http.StatusSeeOther) + return + } + + action := r.FormValue("action") + + switch action { + case "generate_all": + missing, err := getMissingThumbnailVideos() + if err != nil { + http.Redirect(w, r, "/thumbnails?error="+url.QueryEscape("Failed to get videos: "+err.Error()), http.StatusSeeOther) + return + } + + successCount := 0 + var errors []string + + for _, v := range missing { + err := generateThumbnail(v.Path, config.UploadDir, v.Filename) + if err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", v.Filename, err)) + } else { + successCount++ + } + } + + if len(errors) > 0 { + http.Redirect(w, r, "/thumbnails?success="+url.QueryEscape(fmt.Sprintf("Generated %d thumbnails", successCount))+"&error="+url.QueryEscape(fmt.Sprintf("Failed: %s", strings.Join(errors, "; "))), http.StatusSeeOther) + } else { + http.Redirect(w, r, "/thumbnails?success="+url.QueryEscape(fmt.Sprintf("Successfully generated %d thumbnails", successCount)), http.StatusSeeOther) + } + + case "generate_single": + fileID := r.FormValue("file_id") + timestamp := strings.TrimSpace(r.FormValue("timestamp")) + + if timestamp == "" { + timestamp = "00:00:05" + } + + var filename, path string + err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&filename, &path) + if err != nil { + http.Redirect(w, r, "/thumbnails?error="+url.QueryEscape("File not found"), http.StatusSeeOther) + return + } + + err = generateThumbnailAtTime(path, config.UploadDir, filename, timestamp) + if err != nil { + http.Redirect(w, r, "/thumbnails?error="+url.QueryEscape("Failed to generate thumbnail: "+err.Error()), http.StatusSeeOther) + return + } + + http.Redirect(w, r, fmt.Sprintf("/file/%s?success=%s", fileID, url.QueryEscape(fmt.Sprintf("Thumbnail generated at %s", timestamp))), http.StatusSeeOther) + + default: + http.Redirect(w, r, "/thumbnails", http.StatusSeeOther) + } +} + func generateThumbnail(videoPath, uploadDir, filename string) error { thumbDir := filepath.Join(uploadDir, "thumbnails") if err := os.MkdirAll(thumbDir, 0755); err != nil { diff --git a/static/thumbnails.js b/static/thumbnails.js @@ -0,0 +1,72 @@ +function showTab(tabName) { + // Hide all content + document.getElementById('content-missing').style.display = 'none'; + document.getElementById('content-all').style.display = 'none'; + + // Remove active styling from all tabs + document.getElementById('tab-missing').style.borderBottomColor = 'transparent'; + document.getElementById('tab-missing').style.fontWeight = 'normal'; + document.getElementById('tab-all').style.borderBottomColor = 'transparent'; + document.getElementById('tab-all').style.fontWeight = 'normal'; + + // Show selected content and style tab + document.getElementById('content-' + tabName).style.display = 'block'; + document.getElementById('tab-' + tabName).style.borderBottomColor = '#007bff'; + document.getElementById('tab-' + tabName).style.fontWeight = 'bold'; +} + +// Auto-hide success messages +function autoHideSuccess() { + const successDivs = document.querySelectorAll('.auto-hide-success'); + successDivs.forEach(div => { + setTimeout(() => { + div.style.transition = 'opacity 0.5s'; + div.style.opacity = '0'; + setTimeout(() => div.remove(), 500); + }, 5000); + }); +} + +// Call auto-hide +autoHideSuccess(); + +// Add video preview on hover +document.querySelectorAll('video').forEach(video => { + video.addEventListener('mouseenter', function() { + this.play(); + }); + video.addEventListener('mouseleave', function() { + this.pause(); + this.currentTime = 0; + }); +}); + +// Add timestamp helper +document.querySelectorAll('input[name="timestamp"]').forEach(input => { + const video = input.closest('div[style*="border"]').querySelector('video'); + if (video) { + input.addEventListener('focus', function() { + video.play(); + }); + + // Click on video to set current time as timestamp + video.addEventListener('click', function(e) { + e.preventDefault(); + const currentTime = this.currentTime; + const hours = Math.floor(currentTime / 3600); + const minutes = Math.floor((currentTime % 3600) / 60); + const seconds = Math.floor(currentTime % 60); + const formatted = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + const timestampInput = this.closest('div[style*="border"]').querySelector('input[name="timestamp"]'); + if (timestampInput) { + timestampInput.value = formatted; + // Flash the input to show it updated + timestampInput.style.backgroundColor = '#ffffcc'; + setTimeout(() => { + timestampInput.style.backgroundColor = ''; + }, 500); + } + this.pause(); + }); + } +}); +\ No newline at end of file diff --git a/templates/thumbnails.html b/templates/thumbnails.html @@ -0,0 +1,116 @@ +{{template "_header" .}} +<h1>Thumbnail Management</h1> + +{{if .Data.Error}} +<div style="background-color: #f8d7da; color: #721c24; padding: 10px; border: 1px solid #f5c6cb; border-radius: 4px; margin-bottom: 20px;"> + <strong>Error:</strong> {{.Data.Error}} +</div> +{{end}} + +{{if .Data.Success}} +<div style="background-color: #d4edda; color: #155724; padding: 10px; border: 1px solid #c3e6cb; border-radius: 4px; margin-bottom: 20px;"> + <strong>Success:</strong> {{.Data.Success}} +</div> +{{end}} + +<!-- Tab Navigation --> +<div style="margin-bottom: 20px; border-bottom: 2px solid #ddd;"> + <button onclick="showTab('missing')" id="tab-missing" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid #007bff; font-weight: bold;"> + Missing ({{len .Data.MissingThumbnails}}) + </button> + <button onclick="showTab('all')" id="tab-all" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> + All Videos ({{len .Data.AllVideos}}) + </button> +</div> + +<!-- Missing Thumbnails Tab --> +<div id="content-missing" style="margin-bottom: 30px;"> + <h2>Missing Thumbnails ({{len .Data.MissingThumbnails}})</h2> + + {{if .Data.MissingThumbnails}} + <form method="post" action="/thumbnails/generate" style="margin-bottom: 20px;"> + <input type="hidden" name="action" value="generate_all"> + <button type="submit" onclick="return confirm('Generate thumbnails for all {{len .Data.MissingThumbnails}} videos? This may take a while.');" style="background-color: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Generate All Missing Thumbnails + </button> + <small style="color: #666; margin-left: 10px;">Uses timestamp 00:00:05 for all videos</small> + </form> + + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;"> + {{range .Data.MissingThumbnails}} + <div style="border: 1px solid #ddd; padding: 15px; border-radius: 5px; background-color: #f8f9fa;"> + <h3 style="margin-top: 0; font-size: 14px; word-break: break-word;"> + <a href="/file/{{.ID}}" target="_blank">{{.Filename}}</a> + </h3> + <p style="color: #666; font-size: 12px; margin: 5px 0;">ID: {{.ID}}</p> + + <video width="100%" style="max-height: 200px; background: #000; margin: 10px 0; cursor: pointer;" title="Click to capture frame"> + <source src="/uploads/{{.EscapedFilename}}"> + </video> + + <form method="post" action="/thumbnails/generate" style="margin-top: 10px;"> + <input type="hidden" name="action" value="generate_single"> + <input type="hidden" name="file_id" value="{{.ID}}"> + + <div style="display: flex; gap: 5px; align-items: center; margin-bottom: 10px;"> + <label style="font-size: 13px; white-space: nowrap;">Timestamp:</label> + <input type="text" name="timestamp" value="00:00:05" placeholder="00:00:05" + style="flex: 1; padding: 5px; font-size: 13px; font-family: monospace;"> + </div> + + <button type="submit" style="background-color: #007bff; color: white; padding: 6px 12px; border: none; border-radius: 3px; font-size: 13px; cursor: pointer; width: 100%;"> + Generate Thumbnail + </button> + </form> + </div> + {{end}} + </div> + {{else}} + <div style="padding: 20px; background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; border-radius: 4px;"> + <strong>✓ All videos have thumbnails!</strong> + </div> + {{end}} +</div> + +<!-- All Videos Tab --> +<div id="content-all" style="margin-bottom: 30px; display: none;"> + <h2>Regenerate Thumbnail</h2> + + <div style="margin-bottom: 20px; padding: 10px; background-color: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 4px;"> + <strong>Tip:</strong> Enter a file ID to regenerate its thumbnail with a custom timestamp. You can find file IDs in the URL when viewing a file (e.g., /file/312). + </div> + + <form method="post" action="/thumbnails/generate" style="max-width: 500px; padding: 20px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 5px;"> + <input type="hidden" name="action" value="generate_single"> + + <div style="margin-bottom: 20px;"> + <label for="file_id" style="display: block; font-weight: bold; margin-bottom: 5px;">File ID:</label> + <input type="text" id="file_id" name="file_id" required + style="width: 100%; padding: 8px; font-size: 14px; font-family: monospace;" + placeholder="e.g., 312"> + <small style="color: #666;">Enter the ID of the video file</small> + </div> + + <div style="margin-bottom: 20px;"> + <label for="timestamp" style="display: block; font-weight: bold; margin-bottom: 5px;">Timestamp:</label> + <input type="text" id="timestamp" name="timestamp" value="00:00:05" required + style="width: 100%; padding: 8px; font-size: 14px; font-family: monospace;" + placeholder="00:00:05"> + <small style="color: #666;">Format: HH:MM:SS (e.g., 00:00:05 for 5 seconds)</small> + </div> + + <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; width: 100%;"> + Generate/Regenerate Thumbnail + </button> + </form> + + {{if .Data.Success}} + <div class="auto-hide-success" style="margin-top: 20px; padding: 15px; background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; border-radius: 4px;"> + <strong>✓ Success!</strong> {{.Data.Success}} + </div> + {{end}} +</div> + +<script src="/static/thumbnails.js" defer></script> + +{{template "_footer"}} +\ No newline at end of file