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