- Be conservative about the number of goroutines
- Minor fixes
- Add Webdav to ui
- Add more configs to UI
This commit is contained in:
Mukhtar Akere
2025-03-28 00:25:02 +01:00
parent 4ae5de99e8
commit f9bc7ad914
14 changed files with 252 additions and 96 deletions

View File

@@ -3,7 +3,6 @@ package decypharr
import (
"context"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/pkg/proxy"
@@ -15,26 +14,12 @@ import (
"github.com/sirrobot01/debrid-blackhole/pkg/webdav"
"github.com/sirrobot01/debrid-blackhole/pkg/worker"
"os"
"runtime"
"runtime/debug"
"strconv"
"sync"
"syscall"
"time"
)
func monitorGoroutines(interval time.Duration, _log zerolog.Logger) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
_log.Debug().Msgf("Current goroutines: %d", runtime.NumGoroutine())
}
}
}
func Start(ctx context.Context) error {
if umaskStr := os.Getenv("UMASK"); umaskStr != "" {
@@ -121,11 +106,6 @@ func Start(ctx context.Context) error {
})
}
safeGo(func() error {
monitorGoroutines(1*time.Minute, _log)
return nil
})
go func() {
wg.Wait()
close(errChan)

View File

@@ -25,13 +25,8 @@ type Debrid struct {
CheckCached bool `json:"check_cached"`
RateLimit string `json:"rate_limit"` // 200/minute or 10/second
// Webdav
UseWebdav bool `json:"use_webdav"`
TorrentRefreshInterval string `json:"torrent_refresh_interval"`
DownloadLinksRefreshInterval string `json:"downloads_refresh_interval"`
TorrentRefreshWorkers int `json:"torrent_refresh_workers"`
WebDavFolderNaming string `json:"webdav_folder_naming"`
AutoExpireLinksAfter string `json:"auto_expire_links_after"`
UseWebDav bool `json:"use_webdav"`
WebDav
}
type Proxy struct {
@@ -136,6 +131,10 @@ func (c *Config) loadConfig() error {
c.Debrids = append(c.Debrids, c.Debrid)
}
for i, debrid := range c.Debrids {
c.Debrids[i] = c.GetDebridWebDav(debrid)
}
if len(c.AllowedExt) == 0 {
c.AllowedExt = getDefaultExtensions()
}
@@ -313,17 +312,22 @@ func (c *Config) NeedsSetup() bool {
}
func (c *Config) GetDebridWebDav(d Debrid) Debrid {
if d.TorrentRefreshInterval == "" {
d.TorrentRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "15s") // 15 seconds
if !d.UseWebDav {
return d
}
if d.DownloadLinksRefreshInterval == "" {
if d.TorrentsRefreshInterval == "" {
d.TorrentsRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "15s") // 15 seconds
}
if d.WebDav.DownloadLinksRefreshInterval == "" {
d.DownloadLinksRefreshInterval = cmp.Or(c.WebDav.DownloadLinksRefreshInterval, "40m") // 40 minutes
}
if d.TorrentRefreshWorkers == 0 {
d.TorrentRefreshWorkers = cmp.Or(c.WebDav.Workers, 30) // 30 workers
if d.Workers == 0 {
d.Workers = cmp.Or(c.WebDav.Workers, 30) // 30 workers
}
if d.WebDavFolderNaming == "" {
d.WebDavFolderNaming = cmp.Or(c.WebDav.FolderNaming, "original_no_ext")
if d.FolderNaming == "" {
d.FolderNaming = cmp.Or(c.WebDav.FolderNaming, "original_no_ext")
}
if d.AutoExpireLinksAfter == "" {
d.AutoExpireLinksAfter = cmp.Or(c.WebDav.AutoExpireLinksAfter, "24h")

View File

@@ -39,7 +39,7 @@ type PropfindResponse struct {
type CachedTorrent struct {
*types.Torrent
LastRead time.Time `json:"last_read"`
AddedOn time.Time `json:"added_on"`
IsComplete bool `json:"is_complete"`
}
@@ -86,7 +86,7 @@ type Cache struct {
func NewCache(dc config.Debrid, client types.Client) *Cache {
cfg := config.GetConfig()
torrentRefreshInterval, err := time.ParseDuration(dc.TorrentRefreshInterval)
torrentRefreshInterval, err := time.ParseDuration(dc.TorrentsRefreshInterval)
if err != nil {
torrentRefreshInterval = time.Second * 15
}
@@ -109,7 +109,7 @@ func NewCache(dc config.Debrid, client types.Client) *Cache {
torrentRefreshInterval: torrentRefreshInterval,
downloadLinksRefreshInterval: downloadLinksRefreshInterval,
PropfindResp: xsync.NewMapOf[string, PropfindResponse](),
folderNaming: WebDavFolderNaming(dc.WebDavFolderNaming),
folderNaming: WebDavFolderNaming(dc.FolderNaming),
autoExpiresLinksAfter: autoExpiresLinksAfter,
repairsInProgress: xsync.NewMapOf[string, bool](),
saveSemaphore: make(chan struct{}, 10),
@@ -201,6 +201,7 @@ func (c *Cache) load() (map[string]*CachedTorrent, error) {
return torrents, fmt.Errorf("failed to read cache directory: %w", err)
}
now := time.Now()
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
@@ -232,7 +233,11 @@ func (c *Cache) load() (map[string]*CachedTorrent, error) {
linkStore[f.Link] = true
}
}
addedOn, err := time.Parse(time.RFC3339, ct.Added)
if err != nil {
addedOn = now
}
ct.AddedOn = addedOn
ct.IsComplete = true
torrents[ct.Id] = &ct
}
@@ -447,10 +452,15 @@ func (c *Cache) ProcessTorrent(t *types.Torrent, refreshRclone bool) error {
return fmt.Errorf("failed to update torrent: %w", err)
}
}
addedOn, err := time.Parse(time.RFC3339, t.Added)
if err != nil {
addedOn = time.Now()
}
ct := &CachedTorrent{
Torrent: t,
LastRead: time.Now(),
IsComplete: len(t.Files) > 0,
AddedOn: addedOn,
}
c.setTorrent(ct)
@@ -487,25 +497,27 @@ func (c *Cache) GetDownloadLink(torrentId, filename, fileLink string) string {
downloadLink, err := c.client.GetDownloadLink(ct.Torrent, &file)
if err != nil {
if errors.Is(err, request.HosterUnavailableError) {
// This code is commented iut due to the fact that if a torrent link is uncached, it's likely that we can't redownload it again
// Do not attempt to repair the torrent if the hoster is unavailable
// Check link here??
c.logger.Debug().Err(err).Msgf("Hoster is unavailable. Triggering repair for %s", ct.Name)
if err := c.repairTorrent(ct); err != nil {
c.logger.Error().Err(err).Msgf("Failed to trigger repair for %s", ct.Name)
return ""
}
// Generate download link for the file then
f := ct.Files[filename]
downloadLink, _ = c.client.GetDownloadLink(ct.Torrent, &f)
f.DownloadLink = downloadLink
file.Generated = time.Now()
ct.Files[filename] = f
c.updateDownloadLink(file.Link, downloadLink)
go func() {
go c.setTorrent(ct)
}()
return downloadLink // Gets download link in the next pass
//c.logger.Debug().Err(err).Msgf("Hoster is unavailable. Triggering repair for %s", ct.Name)
//if err := c.repairTorrent(ct); err != nil {
// c.logger.Error().Err(err).Msgf("Failed to trigger repair for %s", ct.Name)
// return ""
//}
//// Generate download link for the file then
//f := ct.Files[filename]
//downloadLink, _ = c.client.GetDownloadLink(ct.Torrent, &f)
//f.DownloadLink = downloadLink
//file.Generated = time.Now()
//ct.Files[filename] = f
//c.updateDownloadLink(file.Link, downloadLink)
//
//go func() {
// go c.setTorrent(ct)
//}()
//
//return downloadLink // Gets download link in the next pass
}
c.logger.Debug().Err(err).Msgf("Failed to get download link for :%s", file.Link)
@@ -537,10 +549,14 @@ func (c *Cache) AddTorrent(t *types.Torrent) error {
return fmt.Errorf("failed to update torrent: %w", err)
}
}
addedOn, err := time.Parse(time.RFC3339, t.Added)
if err != nil {
addedOn = time.Now()
}
ct := &CachedTorrent{
Torrent: t,
LastRead: time.Now(),
IsComplete: len(t.Files) > 0,
AddedOn: addedOn,
}
c.setTorrent(ct)
c.refreshListings()

View File

@@ -18,10 +18,9 @@ func NewEngine() *Engine {
caches := make(map[string]*Cache)
for _, dc := range cfg.Debrids {
dc = cfg.GetDebridWebDav(dc)
client := createDebridClient(dc)
logger := client.GetLogger()
if dc.UseWebdav {
if dc.UseWebDav {
caches[dc.Name] = NewCache(dc, client)
logger.Info().Msg("Debrid Service started with WebDAV")
} else {

View File

@@ -38,25 +38,25 @@ func (c *Cache) refreshListings() {
} else {
return
}
// Copy the current torrents to avoid concurrent issues
torrents := make([]string, 0, c.torrentsNames.Size())
// COpy the torrents to a string|time map
torrentsTime := make(map[string]time.Time, c.torrents.Size())
torrents := make([]string, 0, c.torrents.Size())
c.torrentsNames.Range(func(key string, value *CachedTorrent) bool {
torrentsTime[key] = value.AddedOn
torrents = append(torrents, key)
return true
})
sort.Slice(torrents, func(i, j int) bool {
return torrents[i] < torrents[j]
})
// Sort the torrents by name
sort.Strings(torrents)
files := make([]os.FileInfo, 0, len(torrents))
now := time.Now()
for _, t := range torrents {
files = append(files, &fileInfo{
name: t,
size: 0,
mode: 0755 | os.ModeDir,
modTime: now,
modTime: torrentsTime[t],
isDir: true,
})
}
@@ -219,10 +219,13 @@ func (c *Cache) refreshTorrent(t *CachedTorrent) *CachedTorrent {
if len(t.Files) == 0 {
return nil
}
addedOn, err := time.Parse(time.RFC3339, _torrent.Added)
if err != nil {
addedOn = time.Now()
}
ct := &CachedTorrent{
Torrent: _torrent,
LastRead: time.Now(),
AddedOn: addedOn,
IsComplete: len(t.Files) > 0,
}
c.setTorrent(ct)

View File

@@ -17,6 +17,7 @@ import (
"strconv"
"strings"
"sync"
"time"
)
type RealDebrid struct {
@@ -178,6 +179,7 @@ func (r *RealDebrid) UpdateTorrent(t *types.Torrent) error {
t.Links = data.Links
t.MountPath = r.MountPath
t.Debrid = r.Name
t.Added = data.Added
t.Files = getTorrentFiles(t, data, false) // Get selected files
return nil
}
@@ -422,6 +424,7 @@ func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent,
InfoHash: t.Hash,
Debrid: r.Name,
MountPath: r.MountPath,
Added: t.Added.Format(time.RFC3339),
})
}
return totalItems, torrents, nil

View File

@@ -18,7 +18,7 @@ func (r *Repair) clean(job *Job) error {
mu := sync.Mutex{}
// Limit concurrent goroutines
g.SetLimit(runtime.NumCPU() * 4)
g.SetLimit(10)
for _, a := range job.Arrs {
a := a // Capture range variable

View File

@@ -218,6 +218,8 @@ func (r *Repair) repair(job *Job) error {
// Create a new error group with context
g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(4)
// Use a mutex to protect concurrent access to brokenItems
var mu sync.Mutex
brokenItems := map[string][]arr.ContentFile{}
@@ -397,7 +399,7 @@ func (r *Repair) repairArr(j *Job, _arr string, tmdbId string) ([]arr.ContentFil
g, ctx := errgroup.WithContext(context.Background())
// Limit concurrent goroutines
g.SetLimit(runtime.NumCPU() * 4)
g.SetLimit(10)
// Mutex for brokenItems
var mu sync.Mutex

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/goccy/go-json"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
@@ -13,6 +14,7 @@ import (
"net/http"
"os"
"os/signal"
"runtime"
"syscall"
)
@@ -41,6 +43,7 @@ func (s *Server) Start(ctx context.Context) error {
// Register logs
s.router.Get("/logs", s.getLogs)
s.router.Get("/stats", s.getStats)
port := fmt.Sprintf(":%s", cfg.QBitTorrent.Port)
s.logger.Info().Msgf("Starting server on %s", port)
srv := &http.Server{
@@ -102,3 +105,29 @@ func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
return
}
}
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) {
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
stats := map[string]interface{}{
// Memory stats
"heap_alloc_mb": fmt.Sprintf("%.2fMB", float64(memStats.HeapAlloc)/1024/1024),
"total_alloc_mb": fmt.Sprintf("%.2fMB", float64(memStats.TotalAlloc)/1024/1024),
"sys_mb": fmt.Sprintf("%.2fMB", float64(memStats.Sys)/1024/1024),
// GC stats
"gc_cycles": memStats.NumGC,
// Goroutine stats
"goroutines": runtime.NumGoroutine(),
// System info
"num_cpu": runtime.NumCPU(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(stats); err != nil {
s.logger.Error().Err(err).Msg("Failed to encode stats")
}
}

View File

@@ -11,7 +11,7 @@
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="qbitDebug">Log Level</label>
<label for="log-level">Log Level</label>
<select class="form-select" name="log_level" id="log-level" disabled>
<option value="info">Info</option>
<option value="debug">Debug</option>
@@ -86,13 +86,13 @@
</div>
<!-- Debrid Configuration -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">Debrid Configuration</h5>
<h5 class="border-bottom pb-2">Debrids</h5>
<div id="debridConfigs"></div>
</div>
<!-- QBitTorrent Configuration -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">QBitTorrent Configuration</h5>
<h5 class="border-bottom pb-2">QBitTorrent</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Username</label>
@@ -114,12 +114,16 @@
<label class="form-label">Refresh Interval (seconds)</label>
<input type="number" class="form-control" name="qbit.refresh_interval">
</div>
<div class="col-md-6 mb-3">
<input type="checkbox" disabled class="form-check-input" name="qbit.skip_pre_cache">
<label class="form-check-label">Skip Pre-Cache On Download(This caches a tiny part of your file to speed up import)</label>
</div>
</div>
</div>
<!-- Arr Configurations -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">Arr Configurations</h5>
<h5 class="border-bottom pb-2">Arrs</h5>
<div id="arrConfigs"></div>
</div>
@@ -141,6 +145,10 @@
<input type="checkbox" disabled class="form-check-input" name="repair.enabled" id="repairEnabled">
<label class="form-check-label" for="repairEnabled">Enable Repair</label>
</div>
<div class="form-check me-3 d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="repair.use_webdav" id="repairUseWebdav">
<label class="form-check-label" for="repairUseWebdav">Use Webdav</label>
</div>
<div class="form-check me-3 d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="repair.run_on_start" id="repairOnStart">
<label class="form-check-label" for="repairOnStart">Run on Start</label>
@@ -159,7 +167,7 @@
// Templates for dynamic elements
const debridTemplate = (index) => `
<div class="config-item position-relative mb-3 p-3 border rounded">
<div class="row">
<div class="row mb-2">
<div class="col-md-6 mb-3">
<label class="form-label">Name</label>
<input type="text" disabled class="form-control" name="debrid[${index}].name" required>
@@ -191,6 +199,47 @@
</div>
</div>
</div>
<div class="row mt-3 webdav-${index} d-none">
<h6 class="pb-2">Webdav</h6>
<div class="col-md-3 mb-3">
<label class="form-label">Torrents Refresh Interval</label>
<input type="text" disabled class="form-control" name="debrid[${index}].torrents_refresh_interval" placeholder="15s" required>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Download Links Refresh Interval</label>
<input type="text" disabled class="form-control" name="debrid[${index}].download_links_refresh_interval" placeholder="24h" required>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Expire Links After</label>
<input type="text" disabled class="form-control" name="debrid[${index}].auto_expire_links_after" placeholder="24h" required>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Folder Naming Structure</label>
<select class="form-select" name="debrid[${index}].folder_naming" disabled>
<option value="filename">File name</option>
<option value="filename_no_ext">File name with No Ext</option>
<option value="original">Original name</option>
<option value="original_no_ext">Original name with No Ext</option>
<option value="id">Use ID</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Number of Workers</label>
<input type="text" disabled class="form-control" name="debrid[${index}].workers" required placeholder="e.g., 20">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Rclone RC URL</label>
<input type="text" disabled class="form-control" name="debrid[${index}].rc_url" placeholder="e.g., http://localhost:9990">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Rclone RC User</label>
<input type="text" disabled class="form-control" name="debrid[${index}].rc_user">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Rclone RC Password</label>
<input type="password" disabled class="form-control" name="debrid[${index}].rc_pass">
</div>
</div>
</div>
`;
@@ -360,16 +409,37 @@
container.insertAdjacentHTML('beforeend', debridTemplate(debridCount));
if (data) {
Object.entries(data).forEach(([key, value]) => {
const input = container.querySelector(`[name="debrid[${debridCount}].${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
if (data.use_webdav) {
let _webCfg = container.querySelector(`.webdav-${debridCount}`);
if (_webCfg) {
_webCfg.classList.remove('d-none');
}
});
}
function setFieldValues(obj, prefix) {
Object.entries(obj).forEach(([key, value]) => {
const fieldName = prefix ? `${prefix}.${key}` : key;
// If value is an object and not null, recursively process nested fields
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
setFieldValues(value, fieldName);
} else {
// Handle leaf values (actual form fields)
const input = container.querySelector(`[name="debrid[${debridCount}].${fieldName}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
}
});
}
// Start processing with the root object
setFieldValues(data, '');
}
debridCount++;

View File

@@ -117,6 +117,18 @@
background-color: rgba(128, 128, 128, 0.2);
}
</style>
<script>
(function() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-bs-theme', savedTheme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark');
} else {
document.documentElement.setAttribute('data-bs-theme', 'light');
}
})();
</script>
</head>
<body>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
@@ -149,7 +161,12 @@
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="/config">
<i class="bi bi-gear me-1"></i>Config
<i class="bi bi-gear me-1"></i>Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/webdav" target="_blank">
<i class="bi bi-cloud me-1"></i>WebDAV
</a>
</li>
<li class="nav-item">

View File

@@ -31,6 +31,8 @@ type File struct {
fileId string
torrentId string
modTime time.Time
size int64
offset int64
isDir bool
@@ -176,7 +178,7 @@ func (f *File) Stat() (os.FileInfo, error) {
name: f.name,
size: 0,
mode: 0755 | os.ModeDir,
modTime: time.Now(),
modTime: f.modTime,
isDir: true,
}, nil
}
@@ -185,7 +187,7 @@ func (f *File) Stat() (os.FileInfo, error) {
name: f.name,
size: f.size,
mode: 0644,
modTime: time.Now(),
modTime: f.modTime,
isDir: false,
}, nil
}

View File

@@ -116,6 +116,8 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
metadataOnly = true
}
now := time.Now()
// Fast path optimization with a map lookup instead of string comparisons
switch name {
case rootDir:
@@ -125,6 +127,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
children: h.getParentFiles(),
name: "/",
metadataOnly: metadataOnly,
modTime: now,
}, nil
case path.Join(rootDir, "version.txt"):
return &File{
@@ -134,6 +137,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
name: "version.txt",
size: int64(len("v1.0.0")),
metadataOnly: metadataOnly,
modTime: now,
}, nil
}
@@ -152,6 +156,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
name: folderName,
size: 0,
metadataOnly: metadataOnly,
modTime: now,
}, nil
}
@@ -177,6 +182,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
name: cachedTorrent.Name,
size: cachedTorrent.Size,
metadataOnly: metadataOnly,
modTime: cachedTorrent.AddedOn,
}, nil
}
@@ -192,6 +198,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
size: file.Size,
link: file.Link,
metadataOnly: metadataOnly,
modTime: cachedTorrent.AddedOn,
}
return fi, nil
}
@@ -457,7 +464,12 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
}
// Parse and execute template
tmpl, err := template.New("directory").Parse(directoryTemplate)
funcMap := template.FuncMap{
"add": func(a, b int) int {
return a + b
},
}
tmpl, err := template.New("directory").Funcs(funcMap).Parse(directoryTemplate)
if err != nil {
h.logger.Error().Err(err).Msg("Failed to parse directory template")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)

View File

@@ -73,6 +73,8 @@ const directoryTemplate = `
display: block;
border: 1px solid #eee;
border-radius: 4px;
position: relative;
padding-left: 50px; /* Make room for the number */
}
a:hover {
background-color: #f7f9fa;
@@ -85,23 +87,40 @@ const directoryTemplate = `
.parent-dir {
background-color: #f8f9fa;
}
.file-number {
position: absolute;
left: 10px;
top: 10px;
width: 30px;
color: #777;
font-weight: bold;
text-align: right;
}
.file-name {
display: inline-block;
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</head>
<body>
<h1>Index of {{.Path}}</h1>
<ul>
{{if .ShowParent}}
<li><a href="{{.ParentPath}}" class="parent-dir">Parent Directory</a></li>
<li><a href="{{.ParentPath}}" class="parent-dir"><span class="file-number"></span>Parent Directory</a></li>
{{end}}
{{range .Children}}
{{range $index, $file := .Children}}
<li>
<a href="{{$.Path}}/{{.Name}}">
{{.Name}}{{if .IsDir}}/{{end}}
<a href="{{$.Path}}/{{$file.Name}}">
<span class="file-number">{{add $index 1}}.</span>
<span class="file-name">{{$file.Name}}{{if $file.IsDir}}/{{end}}</span>
<span class="file-info">
{{if not .IsDir}}
{{.Size}} bytes
{{if not $file.IsDir}}
{{$file.Size}} bytes
{{end}}
{{.ModTime.Format "2006-01-02 15:04:05"}}
{{$file.ModTime.Format "2006-01-02 15:04:05"}}
</span>
</a>
</li>