Optimizations

This commit is contained in:
Ben Sarmiento
2023-10-22 11:26:20 +02:00
parent 7009d78003
commit c789ebc96d
8 changed files with 61 additions and 237 deletions

View File

@@ -1,8 +1,12 @@
# Accept GOOS and GOARCH as build arguments
ARG GOOS=linux
ARG GOARCH=amd64
# Build stage # Build stage
FROM golang:1-alpine AS builder FROM golang:1-alpine AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -o zurg cmd/zurg/main.go RUN CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} go build -ldflags="-s -w" -o zurg cmd/zurg/main.go
# Obfuscation stage # Obfuscation stage
FROM alpine:3 AS obfuscator FROM alpine:3 AS obfuscator

View File

@@ -3,7 +3,7 @@
## Building ## Building
```bash ```bash
docker build -t debridmediamanager/zurg:latest . docker build -t ghcr.io/debridmediamanager/zurg:latest .
``` ```
This builds zurg This builds zurg
@@ -74,7 +74,7 @@ directories:
### Standalone webdav server ### Standalone webdav server
```bash ```bash
docker run -v ./config.yml:/app/config.yml -v zurgdata:/app/data -p 9999:9999 debridmediamanager/zurg:latest docker run -v ./config.yml:/app/config.yml -v zurgdata:/app/data -p 9999:9999 ghcr.io/debridmediamanager/zurg:latest
``` ```
- Runs zurg on port 9999 on your localhost - Runs zurg on port 9999 on your localhost
@@ -90,10 +90,10 @@ version: '3.8'
services: services:
zurg: zurg:
image: debridmediamanager/zurg:latest image: ghcr.io/debridmediamanager/zurg:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- 9999:9999 - 9999
volumes: volumes:
- ./config.yml:/app/config.yml - ./config.yml:/app/config.yml
- zurgdata:/app/data - zurgdata:/app/data
@@ -106,14 +106,15 @@ services:
PUID: 1000 PUID: 1000
PGID: 1000 PGID: 1000
volumes: volumes:
- ./media:/data - ./media:/data:rshared
- ./rclone.conf:/config/rclone/rclone.conf - ./rclone.conf:/config/rclone/rclone.conf
privileged: true
cap_add: cap_add:
- SYS_ADMIN - SYS_ADMIN
security_opt:
- apparmor:unconfined
devices: devices:
- /dev/fuse - /dev/fuse:/dev/fuse:rwm
command: "mount zurg: /data --allow-other --allow-non-empty --uid 1000 --gid 1000 --dir-cache-time 1s --poll-interval 1s --read-only --log-level INFO" command: "mount zurg: /data --allow-non-empty --allow-other --uid 1000 --gid 1000 --dir-cache-time 1s --read-only"
volumes: volumes:
zurgdata: zurgdata:

View File

@@ -18,10 +18,6 @@ func main() {
t := torrent.NewTorrentManager(c) t := torrent.NewTorrentManager(c)
// app := aero.New()
// dav.Setup(app, c, t)
// app.Run()
mux := http.NewServeMux() mux := http.NewServeMux()
dav.Router(mux, c, t) dav.Router(mux, c, t)
addr := fmt.Sprintf(":%s", c.GetPort()) addr := fmt.Sprintf(":%s", c.GetPort())

View File

@@ -18,23 +18,33 @@ services:
PUID: 1000 PUID: 1000
PGID: 1000 PGID: 1000
volumes: volumes:
- type: bind - ./media:/data:rshared
source: ./media
target: /data
bind:
propagation: shared
- ./rclone.conf:/config/rclone/rclone.conf - ./rclone.conf:/config/rclone/rclone.conf
- /dev/fuse:/dev/fuse
cap_add: cap_add:
- SYS_ADMIN - SYS_ADMIN
- MKNOD
privileged: true
security_opt: security_opt:
- apparmor:unconfined - apparmor:unconfined
- no-new-privileges:true
devices: devices:
- /dev/fuse:/dev/fuse:rwm - /dev/fuse:/dev/fuse:rwm
command: "mount zurg: /data --allow-other --allow-non-empty --uid 1000 --gid 1000 --dir-cache-time 1s --read-only --log-level DEBUG" command: "mount zurg: /data --allow-other --uid=1000 --gid=1000 --dir-cache-time 10s --read-only"
rclonerd:
image: itstoggle/rclone_rd:latest
restart: unless-stopped
environment:
TZ: Europe/Berlin
PUID: 1000
PGID: 1000
volumes:
- ./media2:/data:rshared
- ./rclone.conf:/config/rclone/rclone.conf
command: "mount rd: /data --allow-other --uid=1000 --gid=1000 --dir-cache-time 10s --read-only"
devices:
- /dev/fuse:/dev/fuse:rwm
cap_add:
- SYS_ADMIN
security_opt:
- apparmor:unconfined
volumes: volumes:
zurgdata: zurgdata:

19
go.mod
View File

@@ -2,21 +2,4 @@ module github.com/debridmediamanager.com/zurg
go 1.21.3 go 1.21.3
require ( require gopkg.in/yaml.v3 v3.0.1
github.com/aerogo/aero v1.3.59 // indirect
github.com/aerogo/csp v0.1.10 // indirect
github.com/aerogo/http v1.1.3 // indirect
github.com/aerogo/session v0.1.9 // indirect
github.com/aerogo/session-store-memory v0.1.9 // indirect
github.com/akyoto/color v1.8.12 // indirect
github.com/akyoto/colorable v0.1.7 // indirect
github.com/akyoto/hash v0.5.0 // indirect
github.com/akyoto/stringutils v0.3.1 // indirect
github.com/akyoto/tty v0.1.4 // indirect
github.com/akyoto/uuid v1.1.3 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/zeebo/xxh3 v1.0.1 // indirect
golang.org/x/sys v0.0.0-20220908164124-27713097b956 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

37
go.sum
View File

@@ -1,39 +1,4 @@
github.com/aerogo/aero v1.3.59 h1:5yu+kk/uIXAXADKSLCFKhxAzThCehvpbF6gst+G32Fw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/aerogo/aero v1.3.59/go.mod h1:ehwj+mb117xQRTvp11jlnrRNPgbcYL6s6aBk9wbIZ0o=
github.com/aerogo/csp v0.1.10 h1:2PJf9gkdRvCFYOA0baTUyp34vwPp5ZJJX8GZRCYc/nM=
github.com/aerogo/csp v0.1.10/go.mod h1:UrxbTXv+X9kJatyuLeu2yGFpOiWVPjbqA/DzqxSVhl8=
github.com/aerogo/http v1.1.3 h1:cvwOYL+zNEfNHvJcX6A6OgUwQ4KROlu8ypuQEQc1HtU=
github.com/aerogo/http v1.1.3/go.mod h1:h+m3WxevpaifyVpRAMV58qt8ScXSZhU1a5DdvBkRwwE=
github.com/aerogo/session v0.1.8/go.mod h1:Q9QqpT8nM6HTaklE14T+bzNSKrwW1M2wZ/NZV1HUTB0=
github.com/aerogo/session v0.1.9 h1:pgsFEtCteOQaZ/103q2/O+qrqZileiCZe+vboWKZMlU=
github.com/aerogo/session v0.1.9/go.mod h1:dgpdXvs9tZXcag5ay6tEoKuySPga226iSh748uIES/E=
github.com/aerogo/session-store-memory v0.1.9 h1:1OswTCtyqzffX5aGr6jI3H8gt/hkU3LKNiKpia7ntcs=
github.com/aerogo/session-store-memory v0.1.9/go.mod h1:z4ZxP+xLVdH69F/Cvgy93v8fWzeDmiJo+Mm+Th3un4c=
github.com/akyoto/assert v0.2.3/go.mod h1:g5e6ag+ksCEQENq/LnmU9z04wCAIFDr8KacBusVL0H8=
github.com/akyoto/assert v0.2.4/go.mod h1:SoqVayyOmM/YSBnwOxJHCt4BCocoIrgeceWtJV701C0=
github.com/akyoto/color v1.8.12 h1:7F/iF/POG6z+oppoGYWO6UOx8E2ZAypANO9rsfsBuHI=
github.com/akyoto/color v1.8.12/go.mod h1:rG1eiYoSE+arV6oLuGuuekPtgujUlIErWeqqM13pVoA=
github.com/akyoto/colorable v0.1.7 h1:ge91E25hiOiT/Zu47ij/rTO3cks7wMlTrcQspua1hFM=
github.com/akyoto/colorable v0.1.7/go.mod h1:zlc1+Es4DyoXzDdbKiSfvdM6R/DsWS8bFi4RHigkuu4=
github.com/akyoto/hash v0.5.0 h1:NAOZ8EySEOzlLpiURs4PLx26Hxsv8vkxpySElJ5U9FY=
github.com/akyoto/hash v0.5.0/go.mod h1:/ftTams8jMXYuc4NWDzdA6sEztxFslBS+VdqYZQZCNI=
github.com/akyoto/stringutils v0.3.1 h1:C+VGuXfud9SSo54QRfdQO+rgQiHmLS5f4nJ4yUOM+8I=
github.com/akyoto/stringutils v0.3.1/go.mod h1:I1F9f8FF7gnAQyYp4PVAl+GJ2WBnaN6kNoYjidCV5Qk=
github.com/akyoto/tty v0.1.3/go.mod h1:+VlbvviCaiwhS4oGpO+iBtC0lYG1ilIs3ZhUnT1Ppgo=
github.com/akyoto/tty v0.1.4 h1:TELbnAmrPTIrUJyuBLhrOSCcBnklC2fh0YeCTjksiDE=
github.com/akyoto/tty v0.1.4/go.mod h1:fkWwtA4F5Cq9kiQSlWdkPy5kAyySGYqalWyaRKn3zHo=
github.com/akyoto/uuid v1.1.3 h1:FEz14tNTfaUeY0Jrkz2F17rjKiks6hOALGcPmAmtn1s=
github.com/akyoto/uuid v1.1.3/go.mod h1:8dgzDQyrpuApBGIQHOX7JkvCZHusXZ0tGlQcxxv4bYg=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/zeebo/xxh3 v1.0.1 h1:FMSRIbkrLikb/0hZxmltpg84VkqDAT5M8ufXynuhXsI=
github.com/zeebo/xxh3 v1.0.1/go.mod h1:8VHV24/3AZLn3b6Mlp/KuC33LWH687Wq6EnziEB+rsA=
golang.org/x/sys v0.0.0-20191025090151-53bf42e6b339/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956 h1:XeJjHH1KiLpKGb6lvMiksZ9l0fVUh+AmGcm0nOMEBOY=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,129 +0,0 @@
package dav
import (
"encoding/xml"
"fmt"
"log"
"net/http"
"strings"
"github.com/aerogo/aero"
"github.com/debridmediamanager.com/zurg/internal/config"
"github.com/debridmediamanager.com/zurg/internal/torrent"
"github.com/debridmediamanager.com/zurg/pkg/dav"
"github.com/debridmediamanager.com/zurg/pkg/davextra"
"github.com/debridmediamanager.com/zurg/pkg/realdebrid"
)
func Setup(app *aero.Application, c config.ConfigInterface, t *torrent.TorrentManager) {
// hack to make PROPFIND work
app.Rewrite(func(ctx aero.RewriteContext) {
newCtx := ctx.(aero.Context)
if newCtx.Request().Internal().Method == "PROPFIND" {
newCtx.Request().Internal().Method = http.MethodGet
}
})
app.Router().Add(http.MethodOptions, "/", func(ctx aero.Context) error {
ctx.SetStatus(http.StatusOK)
return nil
})
// hardcode the root directory
app.Get("/", func(ctx aero.Context) error {
var responses []dav.Response
responses = append(responses, dav.Directory("/"))
for _, directory := range c.GetDirectories() {
responses = append(responses, dav.Directory(fmt.Sprintf("/%s", directory)))
}
resp := dav.MultiStatus{
XMLNS: "DAV:",
Response: responses,
}
return xmlResponse(ctx, resp)
})
for _, directoryPtr := range c.GetDirectories() {
directory := directoryPtr
app.Get(fmt.Sprintf("/%s/", directory), func(ctx aero.Context) error {
torrentsInDirectory := t.GetByDirectory(directory)
resp, err := createMultiTorrentResponse(fmt.Sprintf("/%s", directory), torrentsInDirectory)
if err != nil {
log.Printf("Cannot read directory (%s): %v\n", directory, err)
return ctx.Error(http.StatusInternalServerError, "Cannot read directory")
}
return xmlResponse(ctx, *resp)
})
app.Get(fmt.Sprintf("/%s/:torrentName/", directory), func(ctx aero.Context) error {
torrentName := ctx.Get("torrentName")
sameNameTorrents := findAllTorrentsWithName(t, directory, torrentName)
resp, err := createSingleTorrentResponse(fmt.Sprintf("/%s", directory), sameNameTorrents, t)
if err != nil {
log.Printf("Cannot read directory (%s): %v\n", directory, err)
return ctx.Error(http.StatusInternalServerError, "Cannot read directory")
}
return xmlResponse(ctx, *resp)
})
app.Get(fmt.Sprintf("/%s/:torrentName/:filename", directory), func(ctx aero.Context) error {
torrentName := strings.TrimSpace(ctx.Get("torrentName"))
filename := strings.TrimSpace(ctx.Get("filename"))
torrents := findAllTorrentsWithName(t, directory, torrentName)
if torrents == nil {
log.Println("Cannot find torrent", torrentName)
return ctx.Error(http.StatusNotFound, "Cannot find file")
}
filenameV2, linkFragment := davextra.ExtractLinkFragment(filename)
torrent, file := getFile(torrents, filenameV2, linkFragment)
if file == nil {
log.Println("Cannot find file", filename)
return ctx.Error(http.StatusNotFound, "Cannot find file")
}
if file.Link == "" {
log.Println("Link not found", filename)
return ctx.Error(http.StatusNotFound, "Cannot find file")
}
link := file.Link
unrestrictFn := func() (*realdebrid.UnrestrictResponse, error) {
return realdebrid.UnrestrictLink(c.GetToken(), link)
}
resp := realdebrid.RetryUntilOk(unrestrictFn)
if resp == nil {
// TODO: Readd the file
// when unrestricting fails, it means the file is not available anymore
// if it's the only file, tough luck
log.Println("Cannot unrestrict link", link, filenameV2)
t.MarkFileAsDeleted(torrent, file)
return ctx.Error(http.StatusNotFound, "Cannot find file")
}
if resp.Filename != filenameV2 {
// TODO: Redo the logic to handle mismatch
// [SRS] Pokemon S22E01-35 1080p WEBRip AAC 2.0 x264 CC.rar
// Pokemon.S22E24.The.Secret.Princess.DUBBED.1080p.WEBRip.AAC.2.0.x264-SRS.mkv
// Action: schedule a "cleanup" job for the parent torrent
// do it in 2 batches with different selections
log.Println("Filename mismatch", resp.Filename, filenameV2)
}
return ctx.Redirect(http.StatusFound, resp.Download)
})
}
}
func xmlResponse(ctx aero.Context, resp dav.MultiStatus) error {
output, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
log.Printf("Cannot marshal xml: %v\n", err)
return ctx.Error(http.StatusInternalServerError, "Cannot read directory")
}
ctx.SetStatus(http.StatusMultiStatus)
ctx.Response().SetHeader("Content-Type", "text/xml; charset=\"utf-8\"")
return ctx.String(fmt.Sprintf("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n%s\n", output))
}

View File

@@ -22,7 +22,7 @@ func HandlePropfindRequest(w http.ResponseWriter, r *http.Request, t *torrent.To
pathSegments := strings.Split(requestPath, "/") pathSegments := strings.Split(requestPath, "/")
// Remove empty segments caused by leading or trailing slashes // Remove empty segments caused by leading or trailing slashes
filteredSegments := make([]string, 0, len(pathSegments)) filteredSegments := pathSegments[:0]
for _, segment := range pathSegments { for _, segment := range pathSegments {
if segment != "" { if segment != "" {
filteredSegments = append(filteredSegments, segment) filteredSegments = append(filteredSegments, segment)
@@ -30,36 +30,35 @@ func HandlePropfindRequest(w http.ResponseWriter, r *http.Request, t *torrent.To
} }
switch len(filteredSegments) { switch len(filteredSegments) {
case 0: // Just the root "/" case 0:
output, err = handleRoot(w, r, c) output, err = handleRoot(w, r, c)
case 1: // It's just the basedir e.g. "/basedir" case 1:
output, err = handleListOfTorrents(requestPath, w, r, t, c) output, err = handleListOfTorrents(requestPath, w, r, t, c)
case 2: // It's a specific torrent under a basedir e.g. "/basedir/torrentname/" case 2:
output, err = handleSingleTorrent(requestPath, w, r, t) output, err = handleSingleTorrent(requestPath, w, r, t)
default: default:
// Handle any other paths, e.g., send a 404 Not Found response
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
return return
} }
if err != nil { if err != nil {
log.Printf("Cannot marshal xml: %v\n", err) log.Printf("Error processing request: %v\n", err)
http.Error(w, "Cannot read directory", http.StatusInternalServerError) http.Error(w, "Server error", http.StatusInternalServerError)
return return
} }
if output != nil { if output != nil {
w.Header().Set("Content-Type", "text/xml; charset=\"utf-8\"") w.Header().Set("Content-Type", "text/xml; charset=\"utf-8\"")
w.WriteHeader(http.StatusMultiStatus) w.WriteHeader(http.StatusMultiStatus)
fmt.Fprintf(w, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n%s\n", output) fmt.Fprintf(w, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n%s\n", output)
return
} }
} }
// handleRoot handles a PROPFIND request to the root directory
func handleRoot(w http.ResponseWriter, r *http.Request, c config.ConfigInterface) ([]byte, error) { func handleRoot(w http.ResponseWriter, r *http.Request, c config.ConfigInterface) ([]byte, error) {
var responses []dav.Response var responses []dav.Response
responses = append(responses, dav.Directory("/")) responses = append(responses, dav.Directory("/"))
for _, directory := range c.GetDirectories() { for _, directory := range c.GetDirectories() {
responses = append(responses, dav.Directory(fmt.Sprintf("/%s", directory))) responses = append(responses, dav.Directory("/"+directory))
} }
rootResponse := dav.MultiStatus{ rootResponse := dav.MultiStatus{
XMLNS: "DAV:", XMLNS: "DAV:",
@@ -68,45 +67,40 @@ func handleRoot(w http.ResponseWriter, r *http.Request, c config.ConfigInterface
return xml.MarshalIndent(rootResponse, "", " ") return xml.MarshalIndent(rootResponse, "", " ")
} }
// handleListOfTorrents handles a PROPFIND request to the base directory
func handleListOfTorrents(requestPath string, w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager, c config.ConfigInterface) ([]byte, error) { func handleListOfTorrents(requestPath string, w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager, c config.ConfigInterface) ([]byte, error) {
basePath := path.Base(requestPath) basePath := path.Base(requestPath)
found := false directories := c.GetDirectories()
for _, directory := range c.GetDirectories() { for _, directory := range directories {
if basePath == directory { if basePath == directory {
found = true torrents := t.GetByDirectory(basePath)
resp, err := createMultiTorrentResponse("/"+basePath, torrents)
if err != nil {
log.Printf("Cannot read directory (%s): %v\n", basePath, err)
http.Error(w, "Cannot read directory", http.StatusInternalServerError)
return nil, nil
}
return xml.MarshalIndent(resp, "", " ")
} }
} }
if !found {
log.Println("Cannot find directory when generating list", requestPath)
http.Error(w, "Cannot find directory", http.StatusNotFound)
return nil, nil
}
torrents := t.GetByDirectory(basePath) log.Println("Cannot find directory when generating list", requestPath)
resp, err := createMultiTorrentResponse(fmt.Sprintf("/%s", basePath), torrents) http.Error(w, "Cannot find directory", http.StatusNotFound)
if err != nil { return nil, nil
log.Printf("Cannot read directory (%s): %v\n", basePath, err)
http.Error(w, "Cannot read directory", http.StatusInternalServerError)
return nil, nil
}
return xml.MarshalIndent(resp, "", " ")
} }
// handleSingleTorrent handles a PROPFIND request to a single torrent directory
func handleSingleTorrent(requestPath string, w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager) ([]byte, error) { func handleSingleTorrent(requestPath string, w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager) ([]byte, error) {
directory := strings.TrimPrefix(path.Dir(requestPath), "/") directory := strings.TrimPrefix(path.Dir(requestPath), "/")
torrentName := path.Base(requestPath) torrentName := path.Base(requestPath)
sameNameTorrents := findAllTorrentsWithName(t, directory, torrentName) sameNameTorrents := findAllTorrentsWithName(t, directory, torrentName)
if len(sameNameTorrents) == 0 { if len(sameNameTorrents) == 0 {
log.Println("Cannot find directory when generating single torrent", requestPath) log.Println("Cannot find directory when generating single torrent", requestPath)
http.Error(w, "Cannot find directory", http.StatusNotFound) http.Error(w, "Cannot find directory", http.StatusNotFound)
return nil, nil return nil, nil
} }
var resp *dav.MultiStatus
resp, err := createSingleTorrentResponse(fmt.Sprintf("/%s", directory), sameNameTorrents, t) resp, err := createSingleTorrentResponse("/"+directory, sameNameTorrents, t)
if err != nil { if err != nil {
log.Printf("Cannot read directory (%s): %v\n", requestPath, err) log.Printf("Cannot read directory (%s): %v\n", requestPath, err)
http.Error(w, "Cannot read directory", http.StatusInternalServerError) http.Error(w, "Cannot read directory", http.StatusInternalServerError)