32 Commits

Author SHA1 Message Date
ea70f47f76 Update Changelog 2023-03-13 10:54:47 +10:30
2e3156ef65 New alpha release 2023-03-13 10:50:25 +10:30
08e2c1c377 Use constants and constructors in the test 2023-03-13 10:48:38 +10:30
b40dd218f1 Add move to destination functionality 2023-03-13 10:32:20 +10:30
ba87b943ea Add some stress-test data 2023-03-13 10:25:59 +10:30
3e7a3a2f3b Fix some more races 2023-03-10 00:07:29 +10:30
f2c05d0144 Restore test 2023-03-09 23:24:03 +10:30
3d72b8b16a Remove debugging test URLs 2023-03-09 21:46:28 +10:30
9944cb9104 Appease linter 2023-03-09 21:32:24 +10:30
9719449d01 Update deps 2023-03-09 21:32:13 +10:30
e34bef9263 Update changelog 2023-03-09 21:22:00 +10:30
b0095e0a00 Disable test temporarily 2023-03-09 21:16:43 +10:30
b914799be0 Prep for alpha release 2023-03-09 21:16:26 +10:30
565b777399 Fix portable binary config handling 2023-03-09 21:14:37 +10:30
3e8f04cce9 Clean up docker and update deps 2023-03-09 21:02:21 +10:30
a97cae9c6d Update changelog 2023-03-09 21:01:57 +10:30
2d770781e6 Clean up some wording 2023-03-09 20:47:00 +10:30
16d9ac368c Start of destination support and some refactoring 2022-07-05 20:43:32 +09:30
c1c1fc1866 Add compose files and alter build script to build/push to docker 2022-05-14 17:24:37 +09:30
14c79a7ff2 Start of docker support 2022-05-14 16:38:48 +09:30
ee7b8565cc Refactor to prevent races on access of downloads 2022-04-18 19:53:07 +09:30
6b1dff54f9 Remove some potential races 2022-04-18 13:01:07 +09:30
b344e757a6 Improve presentation of multiple file downloads on index page 2022-04-10 09:21:39 +09:30
c3f58bdcd0 Update changelog 2022-04-09 23:44:48 +09:30
f899b9c0c2 Sanity check destinations 2022-04-09 23:42:38 +09:30
4f33603a0c Add destinations configuration 2022-04-09 23:32:57 +09:30
5c362df35d Add state for "Fixing MPEG-TS" which takes a while 2022-04-09 17:50:26 +09:30
15ee200615 Alternative download ETA string 2022-04-09 17:46:03 +09:30
9d3f54d2ee Show URL if it is still queued and we don't have any filenames yet. 2022-04-09 17:04:19 +09:30
91c68d8816 Show more information when downloading from playlists 2022-04-09 15:13:22 +09:30
b81fce94a2 Fix username 2022-04-09 14:21:39 +09:30
bb8e8662fd Commit missing file 2022-04-09 12:30:37 +09:30
22 changed files with 917 additions and 221 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
github: USERNAME github: tardisx

View File

@@ -1,9 +1,10 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Cleanup", "Cleanup",
"gropple",
"succ",
"tmpl", "tmpl",
"vars", "vars"
"gropple"
], ],
"cSpell.language": "en-GB" "cSpell.language": "en-GB"
} }

View File

@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
## [v0.6.0] - 2023-03-09
- Configurable destinations for downloads
- Multiple destination directories can be configured
- When queueing a download, an alternate destination can be selected
- When downloading from a playlist, show the total number of videos and how many have been downloaded
- Show version in web UI
- Improve index page (show URL of queued downloads instead of nothing)
- Add docker support
- Fixes and improvements to capturing output info and showing it in the UI
- Fixes to handling of queued downloads
- Fix portable mode to look in binary directory, not current directory
## [v0.5.5] - 2022-04-09 ## [v0.5.5] - 2022-04-09
- Fix a bug which would erase configuration when migrating from v1 to v2 config - Fix a bug which would erase configuration when migrating from v1 to v2 config

47
Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# Start from golang base image
FROM golang:alpine as builder
# Install git. (alpine image does not have git in it)
RUN apk update && apk add --no-cache git curl
# Set current working directory
WORKDIR /app
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /app/yt-dlp
RUN chmod a+x /app/yt-dlp
# Note here: To avoid downloading dependencies every time we
# build image. Here, we are caching all the dependencies by
# first copying go.mod and go.sum files and downloading them,
# to be used every time we build the image if the dependencies
# are not changed.
# Copy go mod and sum files
COPY go.mod ./
COPY go.sum ./
# Download all dependencies.
RUN go mod download
# Now, copy the source code
COPY . .
# Note here: CGO_ENABLED is disabled for cross system compilation
# It is also a common best practise.
# Build the application.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./bin/gropple .
# Finally our multi-stage to build a small image
# Start a new stage from scratch
FROM golang:alpine
# Copy the Pre-built binary file
COPY --from=builder /app/bin/gropple .
COPY --from=builder /app/yt-dlp /bin/
# Install things we need to support yt-dlp
RUN apk update && apk add --no-cache python3 ffmpeg
# Run executable
CMD ["./gropple", "--config-path", "/config/gropple.json"]

View File

@@ -8,7 +8,7 @@ open my $fh, "<", "main.go" || die $!;
my $version; my $version;
while (<$fh>) { while (<$fh>) {
# CurrentVersion: "v0.04" # CurrentVersion: "v0.04"
$version = $1 if /CurrentVersion:\s*"(v[\d\.]+)"/; $version = $1 if /CurrentVersion:\s*"(v.*?)"/;
} }
close $fh; close $fh;
@@ -38,3 +38,11 @@ foreach my $type (keys %build) {
system "go", "build", "-o", "release/$type/" . $build{$type}->{filename}; system "go", "build", "-o", "release/$type/" . $build{$type}->{filename};
system "zip", "-j", "dist/gropple-$type-$version.zip", ( glob "release/$type/*" ); system "zip", "-j", "dist/gropple-$type-$version.zip", ( glob "release/$type/*" );
} }
# now docker
$ENV{VERSION}="$version";
system "docker-compose", "-f", "docker-compose.build.yml", "build";
system "docker", "tag", "tardisx/gropple:$version", "tardisx/gropple:latest";
system "docker", "push", "tardisx/gropple:$version";
system "docker", "push", "tardisx/gropple:latest";

View File

@@ -7,6 +7,7 @@ import (
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
@@ -19,21 +20,31 @@ type Server struct {
MaximumActiveDownloads int `yaml:"maximum_active_downloads_per_domain" json:"maximum_active_downloads_per_domain"` MaximumActiveDownloads int `yaml:"maximum_active_downloads_per_domain" json:"maximum_active_downloads_per_domain"`
} }
// DownloadProfile holds the details for executing a downloader
type DownloadProfile struct { type DownloadProfile struct {
Name string `yaml:"name" json:"name"` Name string `yaml:"name" json:"name"`
Command string `yaml:"command" json:"command"` Command string `yaml:"command" json:"command"`
Args []string `yaml:"args" json:"args"` Args []string `yaml:"args" json:"args"`
} }
// UI holds the configuration for the user interface
type UI struct { type UI struct {
PopupWidth int `yaml:"popup_width" json:"popup_width"` PopupWidth int `yaml:"popup_width" json:"popup_width"`
PopupHeight int `yaml:"popup_height" json:"popup_height"` PopupHeight int `yaml:"popup_height" json:"popup_height"`
} }
// Destination is the path for a place that a download can be moved to
type Destination struct {
Name string `yaml:"name" json:"name"` // Name for this location
Path string `yaml:"path" json:"path"` // Path on disk
}
// Config is the top level of the user configuration
type Config struct { type Config struct {
ConfigVersion int `yaml:"config_version" json:"config_version"` ConfigVersion int `yaml:"config_version" json:"config_version"`
Server Server `yaml:"server" json:"server"` Server Server `yaml:"server" json:"server"`
UI UI `yaml:"ui" json:"ui"` UI UI `yaml:"ui" json:"ui"`
Destinations []Destination `yaml:"destinations" json:"destinations"`
DownloadProfiles []DownloadProfile `yaml:"profiles" json:"profiles"` DownloadProfiles []DownloadProfile `yaml:"profiles" json:"profiles"`
} }
@@ -46,18 +57,19 @@ type ConfigService struct {
func (cs *ConfigService) LoadTestConfig() { func (cs *ConfigService) LoadTestConfig() {
cs.LoadDefaultConfig() cs.LoadDefaultConfig()
cs.Config.Server.DownloadPath = "/tmp"
cs.Config.DownloadProfiles = []DownloadProfile{{Name: "test profile", Command: "sleep", Args: []string{"5"}}} cs.Config.DownloadProfiles = []DownloadProfile{{Name: "test profile", Command: "sleep", Args: []string{"5"}}}
} }
func (cs *ConfigService) LoadDefaultConfig() { func (cs *ConfigService) LoadDefaultConfig() {
defaultConfig := Config{} defaultConfig := Config{}
stdProfile := DownloadProfile{Name: "standard video", Command: "youtube-dl", Args: []string{ stdProfile := DownloadProfile{Name: "standard video", Command: "yt-dlp", Args: []string{
"--newline", "--newline",
"--write-info-json", "--write-info-json",
"-f", "-f",
"bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
}} }}
mp3Profile := DownloadProfile{Name: "standard mp3", Command: "youtube-dl", Args: []string{ mp3Profile := DownloadProfile{Name: "standard mp3", Command: "yt-dlp", Args: []string{
"--newline", "--newline",
"--write-info-json", "--write-info-json",
"--extract-audio", "--extract-audio",
@@ -69,14 +81,16 @@ func (cs *ConfigService) LoadDefaultConfig() {
defaultConfig.Server.Port = 6123 defaultConfig.Server.Port = 6123
defaultConfig.Server.Address = "http://localhost:6123" defaultConfig.Server.Address = "http://localhost:6123"
defaultConfig.Server.DownloadPath = "./" defaultConfig.Server.DownloadPath = "/downloads"
defaultConfig.UI.PopupWidth = 500 defaultConfig.UI.PopupWidth = 500
defaultConfig.UI.PopupHeight = 500 defaultConfig.UI.PopupHeight = 500
defaultConfig.Server.MaximumActiveDownloads = 2 defaultConfig.Server.MaximumActiveDownloads = 2
defaultConfig.ConfigVersion = 2 defaultConfig.Destinations = make([]Destination, 0)
defaultConfig.ConfigVersion = 3
cs.Config = &defaultConfig cs.Config = &defaultConfig
@@ -92,6 +106,15 @@ func (c *Config) ProfileCalled(name string) *DownloadProfile {
return nil return nil
} }
func (c *Config) DestinationCalled(name string) *Destination {
for _, p := range c.Destinations {
if p.Name == name {
return &p
}
}
return nil
}
func (c *Config) UpdateFromJSON(j []byte) error { func (c *Config) UpdateFromJSON(j []byte) error {
newConfig := Config{} newConfig := Config{}
err := json.Unmarshal(j, &newConfig) err := json.Unmarshal(j, &newConfig)
@@ -163,18 +186,41 @@ func (c *Config) UpdateFromJSON(j []byte) error {
} }
} }
// check destinations
for _, dest := range newConfig.Destinations {
s, err := os.Stat(dest.Path)
if err != nil {
return fmt.Errorf("destination '%s' (%s) is bad: %s", dest.Name, dest.Path, err)
}
if !s.IsDir() {
return fmt.Errorf("destination '%s' (%s) is not a directory", dest.Name, dest.Path)
}
}
*c = newConfig *c = newConfig
return nil return nil
} }
// DetermineConfigDir determines where the config is (or should be) stored. // DetermineConfigDir determines where the config is (or should be) stored.
func (cs *ConfigService) DetermineConfigDir() { func (cs *ConfigService) DetermineConfigDir() {
// check current directory first, for a file called gropple.yml // check binary path first, for a file called gropple.yml
_, err := os.Stat("gropple.yml") binaryPath := os.Args[0]
binaryDir := filepath.Dir(binaryPath)
potentialConfigPath := filepath.Join(binaryDir, "gropple.yml")
_, err := os.Stat(potentialConfigPath)
if err == nil { if err == nil {
// exists in current directory, use that. // exists in binary directory, use that
cs.ConfigPath = "gropple.yml" // fully qualify, just for clarity in the log
config, err := filepath.Abs(potentialConfigPath)
if err == nil {
log.Printf("found portable config in %s", config)
cs.ConfigPath = config
return return
} else {
log.Printf("got error when trying to convert config to absolute path: %s", err)
log.Print("falling back to using UserConfigDir")
}
} }
// otherwise fall back to using the UserConfigDir // otherwise fall back to using the UserConfigDir
@@ -238,6 +284,14 @@ func (cs *ConfigService) LoadConfig() error {
c.ConfigVersion = 2 c.ConfigVersion = 2
configMigrated = true configMigrated = true
log.Print("migrated config from version 1 => 2") log.Print("migrated config from version 1 => 2")
}
if c.ConfigVersion == 2 {
c.Destinations = make([]Destination, 0)
c.ConfigVersion = 3
configMigrated = true
log.Print("migrated config from version 2 => 3")
} }
if configMigrated { if configMigrated {

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
) )
func TestMigrationV1toV2(t *testing.T) { func TestMigrationV1toV3(t *testing.T) {
v2Config := `config_version: 1 v2Config := `config_version: 1
server: server:
port: 6123 port: 6123
@@ -36,13 +36,16 @@ profiles:
if err != nil { if err != nil {
t.Errorf("got error when loading config: %s", err) t.Errorf("got error when loading config: %s", err)
} }
if cs.Config.ConfigVersion != 2 { if cs.Config.ConfigVersion != 3 {
t.Errorf("did not migrate version (it is '%d')", cs.Config.ConfigVersion) t.Errorf("did not migrate version (it is '%d')", cs.Config.ConfigVersion)
} }
if cs.Config.Server.MaximumActiveDownloads != 2 { if cs.Config.Server.MaximumActiveDownloads != 2 {
t.Error("did not add MaximumActiveDownloads") t.Error("did not add MaximumActiveDownloads")
} }
t.Log(cs.ConfigPath) if len(cs.Config.Destinations) != 0 {
t.Error("incorrect number of destinations added")
}
os.Remove(cs.ConfigPath)
} }
func configServiceFromString(configString string) *ConfigService { func configServiceFromString(configString string) *ConfigService {

6
docker-compose.build.yml Normal file
View File

@@ -0,0 +1,6 @@
version: "3.9"
services:
gropple:
build: .
image: tardisx/gropple:$VERSION

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
version: "3.9"
services:
gropple:
build: .
image: tardisx/gropple:latest
volumes:
- ./gropple-config-dir:/config
- ./downloads:/downloads/
restart: always
ports:
- "6123:6123"

View File

@@ -1,12 +1,14 @@
package download package download
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -23,118 +25,237 @@ type Download struct {
PopupUrl string `json:"popup_url"` PopupUrl string `json:"popup_url"`
Process *os.Process `json:"-"` Process *os.Process `json:"-"`
ExitCode int `json:"exit_code"` ExitCode int `json:"exit_code"`
State string `json:"state"` State State `json:"state"`
DownloadProfile config.DownloadProfile `json:"download_profile"` DownloadProfile config.DownloadProfile `json:"download_profile"`
Destination *config.Destination `json:"destination"`
Finished bool `json:"finished"` Finished bool `json:"finished"`
FinishedTS time.Time `json:"finished_ts"` FinishedTS time.Time `json:"finished_ts"`
Files []string `json:"files"` Files []string `json:"files"`
PlaylistCurrent int `json:"playlist_current"`
PlaylistTotal int `json:"playlist_total"`
Eta string `json:"eta"` Eta string `json:"eta"`
Percent float32 `json:"percent"` Percent float32 `json:"percent"`
Log []string `json:"log"` Log []string `json:"log"`
Config *config.Config Config *config.Config
mutex sync.Mutex Lock sync.Mutex
} }
type Downloads []*Download // The Manager holds and is responsible for all Download objects.
type Manager struct {
Downloads []*Download
MaxPerDomain int
Lock sync.Mutex
}
func (m *Manager) String() string {
m.Lock.Lock()
defer m.Lock.Unlock()
out := fmt.Sprintf("Max per domain: %d, downloads: %d\n", m.MaxPerDomain, len(m.Downloads))
for _, dl := range m.Downloads {
out = out + fmt.Sprintf("%3d: (%10s) %30s\n", dl.Id, dl.State, dl.Url)
}
return out
}
type State string
const (
STATE_PREPARING State = "Preparing to start"
STATE_CHOOSE_PROFILE State = "Choose Profile"
STATE_QUEUED State = "Queued"
STATE_DOWNLOADING State = "Downloading"
STATE_DOWNLOADING_METADATA State = "Downloading metadata"
STATE_FAILED State = "Failed"
STATE_COMPLETE State = "Complete"
STATE_MOVED State = "Moved"
)
var CanStopDownload = false var CanStopDownload = false
var downloadId int32 = 0 var downloadId int32 = 0
// StartQueued starts any downloads that have been queued, we would not exceed func (m *Manager) ManageQueue() {
for {
m.Lock.Lock()
m.startQueued(m.MaxPerDomain)
m.moveToDest()
// m.cleanup()
m.Lock.Unlock()
time.Sleep(time.Second)
}
}
func (m *Manager) DownloadsAsJSON() ([]byte, error) {
m.Lock.Lock()
defer m.Lock.Unlock()
for _, dl := range m.Downloads {
dl.Lock.Lock()
defer dl.Lock.Unlock()
}
b, err := json.Marshal(m.Downloads)
return b, err
}
func (m *Manager) moveToDest() {
// move any downloads that are complete and have a dest
for _, dl := range m.Downloads {
dl.Lock.Lock()
if dl.Destination != nil && dl.State == STATE_COMPLETE {
dl.State = STATE_MOVED
for _, fn := range dl.Files {
src := filepath.Join(dl.Config.Server.DownloadPath, fn)
dst := filepath.Join(dl.Destination.Path, fn)
err := os.Rename(src, dst)
if err != nil {
log.Printf("%s", err)
dl.Log = append(dl.Log, fmt.Sprintf("Could not move %s to %s - %s", fn, dl.Destination.Path, err))
break
} else {
dl.Log = append(dl.Log, fmt.Sprintf("Moved %s to %s", fn, dl.Destination.Path))
}
}
}
dl.Lock.Unlock()
}
}
// startQueued starts any downloads that have been queued, we would not exceed
// maxRunning. If maxRunning is 0, there is no limit. // maxRunning. If maxRunning is 0, there is no limit.
func (dls Downloads) StartQueued(maxRunning int) { func (m *Manager) startQueued(maxRunning int) {
active := make(map[string]int) active := make(map[string]int)
for _, dl := range dls { for _, dl := range m.Downloads {
dl.Lock.Lock()
dl.mutex.Lock() if dl.State == STATE_DOWNLOADING || dl.State == STATE_PREPARING {
if dl.State == "downloading" {
active[dl.domain()]++ active[dl.domain()]++
} }
dl.mutex.Unlock() dl.Lock.Unlock()
} }
for _, dl := range dls { for _, dl := range m.Downloads {
dl.mutex.Lock() dl.Lock.Lock()
if dl.State == "queued" && (maxRunning == 0 || active[dl.domain()] < maxRunning) { if dl.State == STATE_QUEUED && (maxRunning == 0 || active[dl.domain()] < maxRunning) {
dl.State = "downloading" dl.State = STATE_PREPARING
active[dl.domain()]++ active[dl.domain()]++
log.Printf("Starting download for id:%d (%s)", dl.Id, dl.Url) log.Printf("Starting download for id:%d (%s)", dl.Id, dl.Url)
dl.mutex.Unlock()
go func() { dl.Begin() }() dl.Lock.Unlock()
go func(sdl *Download) {
sdl.Begin()
}(dl)
} else { } else {
dl.mutex.Unlock() dl.Lock.Unlock()
}
} }
} }
// Cleanup removes old downloads from the list. Hardcoded to remove them one hour }
// cleanup removes old downloads from the list. Hardcoded to remove them one hour
// completion. // completion.
func (dls Downloads) Cleanup() Downloads { func (m *Manager) XXXcleanup() {
newDLs := Downloads{} newDLs := []*Download{}
for _, dl := range dls { for _, dl := range m.Downloads {
dl.mutex.Lock()
if dl.Finished && time.Since(dl.FinishedTS) > time.Duration(time.Hour) { if dl.Finished && time.Since(dl.FinishedTS) > time.Duration(time.Hour) {
// do nothing // do nothing
} else { } else {
newDLs = append(newDLs, dl) newDLs = append(newDLs, dl)
} }
dl.mutex.Unlock()
} }
return newDLs m.Downloads = newDLs
}
// GetDlById returns one of the downloads in our current list.
func (m *Manager) GetDlById(id int) (*Download, error) {
m.Lock.Lock()
defer m.Lock.Unlock()
for _, dl := range m.Downloads {
if dl.Id == id {
return dl, nil
}
}
return nil, fmt.Errorf("no download with id %d", id)
} }
// Queue queues a download // Queue queues a download
func (dl *Download) Queue() { func (m *Manager) Queue(dl *Download) {
dl.Lock.Lock()
defer dl.Lock.Unlock()
dl.State = STATE_QUEUED
}
dl.mutex.Lock() func (m *Manager) ChangeDestination(dl *Download, dest *config.Destination) {
defer dl.mutex.Unlock() dl.Lock.Lock()
// we can only change destination is certain cases...
if dl.State != STATE_FAILED && dl.State != STATE_MOVED {
dl.Destination = dest
}
dl.State = "queued" dl.Lock.Unlock()
} }
func NewDownload(conf *config.Config, url string) *Download { func NewDownload(url string, conf *config.Config) *Download {
atomic.AddInt32(&downloadId, 1) atomic.AddInt32(&downloadId, 1)
dl := Download{ dl := Download{
Config: conf,
Id: int(downloadId), Id: int(downloadId),
Url: url, Url: url,
PopupUrl: fmt.Sprintf("/fetch/%d", int(downloadId)), PopupUrl: fmt.Sprintf("/fetch/%d", int(downloadId)),
State: "choose profile", State: STATE_CHOOSE_PROFILE,
Finished: false, Files: []string{},
Eta: "?", Log: []string{},
Percent: 0.0, Config: conf,
Log: make([]string, 0, 1000), Lock: sync.Mutex{},
} }
return &dl return &dl
} }
func (m *Manager) AddDownload(dl *Download) {
m.Lock.Lock()
defer m.Lock.Unlock()
m.Downloads = append(m.Downloads, dl)
}
// func (dl *Download) AppendLog(text string) {
// dl.Lock.Lock()
// defer dl.Lock.Unlock()
// dl.Log = append(dl.Log, text)
// }
// Stop the download.
func (dl *Download) Stop() { func (dl *Download) Stop() {
if !CanStopDownload { if !CanStopDownload {
log.Print("attempted to stop download on a platform that it is not currently supported on - please report this as a bug") log.Print("attempted to stop download on a platform that it is not currently supported on - please report this as a bug")
os.Exit(1) os.Exit(1)
} }
log.Printf("stopping the download") log.Printf("stopping the download")
dl.mutex.Lock() dl.Lock.Lock()
defer dl.Lock.Unlock()
dl.Log = append(dl.Log, "aborted by user") dl.Log = append(dl.Log, "aborted by user")
defer dl.mutex.Unlock()
dl.Process.Kill() dl.Process.Kill()
} }
// domain returns a domain for this Download. Download should be locked.
func (dl *Download) domain() string { func (dl *Download) domain() string {
// note that we expect to already have the mutex locked by the caller
url, err := url.Parse(dl.Url) url, err := url.Parse(dl.Url)
if err != nil { if err != nil {
log.Printf("Unknown domain for url: %s", dl.Url) log.Printf("Unknown domain for url: %s", dl.Url)
@@ -148,10 +269,9 @@ func (dl *Download) domain() string {
// Begin starts a download, by starting the command specified in the DownloadProfile. // Begin starts a download, by starting the command specified in the DownloadProfile.
// It blocks until the download is complete. // It blocks until the download is complete.
func (dl *Download) Begin() { func (dl *Download) Begin() {
dl.Lock.Lock()
dl.mutex.Lock() dl.State = STATE_DOWNLOADING
dl.State = "downloading"
cmdSlice := []string{} cmdSlice := []string{}
cmdSlice = append(cmdSlice, dl.DownloadProfile.Args...) cmdSlice = append(cmdSlice, dl.DownloadProfile.Args...)
@@ -165,38 +285,45 @@ func (dl *Download) Begin() {
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
dl.State = "failed" dl.State = STATE_FAILED
dl.Finished = true dl.Finished = true
dl.FinishedTS = time.Now() dl.FinishedTS = time.Now()
dl.Log = append(dl.Log, fmt.Sprintf("error setting up stdout pipe: %v", err)) dl.Log = append(dl.Log, fmt.Sprintf("error setting up stdout pipe: %v", err))
dl.Lock.Unlock()
return return
} }
stderr, err := cmd.StderrPipe() stderr, err := cmd.StderrPipe()
if err != nil { if err != nil {
dl.State = "failed" dl.State = STATE_FAILED
dl.Finished = true dl.Finished = true
dl.FinishedTS = time.Now() dl.FinishedTS = time.Now()
dl.Log = append(dl.Log, fmt.Sprintf("error setting up stderr pipe: %v", err)) dl.Log = append(dl.Log, fmt.Sprintf("error setting up stderr pipe: %v", err))
dl.Lock.Unlock()
return return
} }
log.Printf("Executing command: %v", cmd) log.Printf("Executing command: %v", cmd)
err = cmd.Start() err = cmd.Start()
if err != nil { if err != nil {
dl.State = "failed" dl.State = STATE_FAILED
dl.Finished = true dl.Finished = true
dl.FinishedTS = time.Now() dl.FinishedTS = time.Now()
dl.Log = append(dl.Log, fmt.Sprintf("error starting command '%s': %v", dl.DownloadProfile.Command, err)) dl.Log = append(dl.Log, fmt.Sprintf("error starting command '%s': %v", dl.DownloadProfile.Command, err))
dl.Lock.Unlock()
return return
} }
dl.Process = cmd.Process dl.Process = cmd.Process
var wg sync.WaitGroup var wg sync.WaitGroup
dl.mutex.Unlock()
wg.Add(2) wg.Add(2)
dl.Lock.Unlock()
go func() { go func() {
defer wg.Done() defer wg.Done()
dl.updateDownload(stdout) dl.updateDownload(stdout)
@@ -210,22 +337,26 @@ func (dl *Download) Begin() {
wg.Wait() wg.Wait()
cmd.Wait() cmd.Wait()
dl.mutex.Lock() dl.Lock.Lock()
log.Printf("Process finished for id: %d (%v)", dl.Id, cmd) log.Printf("Process finished for id: %d (%v)", dl.Id, cmd)
dl.State = "complete" dl.State = STATE_COMPLETE
dl.Finished = true dl.Finished = true
dl.FinishedTS = time.Now() dl.FinishedTS = time.Now()
dl.ExitCode = cmd.ProcessState.ExitCode() dl.ExitCode = cmd.ProcessState.ExitCode()
if dl.ExitCode != 0 { if dl.ExitCode != 0 {
dl.State = "failed" dl.State = STATE_FAILED
} }
dl.mutex.Unlock() dl.Lock.Unlock()
} }
// updateDownload updates the download based on data from the reader. Expects the
// Download to be unlocked.
func (dl *Download) updateDownload(r io.Reader) { func (dl *Download) updateDownload(r io.Reader) {
// XXX not sure if we might get a partial line? // XXX not sure if we might get a partial line?
buf := make([]byte, 1024) buf := make([]byte, 1024)
for { for {
@@ -240,15 +371,13 @@ func (dl *Download) updateDownload(r io.Reader) {
continue continue
} }
dl.mutex.Lock()
// append the raw log // append the raw log
dl.Lock.Lock()
dl.Log = append(dl.Log, l) dl.Log = append(dl.Log, l)
dl.mutex.Unlock()
// look for the percent and eta and other metadata // look for the percent and eta and other metadata
dl.updateMetadata(l) dl.updateMetadata(l)
dl.Lock.Unlock()
} }
} }
if err != nil { if err != nil {
@@ -257,18 +386,16 @@ func (dl *Download) updateDownload(r io.Reader) {
} }
} }
// updateMetadata parses some metadata and updates the Download. Download must be locked.
func (dl *Download) updateMetadata(s string) { func (dl *Download) updateMetadata(s string) {
dl.mutex.Lock()
defer dl.mutex.Unlock()
// [download] 49.7% of ~15.72MiB at 5.83MiB/s ETA 00:07 // [download] 49.7% of ~15.72MiB at 5.83MiB/s ETA 00:07
etaRE := regexp.MustCompile(`download.+ETA +(\d\d:\d\d(?::\d\d)?)$`) // [download] 99.3% of ~1.42GiB at 320.87KiB/s ETA 00:07 (frag 212/214)
etaRE := regexp.MustCompile(`download.+ETA +(\d\d:\d\d(?::\d\d)?)`)
matches := etaRE.FindStringSubmatch(s) matches := etaRE.FindStringSubmatch(s)
if len(matches) == 2 { if len(matches) == 2 {
dl.Eta = matches[1] dl.Eta = matches[1]
dl.State = "downloading" dl.State = STATE_DOWNLOADING
} }
@@ -314,4 +441,29 @@ func (dl *Download) updateMetadata(s string) {
} }
} }
} }
// [download] Downloading video 1 of 3
playlistDetails := regexp.MustCompile(`Downloading video (\d+) of (\d+)`)
matches = playlistDetails.FindStringSubmatch(s)
if len(matches) == 3 {
total, _ := strconv.ParseInt(matches[2], 10, 32)
current, _ := strconv.ParseInt(matches[1], 10, 32)
dl.PlaylistTotal = int(total)
dl.PlaylistCurrent = int(current)
}
// [Site] user: Downloading JSON metadata page 2
metadataDL := regexp.MustCompile(`Downloading JSON metadata page (\d+)`)
matches = metadataDL.FindStringSubmatch(s)
if len(matches) == 2 {
dl.State = STATE_DOWNLOADING_METADATA
}
// [FixupM3u8] Fixing MPEG-TS in MP4 container of "file [-168849776_456239489].mp4"
metadataFixup := regexp.MustCompile(`Fixing MPEG-TS in MP4 container`)
matches = metadataFixup.FindStringSubmatch(s)
if len(matches) == 1 {
dl.State = "Fixing MPEG-TS in MP4"
}
} }

View File

@@ -0,0 +1,8 @@
//go:build !testdata
package download
import "github.com/tardisx/gropple/config"
func (m *Manager) AddStressTestData(c *config.ConfigService) {
}

View File

@@ -0,0 +1,5 @@
package download
func init() {
CanStopDownload = true
}

View File

@@ -1,6 +1,8 @@
package download package download
import ( import (
"strings"
"sync"
"testing" "testing"
"time" "time"
@@ -39,6 +41,18 @@ func TestUpdateMetadata(t *testing.T) {
t.Fatalf("%v", newD.Files) t.Fatalf("%v", newD.Files)
} }
// different download
newD.updateMetadata("[download] 99.3% of ~1.42GiB at 320.87KiB/s ETA 00:07 (frag 212/214)")
if newD.Eta != "00:07" {
t.Fatalf("bad short eta in dl with frag\n%v", newD)
}
// [FixupM3u8] Fixing MPEG-TS in MP4 container of "file [-168849776_456239489].mp4"
newD.updateMetadata("[FixupM3u8] Fixing MPEG-TS in MP4 container of \"file [-168849776_456239489].mp4")
if newD.State != "Fixing MPEG-TS in MP4" {
t.Fatalf("did not see fixup state - state is %s", newD.State)
}
// deletes // deletes
// TODO. Not sure why I don't always see the "Deleting original file" messages after merge - // TODO. Not sure why I don't always see the "Deleting original file" messages after merge -
// maybe a youtube-dl fork thing? // maybe a youtube-dl fork thing?
@@ -71,71 +85,278 @@ func TestQueue(t *testing.T) {
cs.LoadTestConfig() cs.LoadTestConfig()
conf := cs.Config conf := cs.Config
new1 := Download{Id: 1, Url: "http://sub.example.org/foo1", State: "queued", DownloadProfile: conf.DownloadProfiles[0], Config: conf} new1 := NewDownload("http://sub.example.org/foo1", conf)
new2 := Download{Id: 2, Url: "http://sub.example.org/foo2", State: "queued", DownloadProfile: conf.DownloadProfiles[0], Config: conf} new2 := NewDownload("http://sub.example.org/foo2", conf)
new3 := Download{Id: 3, Url: "http://sub.example.org/foo3", State: "queued", DownloadProfile: conf.DownloadProfiles[0], Config: conf} new3 := NewDownload("http://sub.example.org/foo3", conf)
new4 := Download{Id: 4, Url: "http://example.org/", State: "queued", DownloadProfile: conf.DownloadProfiles[0], Config: conf} new4 := NewDownload("http://example.org/", conf)
dls := Downloads{&new1, &new2, &new3, &new4} // pretend the user chose a profile for each
dls.StartQueued(1) new1.DownloadProfile = *conf.ProfileCalled("test profile")
new2.DownloadProfile = *conf.ProfileCalled("test profile")
new3.DownloadProfile = *conf.ProfileCalled("test profile")
new4.DownloadProfile = *conf.ProfileCalled("test profile")
new1.State = STATE_QUEUED
new2.State = STATE_QUEUED
new3.State = STATE_QUEUED
new4.State = STATE_QUEUED
q := Manager{
Downloads: []*Download{},
MaxPerDomain: 2,
Lock: sync.Mutex{},
}
q.AddDownload(new1)
q.AddDownload(new2)
q.AddDownload(new3)
q.AddDownload(new4)
q.startQueued(1)
// two should start, one from each of the two domains
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
if dls[0].State == "queued" { if q.Downloads[0].State != STATE_DOWNLOADING {
t.Error("#1 was not started") t.Errorf("#1 was not downloading - %s instead ", q.Downloads[0].State)
t.Log(q.String())
} }
if dls[1].State != "queued" { if q.Downloads[1].State != STATE_QUEUED {
t.Error("#2 is not queued") t.Errorf("#2 is not queued - %s instead", q.Downloads[1].State)
t.Log(q.String())
} }
if dls[3].State == "queued" { if q.Downloads[2].State != STATE_QUEUED {
t.Error("#4 is not started") t.Errorf("#3 is not queued - %s instead", q.Downloads[2].State)
t.Log(q.String())
}
if q.Downloads[3].State != STATE_DOWNLOADING {
t.Errorf("#4 is not downloading - %s instead", q.Downloads[3].State)
t.Log(q.String())
} }
// this should start no more, as one is still going // this should start no more, as one is still going
dls.StartQueued(1) q.startQueued(1)
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
if dls[1].State != "queued" { if q.Downloads[0].State != STATE_DOWNLOADING {
t.Error("#2 was started when it should not be") t.Errorf("#1 was not downloading - %s instead ", q.Downloads[0].State)
t.Log(q.String())
}
if q.Downloads[1].State != STATE_QUEUED {
t.Errorf("#2 is not queued - %s instead", q.Downloads[1].State)
t.Log(q.String())
}
if q.Downloads[2].State != STATE_QUEUED {
t.Errorf("#3 is not queued - %s instead", q.Downloads[2].State)
t.Log(q.String())
}
if q.Downloads[3].State != STATE_DOWNLOADING {
t.Errorf("#4 is not downloading - %s instead", q.Downloads[3].State)
t.Log(q.String())
} }
dls.StartQueued(2) // wait until the two finish, check
time.Sleep(time.Second * 5.0)
if q.Downloads[0].State != STATE_COMPLETE {
t.Errorf("#1 was not complete - %s instead ", q.Downloads[0].State)
t.Log(q.String())
}
if q.Downloads[1].State != STATE_QUEUED {
t.Errorf("#2 is not queued - %s instead", q.Downloads[1].State)
t.Log(q.String())
}
if q.Downloads[2].State != STATE_QUEUED {
t.Errorf("#3 is not queued - %s instead", q.Downloads[2].State)
t.Log(q.String())
}
if q.Downloads[3].State != STATE_COMPLETE {
t.Errorf("#4 is not complete - %s instead", q.Downloads[3].State)
t.Log(q.String())
}
// this should start one more, as one is still going
q.startQueued(1)
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
if dls[1].State == "queued" { if q.Downloads[0].State != STATE_COMPLETE {
t.Error("#2 was not started but it should be") t.Errorf("#1 was not complete - %s instead ", q.Downloads[0].State)
t.Log(q.String())
}
if q.Downloads[1].State != STATE_DOWNLOADING {
t.Errorf("#2 is not downloading - %s instead", q.Downloads[1].State)
t.Log(q.String())
}
if q.Downloads[2].State != STATE_QUEUED {
t.Errorf("#3 is not queued - %s instead", q.Downloads[2].State)
t.Log(q.String())
}
if q.Downloads[3].State != STATE_COMPLETE {
t.Errorf("#4 is not complete - %s instead", q.Downloads[3].State)
t.Log(q.String())
} }
dls.StartQueued(2) // this should start no more, as one is still going
q.startQueued(1)
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
if dls[3].State == "queued" { if q.Downloads[0].State != STATE_COMPLETE {
t.Error("#4 was not started but it should be") t.Errorf("#1 was not complete - %s instead ", q.Downloads[0].State)
t.Log(q.String())
}
if q.Downloads[1].State != STATE_DOWNLOADING {
t.Errorf("#2 is not downloading - %s instead", q.Downloads[1].State)
t.Log(q.String())
}
if q.Downloads[2].State != STATE_QUEUED {
t.Errorf("#3 is not queued - %s instead", q.Downloads[2].State)
t.Log(q.String())
}
if q.Downloads[3].State != STATE_COMPLETE {
t.Errorf("#4 is not complete - %s instead", q.Downloads[3].State)
t.Log(q.String())
} }
// reset them all // but if we allow two per domain, the other queued one will start
dls[0].State = "queued" q.startQueued(2)
dls[1].State = "queued"
dls[2].State = "queued"
dls[3].State = "queued"
dls.StartQueued(0)
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
if q.Downloads[0].State != STATE_COMPLETE {
// they should all be going t.Errorf("#1 was not complete - %s instead ", q.Downloads[0].State)
if dls[0].State == "queued" || dls[1].State == "queued" || dls[2].State == "queued" || dls[3].State == "queued" { t.Log(q.String())
t.Error("none should be queued")
} }
if q.Downloads[1].State != STATE_DOWNLOADING {
// reset them all t.Errorf("#2 is not downloading - %s instead", q.Downloads[1].State)
dls[0].State = "queued" t.Log(q.String())
dls[1].State = "queued" }
dls[2].State = "queued" if q.Downloads[2].State != STATE_DOWNLOADING {
dls[3].State = "queued" t.Errorf("#3 is not downloading - %s instead", q.Downloads[2].State)
t.Log(q.String())
dls.StartQueued(2) }
time.Sleep(time.Millisecond * 100) if q.Downloads[3].State != STATE_COMPLETE {
t.Errorf("#4 is not complete - %s instead", q.Downloads[3].State)
// first two should be running, third not (same domain) and 4th running (different domain) t.Log(q.String())
if dls[0].State == "queued" || dls[1].State == "queued" || dls[2].State != "queued" || dls[3].State == "queued" { }
t.Error("incorrect queued")
}
func TestUpdateMetadataPlaylist(t *testing.T) {
output := `
start of log...
[download] Downloading playlist: niceuser
[RedGifsUser] niceuser: Downloading JSON metadata page 1
[RedGifsUser] niceuser: Downloading JSON metadata page 2
[RedGifsUser] niceuser: Downloading JSON metadata page 3
[RedGifsUser] niceuser: Downloading JSON metadata page 4
[RedGifsUser] niceuser: Downloading JSON metadata page 5
[RedGifsUser] niceuser: Downloading JSON metadata page 6
[info] Writing playlist metadata as JSON to: niceuser [niceuser].info.json
[RedGifsUser] playlist niceuser: Downloading 3 videos
[download] Downloading video 1 of 3
[info] wrongpreciouschrysomelid: Downloading 1 format(s): hd
[info] Writing video metadata as JSON to: Splendid Wonderful Speaker Power Chocolate Drop [wrongpreciouschrysomelid].info.json
[download] Destination: Splendid Wonderful Speaker Power Chocolate Drop [wrongpreciouschrysomelid].mp4
[download] 0.0% of 4.96MiB at Unknown speed ETA Unknown
[download] 0.1% of 4.96MiB at 1.76MiB/s ETA 00:02
[download] 20.1% of 4.96MiB at 7.28MiB/s ETA 00:00
[download] 40.3% of 4.96MiB at 10.06MiB/s ETA 00:00
[download] 80.6% of 4.96MiB at 14.93MiB/s ETA 00:00
[download] 100% of 4.96MiB at 17.33MiB/s ETA 00:00
[download] 100% of 4.96MiB in 00:00
[download] Downloading video 2 of 3
[info] silentnaughtyborzoi: Downloading 1 format(s): hd
[info] Writing video metadata as JSON to: Splendid Printer Tray Computer Outdoor Window Wonderful [silentnaughtyborzoi].info.json
[download] Destination: Splendid Printer Tray Computer Outdoor Window Wonderful [silentnaughtyborzoi].mp4
[download] 0.0% of 5.81MiB at 896.03KiB/s ETA 00:06
[download] 0.1% of 5.81MiB at 1.28MiB/s ETA 00:04
[download] 0.1% of 5.81MiB at 1.59MiB/s ETA 00:03
[download] 34.4% of 5.81MiB at 9.90MiB/s ETA 00:00
[download] 68.8% of 5.81MiB at 12.49MiB/s ETA 00:00
[download] 100% of 5.81MiB at 15.77MiB/s ETA 00:00
[download] 100% of 5.81MiB in 00:00
[download] Downloading video 3 of 3
[info] mammothremarkablewhooper: Downloading 1 format(s): hd
[info] Writing video metadata as JSON to: Porthole Splendid Close Up Gun Gunshot Window Wonderful [mammothremarkablewhooper].info.json
[download] Destination: Porthole Splendid Close Up Gun Gunshot Window Wonderful [mammothremarkablewhooper].mp4
[download] 0.0% of 2.89MiB at Unknown speed ETA Unknown
[download] 0.1% of 2.89MiB at 1.77MiB/s ETA 00:01
[download] 0.2% of 2.89MiB at 2.26MiB/s ETA 00:01
[download] 34.5% of 2.89MiB at 8.23MiB/s ETA 00:00
[download] 69.1% of 2.89MiB at 11.63MiB/s ETA 00:00
[download] 100% of 2.89MiB at 14.25MiB/s ETA 00:00
[download] 100% of 2.89MiB in 00:00
[info] Writing updated playlist metadata as JSON to: niceuser [niceuser].info.json
[download] Finished downloading playlist: niceuser
`
newD := Download{}
lines := strings.Split(output, "\n")
for _, l := range lines {
// t.Log(l)
newD.updateMetadata(l)
}
if len(newD.Files) != 3 {
t.Errorf("%d files, not 3", len(newD.Files))
} else {
if newD.Files[0] != "Splendid Wonderful Speaker Power Chocolate Drop [wrongpreciouschrysomelid].mp4" {
t.Error("Wrong 1st file")
}
if newD.Files[1] != "Splendid Printer Tray Computer Outdoor Window Wonderful [silentnaughtyborzoi].mp4" {
t.Error("Wrong 2nd file")
}
if newD.Files[2] != "Porthole Splendid Close Up Gun Gunshot Window Wonderful [mammothremarkablewhooper].mp4" {
t.Error("Wrong 3rd file")
}
}
if newD.PlaylistTotal != 3 {
t.Errorf("playlist has total %d should be 3", newD.PlaylistTotal)
}
}
func TestUpdateMetadataSingle(t *testing.T) {
output := `
[youtube] 2WoDQBhJCVQ: Downloading webpage
[youtube] 2WoDQBhJCVQ: Downloading android player API JSON
[info] 2WoDQBhJCVQ: Downloading 1 format(s): 137+140
[info] Writing video metadata as JSON to: The Greatest Shot In Television [2WoDQBhJCVQ].info.json
[download] Destination: The Greatest Shot In Television [2WoDQBhJCVQ].f137.mp4
[download] 0.0% of 12.82MiB at 510.94KiB/s ETA 00:26
[download] 0.0% of 12.82MiB at 966.50KiB/s ETA 00:13
[download] 0.1% of 12.82MiB at 1.54MiB/s ETA 00:08
[download] 0.1% of 12.82MiB at 2.75MiB/s ETA 00:04
[download] 0.2% of 12.82MiB at 1.30MiB/s ETA 00:09
[download] 77.5% of 12.82MiB at 2.54MiB/s ETA 00:01
[download] 79.4% of 12.82MiB at 3.89MiB/s ETA 00:00
[download] 83.3% of 12.82MiB at 6.44MiB/s ETA 00:00
[download] 91.1% of 12.82MiB at 10.28MiB/s ETA 00:00
[download] 100% of 12.82MiB at 12.77MiB/s ETA 00:00
[download] 100% of 12.82MiB in 00:01
[download] Destination: The Greatest Shot In Television [2WoDQBhJCVQ].f140.m4a
[download] 0.1% of 1.10MiB at 286.46KiB/s ETA 00:03
[download] 0.3% of 1.10MiB at 716.49KiB/s ETA 00:01
[download] 0.6% of 1.10MiB at 1.42MiB/s ETA 00:00
[download] 91.0% of 1.10MiB at 6.67MiB/s ETA 00:00
[download] 100% of 1.10MiB at 7.06MiB/s ETA 00:00
[download] 100% of 1.10MiB in 00:00
[Merger] Merging formats into "The Greatest Shot In Television [2WoDQBhJCVQ].mp4"
Deleting original file The Greatest Shot In Television [2WoDQBhJCVQ].f137.mp4 (pass -k to keep)
Deleting original file The Greatest Shot In Television [2WoDQBhJCVQ].f140.m4a (pass -k to keep)
`
newD := Download{}
lines := strings.Split(output, "\n")
for _, l := range lines {
// t.Log(l)
newD.updateMetadata(l)
}
if len(newD.Files) != 1 {
t.Errorf("%d files, not 1", len(newD.Files))
} else {
if newD.Files[0] != "The Greatest Shot In Television [2WoDQBhJCVQ].mp4" {
t.Error("Wrong 1st file")
}
}
if newD.PlaylistTotal != 0 {
t.Error("playlist detected but should not be")
} }
} }

View File

@@ -0,0 +1,28 @@
//go:build testdata
package download
import "github.com/tardisx/gropple/config"
func (m *Manager) AddStressTestData(c *config.ConfigService) {
urls := []string{
"https://www.youtube.com/watch?v=qG_rRkuGBW8",
"https://www.youtube.com/watch?v=ZUzhZpQAU40",
"https://www.youtube.com/watch?v=kVxM3eRWGak",
"https://www.youtube.com/watch?v=pl-y9869y0w",
"https://vimeo.com/783453809",
"https://www.youtube.com/watch?v=Uw4NEPE4l3A",
"https://www.youtube.com/watch?v=2RF0lcTuuYE",
"https://www.youtube.com/watch?v=lymwNQY0dus",
"https://www.youtube.com/watch?v=NTc-I4Z_duc",
"https://www.youtube.com/watch?v=wNSm1TJ84Ac",
"https://vimeo.com/786570322",
}
for _, u := range urls {
d := NewDownload(u, c.Config)
d.DownloadProfile = *c.Config.ProfileCalled("standard video")
m.AddDownload(d)
m.Queue(d)
}
}

4
go.mod
View File

@@ -1,9 +1,9 @@
module github.com/tardisx/gropple module github.com/tardisx/gropple
go 1.16 go 1.20
require ( require (
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
golang.org/x/mod v0.5.1 golang.org/x/mod v0.9.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )

15
go.sum
View File

@@ -1,18 +1,7 @@
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

129
main.go
View File

@@ -3,6 +3,7 @@ package main
import ( import (
"embed" "embed"
"encoding/json" "encoding/json"
"flag"
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
@@ -19,11 +20,12 @@ import (
"github.com/tardisx/gropple/version" "github.com/tardisx/gropple/version"
) )
var downloads download.Downloads var dm *download.Manager
var downloadId = 0
var configService *config.ConfigService var configService *config.ConfigService
var versionInfo = version.Info{CurrentVersion: "v0.5.5"} var versionInfo = version.Manager{
VersionInfo: version.Info{CurrentVersion: "v0.6.0-alpha.3"},
}
//go:embed web //go:embed web
var webFS embed.FS var webFS embed.FS
@@ -39,10 +41,19 @@ type errorResponse struct {
} }
func main() { func main() {
log.Printf("Starting gropple %s - https://github.com/tardisx/gropple", versionInfo.CurrentVersion) log.Printf("Starting gropple %s - https://github.com/tardisx/gropple", versionInfo.GetInfo().CurrentVersion)
var configPath string
flag.StringVar(&configPath, "config-path", "", "path to config file")
flag.Parse()
configService = &config.ConfigService{} configService = &config.ConfigService{}
if configPath != "" {
configService.ConfigPath = configPath
} else {
configService.DetermineConfigDir() configService.DetermineConfigDir()
}
exists, err := configService.ConfigFileExists() exists, err := configService.ConfigFileExists()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -58,9 +69,11 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
log.Printf("Configuration loaded from %s", configService.ConfigPath) log.Printf("Configuration loaded from %s", configService.ConfigPath)
} }
// create the download manager
dm = &download.Manager{MaxPerDomain: configService.Config.Server.MaximumActiveDownloads}
r := mux.NewRouter() r := mux.NewRouter()
r.HandleFunc("/", homeHandler) r.HandleFunc("/", homeHandler)
r.HandleFunc("/static/{filename}", staticHandler) r.HandleFunc("/static/{filename}", staticHandler)
@@ -95,13 +108,8 @@ func main() {
// start downloading queued downloads when slots available, and clean up // start downloading queued downloads when slots available, and clean up
// old entries // old entries
go func() { go dm.ManageQueue()
for { dm.AddStressTestData(configService)
downloads.StartQueued(configService.Config.Server.MaximumActiveDownloads)
downloads = downloads.Cleanup()
time.Sleep(time.Second)
}
}()
log.Printf("Visit %s for details on installing the bookmarklet and to check status", configService.Config.Server.Address) log.Printf("Visit %s for details on installing the bookmarklet and to check status", configService.Config.Server.Address)
log.Fatal(srv.ListenAndServe()) log.Fatal(srv.ListenAndServe())
@@ -110,8 +118,8 @@ func main() {
// versionRESTHandler returns the version information, if we have up-to-date info from github // versionRESTHandler returns the version information, if we have up-to-date info from github
func versionRESTHandler(w http.ResponseWriter, r *http.Request) { func versionRESTHandler(w http.ResponseWriter, r *http.Request) {
if versionInfo.GithubVersionFetched { if versionInfo.GetInfo().GithubVersionFetched {
b, _ := json.Marshal(versionInfo) b, _ := json.Marshal(versionInfo.GetInfo())
w.Write(b) w.Write(b)
} else { } else {
w.WriteHeader(400) w.WriteHeader(400)
@@ -130,17 +138,21 @@ func homeHandler(w http.ResponseWriter, r *http.Request) {
} }
type Info struct { type Info struct {
Downloads []*download.Download Manager *download.Manager
BookmarkletURL template.URL BookmarkletURL template.URL
Config *config.Config Config *config.Config
Version version.Info
} }
info := Info{ info := Info{
Downloads: downloads, Manager: dm,
BookmarkletURL: template.URL(bookmarkletURL), BookmarkletURL: template.URL(bookmarkletURL),
Config: configService.Config, Config: configService.Config,
Version: versionInfo.GetInfo(),
} }
dm.Lock.Lock()
defer dm.Lock.Unlock()
err = t.ExecuteTemplate(w, "layout", info) err = t.ExecuteTemplate(w, "layout", info)
if err != nil { if err != nil {
panic(err) panic(err)
@@ -205,7 +217,6 @@ func configRESTHandler(w http.ResponseWriter, r *http.Request) {
w.Write(b) w.Write(b)
} }
//
func fetchInfoOneRESTHandler(w http.ResponseWriter, r *http.Request) { func fetchInfoOneRESTHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
idString := vars["id"] idString := vars["id"]
@@ -216,23 +227,21 @@ func fetchInfoOneRESTHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// find the download thisDownload, err := dm.GetDlById(id)
var thisDownload *download.Download if err != nil {
for _, dl := range downloads {
if dl.Id == id {
thisDownload = dl
}
}
if thisDownload == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if thisDownload == nil {
panic("should not happen")
}
if r.Method == "POST" { if r.Method == "POST" {
type updateRequest struct { type updateRequest struct {
Action string `json:"action"` Action string `json:"action"`
Profile string `json:"profile"` Profile string `json:"profile"`
Destination string `json:"destination"`
} }
thisReq := updateRequest{} thisReq := updateRequest{}
@@ -258,16 +267,35 @@ func fetchInfoOneRESTHandler(w http.ResponseWriter, r *http.Request) {
panic("bad profile name?") panic("bad profile name?")
} }
// set the profile // set the profile
thisDownload.Lock.Lock()
thisDownload.DownloadProfile = *profile thisDownload.DownloadProfile = *profile
thisDownload.Lock.Unlock()
dm.Queue(thisDownload)
thisDownload.Queue()
succRes := successResponse{Success: true, Message: "download started"} succRes := successResponse{Success: true, Message: "download started"}
succResB, _ := json.Marshal(succRes) succResB, _ := json.Marshal(succRes)
w.Write(succResB) w.Write(succResB)
return return
} }
if thisReq.Action == "change_destination" {
// nil means (probably) that they chose "don't move" - which is fine,
// and maps to nil on the Download (the default state).
destination := configService.Config.DestinationCalled(thisReq.Destination)
dm.ChangeDestination(thisDownload, destination)
// log.Printf("%#v", thisDownload)
succRes := successResponse{Success: true, Message: "destination changed"}
succResB, _ := json.Marshal(succRes)
w.Write(succResB)
return
}
if thisReq.Action == "stop" { if thisReq.Action == "stop" {
thisDownload.Stop() thisDownload.Stop()
succRes := successResponse{Success: true, Message: "download stopped"} succRes := successResponse{Success: true, Message: "download stopped"}
succResB, _ := json.Marshal(succRes) succResB, _ := json.Marshal(succRes)
@@ -277,7 +305,11 @@ func fetchInfoOneRESTHandler(w http.ResponseWriter, r *http.Request) {
} }
// just a get, return the object // just a get, return the object
thisDownload.Lock.Lock()
defer thisDownload.Lock.Unlock()
b, _ := json.Marshal(thisDownload) b, _ := json.Marshal(thisDownload)
w.Write(b) w.Write(b)
return return
} else { } else {
@@ -286,7 +318,11 @@ func fetchInfoOneRESTHandler(w http.ResponseWriter, r *http.Request) {
} }
func fetchInfoRESTHandler(w http.ResponseWriter, r *http.Request) { func fetchInfoRESTHandler(w http.ResponseWriter, r *http.Request) {
b, _ := json.Marshal(downloads)
b, err := dm.DownloadsAsJSON()
if err != nil {
panic(err)
}
w.Write(b) w.Write(b)
} }
@@ -298,9 +334,17 @@ func fetchHandler(w http.ResponseWriter, r *http.Request) {
idString := vars["id"] idString := vars["id"]
idInt, err := strconv.ParseInt(idString, 10, 32) idInt, err := strconv.ParseInt(idString, 10, 32)
// existing, load it up
if err == nil && idInt > 0 { if err == nil && idInt > 0 {
for _, dl := range downloads {
if dl.Id == int(idInt) { dl, err := dm.GetDlById(int(idInt))
if err != nil {
log.Printf("not found")
w.WriteHeader(404)
return
}
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/popup.html") t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/popup.html")
if err != nil { if err != nil {
panic(err) panic(err)
@@ -314,9 +358,6 @@ func fetchHandler(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
}
}
query := r.URL.Query() query := r.URL.Query()
url, present := query["url"] url, present := query["url"]
@@ -327,6 +368,7 @@ func fetchHandler(w http.ResponseWriter, r *http.Request) {
return return
} else { } else {
log.Printf("popup for %s", url)
// check the URL for a sudden but inevitable betrayal // check the URL for a sudden but inevitable betrayal
if strings.Contains(url[0], configService.Config.Server.Address) { if strings.Contains(url[0], configService.Config.Server.Address) {
w.WriteHeader(400) w.WriteHeader(400)
@@ -334,27 +376,28 @@ func fetchHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// create the record // create the new download
newDownload := download.NewDownload(configService.Config, url[0]) log.Print("creating")
downloads = append(downloads, newDownload) newDL := download.NewDownload(url[0], configService.Config)
// XXX atomic ^^ log.Print("adding")
dm.AddDownload(newDL)
newDownload.Log = append(newDownload.Log, "start of log...") log.Print("done")
// go func() {
// newDownload.Begin()
// }()
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/popup.html") t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/popup.html")
if err != nil { if err != nil {
panic(err) panic(err)
} }
templateData := map[string]interface{}{"dl": newDownload, "config": configService.Config, "canStop": download.CanStopDownload} log.Print("lock dl")
newDL.Lock.Lock()
defer newDL.Lock.Unlock()
templateData := map[string]interface{}{"Version": versionInfo.GetInfo(), "dl": newDL, "config": configService.Config, "canStop": download.CanStopDownload}
err = t.ExecuteTemplate(w, "layout", templateData) err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil { if err != nil {
panic(err) panic(err)
} }
log.Print("unlock dl because rendered")
} }
} }

View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"sync"
"golang.org/x/mod/semver" "golang.org/x/mod/semver"
) )
@@ -19,8 +20,24 @@ type Info struct {
GithubVersionFetched bool `json:"-"` GithubVersionFetched bool `json:"-"`
} }
func (i *Info) UpdateGitHubVersion() error { type Manager struct {
i.GithubVersionFetched = false VersionInfo Info
lock sync.Mutex
}
func (m *Manager) GetInfo() Info {
// log.Print("getting info... b4 lock")
m.lock.Lock()
defer m.lock.Unlock()
return m.VersionInfo
}
func (m *Manager) UpdateGitHubVersion() error {
m.lock.Lock()
m.VersionInfo.GithubVersionFetched = false
m.lock.Unlock()
versionUrl := "https://api.github.com/repos/tardisx/gropple/releases" versionUrl := "https://api.github.com/repos/tardisx/gropple/releases"
resp, err := http.Get(versionUrl) resp, err := http.Get(versionUrl)
if err != nil { if err != nil {
@@ -51,27 +68,30 @@ func (i *Info) UpdateGitHubVersion() error {
return errors.New("no releases found") return errors.New("no releases found")
} }
i.GithubVersion = releases[0].Name m.lock.Lock()
defer m.lock.Unlock()
m.VersionInfo.GithubVersion = releases[0].Name
m.VersionInfo.GithubVersionFetched = true
m.VersionInfo.UpgradeAvailable = m.canUpgrade()
i.GithubVersionFetched = true
i.UpgradeAvailable = i.canUpgrade()
return nil return nil
} }
func (i *Info) canUpgrade() bool { func (m *Manager) canUpgrade() bool {
if !i.GithubVersionFetched { if !m.VersionInfo.GithubVersionFetched {
return false return false
} }
if !semver.IsValid(i.CurrentVersion) { if !semver.IsValid(m.VersionInfo.CurrentVersion) {
log.Printf("current version %s is invalid", i.CurrentVersion) log.Printf("current version %s is invalid", m.VersionInfo.CurrentVersion)
} }
if !semver.IsValid(i.GithubVersion) { if !semver.IsValid(m.VersionInfo.GithubVersion) {
log.Printf("github version %s is invalid", i.GithubVersion) log.Printf("github version %s is invalid", m.VersionInfo.GithubVersion)
} }
if semver.Compare(i.CurrentVersion, i.GithubVersion) == -1 { if semver.Compare(m.VersionInfo.CurrentVersion, m.VersionInfo.GithubVersion) == -1 {
return true return true
} }
return false return false

View File

@@ -7,11 +7,17 @@
<p class="error" x-show="error_message" x-transition.duration.500ms x-text="error_message"></p> <p class="error" x-show="error_message" x-transition.duration.500ms x-text="error_message"></p>
<p class="success" x-show="success_message" x-transition.duration.500ms x-text="success_message"></p> <p class="success" x-show="success_message" x-transition.duration.500ms x-text="success_message"></p>
<p>Note: changes are not saved until the "Save Config" button is pressed at the bottom of the page.</p> <p>Note: changes are not saved until the "Save Config" button is pressed.</p>
<div class="pure-g">
<div class="pure-u-1">
<button class="pure-button pure-button-primary" @click="save_config();" href="#">Save Config</button>
</div>
</div>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-md-1-2 pure-u-1 l-box"> <div class="pure-u-lg-1-3 pure-u-1 l-box">
<form class="pure-form pure-form-stacked gropple-config"> <form class="pure-form pure-form-stacked gropple-config">
<fieldset> <fieldset>
@@ -31,7 +37,7 @@
<label for="config-server-downloadpath">Download path</label> <label for="config-server-downloadpath">Download path</label>
<input type="text" id="config-server-downloadpath" placeholder="path" class="input-long" x-model="config.server.download_path" /> <input type="text" id="config-server-downloadpath" placeholder="path" class="input-long" x-model="config.server.download_path" />
<span class="pure-form-message">The path on the server to download files to.</span> <span class="pure-form-message">The default path on the server to download files to.</span>
<label for="config-server-max-downloads">Maximum active downloads per domain</label> <label for="config-server-max-downloads">Maximum active downloads per domain</label>
<input type="text" id="config-server-max-downloads" placeholder="2" class="input-long" x-model.number="config.server.maximum_active_downloads_per_domain" /> <input type="text" id="config-server-max-downloads" placeholder="2" class="input-long" x-model.number="config.server.maximum_active_downloads_per_domain" />
@@ -53,7 +59,7 @@
</form> </form>
</div> </div>
<div class="pure-u-md-1-2 pure-u-1 l-box"> <div class="pure-u-lg-1-3 pure-u-1 l-box">
<form class="pure-form gropple-config"> <form class="pure-form gropple-config">
<fieldset> <fieldset>
@@ -103,6 +109,39 @@
</fieldset> </fieldset>
</form> </form>
</div> </div>
<div class="pure-u-lg-1-3 pure-u-1 l-box">
<form class="pure-form gropple-config">
<fieldset>
<legend>Destinations</legend>
<p>You can specify custom destinations (directories) here. Downloads can be
moved to one of these directories after completion from the index page,
if you do not want them to be left in the download path above.</p>
</p>
<template x-for="(dest, i) in config.destinations">
<div>
<label x-bind:for="'config-destinations-'+i+'-name'">Name of destination <span x-text="i+1"></span>
</label>
<input type="text" x-bind:id="'config-destinations-'+i+'-name'" class="input-long" placeholder="name" x-model="dest.name" />
<span class="pure-form-message">The name of this destination. For your information only.</span>
<label x-bind:for="'config-destinations-'+i+'-command'">Path</label>
<input type="text" x-bind:id="'config-destinations-'+i+'-command'" class="input-long" placeholder="name" x-model="dest.path" />
<span class="pure-form-message">Path to move completed downloads to.</span>
<button class="pure-button button-del" href="#" @click.prevent="config.destinations.splice(i, 1);">delete destination</button>
<hr>
</div>
</template>
<button class="pure-button button-add" href="#" @click.prevent="config.destinations.push({name: 'new destination', path: '/tmp'});">add destination</button>
</fieldset>
</form>
</div> </div>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1"> <div class="pure-u-1">
@@ -119,7 +158,7 @@
<script> <script>
function config() { function config() {
return { return {
config: { server : {}, ui : {}, profiles: [] }, config: { server : {}, ui : {}, profiles: [], destinations: []},
error_message: '', error_message: '',
success_message: '', success_message: '',

View File

@@ -31,7 +31,18 @@
<template x-for="item in items"> <template x-for="item in items">
<tr> <tr>
<td x-text="item.id"></td> <td x-text="item.id"></td>
<td x-text="item.files"></td> <td>
<span x-show="item.files && item.files.length > 0">
<ul>
<template x-for="file in item.files">
<li x-text="file"></li>
</template>
</ul>
</span>
<span x-show="! item.files || item.files.length == 0"
x-text="item.url">
</span>
</td>
<td><a class="int-link" x-bind:href="item.url">&#x2197;</a></td> <td><a class="int-link" x-bind:href="item.url">&#x2197;</a></td>
<td><a class="int-link" @click="show_popup(item)" href="#">&#x1F4C4;</a></td> <td><a class="int-link" @click="show_popup(item)" href="#">&#x1F4C4;</a></td>
<td :class="'state-'+item.state" x-text="item.state"></td> <td :class="'state-'+item.state" x-text="item.state"></td>
@@ -43,8 +54,6 @@
</template> </template>
{{ range $k, $v := .Downloads }}
{{ end }}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -54,7 +63,7 @@
<script> <script>
function index() { function index() {
return { return {
items: [], version: {}, items: [], version: {}, popups: {},
fetch_version() { fetch_version() {
fetch('/rest/version') fetch('/rest/version')
.then(response => response.json()) .then(response => response.json())
@@ -79,7 +88,9 @@
}) })
}, },
show_popup(item) { show_popup(item) {
window.open(item.popup_url, item.id, "width={{ .Config.UI.PopupWidth }},height={{ .Config.UI.PopupHeight }}"); // allegedly you can use the reference to pop the window to the front on subsequent
// clicks, but I can't seem to find a reliable way to do so.
this.popups[item.id] = window.open(item.popup_url, item.id, "width={{ .Config.UI.PopupWidth }},height={{ .Config.UI.PopupHeight }}");
}, },
} }
} }

View File

@@ -33,6 +33,9 @@
.state-downloading { .state-downloading {
color: blue; color: blue;
} }
.state-moved {
color: green;
}
.state-complete { .state-complete {
color: green; color: green;
} }
@@ -71,7 +74,8 @@
{{ template "content" . }} {{ template "content" . }}
<footer> <footer>
Homepage: <a href="https://github.com/tardisx/gropple">https://github.com/tardisx/gropple</a> Homepage: <a href="https://github.com/tardisx/gropple">https://github.com/tardisx/gropple</a><br>
Version: {{ .Version.CurrentVersion }}
</footer> </footer>
</body> </body>
{{ template "js" . }} {{ template "js" . }}

View File

@@ -15,7 +15,19 @@
</td> </td>
</tr> </tr>
<tr><th>current filename</th><td x-text="filename"></td></tr> <tr><th>current filename</th><td x-text="filename"></td></tr>
<tr>
<th>destination</th>
<td>
<select x-on:change="update_destination()" class="pure-input-1-2" x-model="destination_chosen">
<option value="-">leave in {{ .config.Server.DownloadPath }}</option>
{{ range $i := .config.Destinations }}
<option>{{ $i.Name }}</option>
{{ end }}
</select>
</td>
</tr>
<tr><th>state</th><td x-text="state"></td></tr> <tr><th>state</th><td x-text="state"></td></tr>
<tr x-show="playlist_total > 0"><th>playlist progress</th><td x-text="playlist_current + '/' + playlist_total"></td></tr>
<tr><th>progress</th><td x-text="percent"></td></tr> <tr><th>progress</th><td x-text="percent"></td></tr>
<tr><th>ETA</th><td x-text="eta"></td></tr> <tr><th>ETA</th><td x-text="eta"></td></tr>
@@ -37,7 +49,9 @@
history.replaceState(null, '', ['/fetch/{{ .dl.Id }}']) history.replaceState(null, '', ['/fetch/{{ .dl.Id }}'])
return { return {
eta: '', percent: 0.0, state: '??', filename: '', finished: false, log :'', eta: '', percent: 0.0, state: '??', filename: '', finished: false, log :'',
playlist_current: 0, playlist_total: 0,
profile_chosen: null, profile_chosen: null,
destination_chosen: null,
watch_profile() { watch_profile() {
this.$watch('profile_chosen', value => this.profile_chosen(value)) this.$watch('profile_chosen', value => this.profile_chosen(value))
}, },
@@ -54,6 +68,18 @@
console.log(info) console.log(info)
}) })
}, },
update_destination(name) {
let op = {
method: 'POST',
body: JSON.stringify({action: 'change_destination', destination: this.destination_chosen}),
headers: { 'Content-Type': 'application/json' }
};
fetch('/rest/fetch/{{ .dl.Id }}', op)
.then(response => response.json())
.then(info => {
console.log(info)
})
},
stop() { stop() {
let op = { let op = {
method: 'POST', method: 'POST',
@@ -73,7 +99,13 @@
this.eta = info.eta; this.eta = info.eta;
this.percent = info.percent + "%"; this.percent = info.percent + "%";
this.state = info.state; this.state = info.state;
if (this.state != 'choose profile') { this.playlist_current = info.playlist_current;
this.playlist_total = info.playlist_total;
this.destination_chosen = null;
if (info.destination) {
this.destination_chosen = info.destination.name;
}
if (this.state != 'Choose Profile') {
this.profile_chosen = true; this.profile_chosen = true;
} }
this.finished = info.finished; this.finished = info.finished;