Compare commits
	
		
			26 Commits
		
	
	
		
			v1.0.0
			...
			551e53ad63
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 551e53ad63 | |||
| 707de8fcf1 | |||
| 64a6eb20a0 | |||
| 67b17c1df7 | |||
| 142c09e624 | |||
| 0b3797dc19 | |||
| d4dae21d8f | |||
| 9f2677485e | |||
| 8aa915e6dc | |||
| 37c390f911 | |||
| 2f9552e771 | |||
| dfe21445e5 | |||
| 5397ba0907 | |||
| 8c010665e1 | |||
| 916d3004de | |||
| 7edf4ed9c5 | |||
| 93d262d293 | |||
| 3872c1c4ca | |||
| 077f1efb6f | |||
| 4cbd64f263 | |||
| 078d0c774b | |||
| 1b0a8fde36 | |||
| f014b06f88 | |||
| 6b192aadc9 | |||
| 6b24eb7e07 | |||
| b8c7234ed9 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | config.toml | ||||||
| @@ -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" | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								config.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								config.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | package main | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"os" | ||||||
|  |  | ||||||
|  | 	"github.com/BurntSushi/toml" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type Config struct { | ||||||
|  | 	BaseDir     string | ||||||
|  | 	Format      string | ||||||
|  | 	TempBaseDir string | ||||||
|  | 	N_m3u8DLRE  struct { | ||||||
|  | 		Path string | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | var config Config | ||||||
|  |  | ||||||
|  | func loadConfig() { | ||||||
|  | 	configFile, err := os.Open("config.toml") | ||||||
|  | 	if err != nil { | ||||||
|  | 		fmt.Println("Error opening config file:", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	defer configFile.Close() | ||||||
|  |  | ||||||
|  | 	byteValue, _ := io.ReadAll(configFile) | ||||||
|  |  | ||||||
|  | 	if _, err := toml.Decode(string(byteValue), &config); err != nil { | ||||||
|  | 		fmt.Println("Error decoding config file:", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if config.N_m3u8DLRE.Path == "" { | ||||||
|  | 		fmt.Println("Error: N_m3u8DL-RE path is not specified in the config file") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| BaseDir = "/mnt/media" | BaseDir = "/mnt/media" | ||||||
| Format = "mkv"  | Format = "mkv"  | ||||||
|  | TempBaseDir = "/tmp/nre" | ||||||
|  |  | ||||||
| [N_m3u8DLRE] | [N_m3u8DLRE] | ||||||
| Path = "nre" | Path = "nre" | ||||||
|   | |||||||
							
								
								
									
										174
									
								
								downloaders.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								downloaders.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | |||||||
|  | package main | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/base64" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"os" | ||||||
|  | 	"os/exec" | ||||||
|  | 	"path/filepath" | ||||||
|  | 	"strings" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func removeBOM(input []byte) []byte { | ||||||
|  | 	if len(input) >= 3 && input[0] == 0xEF && input[1] == 0xBB && input[2] == 0xBF { | ||||||
|  | 		return input[3:] | ||||||
|  | 	} | ||||||
|  | 	return input | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func downloadFile(item Item, jobInfo *JobInfo) error { | ||||||
|  | 	fmt.Println("Downloading:", item.Filename) | ||||||
|  |  | ||||||
|  | 	tempDir := filepath.Join(config.TempBaseDir, sanitizeFilename(item.Filename)) | ||||||
|  | 	err := os.MkdirAll(tempDir, 0755) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error creating temporary directory: %v", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	jobInfo.TempDir = tempDir | ||||||
|  |  | ||||||
|  | 	mpdPath := item.MPD | ||||||
|  | 	if !isValidURL(item.MPD) { | ||||||
|  | 		decodedMPD, err := base64.StdEncoding.DecodeString(item.MPD) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("error decoding base64 MPD: %v", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		tempFile, err := os.CreateTemp("", "temp_mpd_*.mpd") | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("error creating temporary MPD file: %v", err) | ||||||
|  | 		} | ||||||
|  | 		defer os.Remove(tempFile.Name()) | ||||||
|  |  | ||||||
|  | 		if _, err := tempFile.Write(decodedMPD); err != nil { | ||||||
|  | 			return fmt.Errorf("error writing to temporary MPD file: %v", err) | ||||||
|  | 		} | ||||||
|  | 		if err := tempFile.Close(); err != nil { | ||||||
|  | 			return fmt.Errorf("error closing temporary MPD file: %v", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		mpdPath = tempFile.Name() | ||||||
|  | 	} else if strings.HasPrefix(item.MPD, "https://pubads.g.doubleclick.net") { | ||||||
|  | 		resp, err := http.Get(item.MPD) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("error downloading MPD: %v", err) | ||||||
|  | 		} | ||||||
|  | 		defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 		mpdContent, err := io.ReadAll(resp.Body) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("error reading MPD content: %v", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		fixedMPDContent, err := fixGoPlay(string(mpdContent)) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("error fixing MPD content: %v", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		tempFile, err := os.CreateTemp("", "fixed_mpd_*.mpd") | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("error creating temporary MPD file: %v", err) | ||||||
|  | 		} | ||||||
|  | 		defer os.Remove(tempFile.Name()) | ||||||
|  |  | ||||||
|  | 		if _, err := tempFile.WriteString(fixedMPDContent); err != nil { | ||||||
|  | 			return fmt.Errorf("error writing to temporary MPD file: %v", err) | ||||||
|  | 		} | ||||||
|  | 		if err := tempFile.Close(); err != nil { | ||||||
|  | 			return fmt.Errorf("error closing temporary MPD file: %v", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		mpdPath = tempFile.Name() | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	command := getDownloadCommand(item, mpdPath, tempDir) | ||||||
|  |  | ||||||
|  | 	if item.Subtitles != "" { | ||||||
|  | 		subtitlePaths, err := downloadAndConvertSubtitles(item.Subtitles) | ||||||
|  | 		if err != nil { | ||||||
|  | 			fmt.Printf("Error processing subtitles: %v\n", err) | ||||||
|  | 		} else { | ||||||
|  | 			for _, path := range subtitlePaths { | ||||||
|  | 				fmt.Println("Adding subtitle:", path) | ||||||
|  | 				command += fmt.Sprintf(" --mux-import \"path=%s:lang=nl:name=Nederlands\"", path) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	cmd := exec.Command("bash", "-c", command) | ||||||
|  |  | ||||||
|  | 	jobsMutex.Lock() | ||||||
|  | 	jobInfo.Cmd = cmd | ||||||
|  | 	jobsMutex.Unlock() | ||||||
|  |  | ||||||
|  | 	cmd.Stdout = os.Stdout | ||||||
|  | 	cmd.Stderr = os.Stderr | ||||||
|  |  | ||||||
|  | 	err = cmd.Start() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error starting download command: %v", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	done := make(chan error) | ||||||
|  | 	go func() { | ||||||
|  | 		done <- cmd.Wait() | ||||||
|  | 	}() | ||||||
|  |  | ||||||
|  | 	select { | ||||||
|  | 	case <-jobInfo.AbortChan: | ||||||
|  | 		if cmd.Process != nil { | ||||||
|  | 			cmd.Process.Kill() | ||||||
|  | 		} | ||||||
|  | 		os.RemoveAll(tempDir) | ||||||
|  | 		return fmt.Errorf("download aborted") | ||||||
|  | 	case err := <-done: | ||||||
|  | 		if jobInfo.Paused { | ||||||
|  | 			return fmt.Errorf("download paused") | ||||||
|  | 		} | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("error executing download command: %v", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	fmt.Println("Download completed successfully") | ||||||
|  | 	os.RemoveAll(tempDir) | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func getDownloadCommand(item Item, mpdPath string, tempDir string) string { | ||||||
|  | 	metadata := parseMetadata(item.Metadata) | ||||||
|  | 	keys := getKeys(item.Keys) | ||||||
|  |  | ||||||
|  | 	command := fmt.Sprintf("%s %s", config.N_m3u8DLRE.Path, mpdPath) | ||||||
|  |  | ||||||
|  | 	for _, key := range keys { | ||||||
|  | 		if key != "" { | ||||||
|  | 			command += fmt.Sprintf(" --key %s", key) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	command += " --auto-select" | ||||||
|  |  | ||||||
|  | 	sanitizedFilename := sanitizeFilename(item.Filename) | ||||||
|  |  | ||||||
|  | 	filename := fmt.Sprintf("\"%s\"", sanitizedFilename) | ||||||
|  | 	command += fmt.Sprintf(" --save-name %s", filename) | ||||||
|  |  | ||||||
|  | 	command += fmt.Sprintf(" --mux-after-done format=%s", config.Format) | ||||||
|  |  | ||||||
|  | 	saveDir := config.BaseDir | ||||||
|  | 	if metadata.Type == "serie" { | ||||||
|  | 		saveDir = filepath.Join(saveDir, "Series", metadata.Title, metadata.Season) | ||||||
|  | 	} else { | ||||||
|  | 		saveDir = filepath.Join(saveDir, "Movies", metadata.Title) | ||||||
|  | 	} | ||||||
|  | 	command += fmt.Sprintf(" --save-dir \"%s\"", saveDir) | ||||||
|  |  | ||||||
|  | 	command += fmt.Sprintf(" --tmp-dir \"%s\"", tempDir) | ||||||
|  |  | ||||||
|  | 	fmt.Println(command) | ||||||
|  |  | ||||||
|  | 	return command | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								go.mod
									
									
									
									
									
								
							| @@ -2,4 +2,15 @@ module DRMDTool | |||||||
|  |  | ||||||
| go 1.23.0 | go 1.23.0 | ||||||
|  |  | ||||||
| require github.com/BurntSushi/toml v1.4.0 | require ( | ||||||
|  | 	github.com/BurntSushi/toml v1.4.0 | ||||||
|  | 	github.com/asticode/go-astisub v0.26.2 | ||||||
|  | 	github.com/beevik/etree v1.4.1 | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | require ( | ||||||
|  | 	github.com/asticode/go-astikit v0.20.0 // indirect | ||||||
|  | 	github.com/asticode/go-astits v1.8.0 // indirect | ||||||
|  | 	golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect | ||||||
|  | 	golang.org/x/text v0.3.2 // indirect | ||||||
|  | ) | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,2 +1,35 @@ | |||||||
| github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= | ||||||
| github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | ||||||
|  | github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8= | ||||||
|  | github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= | ||||||
|  | github.com/asticode/go-astisub v0.26.2 h1:cdEXcm+SUSmYCEPTQYbbfCECnmQoIFfH6pF8wDJhfVo= | ||||||
|  | github.com/asticode/go-astisub v0.26.2/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= | ||||||
|  | github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg= | ||||||
|  | github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= | ||||||
|  | github.com/beevik/etree v1.4.1 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI= | ||||||
|  | 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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||||
|  | 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||||
|  | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||||
|  | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||||
|  | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= | ||||||
|  | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= | ||||||
|  | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||||
|  | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||||||
|  | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||||||
|  | golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA= | ||||||
|  | golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= | ||||||
|  | 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-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
|  | 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/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | ||||||
|  | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||||
|  | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||||
|  | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= | ||||||
|  | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||||
|   | |||||||
							
								
								
									
										353
									
								
								handlers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								handlers.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,353 @@ | |||||||
|  | package main | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"os" | ||||||
|  | 	"path/filepath" | ||||||
|  | 	"strings" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func handleUpload(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	err := r.ParseMultipartForm(32 << 20) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	files := r.MultipartForm.File["files"] | ||||||
|  | 	if len(files) == 0 { | ||||||
|  | 		http.Error(w, "No files uploaded", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	uploadedFiles := []string{} | ||||||
|  |  | ||||||
|  | 	for _, fileHeader := range files { | ||||||
|  | 		file, err := fileHeader.Open() | ||||||
|  | 		if err != nil { | ||||||
|  | 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		defer file.Close() | ||||||
|  |  | ||||||
|  | 		dst, err := os.Create(filepath.Join(uploadDir, fileHeader.Filename)) | ||||||
|  | 		if err != nil { | ||||||
|  | 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		defer dst.Close() | ||||||
|  |  | ||||||
|  | 		_, err = io.Copy(dst, file) | ||||||
|  | 		if err != nil { | ||||||
|  | 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		uploadedFiles = append(uploadedFiles, fileHeader.Filename) | ||||||
|  |  | ||||||
|  | 		_, err = parseInputFile(dst.Name()) | ||||||
|  | 		if err != nil { | ||||||
|  | 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	validFiles := []string{} | ||||||
|  | 	for _, file := range uploadedFiles { | ||||||
|  | 		if file != "" { | ||||||
|  | 			validFiles = append(validFiles, file) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if len(validFiles) == 0 { | ||||||
|  | 		http.Error(w, "No valid files were uploaded", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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) { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		items, err := parseInputFile(fullPath) | ||||||
|  | 		if err != nil { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		groupedItems := groupItemsBySeason(items) | ||||||
|  | 		allItems[filename] = groupedItems | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if len(allItems) == 0 { | ||||||
|  | 		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 { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func handleProcess(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	if err := r.ParseForm(); err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	selectedItems := r.Form["items"] | ||||||
|  | 	if len(selectedItems) == 0 { | ||||||
|  | 		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 { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		filename, itemName := parts[0], parts[1] | ||||||
|  | 		itemsByFile[filename] = append(itemsByFile[filename], itemName) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for filename, items := range itemsByFile { | ||||||
|  | 		fullPath := filepath.Join(uploadDir, filename) | ||||||
|  | 		allItems, err := parseInputFile(fullPath) | ||||||
|  | 		if err != nil { | ||||||
|  | 			http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		selectedItems := filterSelectedItems(allItems, items) | ||||||
|  | 		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 == "" { | ||||||
|  | 		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 = true | ||||||
|  | 	if jobInfo.Cmd != nil && jobInfo.Cmd.Process != nil { | ||||||
|  | 		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, | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										266
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										266
									
								
								main.go
									
									
									
									
									
								
							| @@ -1,22 +1,15 @@ | |||||||
| package main | package main | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"encoding/base64" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"flag" | 	"flag" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"html/template" | 	"html/template" | ||||||
| 	"io" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/url" |  | ||||||
| 	"os" | 	"os" | ||||||
| 	"os/exec" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"sync" | 	"sync" | ||||||
| 	"time" |  | ||||||
|  |  | ||||||
| 	"github.com/BurntSushi/toml" | 	"embed" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| type Item struct { | type Item struct { | ||||||
| @@ -39,199 +32,56 @@ type Metadata struct { | |||||||
| 	Season string | 	Season string | ||||||
| } | } | ||||||
|  |  | ||||||
| type Config struct { |  | ||||||
| 	BaseDir    string |  | ||||||
| 	Format     string |  | ||||||
| 	N_m3u8DLRE struct { |  | ||||||
| 		Path string |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| var config Config |  | ||||||
| var progressMutex sync.Mutex | var progressMutex sync.Mutex | ||||||
| var progress = make(map[string]*ProgressInfo) | 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 | ||||||
|  | var templateFS embed.FS | ||||||
|  |  | ||||||
| func init() { | func init() { | ||||||
| 	if err := os.MkdirAll(uploadDir, 0755); err != nil { | 	if err := os.MkdirAll(uploadDir, 0755); err != nil { | ||||||
| 		fmt.Printf("Error creating upload directory: %v\n", err) | 		fmt.Printf("Error creating upload directory: %v\n", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	templates = template.Must(template.ParseGlob("templates/*")) | 	templates = template.Must(template.ParseFS(templateFS, "templates/*")) | ||||||
| } | } | ||||||
|  |  | ||||||
| func main() { | func main() { | ||||||
| 	configFile, err := os.Open("config.toml") | 	loadConfig() | ||||||
| 	if err != nil { |  | ||||||
| 		fmt.Println("Error opening config file:", err) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	defer configFile.Close() |  | ||||||
|  |  | ||||||
| 	byteValue, _ := io.ReadAll(configFile) |  | ||||||
|  |  | ||||||
| 	if _, err := toml.Decode(string(byteValue), &config); err != nil { |  | ||||||
| 		fmt.Println("Error decoding config file:", err) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if config.N_m3u8DLRE.Path == "" { |  | ||||||
| 		fmt.Println("Error: N_m3u8DL-RE path is not specified in the config file") |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	inputFile := flag.String("f", "", "Path to the input JSON file") | 	inputFile := flag.String("f", "", "Path to the input JSON file") | ||||||
| 	flag.Parse() | 	flag.Parse() | ||||||
|  |  | ||||||
| 	if *inputFile == "" { | 	if *inputFile == "" { | ||||||
| 		startWebServer() | 		startWebServer() | ||||||
| 	} else { | 	} else { | ||||||
| 		processInputFile(*inputFile) | 		items, err := parseInputFile(*inputFile) | ||||||
|  | 		if err != nil { | ||||||
|  | 			fmt.Printf("Error parsing input file: %v\n", 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) | ||||||
|  |  | ||||||
| 	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 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) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| 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) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	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 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() | ||||||
| @@ -253,83 +103,3 @@ func parseMetadata(metadata string) Metadata { | |||||||
| 		Season: "S" + strings.TrimSpace(parts[2]), | 		Season: "S" + strings.TrimSpace(parts[2]), | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func getDownloadCommand(item Item, mpdPath string) string { |  | ||||||
| 	metadata := parseMetadata(item.Metadata) |  | ||||||
| 	keys := getKeys(item.Keys) |  | ||||||
|  |  | ||||||
| 	command := fmt.Sprintf("%s %s", config.N_m3u8DLRE.Path, mpdPath) |  | ||||||
|  |  | ||||||
| 	for _, key := range keys { |  | ||||||
| 		if key != "" { |  | ||||||
| 			command += fmt.Sprintf(" --key %s", key) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	command += " --auto-select" |  | ||||||
|  |  | ||||||
| 	filename := fmt.Sprintf("\"%s\"", item.Filename) |  | ||||||
| 	command += fmt.Sprintf(" --save-name %s", filename) |  | ||||||
|  |  | ||||||
| 	command += fmt.Sprintf(" --mux-after-done format=%s", config.Format) |  | ||||||
|  |  | ||||||
| 	saveDir := config.BaseDir |  | ||||||
| 	if metadata.Type == "serie" { |  | ||||||
| 		saveDir = filepath.Join(saveDir, "Series", metadata.Title, metadata.Season) |  | ||||||
| 	} else { |  | ||||||
| 		saveDir = filepath.Join(saveDir, "Movies", metadata.Title) |  | ||||||
| 	} |  | ||||||
| 	command += fmt.Sprintf(" --save-dir \"%s\"", saveDir) |  | ||||||
|  |  | ||||||
| 	fmt.Println(command) |  | ||||||
|  |  | ||||||
| 	return command |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func downloadFile(item Item) error { |  | ||||||
| 	fmt.Println("Downloading:", item.Filename) |  | ||||||
|  |  | ||||||
| 	mpdPath := item.MPD |  | ||||||
| 	if !isValidURL(item.MPD) { |  | ||||||
|  |  | ||||||
| 		decodedMPD, err := base64.StdEncoding.DecodeString(item.MPD) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return fmt.Errorf("error decoding base64 MPD: %v", err) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		tempFile, err := os.CreateTemp("", "temp_mpd_*.mpd") |  | ||||||
| 		if err != nil { |  | ||||||
| 			return fmt.Errorf("error creating temporary MPD file: %v", err) |  | ||||||
| 		} |  | ||||||
| 		defer os.Remove(tempFile.Name()) |  | ||||||
|  |  | ||||||
| 		if _, err := tempFile.Write(decodedMPD); err != nil { |  | ||||||
| 			return fmt.Errorf("error writing to temporary MPD file: %v", err) |  | ||||||
| 		} |  | ||||||
| 		if err := tempFile.Close(); err != nil { |  | ||||||
| 			return fmt.Errorf("error closing temporary MPD file: %v", err) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		mpdPath = tempFile.Name() |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	command := getDownloadCommand(item, mpdPath) |  | ||||||
|  |  | ||||||
| 	cmd := exec.Command("bash", "-c", command) |  | ||||||
|  |  | ||||||
| 	cmd.Stdout = os.Stdout |  | ||||||
| 	cmd.Stderr = os.Stderr |  | ||||||
|  |  | ||||||
| 	err := cmd.Run() |  | ||||||
| 	if err != nil { |  | ||||||
| 		return fmt.Errorf("error executing download command: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	fmt.Println("Download completed successfully") |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func isValidURL(toTest string) bool { |  | ||||||
| 	_, err := url.ParseRequestURI(toTest) |  | ||||||
| 	return err == nil |  | ||||||
| } |  | ||||||
|   | |||||||
							
								
								
									
										60
									
								
								subtitles.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								subtitles.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | |||||||
|  | package main | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"os" | ||||||
|  | 	"strings" | ||||||
|  |  | ||||||
|  | 	"github.com/asticode/go-astisub" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func downloadAndConvertSubtitles(subtitlesURLs string) ([]string, error) { | ||||||
|  | 	var subtitlePaths []string | ||||||
|  | 	urls := strings.Split(subtitlesURLs, ",") | ||||||
|  |  | ||||||
|  | 	for _, url := range urls { | ||||||
|  | 		vttPath, err := downloadSubtitle(url) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, fmt.Errorf("error downloading subtitle: %v", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		srtPath, err := convertVTTtoSRT(vttPath) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, fmt.Errorf("error converting subtitle: %v", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		subtitlePaths = append(subtitlePaths, srtPath) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return subtitlePaths, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func downloadSubtitle(url string) (string, error) { | ||||||
|  | 	resp, err := http.Get(url) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	tempFile, err := os.CreateTemp("", "subtitle_*.vtt") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  | 	defer tempFile.Close() | ||||||
|  |  | ||||||
|  | 	_, err = io.Copy(tempFile, resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return tempFile.Name(), nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func convertVTTtoSRT(vttPath string) (string, error) { | ||||||
|  | 	srtPath := strings.TrimSuffix(vttPath, ".vtt") + ".srt" | ||||||
|  | 	s1, _ := astisub.OpenFile(vttPath) | ||||||
|  | 	s1.Write(srtPath) | ||||||
|  | 	return srtPath, nil | ||||||
|  | } | ||||||
							
								
								
									
										140
									
								
								templates/index
									
									
									
									
									
								
							
							
						
						
									
										140
									
								
								templates/index
									
									
									
									
									
								
							| @@ -1,22 +1,152 @@ | |||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| <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: 800px; | ||||||
|  |             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> | <body> | ||||||
|     <h1>Simple Downloader</h1> |     <h1>Simple Downloader</h1> | ||||||
|     <form action="/upload" method="post" enctype="multipart/form-data"> |     <form action="/upload" method="post" enctype="multipart/form-data"> | ||||||
|         <input type="file" name="file" accept=".drmd"> |         <input type="file" name="files" accept=".drmd" multiple> | ||||||
|         <input type="submit" value="Upload and Process"> |         <input type="submit" value="Upload and Process"> | ||||||
|     </form> |     </form> | ||||||
|     <h2>Currently Running Jobs</h2> |     <h2>Currently Running Jobs</h2> | ||||||
|     <ul> |     <ul> | ||||||
|         {{range $filename, $info := .Jobs}} |         {{range $filename, $info := .Jobs}} | ||||||
|             <li> |             <li> | ||||||
|                 <a href="/progress?filename={{$filename}}">{{$filename}}</a>:  |                 <div class="job-title"> | ||||||
|                 {{printf "%.2f%%" $info.Percentage}} |                     <a href="/progress?filename={{$filename}}">{{$filename}}</a> | ||||||
|                 (Current file: {{$info.CurrentFile}}) |                 </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> |             </li> | ||||||
|         {{else}} |         {{else}} | ||||||
|             <li>No active jobs</li> |             <li>No active jobs</li> | ||||||
|         {{end}} |         {{end}} | ||||||
|     </ul> |     </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> | </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
| @@ -1,10 +1,138 @@ | |||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| <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: 800px; | ||||||
|  |             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 { | ||||||
|  |             background-color: #4CAF50; | ||||||
|  |             color: white; | ||||||
|  |             border: none; | ||||||
|  |             padding: 10px 15px; | ||||||
|  |             margin-top: 10px; | ||||||
|  |             border-radius: 4px; | ||||||
|  |             cursor: pointer; | ||||||
|  |         } | ||||||
|  |         #pause-button:hover, #resume-button: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; | ||||||
|  |         } | ||||||
|  |         @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> | <body> | ||||||
|     <h1>Processing {{.Filename}}</h1> |     <h1>Processing {{.Filename}}</h1> | ||||||
|     <div id="progress">0%</div> |     <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 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="back-button" onclick="window.location.href='/'">Back to Index</button> | ||||||
|  |     </div> | ||||||
|     <script> |     <script> | ||||||
|  |         let isPaused = false; | ||||||
|  |  | ||||||
|         function updateProgress() { |         function updateProgress() { | ||||||
|             fetch('/progress?filename={{.Filename}}', { |             fetch('/progress?filename={{.Filename}}', { | ||||||
|                 headers: { |                 headers: { | ||||||
| @@ -14,13 +142,67 @@ | |||||||
|                 .then(response => response.json()) |                 .then(response => response.json()) | ||||||
|                 .then(data => { |                 .then(data => { | ||||||
|                     const progress = Math.round(data.Percentage); |                     const progress = Math.round(data.Percentage); | ||||||
|                     document.getElementById('progress').innerText = progress + '%'; |                     document.getElementById('progress-bar').style.width = progress + '%'; | ||||||
|  |                     document.getElementById('progress-text').innerText = progress + '%'; | ||||||
|                     document.getElementById('currentFile').innerText = 'Current file: ' + (data.CurrentFile || 'None'); |                     document.getElementById('currentFile').innerText = 'Current file: ' + (data.CurrentFile || 'None'); | ||||||
|                     if (progress < 100) { |                      | ||||||
|  |                     isPaused = data.Paused; | ||||||
|  |                     updatePauseResumeButtons(); | ||||||
|  |                      | ||||||
|  |                     if (progress < 100 && !isPaused) { | ||||||
|                         setTimeout(updateProgress, 1000); |                         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.'); | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         updateProgress(); |         updateProgress(); | ||||||
|     </script> |     </script> | ||||||
| </body> | </body> | ||||||
|   | |||||||
							
								
								
									
										105
									
								
								templates/select
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								templates/select
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | |||||||
|  | <!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: 800px; | ||||||
|  |             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; | ||||||
|  |         } | ||||||
|  |     </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"> | ||||||
|  |                     <div class="season-title"> | ||||||
|  |                         <input type="checkbox" class="season-checkbox" id="season-{{$filename}}-{{$season}}" checked onchange="toggleSeason('{{$filename}}-{{$season}}')"> | ||||||
|  |                         <label for="season-{{$filename}}-{{$season}}">{{$season}}</label> | ||||||
|  |                     </div> | ||||||
|  |                     {{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> | ||||||
|  |             {{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-' + season); | ||||||
|  |             var episodeCheckboxes = document.getElementsByClassName('episode-' + season); | ||||||
|  |             for (var i = 0; i < episodeCheckboxes.length; i++) { | ||||||
|  |                 episodeCheckboxes[i].checked = seasonCheckbox.checked; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     </script> | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										290
									
								
								utils.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										290
									
								
								utils.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,290 @@ | |||||||
|  | package main | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/url" | ||||||
|  | 	"os" | ||||||
|  | 	"os/exec" | ||||||
|  | 	"path/filepath" | ||||||
|  | 	"regexp" | ||||||
|  | 	"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 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) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  |  | ||||||
|  | 	for i := 0; i < len(items); i++ { | ||||||
|  | 		select { | ||||||
|  | 		case <-jobInfo.AbortChan: | ||||||
|  | 			updateProgress(filename, 100, "Aborted") | ||||||
|  | 			return fmt.Errorf("download aborted") | ||||||
|  | 		default: | ||||||
|  | 			if jobInfo.Paused { | ||||||
|  | 				select { | ||||||
|  | 				case <-jobInfo.ResumeChan: | ||||||
|  | 					jobInfo.Paused = false | ||||||
|  | 				case <-jobInfo.AbortChan: | ||||||
|  | 					updateProgress(filename, 100, "Aborted") | ||||||
|  | 					return fmt.Errorf("download aborted") | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename) | ||||||
|  | 			err := downloadFile(items[i], jobInfo) | ||||||
|  | 			if err != nil { | ||||||
|  | 				if err.Error() == "download paused" { | ||||||
|  | 					removeCompletedEpisodes(filename, items[:i]) | ||||||
|  | 					i-- | ||||||
|  | 					continue | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	updateProgress(filename, 100, "") | ||||||
|  | 	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 | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user