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:
| D | backfill.sh | | | 56 | -------------------------------------------------------- |
| M | main.go | | | 180 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | static/thumbnails.js | | | 73 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | templates/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