diff --git a/config.go b/config.go index b481b97..b7253bb 100644 --- a/config.go +++ b/config.go @@ -9,9 +9,10 @@ import ( ) type Config struct { - BaseDir string - Format string - N_m3u8DLRE struct { + BaseDir string + Format string + TempBaseDir string + N_m3u8DLRE struct { Path string } } diff --git a/config.toml b/config.toml index 90f1faa..7fe8fdb 100644 --- a/config.toml +++ b/config.toml @@ -1,5 +1,6 @@ BaseDir = "/mnt/media" Format = "mkv" +TempBaseDir = "/tmp/nre" [N_m3u8DLRE] Path = "nre" diff --git a/downloaders.go b/downloaders.go index dd77c35..3681151 100644 --- a/downloaders.go +++ b/downloaders.go @@ -2,7 +2,6 @@ package main import ( "encoding/base64" - "encoding/json" "fmt" "io" "net/http" @@ -12,38 +11,6 @@ import ( "strings" ) -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 { if len(input) >= 3 && input[0] == 0xEF && input[1] == 0xBB && input[2] == 0xBF { return input[3:] @@ -51,9 +18,17 @@ func removeBOM(input []byte) []byte { return input } -func downloadFile(item Item) error { +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) @@ -108,7 +83,7 @@ func downloadFile(item Item) error { mpdPath = tempFile.Name() } - command := getDownloadCommand(item, mpdPath) + command := getDownloadCommand(item, mpdPath, tempDir) if item.Subtitles != "" { subtitlePaths, err := downloadAndConvertSubtitles(item.Subtitles) @@ -124,19 +99,45 @@ func downloadFile(item Item) error { cmd := exec.Command("bash", "-c", command) + jobsMutex.Lock() + jobInfo.Cmd = cmd + jobsMutex.Unlock() + cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - err := cmd.Run() + err = cmd.Start() if err != nil { - return fmt.Errorf("error executing download command: %v", err) + 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) string { +func getDownloadCommand(item Item, mpdPath string, tempDir string) string { metadata := parseMetadata(item.Metadata) keys := getKeys(item.Keys) @@ -165,6 +166,8 @@ func getDownloadCommand(item Item, mpdPath string) string { } command += fmt.Sprintf(" --save-dir \"%s\"", saveDir) + command += fmt.Sprintf(" --tmp-dir \"%s\"", tempDir) + fmt.Println(command) return command diff --git a/go.sum b/go.sum index 92cc1b6..97fe73a 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ 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= diff --git a/handlers.go b/handlers.go index d7df463..76b9701 100644 --- a/handlers.go +++ b/handlers.go @@ -51,16 +51,59 @@ func handleUpload(w http.ResponseWriter, r *http.Request) { tempFilename := filepath.Base(tempFile.Name()) + _, err = parseInputFile(tempFile.Name()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/select?filename="+tempFilename, http.StatusSeeOther) +} + +func handleSelect(w http.ResponseWriter, r *http.Request) { + filename := r.URL.Query().Get("filename") + items, err := parseInputFile(filepath.Join(uploadDir, filename)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + groupedItems := groupItemsBySeason(items) + + err = templates.ExecuteTemplate(w, "select", struct { + Filename string + Items map[string][]Item + }{ + Filename: filename, + Items: groupedItems, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func handleProcess(w http.ResponseWriter, r *http.Request) { + filename := r.FormValue("filename") + selectedItems := r.Form["items"] + + items, err := parseInputFile(filepath.Join(uploadDir, filename)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + filteredItems := filterSelectedItems(items, selectedItems) + go func() { - err := processInputFile(tempFile.Name()) + err := processItems(filename, filteredItems) if err != nil { fmt.Printf("Error processing file: %v\n", err) } - os.Remove(tempFile.Name()) + os.Remove(filepath.Join(uploadDir, filename)) }() - http.Redirect(w, r, "/progress?filename="+tempFilename, http.StatusSeeOther) + http.Redirect(w, r, "/progress?filename="+filename, http.StatusSeeOther) } func handleProgress(w http.ResponseWriter, r *http.Request) { @@ -92,3 +135,100 @@ func handleProgress(w http.ResponseWriter, r *http.Request) { 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() + } + + 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{}{} + + 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) + } + } +} diff --git a/main.go b/main.go index 74fcf39..1b66682 100644 --- a/main.go +++ b/main.go @@ -63,14 +63,25 @@ func main() { if *inputFile == "" { startWebServer() } 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() { http.HandleFunc("/", handleRoot) http.HandleFunc("/upload", handleUpload) + http.HandleFunc("/select", handleSelect) + http.HandleFunc("/process", handleProcess) 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") http.ListenAndServe(":8080", nil) diff --git a/templates/index b/templates/index index b0e7cc1..bc57763 100644 --- a/templates/index +++ b/templates/index @@ -45,6 +45,7 @@ ul { list-style-type: none; padding: 0; + margin-bottom: 10px; } li { background-color: #2d2d2d; @@ -83,6 +84,25 @@ 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; } @@ -107,5 +127,19 @@
  • No active jobs
  • {{end}} + + diff --git a/templates/progress b/templates/progress index e5c05f4..b113c91 100644 --- a/templates/progress +++ b/templates/progress @@ -56,6 +56,46 @@ 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; @@ -84,6 +124,12 @@
    +
    + + + + +
    diff --git a/templates/select b/templates/select new file mode 100644 index 0000000..4527b29 --- /dev/null +++ b/templates/select @@ -0,0 +1,102 @@ + + + + + + Select Items to Download + + + +

    Select Items to Download

    +
    + + {{range $season, $items := .Items}} +
    +
    + + +
    + {{range $item := $items}} +
    + +
    + {{end}} +
    + {{end}} +
    + + + +
    +
    + + + diff --git a/utils.go b/utils.go index 321a237..6fc3cf7 100644 --- a/utils.go +++ b/utils.go @@ -1,15 +1,34 @@ 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, "_") @@ -118,3 +137,148 @@ func findOrCreateSegmentTimeline(adaptationSet *etree.Element) *etree.Element { segmentTemplate := representation.CreateElement("SegmentTemplate") return segmentTemplate.CreateElement("SegmentTimeline") } + +func parseInputFile(inputFile string) ([]Item, error) { + jsonFile, err := os.Open(inputFile) + if err != nil { + return nil, fmt.Errorf("error opening file %s: %v", inputFile, err) + } + defer jsonFile.Close() + + byteValue, err := io.ReadAll(jsonFile) + if err != nil { + return nil, fmt.Errorf("error reading file %s: %v", inputFile, err) + } + + byteValue = removeBOM(byteValue) + + var items Items + err = json.Unmarshal(byteValue, &items) + if err != nil { + return nil, fmt.Errorf("error unmarshaling JSON: %v", 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 + fmt.Printf("Resuming download for %s\n", filename) + 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 + } + fmt.Printf("Error downloading file: %v\n", err) + } + } + } + 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 +}