include-cbz.go (8805B)
1 package main 2 3 import ( 4 "archive/zip" 5 "fmt" 6 "image" 7 "image/jpeg" 8 "io" 9 "log" 10 "net/http" 11 "os" 12 "path/filepath" 13 "sort" 14 "strings" 15 ) 16 17 // generateCBZThumbnail creates a 2x2 collage thumbnail from a CBZ file 18 func generateCBZThumbnail(cbzPath, uploadDir, filename string) error { 19 20 thumbDir := filepath.Join(uploadDir, "thumbnails") 21 22 if err := os.MkdirAll(thumbDir, 0755); err != nil { 23 return fmt.Errorf("failed to create thumbnails directory: %v", err) 24 } 25 26 thumbPath := filepath.Join(thumbDir, filename+".jpg") 27 28 // Open the CBZ (ZIP) file 29 r, err := zip.OpenReader(cbzPath) 30 if err != nil { 31 return fmt.Errorf("failed to open CBZ: %v", err) 32 } 33 defer r.Close() 34 35 // Get list of image files from the archive 36 var imageFiles []*zip.File 37 for _, f := range r.File { 38 if !f.FileInfo().IsDir() { 39 ext := strings.ToLower(filepath.Ext(f.Name)) 40 if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { 41 imageFiles = append(imageFiles, f) 42 } 43 } 44 } 45 46 if len(imageFiles) == 0 { 47 return fmt.Errorf("no images found in CBZ") 48 } 49 50 // Sort files by name to get consistent ordering 51 sort.Slice(imageFiles, func(i, j int) bool { 52 return imageFiles[i].Name < imageFiles[j].Name 53 }) 54 55 // Select up to 4 images evenly distributed 56 var selectedFiles []*zip.File 57 if len(imageFiles) <= 4 { 58 selectedFiles = imageFiles 59 } else { 60 // Pick 4 images evenly distributed through the comic 61 step := len(imageFiles) / 4 62 for i := 0; i < 4; i++ { 63 selectedFiles = append(selectedFiles, imageFiles[i*step]) 64 } 65 } 66 67 // Load the selected images 68 var images []image.Image 69 for _, f := range selectedFiles { 70 rc, err := f.Open() 71 if err != nil { 72 log.Printf("Warning: generateCBZThumbnail: failed to open %s: %v", f.Name, err) 73 continue 74 } 75 76 img, _, err := image.Decode(rc) 77 rc.Close() 78 if err != nil { 79 log.Printf("Warning: generateCBZThumbnail: failed to decode %s: %v", f.Name, err) 80 continue 81 } 82 images = append(images, img) 83 } 84 85 if len(images) == 0 { 86 return fmt.Errorf("failed to decode any images from CBZ") 87 } 88 89 // Create collage 90 collage := createCollage(images, 400) // 400px target width 91 92 // Save as JPEG 93 outFile, err := os.Create(thumbPath) 94 if err != nil { 95 return fmt.Errorf("failed to create thumbnail file: %v", err) 96 } 97 defer outFile.Close() 98 99 if err := jpeg.Encode(outFile, collage, &jpeg.Options{Quality: 85}); err != nil { 100 return fmt.Errorf("failed to encode JPEG: %v", err) 101 } 102 103 return nil 104 } 105 106 // createCollage creates a 2x2 grid from up to 4 images 107 func createCollage(images []image.Image, targetWidth int) image.Image { 108 // Calculate cell size (half of target width) 109 cellSize := targetWidth / 2 110 111 // Create output image 112 collageImg := image.NewRGBA(image.Rect(0, 0, targetWidth, targetWidth)) 113 114 // Fill with white background 115 for y := 0; y < targetWidth; y++ { 116 for x := 0; x < targetWidth; x++ { 117 collageImg.Set(x, y, image.White) 118 } 119 } 120 121 // Positions for the 2x2 grid 122 positions := []image.Point{ 123 {0, 0}, // Top-left 124 {cellSize, 0}, // Top-right 125 {0, cellSize}, // Bottom-left 126 {cellSize, cellSize}, // Bottom-right 127 } 128 129 // Draw each image 130 for i, img := range images { 131 if i >= 4 { 132 break 133 } 134 135 // Resize image to fit cell 136 resized := resizeImage(img, cellSize, cellSize) 137 138 // Draw at position 139 pos := positions[i] 140 drawImage(collageImg, resized, pos.X, pos.Y) 141 } 142 143 return collageImg 144 } 145 146 // resizeImage resizes an image to fit within maxWidth x maxHeight while maintaining aspect ratio 147 func resizeImage(img image.Image, maxWidth, maxHeight int) image.Image { 148 bounds := img.Bounds() 149 srcWidth := bounds.Dx() 150 srcHeight := bounds.Dy() 151 152 // Calculate scale to fit 153 scaleX := float64(maxWidth) / float64(srcWidth) 154 scaleY := float64(maxHeight) / float64(srcHeight) 155 scale := scaleX 156 if scaleY < scale { 157 scale = scaleY 158 } 159 160 newWidth := int(float64(srcWidth) * scale) 161 newHeight := int(float64(srcHeight) * scale) 162 163 // Create new image 164 dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) 165 166 // Simple nearest-neighbor scaling 167 for y := 0; y < newHeight; y++ { 168 for x := 0; x < newWidth; x++ { 169 srcX := int(float64(x) / scale) 170 srcY := int(float64(y) / scale) 171 dst.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) 172 } 173 } 174 175 return dst 176 } 177 178 // drawImage draws src onto dst at the given position 179 func drawImage(dst *image.RGBA, src image.Image, x, y int) { 180 bounds := src.Bounds() 181 for dy := 0; dy < bounds.Dy(); dy++ { 182 for dx := 0; dx < bounds.Dx(); dx++ { 183 dst.Set(x+dx, y+dy, src.At(bounds.Min.X+dx, bounds.Min.Y+dy)) 184 } 185 } 186 } 187 188 // CBZImage represents a single image within a CBZ file 189 type CBZImage struct { 190 Filename string 191 Index int 192 } 193 194 // getCBZImages returns a list of images in a CBZ file 195 func getCBZImages(cbzPath string) ([]CBZImage, error) { 196 r, err := zip.OpenReader(cbzPath) 197 if err != nil { 198 return nil, err 199 } 200 defer r.Close() 201 202 var images []CBZImage 203 for i, f := range r.File { 204 if !f.FileInfo().IsDir() { 205 ext := strings.ToLower(filepath.Ext(f.Name)) 206 if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { 207 images = append(images, CBZImage{ 208 Filename: f.Name, 209 Index: i, 210 }) 211 } 212 } 213 } 214 215 // Sort by filename for consistent ordering 216 sort.Slice(images, func(i, j int) bool { 217 return images[i].Filename < images[j].Filename 218 }) 219 220 // Re-index after sorting 221 for i := range images { 222 images[i].Index = i 223 } 224 225 return images, nil 226 } 227 228 // serveCBZImage extracts and serves a specific image from a CBZ file 229 func serveCBZImage(w http.ResponseWriter, cbzPath string, imageIndex int) error { 230 r, err := zip.OpenReader(cbzPath) 231 if err != nil { 232 return err 233 } 234 defer r.Close() 235 236 // Get sorted list of images 237 var imageFiles []*zip.File 238 for _, f := range r.File { 239 if !f.FileInfo().IsDir() { 240 ext := strings.ToLower(filepath.Ext(f.Name)) 241 if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { 242 imageFiles = append(imageFiles, f) 243 } 244 } 245 } 246 247 sort.Slice(imageFiles, func(i, j int) bool { 248 return imageFiles[i].Name < imageFiles[j].Name 249 }) 250 251 if imageIndex < 0 || imageIndex >= len(imageFiles) { 252 return fmt.Errorf("image index out of range") 253 } 254 255 targetFile := imageFiles[imageIndex] 256 257 // Set content type based on extension 258 ext := strings.ToLower(filepath.Ext(targetFile.Name)) 259 switch ext { 260 case ".jpg", ".jpeg": 261 w.Header().Set("Content-Type", "image/jpeg") 262 case ".png": 263 w.Header().Set("Content-Type", "image/png") 264 case ".webp": 265 w.Header().Set("Content-Type", "image/webp") 266 } 267 268 rc, err := targetFile.Open() 269 if err != nil { 270 return err 271 } 272 defer rc.Close() 273 274 _, err = io.Copy(w, rc) 275 return err 276 } 277 278 // cbzViewerHandler handles the CBZ gallery viewer 279 func cbzViewerHandler(w http.ResponseWriter, r *http.Request) { 280 // Extract file ID from URL 281 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/cbz/"), "/") 282 if len(parts) < 1 { 283 renderError(w, "Invalid CBZ viewer path", http.StatusBadRequest) 284 return 285 } 286 287 fileID := parts[0] 288 289 // Get the file from database 290 var f File 291 err := db.QueryRow("SELECT id, filename, path, COALESCE(description, '') FROM files WHERE id = ?", fileID). 292 Scan(&f.ID, &f.Filename, &f.Path, &f.Description) 293 if err != nil { 294 log.Printf("Error: cbzViewerHandler: file not found for id=%s: %v", fileID, err) 295 renderError(w, "File not found", http.StatusNotFound) 296 return 297 } 298 299 cbzPath := filepath.Join(config.UploadDir, f.Path) 300 301 // Check if requesting a specific image 302 if len(parts) >= 3 && parts[1] == "image" { 303 imageIndex := 0 304 fmt.Sscanf(parts[2], "%d", &imageIndex) 305 306 if err := serveCBZImage(w, cbzPath, imageIndex); err != nil { 307 log.Printf("Error: cbzViewerHandler: failed to serve image index %d from %s: %v", imageIndex, cbzPath, err) 308 renderError(w, "Failed to serve image", http.StatusInternalServerError) 309 } 310 return 311 } 312 313 // Get list of images 314 images, err := getCBZImages(cbzPath) 315 if err != nil { 316 log.Printf("Error: cbzViewerHandler: failed to read CBZ contents at %s: %v", cbzPath, err) 317 renderError(w, "Failed to read CBZ contents", http.StatusInternalServerError) 318 return 319 } 320 321 // Determine which image to display 322 currentIndex := 0 323 if len(parts) >= 2 { 324 fmt.Sscanf(parts[1], "%d", ¤tIndex) 325 } 326 327 if currentIndex < 0 { 328 currentIndex = 0 329 } 330 if currentIndex >= len(images) { 331 currentIndex = len(images) - 1 332 } 333 334 // Prepare data for template 335 type CBZViewData struct { 336 File File 337 Images []CBZImage 338 CurrentIndex int 339 TotalImages int 340 HasPrev bool 341 HasNext bool 342 } 343 344 viewData := CBZViewData{ 345 File: f, 346 Images: images, 347 CurrentIndex: currentIndex, 348 TotalImages: len(images), 349 HasPrev: currentIndex > 0, 350 HasNext: currentIndex < len(images)-1, 351 } 352 353 pageData := buildPageData(f.Filename, viewData) 354 355 renderTemplate(w, "cbz_viewer.html", pageData) 356 }