feat(preview): add archive listing and browser support

Introduces the ability to browse and preview the contents of archive files directly within the web interface.

Changes include:
- Added a new API endpoint `GET /d/{boxID}/archive/{fileID}` to fetch archive listings.
- Implemented on-demand archive listing generation in the backend.
- Updated the frontend preview component to support rendering and navigating archive contents.
This commit is contained in:
2026-06-08 03:43:43 +03:00
parent f9755fa98f
commit cba416b238
9 changed files with 852 additions and 2 deletions

View File

@@ -48,8 +48,10 @@ type fileView struct {
DownloadURL string
ThumbnailURL string
SceneURL string
ArchiveURL string
HasThumbnail bool
HasScene bool
HasArchive bool
IconURL string
IconRetroURL string
ReactURL string
@@ -384,6 +386,51 @@ func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if !jobs.NeedsArchiveListing(file) {
http.NotFound(w, r)
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
}
}
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err != nil {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err == nil {
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
return
}
}
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) {
return ""
@@ -432,6 +479,31 @@ func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services
return scene
}
func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string {
if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) {
return ""
}
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
if err != nil || listing == "" {
if err != nil {
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
}
return ""
}
for i := range box.Files {
if box.Files[i].ID == file.ID {
box.Files[i].ArchiveListing = listing
box.Files[i].ArchiveListingObjectKey = ""
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return listing
}
// servePlaceholderThumbnail serves the fallback image with no-store so the
// browser re-requests on the next load and picks up the real thumbnail as soon
// as it has been generated.
@@ -577,8 +649,10 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID),
ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file),
HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file),
IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),