Compare commits
42 Commits
v1.0.0-alp
...
main
Author | SHA1 | Date | |
---|---|---|---|
be963c7c54 | |||
661e676fd2 | |||
08b56d1c77 | |||
2c42204f90 | |||
dc31122c05 | |||
08d73d72e1 | |||
b559f15eb1 | |||
d864a8c486 | |||
0655700d15 | |||
4cff36e54f | |||
54e5dbab60 | |||
c45e261396 | |||
192479819d | |||
92c4cc6284 | |||
dd211f6077 | |||
3b23ff356c | |||
94b57fc327 | |||
36607b43ab | |||
b466157cd0 | |||
d9a979b782 | |||
3dec93c4f4 | |||
3353d3d923 | |||
7b326d72b1 | |||
bef753d7ee | |||
a66ab08431 | |||
b0048a5764 | |||
73833a1a14 | |||
aa64e000ee | |||
5121438ffc | |||
46dbf2d64f | |||
6b13e54fb5 | |||
9a2497c244 | |||
bb8193b504 | |||
5d57803799 | |||
e699c7ea5d | |||
c4e55c0870 | |||
58d1b0c3de | |||
cab1d2d498 | |||
b5987d6eac | |||
9538e2a2bf | |||
42a4793953 | |||
3708df9525 |
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
@ -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.17
|
go-version: 1.22
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +1,6 @@
|
|||||||
gropple
|
gropple
|
||||||
release
|
release
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
@ -1,17 +1,9 @@
|
|||||||
# This is an example .goreleaser.yml file with some sensible defaults.
|
version: 2
|
||||||
# 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:
|
||||||
@ -22,7 +14,7 @@ builds:
|
|||||||
- darwin
|
- darwin
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- formats: [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 }}_
|
||||||
@ -34,20 +26,28 @@ archives:
|
|||||||
# use zip for windows archives
|
# use zip for windows archives
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: [zip]
|
||||||
|
|
||||||
# changelog:
|
changelog:
|
||||||
# sort: asc
|
disable: true
|
||||||
# filters:
|
|
||||||
# exclude:
|
|
||||||
# - "^docs:"
|
|
||||||
# - "^test:"
|
|
||||||
|
|
||||||
dockers:
|
dockers:
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- "tardisx/gropple:{{ .Tag }}"
|
- "tardisx/gropple:{{ .Tag }}-amd64"
|
||||||
- "tardisx/gropple:v{{ .Major }}"
|
use: buildx
|
||||||
- "tardisx/gropple:v{{ .Major }}.{{ .Minor }}"
|
build_flag_templates:
|
||||||
- "tardisx/gropple:latest"
|
- "--pull"
|
||||||
|
- "--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"
|
||||||
|
36
CHANGELOG.md
36
CHANGELOG.md
@ -2,7 +2,41 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
- Don't start downloads until "start download" is pressed
|
||||||
|
- Add "download option" for more per-download customisability, especially for destinations
|
||||||
|
- Removed "destinations" as that is now possible more flexibly with download options.
|
||||||
|
- Existing configurations using destinations are automatically migrated to an appropriate `yt-dlp -o ...` download options
|
||||||
|
- Gropple now available via docker
|
||||||
|
- 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
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM ubuntu:mantic
|
FROM ubuntu:noble
|
||||||
COPY gropple /
|
COPY gropple /
|
||||||
|
|
||||||
RUN apt update && apt install -y curl python3 ffmpeg
|
RUN apt update && apt install -y curl python3 ffmpeg
|
||||||
|
155
README.md
155
README.md
@ -1,41 +1,56 @@
|
|||||||
# gropple
|
# gropple
|
||||||
|
|
||||||
A frontend to youtube-dl (or compatible forks, like yt-dlp) to download videos with a single click, straight from your web browser.
|
A frontend to youtube-dl (or compatible forks, like yt-dlp) to download videos
|
||||||
|
with a single click, straight from your web browser.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Pre-requisites
|
## Installing
|
||||||
|
|
||||||
* some familiarity with the command line
|
### From Source
|
||||||
* youtube-dl (plus any of its required dependencies, like ffmpeg)
|
|
||||||
* golang compiler (only if you'd like to build from source)
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
go build
|
go build
|
||||||
|
|
||||||
## Binaries
|
### Standalone Binaries
|
||||||
|
|
||||||
Binaries are available at <https://github.com/tardisx/gropple/releases>
|
Binaries are available at <https://github.com/tardisx/gropple/releases> for most
|
||||||
|
platforms.
|
||||||
Gropple will automatically check for available updates and prompt you to
|
|
||||||
upgrade.
|
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
./gropple
|
### From Binaries
|
||||||
|
|
||||||
|
./gropple
|
||||||
|
|
||||||
There are no command line arguments. All configuration is done via the web
|
There are no command line arguments. All configuration is done via the web
|
||||||
interface. The address will be printed after startup:
|
interface. The address will be printed after startup:
|
||||||
|
|
||||||
2021/09/30 23:53:00 starting gropple v0.5.0 - https://github.com/tardisx/gropple
|
2023/11/22 22:42:06 Starting gropple v1.0.0 - https://github.com/tardisx/gropple
|
||||||
2021/09/30 23:53:00 go to http://localhost:6123 for details on installing the bookmarklet and to check status
|
2023/11/22 22:42:07 Configuration loaded from /Users/username/path/config.yml
|
||||||
|
2023/11/22 22:42:07 Visit http://localhost:6123 for details on installing the bookmarklet and to check status
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Copy the `docker-compose.yml` to a directory somewhere.
|
||||||
|
|
||||||
|
Edit the two `volume` entries to point to local paths where you would like to
|
||||||
|
store the config file, and the downloads (the path on the left hand side of the
|
||||||
|
colon).
|
||||||
|
|
||||||
|
Run `docker-compose up -d` to start the program.
|
||||||
|
|
||||||
|
Note that the docker images include `yt-dlp` and `ffmpeg` and are thus
|
||||||
|
completely self-contained.
|
||||||
|
|
||||||
|
Run `docker-compose logs` to see the output of the program, if you are having
|
||||||
|
problems.
|
||||||
|
|
||||||
## Using
|
## Using
|
||||||
|
|
||||||
Bring up `http://localhost:6283` (or your configured address) in your browser.
|
Bring up `http://localhost:6283` (or the appropriate host if you are running it
|
||||||
You should see a link to the bookmarklet at the top of the screen, and the list
|
on a different machine) in your browser. You should see a link to the
|
||||||
of downloads (currently empty).
|
bookmarklet at the top of the screen, and the list of downloads (currently
|
||||||
|
empty).
|
||||||
|
|
||||||
Drag the bookmarklet to your favourites bar, or otherwise bookmark it as you see
|
Drag the bookmarklet to your favourites bar, or otherwise bookmark it as you see
|
||||||
fit. Any kind of browser bookmark should work. The bookmarklet contains embedded
|
fit. Any kind of browser bookmark should work. The bookmarklet contains embedded
|
||||||
@ -48,44 +63,114 @@ the bookmarklet.
|
|||||||
A popup window will appear. Choose a download profile and the download will
|
A popup window will appear. Choose a download profile and the download will
|
||||||
start. 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.
|
||||||
|
|
||||||
|
There is also an optional "download option" you can choose. These are discussed
|
||||||
|
below.
|
||||||
|
|
||||||
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. Clicking on the id number will
|
||||||
|
show the popup again.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Click the "config" link on the index page to configure gropple. The default
|
Click the "config" link on the index page to configure gropple.
|
||||||
options are fine if you are running on your local machine. If you are running it
|
|
||||||
remotely you will need to set the "server address" to ensure the bookmarklet has
|
|
||||||
the correct URL in it.
|
|
||||||
|
|
||||||
### Configuring Downloaders
|
The options in each part are dicussed below.
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
#### Port and Server Address
|
||||||
|
|
||||||
|
You can configure the port number here if you do not want the default of `6123`.
|
||||||
|
|
||||||
|
If you are running it on a machine other than `localhost` you will need to set
|
||||||
|
the "server address" to ensure the bookmarklet has the correct URL in it.
|
||||||
|
Similarly, if you are running it behind a reverse proxy, the address here must
|
||||||
|
match what you would type in the browser so that the bookmarklet will work
|
||||||
|
correctly.
|
||||||
|
|
||||||
|
#### Download path
|
||||||
|
|
||||||
|
The download path specifies where downloads will end up, *if* no specific `-o`
|
||||||
|
options are passed to `yt-dlp`.
|
||||||
|
|
||||||
|
#### Maximum active downloads per domain
|
||||||
|
|
||||||
|
Gropple will limit the number of downloads per domain to this number. Increasing
|
||||||
|
this will likely result in failed downloads when server rate limiters notice
|
||||||
|
you.
|
||||||
|
|
||||||
|
#### UI popup size
|
||||||
|
|
||||||
|
Changes the size of the popup window.
|
||||||
|
|
||||||
|
### Download Profiles
|
||||||
|
|
||||||
Gropple's default configuration uses `yt-dlp` and has two profiles set up, one
|
Gropple's default configuration uses `yt-dlp` and has two profiles set up, one
|
||||||
for downloading video, the other for downloading audio (mp3).
|
for downloading video, the other for downloading audio (mp3).
|
||||||
|
|
||||||
|
Each download profile consists of a name (for your reference), a command to run,
|
||||||
|
and a number of arguments.
|
||||||
|
|
||||||
Note that gropple does not include any downloaders, you have to install them
|
Note that gropple does not include any downloaders, you have to install them
|
||||||
separately.
|
separately (unless using the docker image).
|
||||||
|
|
||||||
If you would like to use a youtube-dl compatible fork or change the options you
|
If you would like to use a youtube-dl compatible fork or change the options you
|
||||||
can do so on the right hand side. Create as many profiles as you wish, whenever
|
can do so here. Create as many profiles as you wish, whenever you start a
|
||||||
you start a download you can choose the appropriate profile.
|
download you can choose the appropriate profile.
|
||||||
|
|
||||||
Note that the command arguments must each be specified separately - see the
|
Note that the command arguments must each be specified separately - see the
|
||||||
default configuration for an example.
|
default configuration. For example, if you have a single argument like
|
||||||
|
`--audio-format mp3`, it will be parsed by the `yt-dlp` as a single, long
|
||||||
|
unknown argument, and will fail. This needs to be configured as two arguments,
|
||||||
|
`--audio-format` and `mp3`.
|
||||||
|
|
||||||
While gropple will use your `PATH` to find the executable, you can also specify
|
While gropple will use your `PATH` to find the executable, you can also specify
|
||||||
a full path instead. Note that any tools that the downloader calls itself (for
|
a full path instead. Note that any tools that the downloader calls itself (for
|
||||||
instance, `ffmpeg`) will need to be available on your path.
|
instance, `ffmpeg`) will need to be available on your path.
|
||||||
|
|
||||||
### Alternate destinations
|
### Download Options
|
||||||
|
|
||||||
Gropple supports adding additional optional destinations. By default, all
|
There are also an arbitrary amount of Download Options you can configure. Each
|
||||||
downloads will be stored in the main download path specified in the config. You
|
one specifies one or more extra arguments to add to the downloader command line.
|
||||||
can also add one or more destinations, and you can choose one of these
|
The most common use for this is to have customised download paths. For instance,
|
||||||
destinations when queueing a new download, or while it is still downloading from
|
sometimes you might want to bundle all files into a single directory, other
|
||||||
the popup.
|
times you might want to separate files by download playlist URL or similar.
|
||||||
|
|
||||||
The file will be moved after downloading is complete.
|
Most of this is done directly through appropriate options for `yt-dlp`, see the
|
||||||
|
[output template
|
||||||
|
documentation](https://github.com/yt-dlp/yt-dlp#output-template).
|
||||||
|
|
||||||
|
However, gropple offers two extra substitutions:
|
||||||
|
|
||||||
|
* `%GROPPLE_HOST%`
|
||||||
|
* `%GROPPLE_PATH%`
|
||||||
|
|
||||||
|
These will be replaced with the hostname, and path of the download,
|
||||||
|
respectively.
|
||||||
|
|
||||||
|
So, a playlist URL `https://www.youtube.com/@UsernameHere`
|
||||||
|
|
||||||
|
With a download option setup like this:
|
||||||
|
|
||||||
|
* Name of Option: Split by Host and Path
|
||||||
|
* Arguments:
|
||||||
|
* -o
|
||||||
|
* /Downloads/%GROPPLE_HOST%/%GROPPLE_PATH%/%(title)s [%(id)s].%(ext)s
|
||||||
|
|
||||||
|
Will result in downloads going into the path
|
||||||
|
`/Downloads/www.youtube.com/@UsernameHere/...`.
|
||||||
|
|
||||||
|
Note that this also means that `yt-dlp` can resume partially downloaded files, and
|
||||||
|
also automatically 'backfill', downloading only files that have not been
|
||||||
|
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
|
||||||
|
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
#!/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";
|
|
||||||
|
|
@ -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: "sleep", Args: []string{"5"}}}
|
cs.Config.DownloadProfiles = []DownloadProfile{{Name: "test profile", Command: "/bin/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 = 3
|
defaultConfig.ConfigVersion = 4
|
||||||
|
|
||||||
cs.Config = &defaultConfig
|
cs.Config = &defaultConfig
|
||||||
|
|
||||||
@ -189,9 +189,10 @@ 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("Could not find %s on the path", newConfig.DownloadProfiles[i].Command)
|
return fmt.Errorf("problem with command '%s': %s", newConfig.DownloadProfiles[i].Command, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,14 +266,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
|
||||||
@ -300,8 +301,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")
|
||||||
}
|
}
|
||||||
@ -318,7 +319,8 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error writing config: %s", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
path := cs.ConfigPath
|
path := cs.ConfigPath
|
||||||
@ -337,3 +339,28 @@ 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
|
||||||
|
}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
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"
|
||||||
@ -102,13 +105,116 @@ 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")
|
||||||
tmpFile.Write([]byte(configString))
|
_, err1 := tmpFile.Write([]byte(configString))
|
||||||
tmpFile.Close()
|
err2 := 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
gropple:
|
|
||||||
build: .
|
|
||||||
image: tardisx/gropple:$VERSION
|
|
@ -1,12 +1,11 @@
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
gropple:
|
gropple:
|
||||||
build: .
|
image: tardisx/gropple:v1.1.4
|
||||||
image: tardisx/gropple:latest
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./gropple-config-dir:/config
|
- /tmp/gropple-config-dir/:/config
|
||||||
- ./downloads:/downloads/
|
- /tmp/downloads/:/downloads/
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "6123:6123"
|
- "6123:6123"
|
||||||
|
@ -277,13 +277,25 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
dl.Log = append(dl.Log, fmt.Sprintf("executing: %s with args: %s", dl.DownloadProfile.Command, strings.Join(cmdSlice, " ")))
|
cmdPath, err := config.AbsPathToExecutable(dl.DownloadProfile.Command)
|
||||||
cmd := exec.Command(dl.DownloadProfile.Command, cmdSlice...)
|
if err != nil {
|
||||||
|
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 {
|
||||||
@ -292,7 +304,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,9 +318,10 @@ 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()
|
||||||
@ -363,7 +375,6 @@ 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
|
||||||
@ -418,8 +429,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
t.Fatalf("bad long eta in dl\n%#v", newD) //nolint
|
||||||
}
|
}
|
||||||
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) //nolint
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
t.Fatalf("bad short eta in dl with frag\n%v", newD) //nolint
|
||||||
}
|
}
|
||||||
|
|
||||||
// [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
|
||||||
[download] Destination: The Greatest Shot In Television [2WoDQBhJCVQ].f137.mp4
|
[debug] Invoking hlsnative downloader on "https://example.org/urls/1.2.3.4%
|
||||||
[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
8
go.mod
@ -1,11 +1,13 @@
|
|||||||
module github.com/tardisx/gropple
|
module github.com/tardisx/gropple
|
||||||
|
|
||||||
go 1.20
|
go 1.23.0
|
||||||
|
|
||||||
|
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.8.4
|
github.com/stretchr/testify v1.9.0
|
||||||
golang.org/x/mod v0.14.0
|
golang.org/x/mod v0.24.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
8
go.sum
8
go.sum
@ -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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
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
11
main.go
@ -9,13 +9,18 @@ import (
|
|||||||
|
|
||||||
"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"
|
v "github.com/tardisx/gropple/version"
|
||||||
"github.com/tardisx/gropple/web"
|
"github.com/tardisx/gropple/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
version = "dev"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
versionInfo := &version.Manager{
|
versionInfo := &v.Manager{
|
||||||
VersionInfo: version.Info{CurrentVersion: "v0.7.0"},
|
// version from goreleaser has no leading 'v', even if the tag does
|
||||||
|
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)
|
||||||
|
|
||||||
|
91
web/data/templates/bulk.tmpl
Normal file
91
web/data/templates/bulk.tmpl
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
{{ 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> </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 }}
|
@ -84,7 +84,10 @@
|
|||||||
|
|
||||||
<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>
|
<span class="pure-form-message">Which command to run. Your path will be searched, or you can specify the full path here.
|
||||||
|
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>
|
||||||
|
@ -7,9 +7,11 @@
|
|||||||
<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>
|
||||||
|
|
@ -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>{{ $i.Name }}</option>
|
<option name="{{$i.Name}}">{{ $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>{{ $i.Name }}</option>
|
<option name="{{$i.Name}}">{{ $i.Name }}</option>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
181
web/web.go
181
web/web.go
@ -55,6 +55,9 @@ 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))
|
||||||
|
|
||||||
@ -89,7 +92,10 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
@ -110,7 +116,10 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -146,12 +155,18 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,7 +179,10 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
err = cs.Config.UpdateFromJSON(b)
|
err = cs.Config.UpdateFromJSON(b)
|
||||||
|
|
||||||
@ -218,7 +236,10 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(b, &thisReq)
|
err = json.Unmarshal(b, &thisReq)
|
||||||
@ -268,7 +289,10 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", 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 {
|
||||||
@ -304,14 +328,20 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
} else if method == "POST" {
|
} else if method == "POST" {
|
||||||
@ -323,23 +353,29 @@ func fetchHandler(cs *config.ConfigService, vm *version.Manager, dm *download.Ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := reqType{}
|
req := reqType{}
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
err := 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(400)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
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",
|
||||||
})
|
})
|
||||||
@ -349,7 +385,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),
|
||||||
})
|
})
|
||||||
@ -367,7 +403,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),
|
||||||
})
|
})
|
||||||
@ -380,22 +416,129 @@ 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", 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 {
|
||||||
panic(err)
|
log.Printf("error: %s", 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user