Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87bf8d0574 | ||
|
|
7f25599b60 | ||
|
|
d313ed0712 |
@@ -1,12 +1,12 @@
|
|||||||
# DecyphArr
|
# Decypharr
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**DecyphArr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go.
|
**Decypharr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go.
|
||||||
|
|
||||||
## What is DecyphArr?
|
## What is Decypharr?
|
||||||
|
|
||||||
DecyphArr combines the power of QBittorrent with popular Debrid services to enhance your media management. It provides a familiar interface for Sonarr, Radarr, and other \*Arr applications while leveraging the capabilities of Debrid providers.
|
Decypharr combines the power of QBittorrent with popular Debrid services to enhance your media management. It provides a familiar interface for Sonarr, Radarr, and other \*Arr applications while leveraging the capabilities of Debrid providers.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/sirrobot01/decypharr/internal/logger"
|
"github.com/sirrobot01/decypharr/internal/logger"
|
||||||
"github.com/sirrobot01/decypharr/internal/utils"
|
"github.com/sirrobot01/decypharr/internal/utils"
|
||||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||||
|
_ "time/tzdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebDavFolderNaming string
|
type WebDavFolderNaming string
|
||||||
@@ -109,9 +110,16 @@ type Cache struct {
|
|||||||
|
|
||||||
func New(dc config.Debrid, client types.Client) *Cache {
|
func New(dc config.Debrid, client types.Client) *Cache {
|
||||||
cfg := config.Get()
|
cfg := config.Get()
|
||||||
cet, _ := time.LoadLocation("CET")
|
cetSc, err := gocron.NewScheduler(gocron.WithLocation(time.UTC))
|
||||||
cetSc, _ := gocron.NewScheduler(gocron.WithLocation(cet))
|
if err != nil {
|
||||||
scheduler, _ := gocron.NewScheduler(gocron.WithLocation(time.Local))
|
// If we can't create a CET scheduler, fallback to local time
|
||||||
|
cetSc, _ = gocron.NewScheduler(gocron.WithLocation(time.Local))
|
||||||
|
}
|
||||||
|
scheduler, err := gocron.NewScheduler(gocron.WithLocation(time.Local))
|
||||||
|
if err != nil {
|
||||||
|
// If we can't create a local scheduler, fallback to CET
|
||||||
|
scheduler = cetSc
|
||||||
|
}
|
||||||
|
|
||||||
autoExpiresLinksAfter, err := time.ParseDuration(dc.AutoExpireLinksAfter)
|
autoExpiresLinksAfter, err := time.ParseDuration(dc.AutoExpireLinksAfter)
|
||||||
if autoExpiresLinksAfter == 0 || err != nil {
|
if autoExpiresLinksAfter == 0 || err != nil {
|
||||||
@@ -307,10 +315,10 @@ func (c *Cache) load(ctx context.Context) (map[string]CachedTorrent, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isComplete := true
|
isComplete := true
|
||||||
if len(ct.Files) != 0 {
|
if len(ct.GetFiles()) != 0 {
|
||||||
// Check if all files are valid, if not, delete the file.json and remove from cache.
|
// Check if all files are valid, if not, delete the file.json and remove from cache.
|
||||||
fs := make(map[string]types.File, len(ct.Files))
|
fs := make(map[string]types.File, len(ct.GetFiles()))
|
||||||
for _, f := range ct.Files {
|
for _, f := range ct.GetFiles() {
|
||||||
if f.Link == "" {
|
if f.Link == "" {
|
||||||
isComplete = false
|
isComplete = false
|
||||||
break
|
break
|
||||||
@@ -756,7 +764,7 @@ func (c *Cache) deleteTorrent(id string, removeFromDebrid bool) bool {
|
|||||||
|
|
||||||
newFiles := map[string]types.File{}
|
newFiles := map[string]types.File{}
|
||||||
newId := ""
|
newId := ""
|
||||||
for _, file := range t.Files {
|
for _, file := range t.GetFiles() {
|
||||||
if file.TorrentId != "" && file.TorrentId != id {
|
if file.TorrentId != "" && file.TorrentId != id {
|
||||||
if newId == "" && file.TorrentId != "" {
|
if newId == "" && file.TorrentId != "" {
|
||||||
newId = file.TorrentId
|
newId = file.TorrentId
|
||||||
@@ -815,6 +823,36 @@ func (c *Cache) OnRemove(torrentId string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveFile removes a file from the torrent cache
|
||||||
|
// TODO sends a re-insert that removes the file from debrid
|
||||||
|
func (c *Cache) RemoveFile(torrentId string, filename string) error {
|
||||||
|
c.logger.Debug().Str("torrent_id", torrentId).Msgf("Removing file %s", filename)
|
||||||
|
torrent, ok := c.torrents.getByID(torrentId)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("torrent %s not found", torrentId)
|
||||||
|
}
|
||||||
|
file, ok := torrent.GetFile(filename)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("file %s not found in torrent %s", filename, torrentId)
|
||||||
|
}
|
||||||
|
file.Deleted = true
|
||||||
|
torrent.Files[filename] = file
|
||||||
|
|
||||||
|
// If the torrent has no files left, delete it
|
||||||
|
if len(torrent.GetFiles()) == 0 {
|
||||||
|
c.logger.Debug().Msgf("Torrent %s has no files left, deleting it", torrentId)
|
||||||
|
if err := c.DeleteTorrent(torrentId); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete torrent %s: %w", torrentId, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setTorrent(torrent, func(torrent CachedTorrent) {
|
||||||
|
c.listingDebouncer.Call(true)
|
||||||
|
}) // Update the torrent in the cache
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Cache) GetLogger() zerolog.Logger {
|
func (c *Cache) GetLogger() zerolog.Logger {
|
||||||
return c.logger
|
return c.logger
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,10 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (strin
|
|||||||
if ct == nil {
|
if ct == nil {
|
||||||
return "", fmt.Errorf("torrent not found")
|
return "", fmt.Errorf("torrent not found")
|
||||||
}
|
}
|
||||||
file := ct.Files[filename]
|
file, ok := ct.GetFile(filename)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("file %s not found in torrent %s", filename, torrentName)
|
||||||
|
}
|
||||||
|
|
||||||
if file.Link == "" {
|
if file.Link == "" {
|
||||||
// file link is empty, refresh the torrent to get restricted links
|
// file link is empty, refresh the torrent to get restricted links
|
||||||
@@ -111,7 +114,10 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (strin
|
|||||||
if ct == nil {
|
if ct == nil {
|
||||||
return "", fmt.Errorf("failed to refresh torrent")
|
return "", fmt.Errorf("failed to refresh torrent")
|
||||||
} else {
|
} else {
|
||||||
file = ct.Files[filename]
|
file, ok = ct.GetFile(filename)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("file %s not found in refreshed torrent %s", filename, torrentName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +129,10 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (strin
|
|||||||
return "", fmt.Errorf("failed to reinsert torrent. %w", err)
|
return "", fmt.Errorf("failed to reinsert torrent. %w", err)
|
||||||
}
|
}
|
||||||
ct = newCt
|
ct = newCt
|
||||||
file = ct.Files[filename]
|
file, ok = ct.GetFile(filename)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logger.Trace().Msgf("Getting download link for %s(%s)", filename, file.Link)
|
c.logger.Trace().Msgf("Getting download link for %s(%s)", filename, file.Link)
|
||||||
@@ -135,7 +144,10 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (strin
|
|||||||
return "", fmt.Errorf("failed to reinsert torrent: %w", err)
|
return "", fmt.Errorf("failed to reinsert torrent: %w", err)
|
||||||
}
|
}
|
||||||
ct = newCt
|
ct = newCt
|
||||||
file = ct.Files[filename]
|
file, ok = ct.GetFile(filename)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||||
|
}
|
||||||
// Retry getting the download link
|
// Retry getting the download link
|
||||||
downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file)
|
downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,7 +177,7 @@ func (c *Cache) GenerateDownloadLinks(t CachedTorrent) {
|
|||||||
c.logger.Error().Err(err).Str("torrent", t.Name).Msg("Failed to generate download links")
|
c.logger.Error().Err(err).Str("torrent", t.Name).Msg("Failed to generate download links")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, file := range t.Files {
|
for _, file := range t.GetFiles() {
|
||||||
if file.DownloadLink != nil {
|
if file.DownloadLink != nil {
|
||||||
c.updateDownloadLink(file.DownloadLink)
|
c.updateDownloadLink(file.DownloadLink)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func mergeFiles(torrents ...CachedTorrent) map[string]types.File {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for _, torrent := range torrents {
|
for _, torrent := range torrents {
|
||||||
for _, file := range torrent.Files {
|
for _, file := range torrent.GetFiles() {
|
||||||
merged[file.Name] = file
|
merged[file.Name] = file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,9 @@ func (c *Cache) markAsSuccessfullyReinserted(torrentId string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) IsTorrentBroken(t *CachedTorrent, filenames []string) bool {
|
func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string {
|
||||||
// Check torrent files
|
|
||||||
|
|
||||||
isBroken := false
|
|
||||||
files := make(map[string]types.File)
|
files := make(map[string]types.File)
|
||||||
|
brokenFiles := make([]string, 0)
|
||||||
if len(filenames) > 0 {
|
if len(filenames) > 0 {
|
||||||
for name, f := range t.Files {
|
for name, f := range t.Files {
|
||||||
if utils.Contains(filenames, name) {
|
if utils.Contains(filenames, name) {
|
||||||
@@ -73,8 +71,6 @@ func (c *Cache) IsTorrentBroken(t *CachedTorrent, filenames []string) bool {
|
|||||||
} else {
|
} else {
|
||||||
files = t.Files
|
files = t.Files
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check empty links
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
// Check if file is missing
|
// Check if file is missing
|
||||||
if f.Link == "" {
|
if f.Link == "" {
|
||||||
@@ -83,14 +79,14 @@ func (c *Cache) IsTorrentBroken(t *CachedTorrent, filenames []string) bool {
|
|||||||
t = newT
|
t = newT
|
||||||
} else {
|
} else {
|
||||||
c.logger.Error().Str("torrentId", t.Torrent.Id).Msg("Failed to refresh torrent")
|
c.logger.Error().Str("torrentId", t.Torrent.Id).Msg("Failed to refresh torrent")
|
||||||
return true
|
return filenames // Return original filenames if refresh fails(torrent is somehow botched)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.Torrent == nil {
|
if t.Torrent == nil {
|
||||||
c.logger.Error().Str("torrentId", t.Torrent.Id).Msg("Failed to refresh torrent")
|
c.logger.Error().Str("torrentId", t.Torrent.Id).Msg("Failed to refresh torrent")
|
||||||
return true
|
return filenames // Return original filenames if refresh fails(torrent is somehow botched)
|
||||||
}
|
}
|
||||||
|
|
||||||
files = t.Files
|
files = t.Files
|
||||||
@@ -98,29 +94,28 @@ func (c *Cache) IsTorrentBroken(t *CachedTorrent, filenames []string) bool {
|
|||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
// Check if file link is still missing
|
// Check if file link is still missing
|
||||||
if f.Link == "" {
|
if f.Link == "" {
|
||||||
isBroken = true
|
brokenFiles = append(brokenFiles, f.Name)
|
||||||
break
|
|
||||||
} else {
|
} else {
|
||||||
// Check if file.Link not in the downloadLink Cache
|
// Check if file.Link not in the downloadLink Cache
|
||||||
if err := c.client.CheckLink(f.Link); err != nil {
|
if err := c.client.CheckLink(f.Link); err != nil {
|
||||||
if errors.Is(err, request.HosterUnavailableError) {
|
if errors.Is(err, request.HosterUnavailableError) {
|
||||||
isBroken = true
|
brokenFiles = append(brokenFiles, f.Name)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to reinsert the torrent if it's broken
|
// Try to reinsert the torrent if it's broken
|
||||||
if isBroken && t.Torrent != nil {
|
if len(brokenFiles) > 0 && t.Torrent != nil {
|
||||||
// Check if the torrent is already in progress
|
// Check if the torrent is already in progress
|
||||||
if _, err := c.reInsertTorrent(t); err != nil {
|
if _, err := c.reInsertTorrent(t); err != nil {
|
||||||
c.logger.Error().Err(err).Str("torrentId", t.Torrent.Id).Msg("Failed to reinsert torrent")
|
c.logger.Error().Err(err).Str("torrentId", t.Torrent.Id).Msg("Failed to reinsert torrent")
|
||||||
return true
|
return brokenFiles // Return broken files if reinsert fails
|
||||||
}
|
}
|
||||||
return false
|
return nil // Return nil if the torrent was successfully reinserted
|
||||||
}
|
}
|
||||||
|
|
||||||
return isBroken
|
return brokenFiles
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) repairWorker(ctx context.Context) {
|
func (c *Cache) repairWorker(ctx context.Context) {
|
||||||
@@ -223,7 +218,7 @@ func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
addedOn = time.Now()
|
addedOn = time.Now()
|
||||||
}
|
}
|
||||||
for _, f := range newTorrent.Files {
|
for _, f := range newTorrent.GetFiles() {
|
||||||
if f.Link == "" {
|
if f.Link == "" {
|
||||||
c.markAsFailedToReinsert(oldID)
|
c.markAsFailedToReinsert(oldID)
|
||||||
return ct, fmt.Errorf("failed to reinsert torrent: empty link")
|
return ct, fmt.Errorf("failed to reinsert torrent: empty link")
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
|
|||||||
Id: strconv.Itoa(f.Id),
|
Id: strconv.Itoa(f.Id),
|
||||||
Name: fileName,
|
Name: fileName,
|
||||||
Size: f.Size,
|
Size: f.Size,
|
||||||
Path: fileName,
|
Path: f.Name,
|
||||||
}
|
}
|
||||||
t.Files[fileName] = file
|
t.Files[fileName] = file
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Torrent struct {
|
|||||||
Seeders int `json:"seeders"`
|
Seeders int `json:"seeders"`
|
||||||
Links []string `json:"links"`
|
Links []string `json:"links"`
|
||||||
MountPath string `json:"mount_path"`
|
MountPath string `json:"mount_path"`
|
||||||
|
DeletedFiles []string `json:"deleted_files"`
|
||||||
|
|
||||||
Debrid string `json:"debrid"`
|
Debrid string `json:"debrid"`
|
||||||
|
|
||||||
@@ -75,6 +76,24 @@ func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
|
|||||||
return "", fmt.Errorf("no path found")
|
return "", fmt.Errorf("no path found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) GetFile(filename string) (File, bool) {
|
||||||
|
f, ok := t.Files[filename]
|
||||||
|
if !ok {
|
||||||
|
return File{}, false
|
||||||
|
}
|
||||||
|
return f, !f.Deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) GetFiles() []File {
|
||||||
|
files := make([]File, 0, len(t.Files))
|
||||||
|
for _, f := range t.Files {
|
||||||
|
if !f.Deleted {
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
TorrentId string `json:"torrent_id"`
|
TorrentId string `json:"torrent_id"`
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
@@ -85,6 +104,7 @@ type File struct {
|
|||||||
DownloadLink *DownloadLink `json:"-"`
|
DownloadLink *DownloadLink `json:"-"`
|
||||||
AccountId string `json:"account_id"`
|
AccountId string `json:"account_id"`
|
||||||
Generated time.Time `json:"generated"`
|
Generated time.Time `json:"generated"`
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Torrent) Cleanup(remove bool) {
|
func (t *Torrent) Cleanup(remove bool) {
|
||||||
@@ -96,15 +116,6 @@ func (t *Torrent) Cleanup(remove bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Torrent) GetFile(id string) *File {
|
|
||||||
for _, f := range t.Files {
|
|
||||||
if f.Id == id {
|
|
||||||
return &f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (q *QBit) downloadFiles(torrent *Torrent, parent string) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
totalSize := int64(0)
|
totalSize := int64(0)
|
||||||
for _, file := range debridTorrent.Files {
|
for _, file := range debridTorrent.GetFiles() {
|
||||||
totalSize += file.Size
|
totalSize += file.Size
|
||||||
}
|
}
|
||||||
debridTorrent.Mu.Lock()
|
debridTorrent.Mu.Lock()
|
||||||
@@ -100,7 +100,7 @@ func (q *QBit) downloadFiles(torrent *Torrent, parent string) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
errChan := make(chan error, len(debridTorrent.Files))
|
errChan := make(chan error, len(debridTorrent.Files))
|
||||||
for _, file := range debridTorrent.Files {
|
for _, file := range debridTorrent.GetFiles() {
|
||||||
if file.DownloadLink == nil {
|
if file.DownloadLink == nil {
|
||||||
q.logger.Info().Msgf("No download link found for %s", file.Name)
|
q.logger.Info().Msgf("No download link found for %s", file.Name)
|
||||||
continue
|
continue
|
||||||
@@ -164,7 +164,75 @@ func (q *QBit) ProcessSymlink(torrent *Torrent) (string, error) {
|
|||||||
torrentFolder = utils.RemoveExtension(torrentFolder)
|
torrentFolder = utils.RemoveExtension(torrentFolder)
|
||||||
torrentRclonePath = rCloneBase // /mnt/rclone/magnets/ // Remove the filename since it's in the root folder
|
torrentRclonePath = rCloneBase // /mnt/rclone/magnets/ // Remove the filename since it's in the root folder
|
||||||
}
|
}
|
||||||
return q.createSymlinks(debridTorrent, torrentRclonePath, torrentFolder) // verify cos we're using external webdav
|
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentFolder) // /mnt/symlinks/{category}/MyTVShow/
|
||||||
|
err = os.MkdirAll(torrentSymlinkPath, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create directory: %s: %v", torrentSymlinkPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
realPaths := make(map[string]string)
|
||||||
|
err = filepath.WalkDir(torrentRclonePath, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !d.IsDir() {
|
||||||
|
filename := d.Name()
|
||||||
|
rel, _ := filepath.Rel(torrentRclonePath, path)
|
||||||
|
realPaths[filename] = rel
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
q.logger.Warn().Msgf("Error while scanning rclone path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := make(map[string]debridTypes.File)
|
||||||
|
for _, file := range files {
|
||||||
|
if realRelPath, ok := realPaths[file.Name]; ok {
|
||||||
|
file.Path = realRelPath
|
||||||
|
}
|
||||||
|
pending[file.Path] = file
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.After(30 * time.Minute)
|
||||||
|
filePaths := make([]string, 0, len(pending))
|
||||||
|
|
||||||
|
for len(pending) > 0 {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
for path, file := range pending {
|
||||||
|
fullFilePath := filepath.Join(torrentRclonePath, file.Path)
|
||||||
|
if _, err := os.Stat(fullFilePath); !os.IsNotExist(err) {
|
||||||
|
fileSymlinkPath := filepath.Join(torrentSymlinkPath, file.Name)
|
||||||
|
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
||||||
|
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||||
|
} else {
|
||||||
|
filePaths = append(filePaths, fileSymlinkPath)
|
||||||
|
delete(pending, path)
|
||||||
|
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
q.logger.Warn().Msgf("Timeout waiting for files, %d files still pending", len(pending))
|
||||||
|
return torrentSymlinkPath, fmt.Errorf("timeout waiting for files: %d files still pending", len(pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.SkipPreCache {
|
||||||
|
return torrentSymlinkPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
|
||||||
|
if err := q.preCacheFile(debridTorrent.Name, filePaths); err != nil {
|
||||||
|
q.logger.Error().Msgf("Failed to pre-cache file: %s", err)
|
||||||
|
} else {
|
||||||
|
q.logger.Trace().Msgf("Pre-cached %d files", len(filePaths))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return torrentSymlinkPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QBit) createSymlinksWebdav(debridTorrent *debridTypes.Torrent, rclonePath, torrentFolder string) (string, error) {
|
func (q *QBit) createSymlinksWebdav(debridTorrent *debridTypes.Torrent, rclonePath, torrentFolder string) (string, error) {
|
||||||
@@ -232,70 +300,6 @@ func (q *QBit) createSymlinksWebdav(debridTorrent *debridTypes.Torrent, rclonePa
|
|||||||
return symlinkPath, nil
|
return symlinkPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QBit) createSymlinks(debridTorrent *debridTypes.Torrent, rclonePath, torrentFolder string) (string, error) {
|
|
||||||
files := debridTorrent.Files
|
|
||||||
symlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentFolder) // /mnt/symlinks/{category}/MyTVShow/
|
|
||||||
err := os.MkdirAll(symlinkPath, os.ModePerm)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create directory: %s: %v", symlinkPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
remainingFiles := make(map[string]debridTypes.File)
|
|
||||||
for _, file := range files {
|
|
||||||
remainingFiles[file.Path] = file
|
|
||||||
}
|
|
||||||
|
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
timeout := time.After(30 * time.Minute)
|
|
||||||
filePaths := make([]string, 0, len(files))
|
|
||||||
|
|
||||||
for len(remainingFiles) > 0 {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
entries, err := os.ReadDir(rclonePath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check which files exist in this batch
|
|
||||||
for _, entry := range entries {
|
|
||||||
filename := entry.Name()
|
|
||||||
if file, exists := remainingFiles[filename]; exists {
|
|
||||||
fullFilePath := filepath.Join(rclonePath, filename)
|
|
||||||
fileSymlinkPath := filepath.Join(symlinkPath, file.Name)
|
|
||||||
|
|
||||||
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
|
||||||
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
|
||||||
} else {
|
|
||||||
filePaths = append(filePaths, fileSymlinkPath)
|
|
||||||
delete(remainingFiles, filename)
|
|
||||||
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case <-timeout:
|
|
||||||
q.logger.Warn().Msgf("Timeout waiting for files, %d files still pending", len(remainingFiles))
|
|
||||||
return symlinkPath, fmt.Errorf("timeout waiting for files")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if q.SkipPreCache {
|
|
||||||
return symlinkPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
|
|
||||||
if err := q.preCacheFile(debridTorrent.Name, filePaths); err != nil {
|
|
||||||
q.logger.Error().Msgf("Failed to pre-cache file: %s", err)
|
|
||||||
} else {
|
|
||||||
q.logger.Trace().Msgf("Pre-cached %d files", len(filePaths))
|
|
||||||
}
|
|
||||||
}() // Pre-cache the files in the background
|
|
||||||
return symlinkPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debridTypes.Torrent) (string, error) {
|
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debridTypes.Torrent) (string, error) {
|
||||||
for {
|
for {
|
||||||
torrentPath, err := debridTorrent.GetMountFolder(rclonePath)
|
torrentPath, err := debridTorrent.GetMountFolder(rclonePath)
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debridTypes.Torrent
|
|||||||
debridTorrent.Arr = arr
|
debridTorrent.Arr = arr
|
||||||
|
|
||||||
// Check if debrid supports webdav by checking cache
|
// Check if debrid supports webdav by checking cache
|
||||||
|
timer := time.Now()
|
||||||
if isSymlink {
|
if isSymlink {
|
||||||
timer := time.Now()
|
|
||||||
cache, useWebdav := svc.Debrid.Caches[debridTorrent.Debrid]
|
cache, useWebdav := svc.Debrid.Caches[debridTorrent.Debrid]
|
||||||
if useWebdav {
|
if useWebdav {
|
||||||
q.logger.Info().Msgf("Using internal webdav for %s", debridTorrent.Debrid)
|
q.logger.Info().Msgf("Using internal webdav for %s", debridTorrent.Debrid)
|
||||||
@@ -131,7 +131,6 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debridTypes.Torrent
|
|||||||
// User is using either zurg or debrid webdav
|
// User is using either zurg or debrid webdav
|
||||||
torrentSymlinkPath, err = q.ProcessSymlink(torrent) // /mnt/symlinks/{category}/MyTVShow/
|
torrentSymlinkPath, err = q.ProcessSymlink(torrent) // /mnt/symlinks/{category}/MyTVShow/
|
||||||
}
|
}
|
||||||
q.logger.Info().Msgf("Adding %s took %s", debridTorrent.Name, time.Since(timer))
|
|
||||||
} else {
|
} else {
|
||||||
torrentSymlinkPath, err = q.ProcessManualFile(torrent)
|
torrentSymlinkPath, err = q.ProcessManualFile(torrent)
|
||||||
}
|
}
|
||||||
@@ -145,6 +144,7 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debridTypes.Torrent
|
|||||||
}
|
}
|
||||||
torrent.TorrentPath = torrentSymlinkPath
|
torrent.TorrentPath = torrentSymlinkPath
|
||||||
q.UpdateTorrent(torrent, debridTorrent)
|
q.UpdateTorrent(torrent, debridTorrent)
|
||||||
|
q.logger.Info().Msgf("Adding %s took %s", debridTorrent.Name, time.Since(timer))
|
||||||
go func() {
|
go func() {
|
||||||
if err := request.SendDiscordMessage("download_complete", "success", torrent.discordContext()); err != nil {
|
if err := request.SendDiscordMessage("download_complete", "success", torrent.discordContext()); err != nil {
|
||||||
q.logger.Error().Msgf("Error sending discord message: %v", err)
|
q.logger.Error().Msgf("Error sending discord message: %v", err)
|
||||||
@@ -289,7 +289,7 @@ func (q *QBit) GetTorrentFiles(t *Torrent) []*TorrentFile {
|
|||||||
if t.DebridTorrent == nil {
|
if t.DebridTorrent == nil {
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
for _, file := range t.DebridTorrent.Files {
|
for _, file := range t.DebridTorrent.GetFiles() {
|
||||||
files = append(files, &TorrentFile{
|
files = append(files, &TorrentFile{
|
||||||
Name: file.Path,
|
Name: file.Path,
|
||||||
Size: file.Size,
|
Size: file.Size,
|
||||||
|
|||||||
@@ -492,15 +492,14 @@ func (r *Repair) getFileBrokenFiles(media arr.Content) []arr.ContentFile {
|
|||||||
|
|
||||||
uniqueParents := collectFiles(media)
|
uniqueParents := collectFiles(media)
|
||||||
|
|
||||||
for parent, f := range uniqueParents {
|
for parent, files := range uniqueParents {
|
||||||
// Check stat
|
// Check stat
|
||||||
// Check file stat first
|
// Check file stat first
|
||||||
firstFile := f[0]
|
for _, file := range files {
|
||||||
// Read a tiny bit of the file
|
if err := fileIsReadable(file.Path); err != nil {
|
||||||
if err := fileIsReadable(firstFile.Path); err != nil {
|
r.logger.Debug().Msgf("Broken file found at: %s", parent)
|
||||||
r.logger.Debug().Msgf("Broken file found at: %s", parent)
|
brokenFiles = append(brokenFiles, file)
|
||||||
brokenFiles = append(brokenFiles, f...)
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(brokenFiles) == 0 {
|
if len(brokenFiles) == 0 {
|
||||||
@@ -526,41 +525,44 @@ func (r *Repair) getZurgBrokenFiles(media arr.Content) []arr.ContentFile {
|
|||||||
}
|
}
|
||||||
client := request.New(request.WithTimeout(0), request.WithTransport(tr))
|
client := request.New(request.WithTimeout(0), request.WithTransport(tr))
|
||||||
// Access zurg url + symlink folder + first file(encoded)
|
// Access zurg url + symlink folder + first file(encoded)
|
||||||
for parent, f := range uniqueParents {
|
for parent, files := range uniqueParents {
|
||||||
r.logger.Debug().Msgf("Checking %s", parent)
|
r.logger.Debug().Msgf("Checking %s", parent)
|
||||||
torrentName := url.PathEscape(filepath.Base(parent))
|
torrentName := url.PathEscape(filepath.Base(parent))
|
||||||
encodedFile := url.PathEscape(f[0].TargetPath)
|
|
||||||
fullURL := fmt.Sprintf("%s/http/__all__/%s/%s", r.ZurgURL, torrentName, encodedFile)
|
if len(files) == 0 {
|
||||||
// Check file stat first
|
r.logger.Debug().Msgf("No files found for %s. Skipping", torrentName)
|
||||||
if _, err := os.Stat(f[0].Path); os.IsNotExist(err) {
|
|
||||||
r.logger.Debug().Msgf("Broken symlink found: %s", fullURL)
|
|
||||||
brokenFiles = append(brokenFiles, f...)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Get(fullURL)
|
for _, file := range files {
|
||||||
if err != nil {
|
encodedFile := url.PathEscape(file.TargetPath)
|
||||||
r.logger.Error().Err(err).Msgf("Failed to reach %s", fullURL)
|
fullURL := fmt.Sprintf("%s/http/__all__/%s/%s", r.ZurgURL, torrentName, encodedFile)
|
||||||
brokenFiles = append(brokenFiles, f...)
|
if _, err := os.Stat(file.Path); os.IsNotExist(err) {
|
||||||
continue
|
r.logger.Debug().Msgf("Broken symlink found: %s", fullURL)
|
||||||
}
|
brokenFiles = append(brokenFiles, file)
|
||||||
|
continue
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
}
|
||||||
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
|
resp, err := client.Get(fullURL)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error().Err(err).Msgf("Failed to reach %s", fullURL)
|
||||||
|
brokenFiles = append(brokenFiles, file)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
|
||||||
|
resp.Body.Close()
|
||||||
|
brokenFiles = append(brokenFiles, file)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
downloadUrl := resp.Request.URL.String()
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
brokenFiles = append(brokenFiles, f...)
|
if downloadUrl != "" {
|
||||||
continue
|
r.logger.Trace().Msgf("Found download url: %s", downloadUrl)
|
||||||
}
|
} else {
|
||||||
|
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
|
||||||
downloadUrl := resp.Request.URL.String()
|
brokenFiles = append(brokenFiles, file)
|
||||||
resp.Body.Close()
|
continue
|
||||||
|
}
|
||||||
if downloadUrl != "" {
|
|
||||||
r.logger.Trace().Msgf("Found download url: %s", downloadUrl)
|
|
||||||
} else {
|
|
||||||
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
|
|
||||||
brokenFiles = append(brokenFiles, f...)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(brokenFiles) == 0 {
|
if len(brokenFiles) == 0 {
|
||||||
@@ -588,7 +590,6 @@ func (r *Repair) getWebdavBrokenFiles(media arr.Content) []arr.ContentFile {
|
|||||||
|
|
||||||
brokenFiles := make([]arr.ContentFile, 0)
|
brokenFiles := make([]arr.ContentFile, 0)
|
||||||
uniqueParents := collectFiles(media)
|
uniqueParents := collectFiles(media)
|
||||||
// Access zurg url + symlink folder + first file(encoded)
|
|
||||||
for torrentPath, f := range uniqueParents {
|
for torrentPath, f := range uniqueParents {
|
||||||
r.logger.Debug().Msgf("Checking %s", torrentPath)
|
r.logger.Debug().Msgf("Checking %s", torrentPath)
|
||||||
// Get the debrid first
|
// Get the debrid first
|
||||||
@@ -627,11 +628,15 @@ func (r *Repair) getWebdavBrokenFiles(media arr.Content) []arr.ContentFile {
|
|||||||
files = append(files, file.TargetPath)
|
files = append(files, file.TargetPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.IsTorrentBroken(torrent, files) {
|
_brokenFiles := cache.GetBrokenFiles(torrent, files)
|
||||||
r.logger.Debug().Msgf("[webdav] Broken symlink found: %s", torrentPath)
|
totalBrokenFiles := len(_brokenFiles)
|
||||||
// Delete the torrent?
|
if totalBrokenFiles > 0 {
|
||||||
brokenFiles = append(brokenFiles, f...)
|
r.logger.Debug().Msgf("%d broken files found in %s", totalBrokenFiles, torrentName)
|
||||||
continue
|
for _, contentFile := range f {
|
||||||
|
if utils.Contains(_brokenFiles, contentFile.TargetPath) {
|
||||||
|
brokenFiles = append(brokenFiles, contentFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type File struct {
|
|||||||
cache *debrid.Cache
|
cache *debrid.Cache
|
||||||
fileId string
|
fileId string
|
||||||
torrentName string
|
torrentName string
|
||||||
|
torrentId string
|
||||||
|
|
||||||
modTime time.Time
|
modTime time.Time
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ type File struct {
|
|||||||
|
|
||||||
downloadLink string
|
downloadLink string
|
||||||
link string
|
link string
|
||||||
|
canDelete bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// File interface implementations for File
|
// File interface implementations for File
|
||||||
|
|||||||
@@ -61,25 +61,58 @@ func (h *Handler) readinessMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// RemoveAll implements webdav.FileSystem
|
// RemoveAll implements webdav.FileSystem
|
||||||
func (h *Handler) RemoveAll(ctx context.Context, name string) error {
|
func (h *Handler) RemoveAll(ctx context.Context, name string) error {
|
||||||
if name[0] != '/' {
|
if !strings.HasPrefix(name, "/") {
|
||||||
name = "/" + name
|
name = "/" + name
|
||||||
}
|
}
|
||||||
name = path.Clean(name)
|
name = utils.PathUnescape(path.Clean(name))
|
||||||
|
|
||||||
rootDir := path.Clean(h.RootPath)
|
rootDir := path.Clean(h.RootPath)
|
||||||
|
|
||||||
if name == rootDir {
|
if name == rootDir {
|
||||||
return os.ErrPermission
|
return os.ErrPermission
|
||||||
}
|
}
|
||||||
|
|
||||||
torrentName, _ := getName(rootDir, name)
|
// Skip if it's version.txt
|
||||||
cachedTorrent := h.cache.GetTorrentByName(torrentName)
|
if name == path.Join(rootDir, "version.txt") {
|
||||||
if cachedTorrent == nil {
|
return os.ErrPermission
|
||||||
h.logger.Debug().Msgf("Torrent not found: %s", torrentName)
|
}
|
||||||
return nil // It's possible that the torrent was removed
|
|
||||||
|
// Check if the name is a parent path
|
||||||
|
if _, ok := h.isParentPath(name); ok {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the name is a torrent folder
|
||||||
|
rel := strings.TrimPrefix(name, rootDir+"/")
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
if len(parts) == 2 && utils.Contains(h.getParentItems(), parts[0]) {
|
||||||
|
torrentName := parts[1]
|
||||||
|
torrent := h.cache.GetTorrentByName(torrentName)
|
||||||
|
if torrent == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
// Remove the torrent from the cache and debrid
|
||||||
|
h.cache.OnRemove(torrent.Id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// If we reach here, it means the path is a file
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
if utils.Contains(h.getParentItems(), parts[0]) {
|
||||||
|
torrentName := parts[1]
|
||||||
|
cached := h.cache.GetTorrentByName(torrentName)
|
||||||
|
if cached != nil && len(parts) >= 3 {
|
||||||
|
filename := filepath.Clean(path.Join(parts[2:]...))
|
||||||
|
if file, ok := cached.GetFile(filename); ok {
|
||||||
|
if err := h.cache.RemoveFile(cached.Id, file.Name); err != nil {
|
||||||
|
h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// If the file was successfully removed, we can return nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.cache.OnRemove(cachedTorrent.Id)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +235,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
|
|||||||
cached := h.cache.GetTorrentByName(torrentName)
|
cached := h.cache.GetTorrentByName(torrentName)
|
||||||
if cached != nil && len(parts) >= 3 {
|
if cached != nil && len(parts) >= 3 {
|
||||||
filename := filepath.Clean(path.Join(parts[2:]...))
|
filename := filepath.Clean(path.Join(parts[2:]...))
|
||||||
if file, ok := cached.Files[filename]; ok {
|
if file, ok := cached.GetFile(filename); ok && !file.Deleted {
|
||||||
return &File{
|
return &File{
|
||||||
cache: h.cache,
|
cache: h.cache,
|
||||||
torrentName: torrentName,
|
torrentName: torrentName,
|
||||||
@@ -233,12 +266,13 @@ func (h *Handler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getFileInfos(torrent *types.Torrent) []os.FileInfo {
|
func (h *Handler) getFileInfos(torrent *types.Torrent) []os.FileInfo {
|
||||||
files := make([]os.FileInfo, 0, len(torrent.Files))
|
torrentFiles := torrent.GetFiles()
|
||||||
|
files := make([]os.FileInfo, 0, len(torrentFiles))
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Sort by file name since the order is lost when using the map
|
// Sort by file name since the order is lost when using the map
|
||||||
sortedFiles := make([]*types.File, 0, len(torrent.Files))
|
sortedFiles := make([]*types.File, 0, len(torrentFiles))
|
||||||
for _, file := range torrent.Files {
|
for _, file := range torrentFiles {
|
||||||
sortedFiles = append(sortedFiles, &file)
|
sortedFiles = append(sortedFiles, &file)
|
||||||
}
|
}
|
||||||
slices.SortFunc(sortedFiles, func(a, b *types.File) int {
|
slices.SortFunc(sortedFiles, func(a, b *types.File) int {
|
||||||
@@ -273,7 +307,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.handlePropfind(w, r)
|
h.handlePropfind(w, r)
|
||||||
return
|
return
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
if err := h.handleDelete(w, r); err == nil {
|
if err := h.handleIDDelete(w, r); err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// fallthrough to default
|
// fallthrough to default
|
||||||
@@ -504,7 +538,7 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleDelete deletes a torrent from using id
|
// handleDelete deletes a torrent from using id
|
||||||
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) error {
|
func (h *Handler) handleIDDelete(w http.ResponseWriter, r *http.Request) error {
|
||||||
cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes
|
cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes
|
||||||
|
|
||||||
_, torrentId := path.Split(cleanPath)
|
_, torrentId := path.Split(cleanPath)
|
||||||
|
|||||||
Reference in New Issue
Block a user