From eba24c9d63adbeddac8f5c429c5dfdbf5cd68c29 Mon Sep 17 00:00:00 2001 From: Mukhtar Akere Date: Thu, 7 Aug 2025 05:31:07 +0100 Subject: [PATCH] Fix issues with rclone setup --- Dockerfile | 2 +- internal/config/config.go | 13 +-- main.go | 11 +++ pkg/debrid/store/refresh.go | 2 +- pkg/debrid/store/worker.go | 9 +- pkg/mount/mount.go | 152 +++++++++++++----------------- pkg/web/assets/build/js/config.js | 2 +- pkg/web/assets/js/config.js | 3 +- pkg/web/templates/config.html | 19 +++- 9 files changed, 108 insertions(+), 105 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0e01368..8b53ceb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,7 @@ ENV LOG_PATH=/app/logs EXPOSE 8282 VOLUME ["/app"] -HEALTHCHECK --interval=3s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app", "--basic"] +HEALTHCHECK --interval=10s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app", "--basic"] ENTRYPOINT ["/entrypoint.sh"] CMD ["/usr/bin/decypharr", "--config", "/app"] \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 111c29b..7977426 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -103,8 +103,9 @@ type Rclone struct { BufferSize string `json:"buffer_size,omitempty"` // Buffer size for reading files (default 16M) // File system settings - UID uint32 `json:"uid,omitempty"` // User ID for mounted files - GID uint32 `json:"gid,omitempty"` // Group ID for mounted files + UID uint32 `json:"uid,omitempty"` // User ID for mounted files + GID uint32 `json:"gid,omitempty"` // Group ID for mounted files + Umask string `json:"umask,omitempty"` // Timeout settings AttrTimeout string `json:"attr_timeout,omitempty"` // Attribute cache timeout (default 1s) @@ -338,7 +339,7 @@ func (c *Config) updateDebrid(d Debrid) Debrid { } if d.TorrentsRefreshInterval == "" { - d.TorrentsRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "15s") // 15 seconds + d.TorrentsRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "45s") // 45 seconds } if d.WebDav.DownloadLinksRefreshInterval == "" { d.DownloadLinksRefreshInterval = cmp.Or(c.WebDav.DownloadLinksRefreshInterval, "40m") // 40 minutes @@ -399,8 +400,8 @@ func (c *Config) setDefaults() { c.Repair.Strategy = RepairStrategyPerTorrent } + // Rclone defaults if c.Rclone.Enabled { - c.Rclone.MountPath = cmp.Or(c.Rclone.MountPath, filepath.Join(c.Path, "mounts")) c.Rclone.VfsCacheMode = cmp.Or(c.Rclone.VfsCacheMode, "off") if c.Rclone.UID == 0 { c.Rclone.UID = uint32(os.Getuid()) @@ -414,12 +415,8 @@ func (c *Config) setDefaults() { } } if c.Rclone.VfsCacheMode != "off" { - c.Rclone.VfsCachePollInterval = cmp.Or(c.Rclone.VfsCachePollInterval, "1m") - c.Rclone.VfsReadChunkSizeLimit = cmp.Or(c.Rclone.VfsReadChunkSizeLimit, "off") c.Rclone.VfsCachePollInterval = cmp.Or(c.Rclone.VfsCachePollInterval, "1m") // Clean cache every minute } - - c.Rclone.AttrTimeout = cmp.Or(c.Rclone.AttrTimeout, "10s") c.Rclone.DirCacheTime = cmp.Or(c.Rclone.DirCacheTime, "5m") } // Load the auth file diff --git a/main.go b/main.go index 8aff7dd..f51079c 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "github.com/sirrobot01/decypharr/cmd/decypharr" "github.com/sirrobot01/decypharr/internal/config" "log" + "net/http" + _ "net/http/pprof" "os" "os/signal" "runtime/debug" @@ -25,6 +27,15 @@ func main() { config.SetConfigPath(configPath) config.Get() + if os.Getenv("ENABLE_PPROF") == "true" { + go func() { + log.Println("Starting pprof server on :6060") + if err := http.ListenAndServe(":6060", nil); err != nil { + log.Printf("pprof server error: %v", err) + } + }() + } + // Create a context canceled on SIGINT/SIGTERM ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/pkg/debrid/store/refresh.go b/pkg/debrid/store/refresh.go index 40a359c..310d2fa 100644 --- a/pkg/debrid/store/refresh.go +++ b/pkg/debrid/store/refresh.go @@ -134,7 +134,7 @@ func (c *Cache) refreshRclone() error { dirs = []string{"__all__"} } if c.mounter != nil { - return c.mounter.Refresh(dirs) + return c.mounter.RefreshDir(dirs) } else { return c.refreshRcloneWithRC(dirs) } diff --git a/pkg/debrid/store/worker.go b/pkg/debrid/store/worker.go index e572727..5f6552d 100644 --- a/pkg/debrid/store/worker.go +++ b/pkg/debrid/store/worker.go @@ -9,6 +9,9 @@ import ( func (c *Cache) StartSchedule(ctx context.Context) error { // For now, we just want to refresh the listing and download links + // Stop any existing jobs before starting new ones + c.scheduler.RemoveByTags("decypharr") + // Schedule download link refresh job if jd, err := utils.ConvertToJobDef(c.downloadLinksRefreshInterval); err != nil { c.logger.Error().Err(err).Msg("Failed to convert download link refresh interval to job definition") @@ -16,7 +19,7 @@ func (c *Cache) StartSchedule(ctx context.Context) error { // Schedule the job if _, err := c.scheduler.NewJob(jd, gocron.NewTask(func() { c.refreshDownloadLinks(ctx) - }), gocron.WithContext(ctx)); err != nil { + }), gocron.WithContext(ctx), gocron.WithTags("decypharr")); err != nil { c.logger.Error().Err(err).Msg("Failed to create download link refresh job") } else { c.logger.Debug().Msgf("Download link refresh job scheduled for every %s", c.downloadLinksRefreshInterval) @@ -30,7 +33,7 @@ func (c *Cache) StartSchedule(ctx context.Context) error { // Schedule the job if _, err := c.scheduler.NewJob(jd, gocron.NewTask(func() { c.refreshTorrents(ctx) - }), gocron.WithContext(ctx)); err != nil { + }), gocron.WithContext(ctx), gocron.WithTags("decypharr")); err != nil { c.logger.Error().Err(err).Msg("Failed to create torrent refresh job") } else { c.logger.Debug().Msgf("Torrent refresh job scheduled for every %s", c.torrentRefreshInterval) @@ -46,7 +49,7 @@ func (c *Cache) StartSchedule(ctx context.Context) error { // Schedule the job if _, err := c.cetScheduler.NewJob(jd, gocron.NewTask(func() { c.resetInvalidLinks(ctx) - }), gocron.WithContext(ctx)); err != nil { + }), gocron.WithContext(ctx), gocron.WithTags("decypharr")); err != nil { c.logger.Error().Err(err).Msg("Failed to create link reset job") } else { c.logger.Debug().Msgf("Link reset job scheduled for every midnight, CET") diff --git a/pkg/mount/mount.go b/pkg/mount/mount.go index a02326f..5474ad4 100644 --- a/pkg/mount/mount.go +++ b/pkg/mount/mount.go @@ -48,7 +48,6 @@ type Mount struct { LocalPath string WebDAVURL string mountPoint *mountlib.MountPoint - vfs *vfs.VFS cancel context.CancelFunc mounted atomic.Bool logger zerolog.Logger @@ -105,13 +104,6 @@ func (m *Mount) Mount(ctx context.Context) error { return fmt.Errorf("failed to set rclone config: %w", err) } - // Check if fusermount3 is available - if _, err := exec.LookPath("fusermount3"); err != nil { - m.logger.Info().Msgf("FUSE mounting not available (fusermount3 not found). Files accessible via WebDAV at %s", m.WebDAVURL) - m.mounted.Store(true) // Mark as "mounted" for WebDAV access - return nil - } - // Get the mount function - try different mount methods mountFn, err := getMountFn() if err != nil { @@ -125,9 +117,7 @@ func (m *Mount) Mount(ctx context.Context) error { } m.mounted.Store(true) m.logger.Info().Msgf("Successfully mounted %s WebDAV at %s", m.Provider, m.LocalPath) - - // Wait for context cancellation - <-mountCtx.Done() + <-mountCtx.Done() // Wait for context cancellation }() m.logger.Info().Msgf("Mount process started for %s at %s", m.Provider, m.LocalPath) return nil @@ -138,6 +128,7 @@ func setRcloneConfig(configName, webdavURL string) error { config.FileSetValue(configName, "type", "webdav") config.FileSetValue(configName, "url", webdavURL) config.FileSetValue(configName, "vendor", "other") + config.FileSetValue(configName, "pacer_min_sleep", "0") return nil } @@ -149,8 +140,7 @@ func (m *Mount) performMount(ctx context.Context, mountfn mountlib.MountFn) erro } // Get global rclone config - cfg := configPkg.Get() - rcloneOpt := &cfg.Rclone + rcloneOpt := configPkg.Get().Rclone // Parse cache mode var cacheMode vfscommon.CacheMode @@ -167,12 +157,30 @@ func (m *Mount) performMount(ctx context.Context, mountfn mountlib.MountFn) erro cacheMode = vfscommon.CacheModeOff } - vfsOpt := &vfscommon.Options{ - NoModTime: rcloneOpt.NoModTime, - NoChecksum: rcloneOpt.NoChecksum, - CacheMode: cacheMode, - UID: rcloneOpt.UID, - GID: rcloneOpt.GID, + vfsOpt := &vfscommon.Options{} + + vfsOpt.Init() // Initialize VFS options with default values + + vfsOpt.CacheMode = cacheMode + + // Set VFS options based on rclone configuration + if rcloneOpt.NoChecksum { + vfsOpt.NoChecksum = rcloneOpt.NoChecksum + } + if rcloneOpt.NoModTime { + vfsOpt.NoModTime = rcloneOpt.NoModTime + } + if rcloneOpt.UID != 0 { + vfsOpt.UID = rcloneOpt.UID + } + if rcloneOpt.GID != 0 { + vfsOpt.GID = rcloneOpt.GID + } + if rcloneOpt.Umask != "" { + var umask vfscommon.FileMode + if err := umask.Set(rcloneOpt.Umask); err == nil { + vfsOpt.Umask = umask + } } // Parse duration strings @@ -223,6 +231,8 @@ func (m *Mount) performMount(ctx context.Context, mountfn mountlib.MountFn) erro } } + fs.GetConfig(ctx).UseMmap = true + if rcloneOpt.VfsCacheMaxSize != "" { var cacheMaxSize fs.SizeSuffix if err := cacheMaxSize.Set(rcloneOpt.VfsCacheMaxSize); err == nil { @@ -236,6 +246,7 @@ func (m *Mount) performMount(ctx context.Context, mountfn mountlib.MountFn) erro AllowNonEmpty: true, AllowOther: true, Daemon: false, + AsyncRead: true, DeviceName: fmt.Sprintf("decypharr-%s", m.Provider), VolumeName: fmt.Sprintf("decypharr-%s", m.Provider), } @@ -258,20 +269,14 @@ func (m *Mount) performMount(ctx context.Context, mountfn mountlib.MountFn) erro m.logger.Error().Err(err).Msgf("Failed to set cache directory %s, using default cache", cacheDir) } } - - // Create VFS instance - vfsInstance := vfs.New(fsrc, vfsOpt) - m.vfs = vfsInstance - // Create mount point using rclone's internal mounting - mountPoint := mountlib.NewMountPoint(mountfn, m.LocalPath, fsrc, mountOpt, vfsOpt) - m.mountPoint = mountPoint + m.mountPoint = mountlib.NewMountPoint(mountfn, m.LocalPath, fsrc, mountOpt, vfsOpt) // Start the mount - _, err = mountPoint.Mount() + _, err = m.mountPoint.Mount() if err != nil { // Cleanup mount point if it failed - if mountPoint != nil && mountPoint.UnmountFn != nil { + if m.mountPoint != nil && m.mountPoint.UnmountFn != nil { if unmountErr := m.Unmount(); unmountErr != nil { m.logger.Error().Err(unmountErr).Msgf("Failed to cleanup mount point %s after mount failure", m.LocalPath) } else { @@ -292,12 +297,8 @@ func (m *Mount) Unmount() error { m.mounted.Store(false) - if m.vfs != nil { - m.logger.Debug().Msgf("Shutting down VFS for provider %s", m.Provider) - m.vfs.Shutdown() - } else { - m.logger.Warn().Msgf("VFS instance for provider %s is nil, skipping shutdown", m.Provider) - } + m.logger.Debug().Msgf("Shutting down VFS for provider %s", m.Provider) + m.mountPoint.VFS.Shutdown() if m.mountPoint == nil || m.mountPoint.UnmountFn == nil { m.logger.Warn().Msgf("Mount point for provider %s is nil or unmount function is not set, skipping unmount", m.Provider) return nil @@ -364,34 +365,31 @@ func (m *Mount) isMountBusy() bool { } func (m *Mount) IsMounted() bool { - return m.mounted.Load() && m.mountPoint != nil + return m.mounted.Load() && m.mountPoint != nil && m.mountPoint.VFS != nil } -func (m *Mount) Refresh(dirs []string) error { +func (m *Mount) RefreshDir(dirs []string) error { + if !m.IsMounted() { + return fmt.Errorf("provider %s not properly mounted. Skipping refreshes", m.Provider) + } - if !m.mounted.Load() || m.vfs == nil { - return fmt.Errorf("provider %s not properly mounted", m.Provider) - } - // Forget the directories first - if err := m.ForgetVFS(dirs); err != nil { - return fmt.Errorf("failed to forget VFS directories for %s: %w", m.Provider, err) - } - //Then refresh the directories - if err := m.RefreshVFS(dirs); err != nil { - return fmt.Errorf("failed to refresh VFS directories for %s: %w", m.Provider, err) - } - return nil + // Use atomic forget-and-refresh to avoid race conditions + return m.forceRefreshVFS(dirs) } -func (m *Mount) RefreshVFS(dirs []string) error { - - root, err := m.vfs.Root() +// forceRefreshVFS atomically forgets and refreshes VFS directories to ensure immediate visibility +func (m *Mount) forceRefreshVFS(dirs []string) error { + vfsInstance := m.mountPoint.VFS + root, err := vfsInstance.Root() if err != nil { return fmt.Errorf("failed to get VFS root for %s: %w", m.Provider, err) } getDir := func(path string) (*vfs.Dir, error) { path = strings.Trim(path, "/") + if path == "" { + return root, nil + } segments := strings.Split(path, "/") var node vfs.Node = root for _, s := range segments { @@ -408,62 +406,38 @@ func (m *Mount) RefreshVFS(dirs []string) error { return nil, vfs.EINVAL } - // If no specific directories provided, refresh root + // If no specific directories provided, work with root if len(dirs) == 0 { - + // Atomically forget and refresh root + root.ForgetAll() if _, err := root.ReadDirAll(); err != nil { - return err - } - return nil - } - - if len(dirs) == 1 { - vfsDir, err := getDir(dirs[0]) - if err != nil { - return fmt.Errorf("failed to find directory '%s' for refresh in %s: %w", dirs[0], m.Provider, err) - } - if _, err := vfsDir.ReadDirAll(); err != nil { - return fmt.Errorf("failed to refresh directory '%s' in %s: %w", dirs[0], m.Provider, err) + return fmt.Errorf("failed to force-refresh root for %s: %w", m.Provider, err) } return nil } var errs []error - // Refresh specific directories + // Process each directory atomically for _, dir := range dirs { if dir != "" { - // Clean the directory path + dir = strings.Trim(dir, "/") + // Get the directory handle vfsDir, err := getDir(dir) if err != nil { - errs = append(errs, fmt.Errorf("failed to find directory '%s' for refresh in %s: %w", dir, m.Provider, err)) + errs = append(errs, fmt.Errorf("failed to find directory '%s' for force-refresh in %s: %w", dir, m.Provider, err)) + continue } + + // Atomically forget and refresh this specific directory + vfsDir.ForgetAll() if _, err := vfsDir.ReadDirAll(); err != nil { - errs = append(errs, fmt.Errorf("failed to refresh directory '%s' in %s: %w", dir, m.Provider, err)) + errs = append(errs, fmt.Errorf("failed to force-refresh directory '%s' in %s: %w", dir, m.Provider, err)) } } } + if len(errs) > 0 { return errors.Join(errs...) } return nil } - -func (m *Mount) ForgetVFS(dirs []string) error { - // Get root directory - root, err := m.vfs.Root() - if err != nil { - return fmt.Errorf("failed to get VFS root for %s: %w", m.Provider, err) - } - - // Forget specific directories - for _, dir := range dirs { - if dir != "" { - // Clean the directory path - dir = strings.Trim(dir, "/") - // Forget the directory from cache - root.ForgetPath(dir, fs.EntryDirectory) - } - } - - return nil -} diff --git a/pkg/web/assets/build/js/config.js b/pkg/web/assets/build/js/config.js index 924c4ca..2fa9927 100644 --- a/pkg/web/assets/build/js/config.js +++ b/pkg/web/assets/build/js/config.js @@ -1 +1 @@ -class ConfigManager{constructor(){this.debridCount=0,this.arrCount=0,this.debridDirectoryCounts={},this.directoryFilterCounts={},this.refs={configForm:document.getElementById("configForm"),loadingOverlay:document.getElementById("loadingOverlay"),debridConfigs:document.getElementById("debridConfigs"),arrConfigs:document.getElementById("arrConfigs"),addDebridBtn:document.getElementById("addDebridBtn"),addArrBtn:document.getElementById("addArrBtn")},this.init()}init(){this.bindEvents(),this.loadConfiguration(),this.setupMagnetHandler(),this.checkIncompleteConfig()}checkIncompleteConfig(){const e=new URLSearchParams(window.location.search);if(e.has("inco")){const n=e.get("inco");window.decypharrUtils.createToast(`Incomplete configuration: ${n}`,"warning")}}bindEvents(){this.refs.configForm.addEventListener("submit",e=>this.saveConfiguration(e)),this.refs.addDebridBtn.addEventListener("click",()=>this.addDebridConfig()),this.refs.addArrBtn.addEventListener("click",()=>this.addArrConfig()),document.addEventListener("change",e=>{e.target.classList.contains("useWebdav")&&this.toggleWebDAVSection(e.target)})}async loadConfiguration(){try{const e=await window.decypharrUtils.fetcher("/api/config");if(!e.ok)throw new Error("Failed to load configuration");const n=await e.json();this.populateForm(n)}catch(e){console.error("Error loading configuration:",e),window.decypharrUtils.createToast("Error loading configuration","error")}}populateForm(e){this.populateGeneralSettings(e),e.debrids&&Array.isArray(e.debrids)&&e.debrids.forEach(e=>this.addDebridConfig(e)),this.populateQBittorrentSettings(e.qbittorrent),e.arrs&&Array.isArray(e.arrs)&&e.arrs.forEach(e=>this.addArrConfig(e)),this.populateRepairSettings(e.repair),this.populateRcloneSettings(e.rclone)}populateGeneralSettings(e){["log_level","url_base","bind_address","port","discord_webhook_url","min_file_size","max_file_size","remove_stalled_after"].forEach(n=>{const t=document.querySelector(`[name="${n}"]`);t&&void 0!==e[n]&&(t.value=e[n])}),e.allowed_file_types&&Array.isArray(e.allowed_file_types)&&(document.querySelector('[name="allowed_file_types"]').value=e.allowed_file_types.join(", "))}populateQBittorrentSettings(e){if(!e)return;["download_folder","refresh_interval","max_downloads","skip_pre_cache"].forEach(n=>{const t=document.querySelector(`[name="qbit.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}populateRepairSettings(e){if(!e)return;["enabled","interval","workers","zurg_url","strategy","use_webdav","auto_process"].forEach(n=>{const t=document.querySelector(`[name="repair.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}populateRcloneSettings(e){if(!e)return;["enabled","mount_path","cache_dir","vfs_cache_mode","vfs_cache_max_size","vfs_cache_max_age","vfs_cache_poll_interval","vfs_read_chunk_size","vfs_read_chunk_size_limit","buffer_size","uid","gid","vfs_read_ahead","attr_timeout","dir_cache_time","poll_interval","no_modtime","no_checksum"].forEach(n=>{const t=document.querySelector(`[name="rclone.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}addDebridConfig(e={}){const n=this.getDebridTemplate(this.debridCount,e);this.refs.debridConfigs.insertAdjacentHTML("beforeend",n);const t=this.refs.debridConfigs.lastElementChild.querySelector(".useWebdav");e.use_webdav&&this.toggleWebDAVSection(t,!0),Object.keys(e).length>0&&this.populateDebridData(this.debridCount,e),this.debridDirectoryCounts[this.debridCount]=0,e.directories&&Object.entries(e.directories).forEach(([e,n])=>{const t=this.addDirectory(this.debridCount,{name:e,...n});n.filters&&Object.entries(n.filters).forEach(([e,n])=>{this.addFilter(this.debridCount,t,e,n)})}),this.debridCount++}populateDebridData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="debrid[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:"download_api_keys"===n&&Array.isArray(t)?(a.value=t.join("\n"),"textarea"===a.tagName.toLowerCase()&&(a.style.webkitTextSecurity="disc",a.style.textSecurity="disc",a.setAttribute("data-password-visible","false"))):a.value=t)})}getDebridTemplate(e,n={}){return`\n
\n
\n
\n

\n \n Debrid Service #${e+1}\n

\n \n
\n
\n
\n \n \n
\n\n
\n \n
\n \n \n
\n
\n API key for the debrid service\n
\n
\n
\n\n
\n
\n
\n \n
\n \n \n
\n
\n Multiple API keys for downloads - leave empty to use main API key\n
\n
\n
\n
\n
\n
\n \n \n
\n Path where debrid files are mounted\n
\n
\n
\n \n \n
\n API rate limit for this service\n
\n
\n
\n \n \n
\n This proxy is used for this debrid account\n
\n
\n
\n \n
\n
\n\n \x3c!-- Options Grid - Full Width Below --\x3e\n
\n
\n \n
\n Create internal WebDAV server\n
\n
\n\n
\n \n
\n Download uncached files\n
\n
\n\n
\n \n
\n Include sample files\n
\n
\n\n
\n \n
\n Preprocess RAR files\n
\n
\n
\n\n \x3c!-- WebDAV Configuration (Initially Hidden) --\x3e\n \n
\n
\n `}toggleWebDAVSection(e,n=!1){const t=e.closest(".debrid-config"),a=t.dataset.index,r=t.querySelector(`#webdav-section-${a}`),i=r.querySelectorAll(".webdav-field");e.checked||n?(r.classList.remove("hidden"),r.querySelectorAll('input[name$=".torrents_refresh_interval"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".download_links_refresh_interval"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".auto_expire_links_after"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".workers"]').forEach(e=>e.required=!0)):(r.classList.add("hidden"),i.forEach(e=>e.required=!1))}addDirectory(e,n={}){this.debridDirectoryCounts[e]||(this.debridDirectoryCounts[e]=0);const t=this.debridDirectoryCounts[e],a=document.getElementById(`debrid[${e}].directories`),r=this.getDirectoryTemplate(e,t);a.insertAdjacentHTML("beforeend",r);const i=`${e}-${t}`;if(this.directoryFilterCounts[i]=0,n.name){const a=document.querySelector(`[name="debrid[${e}].directory[${t}].name"]`);a&&(a.value=n.name)}return this.debridDirectoryCounts[e]++,t}getDirectoryTemplate(e,n){return`\n
\n
\n
\n
Virtual Directory
\n \n
\n\n
\n \n \n
\n\n
\n
\n
\n Filters\n \n
\n
\n\n
\n \x3c!-- Filters will be added here --\x3e\n
\n\n
\n \n\n \n\n \n\n \n
\n
\n
\n
\n `}addFilter(e,n,t,a=""){const r=`${e}-${n}`;this.directoryFilterCounts[r]||(this.directoryFilterCounts[r]=0);const i=this.directoryFilterCounts[r],l=document.getElementById(`debrid[${e}].directory[${n}].filters`);if(l){const s=this.getFilterTemplate(e,n,i,t);if(l.insertAdjacentHTML("beforeend",s),a){const t=l.querySelector(`[name="debrid[${e}].directory[${n}].filter[${i}].value"]`);t&&(t.value=a)}this.directoryFilterCounts[r]++}}getFilterTemplate(e,n,t,a){const r=this.getFilterConfig(a);return`\n
\n
\n ${r.label}\n
\n \n
\n \n
\n \n
\n `}getFilterConfig(e){return{include:{label:"Include",placeholder:"Text that should be included in filename",badgeClass:"badge-primary"},exclude:{label:"Exclude",placeholder:"Text that should not be in filename",badgeClass:"badge-error"},regex:{label:"Regex Match",placeholder:"Regular expression pattern",badgeClass:"badge-warning"},not_regex:{label:"Regex Not Match",placeholder:"Regular expression pattern that should not match",badgeClass:"badge-error"},exact_match:{label:"Exact Match",placeholder:"Exact text to match",badgeClass:"badge-primary"},not_exact_match:{label:"Not Exact Match",placeholder:"Exact text that should not match",badgeClass:"badge-error"},starts_with:{label:"Starts With",placeholder:"Text that filename starts with",badgeClass:"badge-primary"},not_starts_with:{label:"Not Starts With",placeholder:"Text that filename should not start with",badgeClass:"badge-error"},ends_with:{label:"Ends With",placeholder:"Text that filename ends with",badgeClass:"badge-primary"},not_ends_with:{label:"Not Ends With",placeholder:"Text that filename should not end with",badgeClass:"badge-error"},size_gt:{label:"Size Greater Than",placeholder:"Size in bytes, KB, MB, GB (e.g. 700MB)",badgeClass:"badge-success"},size_lt:{label:"Size Less Than",placeholder:"Size in bytes, KB, MB, GB (e.g. 700MB)",badgeClass:"badge-warning"},last_added:{label:"Added in the last",placeholder:"Time duration (e.g. 24h, 7d, 30d)",badgeClass:"badge-info"}}[e]||{label:e.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase()),placeholder:"Filter value",badgeClass:"badge-ghost"}}showFilterHelp(){const e=document.createElement("dialog");e.className="modal",e.innerHTML='\n \n ',document.body.appendChild(e),e.showModal(),e.addEventListener("close",()=>{document.body.removeChild(e)})}addArrConfig(e={}){const n=this.getArrTemplate(this.arrCount,e);this.refs.arrConfigs.insertAdjacentHTML("beforeend",n),Object.keys(e).length>0&&this.populateArrData(this.arrCount,e),this.arrCount++}populateArrData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="arr[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:a.value=t)})}getArrTemplate(e,n={}){const t="auto"===n.source;return`\n
\n
\n
\n

\n \n Arr Service #${e+1}\n ${t?'
Auto-detected
':""}\n

\n ${t?"":'\n \n '}\n
\n\n \n\n
\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n
\n \n \n
\n
\n
\n\n
\n
\n \n \n
\n Which debrid service this Arr should prefer\n
\n
\n\n
\n
\n
\n \n
\n\n
\n \n
\n\n
\n \n
\n
\n
\n
\n
\n
\n `}async saveConfiguration(e){e.preventDefault(),this.refs.loadingOverlay.classList.remove("hidden");try{const e=this.collectFormData(),n=this.validateConfiguration(e);if(!n.valid)throw new Error(n.errors.join("\n"));const t=await window.decypharrUtils.fetcher("/api/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok){const e=await t.text();throw new Error(e||"Failed to save configuration")}window.decypharrUtils.createToast("Configuration saved successfully! Services are restarting...","success"),setTimeout(()=>{window.location.reload()},2e3)}catch(e){console.error("Error saving configuration:",e),window.decypharrUtils.createToast(`Error saving configuration: ${e.message}`,"error"),this.refs.loadingOverlay.classList.add("hidden")}}validateConfiguration(e){const n=[];return e.debrids.forEach((e,t)=>{e.name&&e.api_key&&e.folder||n.push(`Debrid service #${t+1}: Name, API key, and folder are required`)}),e.arrs.forEach((e,t)=>{e.name&&e.host||n.push(`Arr service #${t+1}: Name and host are required`),e.host&&!this.isValidUrl(e.host)&&n.push(`Arr service #${t+1}: Invalid host URL format`)}),e.repair.enabled&&(e.repair.interval||n.push("Repair interval is required when repair is enabled")),e.rclone.enabled&&""===e.rclone.mount_path&&n.push("Rclone mount path is required when Rclone is enabled"),{valid:0===n.length,errors:n}}isValidUrl(e){try{return new URL(e),!0}catch(e){return!1}}collectFormData(){return{log_level:document.getElementById("log-level").value,url_base:document.getElementById("urlBase").value,bind_address:document.getElementById("bindAddress").value,port:document.getElementById("port").value?document.getElementById("port").value:null,discord_webhook_url:document.getElementById("discordWebhookUrl").value,allowed_file_types:document.getElementById("allowedExtensions").value.split(",").map(e=>e.trim()).filter(Boolean),min_file_size:document.getElementById("minFileSize").value,max_file_size:document.getElementById("maxFileSize").value,remove_stalled_after:document.getElementById("removeStalledAfter").value,debrids:this.collectDebridConfigs(),qbittorrent:this.collectQBittorrentConfig(),arrs:this.collectArrConfigs(),repair:this.collectRepairConfig(),rclone:this.collectRcloneConfig()}}collectDebridConfigs(){const e=[];for(let n=0;ne.trim()).filter(e=>e.length>0)),a.use_webdav){a.torrents_refresh_interval=document.querySelector(`[name="debrid[${n}].torrents_refresh_interval"]`).value,a.download_links_refresh_interval=document.querySelector(`[name="debrid[${n}].download_links_refresh_interval"]`).value,a.auto_expire_links_after=document.querySelector(`[name="debrid[${n}].auto_expire_links_after"]`).value,a.folder_naming=document.querySelector(`[name="debrid[${n}].folder_naming"]`).value,a.workers=parseInt(document.querySelector(`[name="debrid[${n}].workers"]`).value),a.rc_url=document.querySelector(`[name="debrid[${n}].rc_url"]`).value,a.rc_user=document.querySelector(`[name="debrid[${n}].rc_user"]`).value,a.rc_pass=document.querySelector(`[name="debrid[${n}].rc_pass"]`).value,a.rc_refresh_dirs=document.querySelector(`[name="debrid[${n}].rc_refresh_dirs"]`).value,a.serve_from_rclone=document.querySelector(`[name="debrid[${n}].serve_from_rclone"]`).checked,a.directories={};const e=this.debridDirectoryCounts[n]||0;for(let t=0;t{const t=document.querySelector(`[name="rclone.${e}"]`);if(!t)return n;if("checkbox"===t.type)return t.checked;if("number"===t.type){const e=parseInt(t.value);return isNaN(e)?0:e}return t.value||n};return{enabled:e("enabled",!1),mount_path:e("mount_path"),buffer_size:e("buffer_size"),cache_dir:e("cache_dir"),vfs_cache_mode:e("vfs_cache_mode","off"),vfs_cache_max_age:e("vfs_cache_max_age","1h"),vfs_cache_max_size:e("vfs_cache_max_size"),vfs_cache_poll_interval:e("vfs_cache_poll_interval","1m"),vfs_read_chunk_size:e("vfs_read_chunk_size","128M"),vfs_read_chunk_size_limit:e("vfs_read_chunk_size_limit","off"),uid:e("uid",0),gid:e("gid",0),vfs_read_ahead:e("vfs_read_ahead","128k"),attr_timeout:e("attr_timeout","1s"),dir_cache_time:e("dir_cache_time","5m"),no_modtime:e("no_modtime",!1),no_checksum:e("no_checksum",!1)}}setupMagnetHandler(){if(window.registerMagnetLinkHandler=()=>{if("registerProtocolHandler"in navigator)try{navigator.registerProtocolHandler("magnet",`${window.location.origin}${window.urlBase}download?magnet=%s`,"Decypharr"),localStorage.setItem("magnetHandler","true");const e=document.getElementById("registerMagnetLink");e.innerHTML='Magnet Handler Registered',e.classList.remove("btn-primary"),e.classList.add("btn-success"),e.disabled=!0,window.decypharrUtils.createToast("Magnet link handler registered successfully")}catch(e){console.error("Failed to register magnet link handler:",e),window.decypharrUtils.createToast("Failed to register magnet link handler","error")}else window.decypharrUtils.createToast("Magnet link registration not supported in this browser","warning")},"true"===localStorage.getItem("magnetHandler")){const e=document.getElementById("registerMagnetLink");e&&(e.innerHTML='Magnet Handler Registered',e.classList.remove("btn-primary"),e.classList.add("btn-success"),e.disabled=!0)}}} \ No newline at end of file +class ConfigManager{constructor(){this.debridCount=0,this.arrCount=0,this.debridDirectoryCounts={},this.directoryFilterCounts={},this.refs={configForm:document.getElementById("configForm"),loadingOverlay:document.getElementById("loadingOverlay"),debridConfigs:document.getElementById("debridConfigs"),arrConfigs:document.getElementById("arrConfigs"),addDebridBtn:document.getElementById("addDebridBtn"),addArrBtn:document.getElementById("addArrBtn")},this.init()}init(){this.bindEvents(),this.loadConfiguration(),this.setupMagnetHandler(),this.checkIncompleteConfig()}checkIncompleteConfig(){const e=new URLSearchParams(window.location.search);if(e.has("inco")){const n=e.get("inco");window.decypharrUtils.createToast(`Incomplete configuration: ${n}`,"warning")}}bindEvents(){this.refs.configForm.addEventListener("submit",e=>this.saveConfiguration(e)),this.refs.addDebridBtn.addEventListener("click",()=>this.addDebridConfig()),this.refs.addArrBtn.addEventListener("click",()=>this.addArrConfig()),document.addEventListener("change",e=>{e.target.classList.contains("useWebdav")&&this.toggleWebDAVSection(e.target)})}async loadConfiguration(){try{const e=await window.decypharrUtils.fetcher("/api/config");if(!e.ok)throw new Error("Failed to load configuration");const n=await e.json();this.populateForm(n)}catch(e){console.error("Error loading configuration:",e),window.decypharrUtils.createToast("Error loading configuration","error")}}populateForm(e){this.populateGeneralSettings(e),e.debrids&&Array.isArray(e.debrids)&&e.debrids.forEach(e=>this.addDebridConfig(e)),this.populateQBittorrentSettings(e.qbittorrent),e.arrs&&Array.isArray(e.arrs)&&e.arrs.forEach(e=>this.addArrConfig(e)),this.populateRepairSettings(e.repair),this.populateRcloneSettings(e.rclone)}populateGeneralSettings(e){["log_level","url_base","bind_address","port","discord_webhook_url","min_file_size","max_file_size","remove_stalled_after"].forEach(n=>{const t=document.querySelector(`[name="${n}"]`);t&&void 0!==e[n]&&(t.value=e[n])}),e.allowed_file_types&&Array.isArray(e.allowed_file_types)&&(document.querySelector('[name="allowed_file_types"]').value=e.allowed_file_types.join(", "))}populateQBittorrentSettings(e){if(!e)return;["download_folder","refresh_interval","max_downloads","skip_pre_cache"].forEach(n=>{const t=document.querySelector(`[name="qbit.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}populateRepairSettings(e){if(!e)return;["enabled","interval","workers","zurg_url","strategy","use_webdav","auto_process"].forEach(n=>{const t=document.querySelector(`[name="repair.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}populateRcloneSettings(e){if(!e)return;["enabled","mount_path","cache_dir","vfs_cache_mode","vfs_cache_max_size","vfs_cache_max_age","vfs_cache_poll_interval","vfs_read_chunk_size","vfs_read_chunk_size_limit","buffer_size","uid","gid","vfs_read_ahead","attr_timeout","dir_cache_time","poll_interval","umask","no_modtime","no_checksum"].forEach(n=>{const t=document.querySelector(`[name="rclone.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}addDebridConfig(e={}){const n=this.getDebridTemplate(this.debridCount,e);this.refs.debridConfigs.insertAdjacentHTML("beforeend",n);const t=this.refs.debridConfigs.lastElementChild.querySelector(".useWebdav");e.use_webdav&&this.toggleWebDAVSection(t,!0),Object.keys(e).length>0&&this.populateDebridData(this.debridCount,e),this.debridDirectoryCounts[this.debridCount]=0,e.directories&&Object.entries(e.directories).forEach(([e,n])=>{const t=this.addDirectory(this.debridCount,{name:e,...n});n.filters&&Object.entries(n.filters).forEach(([e,n])=>{this.addFilter(this.debridCount,t,e,n)})}),this.debridCount++}populateDebridData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="debrid[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:"download_api_keys"===n&&Array.isArray(t)?(a.value=t.join("\n"),"textarea"===a.tagName.toLowerCase()&&(a.style.webkitTextSecurity="disc",a.style.textSecurity="disc",a.setAttribute("data-password-visible","false"))):a.value=t)})}getDebridTemplate(e,n={}){return`\n
\n
\n
\n

\n \n Debrid Service #${e+1}\n

\n \n
\n
\n
\n \n \n
\n\n
\n \n
\n \n \n
\n
\n API key for the debrid service\n
\n
\n
\n\n
\n
\n
\n \n
\n \n \n
\n
\n Multiple API keys for downloads - leave empty to use main API key\n
\n
\n
\n
\n
\n
\n \n \n
\n Path where debrid files are mounted\n
\n
\n
\n \n \n
\n API rate limit for this service\n
\n
\n
\n \n \n
\n This proxy is used for this debrid account\n
\n
\n
\n \n
\n
\n\n \x3c!-- Options Grid - Full Width Below --\x3e\n
\n
\n \n
\n Create internal WebDAV server\n
\n
\n\n
\n \n
\n Download uncached files\n
\n
\n\n
\n \n
\n Include sample files\n
\n
\n\n
\n \n
\n Preprocess RAR files\n
\n
\n
\n\n \x3c!-- WebDAV Configuration (Initially Hidden) --\x3e\n \n
\n
\n `}toggleWebDAVSection(e,n=!1){const t=e.closest(".debrid-config"),a=t.dataset.index,r=t.querySelector(`#webdav-section-${a}`),i=r.querySelectorAll(".webdav-field");e.checked||n?(r.classList.remove("hidden"),r.querySelectorAll('input[name$=".torrents_refresh_interval"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".download_links_refresh_interval"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".auto_expire_links_after"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".workers"]').forEach(e=>e.required=!0)):(r.classList.add("hidden"),i.forEach(e=>e.required=!1))}addDirectory(e,n={}){this.debridDirectoryCounts[e]||(this.debridDirectoryCounts[e]=0);const t=this.debridDirectoryCounts[e],a=document.getElementById(`debrid[${e}].directories`),r=this.getDirectoryTemplate(e,t);a.insertAdjacentHTML("beforeend",r);const i=`${e}-${t}`;if(this.directoryFilterCounts[i]=0,n.name){const a=document.querySelector(`[name="debrid[${e}].directory[${t}].name"]`);a&&(a.value=n.name)}return this.debridDirectoryCounts[e]++,t}getDirectoryTemplate(e,n){return`\n
\n
\n
\n
Virtual Directory
\n \n
\n\n
\n \n \n
\n\n
\n
\n
\n Filters\n \n
\n
\n\n
\n \x3c!-- Filters will be added here --\x3e\n
\n\n
\n \n\n \n\n \n\n \n
\n
\n
\n
\n `}addFilter(e,n,t,a=""){const r=`${e}-${n}`;this.directoryFilterCounts[r]||(this.directoryFilterCounts[r]=0);const i=this.directoryFilterCounts[r],l=document.getElementById(`debrid[${e}].directory[${n}].filters`);if(l){const s=this.getFilterTemplate(e,n,i,t);if(l.insertAdjacentHTML("beforeend",s),a){const t=l.querySelector(`[name="debrid[${e}].directory[${n}].filter[${i}].value"]`);t&&(t.value=a)}this.directoryFilterCounts[r]++}}getFilterTemplate(e,n,t,a){const r=this.getFilterConfig(a);return`\n
\n
\n ${r.label}\n
\n \n
\n \n
\n \n
\n `}getFilterConfig(e){return{include:{label:"Include",placeholder:"Text that should be included in filename",badgeClass:"badge-primary"},exclude:{label:"Exclude",placeholder:"Text that should not be in filename",badgeClass:"badge-error"},regex:{label:"Regex Match",placeholder:"Regular expression pattern",badgeClass:"badge-warning"},not_regex:{label:"Regex Not Match",placeholder:"Regular expression pattern that should not match",badgeClass:"badge-error"},exact_match:{label:"Exact Match",placeholder:"Exact text to match",badgeClass:"badge-primary"},not_exact_match:{label:"Not Exact Match",placeholder:"Exact text that should not match",badgeClass:"badge-error"},starts_with:{label:"Starts With",placeholder:"Text that filename starts with",badgeClass:"badge-primary"},not_starts_with:{label:"Not Starts With",placeholder:"Text that filename should not start with",badgeClass:"badge-error"},ends_with:{label:"Ends With",placeholder:"Text that filename ends with",badgeClass:"badge-primary"},not_ends_with:{label:"Not Ends With",placeholder:"Text that filename should not end with",badgeClass:"badge-error"},size_gt:{label:"Size Greater Than",placeholder:"Size in bytes, KB, MB, GB (e.g. 700MB)",badgeClass:"badge-success"},size_lt:{label:"Size Less Than",placeholder:"Size in bytes, KB, MB, GB (e.g. 700MB)",badgeClass:"badge-warning"},last_added:{label:"Added in the last",placeholder:"Time duration (e.g. 24h, 7d, 30d)",badgeClass:"badge-info"}}[e]||{label:e.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase()),placeholder:"Filter value",badgeClass:"badge-ghost"}}showFilterHelp(){const e=document.createElement("dialog");e.className="modal",e.innerHTML='\n \n ',document.body.appendChild(e),e.showModal(),e.addEventListener("close",()=>{document.body.removeChild(e)})}addArrConfig(e={}){const n=this.getArrTemplate(this.arrCount,e);this.refs.arrConfigs.insertAdjacentHTML("beforeend",n),Object.keys(e).length>0&&this.populateArrData(this.arrCount,e),this.arrCount++}populateArrData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="arr[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:a.value=t)})}getArrTemplate(e,n={}){const t="auto"===n.source;return`\n
\n
\n
\n

\n \n Arr Service #${e+1}\n ${t?'
Auto-detected
':""}\n

\n ${t?"":'\n \n '}\n
\n\n \n\n
\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n
\n \n \n
\n
\n
\n\n
\n
\n \n \n
\n Which debrid service this Arr should prefer\n
\n
\n\n
\n
\n
\n \n
\n\n
\n \n
\n\n
\n \n
\n
\n
\n
\n
\n
\n `}async saveConfiguration(e){e.preventDefault(),this.refs.loadingOverlay.classList.remove("hidden");try{const e=this.collectFormData(),n=this.validateConfiguration(e);if(!n.valid)throw new Error(n.errors.join("\n"));const t=await window.decypharrUtils.fetcher("/api/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok){const e=await t.text();throw new Error(e||"Failed to save configuration")}window.decypharrUtils.createToast("Configuration saved successfully! Services are restarting...","success"),setTimeout(()=>{window.location.reload()},2e3)}catch(e){console.error("Error saving configuration:",e),window.decypharrUtils.createToast(`Error saving configuration: ${e.message}`,"error"),this.refs.loadingOverlay.classList.add("hidden")}}validateConfiguration(e){const n=[];return e.debrids.forEach((e,t)=>{e.name&&e.api_key&&e.folder||n.push(`Debrid service #${t+1}: Name, API key, and folder are required`)}),e.arrs.forEach((e,t)=>{e.name&&e.host||n.push(`Arr service #${t+1}: Name and host are required`),e.host&&!this.isValidUrl(e.host)&&n.push(`Arr service #${t+1}: Invalid host URL format`)}),e.repair.enabled&&(e.repair.interval||n.push("Repair interval is required when repair is enabled")),e.rclone.enabled&&""===e.rclone.mount_path&&n.push("Rclone mount path is required when Rclone is enabled"),{valid:0===n.length,errors:n}}isValidUrl(e){try{return new URL(e),!0}catch(e){return!1}}collectFormData(){return{log_level:document.getElementById("log-level").value,url_base:document.getElementById("urlBase").value,bind_address:document.getElementById("bindAddress").value,port:document.getElementById("port").value?document.getElementById("port").value:null,discord_webhook_url:document.getElementById("discordWebhookUrl").value,allowed_file_types:document.getElementById("allowedExtensions").value.split(",").map(e=>e.trim()).filter(Boolean),min_file_size:document.getElementById("minFileSize").value,max_file_size:document.getElementById("maxFileSize").value,remove_stalled_after:document.getElementById("removeStalledAfter").value,debrids:this.collectDebridConfigs(),qbittorrent:this.collectQBittorrentConfig(),arrs:this.collectArrConfigs(),repair:this.collectRepairConfig(),rclone:this.collectRcloneConfig()}}collectDebridConfigs(){const e=[];for(let n=0;ne.trim()).filter(e=>e.length>0)),a.use_webdav){a.torrents_refresh_interval=document.querySelector(`[name="debrid[${n}].torrents_refresh_interval"]`).value,a.download_links_refresh_interval=document.querySelector(`[name="debrid[${n}].download_links_refresh_interval"]`).value,a.auto_expire_links_after=document.querySelector(`[name="debrid[${n}].auto_expire_links_after"]`).value,a.folder_naming=document.querySelector(`[name="debrid[${n}].folder_naming"]`).value,a.workers=parseInt(document.querySelector(`[name="debrid[${n}].workers"]`).value),a.rc_url=document.querySelector(`[name="debrid[${n}].rc_url"]`).value,a.rc_user=document.querySelector(`[name="debrid[${n}].rc_user"]`).value,a.rc_pass=document.querySelector(`[name="debrid[${n}].rc_pass"]`).value,a.rc_refresh_dirs=document.querySelector(`[name="debrid[${n}].rc_refresh_dirs"]`).value,a.serve_from_rclone=document.querySelector(`[name="debrid[${n}].serve_from_rclone"]`).checked,a.directories={};const e=this.debridDirectoryCounts[n]||0;for(let t=0;t{const t=document.querySelector(`[name="rclone.${e}"]`);if(!t)return n;if("checkbox"===t.type)return t.checked;if("number"===t.type){const e=parseInt(t.value);return isNaN(e)?0:e}return t.value||n};return{enabled:e("enabled",!1),mount_path:e("mount_path"),buffer_size:e("buffer_size"),cache_dir:e("cache_dir"),vfs_cache_mode:e("vfs_cache_mode","off"),vfs_cache_max_age:e("vfs_cache_max_age","1h"),vfs_cache_max_size:e("vfs_cache_max_size"),vfs_cache_poll_interval:e("vfs_cache_poll_interval","1m"),vfs_read_chunk_size:e("vfs_read_chunk_size","128M"),vfs_read_chunk_size_limit:e("vfs_read_chunk_size_limit","off"),uid:e("uid",0),gid:e("gid",0),umask:e("umask",""),vfs_read_ahead:e("vfs_read_ahead","128k"),attr_timeout:e("attr_timeout","1s"),dir_cache_time:e("dir_cache_time","5m"),no_modtime:e("no_modtime",!1),no_checksum:e("no_checksum",!1)}}setupMagnetHandler(){if(window.registerMagnetLinkHandler=()=>{if("registerProtocolHandler"in navigator)try{navigator.registerProtocolHandler("magnet",`${window.location.origin}${window.urlBase}download?magnet=%s`,"Decypharr"),localStorage.setItem("magnetHandler","true");const e=document.getElementById("registerMagnetLink");e.innerHTML='Magnet Handler Registered',e.classList.remove("btn-primary"),e.classList.add("btn-success"),e.disabled=!0,window.decypharrUtils.createToast("Magnet link handler registered successfully")}catch(e){console.error("Failed to register magnet link handler:",e),window.decypharrUtils.createToast("Failed to register magnet link handler","error")}else window.decypharrUtils.createToast("Magnet link registration not supported in this browser","warning")},"true"===localStorage.getItem("magnetHandler")){const e=document.getElementById("registerMagnetLink");e&&(e.innerHTML='Magnet Handler Registered',e.classList.remove("btn-primary"),e.classList.add("btn-success"),e.disabled=!0)}}} \ No newline at end of file diff --git a/pkg/web/assets/js/config.js b/pkg/web/assets/js/config.js index 17cd7ab..dcbb934 100644 --- a/pkg/web/assets/js/config.js +++ b/pkg/web/assets/js/config.js @@ -148,7 +148,7 @@ class ConfigManager { const fields = [ 'enabled', 'mount_path', 'cache_dir', 'vfs_cache_mode', 'vfs_cache_max_size', 'vfs_cache_max_age', 'vfs_cache_poll_interval', 'vfs_read_chunk_size', 'vfs_read_chunk_size_limit', 'buffer_size', - 'uid', 'gid', 'vfs_read_ahead', 'attr_timeout', 'dir_cache_time', 'poll_interval', + 'uid', 'gid', 'vfs_read_ahead', 'attr_timeout', 'dir_cache_time', 'poll_interval', 'umask', 'no_modtime', 'no_checksum' ]; @@ -1236,6 +1236,7 @@ class ConfigManager { vfs_read_chunk_size_limit: getElementValue('vfs_read_chunk_size_limit', 'off'), uid: getElementValue('uid', 0), gid: getElementValue('gid', 0), + umask: getElementValue('umask', ''), vfs_read_ahead: getElementValue('vfs_read_ahead', '128k'), attr_timeout: getElementValue('attr_timeout', '1s'), dir_cache_time: getElementValue('dir_cache_time', '5m'), diff --git a/pkg/web/templates/config.html b/pkg/web/templates/config.html index 1f5470b..7f56766 100644 --- a/pkg/web/templates/config.html +++ b/pkg/web/templates/config.html @@ -385,6 +385,15 @@ Group ID for mounted files (0 = current group) +
+ + +
+ Umask +
+
+
+ + +
+ How long the kernel caches the attributes (size, modification time, etc.) +
+
@@ -497,7 +515,6 @@ How often VFS cache dir gets cleaned -