31 Commits

Author SHA1 Message Date
7445627f7e Add option to disable the console broadcasts 2024-09-24 15:17:58 +02:00
cc28f0f3c2 Correctly split console for each process 2024-09-24 15:01:09 +02:00
ace79838fe Make page a bit wider to account for console 2024-09-23 17:26:33 +02:00
8a63f73839 Fix: Console display should be set to none 2024-09-23 17:24:03 +02:00
da03138d5c Implement logging, console window 2024-09-23 17:23:22 +02:00
0bae45a824 Display Console 2024-09-23 16:50:04 +02:00
7159bae9f7 Basic tests 2024-09-15 05:11:00 +02:00
5b6e1e6b01 Delete src/templates/stats 2024-09-15 05:02:02 +02:00
4b03c7c59b Change project structure 2024-09-15 05:00:02 +02:00
1dd8aa594d Add images for docxs 2024-09-15 04:40:14 +02:00
72889d3083 Sort episodes in a holy way 2024-09-15 04:29:48 +02:00
bd87baa40a Makefile 2024-09-15 00:34:25 +02:00
2f738413f3 H 2024-09-15 00:31:46 +02:00
f6a447d7f4 Hm 2024-09-14 03:39:15 +02:00
551e53ad63 Merge pull request 'Support uploading multiple files' (#5) from multiUpload into main
Reviewed-on: #5
2024-09-14 02:03:26 +02:00
707de8fcf1 Support uploading multiple files 2024-09-14 02:02:35 +02:00
64a6eb20a0 Merge pull request 'Implement correct pause state' (#4) from issue-3 into main
Reviewed-on: #4
2024-09-14 01:09:48 +02:00
67b17c1df7 Merge branch 'main' of ssh://git.directme.in:2222/Joren/DRMDTool into issue-3 2024-09-14 01:08:54 +02:00
142c09e624 Implement correct pause state 2024-09-14 00:53:55 +02:00
0b3797dc19 Update README.md 2024-09-13 23:32:56 +02:00
d4dae21d8f Update README.md 2024-09-13 23:03:59 +02:00
9f2677485e Merge pull request 'abortPause' (#1) from abortPause into main
Reviewed-on: #1
2024-09-13 22:59:31 +02:00
8aa915e6dc Clear jobs 2024-09-13 22:57:55 +02:00
37c390f911 Pause n abort 2024-09-13 22:43:22 +02:00
2f9552e771 Pause imp 2024-09-13 22:29:03 +02:00
dfe21445e5 Kill downloader process to instantly abort 2024-09-13 22:17:16 +02:00
5397ba0907 Abort (kinda) 2024-09-13 22:15:20 +02:00
8c010665e1 Selectp age 2024-09-13 22:09:00 +02:00
916d3004de Episode Selector 2024-09-13 22:00:44 +02:00
7edf4ed9c5 properly use createtemp for the uploaded files 2024-09-06 19:05:57 +02:00
93d262d293 Sell soul to the css gods 2024-09-06 18:31:42 +02:00
23 changed files with 1618 additions and 333 deletions

40
Makefile Normal file
View File

@ -0,0 +1,40 @@
GOCMD=go
GOBUILD=$(GOCMD) build
GOCLEAN=$(GOCMD) clean
GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get
BINARY_NAME=drmdtool
SRC_DIR=src
all: test build
build:
cd $(SRC_DIR) && $(GOBUILD) -o ../$(BINARY_NAME) -v
test:
cd $(SRC_DIR) && $(GOTEST) -v ./...
clean:
$(GOCLEAN)
rm -f $(BINARY_NAME)
run:
cd $(SRC_DIR) && $(GOBUILD) -o ../$(BINARY_NAME) -v
./$(BINARY_NAME)
deps:
$(GOGET) github.com/BurntSushi/toml
$(GOGET) github.com/beevik/etree
$(GOGET) github.com/asticode/go-astisub
# Cross compilation
build-linux:
cd $(SRC_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o ../$(BINARY_NAME)_linux -v
build-windows:
cd $(SRC_DIR) && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GOBUILD) -o ../$(BINARY_NAME).exe -v
build-mac:
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

View File

@ -9,6 +9,7 @@ Create a `config.toml` file in the same directory as the drmdtool executable:
```toml ```toml
BaseDir = "/path/to/save/downloads" BaseDir = "/path/to/save/downloads"
Format = "mkv" Format = "mkv"
TempBaseDir = "/tmp/nre"
[N_m3u8DL-RE] [N_m3u8DL-RE]
Path = "/path/to/N_m3u8DL-RE" Path = "/path/to/N_m3u8DL-RE"
@ -38,8 +39,17 @@ To process a file directly from the command line:
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.
## TODO
- ~~Filename Sanitation (Makes new directory on /... oops)~~ # Previews
- ~~GoPlay Fix~~
- Windows? ## Index Page
- Proper UI?
![Index Page](images/index.png)
## Select Page
![Select Page](images/select.png)
## Progress Page
![Progress Page](images/progress.png)

View File

@ -1,5 +1,7 @@
BaseDir = "/mnt/media" BaseDir = "/mnt/media"
Format = "mkv" Format = "mkv"
TempBaseDir = "/tmp/nre"
EnableConsole = true
[N_m3u8DLRE] [N_m3u8DLRE]
Path = "nre" Path = "nre"

View File

@ -1,96 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
func handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
progressMutex.Lock()
jobs := make(map[string]*ProgressInfo)
for k, v := range progress {
jobs[k] = v
}
progressMutex.Unlock()
err := templates.ExecuteTemplate(w, "index", struct{ Jobs map[string]*ProgressInfo }{jobs})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func handleUpload(w http.ResponseWriter, r *http.Request) {
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), header.Filename)
filepath := filepath.Join(uploadDir, filename)
newFile, err := os.Create(filepath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer newFile.Close()
_, err = io.Copy(newFile, file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
go func() {
err := processInputFile(filepath)
if err != nil {
fmt.Printf("Error processing file: %v\n", err)
}
os.Remove(filepath)
}()
http.Redirect(w, r, "/progress?filename="+filename, http.StatusSeeOther)
}
func handleProgress(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("filename")
fmt.Printf("Handling progress request for filename: %s\n", filename)
if r.Header.Get("Accept") == "application/json" {
progressInfo := getProgress(filename)
fmt.Printf("Progress info for %s: %+v\n", filename, progressInfo)
if progressInfo == nil {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "No progress information found"})
return
}
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(progressInfo)
if err != nil {
fmt.Printf("Error encoding progress info: %v\n", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
return
}
err := templates.ExecuteTemplate(w, "progress", struct{ Filename string }{filename})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

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

@ -11,9 +11,11 @@ import (
type Config struct { type Config struct {
BaseDir string BaseDir string
Format string Format string
TempBaseDir string
N_m3u8DLRE struct { N_m3u8DLRE struct {
Path string Path string
} }
EnableConsole bool
} }
var config Config var config Config

View File

@ -1,8 +1,8 @@
package main package main
import ( import (
"bytes"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -10,40 +10,9 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
func processInputFile(inputFile string) error {
jsonFile, err := os.Open(inputFile)
if err != nil {
return fmt.Errorf("error opening file %s: %v", inputFile, err)
}
defer jsonFile.Close()
byteValue, err := io.ReadAll(jsonFile)
if err != nil {
return fmt.Errorf("error reading file %s: %v", inputFile, err)
}
byteValue = removeBOM(byteValue)
var items Items
err = json.Unmarshal(byteValue, &items)
if err != nil {
return fmt.Errorf("error unmarshaling JSON: %v", err)
}
for i, item := range items.Items {
updateProgress(filepath.Base(inputFile), float64(i)/float64(len(items.Items))*100, item.Filename)
err := downloadFile(item)
if err != nil {
fmt.Printf("Error downloading file: %v\n", err)
}
}
updateProgress(filepath.Base(inputFile), 100, "")
return nil
}
func removeBOM(input []byte) []byte { func removeBOM(input []byte) []byte {
if len(input) >= 3 && input[0] == 0xEF && input[1] == 0xBB && input[2] == 0xBF { if len(input) >= 3 && input[0] == 0xEF && input[1] == 0xBB && input[2] == 0xBF {
return input[3:] return input[3:]
@ -51,26 +20,39 @@ func removeBOM(input []byte) []byte {
return input return input
} }
func downloadFile(item Item) 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))
err := os.MkdirAll(tempDir, 0755)
if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error creating temporary directory: %v", err))
return fmt.Errorf("error creating temporary directory: %v", err)
}
jobInfo.TempDir = tempDir
mpdPath := item.MPD mpdPath := item.MPD
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)
} }
@ -78,65 +60,111 @@ func downloadFile(item Item) 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)
} }
mpdPath = tempFile.Name() mpdPath = tempFile.Name()
} }
command := getDownloadCommand(item, mpdPath) command := getDownloadCommand(item, mpdPath, tempDir)
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)
jobInfo.Cmd = cmd
cmd.Stdout = os.Stdout var outputBuffer bytes.Buffer
cmd.Stdout = io.MultiWriter(&outputBuffer)
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
err := cmd.Run() err = cmd.Start()
if err != nil { if err != nil {
return fmt.Errorf("error executing download command: %v", err) logger.LogError("Download File", fmt.Sprintf("Error starting download command: %v", err))
return fmt.Errorf("error starting download command: %v", err)
} }
fmt.Println("Download completed successfully") done := make(chan error)
go func() {
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 {
case <-jobInfo.AbortChan:
if cmd.Process != nil {
cmd.Process.Kill()
}
os.RemoveAll(tempDir)
logger.LogInfo("Download File", "Download aborted")
return fmt.Errorf("download aborted")
case err := <-done:
if jobInfo.Paused {
logger.LogInfo("Download File", "Download paused")
return fmt.Errorf("download paused")
}
if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error executing download command: %v", err))
return fmt.Errorf("error executing download command: %v", err)
}
}
logger.LogInfo("Download File", "Download completed successfully")
return nil return nil
} }
func getDownloadCommand(item Item, mpdPath string) string { func getDownloadCommand(item Item, mpdPath string, tempDir string) string {
metadata := parseMetadata(item.Metadata) metadata := parseMetadata(item.Metadata)
keys := getKeys(item.Keys) keys := getKeys(item.Keys)
@ -165,6 +193,8 @@ func getDownloadCommand(item Item, mpdPath string) string {
} }
command += fmt.Sprintf(" --save-dir \"%s\"", saveDir) command += fmt.Sprintf(" --save-dir \"%s\"", saveDir)
command += fmt.Sprintf(" --tmp-dir \"%s\"", tempDir)
fmt.Println(command) fmt.Println(command)
return command return command

View File

@ -11,6 +11,7 @@ require (
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/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/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/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=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

445
src/handlers.go Normal file
View File

@ -0,0 +1,445 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"github.com/gorilla/websocket"
)
type ProgressInfo struct {
Percentage float64
CurrentFile string
Paused bool
}
func handleRoot(w http.ResponseWriter, r *http.Request) {
progressMutex.Lock()
defer progressMutex.Unlock()
jobsInfo := make(map[string]struct {
Percentage float64
CurrentFile string
Paused bool
})
for filename, info := range progress {
jobsInfo[filename] = struct {
Percentage float64
CurrentFile string
Paused bool
}{
Percentage: info.Percentage,
CurrentFile: info.CurrentFile,
Paused: info.Paused,
}
}
err := templates.ExecuteTemplate(w, "index", struct {
Jobs map[string]struct {
Percentage float64
CurrentFile string
Paused bool
}
}{jobsInfo})
if err != nil {
logger.LogError("Handle Root", fmt.Sprintf("Error executing template: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func handleUpload(w http.ResponseWriter, r *http.Request) {
logger.LogInfo("Handle Upload", "Starting file upload")
err := r.ParseMultipartForm(32 << 20)
if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error parsing multipart form: %v", err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
logger.LogError("Handle Upload", "No files uploaded")
http.Error(w, "No files uploaded", http.StatusBadRequest)
return
}
uploadedFiles := []string{}
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error opening file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer file.Close()
tempFile, err := os.CreateTemp(uploadDir, fileHeader.Filename)
if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error creating temporary file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer tempFile.Close()
_, err = io.Copy(tempFile, file)
if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error copying file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
uploadedFiles = append(uploadedFiles, filepath.Base(tempFile.Name()))
_, err = parseInputFile(tempFile.Name())
if err != nil {
logger.LogError("Handle Upload", fmt.Sprintf("Error parsing input file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
validFiles := []string{}
for _, file := range uploadedFiles {
if file != "" {
validFiles = append(validFiles, file)
}
}
if len(validFiles) == 0 {
logger.LogError("Handle Upload", "No valid files were uploaded")
http.Error(w, "No valid files were uploaded", http.StatusBadRequest)
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)
}
func handleSelect(w http.ResponseWriter, r *http.Request) {
filesParam := r.URL.Query().Get("files")
filenames := strings.Split(filesParam, ",")
allItems := make(map[string]map[string][]Item)
for _, filename := range filenames {
if filename == "" {
continue
}
fullPath := filepath.Join(uploadDir, filename)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
logger.LogError("Handle Select", fmt.Sprintf("File does not exist: %s", fullPath))
continue
}
items, err := parseInputFile(fullPath)
if err != nil {
logger.LogError("Handle Select", fmt.Sprintf("Error parsing input file: %v", err))
continue
}
sortItems(items)
groupedItems := groupItemsBySeason(items)
allItems[filename] = groupedItems
}
if len(allItems) == 0 {
logger.LogError("Handle Select", "No valid files were processed")
http.Error(w, "No valid files were processed", http.StatusBadRequest)
return
}
err := templates.ExecuteTemplate(w, "select", struct {
Filenames string
AllItems map[string]map[string][]Item
}{
Filenames: filesParam,
AllItems: allItems,
})
if err != nil {
logger.LogError("Handle Select", fmt.Sprintf("Error executing template: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func handleProcess(w http.ResponseWriter, r *http.Request) {
logger.LogInfo("Handle Process", "Starting process")
if err := r.ParseForm(); err != nil {
logger.LogError("Handle Process", fmt.Sprintf("Error parsing form: %v", err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
selectedItems := r.Form["items"]
if len(selectedItems) == 0 {
logger.LogError("Handle Process", "No items selected")
http.Error(w, "No items selected", http.StatusBadRequest)
return
}
itemsByFile := make(map[string][]string)
for _, item := range selectedItems {
parts := strings.SplitN(item, ":", 2)
if len(parts) != 2 {
logger.LogError("Handle Process", "Invalid item format")
continue
}
filename, itemName := parts[0], parts[1]
itemsByFile[filename] = append(itemsByFile[filename], itemName)
}
for filename, items := range itemsByFile {
logger.LogInfo("Handle Process", fmt.Sprintf("Processing file: %s", filename))
fullPath := filepath.Join(uploadDir, filename)
allItems, err := parseInputFile(fullPath)
if err != nil {
logger.LogError("Handle Process", fmt.Sprintf("Error parsing input file: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
selectedItems := filterSelectedItems(allItems, items)
sortItems(selectedItems)
go processItems(filename, selectedItems)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleProgress(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("filename")
if r.Header.Get("Accept") == "application/json" {
progressInfo := getProgress(filename)
if progressInfo == nil {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "No progress information found"})
return
}
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(progressInfo)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
return
}
err := templates.ExecuteTemplate(w, "progress", struct{ Filename string }{filename})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func handlePause(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("filename")
if filename == "" {
logger.LogError("Pause Handler", "Filename is required")
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
jobsMutex.Lock()
jobInfo, exists := jobs[filename]
jobsMutex.Unlock()
if !exists {
logger.LogError("Pause Handler", "Job not found")
http.Error(w, "Job not found", http.StatusNotFound)
return
}
jobInfo.Paused = true
if jobInfo.Cmd != nil && jobInfo.Cmd.Process != nil {
logger.LogJobState(filename, "pausing")
jobInfo.Cmd.Process.Kill()
}
progressMutex.Lock()
if progressInfo, ok := progress[filename]; ok {
progressInfo.Paused = true
}
progressMutex.Unlock()
fmt.Fprintf(w, "Pause signal sent for %s", filename)
}
func handleResume(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
jobsMutex.Lock()
jobInfo, exists := jobs[filename]
jobsMutex.Unlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
jobInfo.Paused = false
jobInfo.ResumeChan <- struct{}{}
progressMutex.Lock()
if progressInfo, ok := progress[filename]; ok {
progressInfo.Paused = false
}
progressMutex.Unlock()
fmt.Fprintf(w, "Resume signal sent for %s", filename)
}
func handleAbort(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
jobsMutex.Lock()
jobInfo, exists := jobs[filename]
jobsMutex.Unlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
close(jobInfo.AbortChan)
if jobInfo.Cmd != nil && jobInfo.Cmd.Process != nil {
jobInfo.Cmd.Process.Kill()
}
if jobInfo.TempDir != "" {
os.RemoveAll(jobInfo.TempDir)
}
fmt.Fprintf(w, "Abort signal sent for %s", filename)
}
func handleClearCompleted(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clearCompletedJobs()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func clearCompletedJobs() {
progressMutex.Lock()
defer progressMutex.Unlock()
for filename, info := range progress {
if info.Percentage >= 100 {
delete(progress, filename)
}
}
}
func updateProgress(filename string, value float64, currentFile string) {
progressMutex.Lock()
defer progressMutex.Unlock()
jobsMutex.Lock()
jobInfo, exists := jobs[filename]
jobsMutex.Unlock()
paused := false
if exists {
paused = jobInfo.Paused
}
if existingProgress, ok := progress[filename]; ok {
existingProgress.Percentage = value
existingProgress.CurrentFile = currentFile
existingProgress.Paused = paused
} else {
progress[filename] = &ProgressInfo{
Percentage: value,
CurrentFile: currentFile,
Paused: paused,
}
}
}
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
@ -37,11 +39,6 @@ var progress = make(map[string]*ProgressInfo)
const uploadDir = "uploads" const uploadDir = "uploads"
type ProgressInfo struct {
Percentage float64
CurrentFile string
}
var templates *template.Template var templates *template.Template
//go:embed templates //go:embed templates
@ -53,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() {
@ -63,29 +62,31 @@ func main() {
if *inputFile == "" { if *inputFile == "" {
startWebServer() startWebServer()
} else { } else {
processInputFile(*inputFile) items, err := parseInputFile(*inputFile)
if err != nil {
logger.LogError("Main", fmt.Sprintf("Error parsing input file: %v", err))
return
}
processItems(*inputFile, items)
} }
} }
func startWebServer() { func startWebServer() {
http.HandleFunc("/", handleRoot) http.HandleFunc("/", handleRoot)
http.HandleFunc("/upload", handleUpload) http.HandleFunc("/upload", handleUpload)
http.HandleFunc("/select", handleSelect)
http.HandleFunc("/process", handleProcess)
http.HandleFunc("/progress", handleProgress) http.HandleFunc("/progress", handleProgress)
http.HandleFunc("/abort", handleAbort)
http.HandleFunc("/pause", handlePause)
http.HandleFunc("/resume", handleResume)
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)
} }
func updateProgress(filename string, value float64, currentFile string) {
progressMutex.Lock()
defer progressMutex.Unlock()
progress[filename] = &ProgressInfo{
Percentage: value,
CurrentFile: currentFile,
}
fmt.Printf("Progress updated for %s: %.2f%%, Current file: %s\n", filename, value, currentFile)
}
func getProgress(filename string) *ProgressInfo { func getProgress(filename string) *ProgressInfo {
progressMutex.Lock() progressMutex.Lock()
defer progressMutex.Unlock() defer progressMutex.Unlock()

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
} }

152
src/templates/index Normal file
View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Downloader</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #1e1e1e;
color: #d4d4d4;
line-height: 1.6;
padding: 20px;
max-width: 900px;
margin: 0 auto;
box-sizing: border-box;
}
h1, h2 {
border-bottom: 1px solid #333;
padding-bottom: 10px;
word-wrap: break-word;
}
form {
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
input[type="file"], input[type="submit"] {
background-color: #2d2d2d;
color: #d4d4d4;
border: 1px solid #444;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 10px;
max-width: 100%;
}
input[type="submit"] {
cursor: pointer;
background-color: #4CAF50;
color: white;
}
input[type="submit"]:hover {
background-color: #45a049;
}
ul {
list-style-type: none;
padding: 0;
margin-bottom: 10px;
}
li {
background-color: #2d2d2d;
margin-bottom: 10px;
padding: 10px;
border-radius: 4px;
word-wrap: break-word;
}
.job-title {
font-size: 1.1em;
font-weight: bold;
margin-bottom: 5px;
}
.job-title a {
color: #58a6ff;
text-decoration: none;
}
.job-title a:hover {
text-decoration: underline;
}
.job-info {
font-size: 0.9em;
color: #a0a0a0;
}
.progress-text {
display: inline-block;
width: 5em;
}
.paused {
color: #ffa500;
}
@media (max-width: 600px) {
body {
padding: 10px;
}
h1, h2 {
font-size: 1.5em;
}
input[type="file"], input[type="submit"] {
font-size: 16px;
}
input[type="submit"], #clear-completed {
font-size: 16px;
}
}
input[type="submit"], #clear-completed {
cursor: pointer;
color: white;
border: 1px solid #444;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 10px;
max-width: 100%;
width: 100%;
}
#clear-completed {
background-color: #f44336;
}
#clear-completed:hover {
background-color: #d32f2f;
}
</style>
</head>
<body>
<h1>Simple Downloader</h1>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="files" accept=".drmd" multiple>
<input type="submit" value="Upload and Process">
</form>
<h2>Currently Running Jobs</h2>
<ul>
{{range $filename, $info := .Jobs}}
<li>
<div class="job-title">
<a href="/progress?filename={{$filename}}">{{$filename}}</a>
</div>
<div class="job-info">
Progress: <span class="progress-text">{{printf "%5.1f%%" $info.Percentage}}</span>
Current file: {{$info.CurrentFile}}
{{if $info.Paused}}
<span class="paused">(Paused)</span>
{{end}}
</div>
</li>
{{else}}
<li>No active jobs</li>
{{end}}
</ul>
<button id="clear-completed" onclick="clearCompleted()">Clear Completed Jobs</button>
<script>
function clearCompleted() {
fetch('/clear-completed', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Failed to clear completed jobs');
}
});
}
</script>
</body>
</html>

239
src/templates/progress Normal file
View File

@ -0,0 +1,239 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Processing {{.Filename}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #1e1e1e;
color: #d4d4d4;
line-height: 1.6;
padding: 20px;
max-width: 900px;
margin: 0 auto;
box-sizing: border-box;
}
h1 {
border-bottom: 1px solid #333;
padding-bottom: 10px;
word-wrap: break-word;
}
#progress-container {
background-color: #2d2d2d;
border-radius: 4px;
margin-bottom: 20px;
padding: 20px;
}
#progress-bar-container {
background-color: #444;
height: 20px;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
position: relative;
}
#progress-bar {
background-color: #4CAF50;
height: 100%;
width: 0;
transition: width 0.5s ease-in-out;
}
#progress-text {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
text-align: center;
color: #fff;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
line-height: 20px;
}
#currentFile {
margin-top: 10px;
word-wrap: break-word;
}
#abort-button {
background-color: #f44336;
color: white;
border: none;
padding: 10px 15px;
margin-top: 10px;
border-radius: 4px;
cursor: pointer;
}
#abort-button:hover {
background-color: #d32f2f;
}
#pause-button, #resume-button, #toggle-console {
background-color: #4CAF50;
color: white;
border: none;
padding: 10px 15px;
margin-top: 10px;
border-radius: 4px;
cursor: pointer;
}
#pause-button:hover, #resume-button:hover, #toggle-console:hover {
background-color: #45a049;
}
#resume-button {
display: none;
}
#back-button {
background-color: #2196F3;
color: white;
border: none;
padding: 10px 15px;
margin-top: 10px;
border-radius: 4px;
cursor: pointer;
float: right;
}
#back-button:hover {
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) {
body {
padding: 10px;
}
h1 {
font-size: 1.5em;
}
#progress-container {
padding: 10px;
}
#progress-bar-container {
height: 15px;
}
#progress-text {
font-size: 0.9em;
}
}
</style>
</head>
<body>
<h1>Processing {{.Filename}}</h1>
<div id="progress-container">
<div id="progress-bar-container">
<div id="progress-bar"></div>
<div id="progress-text">0%</div>
</div>
<div id="currentFile"></div>
</div>
<div>
<button id="abort-button" onclick="abortDownload()">Abort 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="toggle-console">Toggle Console View</button>
<button id="back-button" onclick="window.location.href='/'">Back to Index</button>
</div>
<div style="display: none;" id="console"></div>
<script>
let isPaused = false;
const filename = "{{.Filename}}";
function updateProgress() {
fetch(`/progress?filename=${filename}`, {
headers: {
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
const progress = Math.round(data.Percentage);
document.getElementById('progress-bar').style.width = progress + '%';
document.getElementById('progress-text').innerText = progress + '%';
document.getElementById('currentFile').innerText = 'Current file: ' + (data.CurrentFile || 'None');
isPaused = data.Paused;
updatePauseResumeButtons();
if (progress < 100 && !isPaused) {
setTimeout(updateProgress, 1000);
}
});
}
function updatePauseResumeButtons() {
if (isPaused) {
document.getElementById('pause-button').style.display = 'none';
document.getElementById('resume-button').style.display = 'inline-block';
} else {
document.getElementById('pause-button').style.display = 'inline-block';
document.getElementById('resume-button').style.display = 'none';
}
}
function abortDownload() {
fetch(`/abort?filename=${filename}`, { method: 'POST' })
.then(response => {
if (response.ok) {
console.log('Abort signal sent. The download will stop soon.');
} else {
alert('Failed to abort the download.');
}
});
}
function pauseDownload() {
fetch(`/pause?filename=${filename}`, { method: 'POST' })
.then(response => {
if (response.ok) {
console.log('Pause signal sent. The download will pause soon.');
isPaused = true;
updatePauseResumeButtons();
} else {
alert('Failed to pause the download.');
}
});
}
function resumeDownload() {
fetch(`/resume?filename=${filename}`, { method: 'POST' })
.then(response => {
if (response.ok) {
console.log('Resume signal sent. The download will resume soon.');
isPaused = false;
updatePauseResumeButtons();
updateProgress();
} else {
alert('Failed to resume the download.');
}
});
}
const consoleDiv = document.getElementById('console');
const ws = new WebSocket(`ws://${window.location.host}/ws?filename=${filename}`);
ws.onmessage = function(event) {
consoleDiv.textContent += event.data;
consoleDiv.scrollTop = consoleDiv.scrollHeight;
};
document.getElementById('toggle-console').onclick = function() {
if (consoleDiv.style.display === "none") {
consoleDiv.style.display = "block";
} else {
consoleDiv.style.display = "none";
}
};
updateProgress();
</script>
</body>
</html>

119
src/templates/select Normal file
View File

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select Items to Download</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #1e1e1e;
color: #d4d4d4;
line-height: 1.6;
padding: 20px;
max-width: 900px;
margin: 0 auto;
box-sizing: border-box;
}
h1 {
border-bottom: 1px solid #333;
padding-bottom: 10px;
}
.season {
margin-bottom: 20px;
background-color: #2d2d2d;
padding: 10px;
border-radius: 4px;
}
.season-title {
font-size: 1.2em;
font-weight: bold;
margin-bottom: 10px;
display: flex;
align-items: center;
}
.season-checkbox {
margin-right: 10px;
}
.item {
margin-left: 20px;
}
button, input[type="submit"] {
background-color: #4CAF50;
color: white;
border: none;
padding: 10px 15px;
margin: 5px;
border-radius: 4px;
cursor: pointer;
}
button:hover, input[type="submit"]:hover {
background-color: #45a049;
}
#fix-order-button {
background-color: #2196F3;
color: white;
border: none;
padding: 10px 15px;
margin: 5px;
border-radius: 4px;
cursor: pointer;
}
#fix-order-button:hover {
background-color: #1976D2;
}
</style>
</head>
<body>
<h1>Select Items to Download</h1>
<form action="/process" method="post">
<input type="hidden" name="filenames" value="{{.Filenames}}">
{{range $filename, $fileItems := .AllItems}}
<h2>{{$filename}}</h2>
{{range $season, $items := $fileItems}}
<div class="season" id="season-{{$filename}}-{{$season}}">
<div class="season-title">
<input type="checkbox" class="season-checkbox" id="season-checkbox-{{$filename}}-{{$season}}" checked onchange="toggleSeason('{{$filename}}-{{$season}}')">
<label for="season-checkbox-{{$filename}}-{{$season}}">{{$season}}</label>
</div>
<div class="season-items">
{{range $item := $items}}
<div class="item">
<label>
<input type="checkbox" name="items" value="{{$filename}}:{{$item.Filename}}" checked class="episode-{{$filename}}-{{$season}}">
{{$item.Filename}}
</label>
</div>
{{end}}
</div>
</div>
{{end}}
{{end}}
<div>
<button type="button" onclick="selectAll(true)">Select All</button>
<button type="button" onclick="selectAll(false)">Select None</button>
<input type="submit" value="Start Download">
</div>
</form>
<script>
function selectAll(checked) {
var checkboxes = document.getElementsByName('items');
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = checked;
}
var seasonCheckboxes = document.getElementsByClassName('season-checkbox');
for (var i = 0; i < seasonCheckboxes.length; i++) {
seasonCheckboxes[i].checked = checked;
}
}
function toggleSeason(season) {
var seasonCheckbox = document.getElementById('season-checkbox-' + season);
var episodeCheckboxes = document.getElementsByClassName('episode-' + season);
for (var i = 0; i < episodeCheckboxes.length; i++) {
episodeCheckboxes[i].checked = seasonCheckbox.checked;
}
}
</script>
</body>
</html>

335
src/utils.go Normal file
View File

@ -0,0 +1,335 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"github.com/beevik/etree"
)
type JobInfo struct {
AbortChan chan struct{}
ResumeChan chan struct{}
Cmd *exec.Cmd
Paused bool
TempDir string
}
var (
jobsMutex sync.Mutex
jobs = make(map[string]*JobInfo)
)
func sanitizeFilename(filename string) string {
filename = regexp.MustCompile(`[<>:"/\\|?*]`).ReplaceAllString(filename, "_")
filename = strings.Trim(filename, ".")
return filename
}
func isValidURL(toTest string) bool {
_, err := url.ParseRequestURI(toTest)
return err == nil
}
func fixGoPlay(mpdContent string) (string, error) {
doc := etree.NewDocument()
if err := doc.ReadFromString(mpdContent); err != nil {
return "", fmt.Errorf("error parsing MPD content: %v", err)
}
root := doc.Root()
// Remove ad periods
for _, period := range root.SelectElements("Period") {
if strings.Contains(period.SelectAttrValue("id", ""), "-ad-") {
root.RemoveChild(period)
}
}
// Find highest bandwidth for video
highestBandwidth := 0
for _, adaptationSet := range root.FindElements("//AdaptationSet") {
if strings.Contains(adaptationSet.SelectAttrValue("mimeType", ""), "video") {
for _, representation := range adaptationSet.SelectElements("Representation") {
bandwidth, _ := strconv.Atoi(representation.SelectAttrValue("bandwidth", "0"))
if bandwidth > highestBandwidth {
highestBandwidth = bandwidth
}
}
}
}
// Remove lower bitrate representations
for _, adaptationSet := range root.FindElements("//AdaptationSet") {
if strings.Contains(adaptationSet.SelectAttrValue("mimeType", ""), "video") {
for _, representation := range adaptationSet.SelectElements("Representation") {
bandwidth, _ := strconv.Atoi(representation.SelectAttrValue("bandwidth", "0"))
if bandwidth != highestBandwidth {
adaptationSet.RemoveChild(representation)
}
}
}
}
// Combine periods
periods := root.SelectElements("Period")
if len(periods) > 1 {
firstPeriod := periods[0]
var newVideoTimeline, newAudioTimeline *etree.Element
// Find or create SegmentTimeline elements
for _, adaptationSet := range firstPeriod.SelectElements("AdaptationSet") {
mimeType := adaptationSet.SelectAttrValue("mimeType", "")
if strings.Contains(mimeType, "video") && newVideoTimeline == nil {
newVideoTimeline = findOrCreateSegmentTimeline(adaptationSet)
} else if strings.Contains(mimeType, "audio") && newAudioTimeline == nil {
newAudioTimeline = findOrCreateSegmentTimeline(adaptationSet)
}
}
for _, period := range periods[1:] {
for _, adaptationSet := range period.SelectElements("AdaptationSet") {
mimeType := adaptationSet.SelectAttrValue("mimeType", "")
var timeline *etree.Element
if strings.Contains(mimeType, "video") {
timeline = newVideoTimeline
} else if strings.Contains(mimeType, "audio") {
timeline = newAudioTimeline
}
if timeline != nil {
segmentTimeline := findOrCreateSegmentTimeline(adaptationSet)
for _, s := range segmentTimeline.SelectElements("S") {
timeline.AddChild(s.Copy())
}
}
}
root.RemoveChild(period)
}
}
return doc.WriteToString()
}
func findOrCreateSegmentTimeline(adaptationSet *etree.Element) *etree.Element {
for _, representation := range adaptationSet.SelectElements("Representation") {
for _, segmentTemplate := range representation.SelectElements("SegmentTemplate") {
timeline := segmentTemplate.SelectElement("SegmentTimeline")
if timeline != nil {
return timeline
}
}
}
// If no SegmentTimeline found, create one
representation := adaptationSet.CreateElement("Representation")
segmentTemplate := representation.CreateElement("SegmentTemplate")
return segmentTemplate.CreateElement("SegmentTimeline")
}
func parseInputFile(filename string) ([]Item, error) {
fileInfo, err := os.Stat(filename)
if err != nil {
return nil, err
}
if fileInfo.IsDir() {
return nil, fmt.Errorf("%s is a directory", filename)
}
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
byteValue, err := io.ReadAll(file)
if err != nil {
return nil, err
}
byteValue = removeBOM(byteValue)
var items Items
err = json.Unmarshal(byteValue, &items)
if err != nil {
return nil, err
}
return items.Items, nil
}
func groupItemsBySeason(items []Item) map[string][]Item {
grouped := make(map[string][]Item)
for _, item := range items {
metadata := parseMetadata(item.Metadata)
if metadata.Type == "serie" {
key := fmt.Sprintf("%s - %s", metadata.Title, metadata.Season)
grouped[key] = append(grouped[key], item)
} else {
grouped["Movies"] = append(grouped["Movies"], item)
}
}
return grouped
}
func filterSelectedItems(items []Item, selectedItems []string) []Item {
var filtered []Item
for _, item := range items {
for _, selected := range selectedItems {
if item.Filename == selected {
filtered = append(filtered, item)
break
}
}
}
return filtered
}
func sortItems(items []Item) {
sort.Slice(items, func(i, j int) bool {
iMeta := parseMetadata(items[i].Metadata)
jMeta := parseMetadata(items[j].Metadata)
if iMeta.Title != jMeta.Title {
return iMeta.Title < jMeta.Title
}
iSeason := extractNumber(iMeta.Season)
jSeason := extractNumber(jMeta.Season)
if iSeason != jSeason {
return iSeason < jSeason
}
iEpisode := extractEpisodeNumber(items[i].Filename)
jEpisode := extractEpisodeNumber(items[j].Filename)
return iEpisode < jEpisode
})
}
func extractNumber(s string) int {
num, _ := strconv.Atoi(strings.TrimLeft(s, "S"))
return num
}
func extractEpisodeNumber(filename string) int {
parts := strings.Split(filename, "E")
if len(parts) > 1 {
num, _ := strconv.Atoi(parts[1])
return num
}
return 0
}
func processItems(filename string, items []Item) error {
jobsMutex.Lock()
jobInfo := &JobInfo{
AbortChan: make(chan struct{}),
ResumeChan: make(chan struct{}),
}
jobs[filename] = jobInfo
jobsMutex.Unlock()
defer func() {
jobsMutex.Lock()
delete(jobs, filename)
jobsMutex.Unlock()
if jobInfo.TempDir != "" {
os.RemoveAll(jobInfo.TempDir)
}
}()
sortItems(items)
for i := 0; i < len(items); i++ {
select {
case <-jobInfo.AbortChan:
updateProgress(filename, 100, "Aborted")
logger.LogJobState(filename, "aborted")
return fmt.Errorf("download aborted")
default:
if jobInfo.Paused {
select {
case <-jobInfo.ResumeChan:
jobInfo.Paused = false
logger.LogJobState(filename, "resumed")
case <-jobInfo.AbortChan:
updateProgress(filename, 100, "Aborted")
logger.LogJobState(filename, "aborted")
return fmt.Errorf("download aborted")
}
}
updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename)
err := downloadFile(filename, items[i], jobInfo)
if err != nil {
if err.Error() == "download paused" {
logger.LogJobState(filename, "paused")
removeCompletedEpisodes(filename, items[:i])
i--
continue
}
}
}
}
updateProgress(filename, 100, "")
logger.LogJobState(filename, "completed successfully")
return nil
}
func removeCompletedEpisodes(filename string, completedItems []Item) error {
inputFile := filepath.Join(uploadDir, filename)
items, err := parseInputFile(inputFile)
if err != nil {
return fmt.Errorf("error parsing input file: %v", err)
}
remainingItems := make([]Item, 0)
for _, item := range items {
if !isItemCompleted(item, completedItems) || isLastCompletedItem(item, completedItems) {
remainingItems = append(remainingItems, item)
}
}
updatedItems := Items{Items: remainingItems}
jsonData, err := json.MarshalIndent(updatedItems, "", " ")
if err != nil {
return fmt.Errorf("error marshaling updated items: %v", err)
}
err = os.WriteFile(inputFile, jsonData, 0644)
if err != nil {
return fmt.Errorf("error writing updated DRMD file: %v", err)
}
return nil
}
func isItemCompleted(item Item, completedItems []Item) bool {
for _, completedItem := range completedItems {
if item.Filename == completedItem.Filename {
return true
}
}
return false
}
func isLastCompletedItem(item Item, completedItems []Item) bool {
if len(completedItems) == 0 {
return false
}
return item.Filename == completedItems[len(completedItems)-1].Filename
}

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<h1>Simple Downloader</h1>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept=".drmd">
<input type="submit" value="Upload and Process">
</form>
<h2>Currently Running Jobs</h2>
<ul>
{{range $filename, $info := .Jobs}}
<li>
<a href="/progress?filename={{$filename}}">{{$filename}}</a>:
{{printf "%.2f%%" $info.Percentage}}
(Current file: {{$info.CurrentFile}})
</li>
{{else}}
<li>No active jobs</li>
{{end}}
</ul>
</body>
</html>

View File

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<h1>Processing {{.Filename}}</h1>
<div id="progress">0%</div>
<div id="currentFile"></div>
<script>
function updateProgress() {
fetch('/progress?filename={{.Filename}}', {
headers: {
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
const progress = Math.round(data.Percentage);
document.getElementById('progress').innerText = progress + '%';
document.getElementById('currentFile').innerText = 'Current file: ' + (data.CurrentFile || 'None');
if (progress < 100) {
setTimeout(updateProgress, 1000);
}
});
}
updateProgress();
</script>
</body>
</html>

120
utils.go
View File

@ -1,120 +0,0 @@
package main
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/beevik/etree"
)
func sanitizeFilename(filename string) string {
filename = regexp.MustCompile(`[<>:"/\\|?*]`).ReplaceAllString(filename, "_")
filename = strings.Trim(filename, ".")
return filename
}
func isValidURL(toTest string) bool {
_, err := url.ParseRequestURI(toTest)
return err == nil
}
func fixGoPlay(mpdContent string) (string, error) {
doc := etree.NewDocument()
if err := doc.ReadFromString(mpdContent); err != nil {
return "", fmt.Errorf("error parsing MPD content: %v", err)
}
root := doc.Root()
// Remove ad periods
for _, period := range root.SelectElements("Period") {
if strings.Contains(period.SelectAttrValue("id", ""), "-ad-") {
root.RemoveChild(period)
}
}
// Find highest bandwidth for video
highestBandwidth := 0
for _, adaptationSet := range root.FindElements("//AdaptationSet") {
if strings.Contains(adaptationSet.SelectAttrValue("mimeType", ""), "video") {
for _, representation := range adaptationSet.SelectElements("Representation") {
bandwidth, _ := strconv.Atoi(representation.SelectAttrValue("bandwidth", "0"))
if bandwidth > highestBandwidth {
highestBandwidth = bandwidth
}
}
}
}
// Remove lower bitrate representations
for _, adaptationSet := range root.FindElements("//AdaptationSet") {
if strings.Contains(adaptationSet.SelectAttrValue("mimeType", ""), "video") {
for _, representation := range adaptationSet.SelectElements("Representation") {
bandwidth, _ := strconv.Atoi(representation.SelectAttrValue("bandwidth", "0"))
if bandwidth != highestBandwidth {
adaptationSet.RemoveChild(representation)
}
}
}
}
// Combine periods
periods := root.SelectElements("Period")
if len(periods) > 1 {
firstPeriod := periods[0]
var newVideoTimeline, newAudioTimeline *etree.Element
// Find or create SegmentTimeline elements
for _, adaptationSet := range firstPeriod.SelectElements("AdaptationSet") {
mimeType := adaptationSet.SelectAttrValue("mimeType", "")
if strings.Contains(mimeType, "video") && newVideoTimeline == nil {
newVideoTimeline = findOrCreateSegmentTimeline(adaptationSet)
} else if strings.Contains(mimeType, "audio") && newAudioTimeline == nil {
newAudioTimeline = findOrCreateSegmentTimeline(adaptationSet)
}
}
for _, period := range periods[1:] {
for _, adaptationSet := range period.SelectElements("AdaptationSet") {
mimeType := adaptationSet.SelectAttrValue("mimeType", "")
var timeline *etree.Element
if strings.Contains(mimeType, "video") {
timeline = newVideoTimeline
} else if strings.Contains(mimeType, "audio") {
timeline = newAudioTimeline
}
if timeline != nil {
segmentTimeline := findOrCreateSegmentTimeline(adaptationSet)
for _, s := range segmentTimeline.SelectElements("S") {
timeline.AddChild(s.Copy())
}
}
}
root.RemoveChild(period)
}
}
return doc.WriteToString()
}
func findOrCreateSegmentTimeline(adaptationSet *etree.Element) *etree.Element {
for _, representation := range adaptationSet.SelectElements("Representation") {
for _, segmentTemplate := range representation.SelectElements("SegmentTemplate") {
timeline := segmentTemplate.SelectElement("SegmentTimeline")
if timeline != nil {
return timeline
}
}
}
// If no SegmentTimeline found, create one
representation := adaptationSet.CreateElement("Representation")
segmentTemplate := representation.CreateElement("SegmentTemplate")
return segmentTemplate.CreateElement("SegmentTimeline")
}