mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
6
Commits
main
...
v1.2.3-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
683c837d0c | ||
|
|
b95ce79d50 | ||
|
|
a0c6b123de | ||
|
|
5832a2ebfd | ||
|
|
a9d738d419 | ||
|
|
952484cd3d |
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v1.2.3-beta
|
||||
|
||||
**Notes**:
|
||||
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
|
||||
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
|
||||
|
||||
**BugFixes**:
|
||||
- fix stuck motion preview popup window issue
|
||||
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
|
||||
- share delete with user scope issue
|
||||
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
|
||||
- delete prompt icons show image previews
|
||||
|
||||
## v1.2.2-beta
|
||||
|
||||
**New Features**:
|
||||
- Resizable sidebar (#1896)
|
||||
- OIDC Authentication: Change Button Text via `frontend.oidcLoginButtonText` (#1708)
|
||||
|
||||
@@ -2,7 +2,6 @@ package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -236,7 +235,8 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
} else {
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
|
||||
info.HasMetadata = true
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -323,11 +323,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract album art and encode as base64 with strict size limits
|
||||
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
|
||||
if picture := m.Picture(); picture != nil && picture.Data != nil {
|
||||
// More aggressive size limit to prevent memory issues (max 5MB)
|
||||
// Size limit to prevent memory issues (max 5MB)
|
||||
if len(picture.Data) <= 5*1024*1024 {
|
||||
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
item.Metadata.AlbumArt = picture.Data
|
||||
} else {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
|
||||
}
|
||||
|
||||
+8
-1
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/icons"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
@@ -194,13 +195,19 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
fileutils.InitAssetFS(nil, false)
|
||||
}
|
||||
|
||||
// setup disk cache
|
||||
// Start preview service
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
}
|
||||
|
||||
fbhttp.StartHttp(ctx, store, shutdownComplete)
|
||||
return nil
|
||||
}
|
||||
@@ -247,7 +247,6 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
continue
|
||||
}
|
||||
|
||||
oldFullPath := l.Path
|
||||
l.Source = newSource
|
||||
l.Path = newPath
|
||||
|
||||
@@ -264,14 +263,8 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
s.shareCache[l.Hash] = l
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated == 0 {
|
||||
logger.Warning("no matching shares to update for provided source/path")
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
devMode: settings.Env.IsDevMode,
|
||||
}
|
||||
|
||||
// Initialize cached PWA manifest
|
||||
InitializePWAManifest()
|
||||
|
||||
router := http.NewServeMux()
|
||||
// API group routing
|
||||
api := http.NewServeMux()
|
||||
|
||||
+14
-15
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -26,6 +25,7 @@ import (
|
||||
|
||||
type requestContext struct {
|
||||
user *users.User
|
||||
shareUser *users.User
|
||||
fileInfo iteminfo.ExtendedFileInfo
|
||||
token string
|
||||
share *share.Link
|
||||
@@ -51,12 +51,7 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
|
||||
func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
|
||||
path, err := url.PathUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
@@ -103,23 +98,27 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
if link.DisableFileViewer || reachedDownloadsLimit {
|
||||
getContent = false
|
||||
}
|
||||
shareCreatedByUser, err := store.Users.Get(link.UserID)
|
||||
data.shareUser, err = store.Users.Get(link.UserID)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
|
||||
}
|
||||
// get user scope path from share
|
||||
userScope, err := shareCreatedByUser.GetScopeForSourceName(source.Name)
|
||||
userScope, err := data.shareUser.GetScopeForSourceName(source.Name)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
// so trim user scope from link.Path
|
||||
userScopedPath := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
|
||||
if !strings.HasSuffix(userScopedPath, "/") {
|
||||
userScopedPath = userScopedPath + "/"
|
||||
pathWithoutUserScope := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
|
||||
if !strings.HasSuffix(pathWithoutUserScope, "/") {
|
||||
pathWithoutUserScope = pathWithoutUserScope + "/"
|
||||
}
|
||||
data.IndexPath = pathWithoutUserScope
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
|
||||
// no need to fetch file info for uploads
|
||||
return fn(w, r, data)
|
||||
}
|
||||
data.IndexPath = userScopedPath
|
||||
file, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
Path: userScopedPath,
|
||||
Path: pathWithoutUserScope,
|
||||
Source: source.Name,
|
||||
Expand: true,
|
||||
Content: getContent,
|
||||
@@ -128,7 +127,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: link.ShowHidden,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, shareCreatedByUser)
|
||||
}, store.Access, data.shareUser)
|
||||
if err != nil {
|
||||
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
|
||||
return errToStatus(err), fmt.Errorf("error fetching share from server")
|
||||
|
||||
+10
-24
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
@@ -143,7 +142,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param targetPath query string true "Target path within the share to upload to. Must be relative to share root."
|
||||
// @Param path query string true "path within the share to upload to. Must be relative to share root."
|
||||
// @Param override query bool false "If true, overwrite existing files/folders. Defaults to false."
|
||||
// @Param action query string false "Upload action: 'override' to replace files, 'rename' to auto-rename"
|
||||
// @Param file formData file true "File to upload"
|
||||
@@ -159,18 +158,15 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
if d.share.ShareType != "upload" && !d.share.AllowCreate {
|
||||
return http.StatusForbidden, fmt.Errorf("uploading is disabled for this share")
|
||||
}
|
||||
|
||||
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
|
||||
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
|
||||
}
|
||||
// Go automatically decodes query params
|
||||
path := r.URL.Query().Get("targetPath")
|
||||
fullPath := filepath.Join(d.share.Path, path)
|
||||
source := config.Server.SourceMap[d.share.Source].Name
|
||||
// adjust query params to match resourcePostHandler
|
||||
q := r.URL.Query()
|
||||
q.Set("source", source)
|
||||
q.Set("path", fullPath)
|
||||
q.Set("path", d.IndexPath)
|
||||
r.URL.RawQuery = q.Encode()
|
||||
status, err := resourcePostHandler(w, r, d)
|
||||
if err != nil {
|
||||
@@ -269,36 +265,26 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// deprecated -- see publicBulkDeleteHandler
|
||||
func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.share.AllowDelete {
|
||||
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: indexPath,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
}, store.Access, d.user)
|
||||
Path: d.IndexPath,
|
||||
Source: d.share.Source,
|
||||
}, store.Access, d.shareUser)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
err = files.DeleteFiles(d.share.Source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
logger.Errorf("public delete handler: error deleting resource with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
|
||||
}
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
@@ -320,10 +306,10 @@ func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestC
|
||||
if !d.share.AllowDelete {
|
||||
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
|
||||
}
|
||||
|
||||
// hide the error
|
||||
status, err := resourceBulkDeleteHandler(w, r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("public bulk delete handler: error deleting resources with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
|
||||
}
|
||||
return status, nil
|
||||
|
||||
+43
-47
@@ -203,6 +203,10 @@ type MoveCopyResponse struct {
|
||||
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
|
||||
// @Router /api/resources/bulk/delete [post]
|
||||
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
filePermUser := d.user
|
||||
if d.share != nil {
|
||||
filePermUser = d.shareUser
|
||||
}
|
||||
// Check permissions - either user delete permission or share delete permission
|
||||
if d.share == nil {
|
||||
if !d.user.Permissions.Delete {
|
||||
@@ -248,18 +252,29 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
if d.share != nil {
|
||||
indexPath := utils.JoinPathAsUnix(d.share.Path, item.Path)
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
// get user scope path from share
|
||||
userScope, err := d.shareUser.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: "user does not have access",
|
||||
})
|
||||
continue
|
||||
}
|
||||
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: indexPath,
|
||||
Source: source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, d.user)
|
||||
}, store.Access, filePermUser)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
@@ -289,7 +304,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Check user scope for this source
|
||||
_, err := d.user.GetScopeForSourceName(item.Source)
|
||||
_, err := filePermUser.GetScopeForSourceName(item.Source)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -315,7 +330,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
Path: idx.MakeIndexPath(item.Path, false),
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, d.user)
|
||||
}, store.Access, filePermUser)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -354,7 +369,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
|
||||
// @Param path query string true "url encoded destination path where to place the files inside the destination source"
|
||||
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
|
||||
// @Param override query bool false "Override existing file if true"
|
||||
// @Param isDir query bool false "Explicitly specify if the resource is a directory"
|
||||
@@ -367,22 +382,14 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
var err error
|
||||
accessStore := store.Access
|
||||
// if share is not nil, then set accessStore to nil
|
||||
if d.share != nil {
|
||||
accessStore = nil
|
||||
} else {
|
||||
// Go automatically decodes query params - no need for QueryUnescape
|
||||
if !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
// Path is now handled by FileInfoFaster which will apply user scope
|
||||
if d.share == nil && !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
|
||||
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
|
||||
isDirParam := r.URL.Query().Get("isDir")
|
||||
isDir := isDirParam == "true" || strings.HasSuffix(path, "/")
|
||||
filePermUser := d.user
|
||||
if d.share != nil {
|
||||
filePermUser = d.shareUser
|
||||
}
|
||||
isDir := r.URL.Query().Get("isDir") == "true"
|
||||
fileOpts := utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
@@ -394,10 +401,21 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Debugf("source %s not found", source)
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
|
||||
userscope, err := filePermUser.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting scope from source name: %v", err)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
userscope = strings.TrimRight(userscope, "/")
|
||||
|
||||
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
|
||||
|
||||
// get scoped path
|
||||
realPath, _, _ := idx.GetRealPath(fullIndexPath)
|
||||
|
||||
// Check access control for the target path
|
||||
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
|
||||
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
|
||||
@@ -416,14 +434,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// Directories creation on POST.
|
||||
if isDir {
|
||||
// Get user scope to resolve full index path for directory creation
|
||||
var userScope string
|
||||
userScope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Create a new FileOptions with the full index path
|
||||
dirOpts := fileOpts
|
||||
dirOpts.Path = fullIndexPath
|
||||
@@ -469,7 +479,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore, d.user)
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -519,23 +529,16 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// close file before moving
|
||||
outFile.Close()
|
||||
// Move the completed file from the temp location to the final destination
|
||||
err = fileutils.MoveFile(tempFilePath, realPath)
|
||||
err = files.MoveResource(false, source, source, tempFilePath, realPath, store.Share, store.Access)
|
||||
if err != nil {
|
||||
logger.Debugf("could not move file from %v to %v: %v", tempFilePath, realPath, err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("could not move file from chunked folder to destination: %v", err)
|
||||
}
|
||||
// Refresh index with user scope
|
||||
userScope, scopeErr := d.user.GetScopeForSourceName(source)
|
||||
if scopeErr == nil {
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, fileOpts.Path)
|
||||
go files.RefreshIndex(source, fullIndexPath, false, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore, d.user)
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -545,13 +548,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
}
|
||||
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
|
||||
if err != nil {
|
||||
logger.Debugf("error writing file: %v", err)
|
||||
|
||||
+129
-14
@@ -16,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
var templateRenderer *TemplateRenderer
|
||||
var cachedManifest PWAManifest // generated at startup
|
||||
|
||||
type TemplateRenderer struct {
|
||||
templates *template.Template
|
||||
@@ -78,7 +79,12 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
disableSidebar := false
|
||||
|
||||
// Use custom favicon if configured and validated, otherwise fall back to default
|
||||
favicon := staticURL + "/favicon"
|
||||
// Determine the correct favicon extension based on type
|
||||
faviconExt := ".svg" // Default to SVG
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
|
||||
faviconExt = ".png"
|
||||
}
|
||||
favicon := staticURL + "/favicon" + faviconExt
|
||||
shareHash := ""
|
||||
data := make(map[string]interface{})
|
||||
disableNavButtons := settings.Config.Frontend.DisableNavButtons
|
||||
@@ -193,6 +199,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"pwaIcon192": pwaIcon192,
|
||||
"pwaIcon256": pwaIcon256,
|
||||
"pwaIcon512": pwaIcon512,
|
||||
"manifestURL": staticURL + "/site.webmanifest",
|
||||
}
|
||||
// variables consumed by frontend as json
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
@@ -270,6 +277,16 @@ func setContentType(w http.ResponseWriter, path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// manifestHandler serves the cached PWA manifest
|
||||
func manifestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
|
||||
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
|
||||
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// staticAssetHandler serves static assets exactly as the frontend build produces them
|
||||
func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
const maxAge = 86400 // 1 day
|
||||
@@ -285,25 +302,30 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle special routes that need path mapping
|
||||
var assetPath string
|
||||
switch path {
|
||||
case "favicon.svg", "favicon":
|
||||
// Handle custom favicon from filesystem
|
||||
if settings.Env.FaviconIsCustom {
|
||||
http.ServeFile(w, r, settings.Env.FaviconPath)
|
||||
return
|
||||
}
|
||||
// Use embedded default
|
||||
assetPath = settings.Env.FaviconEmbeddedPath
|
||||
case "pwa-icon.svg":
|
||||
// Serve custom SVG favicon for PWA if configured
|
||||
case "site.webmanifest":
|
||||
manifestHandler(w, r)
|
||||
return
|
||||
case "favicon.svg":
|
||||
// For custom SVG favicons, serve the SVG directly
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
http.ServeFile(w, r, settings.Env.FaviconPath)
|
||||
return
|
||||
}
|
||||
// Fall back to default favicon SVG
|
||||
// Use embedded default SVG
|
||||
assetPath = settings.Env.FaviconEmbeddedPath
|
||||
case "favicon-16x16.png", "favicon-32x32.png",
|
||||
case "favicon.png":
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fall back to embedded default favicon.png
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "favicon-32x32.png",
|
||||
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
|
||||
"apple-touch-icon.png", "mstile-256x256.png":
|
||||
"apple-touch-icon.png":
|
||||
// Serve generated icons from cache directory
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
@@ -312,6 +334,15 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// Fall back to embedded favicon.png if generation failed
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "mstile-256x256.png":
|
||||
// Windows tile - redirect to pwa-icon-256.png (they're identical)
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
}
|
||||
// Fall back to embedded favicon.png if generation failed
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "loginIcon":
|
||||
// Handle custom login icon from filesystem
|
||||
if settings.Env.LoginIconIsCustom {
|
||||
@@ -364,3 +395,87 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
}
|
||||
return handleWithStaticData(w, r, d, "index.html", "text/html")
|
||||
}
|
||||
|
||||
// PWAManifest represents the web app manifest structure
|
||||
type PWAManifest struct {
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"short_name"`
|
||||
Icons []PWAIcon `json:"icons"`
|
||||
StartURL string `json:"start_url"`
|
||||
Display string `json:"display"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
ThemeColor string `json:"theme_color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// PWAIcon represents an icon in the web app manifest
|
||||
type PWAIcon struct {
|
||||
Src string `json:"src"`
|
||||
Sizes string `json:"sizes"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
// generatePWAManifest creates the PWA manifest structure
|
||||
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
|
||||
shortName := name
|
||||
if len(name) > 12 {
|
||||
shortName = name[:12]
|
||||
}
|
||||
|
||||
return PWAManifest{
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
StartURL: baseURL,
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#ffffff",
|
||||
ThemeColor: themeColor,
|
||||
Description: description,
|
||||
Icons: []PWAIcon{
|
||||
{
|
||||
Src: pwaIcon192,
|
||||
Sizes: "192x192",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon256,
|
||||
Sizes: "256x256",
|
||||
Type: "image/png",
|
||||
Purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon512,
|
||||
Sizes: "512x512",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InitializePWAManifest generates and caches the PWA manifest at startup
|
||||
func InitializePWAManifest() {
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
title := config.Frontend.Name
|
||||
description := config.Frontend.Description
|
||||
defaultThemeColor := "#455a64"
|
||||
|
||||
// Determine PWA icon URLs based on custom favicon settings
|
||||
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
|
||||
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
|
||||
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
|
||||
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
favicon := staticURL + "/favicon"
|
||||
pwaIcon192 = favicon
|
||||
pwaIcon256 = favicon
|
||||
pwaIcon512 = favicon
|
||||
} else if settings.Env.FaviconIsCustom {
|
||||
pwaIcon192 = staticURL + "/pwa-icon-192.png"
|
||||
pwaIcon256 = staticURL + "/pwa-icon-256.png"
|
||||
pwaIcon512 = staticURL + "/pwa-icon-512.png"
|
||||
}
|
||||
|
||||
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
// sourceType represents the type of favicon source
|
||||
type sourceType int
|
||||
|
||||
const (
|
||||
sourceCustomSVG sourceType = iota
|
||||
sourceCustomRaster
|
||||
sourceDefault
|
||||
)
|
||||
|
||||
// iconSize defines an icon size to generate
|
||||
type iconSize struct {
|
||||
size int
|
||||
name string
|
||||
env *string
|
||||
}
|
||||
|
||||
// determineSourceType determines which type of favicon source we have
|
||||
func determineSourceType(faviconPath string) sourceType {
|
||||
if !settings.Env.FaviconIsCustom {
|
||||
return sourceDefault
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(faviconPath))
|
||||
switch ext {
|
||||
case ".svg":
|
||||
return sourceCustomSVG
|
||||
default:
|
||||
return sourceCustomRaster
|
||||
}
|
||||
}
|
||||
|
||||
// loadDefaultFavicon loads the embedded default favicon PNG
|
||||
func loadDefaultFavicon() ([]byte, error) {
|
||||
assetFs := fileutils.GetAssetFS()
|
||||
if assetFs == nil {
|
||||
return nil, errors.New("asset filesystem not initialized")
|
||||
}
|
||||
|
||||
pngPath := "img/icons/favicon.png"
|
||||
sourceData, err := fs.ReadFile(assetFs, pngPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read default favicon PNG: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("Loaded default embedded favicon")
|
||||
return sourceData, nil
|
||||
}
|
||||
|
||||
// findRasterCompanion looks for a raster companion file for an SVG favicon
|
||||
// Returns the companion data and path, or error if not found
|
||||
func findRasterCompanion(svgPath string) ([]byte, string, error) {
|
||||
basePath := svgPath[:len(svgPath)-len(filepath.Ext(svgPath))]
|
||||
rasterExts := []string{".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
for _, ext := range rasterExts {
|
||||
companionPath := basePath + ext
|
||||
if _, err := os.Stat(companionPath); err == nil {
|
||||
data, readErr := os.ReadFile(companionPath)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
return data, companionPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("no raster companion found at %s.{png,jpg,webp,gif}", basePath)
|
||||
}
|
||||
|
||||
// convertRasterToPNG decodes any raster format and re-encodes as PNG
|
||||
func convertRasterToPNG(rasterData []byte, sourceExt string) ([]byte, error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(rasterData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to PNG: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf("Converted %s to PNG for icon generation", sourceExt)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// loadFaviconSource loads and prepares the favicon source data as PNG
|
||||
// Returns PNG data ready for resizing into icon sizes
|
||||
func loadFaviconSource() ([]byte, error) {
|
||||
sourceType := determineSourceType(settings.Env.FaviconPath)
|
||||
|
||||
switch sourceType {
|
||||
case sourceCustomSVG:
|
||||
logger.Debugf("Processing custom SVG favicon: %s", settings.Env.FaviconPath)
|
||||
return handleSVGFavicon(settings.Env.FaviconPath)
|
||||
|
||||
case sourceCustomRaster:
|
||||
logger.Debugf("Processing custom favicon: %s", settings.Env.FaviconPath)
|
||||
return handleRasterFavicon(settings.Env.FaviconPath)
|
||||
|
||||
case sourceDefault:
|
||||
logger.Debug("Using default embedded favicon")
|
||||
return loadDefaultFavicon()
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown icon source type")
|
||||
}
|
||||
}
|
||||
|
||||
// handleSVGFavicon processes an SVG favicon by finding its raster companion
|
||||
func handleSVGFavicon(svgPath string) ([]byte, error) {
|
||||
logger.Debug("Looking for companion for SVG favicon")
|
||||
|
||||
companionData, companionPath, err := findRasterCompanion(svgPath)
|
||||
if err != nil {
|
||||
logger.Warningf("SVG favicon has no companion: %v", err)
|
||||
logger.Warning("Falling back to default embedded favicon")
|
||||
logger.Warning("For best Apple/iOS compatibility, provide a PNG/JPG companion alongside your SVG")
|
||||
return loadDefaultFavicon()
|
||||
}
|
||||
|
||||
logger.Debugf("Found companion: %s", companionPath)
|
||||
return convertRasterToPNG(companionData, filepath.Ext(companionPath))
|
||||
}
|
||||
|
||||
// handleRasterFavicon processes a raster favicon (PNG, JPEG, GIF, WebP, etc.)
|
||||
func handleRasterFavicon(faviconPath string) ([]byte, error) {
|
||||
sourceData, err := os.ReadFile(faviconPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read favicon: %w", err)
|
||||
}
|
||||
|
||||
return convertRasterToPNG(sourceData, filepath.Ext(faviconPath))
|
||||
}
|
||||
|
||||
// getIconSizesToGenerate returns all icon sizes that need to be generated
|
||||
func getIconSizesToGenerate() []iconSize {
|
||||
return []iconSize{
|
||||
// Browser favicon (32x32 PNG for Safari/older browsers)
|
||||
{32, "favicon-32x32.png", nil},
|
||||
// PWA icons (PNG required for Apple PWA compatibility)
|
||||
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
|
||||
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
|
||||
// Platform-specific icons
|
||||
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
|
||||
}
|
||||
}
|
||||
|
||||
// generateIconSizes generates all required icon sizes from source PNG data
|
||||
func generateIconSizes(sourceData []byte) error {
|
||||
previewService := preview.GetService()
|
||||
if previewService == nil {
|
||||
return errors.New("preview service not initialized")
|
||||
}
|
||||
|
||||
iconSizes := getIconSizesToGenerate()
|
||||
allSuccess := true
|
||||
generatedCount := 0
|
||||
|
||||
for _, icon := range iconSizes {
|
||||
if err := generateSingleIcon(previewService, sourceData, icon); err != nil {
|
||||
logger.Warningf("Failed to generate %s: %v", icon.name, err)
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Update environment variable if specified
|
||||
if icon.env != nil {
|
||||
*icon.env = filepath.Join("icons", icon.name)
|
||||
}
|
||||
generatedCount++
|
||||
}
|
||||
|
||||
if allSuccess {
|
||||
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
|
||||
} else {
|
||||
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSingleIcon generates a single icon file at the specified size
|
||||
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
|
||||
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Resize image using the preview service's Resize method
|
||||
err = previewService.Resize(
|
||||
bytes.NewReader(sourceData),
|
||||
outFile,
|
||||
preview.ResizeOptions{
|
||||
Width: icon.size,
|
||||
Height: icon.size,
|
||||
ResizeMode: preview.ResizeModeFill,
|
||||
Quality: preview.QualityHigh,
|
||||
Format: preview.FormatPng,
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("resize failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePWAIcons generates all PWA and platform icon sizes from favicon source
|
||||
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
|
||||
func GeneratePWAIcons() error {
|
||||
logger.Info("Generating PWA icons...")
|
||||
|
||||
// Load and prepare source data as PNG
|
||||
sourceData, err := loadFaviconSource()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load favicon source: %w", err)
|
||||
}
|
||||
|
||||
// Generate all icon sizes
|
||||
if err := generateIconSizes(sourceData); err != nil {
|
||||
return fmt.Errorf("failed to generate icon sizes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -40,7 +40,7 @@ type MediaMetadata struct {
|
||||
Genre string `json:"genre,omitempty"` // music/video genre
|
||||
Track int `json:"track,omitempty"` // track number
|
||||
Duration int `json:"duration,omitempty"` // duration in seconds
|
||||
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
|
||||
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
|
||||
}
|
||||
|
||||
// for efficiency, a response will be a pointer to the data
|
||||
|
||||
@@ -489,12 +489,17 @@ func TestCreatePreviewFromReader_UsesConcurrencyLimit(t *testing.T) {
|
||||
data, err := io.ReadAll(source)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.CreatePreviewFromReader(bytes.NewReader(data), "small")
|
||||
// Get options for small preview
|
||||
options, err := getPreviewOptions("small")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create preview from reader
|
||||
out, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, out)
|
||||
|
||||
// Same via CreatePreview (bytes) for parity
|
||||
out2, err := svc.CreatePreview(data, "small")
|
||||
// Same via another call for parity
|
||||
out2, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, out, out2)
|
||||
}
|
||||
+236
-319
@@ -4,12 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -22,7 +20,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -164,10 +161,6 @@ func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
|
||||
logger.Errorf("WARNING: StartPreviewGenerator called multiple times! This will create multiple semaphores!")
|
||||
}
|
||||
service = NewPreviewGenerator(concurrencyLimit, cacheDir)
|
||||
|
||||
// Generate PWA icons after service is initialized
|
||||
GeneratePWAIcons()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -176,153 +169,6 @@ func GetService() *Service {
|
||||
return service
|
||||
}
|
||||
|
||||
// GeneratePWAIcons generates all icon sizes from favicon.svg or favicon.png
|
||||
func GeneratePWAIcons() {
|
||||
if service == nil {
|
||||
logger.Warning("Preview service not initialized, skipping icon generation")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine source icon - prefer custom, fallback to default
|
||||
var sourceIcon string
|
||||
var sourceData []byte
|
||||
var err error
|
||||
var isSVG bool
|
||||
|
||||
if settings.Env.FaviconIsCustom {
|
||||
sourceIcon = settings.Env.FaviconPath
|
||||
logger.Debugf("Generating icons from custom favicon: %s", sourceIcon)
|
||||
isSVG = strings.ToLower(filepath.Ext(sourceIcon)) == ".svg"
|
||||
|
||||
// Read the source file
|
||||
sourceData, err = os.ReadFile(sourceIcon)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to read custom favicon: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If it's not a raster format that imaging library can decode, handle separately
|
||||
if isSVG {
|
||||
// SVG: Set PWA icons to use SVG directly, look for PNG for raster generation
|
||||
logger.Debug("Source is SVG - will use directly where supported")
|
||||
settings.Env.PWAIcon192 = "pwa-icon.svg"
|
||||
settings.Env.PWAIcon256 = "pwa-icon.svg"
|
||||
settings.Env.PWAIcon512 = "pwa-icon.svg"
|
||||
|
||||
// Look for PNG in same directory with same base name
|
||||
basePath := sourceIcon[:len(sourceIcon)-len(filepath.Ext(sourceIcon))]
|
||||
pngPath := basePath + ".png"
|
||||
sourceData, err = os.ReadFile(pngPath)
|
||||
if err != nil {
|
||||
logger.Warningf("SVG favicon provided without PNG version at %s. For best compatibility across all browsers and platforms, provide a PNG version alongside your SVG.", pngPath)
|
||||
return
|
||||
}
|
||||
logger.Debugf("Using PNG version (%s) for generating raster icons", pngPath)
|
||||
} else {
|
||||
// For any raster format (JPEG, PNG, GIF, WebP, etc.), decode and convert to PNG
|
||||
// This creates a standardized PNG source for all icon generation
|
||||
img, decodeErr := imaging.Decode(bytes.NewReader(sourceData))
|
||||
if decodeErr != nil {
|
||||
logger.Warningf("Failed to decode favicon image: %v", decodeErr)
|
||||
return
|
||||
}
|
||||
// Re-encode as PNG in memory for consistent processing
|
||||
var buf bytes.Buffer
|
||||
if encodeErr := imaging.Encode(&buf, img, imaging.PNG); encodeErr != nil {
|
||||
logger.Warningf("Failed to convert favicon to PNG: %v", encodeErr)
|
||||
return
|
||||
}
|
||||
sourceData = buf.Bytes()
|
||||
logger.Debugf("Converted %s favicon to PNG for icon generation", filepath.Ext(sourceIcon))
|
||||
}
|
||||
} else {
|
||||
// Use default embedded favicon - we have both SVG and PNG versions
|
||||
logger.Debug("Generating icons from default favicon")
|
||||
// For default, use the PNG version for raster generation
|
||||
assetFs := fileutils.GetAssetFS()
|
||||
if assetFs == nil {
|
||||
logger.Warning("Asset filesystem not initialized, skipping default icon generation")
|
||||
return
|
||||
}
|
||||
pngPath := "img/icons/favicon.png"
|
||||
sourceData, err = fs.ReadFile(assetFs, pngPath)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to read default favicon PNG: %v", err)
|
||||
return
|
||||
}
|
||||
// Set PWA icons to use SVG for modern browsers
|
||||
settings.Env.PWAIcon192 = "pwa-icon.svg"
|
||||
settings.Env.PWAIcon256 = "pwa-icon.svg"
|
||||
settings.Env.PWAIcon512 = "pwa-icon.svg"
|
||||
}
|
||||
|
||||
// Define all icon sizes we need to generate
|
||||
// Format: {size, filename, envVar pointer (optional)}
|
||||
iconSizes := []struct {
|
||||
size int
|
||||
name string
|
||||
env *string
|
||||
}{
|
||||
// Browser favicon
|
||||
{32, "favicon-32x32.png", nil},
|
||||
// PWA icons
|
||||
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
|
||||
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256},
|
||||
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
|
||||
// Platform-specific icons
|
||||
{180, "apple-touch-icon.png", nil}, // iOS home screen
|
||||
{256, "mstile-256x256.png", nil}, // Windows tile
|
||||
}
|
||||
|
||||
allSuccess := true
|
||||
generatedCount := 0
|
||||
|
||||
for _, iconSize := range iconSizes {
|
||||
outputPath := filepath.Join(settings.Env.PWAIconsDir, iconSize.name)
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to create icon %s: %v", iconSize.name, err)
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Resize image using the service's Resize method
|
||||
err = service.Resize(
|
||||
bytes.NewReader(sourceData),
|
||||
outFile,
|
||||
ResizeOptions{
|
||||
Width: iconSize.size,
|
||||
Height: iconSize.size,
|
||||
ResizeMode: ResizeModeFill,
|
||||
Quality: QualityHigh,
|
||||
Format: FormatPng,
|
||||
},
|
||||
)
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to generate icon %s: %v", iconSize.name, err)
|
||||
os.Remove(outputPath)
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Update environment variable if specified
|
||||
if iconSize.env != nil {
|
||||
*iconSize.env = filepath.Join("icons", iconSize.name)
|
||||
}
|
||||
generatedCount++
|
||||
}
|
||||
|
||||
if allSuccess {
|
||||
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
|
||||
} else {
|
||||
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
|
||||
}
|
||||
}
|
||||
|
||||
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
|
||||
if !file.HasPreview {
|
||||
return nil, ErrUnsupportedMedia
|
||||
@@ -335,10 +181,10 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
|
||||
|
||||
// Generate fast cache key based on file metadata
|
||||
var cacheHash string
|
||||
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
|
||||
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
|
||||
// For audio with album art, hash the album art content
|
||||
hasher := md5.New()
|
||||
_, _ = hasher.Write([]byte(file.Metadata.AlbumArt))
|
||||
_, _ = hasher.Write(file.Metadata.AlbumArt)
|
||||
cacheHash = hex.EncodeToString(hasher.Sum(nil))
|
||||
} else {
|
||||
// For all other files, use fast metadata-based hash
|
||||
@@ -357,6 +203,191 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
|
||||
return GeneratePreviewWithMD5(ctx, file, previewSize, url, seekPercentage, cacheHash)
|
||||
}
|
||||
|
||||
// filePreviewType represents the type of preview generation needed
|
||||
type filePreviewType int
|
||||
|
||||
const (
|
||||
previewTypeDocument filePreviewType = iota
|
||||
previewTypeOffice
|
||||
previewTypeHEIC
|
||||
previewTypeImage
|
||||
previewTypeVideo
|
||||
previewTypeAudio
|
||||
previewTypeUnsupported
|
||||
)
|
||||
|
||||
// determinePreviewType determines what type of preview generation is needed
|
||||
func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
|
||||
// Check document conversion first
|
||||
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
|
||||
return previewTypeDocument
|
||||
}
|
||||
|
||||
// Check office files
|
||||
if file.OnlyOfficeId != "" {
|
||||
return previewTypeOffice
|
||||
}
|
||||
|
||||
// Check HEIC with conversion enabled
|
||||
if strings.HasPrefix(file.Type, "image/heic") &&
|
||||
*settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] {
|
||||
return previewTypeHEIC
|
||||
}
|
||||
|
||||
// Check by MIME type prefix
|
||||
switch {
|
||||
case strings.HasPrefix(file.Type, "image"):
|
||||
return previewTypeImage
|
||||
case strings.HasPrefix(file.Type, "video"):
|
||||
return previewTypeVideo
|
||||
case strings.HasPrefix(file.Type, "audio") && file.Metadata != nil && len(file.Metadata.AlbumArt) > 0:
|
||||
return previewTypeAudio
|
||||
default:
|
||||
return previewTypeUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// generateRawPreview generates the initial preview image bytes based on file type
|
||||
func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, hash string) ([]byte, error) {
|
||||
previewType := determinePreviewType(file)
|
||||
|
||||
switch previewType {
|
||||
case previewTypeDocument:
|
||||
return s.generateDocumentPreview(ctx, file, hash)
|
||||
|
||||
case previewTypeOffice:
|
||||
return s.generateOfficeFilePreview(ctx, file, officeUrl)
|
||||
|
||||
case previewTypeHEIC:
|
||||
return s.generateHEICPreview(ctx, file, previewSize)
|
||||
|
||||
case previewTypeImage:
|
||||
return s.generateImagePreview(ctx, file, previewSize)
|
||||
|
||||
case previewTypeVideo:
|
||||
return s.generateVideoPreviewBytes(ctx, file, seekPercentage)
|
||||
|
||||
case previewTypeAudio:
|
||||
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
|
||||
return file.Metadata.AlbumArt, nil
|
||||
}
|
||||
return nil, nil
|
||||
case previewTypeUnsupported:
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
return nil, fmt.Errorf("unsupported media type: %s", ext)
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown preview type")
|
||||
}
|
||||
}
|
||||
|
||||
// generateDocumentPreview generates preview for PDF and document files
|
||||
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) {
|
||||
tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt"
|
||||
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateOfficeFilePreview generates preview for Office files via OnlyOffice
|
||||
func (s *Service) generateOfficeFilePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, officeUrl string) ([]byte, error) {
|
||||
imageBytes, err := s.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for office file: %w", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateHEICPreview generates preview for HEIC files using FFmpeg
|
||||
func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
|
||||
imageBytes, err := s.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateVideoPreviewBytes generates preview frame from video file
|
||||
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, seekPercentage int) ([]byte, error) {
|
||||
// Check if this video format is enabled for preview generation
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||
if !settings.CanConvertVideo(ext) {
|
||||
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
|
||||
}
|
||||
|
||||
videoSeekPercentage := seekPercentage
|
||||
if videoSeekPercentage == 0 {
|
||||
videoSeekPercentage = 10
|
||||
}
|
||||
|
||||
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
|
||||
if err != nil {
|
||||
// Don't log client cancellations as errors
|
||||
if ctx.Err() != context.Canceled {
|
||||
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
|
||||
file.Name, file.RealPath, videoSeekPercentage, err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create image for video file: %w", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateImagePreview generates preview for regular image files
|
||||
func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
|
||||
// Stream from file instead of os.ReadFile so we don't load every image fully into memory
|
||||
f, err := os.Open(file.RealPath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
|
||||
return nil, fmt.Errorf("failed to open image file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
options, err := getPreviewOptions(previewSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imageBytes, err := s.CreatePreview(f, file.Size, options)
|
||||
if err != nil {
|
||||
// For JPEG files, try FFmpeg fallback if initial decode failed
|
||||
if strings.HasPrefix(file.Type, "image/jpeg") {
|
||||
return handleJPEGFallback(ctx, s, file, previewSize, err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create image preview: %w", err)
|
||||
}
|
||||
|
||||
if len(imageBytes) < 100 {
|
||||
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes", file.Name, file.Type, len(imageBytes))
|
||||
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
|
||||
}
|
||||
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// handleJPEGFallback attempts to use FFmpeg for problematic JPEG files
|
||||
// This is preview-specific logic for user files, not used for icon generation
|
||||
func handleJPEGFallback(ctx context.Context, s *Service, file iteminfo.ExtendedFileInfo, previewSize string, originalErr error) ([]byte, error) {
|
||||
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
|
||||
|
||||
if !enableJPEGFallback {
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", originalErr)
|
||||
}
|
||||
|
||||
if s.ffmpegService == nil {
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", originalErr)
|
||||
}
|
||||
|
||||
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, originalErr)
|
||||
imageBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
|
||||
}
|
||||
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
|
||||
// Note: fileMD5 is actually a cache hash (metadata-based), not a true file content MD5
|
||||
// Validate that cache hash is not empty to prevent cache corruption
|
||||
@@ -374,13 +405,11 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
// Acquire global image processor semaphore for ALL operations
|
||||
const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB
|
||||
if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil {
|
||||
// Large file path - use imageLargeSem
|
||||
if err := service.acquireImageLargeSem(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer service.releaseImageLargeSem()
|
||||
} else {
|
||||
// Small file path or fallback - use imageSem
|
||||
if err := service.acquireImageSem(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -400,122 +429,34 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
var (
|
||||
err error
|
||||
imageBytes []byte
|
||||
)
|
||||
|
||||
// Generate thumbnail image from video
|
||||
// Generate hash for temp file paths
|
||||
hasher := md5.New()
|
||||
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// Check if HEIC conversion is enabled (guaranteed non-nil after defaults)
|
||||
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && *settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
|
||||
// Generate raw preview based on file type
|
||||
imageBytes, err := service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate an image from office document
|
||||
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
|
||||
tempFilePath := filepath.Join(service.cacheDir, "thumbnails", "docs", hash) + ".txt"
|
||||
imageBytes, err = service.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
|
||||
}
|
||||
} else if file.OnlyOfficeId != "" {
|
||||
imageBytes, err = service.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for office file: %w", err)
|
||||
}
|
||||
} else if convertHEIC {
|
||||
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
|
||||
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
|
||||
}
|
||||
// For HEIC files, we've already done the resize/conversion, so cache and return directly
|
||||
// For HEIC files, we've already done the resize/conversion, cache and return directly
|
||||
previewType := determinePreviewType(file)
|
||||
if previewType == previewTypeHEIC {
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache HEIC image: %v", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
} else if strings.HasPrefix(file.Type, "image") {
|
||||
// Stream from file instead of os.ReadFile so we don't load every image fully into memory.
|
||||
// ResizeWithSize() holds the imaging semaphore and only reads in chunks / decodes one at a time.
|
||||
f, openErr := os.Open(file.RealPath)
|
||||
if openErr != nil {
|
||||
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, openErr)
|
||||
return nil, fmt.Errorf("failed to open image file: %w", openErr)
|
||||
}
|
||||
defer f.Close()
|
||||
imageBytes, err = service.CreatePreviewFromReaderWithSize(f, previewSize, file.Size)
|
||||
if err != nil {
|
||||
// Check if this is an unsupported JPEG format and FFmpeg fallback is enabled
|
||||
errMsg := err.Error()
|
||||
if strings.HasPrefix(file.Type, "image/jpeg") &&
|
||||
(strings.Contains(errMsg, "unrecognised marker") ||
|
||||
strings.Contains(errMsg, "invalid JPEG format") ||
|
||||
strings.Contains(errMsg, "Huffman")) {
|
||||
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
|
||||
if enableJPEGFallback && service.ffmpegService != nil {
|
||||
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
|
||||
imageBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
|
||||
}
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
if !enableJPEGFallback {
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create image preview: %w", err)
|
||||
}
|
||||
if len(imageBytes) < 100 {
|
||||
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes", file.Name, file.Type, len(imageBytes))
|
||||
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
|
||||
}
|
||||
}
|
||||
|
||||
// For regular images that were already resized, cache and return
|
||||
if previewType == previewTypeImage {
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache image: %v", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
} else if strings.HasPrefix(file.Type, "video") {
|
||||
// Check if this video format is enabled for preview generation
|
||||
ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||
if !settings.CanConvertVideo(ext) {
|
||||
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
|
||||
}
|
||||
|
||||
videoSeekPercentage := seekPercentage
|
||||
if videoSeekPercentage == 0 {
|
||||
videoSeekPercentage = 10
|
||||
}
|
||||
imageBytes, err = service.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
|
||||
if err != nil {
|
||||
// Don't log client cancellations as errors
|
||||
if ctx.Err() != context.Canceled {
|
||||
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
|
||||
file.Name, file.RealPath, videoSeekPercentage, err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create image for video file: %w", err)
|
||||
}
|
||||
} else if strings.HasPrefix(file.Type, "audio") {
|
||||
// Extract album artwork from audio files
|
||||
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
|
||||
imageBytes, err = base64.StdEncoding.DecodeString(file.Metadata.AlbumArt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode album artwork: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("unsupported media type: %s", ext)
|
||||
}
|
||||
|
||||
// Check if context was cancelled during processing
|
||||
@@ -523,62 +464,47 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Validate generated image size
|
||||
if len(imageBytes) < 100 {
|
||||
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes - likely an error occurred",
|
||||
file.Name, file.Type, len(imageBytes))
|
||||
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
|
||||
}
|
||||
|
||||
// Resize if needed (for videos, audio, documents, office files)
|
||||
if previewSize != "original" {
|
||||
// resize image
|
||||
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
|
||||
options, err := getPreviewOptions(previewSize)
|
||||
if err != nil {
|
||||
// Check if this is an unsupported JPEG format (extended sequential, etc.)
|
||||
errMsg := err.Error()
|
||||
if strings.HasPrefix(file.Type, "image/jpeg") &&
|
||||
(strings.Contains(errMsg, "unrecognised marker") ||
|
||||
strings.Contains(errMsg, "invalid JPEG format") ||
|
||||
strings.Contains(errMsg, "Huffman")) {
|
||||
|
||||
// Check if JPEG FFmpeg conversion is enabled (guaranteed non-nil after defaults)
|
||||
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
|
||||
|
||||
// Only attempt FFmpeg fallback if it's enabled and FFmpeg service is available
|
||||
if enableJPEGFallback && service.ffmpegService != nil {
|
||||
// Fall back to FFmpeg for problematic JPEG files
|
||||
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
|
||||
resizedBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
|
||||
}
|
||||
// Cache and return the FFmpeg-converted image
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err = service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
|
||||
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
|
||||
}
|
||||
return resizedBytes, nil
|
||||
}
|
||||
// If FFmpeg fallback is disabled or not available, return the original error
|
||||
if !enableJPEGFallback {
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to resize preview image: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
// Cache and return
|
||||
|
||||
resizedBytes, err := service.CreatePreview(bytes.NewReader(imageBytes), 0, options)
|
||||
if err != nil {
|
||||
// For JPEG files, try FFmpeg fallback if resize failed
|
||||
if strings.HasPrefix(file.Type, "image/jpeg") {
|
||||
resizedBytes, err = handleJPEGFallback(ctx, service, file, previewSize, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to resize preview image: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache and return resized image
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err := service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
|
||||
logger.Errorf("failed to cache resized image: %v", err)
|
||||
}
|
||||
return resizedBytes, nil
|
||||
} else {
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache original image: %v", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// Cache and return original size
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache original image: %v", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
|
||||
@@ -591,41 +517,32 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
|
||||
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
|
||||
}
|
||||
|
||||
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
|
||||
return s.CreatePreviewFromReader(bytes.NewReader(data), previewSize)
|
||||
}
|
||||
|
||||
// CreatePreviewFromReader resizes an image from a reader (e.g. *os.File) without loading it fully into memory.
|
||||
// Used for the image preview path so only concurrencyLimit decodes run at once.
|
||||
func (s *Service) CreatePreviewFromReader(reader io.Reader, previewSize string) ([]byte, error) {
|
||||
return s.CreatePreviewFromReaderWithSize(reader, previewSize, 0)
|
||||
}
|
||||
|
||||
// CreatePreviewFromReaderWithSize resizes an image with file size information for semaphore selection.
|
||||
func (s *Service) CreatePreviewFromReaderWithSize(reader io.Reader, previewSize string, fileSize int64) ([]byte, error) {
|
||||
var options ResizeOptions
|
||||
|
||||
// getPreviewOptions returns resize options for the given preview size
|
||||
func getPreviewOptions(previewSize string) (ResizeOptions, error) {
|
||||
switch previewSize {
|
||||
case "large":
|
||||
options = ResizeOptions{
|
||||
return ResizeOptions{
|
||||
Width: 640,
|
||||
Height: 640,
|
||||
ResizeMode: ResizeModeFit,
|
||||
Quality: QualityHigh,
|
||||
Format: FormatJpeg,
|
||||
}
|
||||
}, nil
|
||||
case "small":
|
||||
options = ResizeOptions{
|
||||
return ResizeOptions{
|
||||
Width: 256,
|
||||
Height: 256,
|
||||
ResizeMode: ResizeModeFit,
|
||||
Quality: QualityMedium,
|
||||
Format: FormatJpeg,
|
||||
}
|
||||
}, nil
|
||||
default:
|
||||
return nil, ErrUnsupportedFormat
|
||||
return ResizeOptions{}, ErrUnsupportedFormat
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePreview resizes an image from a reader with the given options
|
||||
func (s *Service) CreatePreview(reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
|
||||
output := &bytes.Buffer{}
|
||||
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1564,7 +1564,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
|
||||
"description": "url encoded destination path where to place the files inside the destination source",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -3063,8 +3063,8 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Target path within the share to upload to. Must be relative to share root.",
|
||||
"name": "targetPath",
|
||||
"description": "path within the share to upload to. Must be relative to share root.",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -4075,8 +4075,11 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"albumArt": {
|
||||
"description": "base64 encoded album art / video thumbnail",
|
||||
"type": "string"
|
||||
"description": "album art image data (automatically base64-encoded in JSON)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"description": "track artist",
|
||||
|
||||
@@ -1553,7 +1553,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
|
||||
"description": "url encoded destination path where to place the files inside the destination source",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -3052,8 +3052,8 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Target path within the share to upload to. Must be relative to share root.",
|
||||
"name": "targetPath",
|
||||
"description": "path within the share to upload to. Must be relative to share root.",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -4064,8 +4064,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"albumArt": {
|
||||
"description": "base64 encoded album art / video thumbnail",
|
||||
"type": "string"
|
||||
"description": "album art image data (automatically base64-encoded in JSON)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"description": "track artist",
|
||||
|
||||
@@ -447,8 +447,10 @@ definitions:
|
||||
description: album name
|
||||
type: string
|
||||
albumArt:
|
||||
description: base64 encoded album art / video thumbnail
|
||||
type: string
|
||||
description: album art image data (automatically base64-encoded in JSON)
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
artist:
|
||||
description: track artist
|
||||
type: string
|
||||
@@ -2855,7 +2857,7 @@ paths:
|
||||
Supports file uploads and directory creation.
|
||||
parameters:
|
||||
- description: url encoded destination path where to place the files inside
|
||||
the destination source, a directory must end in / to create a directory
|
||||
the destination source
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
@@ -3839,10 +3841,10 @@ paths:
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Target path within the share to upload to. Must be relative to
|
||||
share root.
|
||||
- description: path within the share to upload to. Must be relative to share
|
||||
root.
|
||||
in: query
|
||||
name: targetPath
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: If true, overwrite existing files/folders. Defaults to false.
|
||||
|
||||
Generated
+339
-279
File diff suppressed because it is too large.
Load diff
@@ -17,7 +17,7 @@
|
||||
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
|
||||
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
|
||||
<meta name="theme-color" content="{{ .htmlVars.color }}">
|
||||
|
||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||
@@ -52,40 +52,6 @@
|
||||
<!-- Injected variables come from backend server -->
|
||||
<script>
|
||||
window.globalVars = {{ .globalVars }};
|
||||
var dynamicManifest = {
|
||||
"name": window.globalVars.name || 'FileBrowser Quantum',
|
||||
"short_name": window.globalVars.name || 'FileBrowser',
|
||||
"icons": [
|
||||
{
|
||||
"src": "{{ .htmlVars.pwaIcon192 }}",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "{{ .htmlVars.pwaIcon256 }}",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "{{ .htmlVars.pwaIcon512 }}",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "{{ .htmlVars.color }}",
|
||||
"description": "{{ .htmlVars.description }}"
|
||||
}
|
||||
|
||||
const stringManifest = JSON.stringify(dynamicManifest);
|
||||
const blob = new Blob([stringManifest], { type: 'application/json' });
|
||||
const manifestURL = URL.createObjectURL(blob);
|
||||
document.querySelector('#manifestPlaceholder').setAttribute('href', manifestURL);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function post(
|
||||
}
|
||||
try {
|
||||
const apiPath = getPublicApiPath("resources", {
|
||||
targetPath: encodeURIComponent(path),
|
||||
path: path,
|
||||
hash: hash,
|
||||
override: overwrite,
|
||||
...(isDir && { isDir: 'true' })
|
||||
|
||||
@@ -250,7 +250,7 @@ export default {
|
||||
let index = 0;
|
||||
|
||||
const updateThumbnail = () => {
|
||||
if (state.popupPreviewSourceInfo?.url === "") {
|
||||
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== this.path) {
|
||||
this.previewTimeouts.forEach(clearTimeout);
|
||||
this.previewTimeouts = [];
|
||||
return;
|
||||
@@ -361,6 +361,11 @@ export default {
|
||||
this.svgPath = result.svgPath || "";
|
||||
this.updateImageTargetSrc();
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up any pending animation timeouts
|
||||
this.previewTimeouts.forEach(clearTimeout);
|
||||
this.previewTimeouts = [];
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
:name="getItemName(item.path)"
|
||||
:isDir="item.type === 'directory'"
|
||||
:source="item.source"
|
||||
:type="item.type || 'file'"
|
||||
:size="item.size || 0"
|
||||
:modified="item.modified || ''"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
:modified="item.modified"
|
||||
:index="index"
|
||||
:path="item.path"
|
||||
:hasPreview="item.hasPreview"
|
||||
@@ -107,6 +107,7 @@ export default {
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
previewUrl: previewUrl,
|
||||
hasPreview: item.hasPreview,
|
||||
});
|
||||
} else if (!this.isListing) {
|
||||
const item = state.req.items[state.selected[0]];
|
||||
@@ -118,6 +119,7 @@ export default {
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
previewUrl: previewUrl,
|
||||
hasPreview: item.hasPreview,
|
||||
});
|
||||
} else {
|
||||
for (let index of state.selected) {
|
||||
@@ -130,6 +132,7 @@ export default {
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
previewUrl: previewUrl,
|
||||
hasPreview: item.hasPreview,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,9 @@ router.beforeResolve(async (to, from, next) => {
|
||||
return next(false);
|
||||
}
|
||||
|
||||
// Clear any popup previews when navigating
|
||||
mutations.setPreviewSource("");
|
||||
|
||||
const title = titles[to.name as keyof typeof titles] || String(to.name || '');
|
||||
document.title = globalVars.name + " - " + title;
|
||||
mutations.setRoute(to);
|
||||
|
||||
@@ -211,7 +211,6 @@ export function goToItem(source, path, previousHistoryItem, newTab = false) {
|
||||
if (source == state.sources.current && path == state.req.path) {
|
||||
return;
|
||||
}
|
||||
mutations.setPreviewSource("");
|
||||
let newPath = encodedPath(path);
|
||||
let fullPath = `/files/${encodeURIComponent(source)}${newPath}`;
|
||||
if (newTab) {
|
||||
|
||||
Reference in new issue
Block a user