58 Commits

Author SHA1 Message Date
c31d25c048 Launch.json 2023-11-22 08:33:11 +10:30
c806ee8905 Clean up file list 2023-11-22 08:32:36 +10:30
d0e61d1247 Prepare for goreleaser 2023-11-22 08:32:20 +10:30
746a65dc80 Update .gitignore 2023-11-22 08:31:58 +10:30
d51a703820 Tidy up file list 2023-11-21 22:05:13 +10:30
d73c38ddc3 Perform host/path substitutions 2023-11-21 22:04:52 +10:30
e65ae41a4a Fix version display 2023-11-20 21:22:48 +10:30
5bd7601faa More debugging if test fails 2023-11-20 21:02:45 +10:30
a1e6421842 Destinations are now DownloadOptions 2023-11-20 21:01:50 +10:30
fa978fecc2 Spelling 2023-11-20 20:57:46 +10:30
12e9b83916 Update dependencies 2023-11-20 07:39:38 +10:30
adb9922b52 Refactor web (#26) 2023-11-20 07:38:16 +10:30
6e2c8d17a1 I am a numpty, put the testdata in the right place. 2023-11-19 20:58:26 +10:30
fe884799c7 Add an option to start with some test downloads queued 2023-11-11 13:07:26 +10:30
329d7703a0 More detail in README 2023-03-15 05:46:25 +10:30
5d1f4ffadb Update changelog, skip docker for now until it is ready 2023-03-15 05:18:56 +10:30
385de634f6 Fix Stop button 2023-03-15 05:15:49 +10:30
431ef985bc Cleanup spruious logs 2023-03-15 05:15:25 +10:30
a5e201c290 Restore cleanup 2023-03-15 05:04:02 +10:30
d650725523 Clean up documentation 2023-03-15 05:00:56 +10:30
7f0a51d659 New alpha 2023-03-15 04:46:14 +10:30
4909f63c93 Appease the linter 2023-03-15 04:45:10 +10:30
c8f10e01c7 Cleanup for release 2023-03-15 04:33:48 +10:30
cf7efa70ee Adjust log display to always show all entries. Fixes #22. 2023-03-13 11:43:08 +10:30
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
50a6ac9e85 Fix bug migrating config, add test 2022-04-09 12:26:56 +09:30
3bbc715e74 Add funding config 2022-04-09 11:32:27 +09:30
30 changed files with 1649 additions and 586 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: tardisx

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
gropple gropple
dist
release release
dist
.env

53
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,53 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 1
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
# changelog:
# sort: asc
# filters:
# exclude:
# - "^docs:"
# - "^test:"
dockers:
- image_templates:
- "tardisx/gropple:{{ .Tag }}"
- "tardisx/gropple:v{{ .Major }}"
- "tardisx/gropple:v{{ .Major }}.{{ .Minor }}"
- "tardisx/gropple:latest"

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}"
}
]
}

View File

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

View File

@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
## [v0.6.0] - 2023-03-15
- 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)
- Fixes and improvements to capturing output info and showing it in the UI
- Show all log output in the popup
- Fixes to handling of queued downloads
- Fix portable mode to look in binary directory, not current directory
- Automatically cleanup download list, removing old entries automatically
## [v0.5.5] - 2022-04-09
- Fix a bug which would erase configuration when migrating from v1 to v2 config
## [v0.5.4] - 2022-04-07 ## [v0.5.4] - 2022-04-07
- Check the chosen command exists when configuring a profile - Check the chosen command exists when configuring a profile

9
Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM ubuntu:mantic
COPY gropple /
RUN apt update && apt install -y curl python3 ffmpeg
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/bin/yt-dlp
RUN chmod a+x /usr/bin/yt-dlp
# Run executable
CMD ["/gropple", "--config-path", "/config/gropple.json"]

View File

@@ -16,9 +16,10 @@ A frontend to youtube-dl (or compatible forks, like yt-dlp) to download videos w
## Binaries ## Binaries
Binaries are available at https://github.com/tardisx/gropple/releases Binaries are available at <https://github.com/tardisx/gropple/releases>
Gropple will automatically check for available updates and prompt you to upgrade. Gropple will automatically check for available updates and prompt you to
upgrade.
## Running ## Running
@@ -32,66 +33,79 @@ interface. The address will be printed after startup:
## Using ## Using
Bring up `http://localhost:6283` (or your configured address) in your browser. You Bring up `http://localhost:6283` (or your configured address) in your browser.
should see a link to the bookmarklet at the top of the screen, and the list of You should see a link to the bookmarklet at the top of the screen, and the list
downloads (currently empty). of downloads (currently empty).
Drag the bookmarklet to your favourites bar, or otherwise bookmark it as you Drag the bookmarklet to your favourites bar, or otherwise bookmark it as you see
see fit. Any kind of browser bookmark should work. The bookmarklet contains fit. Any kind of browser bookmark should work. The bookmarklet contains embedded
embedded javascript to pass the URL of whatever page you are currently on back javascript to pass the URL of whatever page you are currently on back to
to gropple. gropple.
So, whenever you are on a page with a video you would like to download just Whenever you are on a page with a video you would like to download just click
click the bookmarklet. the bookmarklet.
A popup window will appear. Choose a download profile and the download will start. A popup window will appear. Choose a download profile and the download will
The status will be shown in the window, updating in real time. start. The status will be shown in the window, updating in real time.
You may close this window at any time without stopping the download, the status You may close this window at any time without stopping the download, the status
of all downloads is available on the index page. of all downloads is available on the index page.
## Configuration ## Configuration
Click the "config" link on the index page to configure gropple. The default options Click the "config" link on the index page to configure gropple. The default
are fine if you are running on your local machine. If you are running it remotely options are fine if you are running on your local machine. If you are running it
you will need to set the "server address" to ensure the bookmarklet has the correct remotely you will need to set the "server address" to ensure the bookmarklet has
URL in it. the correct URL in it.
### Configuring Downloaders ### Configuring Downloaders
Gropple's default configuration uses the original youtube-dl and has two profiles set Gropple's default configuration uses `yt-dlp` and has two profiles set up, one
up, one for downloading video, the other for downloading audio (mp3). for downloading video, the other for downloading audio (mp3).
Note that gropple does not include any downloaders, you have to install them separately. Note that gropple does not include any downloaders, you have to install them
separately.
If you would like to use a youtube-dl fork (like [yt-dlp](https://github.com/yt-dlp/yt-dlp)) If you would like to use a youtube-dl compatible fork or change the options you
or change the options, you can do so on the right hand side. Create as many profiles as you can do so on the right hand side. Create as many profiles as you wish, whenever
wish, whenever you start a download you can choose the appropriate profile. you start a download you can choose the appropriate profile.
Note that the command arguments must each be specified separately - see the default configuration Note that the command arguments must each be specified separately - see the
for an example. default configuration for an example.
While gropple will use your `PATH` to find the executable, you can also specify a full path While gropple will use your `PATH` to find the executable, you can also specify
instead. Note that any tools that the downloader calls itself (for instance, ffmpeg) will a full path instead. Note that any tools that the downloader calls itself (for
probably need to be available on your path. instance, `ffmpeg`) will need to be available on your path.
### Alternate destinations
Gropple supports adding additional optional destinations. By default, all
downloads will be stored in the main download path specified in the config. You
can also add one or more destinations, and you can choose one of these
destinations when queueing a new download, or while it is still downloading from
the popup.
The file will be moved after downloading is complete.
## Portable mode ## Portable mode
If you'd like to use gropple from a USB stick or similar, copy the config file from If you'd like to use gropple from a USB stick or similar, copy the config file
it's default location (shown when you start gropple) to the same location as the binary, and rename it to `gropple.yml`. from its default location (shown when you start gropple) to the same location as
the binary, and rename it to `gropple.yml`.
If that file is present in the same directory as the binary, it will be used instead.
## Problems ## Problems
Most download problems are probably diagnosable via the log - check in the popup window and scroll Many download problems are diagnosable via the log - check in the popup window
the log down to the bottom. The most common problem is that youtube-dl cannot be found, or its and scroll the log down to the bottom. The most common problem is that `yt-dlp`
dependency (like ffmpeg) cannot be found on your path. cannot be found, or its dependency (like `ffmpeg`) cannot be found on your path.
Gropple only calls external tools like `yt-dlp` to do the downloading. If you
are having problems downloading from a site, make sure that `yt-dlp` is updated
to the latest version (`yd-dlp -U`).
For other problems, please file an issue on github. For other problems, please file an issue on github.
## TODO ## TODO
Many things. Please raise an issue after checking the [currently open issues](https://github.com/tardisx/gropple/issues). Many things. Please raise an issue after checking the [currently open
issues](https://github.com/tardisx/gropple/issues).

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,12 @@ 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
exit 0;
$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,22 +20,39 @@ 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"`
} }
// DownloadOption contains configuration for extra arguments to pass to the download command
type DownloadOption struct {
Name string `yaml:"name" json:"name"`
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"` // no longer in use, see DownloadOptions
DownloadProfiles []DownloadProfile `yaml:"profiles" json:"profiles"` DownloadProfiles []DownloadProfile `yaml:"profiles" json:"profiles"`
DownloadOptions []DownloadOption `yaml:"download_options" json:"download_options"`
} }
// ConfigService is a struct to handle configuration requests, allowing for the // ConfigService is a struct to handle configuration requests, allowing for the
@@ -46,18 +64,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,20 +88,23 @@ 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 = nil
defaultConfig.DownloadOptions = make([]DownloadOption, 0)
defaultConfig.ConfigVersion = 3
cs.Config = &defaultConfig cs.Config = &defaultConfig
return
} }
// ProfileCalled returns the corresponding DownloadProfile, or nil if it does not exist
func (c *Config) ProfileCalled(name string) *DownloadProfile { func (c *Config) ProfileCalled(name string) *DownloadProfile {
for _, p := range c.DownloadProfiles { for _, p := range c.DownloadProfiles {
if p.Name == name { if p.Name == name {
@@ -92,6 +114,16 @@ func (c *Config) ProfileCalled(name string) *DownloadProfile {
return nil return nil
} }
// DownloadOptionCalled returns the corresponding DownloadOption, or nil if it does not exist
func (c *Config) DownloadOptionCalled(name string) *DownloadOption {
for _, o := range c.DownloadOptions {
if o.Name == name {
return &o
}
}
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)
@@ -169,12 +201,24 @@ func (c *Config) UpdateFromJSON(j []byte) error {
// 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
return config, err := filepath.Abs(potentialConfigPath)
if err == nil {
log.Printf("found portable config in %s", config)
cs.ConfigPath = config
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
@@ -224,6 +268,8 @@ func (cs *ConfigService) LoadConfig() error {
return fmt.Errorf("Could not read config '%s': %v", path, err) return fmt.Errorf("Could not read config '%s': %v", path, err)
} }
c := Config{} c := Config{}
cs.Config = &c
err = yaml.Unmarshal(b, &c) err = yaml.Unmarshal(b, &c)
if err != nil { if err != nil {
return fmt.Errorf("Could not parse YAML config '%s': %v", path, err) return fmt.Errorf("Could not parse YAML config '%s': %v", path, err)
@@ -236,6 +282,28 @@ 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 c.ConfigVersion == 3 {
c.ConfigVersion = 4
for i := range c.Destinations {
newDownloadOption := DownloadOption{
Name: c.Destinations[i].Name,
Args: []string{"-o", fmt.Sprintf("%s/%%(title)s [%%(id)s].%%(ext)s", c.Destinations[i].Path)},
}
c.DownloadOptions = append(c.DownloadOptions, newDownloadOption)
c.Destinations = nil
}
configMigrated = true
log.Print("migrated config from version 3 => 4")
} }
if configMigrated { if configMigrated {
@@ -243,8 +311,6 @@ func (cs *ConfigService) LoadConfig() error {
cs.WriteConfig() cs.WriteConfig()
} }
cs.Config = &c
return nil return nil
} }
@@ -265,6 +331,9 @@ func (cs *ConfigService) WriteConfig() {
} }
defer file.Close() defer file.Close()
file.Write(s) _, err = file.Write(s)
if err != nil {
log.Fatalf("could not write config file %s: %s", path, err)
}
file.Close() file.Close()
} }

114
config/config_test.go Normal file
View File

@@ -0,0 +1,114 @@
package config
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMigrationV1toV4(t *testing.T) {
v1Config := `config_version: 1
server:
port: 6123
address: http://localhost:6123
download_path: ./
ui:
popup_width: 500
popup_height: 500
profiles:
- name: standard video
command: youtube-dl
args:
- --newline
- --write-info-json
- -f
- bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best
- name: standard mp3
command: youtube-dl
args:
- --newline
- --write-info-json
- --extract-audio
- --audio-format
- mp3
`
cs := configServiceFromString(v1Config)
err := cs.LoadConfig()
if err != nil {
t.Errorf("got error when loading config: %s", err)
}
if cs.Config.ConfigVersion != 4 {
t.Errorf("did not migrate version (it is '%d')", cs.Config.ConfigVersion)
}
if cs.Config.Server.MaximumActiveDownloads != 2 {
t.Error("did not add MaximumActiveDownloads")
}
if len(cs.Config.Destinations) != 0 {
t.Error("incorrect number of destinations added")
}
os.Remove(cs.ConfigPath)
}
func TestMigrateV3toV4(t *testing.T) {
v3Config := `config_version: 3
server:
port: 6123
address: http://localhost:6123
download_path: /tmp/Downloads
maximum_active_downloads_per_domain: 2
ui:
popup_width: 900
popup_height: 900
destinations:
- name: cool destination
path: /tmp/coolness
profiles:
- name: standard video
command: yt-dlp
args:
- --newline
- --write-info-json
- -f
- bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best
- name: standard mp3
command: yt-dlp
args:
- --newline
- --write-info-json
- --extract-audio
- --audio-format
- mp3`
cs := configServiceFromString(v3Config)
err := cs.LoadConfig()
if err != nil {
t.Errorf("got error when loading config: %s", err)
}
if cs.Config.ConfigVersion != 4 {
t.Errorf("did not migrate version (it is '%d')", cs.Config.ConfigVersion)
}
if cs.Config.Server.MaximumActiveDownloads != 2 {
t.Error("did not add MaximumActiveDownloads")
}
if len(cs.Config.Destinations) != 0 {
t.Error("incorrect number of destinations from migrated file")
}
if assert.Len(t, cs.Config.DownloadOptions, 1) {
if assert.Len(t, cs.Config.DownloadOptions[0].Args, 2) {
assert.Equal(t, "-o", cs.Config.DownloadOptions[0].Args[0])
assert.Equal(t, "/tmp/coolness/%(title)s [%(id)s].%(ext)s", cs.Config.DownloadOptions[0].Args[1])
}
}
os.Remove(cs.ConfigPath)
}
func configServiceFromString(configString string) *ConfigService {
tmpFile, _ := os.CreateTemp("", "gropple_test_*.yml")
tmpFile.Write([]byte(configString))
tmpFile.Close()
cs := ConfigService{
Config: &Config{},
ConfigPath: tmpFile.Name(),
}
return &cs
}

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,203 @@ 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"`
DownloadOption *config.DownloadOption `json:"download_option"`
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.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
}
// 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. Expects the Manager to be locked.
func (dls Downloads) Cleanup() Downloads { func (m *Manager) cleanup() {
newDLs := Downloads{} newDLs := []*Download{}
for _, dl := range dls { for _, dl := range m.Downloads {
dl.Lock.Lock()
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() dl.Lock.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()
dl.mutex.Lock() defer dl.Lock.Unlock()
defer dl.mutex.Unlock() dl.State = STATE_QUEUED
dl.State = "queued"
} }
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() err := dl.Process.Kill()
dl.Process.Kill() if err != nil {
log.Printf("could not send kill to process: %s", err)
}
} }
// 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,55 +235,97 @@ 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()
u, err := url.Parse(dl.Url)
if err != nil {
log.Printf("Bad url '%s': %s", dl.Url, err.Error())
}
dl.mutex.Lock() // grab the host and path for substitutions
host := u.Host
path := u.Path
dl.State = "downloading" // strip the leading /
if strings.Index(path, "/") == 0 {
path = path[1:]
}
// escape them in a way that should mean we can use them as a filepath
host = strings.ReplaceAll(host, string(filepath.Separator), "_")
host = strings.ReplaceAll(host, string(filepath.ListSeparator), "_")
path = strings.ReplaceAll(path, string(filepath.Separator), "_")
path = strings.ReplaceAll(path, string(filepath.ListSeparator), "_")
dl.State = STATE_DOWNLOADING
cmdSlice := []string{} cmdSlice := []string{}
cmdSlice = append(cmdSlice, dl.DownloadProfile.Args...)
for i := range dl.DownloadProfile.Args {
arg := dl.DownloadProfile.Args[i]
arg = strings.ReplaceAll(arg, "%GROPPLE_HOST%", host)
arg = strings.ReplaceAll(arg, "%GROPPLE_PATH%", path)
cmdSlice = append(cmdSlice, arg)
}
// add the option, if any
if dl.DownloadOption != nil {
for i := range dl.DownloadOption.Args {
arg := dl.DownloadOption.Args[i]
arg = strings.ReplaceAll(arg, "%GROPPLE_HOST%", host)
arg = strings.ReplaceAll(arg, "%GROPPLE_PATH%", path)
cmdSlice = append(cmdSlice, arg)
}
}
// only add the url if it's not empty or an example URL. This helps us with testing // only add the url if it's not empty or an example URL. This helps us with testing
if !(dl.Url == "" || strings.Contains(dl.domain(), "example.org")) { if !(dl.Url == "" || strings.Contains(dl.domain(), "example.org")) {
cmdSlice = append(cmdSlice, dl.Url) cmdSlice = append(cmdSlice, dl.Url)
} }
dl.Log = append(dl.Log, fmt.Sprintf("executing: %s with args: %s", dl.DownloadProfile.Command, strings.Join(cmdSlice, " ")))
cmd := exec.Command(dl.DownloadProfile.Command, cmdSlice...) cmd := exec.Command(dl.DownloadProfile.Command, cmdSlice...)
cmd.Dir = dl.Config.Server.DownloadPath cmd.Dir = dl.Config.Server.DownloadPath
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)
@@ -208,24 +337,39 @@ func (dl *Download) Begin() {
}() }()
wg.Wait() wg.Wait()
cmd.Wait()
dl.mutex.Lock() err = cmd.Wait()
log.Printf("Process finished for id: %d (%v)", dl.Id, cmd) dl.Lock.Lock()
dl.State = "complete" if err != nil {
dl.Finished = true log.Printf("process failed for id: %d: %s", dl.Id, err)
dl.FinishedTS = time.Now()
dl.ExitCode = cmd.ProcessState.ExitCode()
if dl.ExitCode != 0 { dl.State = STATE_FAILED
dl.State = "failed" dl.Finished = true
dl.FinishedTS = time.Now()
dl.ExitCode = cmd.ProcessState.ExitCode()
} else {
log.Printf("process finished for id: %d (%v)", dl.Id, cmd)
dl.State = STATE_COMPLETE
dl.Finished = true
dl.FinishedTS = time.Now()
dl.ExitCode = cmd.ProcessState.ExitCode()
if dl.ExitCode != 0 {
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 +384,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 +399,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 +454,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"
@@ -19,11 +21,11 @@ func TestUpdateMetadata(t *testing.T) {
// eta's might be xx:xx:xx or xx:xx // eta's might be xx:xx:xx or xx:xx
newD.updateMetadata("[download] 0.0% of 504.09MiB at 135.71KiB/s ETA 01:03:36") newD.updateMetadata("[download] 0.0% of 504.09MiB at 135.71KiB/s ETA 01:03:36")
if newD.Eta != "01:03:36" { if newD.Eta != "01:03:36" {
t.Fatalf("bad long eta in dl\n%v", newD) t.Fatalf("bad long eta in dl\n%#v", newD)
} }
newD.updateMetadata("[download] 0.0% of 504.09MiB at 397.98KiB/s ETA 21:38") newD.updateMetadata("[download] 0.0% of 504.09MiB at 397.98KiB/s ETA 21:38")
if newD.Eta != "21:38" { if newD.Eta != "21:38" {
t.Fatalf("bad short eta in dl\n%v", newD) t.Fatalf("bad short eta in dl\n%#v", newD)
} }
// added a new file, now we are tracking two // added a new file, now we are tracking two
@@ -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.Millisecond * 100) time.Sleep(time.Second * 5.0)
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_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())
} }
dls.StartQueued(2) // 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[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 // this should start no more, as one is still going
dls[0].State = "queued" q.startQueued(1)
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 {
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(2)
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
if q.Downloads[0].State != STATE_COMPLETE {
// first two should be running, third not (same domain) and 4th running (different domain) 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("incorrect queued") }
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_DOWNLOADING {
t.Errorf("#3 is not downloading - %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())
}
}
func TestUpdateMetadataPlaylist(t *testing.T) {
output := `
start of log...
[download] Downloading playlist: nice_user
[RedGifsUser] nice_user: Downloading JSON metadata page 1
[RedGifsUser] nice_user: Downloading JSON metadata page 2
[RedGifsUser] nice_user: Downloading JSON metadata page 3
[RedGifsUser] nice_user: Downloading JSON metadata page 4
[RedGifsUser] nice_user: Downloading JSON metadata page 5
[RedGifsUser] nice_user: Downloading JSON metadata page 6
[info] Writing playlist metadata as JSON to: nice_user [nice_user].info.json
[RedGifsUser] playlist nice_user: 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: nice_user [nice_user].info.json
[download] Finished downloading playlist: nice_user
`
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,41 @@
//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://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://www.youtube.com/watch?v=tyixMpuGEL8",
"https://www.youtube.com/watch?v=VnxbkH_3E_4",
"https://www.youtube.com/watch?v=VStscvYLYLs",
"https://www.youtube.com/watch?v=vYMiSz-WlEY",
"https://vimeo.com/786570322",
"https://vimeo.com/783453809",
"https://www.gamespot.com/videos/survival-fps-how-metro-2033-solidified-a-subgenre/2300-6408243/",
"https://www.gamespot.com/videos/dirt-3-right-back-where-you-started-gameplay-movie/2300-6314712/",
"https://www.gamespot.com/videos/the-b-list-driver-san-francisco/2300-6405593/",
"https://www.imdb.com/video/vi1914750745/?listId=ls053181649&ref_=hm_hp_i_hero-video-1_1",
"https://www.imdb.com/video/vi3879585561/?listId=ls053181649&ref_=vp_pl_ap_6",
"https://www.imdb.com/video/vi54445849/?listId=ls053181649&ref_=vp_nxt_btn",
}
for _, u := range urls {
d := NewDownload(u, c.Config)
d.DownloadProfile = *c.Config.ProfileCalled("standard video")
m.AddDownload(d)
m.Queue(d)
}
}

13
go.mod
View File

@@ -1,9 +1,16 @@
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.1
golang.org/x/mod v0.5.1 github.com/stretchr/testify v1.8.4
golang.org/x/mod v0.14.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

27
go.sum
View File

@@ -1,19 +1,16 @@
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

334
main.go
View File

@@ -1,48 +1,36 @@
package main package main
import ( import (
"embed" "flag"
"encoding/json"
"fmt" "fmt"
"html/template"
"io"
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
"strconv"
"github.com/gorilla/mux"
"github.com/tardisx/gropple/config" "github.com/tardisx/gropple/config"
"github.com/tardisx/gropple/download" "github.com/tardisx/gropple/download"
"github.com/tardisx/gropple/version" "github.com/tardisx/gropple/version"
"github.com/tardisx/gropple/web"
) )
var downloads download.Downloads
var downloadId = 0
var configService *config.ConfigService
var versionInfo = version.Info{CurrentVersion: "v0.5.4"}
//go:embed web
var webFS embed.FS
type successResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type errorResponse struct {
Success bool `json:"success"`
Error string `json:"error"`
}
func main() { func main() {
log.Printf("Starting gropple %s - https://github.com/tardisx/gropple", versionInfo.CurrentVersion) versionInfo := &version.Manager{
VersionInfo: version.Info{CurrentVersion: "v0.7.0"},
}
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{}
if configPath != "" {
configService.ConfigPath = configPath
} else {
configService.DetermineConfigDir()
}
configService = &config.ConfigService{}
configService.DetermineConfigDir()
exists, err := configService.ConfigFileExists() exists, err := configService.ConfigFileExists()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -58,24 +46,13 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
log.Printf("Configuration loaded from %s", configService.ConfigPath) log.Printf("Configuration loaded from %s", configService.ConfigPath)
} }
r := mux.NewRouter() // create the download manager
r.HandleFunc("/", homeHandler) downloadManager := &download.Manager{MaxPerDomain: configService.Config.Server.MaximumActiveDownloads}
r.HandleFunc("/static/{filename}", staticHandler)
r.HandleFunc("/config", configHandler)
r.HandleFunc("/fetch", fetchHandler)
r.HandleFunc("/fetch/{id}", fetchHandler)
// info for the list // create the web handlers
r.HandleFunc("/rest/fetch", fetchInfoRESTHandler) r := web.CreateRoutes(configService, downloadManager, versionInfo)
// info for one, including update
r.HandleFunc("/rest/fetch/{id}", fetchInfoOneRESTHandler)
r.HandleFunc("/rest/version", versionRESTHandler)
r.HandleFunc("/rest/config", configRESTHandler)
http.Handle("/", r)
srv := &http.Server{ srv := &http.Server{
Handler: r, Handler: r,
@@ -88,273 +65,22 @@ func main() {
// check for a new version every 4 hours // check for a new version every 4 hours
go func() { go func() {
for { for {
versionInfo.UpdateGitHubVersion() err := versionInfo.UpdateGitHubVersion()
if err != nil {
log.Printf("could not get version info: %s", err)
}
time.Sleep(time.Hour * 4) time.Sleep(time.Hour * 4)
} }
}() }()
// 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 downloadManager.ManageQueue()
for {
downloads.StartQueued(configService.Config.Server.MaximumActiveDownloads) // add testdata if compiled with the '-tags testdata' flag
downloads = downloads.Cleanup() downloadManager.AddStressTestData(configService)
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())
} }
// versionRESTHandler returns the version information, if we have up-to-date info from github
func versionRESTHandler(w http.ResponseWriter, r *http.Request) {
if versionInfo.GithubVersionFetched {
b, _ := json.Marshal(versionInfo)
w.Write(b)
} else {
w.WriteHeader(400)
}
}
// homeHandler returns the main index page
func homeHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
bookmarkletURL := fmt.Sprintf("javascript:(function(f,s,n,o){window.open(f+encodeURIComponent(s),n,o)}('%s/fetch?url=',window.location,'yourform','width=%d,height=%d'));", configService.Config.Server.Address, configService.Config.UI.PopupWidth, configService.Config.UI.PopupHeight)
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/menu.tmpl", "web/index.html")
if err != nil {
panic(err)
}
type Info struct {
Downloads []*download.Download
BookmarkletURL template.URL
Config *config.Config
}
info := Info{
Downloads: downloads,
BookmarkletURL: template.URL(bookmarkletURL),
Config: configService.Config,
}
err = t.ExecuteTemplate(w, "layout", info)
if err != nil {
panic(err)
}
}
// staticHandler handles requests for static files
func staticHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
if strings.Index(filename, ".js") == len(filename)-3 {
f, err := webFS.Open("web/" + filename)
if err != nil {
log.Printf("error accessing %s - %v", filename, err)
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
io.Copy(w, f)
return
}
w.WriteHeader(http.StatusNotFound)
}
// configHandler returns the configuration page
func configHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/menu.tmpl", "web/config.html")
if err != nil {
panic(err)
}
err = t.ExecuteTemplate(w, "layout", nil)
if err != nil {
panic(err)
}
}
// configRESTHandler handles both reading and writing of the configuration
func configRESTHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
log.Printf("Updating config")
b, err := io.ReadAll(r.Body)
if err != nil {
panic(err)
}
err = configService.Config.UpdateFromJSON(b)
if err != nil {
errorRes := errorResponse{Success: false, Error: err.Error()}
errorResB, _ := json.Marshal(errorRes)
w.WriteHeader(400)
w.Write(errorResB)
return
}
configService.WriteConfig()
}
b, _ := json.Marshal(configService.Config)
w.Write(b)
}
//
func fetchInfoOneRESTHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idString := vars["id"]
if idString != "" {
id, err := strconv.Atoi(idString)
if err != nil {
http.NotFound(w, r)
return
}
// find the download
var thisDownload *download.Download
for _, dl := range downloads {
if dl.Id == id {
thisDownload = dl
}
}
if thisDownload == nil {
http.NotFound(w, r)
return
}
if r.Method == "POST" {
type updateRequest struct {
Action string `json:"action"`
Profile string `json:"profile"`
}
thisReq := updateRequest{}
b, err := io.ReadAll(r.Body)
if err != nil {
panic(err)
}
err = json.Unmarshal(b, &thisReq)
if err != nil {
errorRes := errorResponse{Success: false, Error: err.Error()}
errorResB, _ := json.Marshal(errorRes)
w.WriteHeader(400)
w.Write(errorResB)
return
}
if thisReq.Action == "start" {
// find the profile they asked for
profile := configService.Config.ProfileCalled(thisReq.Profile)
if profile == nil {
panic("bad profile name?")
}
// set the profile
thisDownload.DownloadProfile = *profile
thisDownload.Queue()
succRes := successResponse{Success: true, Message: "download started"}
succResB, _ := json.Marshal(succRes)
w.Write(succResB)
return
}
if thisReq.Action == "stop" {
thisDownload.Stop()
succRes := successResponse{Success: true, Message: "download stopped"}
succResB, _ := json.Marshal(succRes)
w.Write(succResB)
return
}
}
// just a get, return the object
b, _ := json.Marshal(thisDownload)
w.Write(b)
return
} else {
http.NotFound(w, r)
}
}
func fetchInfoRESTHandler(w http.ResponseWriter, r *http.Request) {
b, _ := json.Marshal(downloads)
w.Write(b)
}
func fetchHandler(w http.ResponseWriter, r *http.Request) {
// if they refreshed the popup, just load the existing object, don't
// create a new one
vars := mux.Vars(r)
idString := vars["id"]
idInt, err := strconv.ParseInt(idString, 10, 32)
if err == nil && idInt > 0 {
for _, dl := range downloads {
if dl.Id == int(idInt) {
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/popup.html")
if err != nil {
panic(err)
}
templateData := map[string]interface{}{"dl": dl, "config": configService.Config, "canStop": download.CanStopDownload}
err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil {
panic(err)
}
return
}
}
}
query := r.URL.Query()
url, present := query["url"]
if !present {
w.WriteHeader(400)
fmt.Fprint(w, "No url supplied")
return
} else {
// check the URL for a sudden but inevitable betrayal
if strings.Contains(url[0], configService.Config.Server.Address) {
w.WriteHeader(400)
fmt.Fprint(w, "you mustn't gropple your gropple :-)")
return
}
// create the record
newDownload := download.NewDownload(configService.Config, url[0])
downloads = append(downloads, newDownload)
// XXX atomic ^^
newDownload.Log = append(newDownload.Log, "start of log...")
// go func() {
// newDownload.Begin()
// }()
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/popup.html")
if err != nil {
panic(err)
}
templateData := map[string]interface{}{"dl": newDownload, "config": configService.Config, "canStop": download.CanStopDownload}
err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil {
panic(err)
}
}
}

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,23 @@ 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 {
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 +67,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,12 +7,18 @@
<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="button-small pure-button button-small 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,14 +59,14 @@
</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>
<legend>Download Profiles</legend> <legend>Download Profiles</legend>
<p>Gropple supports multiple download profiles. Each profile specifies a different youtube-dl <p>Gropple supports multiple download profiles. Each profile specifies a different youtube-dl
compatible command, and arguments. When starting a download, you may choose which profile compatible command, and arguments. When starting a download, you may choose which profile
to use. The URL will be appended to the argument list at the end. to use. The URL will be appended to the argument list at the end.
</p> </p>
@@ -69,10 +75,10 @@
<template x-for="(profile, i) in config.profiles"> <template x-for="(profile, i) in config.profiles">
<div> <div>
<label x-bind:for="'config-profiles-'+i+'-name'">Name of profile <span x-text="i+1"></span> <label x-bind:for="'config-profiles-'+i+'-name'">Name of profile <span x-text="i+1"></span>
</label> </label>
<input type="text" x-bind:id="'config-profiles-'+i+'-name'" class="input-long" placeholder="name" x-model="profile.name" /> <input type="text" x-bind:id="'config-profiles-'+i+'-name'" class="input-long" placeholder="name" x-model="profile.name" />
<button class="pure-button button-del" href="#" @click.prevent="config.profiles.splice(i, 1);;">delete profile</button> <button class="button-small pure-button button-del" href="#" @click.prevent="config.profiles.splice(i, 1);;">delete profile</button>
<span class="pure-form-message">The name of this profile. For your information only.</span> <span class="pure-form-message">The name of this profile. For your information only.</span>
@@ -86,11 +92,11 @@
<template x-for="(arg, j) in profile.args"> <template x-for="(arg, j) in profile.args">
<div> <div>
<input type="text" x-bind:id="'config-profiles-'+i+'-arg-'+j" placeholder="arg" x-model="profile.args[j]" /> <input type="text" x-bind:id="'config-profiles-'+i+'-arg-'+j" placeholder="arg" x-model="profile.args[j]" />
<button class="pure-button button-del" href="#" @click.prevent="profile.args.splice(j, 1);;">delete arg</button> <button class="button-small pure-button button-del" href="#" @click.prevent="profile.args.splice(j, 1);;">delete arg</button>
</div> </div>
</template> </template>
<button class="pure-button button-add" href="#" @click.prevent="profile.args.push('');">add arg</button> <button class="button-small pure-button button-add" href="#" @click.prevent="profile.args.push('');">add arg</button>
<span class="pure-form-message">Arguments for the command. Note that the shell is not used, so there is no need to quote or escape arguments, including those with spaces.</span> <span class="pure-form-message">Arguments for the command. Note that the shell is not used, so there is no need to quote or escape arguments, including those with spaces.</span>
<hr> <hr>
@@ -98,15 +104,53 @@
</div> </div>
</template> </template>
<button class="pure-button button-add" href="#" @click.prevent="config.profiles.push({name: 'new profile', command: 'youtube-dl', args: []});">add profile</button> <button class="button-small pure-button button-add" href="#" @click.prevent="config.profiles.push({name: 'new profile', command: 'youtube-dl', args: []});">add profile</button>
</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>Download Options</legend>
<p>You can specify custom download options here. These are (optionally) selectable in addition
to the profile when starting a download. They append extra arguments to the downloader command.
The most common use is to specify a particular <tt>-o</tt> argument to <tt>yt-dlp</tt> to allow files to be downloaded
to a custom path.</p>
</p>
<template x-for="(download_option, i) in config.download_options">
<div>
<label x-bind:for="'config-download-option-'+i+'-name'">Name of option <span x-text="i+1"></span>
</label>
<input type="text" x-bind:id="'config-download-option-'+i+'-name'" class="input-long" placeholder="name" x-model="download_option.name" />
<span class="pure-form-message">The name of this option. For your information only.</span>
<label>Arguments</label>
<template x-for="(arg, j) in download_option.args">
<div>
<input type="text" x-bind:id="'config-download-option-'+i+'-arg-'+j" placeholder="arg" x-model="download_option.args[j]" />
<button class="button-small pure-button button-del" href="#" @click.prevent="download_option.args.splice(j, 1);;">delete arg</button>
</div>
</template>
<button class="button-small pure-button button-del" href="#" @click.prevent="config.download_options.splice(i, 1);">delete option</button>
<hr>
</div>
</template>
<button class="button-small pure-button button-add" href="#" @click.prevent="config.download_options.push({name: 'new option', args: ['-o', 'someting']});">add option</button>
</fieldset>
</form>
</div> </div>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1"> <div class="pure-u-1">
<button class="pure-button pure-button-primary" @click="save_config();" href="#">Save Config</button> <button class="button-small pure-button button-small pure-button-primary" @click="save_config();" href="#">Save Config</button>
</div> </div>
</div> </div>
@@ -118,8 +162,8 @@
{{ define "js" }} {{ define "js" }}
<script> <script>
function config() { function config() {
return { return {
config: { server : {}, ui : {}, profiles: [] }, config: { server : {}, ui : {}, profiles: [], download_options: []},
error_message: '', error_message: '',
success_message: '', success_message: '',

View File

@@ -5,9 +5,9 @@
<div x-data="index()" x-init="fetch_data(); fetch_version()"> <div x-data="index()" x-init="fetch_data(); fetch_version()">
<p x-cloak x-show="version && version.upgrade_available"> <p x-cloak x-show="version && version.upgrade_available">
<a href="https://github.com/tardisx/gropple/releases">Upgrade is available</a> - <a href="https://github.com/tardisx/gropple/releases">Upgrade is available</a> -
you have you have
<span x-text="version.current_version"></span> and <span x-text="version.current_version"></span> and
<span x-text="version.github_version"></span> <span x-text="version.github_version"></span>
is available.</p> is available.</p>
@@ -24,28 +24,53 @@
<table class="pure-table"> <table class="pure-table">
<thead> <thead>
<tr> <tr>
<th>id</th><th>filename</th><th>url</th><th>show</th><th>state</th><th>percent</th><th>eta</th><th>finished</th> <th>id</th>
<th>filename</th>
<th>url</th>
<th>state</th>
<th>percent</th>
<th>eta</th>
<th>finished</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template x-for="item in items"> <template x-for="item in items">
<tr> <tr>
<td x-text="item.id"></td> <td>
<td x-text="item.files"></td> <a class="int-link" @click="show_popup(item)" href="#">
<td><a class="int-link" x-bind:href="item.url">&#x2197;</a></td> <span x-text="item.id">
<td><a class="int-link" @click="show_popup(item)" href="#">&#x1F4C4;</a></td> </a>
</td>
<td>
<span x-show="item.files && item.files.length == 1">
<span class="filelist" x-text="item.files[0]"></span>
</span>
<span x-data="{open: false}" x-show="item.files && item.files.length > 1">
<span class="filelist" x-text="item.files.length + ' files...'"></span>
<button class="pure-button button-small" @click="open = ! open" x-text="open ? 'hide' : 'show'"></button>
<div x-show="open" x-transition>
<ul class="filelist">
<template x-for="file in item.files">
<li x-text="file"></li>
</template>
</ul>
</div>
</span>
<span class="filelist" x-show="! item.files || item.files.length == 0"
x-text="'fetching ' + item.url + '...'">
</span>
</td>
<td><a class="int-link" x-bind:href="item.url">&#x1F517;</a></td>
<td :class="'state-'+item.state" x-text="item.state"></td> <td :class="'state-'+item.state" x-text="item.state"></td>
<td x-text="item.percent"></td> <td x-text="item.percent"></td>
<td x-text="item.eta"></td> <td x-text="item.eta"></td>
<td x-text="item.finished ? '&#x2714;' : '-'"></td> <td x-text="item.finished ? '&#x2714;' : '-'"></td>
</tr> </tr>
</template>
{{ range $k, $v := .Downloads }} </template>
{{ end }}
</tbody>
</tbody>
</table> </table>
</div> </div>
{{ end }} {{ end }}
@@ -53,8 +78,8 @@
{{ define "js" }} {{ define "js" }}
<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,9 +104,11 @@
}) })
}, },
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 }}");
},
}
} }
</script> </script>
{{ end }} {{ end }}

View File

@@ -5,9 +5,24 @@
<title>gropple</title> <title>gropple</title>
<script src="/static/alpine.min.js" defer></script> <script src="/static/alpine.min.js" defer></script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/purecss@2.0.6/build/pure-min.css" integrity="sha384-Uu6IeWbM+gzNVXJcM9XV3SohHtmWE+3VGi496jvgX1jyvDTXfdK+rfZc8C1Aehk5" crossorigin="anonymous"> <link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://unpkg.com/purecss@2.0.6/build/grids-responsive-min.css"> <link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css" integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/grids-responsive-min.css">
<style> <style>
:root {
font-family: Inter, sans-serif;
font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */
}
@supports (font-variation-settings: normal) {
:root { font-family: InterVariable, sans-serif; }
}
.button-small {
font-size: 85%;
}
.pure-g > div { .pure-g > div {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -19,6 +34,9 @@
height: 100px; height: 100px;
overflow:auto; overflow:auto;
} }
.filelist {
font-size: 60%;
}
footer { footer {
padding-top: 50px; padding-top: 50px;
font-size: 30%; font-size: 30%;
@@ -33,6 +51,9 @@
.state-downloading { .state-downloading {
color: blue; color: blue;
} }
.state-moved {
color: green;
}
.state-complete { .state-complete {
color: green; color: green;
} }
@@ -42,9 +63,6 @@
.gropple-config input.input-long { .gropple-config input.input-long {
width: 27em; width: 27em;
} }
.gropple-config button {
border-radius: 12px;
}
.gropple-config button.button-del { .gropple-config button.button-del {
background: rgb(202, 60, 60); background: rgb(202, 60, 60);
} }
@@ -57,7 +75,7 @@
} }
.error { .error {
color: red; color: red;
font-size: 150%; font-size: 120%;
} }
.success { .success {
color: green; color: green;
@@ -71,7 +89,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

@@ -2,31 +2,33 @@
<div id="layout" class="pure-g pure-u-1" x-data="popup()" x-init="fetch_data()"> <div id="layout" class="pure-g pure-u-1" x-data="popup()" x-init="fetch_data()">
<h2>Download started</h2> <h2>Download started</h2>
<p>Fetching <tt>{{ .dl.Url }}</tt></p> <p>Fetching <tt>{{ .dl.Url }}</tt></p>
<form class="pure-form">
<table class="pure-table" > <table class="pure-table" >
<tr> <tr>
<th>profile</th> <th>profile</th>
<td> <td>{{ .dl.DownloadProfile.Name }}</td>
<select x-bind:disabled="profile_chosen" x-on:change="update_profile()" class="pure-input-1-2" x-model="profile_chosen">
<option value="">choose a profile to start</option>
{{ range $i := .config.DownloadProfiles }}
<option>{{ $i.Name }}</option>
{{ end }}
</select>
</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>option</th>
<td>
{{ if .dl.DownloadOption }} {{ .dl.DownloadOption.Name }} {{ else }} n/a {{ end }}
</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>
</table> </table>
<p>You can close this window and your download will continue. Check the <a href="/" target="_gropple_status">Status page</a> to see all downloads in progress.</p> <p>You can close this window and your download will continue. Check the <a href="/" target="_gropple_status">Status page</a> to see all downloads in progress.</p>
{{ if .canStop }} {{ if .canStop }}
<button x-show="state=='downloading'" class="pure-button" @click="stop()">stop</button> <button x-show="state=='Downloading'" class="button-small pure-button" @click="stop()">stop</button>
{{ end }} {{ end }}
</form>
<div> <div>
<h4>Logs</h4> <h4>Logs</h4>
<pre x-text="log"> <pre x-text="log" style="height: auto;">
</pre> </pre>
</div> </div>
</div> </div>
@@ -35,25 +37,9 @@
<script> <script>
function popup() { function popup() {
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 :'',
profile_chosen: null, playlist_current: 0, playlist_total: 0,
watch_profile() {
this.$watch('profile_chosen', value => this.profile_chosen(value))
},
update_profile(name) {
console.log('you chose name', this.profile_chosen);
let op = {
method: 'POST',
body: JSON.stringify({action: 'start', profile: this.profile_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,9 +59,8 @@
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.profile_chosen = true; this.playlist_total = info.playlist_total;
}
this.finished = info.finished; this.finished = info.finished;
if (info.files && info.files.length > 0) { if (info.files && info.files.length > 0) {
this.filename = info.files[info.files.length - 1]; this.filename = info.files[info.files.length - 1];

View File

@@ -0,0 +1,74 @@
{{ define "content" }}
<div id="layout" class="pure-g pure-u-1" x-data="popup_create()" >
<h2>Download create</h2>
<p>URL: <tt>{{ .url }}</tt></p>
<p class="error" x-show="error_message" x-transition.duration.500ms x-text="error_message"></p>
<table class="pure-table" >
<tr>
<th>profile</th>
<td>
<select class="pure-input-1-2" x-model="profile_chosen">
<option value="">choose a profile</option>
{{ range $i := .config.DownloadProfiles }}
<option>{{ $i.Name }}</option>
{{ end }}
</select>
</td>
</tr>
<tr>
<th>download option</th>
<td>
<select class="pure-input-1-2" x-model="download_option_chosen">
<option value="">no option</option>
{{ range $i := .config.DownloadOptions }}
<option>{{ $i.Name }}</option>
{{ end }}
</select>
</td>
</tr>
<tr>
<th>&nbsp;</th>
<td>
<button class="button-small pure-button" @click="start()">start download</button>
</td>
</tr>
</table>
</div>
{{ end }}
{{ define "js" }}
<script>
function popup_create() {
return {
profile_chosen: "",
download_option_chosen: "",
error_message: "",
start() {
let op = {
method: 'POST',
body: JSON.stringify({action: 'start', url: '{{ .url }}', profile: this.profile_chosen, download_option: this.download_option_chosen}),
headers: { 'Content-Type': 'application/json' }
};
fetch('/fetch', op)
.then(response => response.json())
.then(response => {
console.log(response)
if (response.error) {
this.error_message = response.error;
this.success_message = '';
document.body.scrollTop = document.documentElement.scrollTop = 0;
} else {
this.error_message = '';
console.log(response.location)
window.location = response.location
}
})
}
}
}
</script>
{{ end }}

401
web/web.go Normal file
View File

@@ -0,0 +1,401 @@
package web
import (
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/tardisx/gropple/config"
"github.com/tardisx/gropple/download"
"github.com/tardisx/gropple/version"
)
type successResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type queuedResponse struct {
Success bool `json:"success"`
Location string `json:"location"`
}
type errorResponse struct {
Success bool `json:"success"`
Error string `json:"error"`
}
//go:embed data/**
var webFS embed.FS
func CreateRoutes(cs *config.ConfigService, dm *download.Manager, vm *version.Manager) *mux.Router {
r := mux.NewRouter()
// main index page
r.HandleFunc("/", homeHandler(cs, vm, dm))
// update info on the status page
r.HandleFunc("/rest/fetch", fetchInfoRESTHandler(dm))
// return static files
r.HandleFunc("/static/{filename}", staticHandler())
// return the config page
r.HandleFunc("/config", configHandler())
// handle config fetches/updates
r.HandleFunc("/rest/config", configRESTHandler(cs))
// create or present a download in the popup
r.HandleFunc("/fetch", fetchHandler(cs, vm, dm))
r.HandleFunc("/fetch/{id}", fetchHandler(cs, vm, dm))
// get/update info on a download
r.HandleFunc("/rest/fetch/{id}", fetchInfoOneRESTHandler(cs, dm))
// version information
r.HandleFunc("/rest/version", versionRESTHandler(vm))
http.Handle("/", r)
return r
}
// versionRESTHandler returns the version information, if we have up-to-date info from github
func versionRESTHandler(vm *version.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if vm.GetInfo().GithubVersionFetched {
b, _ := json.Marshal(vm.GetInfo())
_, err := w.Write(b)
if err != nil {
log.Printf("could not write to client: %s", err)
}
} else {
w.WriteHeader(400)
}
}
}
// homeHandler returns the main index page
func homeHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
bookmarkletURL := fmt.Sprintf("javascript:(function(f,s,n,o){window.open(f+encodeURIComponent(s),n,o)}('%s/fetch?url=',window.location,'yourform','width=%d,height=%d'));", cs.Config.Server.Address, cs.Config.UI.PopupWidth, cs.Config.UI.PopupHeight)
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/menu.tmpl", "data/templates/index.tmpl")
if err != nil {
panic(err)
}
type Info struct {
Manager *download.Manager
BookmarkletURL template.URL
Config *config.Config
Version version.Info
}
info := Info{
Manager: dm,
BookmarkletURL: template.URL(bookmarkletURL),
Config: cs.Config,
Version: vm.GetInfo(),
}
dm.Lock.Lock()
defer dm.Lock.Unlock()
err = t.ExecuteTemplate(w, "layout", info)
if err != nil {
panic(err)
}
}
}
// staticHandler handles requests for static files
func staticHandler() func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
if strings.Index(filename, ".js") == len(filename)-3 {
f, err := webFS.Open("data/js/" + filename)
if err != nil {
log.Printf("error accessing %s - %v", filename, err)
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, f)
if err != nil {
log.Printf("could not write to client: %s", err)
}
return
}
w.WriteHeader(http.StatusNotFound)
}
}
// configHandler returns the configuration page
func configHandler() func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/menu.tmpl", "data/templates/config.tmpl")
if err != nil {
panic(err)
}
err = t.ExecuteTemplate(w, "layout", nil)
if err != nil {
panic(err)
}
}
}
// configRESTHandler handles both reading and writing of the configuration
func configRESTHandler(cs *config.ConfigService) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
log.Printf("Updating config")
b, err := io.ReadAll(r.Body)
if err != nil {
panic(err)
}
err = cs.Config.UpdateFromJSON(b)
if err != nil {
errorRes := errorResponse{Success: false, Error: err.Error()}
errorResB, _ := json.Marshal(errorRes)
w.WriteHeader(400)
_, err = w.Write(errorResB)
if err != nil {
log.Printf("could not write to client: %s", err)
}
return
}
cs.WriteConfig()
}
b, _ := json.Marshal(cs.Config)
_, err := w.Write(b)
if err != nil {
log.Printf("could not write config to client: %s", err)
}
}
}
func fetchInfoOneRESTHandler(cs *config.ConfigService, dm *download.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idString := vars["id"]
if idString != "" {
id, err := strconv.Atoi(idString)
if err != nil {
http.NotFound(w, r)
return
}
thisDownload, err := dm.GetDlById(id)
if err != nil {
http.NotFound(w, r)
return
}
if thisDownload == nil {
panic("should not happen")
}
if r.Method == "POST" {
type updateRequest struct {
Action string `json:"action"`
}
thisReq := updateRequest{}
b, err := io.ReadAll(r.Body)
if err != nil {
panic(err)
}
err = json.Unmarshal(b, &thisReq)
if err != nil {
errorRes := errorResponse{Success: false, Error: err.Error()}
errorResB, _ := json.Marshal(errorRes)
w.WriteHeader(400)
_, err = w.Write(errorResB)
if err != nil {
log.Printf("could not write to client: %s", err)
}
return
}
if thisReq.Action == "stop" {
thisDownload.Stop()
succRes := successResponse{Success: true, Message: "download stopped"}
succResB, _ := json.Marshal(succRes)
_, err = w.Write(succResB)
if err != nil {
log.Printf("could not write to client: %s", err)
}
return
}
}
// just a get, return the object
thisDownload.Lock.Lock()
defer thisDownload.Lock.Unlock()
b, _ := json.Marshal(thisDownload)
_, err = w.Write(b)
if err != nil {
log.Printf("could not write to client: %s", err)
}
return
} else {
http.NotFound(w, r)
}
}
}
func fetchInfoRESTHandler(dm *download.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
b, err := dm.DownloadsAsJSON()
if err != nil {
panic(err)
}
_, err = w.Write(b)
if err != nil {
log.Printf("could not write to client: %s", err)
}
}
}
// fetchHandler shows the popup, either the initial form (for create) or the form when in
// progress (to be updated by REST). It also handles the form POST for creating a new download.
func fetchHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("fetchHandler")
method := r.Method
// if they refreshed the popup, just load the existing object, don't
// create a new one
vars := mux.Vars(r)
idString := vars["id"]
idInt, idOK := strconv.ParseInt(idString, 10, 32)
if method == "GET" && idOK == nil && idInt > 0 {
// existing, load it up
log.Printf("loading popup for id %d", idInt)
dl, err := dm.GetDlById(int(idInt))
if err != nil {
log.Printf("not found")
w.WriteHeader(404)
return
}
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/popup.tmpl")
if err != nil {
panic(err)
}
templateData := map[string]interface{}{"dl": dl, "config": cs.Config, "canStop": download.CanStopDownload, "Version": vm.GetInfo()}
err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil {
panic(err)
}
return
} else if method == "POST" {
// creating a new one
type reqType struct {
URL string `json:"url"`
ProfileChosen string `json:"profile"`
DownloadOptionChosen string `json:"download_option"`
}
req := reqType{}
json.NewDecoder(r.Body).Decode(&req)
log.Printf("popup POST request: %#v", req)
if req.URL == "" {
w.WriteHeader(400)
json.NewEncoder(w).Encode(errorResponse{
Success: false,
Error: "No URL supplied",
})
return
} else {
if req.ProfileChosen == "" {
w.WriteHeader(400)
json.NewEncoder(w).Encode(errorResponse{
Success: false,
Error: "you must choose a profile",
})
return
}
profile := cs.Config.ProfileCalled(req.ProfileChosen)
if profile == nil {
w.WriteHeader(400)
json.NewEncoder(w).Encode(errorResponse{
Success: false,
Error: fmt.Sprintf("no such profile: '%s'", req.ProfileChosen),
})
return
}
option := cs.Config.DownloadOptionCalled(req.DownloadOptionChosen)
// create the new download
newDL := download.NewDownload(req.URL, cs.Config)
id := newDL.Id
newDL.DownloadOption = option
newDL.DownloadProfile = *profile
dm.AddDownload(newDL)
dm.Queue(newDL)
w.WriteHeader(200)
json.NewEncoder(w).Encode(queuedResponse{
Success: true,
Location: fmt.Sprintf("/fetch/%d", id),
})
}
} else {
// a GET, show the popup so they can start the download
log.Print("loading popup for a new download")
query := r.URL.Query()
url, present := query["url"]
if !present {
w.WriteHeader(400)
fmt.Fprint(w, "No url supplied")
return
}
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/popup_create.tmpl")
if err != nil {
panic(err)
}
templateData := map[string]interface{}{"config": cs.Config, "url": url[0], "Version": vm.GetInfo()}
err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil {
panic(err)
}
}
}
}