18 Commits

20 changed files with 515 additions and 28 deletions

View File

@ -4,21 +4,22 @@ GOCLEAN=$(GOCMD) clean
GOTEST=$(GOCMD) test GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get GOGET=$(GOCMD) get
BINARY_NAME=drmdtool BINARY_NAME=drmdtool
SRC_DIR=src
all: test build all: test build
build: build:
$(GOBUILD) -o $(BINARY_NAME) -v cd $(SRC_DIR) && $(GOBUILD) -o ../$(BINARY_NAME) -v
test: test:
$(GOTEST) -v ./... cd $(SRC_DIR) && $(GOTEST) -v ./...
clean: clean:
$(GOCLEAN) $(GOCLEAN)
rm -f $(BINARY_NAME) rm -f $(BINARY_NAME)
run: run:
$(GOBUILD) -o $(BINARY_NAME) -v cd $(SRC_DIR) && $(GOBUILD) -o ../$(BINARY_NAME) -v
./$(BINARY_NAME) ./$(BINARY_NAME)
deps: deps:
@ -28,12 +29,12 @@ deps:
# Cross compilation # Cross compilation
build-linux: build-linux:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME)_linux -v cd $(SRC_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o ../$(BINARY_NAME)_linux -v
build-windows: build-windows:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME).exe -v cd $(SRC_DIR) && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GOBUILD) -o ../$(BINARY_NAME).exe -v
build-mac: build-mac:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME)_mac -v cd $(SRC_DIR) && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GOBUILD) -o ../$(BINARY_NAME)_mac -v
.PHONY: all build test clean run deps build-linux build-windows build-mac .PHONY: all build test clean run deps build-linux build-windows build-mac

View File

@ -10,6 +10,8 @@ Create a `config.toml` file in the same directory as the drmdtool executable:
BaseDir = "/path/to/save/downloads" BaseDir = "/path/to/save/downloads"
Format = "mkv" Format = "mkv"
TempBaseDir = "/tmp/nre" TempBaseDir = "/tmp/nre"
EnableConsole = true
WatchedFolder = "/path/to/watched/folder"
[N_m3u8DL-RE] [N_m3u8DL-RE]
Path = "/path/to/N_m3u8DL-RE" Path = "/path/to/N_m3u8DL-RE"
@ -37,4 +39,19 @@ To process a file directly from the command line:
./drmdtool -f /path/to/file.drmd ./drmdtool -f /path/to/file.drmd
``` ```
This will download the file and save it in the base directory specified in the config. This will download the file and save it in the base directory specified in the config.
# Previews
## Index Page
![Index Page](images/index.png)
## Select Page
![Select Page](images/select.png)
## Progress Page
![Progress Page](images/progress.png)

View File

@ -1,6 +1,8 @@
BaseDir = "/mnt/media" BaseDir = "/mnt/media"
Format = "mkv" Format = "mkv"
TempBaseDir = "/tmp/nre" TempBaseDir = "/tmp/nre"
EnableConsole = true
WatchedFolder = "/mnt/watched"
[N_m3u8DLRE] [N_m3u8DLRE]
Path = "nre" Path = "nre"

BIN
images/index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
images/progress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
images/select.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -15,6 +15,8 @@ type Config struct {
N_m3u8DLRE struct { N_m3u8DLRE struct {
Path string Path string
} }
EnableConsole bool
WatchedFolder string
} }
var config Config var config Config
@ -39,4 +41,8 @@ func loadConfig() {
return return
} }
if config.WatchedFolder == "" {
fmt.Println("Error: Watched folder is not specified in the config file")
return
}
} }

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
@ -9,6 +10,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
func removeBOM(input []byte) []byte { func removeBOM(input []byte) []byte {
@ -18,12 +20,13 @@ func removeBOM(input []byte) []byte {
return input return input
} }
func downloadFile(item Item, jobInfo *JobInfo) error { func downloadFile(drmdFilename string, item Item, jobInfo *JobInfo) error {
fmt.Println("Downloading:", item.Filename) logger.LogInfo("Download File", fmt.Sprintf("Starting download for: %s", item.Filename))
tempDir := filepath.Join(config.TempBaseDir, sanitizeFilename(item.Filename)) tempDir := filepath.Join(config.TempBaseDir, sanitizeFilename(item.Filename))
err := os.MkdirAll(tempDir, 0755) err := os.MkdirAll(tempDir, 0755)
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error creating temporary directory: %v", err))
return fmt.Errorf("error creating temporary directory: %v", err) return fmt.Errorf("error creating temporary directory: %v", err)
} }
@ -33,19 +36,23 @@ func downloadFile(item Item, jobInfo *JobInfo) error {
if !isValidURL(item.MPD) { if !isValidURL(item.MPD) {
decodedMPD, err := base64.StdEncoding.DecodeString(item.MPD) decodedMPD, err := base64.StdEncoding.DecodeString(item.MPD)
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error decoding base64 MPD: %v", err))
return fmt.Errorf("error decoding base64 MPD: %v", err) return fmt.Errorf("error decoding base64 MPD: %v", err)
} }
tempFile, err := os.CreateTemp("", "temp_mpd_*.mpd") tempFile, err := os.CreateTemp("", "temp_mpd_*.mpd")
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error creating temporary MPD file: %v", err))
return fmt.Errorf("error creating temporary MPD file: %v", err) return fmt.Errorf("error creating temporary MPD file: %v", err)
} }
defer os.Remove(tempFile.Name()) defer os.Remove(tempFile.Name())
if _, err := tempFile.Write(decodedMPD); err != nil { if _, err := tempFile.Write(decodedMPD); err != nil {
logger.LogError("Download File", fmt.Sprintf("Error writing to temporary MPD file: %v", err))
return fmt.Errorf("error writing to temporary MPD file: %v", err) return fmt.Errorf("error writing to temporary MPD file: %v", err)
} }
if err := tempFile.Close(); err != nil { if err := tempFile.Close(); err != nil {
logger.LogError("Download File", fmt.Sprintf("Error closing temporary MPD file: %v", err))
return fmt.Errorf("error closing temporary MPD file: %v", err) return fmt.Errorf("error closing temporary MPD file: %v", err)
} }
@ -53,30 +60,36 @@ func downloadFile(item Item, jobInfo *JobInfo) error {
} else if strings.HasPrefix(item.MPD, "https://pubads.g.doubleclick.net") { } else if strings.HasPrefix(item.MPD, "https://pubads.g.doubleclick.net") {
resp, err := http.Get(item.MPD) resp, err := http.Get(item.MPD)
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error downloading MPD: %v", err))
return fmt.Errorf("error downloading MPD: %v", err) return fmt.Errorf("error downloading MPD: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
mpdContent, err := io.ReadAll(resp.Body) mpdContent, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error reading MPD content: %v", err))
return fmt.Errorf("error reading MPD content: %v", err) return fmt.Errorf("error reading MPD content: %v", err)
} }
fixedMPDContent, err := fixGoPlay(string(mpdContent)) fixedMPDContent, err := fixGoPlay(string(mpdContent))
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error fixing MPD content: %v", err))
return fmt.Errorf("error fixing MPD content: %v", err) return fmt.Errorf("error fixing MPD content: %v", err)
} }
tempFile, err := os.CreateTemp("", "fixed_mpd_*.mpd") tempFile, err := os.CreateTemp("", "fixed_mpd_*.mpd")
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error creating temporary MPD file: %v", err))
return fmt.Errorf("error creating temporary MPD file: %v", err) return fmt.Errorf("error creating temporary MPD file: %v", err)
} }
defer os.Remove(tempFile.Name()) defer os.Remove(tempFile.Name())
if _, err := tempFile.WriteString(fixedMPDContent); err != nil { if _, err := tempFile.WriteString(fixedMPDContent); err != nil {
logger.LogError("Download File", fmt.Sprintf("Error writing to temporary MPD file: %v", err))
return fmt.Errorf("error writing to temporary MPD file: %v", err) return fmt.Errorf("error writing to temporary MPD file: %v", err)
} }
if err := tempFile.Close(); err != nil { if err := tempFile.Close(); err != nil {
logger.LogError("Download File", fmt.Sprintf("Error closing temporary MPD file: %v", err))
return fmt.Errorf("error closing temporary MPD file: %v", err) return fmt.Errorf("error closing temporary MPD file: %v", err)
} }
@ -88,26 +101,25 @@ func downloadFile(item Item, jobInfo *JobInfo) error {
if item.Subtitles != "" { if item.Subtitles != "" {
subtitlePaths, err := downloadAndConvertSubtitles(item.Subtitles) subtitlePaths, err := downloadAndConvertSubtitles(item.Subtitles)
if err != nil { if err != nil {
fmt.Printf("Error processing subtitles: %v\n", err) logger.LogError("Download File", fmt.Sprintf("Error processing subtitles: %v", err))
} else { } else {
for _, path := range subtitlePaths { for _, path := range subtitlePaths {
fmt.Println("Adding subtitle:", path) logger.LogInfo("Download File", fmt.Sprintf("Adding subtitle: %s", path))
command += fmt.Sprintf(" --mux-import \"path=%s:lang=nl:name=Nederlands\"", path) command += fmt.Sprintf(" --mux-import \"path=%s:lang=nl:name=Nederlands\"", path)
} }
} }
} }
cmd := exec.Command("bash", "-c", command) cmd := exec.Command("bash", "-c", command)
jobsMutex.Lock()
jobInfo.Cmd = cmd jobInfo.Cmd = cmd
jobsMutex.Unlock()
cmd.Stdout = os.Stdout var outputBuffer bytes.Buffer
cmd.Stdout = io.MultiWriter(&outputBuffer)
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
err = cmd.Start() err = cmd.Start()
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error starting download command: %v", err))
return fmt.Errorf("error starting download command: %v", err) return fmt.Errorf("error starting download command: %v", err)
} }
@ -116,23 +128,39 @@ func downloadFile(item Item, jobInfo *JobInfo) error {
done <- cmd.Wait() done <- cmd.Wait()
}() }()
go func() {
for {
if outputBuffer.Len() > 0 {
message := outputBuffer.Bytes()
if config.EnableConsole {
broadcast(drmdFilename, message)
}
outputBuffer.Reset()
}
time.Sleep(1 * time.Second)
}
}()
select { select {
case <-jobInfo.AbortChan: case <-jobInfo.AbortChan:
if cmd.Process != nil { if cmd.Process != nil {
cmd.Process.Kill() cmd.Process.Kill()
} }
os.RemoveAll(tempDir) os.RemoveAll(tempDir)
logger.LogInfo("Download File", "Download aborted")
return fmt.Errorf("download aborted") return fmt.Errorf("download aborted")
case err := <-done: case err := <-done:
if jobInfo.Paused { if jobInfo.Paused {
logger.LogInfo("Download File", "Download paused")
return fmt.Errorf("download paused") return fmt.Errorf("download paused")
} }
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error executing download command: %v", err))
return fmt.Errorf("error executing download command: %v", err) return fmt.Errorf("error executing download command: %v", err)
} }
} }
fmt.Println("Download completed successfully") logger.LogInfo("Download File", "Download completed successfully")
return nil return nil
} }

View File

@ -8,9 +8,13 @@ require (
github.com/beevik/etree v1.4.1 github.com/beevik/etree v1.4.1
) )
require golang.org/x/sys v0.4.0 // indirect
require ( require (
github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect
github.com/fsnotify/fsnotify v1.7.0
github.com/gorilla/websocket v1.5.3
golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect
golang.org/x/text v0.3.2 // indirect golang.org/x/text v0.3.2 // indirect
) )

View File

@ -10,6 +10,10 @@ github.com/beevik/etree v1.4.1 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI=
github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pkg/exec v0.0.0-20150614095509-0bd164ad2a5a h1:EN123kAtAAE2pg/+TvBsUBZfHCWNNFyL2ZBPPfNWAc0= github.com/pkg/exec v0.0.0-20150614095509-0bd164ad2a5a h1:EN123kAtAAE2pg/+TvBsUBZfHCWNNFyL2ZBPPfNWAc0=
github.com/pkg/exec v0.0.0-20150614095509-0bd164ad2a5a/go.mod h1:b95YoNrAnScjaWG+asr8lxqlrsPUcT2ZEBcjvVGshMo= github.com/pkg/exec v0.0.0-20150614095509-0bd164ad2a5a/go.mod h1:b95YoNrAnScjaWG+asr8lxqlrsPUcT2ZEBcjvVGshMo=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
@ -26,6 +30,8 @@ golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=

View File

@ -9,6 +9,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"github.com/gorilla/websocket"
) )
type ProgressInfo struct { type ProgressInfo struct {
@ -47,19 +50,23 @@ func handleRoot(w http.ResponseWriter, r *http.Request) {
} }
}{jobsInfo}) }{jobsInfo})
if err != nil { if err != nil {
logger.LogError("Handle Root", fmt.Sprintf("Error executing template: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
func handleUpload(w http.ResponseWriter, r *http.Request) { func handleUpload(w http.ResponseWriter, r *http.Request) {
logger.LogInfo("Handle Upload", "Starting file upload")
err := r.ParseMultipartForm(32 << 20) err := r.ParseMultipartForm(32 << 20)
if err != nil { if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error parsing multipart form: %v", err))
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
files := r.MultipartForm.File["files"] files := r.MultipartForm.File["files"]
if len(files) == 0 { if len(files) == 0 {
logger.LogError("Handle Upload", "No files uploaded")
http.Error(w, "No files uploaded", http.StatusBadRequest) http.Error(w, "No files uploaded", http.StatusBadRequest)
return return
} }
@ -69,6 +76,7 @@ func handleUpload(w http.ResponseWriter, r *http.Request) {
for _, fileHeader := range files { for _, fileHeader := range files {
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error opening file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -76,6 +84,7 @@ func handleUpload(w http.ResponseWriter, r *http.Request) {
tempFile, err := os.CreateTemp(uploadDir, fileHeader.Filename) tempFile, err := os.CreateTemp(uploadDir, fileHeader.Filename)
if err != nil { if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error creating temporary file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -83,6 +92,7 @@ func handleUpload(w http.ResponseWriter, r *http.Request) {
_, err = io.Copy(tempFile, file) _, err = io.Copy(tempFile, file)
if err != nil { if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error copying file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -91,6 +101,7 @@ func handleUpload(w http.ResponseWriter, r *http.Request) {
_, err = parseInputFile(tempFile.Name()) _, err = parseInputFile(tempFile.Name())
if err != nil { if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error parsing input file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -104,10 +115,12 @@ func handleUpload(w http.ResponseWriter, r *http.Request) {
} }
if len(validFiles) == 0 { if len(validFiles) == 0 {
logger.LogError("Handle Upload", "No valid files were uploaded")
http.Error(w, "No valid files were uploaded", http.StatusBadRequest) http.Error(w, "No valid files were uploaded", http.StatusBadRequest)
return return
} }
logger.LogInfo("Handle Upload", fmt.Sprintf("Redirecting to select with files: %v", validFiles))
http.Redirect(w, r, "/select?files="+url.QueryEscape(strings.Join(validFiles, ",")), http.StatusSeeOther) http.Redirect(w, r, "/select?files="+url.QueryEscape(strings.Join(validFiles, ",")), http.StatusSeeOther)
} }
@ -124,11 +137,13 @@ func handleSelect(w http.ResponseWriter, r *http.Request) {
fullPath := filepath.Join(uploadDir, filename) fullPath := filepath.Join(uploadDir, filename)
if _, err := os.Stat(fullPath); os.IsNotExist(err) { if _, err := os.Stat(fullPath); os.IsNotExist(err) {
logger.LogError("Handle Select", fmt.Sprintf("File does not exist: %s", fullPath))
continue continue
} }
items, err := parseInputFile(fullPath) items, err := parseInputFile(fullPath)
if err != nil { if err != nil {
logger.LogError("Handle Select", fmt.Sprintf("Error parsing input file: %v", err))
continue continue
} }
@ -139,6 +154,7 @@ func handleSelect(w http.ResponseWriter, r *http.Request) {
} }
if len(allItems) == 0 { if len(allItems) == 0 {
logger.LogError("Handle Select", "No valid files were processed")
http.Error(w, "No valid files were processed", http.StatusBadRequest) http.Error(w, "No valid files were processed", http.StatusBadRequest)
return return
} }
@ -151,18 +167,22 @@ func handleSelect(w http.ResponseWriter, r *http.Request) {
AllItems: allItems, AllItems: allItems,
}) })
if err != nil { if err != nil {
logger.LogError("Handle Select", fmt.Sprintf("Error executing template: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
func handleProcess(w http.ResponseWriter, r *http.Request) { func handleProcess(w http.ResponseWriter, r *http.Request) {
logger.LogInfo("Handle Process", "Starting process")
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
logger.LogError("Handle Process", fmt.Sprintf("Error parsing form: %v", err))
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
selectedItems := r.Form["items"] selectedItems := r.Form["items"]
if len(selectedItems) == 0 { if len(selectedItems) == 0 {
logger.LogError("Handle Process", "No items selected")
http.Error(w, "No items selected", http.StatusBadRequest) http.Error(w, "No items selected", http.StatusBadRequest)
return return
} }
@ -171,6 +191,7 @@ func handleProcess(w http.ResponseWriter, r *http.Request) {
for _, item := range selectedItems { for _, item := range selectedItems {
parts := strings.SplitN(item, ":", 2) parts := strings.SplitN(item, ":", 2)
if len(parts) != 2 { if len(parts) != 2 {
logger.LogError("Handle Process", "Invalid item format")
continue continue
} }
filename, itemName := parts[0], parts[1] filename, itemName := parts[0], parts[1]
@ -178,9 +199,11 @@ func handleProcess(w http.ResponseWriter, r *http.Request) {
} }
for filename, items := range itemsByFile { for filename, items := range itemsByFile {
logger.LogInfo("Handle Process", fmt.Sprintf("Processing file: %s", filename))
fullPath := filepath.Join(uploadDir, filename) fullPath := filepath.Join(uploadDir, filename)
allItems, err := parseInputFile(fullPath) allItems, err := parseInputFile(fullPath)
if err != nil { if err != nil {
logger.LogError("Handle Process", fmt.Sprintf("Error parsing input file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -223,6 +246,7 @@ func handleProgress(w http.ResponseWriter, r *http.Request) {
func handlePause(w http.ResponseWriter, r *http.Request) { func handlePause(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("filename") filename := r.URL.Query().Get("filename")
if filename == "" { if filename == "" {
logger.LogError("Pause Handler", "Filename is required")
http.Error(w, "Filename is required", http.StatusBadRequest) http.Error(w, "Filename is required", http.StatusBadRequest)
return return
} }
@ -232,12 +256,14 @@ func handlePause(w http.ResponseWriter, r *http.Request) {
jobsMutex.Unlock() jobsMutex.Unlock()
if !exists { if !exists {
logger.LogError("Pause Handler", "Job not found")
http.Error(w, "Job not found", http.StatusNotFound) http.Error(w, "Job not found", http.StatusNotFound)
return return
} }
jobInfo.Paused = true jobInfo.Paused = true
if jobInfo.Cmd != nil && jobInfo.Cmd.Process != nil { if jobInfo.Cmd != nil && jobInfo.Cmd.Process != nil {
logger.LogJobState(filename, "pausing")
jobInfo.Cmd.Process.Kill() jobInfo.Cmd.Process.Kill()
} }
@ -354,3 +380,66 @@ func updateProgress(filename string, value float64, currentFile string) {
} }
} }
} }
var upgrader = websocket.Upgrader{}
var clients = make(map[string]map[*websocket.Conn]bool)
var mu sync.Mutex
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
fmt.Println(config.EnableConsole)
if !config.EnableConsole {
http.Error(w, "Console output is disabled", http.StatusForbidden)
return
}
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.LogError("WebSocket", fmt.Sprintf("Error while upgrading connection: %v", err))
return
}
defer conn.Close()
logger.LogInfo("WebSocket", fmt.Sprintf("WebSocket connection established for filename: %s", filename))
mu.Lock()
if clients[filename] == nil {
clients[filename] = make(map[*websocket.Conn]bool)
}
clients[filename][conn] = true
mu.Unlock()
for {
if _, _, err := conn.NextReader(); err != nil {
break
}
}
mu.Lock()
delete(clients[filename], conn)
mu.Unlock()
logger.LogInfo("WebSocket", fmt.Sprintf("WebSocket connection closed for filename: %s", filename))
}
func broadcast(filename string, message []byte) {
if !config.EnableConsole {
return
}
mu.Lock()
defer mu.Unlock()
for client := range clients[filename] {
if err := client.WriteMessage(websocket.TextMessage, message); err != nil {
client.Close()
delete(clients[filename], client)
logger.LogError("Broadcast", fmt.Sprintf("Error writing message to client: %v", err))
}
}
}

36
src/logger.go Normal file
View File

@ -0,0 +1,36 @@
package main
import (
"log"
"os"
)
type Logger struct {
*log.Logger
}
const (
Reset = "\033[0m"
Red = "\033[31m"
Green = "\033[32m"
Yellow = "\033[33m"
Blue = "\033[34m"
)
func NewLogger(prefix string) *Logger {
return &Logger{
Logger: log.New(os.Stdout, prefix, log.Ldate|log.Ltime|log.Lshortfile),
}
}
func (l *Logger) LogInfo(jobName, message string) {
l.Printf("%s[INFO] [%s] %s%s", Green, jobName, message, Reset)
}
func (l *Logger) LogError(jobName, message string) {
l.Printf("%s[ERROR] [%s] %s%s", Red, jobName, message, Reset)
}
func (l *Logger) LogJobState(jobName, state string) {
l.Printf("%s[JOB STATE] [%s] %s%s", Yellow, jobName, state, Reset)
}

View File

@ -12,6 +12,8 @@ import (
"embed" "embed"
) )
var logger *Logger
type Item struct { type Item struct {
MPD string MPD string
Keys string Keys string
@ -48,6 +50,8 @@ func init() {
} }
templates = template.Must(template.ParseFS(templateFS, "templates/*")) templates = template.Must(template.ParseFS(templateFS, "templates/*"))
logger = NewLogger("")
} }
func main() { func main() {
@ -56,11 +60,12 @@ func main() {
flag.Parse() flag.Parse()
if *inputFile == "" { if *inputFile == "" {
go watchFolder()
startWebServer() startWebServer()
} else { } else {
items, err := parseInputFile(*inputFile) items, err := parseInputFile(*inputFile)
if err != nil { if err != nil {
fmt.Printf("Error parsing input file: %v\n", err) logger.LogError("Main", fmt.Sprintf("Error parsing input file: %v", err))
return return
} }
processItems(*inputFile, items) processItems(*inputFile, items)
@ -77,6 +82,7 @@ func startWebServer() {
http.HandleFunc("/pause", handlePause) http.HandleFunc("/pause", handlePause)
http.HandleFunc("/resume", handleResume) http.HandleFunc("/resume", handleResume)
http.HandleFunc("/clear-completed", handleClearCompleted) http.HandleFunc("/clear-completed", handleClearCompleted)
http.HandleFunc("/ws", handleWebSocket)
fmt.Println("Starting web server on http://0.0.0.0:8080") fmt.Println("Starting web server on http://0.0.0.0:8080")
http.ListenAndServe(":8080", nil) http.ListenAndServe(":8080", nil)

125
src/main_test.go Normal file
View File

@ -0,0 +1,125 @@
package main
import (
"encoding/json"
"os"
"reflect"
"testing"
)
func TestSanitizeFilename(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"file:name.mp4", "file_name.mp4"},
{"file/name.mp4", "file_name.mp4"},
{"file\\name.mp4", "file_name.mp4"},
{"file?name.mp4", "file_name.mp4"},
{"file*name.mp4", "file_name.mp4"},
{"file<name>.mp4", "file_name_.mp4"},
{".hidden", "hidden"},
}
for _, test := range tests {
result := sanitizeFilename(test.input)
if result != test.expected {
t.Errorf("sanitizeFilename(%q) = %q, want %q", test.input, result, test.expected)
}
}
}
func TestIsValidURL(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"https://example.com", true},
{"http://example.com", true},
{"ftp://example.com", true},
{"not a url", false},
}
for _, test := range tests {
result := isValidURL(test.input)
if result != test.expected {
t.Errorf("isValidURL(%q) = %v, want %v", test.input, result, test.expected)
}
}
}
func TestParseMetadata(t *testing.T) {
tests := []struct {
input string
expected Metadata
}{
{"Show Title; serie; 01", Metadata{Title: "Show Title", Type: "serie", Season: "S01"}},
{"Movie Title; movie; ", Metadata{Title: "Movie Title", Type: "movie", Season: "S"}},
{"Invalid Metadata", Metadata{}},
}
for _, test := range tests {
result := parseMetadata(test.input)
if !reflect.DeepEqual(result, test.expected) {
t.Errorf("parseMetadata(%q) = %v, want %v", test.input, result, test.expected)
}
}
}
func TestParseInputFile(t *testing.T) {
tempFile, err := os.CreateTemp("", "test_input_*.json")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tempFile.Name())
testData := Items{
Items: []Item{
{MPD: "http://example.com/video1.mpd", Filename: "video1.mp4"},
{MPD: "http://example.com/video2.mpd", Filename: "video2.mp4"},
},
}
jsonData, _ := json.Marshal(testData)
if _, err := tempFile.Write(jsonData); err != nil {
t.Fatalf("Failed to write to temp file: %v", err)
}
tempFile.Close()
items, err := parseInputFile(tempFile.Name())
if err != nil {
t.Fatalf("parseInputFile() returned an error: %v", err)
}
if len(items) != len(testData.Items) {
t.Errorf("parseInputFile() returned %d items, want %d", len(items), len(testData.Items))
}
for i, item := range items {
if !reflect.DeepEqual(item, testData.Items[i]) {
t.Errorf("parseInputFile() item %d = %v, want %v", i, item, testData.Items[i])
}
}
}
func TestGroupItemsBySeason(t *testing.T) {
items := []Item{
{Filename: "show1_s01e01.mp4", Metadata: "Show 1; serie; 01"},
{Filename: "show1_s01e02.mp4", Metadata: "Show 1; serie; 01"},
{Filename: "show2_s01e01.mp4", Metadata: "Show 2; serie; 01"},
{Filename: "movie1.mp4", Metadata: "Movie 1; movie; "},
}
grouped := groupItemsBySeason(items)
expectedGroups := map[string]int{
"Show 1 - S01": 2,
"Show 2 - S01": 1,
"Movies": 1,
}
for group, count := range expectedGroups {
if len(grouped[group]) != count {
t.Errorf("groupItemsBySeason() group %q has %d items, want %d", group, len(grouped[group]), count)
}
}
}

View File

@ -15,13 +15,16 @@ func downloadAndConvertSubtitles(subtitlesURLs string) ([]string, error) {
urls := strings.Split(subtitlesURLs, ",") urls := strings.Split(subtitlesURLs, ",")
for _, url := range urls { for _, url := range urls {
logger.LogInfo("Subtitle Download", fmt.Sprintf("Downloading subtitle from %s", url))
vttPath, err := downloadSubtitle(url) vttPath, err := downloadSubtitle(url)
if err != nil { if err != nil {
logger.LogError("Subtitle Download", fmt.Sprintf("Error downloading subtitle: %v", err))
return nil, fmt.Errorf("error downloading subtitle: %v", err) return nil, fmt.Errorf("error downloading subtitle: %v", err)
} }
srtPath, err := convertVTTtoSRT(vttPath) srtPath, err := convertVTTtoSRT(vttPath)
if err != nil { if err != nil {
logger.LogError("Subtitle Download", fmt.Sprintf("Error converting subtitle: %v", err))
return nil, fmt.Errorf("error converting subtitle: %v", err) return nil, fmt.Errorf("error converting subtitle: %v", err)
} }
@ -32,23 +35,28 @@ func downloadAndConvertSubtitles(subtitlesURLs string) ([]string, error) {
} }
func downloadSubtitle(url string) (string, error) { func downloadSubtitle(url string) (string, error) {
logger.LogInfo("Download Subtitle", fmt.Sprintf("Starting download from %s", url))
resp, err := http.Get(url) resp, err := http.Get(url)
if err != nil { if err != nil {
logger.LogError("Download Subtitle", fmt.Sprintf("Error getting subtitle URL: %v", err))
return "", err return "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
tempFile, err := os.CreateTemp("", "subtitle_*.vtt") tempFile, err := os.CreateTemp("", "subtitle_*.vtt")
if err != nil { if err != nil {
logger.LogError("Download Subtitle", fmt.Sprintf("Error creating temp file: %v", err))
return "", err return "", err
} }
defer tempFile.Close() defer tempFile.Close()
_, err = io.Copy(tempFile, resp.Body) _, err = io.Copy(tempFile, resp.Body)
if err != nil { if err != nil {
logger.LogError("Download Subtitle", fmt.Sprintf("Error copying to temp file: %v", err))
return "", err return "", err
} }
logger.LogInfo("Download Subtitle", "Subtitle downloaded successfully")
return tempFile.Name(), nil return tempFile.Name(), nil
} }
@ -56,5 +64,6 @@ func convertVTTtoSRT(vttPath string) (string, error) {
srtPath := strings.TrimSuffix(vttPath, ".vtt") + ".srt" srtPath := strings.TrimSuffix(vttPath, ".vtt") + ".srt"
s1, _ := astisub.OpenFile(vttPath) s1, _ := astisub.OpenFile(vttPath)
s1.Write(srtPath) s1.Write(srtPath)
logger.LogInfo("Convert VTT to SRT", fmt.Sprintf("Converted %s to %s", vttPath, srtPath))
return srtPath, nil return srtPath, nil
} }

View File

@ -11,7 +11,7 @@
color: #d4d4d4; color: #d4d4d4;
line-height: 1.6; line-height: 1.6;
padding: 20px; padding: 20px;
max-width: 800px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
} }

View File

@ -11,7 +11,7 @@
color: #d4d4d4; color: #d4d4d4;
line-height: 1.6; line-height: 1.6;
padding: 20px; padding: 20px;
max-width: 800px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
} }
@ -68,7 +68,7 @@
#abort-button:hover { #abort-button:hover {
background-color: #d32f2f; background-color: #d32f2f;
} }
#pause-button, #resume-button { #pause-button, #resume-button, #toggle-console {
background-color: #4CAF50; background-color: #4CAF50;
color: white; color: white;
border: none; border: none;
@ -77,7 +77,7 @@
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
} }
#pause-button:hover, #resume-button:hover { #pause-button:hover, #resume-button:hover, #toggle-console:hover {
background-color: #45a049; background-color: #45a049;
} }
#resume-button { #resume-button {
@ -96,6 +96,17 @@
#back-button:hover { #back-button:hover {
background-color: #1976D2; background-color: #1976D2;
} }
#console {
display: none; /* Initially hidden */
background-color: black;
color: white;
height: 300px; /* Adjust height as needed */
overflow-y: scroll;
white-space: pre; /* Preserve whitespace */
font-family: monospace; /* Use monospace font */
margin-top: 10px;
border: 1px solid #ccc;
}
@media (max-width: 600px) { @media (max-width: 600px) {
body { body {
padding: 10px; padding: 10px;
@ -128,13 +139,16 @@
<button id="abort-button" onclick="abortDownload()">Abort Download</button> <button id="abort-button" onclick="abortDownload()">Abort Download</button>
<button id="pause-button" onclick="pauseDownload()">Pause Download</button> <button id="pause-button" onclick="pauseDownload()">Pause Download</button>
<button id="resume-button" onclick="resumeDownload()" style="display: none;">Resume Download</button> <button id="resume-button" onclick="resumeDownload()" style="display: none;">Resume Download</button>
<button id="toggle-console">Toggle Console View</button>
<button id="back-button" onclick="window.location.href='/'">Back to Index</button> <button id="back-button" onclick="window.location.href='/'">Back to Index</button>
</div> </div>
<div style="display: none;" id="console"></div>
<script> <script>
let isPaused = false; let isPaused = false;
const filename = "{{.Filename}}";
function updateProgress() { function updateProgress() {
fetch('/progress?filename={{.Filename}}', { fetch(`/progress?filename=${filename}`, {
headers: { headers: {
'Accept': 'application/json' 'Accept': 'application/json'
} }
@ -166,7 +180,7 @@
} }
function abortDownload() { function abortDownload() {
fetch('/abort?filename={{.Filename}}', { method: 'POST' }) fetch(`/abort?filename=${filename}`, { method: 'POST' })
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
console.log('Abort signal sent. The download will stop soon.'); console.log('Abort signal sent. The download will stop soon.');
@ -177,7 +191,7 @@
} }
function pauseDownload() { function pauseDownload() {
fetch('/pause?filename={{.Filename}}', { method: 'POST' }) fetch(`/pause?filename=${filename}`, { method: 'POST' })
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
console.log('Pause signal sent. The download will pause soon.'); console.log('Pause signal sent. The download will pause soon.');
@ -190,7 +204,7 @@
} }
function resumeDownload() { function resumeDownload() {
fetch('/resume?filename={{.Filename}}', { method: 'POST' }) fetch(`/resume?filename=${filename}`, { method: 'POST' })
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
console.log('Resume signal sent. The download will resume soon.'); console.log('Resume signal sent. The download will resume soon.');
@ -203,6 +217,31 @@
}); });
} }
const consoleDiv = document.getElementById('console');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/ws?filename=${filename}`);
ws.onmessage = function(event) {
consoleDiv.textContent += event.data;
consoleDiv.scrollTop = consoleDiv.scrollHeight;
};
ws.onclose = function() {
console.log('WebSocket connection closed');
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
};
document.getElementById('toggle-console').onclick = function() {
if (consoleDiv.style.display === "none") {
consoleDiv.style.display = "block";
} else {
consoleDiv.style.display = "none";
}
};
updateProgress(); updateProgress();
</script> </script>
</body> </body>

View File

@ -11,7 +11,7 @@
color: #d4d4d4; color: #d4d4d4;
line-height: 1.6; line-height: 1.6;
padding: 20px; padding: 20px;
max-width: 800px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
} }

View File

@ -259,21 +259,25 @@ func processItems(filename string, items []Item) error {
select { select {
case <-jobInfo.AbortChan: case <-jobInfo.AbortChan:
updateProgress(filename, 100, "Aborted") updateProgress(filename, 100, "Aborted")
logger.LogJobState(filename, "aborted")
return fmt.Errorf("download aborted") return fmt.Errorf("download aborted")
default: default:
if jobInfo.Paused { if jobInfo.Paused {
select { select {
case <-jobInfo.ResumeChan: case <-jobInfo.ResumeChan:
jobInfo.Paused = false jobInfo.Paused = false
logger.LogJobState(filename, "resumed")
case <-jobInfo.AbortChan: case <-jobInfo.AbortChan:
updateProgress(filename, 100, "Aborted") updateProgress(filename, 100, "Aborted")
logger.LogJobState(filename, "aborted")
return fmt.Errorf("download aborted") return fmt.Errorf("download aborted")
} }
} }
updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename) updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename)
err := downloadFile(items[i], jobInfo) err := downloadFile(filename, items[i], jobInfo)
if err != nil { if err != nil {
if err.Error() == "download paused" { if err.Error() == "download paused" {
logger.LogJobState(filename, "paused")
removeCompletedEpisodes(filename, items[:i]) removeCompletedEpisodes(filename, items[:i])
i-- i--
continue continue
@ -282,6 +286,7 @@ func processItems(filename string, items []Item) error {
} }
} }
updateProgress(filename, 100, "") updateProgress(filename, 100, "")
logger.LogJobState(filename, "completed successfully")
return nil return nil
} }

114
src/watcher.go Normal file
View File

@ -0,0 +1,114 @@
package main
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
func watchFolder() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
done := make(chan bool)
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Create == fsnotify.Create {
if strings.HasSuffix(event.Name, ".drmd") {
fmt.Println("New .drmd detected:", event.Name)
processWatchedFile(event.Name)
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("Error:", err)
}
}
}()
err = watcher.Add(config.WatchedFolder)
if err != nil {
log.Fatal(err)
}
<-done
}
func processWatchedFile(filePath string) {
for {
initialSize, err := getFileSize(filePath)
if err != nil {
logger.LogError("Watcher", fmt.Sprintf("Error getting file size: %v", err))
return
}
time.Sleep(1 * time.Second)
currentSize, err := getFileSize(filePath)
if err != nil {
logger.LogError("Watcher", fmt.Sprintf("Error getting file size: %v", err))
return
}
if initialSize == currentSize {
break
}
}
file, err := os.Open(filePath)
if err != nil {
logger.LogError("Watcher", fmt.Sprintf("Error opening file: %v", err))
return
}
defer file.Close()
originalFilename := filepath.Base(filePath)
tempFile, err := os.CreateTemp(uploadDir, originalFilename)
if err != nil {
logger.LogError("Watcher", fmt.Sprintf("Error creating temporary file: %v", err))
return
}
defer tempFile.Close()
_, err = io.Copy(tempFile, file)
if err != nil {
logger.LogError("Watcher", fmt.Sprintf("Error copying file: %v", err))
return
}
if err := os.Remove(filePath); err != nil {
logger.LogError("Watcher", fmt.Sprintf("Error deleting original file: %v", err))
}
items, err := parseInputFile(tempFile.Name())
if err != nil {
logger.LogError("Watcher", fmt.Sprintf("Error parsing input file: %v", err))
return
}
go processItems(filepath.Base(tempFile.Name()), items)
}
func getFileSize(filePath string) (int64, error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return 0, err
}
return fileInfo.Size(), nil
}