Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

20 changed files with 133 additions and 510 deletions

View File

@ -16,7 +16,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: 1.22 go-version: 1.17
- name: Build - name: Build
run: go build -v ./... run: go build -v ./...

4
.gitignore vendored
View File

@ -1,6 +1,4 @@
gropple gropple
release release
dist dist
.env .env
dist/
.DS_Store

View File

@ -1,9 +1,17 @@
version: 2 # 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: before:
hooks: hooks:
# You may remove this if you don't use go modules.
- go mod tidy - go mod tidy
- go test ./...
builds: builds:
- env: - env:
@ -14,7 +22,7 @@ builds:
- darwin - darwin
archives: archives:
- formats: [tar.gz] - format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`. # this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >- name_template: >-
{{ .ProjectName }}_ {{ .ProjectName }}_
@ -26,28 +34,16 @@ archives:
# use zip for windows archives # use zip for windows archives
format_overrides: format_overrides:
- goos: windows - goos: windows
formats: [zip] format: zip
changelog: changelog:
disable: true skip: true
dockers: dockers:
- image_templates: - image_templates:
- "tardisx/gropple:{{ .Tag }}-amd64" - "tardisx/gropple:{{ .Tag }}"
use: buildx - "tardisx/gropple:v{{ .Major }}"
build_flag_templates: - "tardisx/gropple:v{{ .Major }}.{{ .Minor }}"
- "--pull" - "tardisx/gropple:latest"
- "--platform=linux/amd64"
- image_templates:
- "tardisx/gropple:{{ .Tag }}-arm64"
use: buildx
build_flag_templates:
- "--pull"
- "--platform=linux/arm64"
goarch: arm64
docker_manifests:
- name_template: "tardisx/gropple:{{ .Tag }}"
image_templates:
- "tardisx/gropple:{{ .Tag }}-amd64"
- "tardisx/gropple:{{ .Tag }}-arm64"

View File

@ -2,41 +2,17 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v1.1.4] - 2025-04-25
- Minor refactorings, upgrade dependencies
- Migrate to goreleaser v2 configuration
- Generate arm64 docker builds
## [v1.1.3] - 2024-03-17
- Code cleanups, better error checking
## [v1.1.2] - 2024-03-16
- Fix a crash for a certain pattern of log line
## [v1.1.1] - 2023-12-08
- Fix bug where a brand-new config was created with an out-of-date version
- Fix for portable mode and using executable in the current working directory
## [v1.1.0] - 2023-11-25
- Add feature to bulk add URL's for downloading
## [v1.0.1] - 2023-11-24
- Fix crash on migrating a config that had > 1 destinations
## [v1.0.0] - 2023-11-23 ## [v1.0.0] - 2023-11-23
- Don't start downloads until "start download" is pressed - Don't start downloads until "start download" is pressed
- Add "download option" for more per-download customisability, especially for destinations - Add "download option" for more per-download customisability, especially
- Removed "destinations" as that is now possible more flexibly with download options. for destinations
- Existing configurations using destinations are automatically migrated to an appropriate `yt-dlp -o ...` download options - Removed "destinations" as that is now possible more flexibly with download
options. Configurations using destinations automatically migrated to an
appropriate `yt-dlp -o ...` download options
- Gropple now available via docker - Gropple now available via docker
- Clean up web interface display on index page, especially when a playlist with many files is downloading - Clean up web interface display on index page, especially when a playlist
with many files is downloading
## [v0.6.0] - 2023-03-15 ## [v0.6.0] - 2023-03-15

View File

@ -1,4 +1,4 @@
FROM ubuntu:noble FROM ubuntu:mantic
COPY gropple / COPY gropple /
RUN apt update && apt install -y curl python3 ffmpeg RUN apt update && apt install -y curl python3 ffmpeg

View File

@ -164,14 +164,6 @@ Note that this also means that `yt-dlp` can resume partially downloaded files, a
also automatically 'backfill', downloading only files that have not been also automatically 'backfill', downloading only files that have not been
downloaded yet from that playlist. downloaded yet from that playlist.
## Downloading a list of URL's in bulk
From main index page you can click the "Bulk" link in the menu to bring up the
bulk queue page.
In all respects this acts the same as the usual bookmarklet, but it has a
textbox for pasting many URLs at once. All downloads will be queued immediately.
## Portable mode ## Portable mode
If you'd like to use gropple from a USB stick or similar, copy the config file If you'd like to use gropple from a USB stick or similar, copy the config file

49
build_release.pl Executable file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env perl
use strict;
use warnings;
open my $fh, "<", "main.go" || die $!;
my $version;
while (<$fh>) {
# CurrentVersion: "v0.04"
$version = $1 if /CurrentVersion:\s*"(v.*?)"/;
}
close $fh;
die "no version?" unless defined $version;
# quit if tests fail
system("go test ./...") && die "not building release with failing tests";
# so lazy
system "rm", "-rf", "release", "dist";
system "mkdir", "release";
system "mkdir", "dist";
my %build = (
win => { env => { GOOS => 'windows', GOARCH => 'amd64' }, filename => 'gropple.exe' },
linux => { env => { GOOS => 'linux', GOARCH => 'amd64' }, filename => 'gropple' },
mac => { env => { GOOS => 'darwin', GOARCH => 'amd64' }, filename => 'gropple' },
);
foreach my $type (keys %build) {
mkdir "release/$type";
}
foreach my $type (keys %build) {
local $ENV{GOOS} = $build{$type}->{env}->{GOOS};
local $ENV{GOARCH} = $build{$type}->{env}->{GOARCH};
system "go", "build", "-o", "release/$type/" . $build{$type}->{filename};
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

@ -65,7 +65,7 @@ type ConfigService struct {
func (cs *ConfigService) LoadTestConfig() { func (cs *ConfigService) LoadTestConfig() {
cs.LoadDefaultConfig() cs.LoadDefaultConfig()
cs.Config.Server.DownloadPath = "/tmp" cs.Config.Server.DownloadPath = "/tmp"
cs.Config.DownloadProfiles = []DownloadProfile{{Name: "test profile", Command: "/bin/sleep", Args: []string{"5"}}} cs.Config.DownloadProfiles = []DownloadProfile{{Name: "test profile", Command: "sleep", Args: []string{"5"}}}
} }
func (cs *ConfigService) LoadDefaultConfig() { func (cs *ConfigService) LoadDefaultConfig() {
@ -98,7 +98,7 @@ func (cs *ConfigService) LoadDefaultConfig() {
defaultConfig.Destinations = nil defaultConfig.Destinations = nil
defaultConfig.DownloadOptions = make([]DownloadOption, 0) defaultConfig.DownloadOptions = make([]DownloadOption, 0)
defaultConfig.ConfigVersion = 4 defaultConfig.ConfigVersion = 3
cs.Config = &defaultConfig cs.Config = &defaultConfig
@ -189,10 +189,9 @@ func (c *Config) UpdateFromJSON(j []byte) error {
} }
// check the command exists // check the command exists
_, err := exec.LookPath(newConfig.DownloadProfiles[i].Command)
_, err := AbsPathToExecutable(newConfig.DownloadProfiles[i].Command)
if err != nil { if err != nil {
return fmt.Errorf("problem with command '%s': %s", newConfig.DownloadProfiles[i].Command, err) return fmt.Errorf("Could not find %s on the path", newConfig.DownloadProfiles[i].Command)
} }
} }
@ -266,14 +265,14 @@ func (cs *ConfigService) LoadConfig() error {
path := cs.ConfigPath path := cs.ConfigPath
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
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 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)
} }
// do migrations // do migrations
@ -301,8 +300,8 @@ func (cs *ConfigService) LoadConfig() error {
Args: []string{"-o", fmt.Sprintf("%s/%%(title)s [%%(id)s].%%(ext)s", c.Destinations[i].Path)}, Args: []string{"-o", fmt.Sprintf("%s/%%(title)s [%%(id)s].%%(ext)s", c.Destinations[i].Path)},
} }
c.DownloadOptions = append(c.DownloadOptions, newDownloadOption) c.DownloadOptions = append(c.DownloadOptions, newDownloadOption)
c.Destinations = nil
} }
c.Destinations = nil
configMigrated = true configMigrated = true
log.Print("migrated config from version 3 => 4") log.Print("migrated config from version 3 => 4")
} }
@ -319,8 +318,7 @@ func (cs *ConfigService) LoadConfig() error {
func (cs *ConfigService) WriteConfig() { func (cs *ConfigService) WriteConfig() {
s, err := yaml.Marshal(cs.Config) s, err := yaml.Marshal(cs.Config)
if err != nil { if err != nil {
log.Printf("error writing config: %s", err) panic(err)
os.Exit(1)
} }
path := cs.ConfigPath path := cs.ConfigPath
@ -339,28 +337,3 @@ func (cs *ConfigService) WriteConfig() {
} }
file.Close() file.Close()
} }
// AbsPathToExecutable takes a command name, which may or may not be path-qualified,
// and returns the fully qualified path to it, or an error if could not be found, or
// if it does not appear to be a file.
func AbsPathToExecutable(cmd string) (string, error) {
pathCmd, err := exec.LookPath(cmd)
if err != nil {
return "", fmt.Errorf("could not LookPath '%s': %w", cmd, err)
}
execAbsolutePath, err := filepath.Abs(pathCmd)
if err != nil {
return "", fmt.Errorf("could not get absolute path to '%s': %w", cmd, err)
}
fi, err := os.Stat(execAbsolutePath)
if err != nil {
return "", fmt.Errorf("could not get stat '%s': %w", cmd, err)
}
if !fi.Mode().IsRegular() {
return "", fmt.Errorf("'%s' is not a regular file: %w", cmd, err)
}
return execAbsolutePath, nil
}

View File

@ -1,10 +1,7 @@
package config package config
import ( import (
"errors"
"os" "os"
"os/exec"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -105,116 +102,13 @@ profiles:
os.Remove(cs.ConfigPath) os.Remove(cs.ConfigPath)
} }
func TestMigrateV3toV4CrashBug(t *testing.T) {
v3Config := `config_version: 3
server:
port: 6123
address: https://superaddress.here.com
download_path: /home/path/gropple
maximum_active_downloads_per_domain: 2
ui:
popup_width: 500
popup_height: 500
destinations:
- name: somegifs
path: /home/path/somegifs
- name: otherstuff
path: /home/path/otherstuff
profiles:
- name: standard video
command: yt-dlp
args:
- --newline
- --write-info-json
- -f
- bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best
- --verbose
- --embed-metadata
- --embed-subs
- --embed-thumbnail
- name: standard mp3
command: yt-dlp
args:
- --extract-audio
- --audio-format
- mp3
- --prefer-ffmpeg
`
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, 2) {
if assert.Len(t, cs.Config.DownloadOptions[0].Args, 2) {
assert.Equal(t, "-o", cs.Config.DownloadOptions[0].Args[0])
assert.Equal(t, "/home/path/somegifs/%(title)s [%(id)s].%(ext)s", cs.Config.DownloadOptions[0].Args[1])
assert.Equal(t, "-o", cs.Config.DownloadOptions[1].Args[0])
assert.Equal(t, "/home/path/otherstuff/%(title)s [%(id)s].%(ext)s", cs.Config.DownloadOptions[1].Args[1])
}
}
os.Remove(cs.ConfigPath)
}
func configServiceFromString(configString string) *ConfigService { func configServiceFromString(configString string) *ConfigService {
tmpFile, _ := os.CreateTemp("", "gropple_test_*.yml") tmpFile, _ := os.CreateTemp("", "gropple_test_*.yml")
_, err1 := tmpFile.Write([]byte(configString)) tmpFile.Write([]byte(configString))
err2 := tmpFile.Close() tmpFile.Close()
if errors.Join(err1, err2) != nil {
panic("got unexpected error")
}
cs := ConfigService{ cs := ConfigService{
Config: &Config{}, Config: &Config{},
ConfigPath: tmpFile.Name(), ConfigPath: tmpFile.Name(),
} }
return &cs return &cs
} }
func TestLookForExecutable(t *testing.T) {
cmdPath, err := exec.LookPath("sleep")
if err != nil {
t.Errorf("cannot run this test without knowing about sleep: %s", err)
t.FailNow()
}
cmdDir := filepath.Dir(cmdPath)
cmd := "sleep"
path, err := AbsPathToExecutable(cmd)
if assert.NoError(t, err) {
assert.Equal(t, cmdPath, path)
}
cmd = cmdPath
path, err = AbsPathToExecutable(cmd)
if assert.NoError(t, err) {
assert.Equal(t, cmdPath, path)
}
cmd = "../../../../../../../../.." + cmdPath
path, err = AbsPathToExecutable(cmd)
if assert.NoError(t, err) {
assert.Equal(t, cmdPath, path)
}
cmd = "./sleep"
_, err = AbsPathToExecutable(cmd)
assert.Error(t, err)
os.Chdir(cmdDir) //nolint
cmd = "./sleep"
path, err = AbsPathToExecutable(cmd)
if assert.NoError(t, err) {
assert.Equal(t, cmdPath, path)
}
}

View File

@ -2,7 +2,7 @@ version: "3.9"
services: services:
gropple: gropple:
image: tardisx/gropple:v1.1.4 image: tardisx/gropple:latest
volumes: volumes:
- /tmp/gropple-config-dir/:/config - /tmp/gropple-config-dir/:/config
- /tmp/downloads/:/downloads/ - /tmp/downloads/:/downloads/

View File

@ -277,25 +277,13 @@ func (dl *Download) Begin() {
} }
// 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)
} }
cmdPath, err := config.AbsPathToExecutable(dl.DownloadProfile.Command) dl.Log = append(dl.Log, fmt.Sprintf("executing: %s with args: %s", dl.DownloadProfile.Command, strings.Join(cmdSlice, " ")))
if err != nil { cmd := exec.Command(dl.DownloadProfile.Command, cmdSlice...)
dl.State = STATE_FAILED
dl.Finished = true
dl.FinishedTS = time.Now()
dl.Log = append(dl.Log, fmt.Sprintf("error finding executable for downloader: %s", err.Error()))
dl.Lock.Unlock()
return
}
dl.Log = append(dl.Log, fmt.Sprintf("executing: %s (%s) with args: %s", dl.DownloadProfile.Command, cmdPath, strings.Join(cmdSlice, " ")))
cmd := exec.Command(cmdPath, cmdSlice...)
cmd.Dir = dl.Config.Server.DownloadPath cmd.Dir = dl.Config.Server.DownloadPath
log.Printf("Executing command executable: %s) in %s", cmdPath, dl.Config.Server.DownloadPath)
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
@ -304,6 +292,7 @@ func (dl *Download) Begin() {
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() dl.Lock.Unlock()
return return
} }
@ -318,10 +307,9 @@ func (dl *Download) Begin() {
return return
} }
log.Printf("Executing command: %v", cmd)
err = cmd.Start() err = cmd.Start()
if err != nil { if err != nil {
log.Printf("Executing command failed: %s", err.Error())
dl.State = STATE_FAILED dl.State = STATE_FAILED
dl.Finished = true dl.Finished = true
dl.FinishedTS = time.Now() dl.FinishedTS = time.Now()
@ -375,6 +363,7 @@ func (dl *Download) Begin() {
} }
} }
dl.Lock.Unlock() dl.Lock.Unlock()
} }
// updateDownload updates the download based on data from the reader. Expects the // updateDownload updates the download based on data from the reader. Expects the
@ -429,6 +418,8 @@ func (dl *Download) updateMetadata(s string) {
p, err := strconv.ParseFloat(matches[1], 32) p, err := strconv.ParseFloat(matches[1], 32)
if err == nil { if err == nil {
dl.Percent = float32(p) dl.Percent = float32(p)
} else {
panic(err)
} }
} }

View File

@ -21,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) //nolint 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) //nolint 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
@ -44,7 +44,7 @@ func TestUpdateMetadata(t *testing.T) {
// different download // different download
newD.updateMetadata("[download] 99.3% of ~1.42GiB at 320.87KiB/s ETA 00:07 (frag 212/214)") newD.updateMetadata("[download] 99.3% of ~1.42GiB at 320.87KiB/s ETA 00:07 (frag 212/214)")
if newD.Eta != "00:07" { if newD.Eta != "00:07" {
t.Fatalf("bad short eta in dl with frag\n%v", newD) //nolint t.Fatalf("bad short eta in dl with frag\n%v", newD)
} }
// [FixupM3u8] Fixing MPEG-TS in MP4 container of "file [-168849776_456239489].mp4" // [FixupM3u8] Fixing MPEG-TS in MP4 container of "file [-168849776_456239489].mp4"
@ -317,7 +317,7 @@ func TestUpdateMetadataSingle(t *testing.T) {
[youtube] 2WoDQBhJCVQ: Downloading android player API JSON [youtube] 2WoDQBhJCVQ: Downloading android player API JSON
[info] 2WoDQBhJCVQ: Downloading 1 format(s): 137+140 [info] 2WoDQBhJCVQ: Downloading 1 format(s): 137+140
[info] Writing video metadata as JSON to: The Greatest Shot In Television [2WoDQBhJCVQ].info.json [info] Writing video metadata as JSON to: The Greatest Shot In Television [2WoDQBhJCVQ].info.json
[debug] Invoking hlsnative downloader on "https://example.org/urls/1.2.3.4% [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 510.94KiB/s ETA 00:26
[download] 0.0% of 12.82MiB at 966.50KiB/s ETA 00:13 [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 1.54MiB/s ETA 00:08

8
go.mod
View File

@ -1,13 +1,11 @@
module github.com/tardisx/gropple module github.com/tardisx/gropple
go 1.23.0 go 1.20
toolchain go1.24.1
require ( require (
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.8.4
golang.org/x/mod v0.24.0 golang.org/x/mod v0.14.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )

8
go.sum
View File

@ -4,10 +4,10 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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=

11
main.go
View File

@ -9,18 +9,13 @@ import (
"github.com/tardisx/gropple/config" "github.com/tardisx/gropple/config"
"github.com/tardisx/gropple/download" "github.com/tardisx/gropple/download"
v "github.com/tardisx/gropple/version" "github.com/tardisx/gropple/version"
"github.com/tardisx/gropple/web" "github.com/tardisx/gropple/web"
) )
var (
version = "dev"
)
func main() { func main() {
versionInfo := &v.Manager{ versionInfo := &version.Manager{
// version from goreleaser has no leading 'v', even if the tag does VersionInfo: version.Info{CurrentVersion: "v1.0.0"},
VersionInfo: v.Info{CurrentVersion: "v" + version},
} }
log.Printf("Starting gropple %s - https://github.com/tardisx/gropple", versionInfo.GetInfo().CurrentVersion) log.Printf("Starting gropple %s - https://github.com/tardisx/gropple", versionInfo.GetInfo().CurrentVersion)

View File

@ -1,91 +0,0 @@
{{ define "content" }}
{{ template "menu.tmpl" . }}
<div id="layout" class="pure-g pure-u-1" x-data="bulk_create()" >
<h1>Bulk upload</h1>
<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>Paste URLs here, one per line:</p>
<textarea x-model="urls" rows="20" cols="80">
</textarea>
<br><br>
<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 name="{{$i.Name}}">{{ $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 name="{{$i.Name}}">{{ $i.Name }}</option>
{{ end }}
</select>
</td>
</tr>
<tr>
<th>&nbsp;</th>
<td>
<button class="button-small pure-button" @click="start()">add to queue</button>
</td>
</tr>
</table>
</div>
{{ end }}
{{ define "js" }}
<script>
function bulk_create() {
return {
profile_chosen: "",
download_option_chosen: "",
urls: "",
error_message: "",
success_message: "",
start() {
let op = {
method: 'POST',
body: JSON.stringify({action: 'start', urls: this.urls, profile: this.profile_chosen, download_option: this.download_option_chosen}),
headers: { 'Content-Type': 'application/json' }
};
fetch('/bulk', 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 = '';
this.success_message = response.message;
this.urls = '';
}
})
}
}
}
</script>
{{ end }}

View File

@ -84,10 +84,7 @@
<label x-bind:for="'config-profiles-'+i+'-command'">Command to run</label> <label x-bind:for="'config-profiles-'+i+'-command'">Command to run</label>
<input type="text" x-bind:id="'config-profiles-'+i+'-command'" class="input-long" placeholder="name" x-model="profile.command" /> <input type="text" x-bind:id="'config-profiles-'+i+'-command'" class="input-long" placeholder="name" x-model="profile.command" />
<span class="pure-form-message">Which command to run. Your path will be searched, or you can specify the full path here. <span class="pure-form-message">Which command to run. Your path will be searched, or you can specify the full path here.</span>
If you are using gropple in portable mode and store the executables with the gropple executable, use a prefix of
<tt>./</tt>, for instance <tt>yt-dlp.exe</tt>.
</span>
<label>Arguments</label> <label>Arguments</label>

View File

@ -7,11 +7,9 @@
<li class="pure-menu-item"> <li class="pure-menu-item">
<a href="/config" class="pure-menu-link">Config</a> <a href="/config" class="pure-menu-link">Config</a>
</li> </li>
<li class="pure-menu-item">
<a href="/bulk" class="pure-menu-link">Bulk</a>
</li>
<li class="pure-menu-item"> <li class="pure-menu-item">
<a href="https://github.com/tardisx/gropple" class="pure-menu-link">Github</a> <a href="https://github.com/tardisx/gropple" class="pure-menu-link">Github</a>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -13,7 +13,7 @@
<select class="pure-input-1-2" x-model="profile_chosen"> <select class="pure-input-1-2" x-model="profile_chosen">
<option value="">choose a profile</option> <option value="">choose a profile</option>
{{ range $i := .config.DownloadProfiles }} {{ range $i := .config.DownloadProfiles }}
<option name="{{$i.Name}}">{{ $i.Name }}</option> <option>{{ $i.Name }}</option>
{{ end }} {{ end }}
</select> </select>
</td> </td>
@ -24,7 +24,7 @@
<select class="pure-input-1-2" x-model="download_option_chosen"> <select class="pure-input-1-2" x-model="download_option_chosen">
<option value="">no option</option> <option value="">no option</option>
{{ range $i := .config.DownloadOptions }} {{ range $i := .config.DownloadOptions }}
<option name="{{$i.Name}}">{{ $i.Name }}</option> <option>{{ $i.Name }}</option>
{{ end }} {{ end }}
</select> </select>
</td> </td>

View File

@ -55,9 +55,6 @@ func CreateRoutes(cs *config.ConfigService, dm *download.Manager, vm *version.Ma
r.HandleFunc("/fetch", fetchHandler(cs, vm, dm)) r.HandleFunc("/fetch", fetchHandler(cs, vm, dm))
r.HandleFunc("/fetch/{id}", fetchHandler(cs, vm, dm)) r.HandleFunc("/fetch/{id}", fetchHandler(cs, vm, dm))
// handle the bulk uploader
r.HandleFunc("/bulk", bulkHandler(cs, vm, dm))
// get/update info on a download // get/update info on a download
r.HandleFunc("/rest/fetch/{id}", fetchInfoOneRESTHandler(cs, dm)) r.HandleFunc("/rest/fetch/{id}", fetchInfoOneRESTHandler(cs, dm))
@ -92,10 +89,7 @@ func homeHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Man
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/menu.tmpl", "data/templates/index.tmpl") t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/menu.tmpl", "data/templates/index.tmpl")
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
type Info struct { type Info struct {
@ -116,10 +110,7 @@ func homeHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Man
defer dm.Lock.Unlock() defer dm.Lock.Unlock()
err = t.ExecuteTemplate(w, "layout", info) err = t.ExecuteTemplate(w, "layout", info)
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
} }
} }
@ -155,18 +146,12 @@ func configHandler() func(w http.ResponseWriter, r *http.Request) {
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/menu.tmpl", "data/templates/config.tmpl") t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/menu.tmpl", "data/templates/config.tmpl")
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
err = t.ExecuteTemplate(w, "layout", nil) err = t.ExecuteTemplate(w, "layout", nil)
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
} }
} }
@ -179,10 +164,7 @@ func configRESTHandler(cs *config.ConfigService) func(w http.ResponseWriter, r *
log.Printf("Updating config") log.Printf("Updating config")
b, err := io.ReadAll(r.Body) b, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
err = cs.Config.UpdateFromJSON(b) err = cs.Config.UpdateFromJSON(b)
@ -236,10 +218,7 @@ func fetchInfoOneRESTHandler(cs *config.ConfigService, dm *download.Manager) fun
b, err := io.ReadAll(r.Body) b, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
err = json.Unmarshal(b, &thisReq) err = json.Unmarshal(b, &thisReq)
@ -289,10 +268,7 @@ func fetchInfoRESTHandler(dm *download.Manager) func(w http.ResponseWriter, r *h
b, err := dm.DownloadsAsJSON() b, err := dm.DownloadsAsJSON()
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
_, err = w.Write(b) _, err = w.Write(b)
if err != nil { if err != nil {
@ -328,20 +304,14 @@ func fetchHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Ma
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/popup.tmpl") t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/popup.tmpl")
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
templateData := map[string]interface{}{"dl": dl, "config": cs.Config, "canStop": download.CanStopDownload, "Version": vm.GetInfo()} templateData := map[string]interface{}{"dl": dl, "config": cs.Config, "canStop": download.CanStopDownload, "Version": vm.GetInfo()}
err = t.ExecuteTemplate(w, "layout", templateData) err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
return return
} else if method == "POST" { } else if method == "POST" {
@ -353,29 +323,23 @@ func fetchHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Ma
} }
req := reqType{} req := reqType{}
err := json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
if err != nil {
log.Printf("error decoding body of request: %s", err)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
log.Printf("popup POST request: %#v", req) log.Printf("popup POST request: %#v", req)
if req.URL == "" { if req.URL == "" {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(errorResponse{ json.NewEncoder(w).Encode(errorResponse{
Success: false, Success: false,
Error: "No URL supplied", Error: "No URL supplied",
}) })
return return
} else { } else {
if req.ProfileChosen == "" { if req.ProfileChosen == "" {
w.WriteHeader(400) w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(errorResponse{ json.NewEncoder(w).Encode(errorResponse{
Success: false, Success: false,
Error: "you must choose a profile", Error: "you must choose a profile",
}) })
@ -385,7 +349,7 @@ func fetchHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Ma
profile := cs.Config.ProfileCalled(req.ProfileChosen) profile := cs.Config.ProfileCalled(req.ProfileChosen)
if profile == nil { if profile == nil {
w.WriteHeader(400) w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(errorResponse{ json.NewEncoder(w).Encode(errorResponse{
Success: false, Success: false,
Error: fmt.Sprintf("no such profile: '%s'", req.ProfileChosen), Error: fmt.Sprintf("no such profile: '%s'", req.ProfileChosen),
}) })
@ -403,7 +367,7 @@ func fetchHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Ma
dm.Queue(newDL) dm.Queue(newDL)
w.WriteHeader(200) w.WriteHeader(200)
_ = json.NewEncoder(w).Encode(queuedResponse{ json.NewEncoder(w).Encode(queuedResponse{
Success: true, Success: true,
Location: fmt.Sprintf("/fetch/%d", id), Location: fmt.Sprintf("/fetch/%d", id),
}) })
@ -416,129 +380,22 @@ func fetchHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Ma
if !present { if !present {
w.WriteHeader(400) w.WriteHeader(400)
_, _ = fmt.Fprint(w, "No url supplied") fmt.Fprint(w, "No url supplied")
return return
} }
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/popup_create.tmpl") t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/popup_create.tmpl")
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
templateData := map[string]interface{}{"config": cs.Config, "url": url[0], "Version": vm.GetInfo()} templateData := map[string]interface{}{"config": cs.Config, "url": url[0], "Version": vm.GetInfo()}
err = t.ExecuteTemplate(w, "layout", templateData) err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil { if err != nil {
log.Printf("error: %s", err) panic(err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
} }
} }
} }
} }
func bulkHandler(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("bulkHandler")
method := r.Method
switch method {
case "GET":
t, err := template.ParseFS(webFS, "data/templates/layout.tmpl", "data/templates/menu.tmpl", "data/templates/bulk.tmpl")
if err != nil {
log.Printf("error: %s", err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
}
templateData := map[string]interface{}{"config": cs.Config, "Version": vm.GetInfo()}
err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil {
log.Printf("error: %s", err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
}
return
case "POST":
type reqBulkType struct {
URLs string `json:"urls"`
ProfileChosen string `json:"profile"`
DownloadOptionChosen string `json:"download_option"`
}
req := reqBulkType{}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
log.Printf("error decoding request body: %s", err)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
log.Printf("bulk POST request: %#v", req)
if req.URLs == "" {
w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(errorResponse{
Success: false,
Error: "No URLs supplied",
})
return
}
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 downloads
urls := strings.Split(req.URLs, "\n")
count := 0
for _, thisURL := range urls {
thisURL = strings.TrimSpace(thisURL)
if thisURL != "" {
newDL := download.NewDownload(thisURL, cs.Config)
newDL.DownloadOption = option
newDL.DownloadProfile = *profile
dm.AddDownload(newDL)
dm.Queue(newDL)
log.Printf("queued %s", thisURL)
count++
}
}
w.WriteHeader(200)
_ = json.NewEncoder(w).Encode(successResponse{
Success: true,
Message: fmt.Sprintf("queued %d downloads", count),
})
return
}
}
}