Replace shell-based downloader execution with validated arguments, enforce request hardening and safer defaults, and refactor handlers/router/state so job control is safer and easier to maintain.
453 lines
11 KiB
Go
453 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/beevik/etree"
|
|
)
|
|
|
|
type JobInfo struct {
|
|
AbortChan chan struct{}
|
|
ResumeChan chan struct{}
|
|
Cmd *exec.Cmd
|
|
TempDir string
|
|
|
|
mu sync.RWMutex
|
|
paused bool
|
|
abortOnce sync.Once
|
|
}
|
|
|
|
func NewJobInfo() *JobInfo {
|
|
return &JobInfo{
|
|
AbortChan: make(chan struct{}),
|
|
ResumeChan: make(chan struct{}, 1),
|
|
}
|
|
}
|
|
|
|
func (j *JobInfo) SetPaused(value bool) {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
j.paused = value
|
|
}
|
|
|
|
func (j *JobInfo) IsPaused() bool {
|
|
j.mu.RLock()
|
|
defer j.mu.RUnlock()
|
|
return j.paused
|
|
}
|
|
|
|
func (j *JobInfo) SetCmd(cmd *exec.Cmd) {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
j.Cmd = cmd
|
|
}
|
|
|
|
func (j *JobInfo) SetTempDir(tempDir string) {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
j.TempDir = tempDir
|
|
}
|
|
|
|
func (j *JobInfo) GetTempDir() string {
|
|
j.mu.RLock()
|
|
defer j.mu.RUnlock()
|
|
return j.TempDir
|
|
}
|
|
|
|
func (j *JobInfo) KillProcess() {
|
|
j.mu.RLock()
|
|
cmd := j.Cmd
|
|
j.mu.RUnlock()
|
|
|
|
if cmd != nil && cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
}
|
|
|
|
func (j *JobInfo) SignalResume() {
|
|
select {
|
|
case j.ResumeChan <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (j *JobInfo) Abort() {
|
|
j.abortOnce.Do(func() {
|
|
close(j.AbortChan)
|
|
})
|
|
}
|
|
|
|
func (j *JobInfo) IsAborted() bool {
|
|
select {
|
|
case <-j.AbortChan:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var (
|
|
ErrDownloadPaused = errors.New("download paused")
|
|
ErrDownloadAborted = errors.New("download aborted")
|
|
)
|
|
|
|
var sanitizeFilenameRegex = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
|
|
|
var windowsReservedNames = map[string]struct{}{
|
|
"CON": {}, "PRN": {}, "AUX": {}, "NUL": {},
|
|
"COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {}, "COM6": {}, "COM7": {}, "COM8": {}, "COM9": {},
|
|
"LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {}, "LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {},
|
|
}
|
|
|
|
func sanitizeFilename(filename string) string {
|
|
filename = sanitizeFilenameRegex.ReplaceAllString(filename, "_")
|
|
filename = strings.Trim(filename, ". ")
|
|
|
|
base := filename
|
|
if idx := strings.LastIndex(filename, "."); idx > 0 {
|
|
base = filename[:idx]
|
|
}
|
|
if _, reserved := windowsReservedNames[strings.ToUpper(base)]; reserved {
|
|
filename = "_" + filename
|
|
}
|
|
|
|
if filename == "" {
|
|
filename = "_"
|
|
}
|
|
return filename
|
|
}
|
|
|
|
func safeUploadPath(filename string) (string, error) {
|
|
cleanName := strings.TrimSpace(filename)
|
|
if cleanName == "" {
|
|
return "", fmt.Errorf("filename is required")
|
|
}
|
|
|
|
baseName := filepath.Base(cleanName)
|
|
if baseName != cleanName {
|
|
return "", fmt.Errorf("invalid filename")
|
|
}
|
|
|
|
if strings.Contains(baseName, "..") {
|
|
return "", fmt.Errorf("invalid filename")
|
|
}
|
|
|
|
return filepath.Join(uploadDir, baseName), nil
|
|
}
|
|
|
|
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 {
|
|
set := make(map[string]struct{}, len(selectedItems))
|
|
for _, s := range selectedItems {
|
|
set[s] = struct{}{}
|
|
}
|
|
filtered := make([]Item, 0, len(selectedItems))
|
|
for _, item := range items {
|
|
if _, ok := set[item.Filename]; ok {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func sortItems(items []Item) {
|
|
sort.Slice(items, func(i, j int) bool {
|
|
iMeta := parseMetadata(items[i].Metadata)
|
|
jMeta := parseMetadata(items[j].Metadata)
|
|
|
|
if iMeta.Title != jMeta.Title {
|
|
return iMeta.Title < jMeta.Title
|
|
}
|
|
|
|
iSeason := extractNumber(iMeta.Season)
|
|
jSeason := extractNumber(jMeta.Season)
|
|
|
|
if iSeason != jSeason {
|
|
return iSeason < jSeason
|
|
}
|
|
|
|
iEpisode := extractEpisodeNumber(items[i].Filename)
|
|
jEpisode := extractEpisodeNumber(items[j].Filename)
|
|
|
|
return iEpisode < jEpisode
|
|
})
|
|
}
|
|
|
|
func extractNumber(s string) int {
|
|
num, _ := strconv.Atoi(strings.TrimLeft(s, "S"))
|
|
return num
|
|
}
|
|
|
|
var episodeNumberRegex = regexp.MustCompile(`(?i)S\d+E(\d+)`)
|
|
|
|
func extractEpisodeNumber(filename string) int {
|
|
match := episodeNumberRegex.FindStringSubmatch(filename)
|
|
if len(match) < 2 {
|
|
return 0
|
|
}
|
|
num, _ := strconv.Atoi(match[1])
|
|
return num
|
|
}
|
|
|
|
func processItems(filename string, items []Item) error {
|
|
jobInfo := NewJobInfo()
|
|
setJob(filename, jobInfo)
|
|
|
|
defer func() {
|
|
removeJob(filename)
|
|
|
|
tempDir := jobInfo.GetTempDir()
|
|
if tempDir != "" {
|
|
_ = os.RemoveAll(tempDir)
|
|
}
|
|
}()
|
|
|
|
sortItems(items)
|
|
|
|
for i := 0; i < len(items); i++ {
|
|
select {
|
|
case <-jobInfo.AbortChan:
|
|
updateProgress(filename, 100, "Aborted", "aborted")
|
|
logger.LogJobState(filename, "aborted")
|
|
return ErrDownloadAborted
|
|
default:
|
|
if jobInfo.IsPaused() {
|
|
select {
|
|
case <-jobInfo.ResumeChan:
|
|
jobInfo.SetPaused(false)
|
|
logger.LogJobState(filename, "resumed")
|
|
case <-jobInfo.AbortChan:
|
|
updateProgress(filename, 100, "Aborted", "aborted")
|
|
logger.LogJobState(filename, "aborted")
|
|
return ErrDownloadAborted
|
|
}
|
|
}
|
|
updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename, "running")
|
|
err := downloadFile(filename, items[i], jobInfo)
|
|
if err != nil {
|
|
if errors.Is(err, ErrDownloadPaused) {
|
|
logger.LogJobState(filename, "paused")
|
|
if remErr := removeCompletedEpisodes(filename, items[:i]); remErr != nil {
|
|
logger.LogError("Process Items", fmt.Sprintf("Error updating partial progress file: %v", remErr))
|
|
}
|
|
i--
|
|
continue
|
|
}
|
|
if errors.Is(err, ErrDownloadAborted) {
|
|
updateProgress(filename, 100, "Aborted", "aborted")
|
|
logger.LogJobState(filename, "aborted")
|
|
return ErrDownloadAborted
|
|
}
|
|
|
|
updateProgress(filename, float64(i)/float64(len(items))*100, items[i].Filename, "failed")
|
|
logger.LogError("Process Items", fmt.Sprintf("Error downloading item %s: %v", items[i].Filename, err))
|
|
return fmt.Errorf("error downloading %s: %w", items[i].Filename, err)
|
|
}
|
|
}
|
|
}
|
|
updateProgress(filename, 100, "", "completed")
|
|
logger.LogJobState(filename, "completed successfully")
|
|
return nil
|
|
}
|
|
|
|
func removeCompletedEpisodes(filename string, completedItems []Item) error {
|
|
inputFile := filepath.Join(uploadDir, filename)
|
|
items, err := parseInputFile(inputFile)
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing input file: %v", err)
|
|
}
|
|
|
|
remainingItems := make([]Item, 0)
|
|
for _, item := range items {
|
|
if !isItemCompleted(item, completedItems) || isLastCompletedItem(item, completedItems) {
|
|
remainingItems = append(remainingItems, item)
|
|
}
|
|
}
|
|
|
|
updatedItems := Items{Items: remainingItems}
|
|
jsonData, err := json.MarshalIndent(updatedItems, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("error marshaling updated items: %v", err)
|
|
}
|
|
|
|
err = os.WriteFile(inputFile, jsonData, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("error writing updated DRMD file: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isItemCompleted(item Item, completedItems []Item) bool {
|
|
for _, completedItem := range completedItems {
|
|
if item.Filename == completedItem.Filename {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isLastCompletedItem(item Item, completedItems []Item) bool {
|
|
if len(completedItems) == 0 {
|
|
return false
|
|
}
|
|
return item.Filename == completedItems[len(completedItems)-1].Filename
|
|
}
|