Compare commits

...
6 Commits
52 changed files with 379 additions and 277 deletions

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
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.3.8
**BugFixes**:
- Quick download icon style after icon change.
- Missing error popup for resource creatoin actions (upload/create)
- EnforcedOtp login failure until restart (#2330)
- Thumbnails for Folders only display sporadically (#2353)
- Unwanted user scope change for users with non-default scopes (#2347)
- Fix sidebar source info totals (#2321) (#2322) (#982)
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
## v1.3.7
**Notes**:
+3 -4
View File
@@ -146,14 +146,14 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
}
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
useCache := found && user.TOTPSecret == ""
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if found {
if useCache {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
@@ -173,9 +173,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
}
secretToValidate = decryptedSecret
}
// --- END: ADD THIS DECRYPTION LOGIC ---
// Validate the token using the (now plaintext) secret.
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
Period: TOTPPeriod,
Skew: TOTPSkew,
@@ -198,6 +196,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
logger.Error("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
TotpCache.Delete(user.Username)
} else {
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
}
+6 -4
View File
@@ -46,8 +46,7 @@ func validateUserInfo(newDB bool) {
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
if user.Username == adminUser && adminPass != "" && user.LoginMethod == users.LoginMethodPassword {
logger.Info("Resetting admin user to default username and password.")
user.Permissions.Admin = true
user.Password = settings.Config.Auth.AdminPassword
@@ -63,7 +62,11 @@ func validateUserInfo(newDB bool) {
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
err := store.Users.Save(user, changePass, true)
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version"}
if changePass {
fields = append(fields, "Password")
}
err := store.Users.Update(user, true, fields...)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
@@ -94,7 +97,6 @@ func updateUserScopes(user *users.User) bool {
} else {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: src.Path,
Scope: existingScope.Scope,
+1 -1
View File
@@ -210,7 +210,7 @@ type SourceConfig struct {
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
// hidden but used internally - optimized map lookups for conditional rules
ResolvedRules ResolvedRulesConfig `json:"-"`
+5
View File
@@ -17,6 +17,11 @@ type IndexInfo struct {
NumDirs uint64 `json:"numDirs"`
NumFiles uint64 `json:"numFiles"`
// Disk usage (cached from periodic GetIndexInfo / partition probes; aligns with ReducedIndex Stats)
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// Scanner information - map of scanner path to scanner stats
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
}
+87 -82
View File
@@ -298,28 +298,70 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
return err
}
// dbTransientOpAttempts is how many times to run a bulk DB op that may hit SQLITE_BUSY before giving up.
const dbTransientOpAttempts = 2
const dbTransientRetryBackoff = 50 * time.Millisecond
// BulkInsertItems inserts multiple items in a single transaction for a specific source.
// Database errors (busy/locked) are treated as soft failures - the filesystem is the source of truth.
// Returns nil on success or soft failure (busy/locked), error only on hard failures.
// Transient busy/locked errors retry once before giving up (see dbTransientOpAttempts).
func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) error {
if len(items) == 0 {
return nil
}
startTime := time.Now()
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
ok, transient, err := db.attemptBulkInsert(source, items)
if err != nil {
return err
}
if ok {
if attempt > 1 {
logger.Infof("[DB_TX] BulkInsertItems: succeeded on retry (%d items, attempt %d, %v)",
len(items), attempt, time.Since(startTime))
}
return nil
}
if transient && attempt < dbTransientOpAttempts {
logger.Warningf("[DB_TX] BulkInsertItems: transient DB error (attempt %d/%d, %d items), retrying after %v",
attempt, dbTransientOpAttempts, len(items), dbTransientRetryBackoff)
time.Sleep(dbTransientRetryBackoff)
continue
}
if transient {
logger.Warningf("[DB_TX] BulkInsertItems: still failing after %d attempts (%d items, %v) — batch dropped",
dbTransientOpAttempts, len(items), time.Since(startTime))
}
return nil
}
return nil // defensive if dbTransientOpAttempts is ever 0 and loop never runs
}
// attemptBulkInsert runs one transactional bulk insert.
// Returns success=true when committed; transient=true when SQLITE_BUSY-like errors allow a retry (mutex released).
func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo) (success bool, transient bool, err error) {
tx, err := db.BeginTransaction()
if err != nil {
// Soft failure: DB is busy or locked, skip this update
if isBusyError(err) || isTransactionError(err) {
db.mu.Unlock() // Release mutex on error
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked), skipping - took %v", time.Since(startTime))
return nil // Non-fatal: filesystem will be used as fallback
db.mu.Unlock()
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked): %v", err)
return false, true, nil
}
db.mu.Unlock() // Release mutex on error
return err // Hard failure: something is wrong with the DB
db.mu.Unlock()
return false, false, err
}
defer db.mu.Unlock() // Ensure mutex is always released
// Always update all fields including last_updated, even if nothing changed
// This ensures last_updated stays current for stale entry detection
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
db.mu.Unlock()
}()
stmt, err := tx.Prepare(`
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -336,14 +378,12 @@ func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) er
`)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
// With EXCLUSIVE locking and mutex, this shouldn't happen.
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Prepare: %v", err)
return nil
logger.Debugf("[DB_TX] BulkInsertItems: Prepare busy/locked: %v", err)
return false, true, nil
}
return err
return false, false, err
}
defer stmt.Close() // Ensure statement is always closed
defer stmt.Close()
nowUnix := time.Now().Unix()
for _, info := range items {
@@ -363,28 +403,23 @@ func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) er
)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
// With EXCLUSIVE locking and mutex, this shouldn't happen.
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Exec: %v", err)
return nil
logger.Debugf("[DB_TX] BulkInsertItems: Exec busy/locked during insert: %v", err)
return false, true, nil
}
logger.Errorf("[DB] BulkInsertItems: Exec failed for path=%s, source=%s, error=%v", info.Path, source, err)
return err
return false, false, err
}
}
// Try to commit
if err := tx.Commit(); err != nil {
if isBusyError(err) || isTransactionError(err) {
// With EXCLUSIVE locking and mutex, this shouldn't happen.
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Commit: %v", err)
return nil
logger.Debugf("[DB_TX] BulkInsertItems: Commit busy/locked: %v", err)
return false, true, nil
}
return err
return false, false, err
}
return nil
committed = true
return true, false, nil
}
// isBusyError checks if an error is SQLITE_BUSY (error code 5)
@@ -474,51 +509,6 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
return result, nil
}
// GetHasPreviewBatch retrieves hasPreview status for multiple directory paths in a single query.
// This is optimized for the N+1 query pattern in GetDirInfo where we need hasPreview for all subdirectories.
// Returns map[path]hasPreview. Missing paths in the result indicate they're not in the database yet.
func (db *IndexDB) GetHasPreviewBatch(source string, paths []string) (map[string]bool, error) {
if len(paths) == 0 {
return make(map[string]bool), nil
}
// Build query with IN clause - only select path and has_preview for efficiency
placeholders := make([]string, len(paths))
args := make([]interface{}, len(paths)+1)
args[0] = source
for i, path := range paths {
placeholders[i] = "?"
args[i+1] = path
}
query := fmt.Sprintf(`
SELECT path, has_preview
FROM index_items WHERE source = ? AND path IN (%s) AND is_dir = 1
`, strings.Join(placeholders, ","))
rows, err := db.Query(query, args...)
if err != nil {
// Soft failure: DB is busy or locked, return empty map
if isBusyError(err) || isTransactionError(err) {
return make(map[string]bool), nil
}
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var path string
var hasPreview bool
if err := rows.Scan(&path, &hasPreview); err != nil {
return nil, err
}
result[path] = hasPreview
}
return result, nil
}
// GetDirectoryChildren retrieves all children of a directory for a specific source.
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
query := `
@@ -1077,17 +1067,31 @@ func (db *IndexDB) UpdateFolderSizesIfChanged(source string, pathSizes map[strin
args = append(args, path)
}
result, err := db.Exec(query, args...)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
logger.Debugf("[DB_TX] UpdateFolderSizesIfChanged: DB busy/locked, skipping update")
return 0, nil // Non-fatal: sizes will be synced on next scan
startTime := time.Now()
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
result, err := db.Exec(query, args...)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
if attempt < dbTransientOpAttempts {
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: busy/locked (attempt %d/%d), retrying after %v: %v",
attempt, dbTransientOpAttempts, dbTransientRetryBackoff, err)
time.Sleep(dbTransientRetryBackoff)
continue
}
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: still busy after %d attempts (%d paths, %v) — skipped",
dbTransientOpAttempts, len(pathSizes), time.Since(startTime))
return 0, nil
}
return 0, err
}
return 0, err
n, _ := result.RowsAffected()
if attempt > 1 {
logger.Infof("[DB_TX] UpdateFolderSizesIfChanged: succeeded on retry (%d rows, attempt %d, %v)",
n, attempt, time.Since(startTime))
}
return n, nil
}
rowsAffected, _ := result.RowsAffected()
return rowsAffected, nil
return 0, nil // defensive: unreachable when dbTransientOpAttempts >= 1
}
// LoadFolderSizes loads all folder sizes for a source from the database
@@ -1320,6 +1324,7 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
return nil, err
}
info.ModTime = time.Unix(modTime, 0)
info.IsDir = isDir
return &info, nil
}
+1 -1
View File
@@ -105,7 +105,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
// Build list of previewable item names in stable order (same as d.fileInfo.Files)
var previewableNames []string
for _, item := range d.fileInfo.Files {
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
if !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
continue
}
previewableNames = append(previewableNames, item.Name)
+29 -25
View File
@@ -16,44 +16,26 @@ func TestFetchExtendedAttributes(t *testing.T) {
tests := []struct {
name string
adjustedPath string
combinedPath string
files []os.FileInfo
opts Options
expectHasPreview bool
expectMapNotEmpty bool
}{
{
name: "SkipExtendedAttrs returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: true,
Recursive: false,
},
expectHasPreview: false,
name: "SkipExtendedAttrs returns empty",
adjustedPath: "/test/",
opts: Options{SkipExtendedAttrs: true, Recursive: false},
expectMapNotEmpty: false,
},
{
name: "Recursive returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: false,
Recursive: true,
},
expectHasPreview: false,
name: "Recursive returns empty",
adjustedPath: "/test/",
opts: Options{SkipExtendedAttrs: false, Recursive: true},
expectMapNotEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasPreview, subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.files, tt.opts)
if hasPreview != tt.expectHasPreview {
t.Errorf("expected hasPreview=%v, got %v", tt.expectHasPreview, hasPreview)
}
subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.opts)
if (len(subdirMap) > 0) != tt.expectMapNotEmpty {
t.Errorf("expected mapNotEmpty=%v, got %v", tt.expectMapNotEmpty, len(subdirMap) > 0)
}
@@ -61,6 +43,28 @@ func TestFetchExtendedAttributes(t *testing.T) {
}
}
// TestFetchExtendedAttributes_subdirHasPreviewFromMetadata checks folder hasPreview comes from one GetMetadataInfo (non-shallow).
func TestFetchExtendedAttributes_subdirHasPreviewFromMetadata(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
deepdir, err := idx.db.GetItem("test", "/subdir/deepdir/")
if err != nil || deepdir == nil {
t.Fatalf("deepdir row: err=%v", err)
}
deepdir.HasPreview = true
_ = idx.db.InsertItem("test", "/subdir/deepdir/", deepdir)
subdirMap := idx.fetchExtendedAttributes("/subdir/", Options{
SkipExtendedAttrs: false,
Recursive: false,
})
if !subdirMap["/subdir/deepdir/"] {
t.Errorf("expected hasPreview map for /subdir/deepdir/, got %v", subdirMap)
}
}
// TestGetDirectoryName tests the directory name extraction logic
func TestGetDirectoryName(t *testing.T) {
tests := []struct {
+42 -78
View File
@@ -734,50 +734,26 @@ func (idx *Index) GetFsInfoViewableOnly(adjustedPath string, followSymlinks bool
})
}
// fetchExtendedAttributes fetches hasPreview for the current directory and batch fetches for subdirectories
func (idx *Index) fetchExtendedAttributes(indexPath string, files []os.FileInfo, opts Options) (bool, map[string]bool) {
hasPreview := false
// fetchExtendedAttributes loads indexed subfolder has_preview for the current directory from GetMetadataInfo (database).
// The listed directory's own HasPreview is derived only in GetDirInfoCore from children (never from a stale parent DB row).
func (idx *Index) fetchExtendedAttributes(indexPath string, opts Options) map[string]bool {
subdirHasPreviewMap := make(map[string]bool)
if opts.SkipExtendedAttrs || opts.Recursive {
return hasPreview, subdirHasPreviewMap
return subdirHasPreviewMap
}
// Fetch hasPreview for current directory
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, true)
if exists {
hasPreview = realDirInfo.HasPreview
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, false)
if !exists {
return subdirHasPreviewMap
}
// Batch fetch hasPreview for all subdirectories
var subdirPaths []string
for _, file := range files {
if !iteminfo.IsDirectory(file) {
continue
}
baseName := file.Name()
if indexPath == "/" {
if !idx.shouldInclude(baseName) {
continue
}
}
if omitList[baseName] {
continue
}
dirPath := indexPath + baseName
childIndexPath := utils.AddTrailingSlashIfNotExists(dirPath)
subdirPaths = append(subdirPaths, childIndexPath)
for _, folder := range realDirInfo.Folders {
key := utils.AddTrailingSlashIfNotExists(indexPath + folder.Name)
subdirHasPreviewMap[key] = folder.HasPreview
}
if len(subdirPaths) > 0 {
var err error
subdirHasPreviewMap, err = idx.db.GetHasPreviewBatch(idx.Name, subdirPaths)
if err != nil {
subdirHasPreviewMap = make(map[string]bool)
}
}
return hasPreview, subdirHasPreviewMap
return subdirHasPreviewMap
}
// processDirectoryItem processes a directory item and returns the itemInfo, size, and whether it should be counted
@@ -825,11 +801,7 @@ func (idx *Index) processDirectoryItem(file os.FileInfo, indexPath string, subdi
if !opts.SkipExtendedAttrs && subdirHasPreviewMap != nil {
if hasPreviewValue, exists := subdirHasPreviewMap[childIndexPath]; exists {
itemInfo.HasPreview = hasPreviewValue
} else {
itemInfo.HasPreview = false
}
} else {
itemInfo.HasPreview = false
}
return itemInfo, itemInfo.Size, true
@@ -875,9 +847,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
if !usedCachedPreview {
setFilePreviewFlags(itemInfo, fullCombined)
}
if itemInfo.HasPreview && iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo) {
bubblesUpHasPreview = true
}
bubblesUpHasPreview = iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo)
} else {
itemInfo.HasPreview = false
}
@@ -894,8 +864,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
if err != nil {
return nil, err
}
// Fetch extended attributes (hasPreview for current dir and subdirs)
hasPreview, subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, files, opts)
subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, opts)
baseName := filepath.Base(indexPath)
if indexPath == "/" {
@@ -906,6 +875,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
fileInfos := []iteminfo.ExtendedItemInfo{}
dirInfos := []iteminfo.ItemInfo{}
processedCount := 0
hasPreview := false
for _, file := range files {
subfileBaseName := file.Name()
@@ -960,12 +930,12 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
}
} else {
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(file, indexPath, opts, scanner)
if shouldCount {
totalSize += size
}
if bubblesUp {
hasPreview = true
}
if shouldCount {
totalSize += size
}
fileInfos = append(fileInfos, iteminfo.ExtendedItemInfo{ItemInfo: *itemInfo})
if opts.IsRoutineScan {
idx.incrementScannerFiles()
@@ -1132,6 +1102,7 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
if parentDir == "" {
break // Reached the top
}
// Keys in folderSizes match indexing paths (trailing slash for non-root dirs; root stays "/").
if parentDir != "/" {
parentDir = utils.AddTrailingSlashIfNotExists(parentDir)
}
@@ -1188,20 +1159,6 @@ func (idx *Index) getFileSizeForDisplay(file os.FileInfo, realPath string) int64
return getDiskUsage(file, realPath, false)
}
// RecursiveUpdateDirSizes is now a wrapper for backwards compatibility
// This maintains API compatibility while using the new optimized approach
func (idx *Index) RecursiveUpdateDirSizes(path string, previousSize uint64) {
currentSize, exists := idx.GetFolderSize(path)
if !exists {
logger.Debugf("[FOLDER_SIZE] Path %s not found in folderSizes map", path)
return
}
if currentSize != previousSize {
idx.updateFolderSizeAndParents(path, currentSize, previousSize, false) // updateTarget=false, only update parents
}
}
// SyncFolderSizesToDB syncs in-memory folder sizes to the database after a scan completes
// Only syncs folders marked as dirty (changed) and processes in batches to avoid large SQL statements
func (idx *Index) SyncFolderSizesToDB() error {
@@ -1609,14 +1566,13 @@ func (idx *Index) shouldInclude(baseName string) bool {
return false
}
// Save persists the index and scanner information to the database
func (idx *Index) Save() error {
// writePersistedIndexInfo writes scanners, stats, and disk totals to Bolt (no SSE broadcast).
func (idx *Index) writePersistedIndexInfo() error {
if indexingStorage == nil {
return nil // No storage available, skip persistence
return nil
}
idx.mu.RLock()
// Collect scanner information
scanners := make(map[string]*dbindex.PersistedScannerInfo)
for path, scanner := range idx.scanners {
scanners[path] = &dbindex.PersistedScannerInfo{
@@ -1631,32 +1587,37 @@ func (idx *Index) Save() error {
}
}
// Get current index stats
complexity := idx.getComplexityUnlocked()
numDirs := idx.getNumDirsUnlocked()
numFiles := idx.getNumFilesUnlocked()
usedAsIndexed := idx.UsedAsIndexed
usedDisk := idx.UsedDisk
diskTotal := idx.DiskTotal
idx.mu.RUnlock()
// Create IndexInfo for persistence
info := &dbindex.IndexInfo{
Path: idx.Path, // Use real filesystem path as key
Source: idx.Name,
Complexity: complexity,
NumDirs: numDirs,
NumFiles: numFiles,
Scanners: scanners,
Path: idx.Path,
Source: idx.Name,
Complexity: complexity,
NumDirs: numDirs,
NumFiles: numFiles,
UsedAsIndexed: usedAsIndexed,
UsedDisk: usedDisk,
DiskTotal: diskTotal,
Scanners: scanners,
}
err := indexingStorage.Save(info)
if err != nil {
return indexingStorage.Save(info)
}
// Save persists the index and scanner information to the database and notifies clients via SSE.
func (idx *Index) Save() error {
if err := idx.writePersistedIndexInfo(); err != nil {
return err
}
// Send SSE update event after successful save
if err := idx.SendSourceUpdateEvent(); err != nil {
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
}
return nil
}
@@ -1684,6 +1645,9 @@ func (idx *Index) Load() error {
idx.Complexity = info.Complexity
idx.NumDirs = info.NumDirs
idx.NumFiles = info.NumFiles
idx.UsedAsIndexed = info.UsedAsIndexed
idx.UsedDisk = info.UsedDisk
idx.DiskTotal = info.DiskTotal
// Restore scanner information (will be applied when scanners are created)
// Store in a temporary map that setupMultiScanner can use
+7 -5
View File
@@ -502,7 +502,6 @@ func (s *Scanner) calculateSleepTime() time.Duration {
// updateSchedule adjusts the scanner's schedule based on whether files changed
func (s *Scanner) updateSchedule() {
s.statsMu.Lock()
defer s.statsMu.Unlock()
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
@@ -539,7 +538,10 @@ func (s *Scanner) updateSchedule() {
s.currentSchedule = len(scanSchedule) - 1
}
// Persist index after schedule update (happens after each scan)
s.statsMu.Unlock()
// Save must NOT run while holding statsMu (see deadlock: GetScannerStatus = idx.mu then
// scanner stats; Save = idx.mu RL while GetIndexInfo may queue idx.mu LW).
if err := s.idx.Save(); err != nil {
logger.Errorf("Failed to save index after schedule update: %v", err)
}
@@ -549,8 +551,6 @@ func (s *Scanner) updateSchedule() {
// 0: unknown
func (s *Scanner) updateComplexity() {
s.statsMu.Lock()
defer s.statsMu.Unlock()
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
// Set smartModifier based on complexity level
@@ -559,7 +559,9 @@ func (s *Scanner) updateComplexity() {
} else {
s.smartModifier = 0
}
// Persist index after complexity update (happens after full scans)
s.statsMu.Unlock()
// Persist after full scans — release statsMu before Save (same ordering as updateSchedule).
if err := s.idx.Save(); err != nil {
logger.Errorf("Failed to save index after complexity update: %v", err)
}
+5 -2
View File
@@ -205,7 +205,9 @@ func (idx *Index) SendSourceUpdateEvent() error {
if idx.mock {
return nil
}
reducedIndex, err := GetIndexInfo(idx.Name, true)
// Avoid force=true: otherwise GetIndexInfo invalidates DiskUsageCache, recomputes, and writePersistedIndexInfo
// nests under Save recursion; false uses cached probe / current idx totals for the broadcast payload.
reducedIndex, err := GetIndexInfo(idx.Name, false)
if err != nil {
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
return err
@@ -226,7 +228,8 @@ func (idx *Index) SendSourceUpdateEvent() error {
// setupMultiScanner creates and starts the multi-scanner system
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
func (idx *Index) setupMultiScanner(isNewDb bool) {
// Load persisted index and scanner information
// Load persisted index-level stats from Bolt (complexity, numDirs/Files, used/usedAlt/total, etc.)
// Scanner-level stats below use a second GetByPath only for the Scanners map.
if err := idx.Load(); err != nil {
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
}
+2
View File
@@ -30,6 +30,8 @@ type FileInfo struct {
Files []ExtendedItemInfo `json:"files,omitempty"` // files in the directory with optional metadata
Folders []ItemInfo `json:"folders,omitempty"` // folders in the directory
Path string `json:"path,omitempty"` // path scoped to the associated index
// IsDir is the index DB is_dir column; Type alone can disagree (legacy or malformed rows).
IsDir bool `json:"-"`
}
// MediaMetadata contains metadata extracted from audio and video files
+3
View File
@@ -405,6 +405,9 @@ func IsDirectory(fileInfo os.FileInfo) bool {
// Text files, office documents, and PDFs should NOT bubble up to folder previews.
// This ensures consistency between indexing and preview generation.
func ShouldBubbleUpToFolderPreview(item ItemInfo) bool {
if item.Type == "directory" || !item.HasPreview {
return false
}
// Get the simple type (e.g., "image", "video", "text")
simpleType := strings.Split(item.Type, "/")[0]
// Text files should NOT bubble up
@@ -0,0 +1,69 @@
package iteminfo
import "testing"
func TestShouldBubbleUpToFolderPreview(t *testing.T) {
tests := []struct {
name string
item ItemInfo
want bool
}{
{
name: "directory type never bubbles",
item: ItemInfo{Name: "sub", Type: "directory"},
want: false,
},
{
name: "text markdown does not bubble",
item: ItemInfo{Name: "README.md", Type: "text/markdown"},
want: false,
},
{
name: "plain text does not bubble",
item: ItemInfo{Name: "LICENSE", Type: "text/plain; charset=utf-8"},
want: false,
},
{
name: "image does not bubble without HasPreview",
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: false},
want: false,
},
{
name: "image bubbles when HasPreview",
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: true},
want: true,
},
{
name: "video bubbles when HasPreview",
item: ItemInfo{Name: "clip.mp4", Type: "video/mp4", HasPreview: true},
want: true,
},
{
name: "audio bubbles when HasPreview (e.g. album art)",
item: ItemInfo{Name: "track.mp3", Type: "audio/mpeg", HasPreview: true},
want: true,
},
{
name: "OnlyOffice-backed extension does not bubble",
item: ItemInfo{Name: "report.docx", Type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
want: false,
},
{
name: "unknown blob type does not bubble",
item: ItemInfo{Name: "data.bin", Type: "application/octet-stream"},
want: false,
},
{
name: "empty type does not bubble",
item: ItemInfo{Name: "weird", Type: ""},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ShouldBubbleUpToFolderPreview(tt.item); got != tt.want {
t.Fatalf("ShouldBubbleUpToFolderPreview() = %v, want %v", got, tt.want)
}
})
}
}
+22 -6
View File
@@ -12,8 +12,8 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
// scanner parameter is optional - if nil (API refresh), directly inserts to database
// UpdateMetadata persists a completed directory listing to the index. The directory row's has_preview
// scanner is optional — if nil (API refresh), inserts run immediately; otherwise items batch until flush.
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo, scanner *Scanner) bool {
items := make([]*iteminfo.FileInfo, 0, len(info.Files)+len(info.Folders)+1)
dirItem := *info
@@ -297,7 +297,8 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
}
// Only update disk total if cache is missing or explicitly forced.
// Indexed "used" bytes for the UI come from the SQLite root row (see below), not in-memory scan state.
// Indexed "used" sums root-level files from SQLite; top-level folders use idx.folderSizes (scanned rollup)
// because directory size columns in SQLite can lag folderSizes/SyncFolderSizesToDB.
sourcePath := idx.Path
cacheKey := "usageCache-" + sourceName
if forceCacheRefresh {
@@ -313,11 +314,21 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
}
itemInfo, dbErr := idx.db.GetDirectoryChildren(idx.Name, "/")
if dbErr != nil {
logger.Debugf("GetIndexInfo: could not read root folder size from index DB for %s: %v", sourceName, dbErr)
logger.Debugf("GetIndexInfo: could not read root listing from index DB for %s: %v", sourceName, dbErr)
}
indexedSizeFromDB := uint64(0)
var indexedSizeFromDB uint64
for _, item := range itemInfo {
indexedSizeFromDB += uint64(item.Size)
isFolder := item.IsDir || item.Type == "directory"
if !isFolder {
indexedSizeFromDB += uint64(item.Size)
continue
}
folderKey := utils.AddTrailingSlashIfNotExists(item.Path)
if agg, ok := idx.GetFolderSize(folderKey); ok {
indexedSizeFromDB += agg
} else {
indexedSizeFromDB += uint64(item.Size)
}
}
// Also fetch OS-reported partition used space (total - free)
partitionUsed, err := fileutils.GetPartitionUsed(sourcePath)
@@ -326,6 +337,11 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
}
idx.SetUsage(totalPartitionSize, partitionUsed, indexedSizeFromDB)
utils.DiskUsageCache.Set(cacheKey, true)
if indexingStorage != nil {
if err := idx.writePersistedIndexInfo(); err != nil {
logger.Warningf("GetIndexInfo: failed to persist index disk stats for %s: %v", sourceName, err)
}
}
}
// Build scanner info for client
+1 -1
View File
@@ -5775,7 +5775,7 @@ const docTemplate = `{
]
},
"createUserDir": {
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
"description": "create a user directory for each user under defaultUserScope + username",
"type": "boolean"
},
"defaultEnabled": {
+1 -1
View File
@@ -5764,7 +5764,7 @@
]
},
"createUserDir": {
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
"description": "create a user directory for each user under defaultUserScope + username",
"type": "boolean"
},
"defaultEnabled": {
+2 -2
View File
@@ -1177,8 +1177,8 @@ definitions:
- $ref: '#/definitions/settings.ConditionalFilter'
description: 'deprecated: use source.rules instead'
createUserDir:
description: 'deprecated: create a user directory for each user under defaultUserScope
+ username (always true now)'
description: create a user directory for each user under defaultUserScope
+ username
type: boolean
defaultEnabled:
description: should be added as a default source for new users?
+1
View File
@@ -43,6 +43,7 @@ server:
ignoreSymlinks: false # Excludes symbolic links
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
defaultEnabled: true # should be added as a default source for new users?
createUserDir: false # create a user directory for each user under defaultUserScope + username
useLogicalSize: false # calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
+12 -1
View File
@@ -462,7 +462,18 @@ export function post(
error.response = { status: request.status, responseText: request.responseText };
reject(error);
} else {
reject(new Error(request.responseText || "Upload failed"));
let errorMessage = "Upload failed";
try {
const errorData = JSON.parse(request.responseText);
errorMessage = errorData.message || errorMessage;
} catch (e) {
errorMessage = request.responseText || errorMessage;
}
const error = new Error(errorMessage);
error.status = request.status;
error.response = { status: request.status, responseText: request.responseText };
notify.showError(errorMessage);
reject(error);
}
};
+7 -3
View File
@@ -35,19 +35,19 @@
</div>
<hr v-if="showDivider" class="divider">
<action
v-if="showCreateActions"
v-if="showCreateFiles"
icon="create_new_folder"
:label="$t('files.newFolder')"
@action="showNewDirPrompt"
/>
<action
v-if="showCreateActions"
v-if="showCreateFiles"
icon="note_add"
:label="$t('files.newFile')"
@action="showPrompt('newFile')"
/>
<action
v-if="showCreateActions"
v-if="showCreateFiles"
icon="file_upload"
:label="$t('general.upload')"
@action="showUploadPrompt"
@@ -259,6 +259,10 @@ export default {
isShare() {
return getters.isShare();
},
showCreateFiles() {
if (this.showLimitedOptions) return false;
return getters.permissions().create;
},
showCreateActions() {
if (this.showLimitedOptions) return false;
return this.showCreate && !this.isSearchActive;
@@ -256,7 +256,7 @@ export default {
targetDirItems = response?.items;
}
} catch (error) {
notify.showErrorToast(this.$t("files.cannotAccesDir"));
notify.showError(this.$t("files.cannotAccesDir"));
console.log("Cannot access to target directory", e);
return;
}
@@ -296,7 +296,7 @@ export default {
mutations.setReload(true);
} catch (error) {
mutations.closeTopPrompt();
notify.showErrorToast(this.$t("prompts.moveFailed"));
notify.showError(this.$t("prompts.moveFailed"));
console.log("Move failed", e);
}
};
@@ -72,10 +72,6 @@
:clickable=true
:isShared="isShared"
/>
<Icon
v-else-if="quickDownloadPlaceholder"
class="placeholder"
/>
</a>
<div
v-else
@@ -222,10 +218,10 @@ export default {
// @ts-ignore
if (getters.isShare()) {
// @ts-ignore
return state.shareInfo?.quickDownload && !this.galleryView && !this.isDir;
return state.shareInfo?.quickDownload && !this.galleryView;
}
// @ts-ignore
return state.user?.quickDownload && !this.galleryView && !this.isDir;
return state.user?.quickDownload && !this.galleryView;
},
quickDownloadPlaceholder() {
// @ts-ignore
+3 -4
View File
@@ -132,7 +132,7 @@
<script>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { uploadManager } from "@/utils/upload";
import { readAllDirectoryEntries, uploadManager } from "@/utils/upload";
import { mutations, state } from "@/store";
import { notify } from "@/notify";
import { usersApi } from "@/api";
@@ -485,9 +485,8 @@ export default {
}
if (entry.isDirectory) {
const reader = entry.createReader();
const entries = await new Promise((resolve) => {
reader.readEntries((e) => resolve(e));
});
const pathLabel = entry.fullPath || entry.name || "";
const entries = await readAllDirectoryEntries(reader, pathLabel);
const allFiles = await Promise.all(
entries.map((subEntry) => getFilesFromDirectoryEntry(subEntry))
);
+3 -20
View File
@@ -98,11 +98,6 @@
<i class="material-symbols material-size">add</i>
</button>
<div class="settings-items">
<ToggleSwitch v-if="displayHomeDirectoryCheckbox" class="item" v-model="createUserDir"
:name="$t('settings.createUserHomeDirectory')" />
</div>
<p v-if="stateUser.username !== user.username">
<label for="locale">{{ $t("general.language") }}</label>
<languages class="input" id="locale" v-model:locale="user.locale" @input="emitUpdate"></languages>
@@ -240,9 +235,6 @@ export default {
passwordPlaceholder() {
return this.isNew ? "" : this.$t("settings.avoidChanges");
},
displayHomeDirectoryCheckbox() {
return this.isNew && this.createUserDir;
},
/** Password change (existing user): target and signed-in user must both use password login. */
showPasswordChangeSection() {
return (
@@ -261,10 +253,6 @@ export default {
},
},
watch: {
createUserDir(newVal) {
this.user.scopes = newVal ? { default: "" } : this.originalUserScope;
this.emitUserUpdate();
},
stateUser() {
this.user.otpEnabled = state.user.otpEnabled;
this.emitUserUpdate();
@@ -391,7 +379,7 @@ export default {
mutations.closeTopPrompt(); // close user prompt since user doens't exist anymore
} catch (e) {
console.error(e);
notify.showErrorToast(this.$t("settings.userDeleteFailed"));
notify.showError(e);
}
},
},
@@ -427,10 +415,7 @@ export default {
mutations.closeTopPrompt();
} else {
await usersApi.update({ ...this.user, scopes: scopesToSend }, fields);
// Only emit usersChanged for admin user management, not profile updates
if (state.user.permissions.admin && this.user.id !== state.user.id) {
eventBus.emit('usersChanged');
}
eventBus.emit('usersChanged');
notify.showSuccessToast(this.$t("settings.userUpdated"));
mutations.closeTopPrompt();
}
@@ -466,9 +451,7 @@ export default {
await usersApi.update(this.user, ["password"], {
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
});
if (state.user.permissions.admin && this.user.id !== state.user.id) {
eventBus.emit("usersChanged");
}
eventBus.emit("usersChanged");
notify.showSuccessToast(this.$t("settings.userUpdated"));
} catch (e) {
notify.showError(e);
-1
View File
@@ -326,7 +326,6 @@
"tusUploadsRetryCount": "عدد مرات إعادة المحاولة التي يجب إجراؤها في حالة فشل تحميل قطعة ما.",
"userHomeBasePath": "المسار الأساسي للدلائل الرئيسية للمستخدم",
"userScopeGenerationPlaceholder": "سيتم إنشاء النطاق تلقائيًا",
"createUserHomeDirectory": "إنشاء الدليل الرئيسي للمستخدم",
"shareDeleted": "تم حذف المشاركة",
"loginMethodDescription": "طريقة تسجيل الدخول المسموح بها للمستخدم.",
"userNotAdmin": "المستخدم ليس مسؤولاً",
-1
View File
@@ -436,7 +436,6 @@
"tusUploadsRetryCount": "Počet pokusů o opětovné nahrání části při chybě.",
"userHomeBasePath": "Základní cesta pro domovské adresáře uživatelů",
"userScopeGenerationPlaceholder": "Rozsah bude vygenerován automaticky",
"createUserHomeDirectory": "Vytvořit domovský adresář uživatele",
"customStylesheet": "Vlastní styl",
"defaultUserDescription": "Toto jsou výchozí nastavení pro nové uživatele.",
"disableExternalLinks": "Zakázat externí odkazy (kromě dokumentace)",
-1
View File
@@ -327,7 +327,6 @@
"UserManagement": "Benutzerverwaltung",
"userHomeBasePath": "Basispfad für Benutzer-Home-Verzeichnisse",
"userScopeGenerationPlaceholder": "Der Bereich wird automatisch generiert",
"createUserHomeDirectory": "Benutzer-Home-Verzeichnis erstellen",
"loginMethodDescription": "erlaubte Anmeldemethode für den Benutzer.",
"userNotAdmin": "Benutzer ist kein Administrator",
"enterPassword": "Passwort eingeben",
-1
View File
@@ -282,7 +282,6 @@
"tusUploadsRetryCount": "Αριθμός επαναληπτικών δοκιμών που θα πραγματοποιηθούν αν αποτύχει η μεταφόρτωση ενός τμήματος.",
"userHomeBasePath": "Βασική διαδρομή αρχείων για τους φακέλους των χρηστών",
"userScopeGenerationPlaceholder": "Η εμβέλεια εφαρμογής θα δημιουργηθεί αυτόματα",
"createUserHomeDirectory": "Δημιουργία φακέλου χρήστη",
"customStylesheet": "Προσαρμοσμένο στυλ εμφάνισης (stylesheet)",
"defaultUserDescription": "Αυτές είναι οι προεπιλεγμένες ρυθμίσεις για νέους χρήστες.",
"disableExternalLinks": "Απενεργοποίηση εξωτερικών συνδέσμων (εκτός από συνδέσμους προς τις οδηγίες χρήσης)",
-1
View File
@@ -577,7 +577,6 @@
"tusUploadsRetryCount": "Number of retries to perform if a chunk fails to upload.",
"userHomeBasePath": "Base path for user home directories",
"userScopeGenerationPlaceholder": "The scope will be auto generated",
"createUserHomeDirectory": "Create user home directory",
"customStylesheet": "Custom Stylesheet",
"defaultUserDescription": "These are the default settings for new users.",
"disableExternalLinks": "Disable external links (except documentation)",
-1
View File
@@ -276,7 +276,6 @@
"createUserDir": "Crea automaticamente una carpeta cuando se agrega un nuevo usuario",
"userHomeBasePath": "Ruta base para la carpeta personal del usuario",
"userScopeGenerationPlaceholder": "La carpeta se generará automáticamente",
"createUserHomeDirectory": "Crea una carpeta personal para cada usuario",
"customStylesheet": "Hoja de estilos personalizada",
"defaultUserDescription": "Configuración por defecto nuevos usuarios",
"disableExternalLinks": "Desactivar enlaces externos",
-1
View File
@@ -327,7 +327,6 @@
"tusUploadsRetryCount": "Nombre de tentatives à effectuer si un bloc ne parvient pas à être importé.",
"userHomeBasePath": "Chemin de base pour les répertoires personnels des utilisateurs",
"userScopeGenerationPlaceholder": "Le champ d'application sera généré automatiquement",
"createUserHomeDirectory": "Créer le répertoire personnel de l'utilisateur",
"api": "API",
"loginMethodDescription": "méthode de connexion autorisée pour l'utilisateur.",
"userNotAdmin": "L'utilisateur n'est pas un administrateur",
-1
View File
@@ -275,7 +275,6 @@
"createUserDir": "צור תקיית בית במהלך הוספת משתמש חדש",
"userHomeBasePath": "נתיב ראשי לתקיות הבית של משתמשים",
"userScopeGenerationPlaceholder": "התחום יווצר אוטומטית",
"createUserHomeDirectory": "צור תקיית בית למשתמש",
"customStylesheet": "עיצוב מותאם אישית (Stylesheet)",
"defaultUserDescription": "אלה הגדרות ברירת המחדל למשתמשים חדשים",
"disableExternalLinks": "השבת קישורים חיצוניים (למעט תיעוד)",
-1
View File
@@ -275,7 +275,6 @@
"createUserDir": "Felhasználók saját mappáinak automatikus létrehozása új felhasználók hozzáadásakor",
"userHomeBasePath": "Alap elérési útvonal a felhasználók saját mappáihoz",
"userScopeGenerationPlaceholder": "A környezet automatikus lesz létrehozva",
"createUserHomeDirectory": "Felhasználói saját mappák létrehozása",
"customStylesheet": "Egyéni stíluslap",
"defaultUserDescription": "Ezek az alapértelmezett beállítások az új felhasználók számára.",
"disableExternalLinks": "Külső linkek letiltása (kivéve a dokumentáció)",
-1
View File
@@ -577,7 +577,6 @@
"tusUploadsRetryCount": "Numero di tentativi se un pezzo fallisce.",
"userHomeBasePath": "Percorso base per le home degli utenti",
"userScopeGenerationPlaceholder": "L'ambito verrà generato automaticamente",
"createUserHomeDirectory": "Crea directory home utente",
"customStylesheet": "Foglio di stile personalizzato (CSS)",
"defaultUserDescription": "Impostazioni predefinite per i nuovi utenti.",
"disableExternalLinks": "Disabilita link esterni (eccetto documentazione)",
-1
View File
@@ -326,7 +326,6 @@
"tusUploadsRetryCount": "チャンクのアップロードに失敗した場合の再試行回数",
"userHomeBasePath": "ユーザーホームディレクトリのベースパス",
"userScopeGenerationPlaceholder": "スコープは自動生成されます。",
"createUserHomeDirectory": "ユーザーホームディレクトリの作成",
"shareDeleted": "共有が削除されました",
"loginMethodDescription": "ユーザーに許可されるログイン方法",
"userNotAdmin": "ユーザーは管理者ではありません",
-1
View File
@@ -326,7 +326,6 @@
"tusUploadsRetryCount": "청크 업로드에 실패한 경우 수행할 재시도 횟수입니다.",
"userHomeBasePath": "사용자 홈 디렉터리의 기본 경로",
"userScopeGenerationPlaceholder": "범위가 자동으로 생성됩니다.",
"createUserHomeDirectory": "사용자 홈 디렉터리 만들기",
"shareDeleted": "공유 삭제됨!",
"loginMethodDescription": "사용자에게 허용된 로그인 방법을 설정합니다.",
"userNotAdmin": "사용자가 관리자가 아닙니다.",
-1
View File
@@ -332,7 +332,6 @@
"tusUploadsRetryCount": "Aantal pogingen dat moet worden uitgevoerd als het uploaden van een chunk mislukt.",
"userHomeBasePath": "Basispad voor homedirectory's van gebruikers",
"userScopeGenerationPlaceholder": "De scope wordt automatisch gegenereerd",
"createUserHomeDirectory": "Thuismap gebruiker aanmaken",
"shareSettings": "Instellingen delen",
"shareDeleted": "Deel verwijderd!",
"adminOptions": "Opties voor beheerders",
-1
View File
@@ -577,7 +577,6 @@
"tusUploadsRetryCount": "Aantal pogingen dat moet worden uitgevoerd als het uploaden van een brok mislukt.",
"userHomeBasePath": "Basispad voor thuismappen van gebruikers",
"userScopeGenerationPlaceholder": "Het bereik wordt automatisch gegenereerd",
"createUserHomeDirectory": "Thuismap voor gebruiker aanmaken",
"customStylesheet": "Aangepast Stylesheet",
"defaultUserDescription": "Dit zijn de standaardinstellingen voor nieuwe gebruikers.",
"disableExternalLinks": "Externe links uitschakelen (behalve documentatie)",
-1
View File
@@ -326,7 +326,6 @@
"tusUploadsRetryCount": "Liczba ponawianych prób w przypadku niepowodzenia przesłania fragmentu.",
"userHomeBasePath": "Ścieżka podstawowa dla katalogów domowych użytkownika",
"userScopeGenerationPlaceholder": "Zakres zostanie wygenerowany automatycznie",
"createUserHomeDirectory": "Tworzenie katalogu domowego użytkownika",
"shareDeleted": "Usunięto udostępnianie!",
"loginMethodDescription": "dozwolona metoda logowania dla użytkownika.",
"userNotAdmin": "Użytkownik nie jest administratorem",
-1
View File
@@ -275,7 +275,6 @@
"createUserDir": "Criar diretório Home para novos usuários",
"userHomeBasePath": "Caminho base para diretórios de usuários",
"userScopeGenerationPlaceholder": "O escopo será gerado automaticamente",
"createUserHomeDirectory": "Criar diretório Home de usuário",
"customStylesheet": "Estilos personalizados",
"defaultUserDescription": "Estas são as configurações padrão para novos usuários.",
"disableExternalLinks": "Desabilitar links externos (exceto documentação)",
-1
View File
@@ -332,7 +332,6 @@
"tusUploadsRetryCount": "Número de tentativas a efetuar se um pedaço não for carregado.",
"userHomeBasePath": "Caminho de base para as diretorias pessoais dos utilizadores",
"userScopeGenerationPlaceholder": "O âmbito será gerado automaticamente",
"createUserHomeDirectory": "Criar o diretório pessoal do utilizador",
"shareSettings": "Partilhar definições",
"shareDeleted": "Partilhar eliminado!",
"adminOptions": "Opções do utilizador administrador",
-1
View File
@@ -326,7 +326,6 @@
"tusUploadsRetryCount": "Numărul de încercări care trebuie efectuate dacă o bucată nu reușește să fie încărcată.",
"userHomeBasePath": "Calea de bază pentru directoarele home ale utilizatorilor",
"userScopeGenerationPlaceholder": "Domeniul de aplicare va fi generat automat",
"createUserHomeDirectory": "Crearea directorului home al utilizatorului",
"shareDeleted": "Share șters!",
"loginMethodDescription": "metoda de conectare permisă pentru utilizator.",
"userNotAdmin": "Utilizatorul nu este administrator",
-1
View File
@@ -327,7 +327,6 @@
"tusUploadsRetryCount": "Количество повторных попыток, которые необходимо выполнить, если чанк не удалось загрузить.",
"userHomeBasePath": "Базовый путь к домашним каталогам пользователей",
"userScopeGenerationPlaceholder": "Область действия будет сгенерирована автоматически",
"createUserHomeDirectory": "Создать домашний каталог пользователя",
"loginMethodDescription": "разрешенный метод входа для пользователя.",
"userNotAdmin": "Пользователь не является администратором",
"enterPassword": "Введите пароль",
-1
View File
@@ -327,7 +327,6 @@
"tusUploadsRetryCount": "Počet opakovaných pokusov, ktoré sa vykonajú, ak sa nepodarí nahrať časť.",
"userHomeBasePath": "Základná cesta pre domovské adresáre používateľov",
"userScopeGenerationPlaceholder": "Rozsah sa vygeneruje automaticky",
"createUserHomeDirectory": "Vytvorenie domovského adresára používateľa",
"loginMethodDescription": "povolená metóda prihlásenia pre používateľa.",
"userNotAdmin": "Používateľ nie je správca",
"enterPassword": "Zadajte heslo",
-1
View File
@@ -332,7 +332,6 @@
"tusUploadsRetryCount": "Antal försök som ska göras om en chunk inte kan laddas upp.",
"userHomeBasePath": "Basväg för användares hemkataloger",
"userScopeGenerationPlaceholder": "Omfattningen kommer att genereras automatiskt",
"createUserHomeDirectory": "Skapa användarens hemkatalog",
"shareSettings": "Dela inställningar",
"shareDeleted": "Dela borttaget!",
"adminOptions": "Alternativ för administratörsanvändare",
-1
View File
@@ -327,7 +327,6 @@
"tusUploadsRetryCount": "Bir yığının yüklenememesi durumunda gerçekleştirilecek yeniden deneme sayısı.",
"userHomeBasePath": "Kullanıcı ev dizinleri için temel yol",
"userScopeGenerationPlaceholder": "Kapsam otomatik olarak oluşturulacaktır",
"createUserHomeDirectory": "Kullanıcı ev dizini oluşturma",
"loginMethodDescription": "kullanıcı için izin verilen oturum açma yöntemi.",
"userNotAdmin": "Kullanıcı yönetici değil",
"enterPassword": "Şifre girin",
-1
View File
@@ -333,7 +333,6 @@
"tusUploadsRetryCount": "Кількість повторних спроб, якщо фрагмент не вдалося завантажити.",
"userHomeBasePath": "Базовий шлях для домашніх каталогів користувачів",
"userScopeGenerationPlaceholder": "Область дії буде згенеровано автоматично",
"createUserHomeDirectory": "Створення домашнього каталогу користувача",
"shareSettings": "Налаштування спільного доступу",
"adminOptions": "Параметри користувача адміністрування",
"searchOptions": "Параметри пошуку",
-1
View File
@@ -333,7 +333,6 @@
"tusUploadsRetryCount": "块上传失败时的重试次数。",
"userHomeBasePath": "用户主目录的基本路径",
"userScopeGenerationPlaceholder": "范围将自动生成",
"createUserHomeDirectory": "创建用户主目录",
"shareSettings": "共享设置",
"adminOptions": "管理员用户选项",
"searchOptions": "搜索选项",
-1
View File
@@ -333,7 +333,6 @@
"tusUploadsRetryCount": "分塊上傳失敗時的重試次數。",
"userHomeBasePath": "使用者主目錄的基本路徑",
"userScopeGenerationPlaceholder": "範圍會自動產生",
"createUserHomeDirectory": "建立使用者主目錄",
"shareSettings": "分享設定",
"shareDeleted": "分享已刪除!",
"adminOptions": "管理員使用者選項",
+48
View File
@@ -3,6 +3,47 @@ import { resourcesApi } from "@/api";
import { state,mutations } from "@/store";
import { getters } from "@/store/getters";
/**
* Accumulates every directory entry from a FileSystemDirectoryReader.
*
* Chromium and Safari cap DirectoryReader.readEntries() at roughly one batch
* (often 100 entries) per call — callers must loop until a batch is empty.
*
* @param {FileSystemDirectoryReader} reader
* @param {string} [debugLabel] optional path/name for diagnostic logs
* @returns {Promise<FileSystemEntry[]>}
*/
export async function readAllDirectoryEntries(reader, debugLabel = "") {
/** @type {FileSystemEntry[]} */
const all = [];
let nonemptyBatches = 0;
/** @type {FileSystemEntry[]} */
let batch = await new Promise((resolve, reject) => {
reader.readEntries(resolve, reject);
});
while (batch.length > 0) {
nonemptyBatches++;
all.push(...batch);
batch = await new Promise((resolve, reject) => {
reader.readEntries(resolve, reject);
});
}
if (nonemptyBatches > 1 || all.length > 100) {
const suffix =
debugLabel !== ""
? ` (${debugLabel})`
: "";
console.info(
`[FileBrowser upload] directory listing${suffix}: ${nonemptyBatches} readEntries batches — collected ${all.length} entries (multiple batches indicate a directory with > ~100 immediate children)`
);
}
return all;
}
class UploadManager {
constructor() {
this.queue = reactive([]);
@@ -183,6 +224,13 @@ class UploadManager {
return upload;
});
const dirCount = newUploads.length;
if (fileUploads.length >= 50) {
console.info(
`[FileBrowser upload] add(): ${fileUploads.length} file job(s), ${dirCount} directory job(s) → base ${basePath}`
);
}
this.queue.push(...newUploads, ...fileUploads);
// Clean up pending items after successful add
+4 -4
View File
@@ -149,6 +149,7 @@ import * as upload from "@/utils/upload";
import throttle from "@/utils/throttle";
import { state, mutations, getters } from "@/store";
import { url } from "@/utils";
import { readAllDirectoryEntries } from "@/utils/upload";
import Item from "@/components/files/ListingItem.vue";
import Upload from "@/components/prompts/Upload.vue";
@@ -874,11 +875,10 @@ export default {
relativePath: entryPath,
});
} else if (entry.isDirectory) {
// But if it's a directory, read the contents
// But if it's a directory, read the contents (readEntries may require
// multiple calls — browsers often return at most ~100 entries per batch)
const reader = entry.createReader();
const entries = await new Promise((resolve, reject) => {
reader.readEntries(resolve, reject);
});
const entries = await readAllDirectoryEntries(reader, entryPath);
// and then for each child recursively collect files
for (const childEntry of entries) {
const childFiles = await this.collectFilesFromEntry(