Compare commits

...

66 Commits

Author SHA1 Message Date
be963c7c54 Be specific about version 2025-04-25 17:24:13 +09:30
661e676fd2 Fix version passing 2025-04-25 17:10:59 +09:30
08b56d1c77 Update version 2025-04-25 16:50:42 +09:30
2c42204f90 Version from goreleaser 2025-04-25 16:48:36 +09:30
dc31122c05 Bump version 2025-04-25 11:56:47 +09:30
08d73d72e1 Fix goreleaser config 2025-04-25 11:56:38 +09:30
b559f15eb1 New goreleaser config and docker setup (with arm64) 2025-04-25 11:44:59 +09:30
d864a8c486 Do some de-linting, but disable for now in goreleaser 2025-04-25 11:13:48 +09:30
0655700d15 Upgrade dependencies 2025-04-25 11:04:12 +09:30
4cff36e54f Update .gitignore 2025-04-25 11:00:57 +09:30
54e5dbab60 Update changelog 2024-03-17 09:45:35 +10:30
c45e261396 Fix workflow 2024-03-17 09:38:08 +10:30
192479819d Bump version for next release 2024-03-17 09:37:03 +10:30
92c4cc6284 Upgrade deps 2024-03-17 09:36:53 +10:30
dd211f6077 Lint 2024-03-17 09:33:09 +10:30
3b23ff356c Fix template 2024-03-16 23:42:42 +10:30
94b57fc327 Update gitignore 2024-03-16 21:21:31 +10:30
36607b43ab Fix goreleaser 2024-03-16 21:21:05 +10:30
b466157cd0 Bump version 2024-03-16 21:16:49 +10:30
d9a979b782 Remove (almost) all the panics 2024-03-16 21:14:46 +10:30
3dec93c4f4 Version bump 2023-12-08 19:54:20 +10:30
3353d3d923 Juggle the test to the right place, call it also while saving config to be consistent 2023-11-28 21:30:48 +10:30
7b326d72b1 THis time for sure 2023-11-28 20:31:39 +10:30
bef753d7ee THis is lame 2023-11-28 20:28:54 +10:30
a66ab08431 Maybe this 2023-11-28 20:24:18 +10:30
b0048a5764 Deal with /usr/bin/sleep on github (seriously?) 2023-11-28 20:11:31 +10:30
73833a1a14 Break out all the executable-finding guff and add some tests 2023-11-28 20:07:17 +10:30
aa64e000ee Fix test, bump version for a test release 2023-11-26 18:53:52 +10:30
5121438ffc Attempted fix for portable mode 2023-11-26 18:50:30 +10:30
46dbf2d64f Do not need this anymore 2023-11-26 17:02:21 +10:30
6b13e54fb5 Run tests before release 2023-11-26 17:00:46 +10:30
9a2497c244 Fix bug with new config having wrong version 2023-11-26 16:59:54 +10:30
bb8193b504 Update docco 2023-11-25 22:40:39 +10:30
5d57803799 Add feature to do bulk downloads, bump version 2023-11-25 22:03:06 +10:30
e699c7ea5d Bump version 2023-11-24 21:16:58 +10:30
c4e55c0870 Fix crash bug if V3 config had > 1 destinations 2023-11-24 21:15:55 +10:30
58d1b0c3de Update changelog 2023-11-23 18:36:38 +10:30
cab1d2d498 Update docco and bump version for v1.0.0 2023-11-23 18:11:29 +10:30
b5987d6eac Bump version 2023-11-22 21:04:57 +10:30
9538e2a2bf Update docker-compose 2023-11-22 21:04:08 +10:30
42a4793953 Bump version 2023-11-22 21:01:37 +10:30
3708df9525 Don't need that 2023-11-22 21:00:45 +10:30
c31d25c048 Launch.json 2023-11-22 08:33:11 +10:30
c806ee8905 Clean up file list 2023-11-22 08:32:36 +10:30
d0e61d1247 Prepare for goreleaser 2023-11-22 08:32:20 +10:30
746a65dc80 Update .gitignore 2023-11-22 08:31:58 +10:30
d51a703820 Tidy up file list 2023-11-21 22:05:13 +10:30
d73c38ddc3 Perform host/path substitutions 2023-11-21 22:04:52 +10:30
e65ae41a4a Fix version display 2023-11-20 21:22:48 +10:30
5bd7601faa More debugging if test fails 2023-11-20 21:02:45 +10:30
a1e6421842 Destinations are now DownloadOptions 2023-11-20 21:01:50 +10:30
fa978fecc2 Spelling 2023-11-20 20:57:46 +10:30
12e9b83916 Update dependencies 2023-11-20 07:39:38 +10:30
adb9922b52
Refactor web (#26) 2023-11-20 07:38:16 +10:30
6e2c8d17a1 I am a numpty, put the testdata in the right place. 2023-11-19 20:58:26 +10:30
fe884799c7 Add an option to start with some test downloads queued 2023-11-11 13:07:26 +10:30
329d7703a0 More detail in README 2023-03-15 05:46:25 +10:30
5d1f4ffadb Update changelog, skip docker for now until it is ready 2023-03-15 05:18:56 +10:30
385de634f6 Fix Stop button 2023-03-15 05:15:49 +10:30
431ef985bc Cleanup spruious logs 2023-03-15 05:15:25 +10:30
a5e201c290 Restore cleanup 2023-03-15 05:04:02 +10:30
d650725523 Clean up documentation 2023-03-15 05:00:56 +10:30
7f0a51d659 New alpha 2023-03-15 04:46:14 +10:30
4909f63c93 Appease the linter 2023-03-15 04:45:10 +10:30
c8f10e01c7 Cleanup for release 2023-03-15 04:33:48 +10:30
cf7efa70ee Adjust log display to always show all entries. Fixes #22. 2023-03-13 11:43:08 +10:30
29 changed files with 1481 additions and 723 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.17 go-version: 1.22
- name: Build - name: Build
run: go build -v ./... run: go build -v ./...

5
.gitignore vendored
View File

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

53
.goreleaser.yaml Normal file
View File

@ -0,0 +1,53 @@
version: 2
before:
hooks:
- go mod tidy
- go test ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [zip]
changelog:
disable: true
dockers:
- image_templates:
- "tardisx/gropple:{{ .Tag }}-amd64"
use: buildx
build_flag_templates:
- "--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"

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

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

View File

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

View File

@ -2,9 +2,43 @@
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
## [v0.6.0] - 2023-03-09 - 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
- Configurable destinations for downloads - Configurable destinations for downloads
- Multiple destination directories can be configured - Multiple destination directories can be configured
@ -12,10 +46,11 @@ All notable changes to this project will be documented in this file.
- When downloading from a playlist, show the total number of videos and how many have been downloaded - When downloading from a playlist, show the total number of videos and how many have been downloaded
- Show version in web UI - Show version in web UI
- Improve index page (show URL of queued downloads instead of nothing) - Improve index page (show URL of queued downloads instead of nothing)
- Add docker support
- Fixes and improvements to capturing output info and showing it in the UI - Fixes and improvements to capturing output info and showing it in the UI
- Show all log output in the popup
- Fixes to handling of queued downloads - Fixes to handling of queued downloads
- Fix portable mode to look in binary directory, not current directory - Fix portable mode to look in binary directory, not current directory
- Automatically cleanup download list, removing old entries automatically
## [v0.5.5] - 2022-04-09 ## [v0.5.5] - 2022-04-09

View File

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

205
README.md
View File

@ -1,97 +1,196 @@
# 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.
![Screencast](/screencast.gif) ![Screencast](/screencast.gif)
## 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. You Bring up `http://localhost:6283` (or the appropriate host if you are running it
should see a link to the bookmarklet at the top of the screen, and the list of on a different machine) in your browser. You should see a link to the
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 Drag the bookmarklet to your favourites bar, or otherwise bookmark it as you see
see fit. Any kind of browser bookmark should work. The bookmarklet contains fit. Any kind of browser bookmark should work. The bookmarklet contains embedded
embedded javascript to pass the URL of whatever page you are currently on back javascript to pass the URL of whatever page you are currently on back to
to gropple. gropple.
So, whenever you are on a page with a video you would like to download just Whenever you are on a page with a video you would like to download just click
click the bookmarklet. the bookmarklet.
A popup window will appear. Choose a download profile and the download will start. A popup window will appear. Choose a download profile and the download will
The status will be shown in the window, updating in real time. start. The status will be shown in the window, updating in real time.
You may close this window at any time without stopping the download, the status There is also an optional "download option" you can choose. These are discussed
of all downloads is available on the index page. below.
You may close this window at any time without stopping the download, the status
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 options Click the "config" link on the index page to configure gropple.
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.
Gropple's default configuration uses the original youtube-dl and has two profiles set ### Server
up, one for downloading video, the other for downloading audio (mp3).
Note that gropple does not include any downloaders, you have to install them separately. #### Port and Server Address
If you would like to use a youtube-dl fork (like [yt-dlp](https://github.com/yt-dlp/yt-dlp)) You can configure the port number here if you do not want the default of `6123`.
or change the options, you can do so on the right hand side. Create as many profiles as you
wish, whenever you start a download you can choose the appropriate profile.
Note that the command arguments must each be specified separately - see the default configuration If you are running it on a machine other than `localhost` you will need to set
for an example. 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.
While gropple will use your `PATH` to find the executable, you can also specify a full path #### Download path
instead. Note that any tools that the downloader calls itself (for instance, ffmpeg) will
probably need to be available on your 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
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
separately (unless using the docker image).
If you would like to use a youtube-dl compatible fork or change the options you
can do so here. Create as many profiles as you wish, whenever you start a
download you can choose the appropriate profile.
Note that the command arguments must each be specified separately - see the
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
a full path instead. Note that any tools that the downloader calls itself (for
instance, `ffmpeg`) will need to be available on your path.
### Download Options
There are also an arbitrary amount of Download Options you can configure. Each
one specifies one or more extra arguments to add to the downloader command line.
The most common use for this is to have customised download paths. For instance,
sometimes you might want to bundle all files into a single directory, other
times you might want to separate files by download playlist URL or similar.
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
If you'd like to use gropple from a USB stick or similar, copy the config file from If you'd like to use gropple from a USB stick or similar, copy the config file
it's default location (shown when you start gropple) to the same location as the binary, and rename it to `gropple.yml`. from its default location (shown when you start gropple) to the same location as
the binary, and rename it to `gropple.yml`.
If that file is present in the same directory as the binary, it will be used instead.
## Problems ## Problems
Most download problems are probably diagnosable via the log - check in the popup window and scroll Many download problems are diagnosable via the log - check in the popup window
the log down to the bottom. The most common problem is that youtube-dl cannot be found, or its and scroll the log down to the bottom. The most common problem is that `yt-dlp`
dependency (like ffmpeg) cannot be found on your path. cannot be found, or its dependency (like `ffmpeg`) cannot be found on your path.
Gropple only calls external tools like `yt-dlp` to do the downloading. If you
are having problems downloading from a site, make sure that `yt-dlp` is updated
to the latest version (`yd-dlp -U`).
For other problems, please file an issue on github. For other problems, please file an issue on github.
## TODO ## TODO
Many things. Please raise an issue after checking the [currently open issues](https://github.com/tardisx/gropple/issues). Many things. Please raise an issue after checking the [currently open
issues](https://github.com/tardisx/gropple/issues).

View File

@ -1,48 +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
$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

@ -27,6 +27,12 @@ type DownloadProfile struct {
Args []string `yaml:"args" json:"args"` Args []string `yaml:"args" json:"args"`
} }
// DownloadOption contains configuration for extra arguments to pass to the download command
type DownloadOption struct {
Name string `yaml:"name" json:"name"`
Args []string `yaml:"args" json:"args"`
}
// UI holds the configuration for the user interface // UI holds the configuration for the user interface
type UI struct { type UI struct {
PopupWidth int `yaml:"popup_width" json:"popup_width"` PopupWidth int `yaml:"popup_width" json:"popup_width"`
@ -44,8 +50,9 @@ type Config struct {
ConfigVersion int `yaml:"config_version" json:"config_version"` ConfigVersion int `yaml:"config_version" json:"config_version"`
Server Server `yaml:"server" json:"server"` Server Server `yaml:"server" json:"server"`
UI UI `yaml:"ui" json:"ui"` UI UI `yaml:"ui" json:"ui"`
Destinations []Destination `yaml:"destinations" json:"destinations"` Destinations []Destination `yaml:"destinations" json:"destinations"` // no longer in use, see DownloadOptions
DownloadProfiles []DownloadProfile `yaml:"profiles" json:"profiles"` DownloadProfiles []DownloadProfile `yaml:"profiles" json:"profiles"`
DownloadOptions []DownloadOption `yaml:"download_options" json:"download_options"`
} }
// ConfigService is a struct to handle configuration requests, allowing for the // ConfigService is a struct to handle configuration requests, allowing for the
@ -58,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() {
@ -88,15 +95,16 @@ func (cs *ConfigService) LoadDefaultConfig() {
defaultConfig.Server.MaximumActiveDownloads = 2 defaultConfig.Server.MaximumActiveDownloads = 2
defaultConfig.Destinations = make([]Destination, 0) defaultConfig.Destinations = nil
defaultConfig.DownloadOptions = make([]DownloadOption, 0)
defaultConfig.ConfigVersion = 3 defaultConfig.ConfigVersion = 4
cs.Config = &defaultConfig cs.Config = &defaultConfig
return
} }
// ProfileCalled returns the corresponding DownloadProfile, or nil if it does not exist
func (c *Config) ProfileCalled(name string) *DownloadProfile { func (c *Config) ProfileCalled(name string) *DownloadProfile {
for _, p := range c.DownloadProfiles { for _, p := range c.DownloadProfiles {
if p.Name == name { if p.Name == name {
@ -106,10 +114,11 @@ func (c *Config) ProfileCalled(name string) *DownloadProfile {
return nil return nil
} }
func (c *Config) DestinationCalled(name string) *Destination { // DownloadOptionCalled returns the corresponding DownloadOption, or nil if it does not exist
for _, p := range c.Destinations { func (c *Config) DownloadOptionCalled(name string) *DownloadOption {
if p.Name == name { for _, o := range c.DownloadOptions {
return &p if o.Name == name {
return &o
} }
} }
return nil return nil
@ -180,20 +189,10 @@ func (c *Config) UpdateFromJSON(j []byte) error {
} }
// check the command exists // check the command exists
_, err := exec.LookPath(newConfig.DownloadProfiles[i].Command)
if err != nil {
return fmt.Errorf("Could not find %s on the path", newConfig.DownloadProfiles[i].Command)
}
}
// check destinations _, err := AbsPathToExecutable(newConfig.DownloadProfiles[i].Command)
for _, dest := range newConfig.Destinations {
s, err := os.Stat(dest.Path)
if err != nil { if err != nil {
return fmt.Errorf("destination '%s' (%s) is bad: %s", dest.Name, dest.Path, err) return fmt.Errorf("problem with command '%s': %s", newConfig.DownloadProfiles[i].Command, err)
}
if !s.IsDir() {
return fmt.Errorf("destination '%s' (%s) is not a directory", dest.Name, dest.Path)
} }
} }
@ -267,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
@ -294,6 +293,20 @@ func (cs *ConfigService) LoadConfig() error {
log.Print("migrated config from version 2 => 3") log.Print("migrated config from version 2 => 3")
} }
if c.ConfigVersion == 3 {
c.ConfigVersion = 4
for i := range c.Destinations {
newDownloadOption := DownloadOption{
Name: c.Destinations[i].Name,
Args: []string{"-o", fmt.Sprintf("%s/%%(title)s [%%(id)s].%%(ext)s", c.Destinations[i].Path)},
}
c.DownloadOptions = append(c.DownloadOptions, newDownloadOption)
}
c.Destinations = nil
configMigrated = true
log.Print("migrated config from version 3 => 4")
}
if configMigrated { if configMigrated {
log.Print("Writing new config after version migration") log.Print("Writing new config after version migration")
cs.WriteConfig() cs.WriteConfig()
@ -306,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
@ -319,6 +333,34 @@ func (cs *ConfigService) WriteConfig() {
} }
defer file.Close() defer file.Close()
file.Write(s) _, err = file.Write(s)
if err != nil {
log.Fatalf("could not write config file %s: %s", path, err)
}
file.Close() file.Close()
} }
// 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,12 +1,17 @@
package config package config
import ( import (
"errors"
"os" "os"
"os/exec"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
func TestMigrationV1toV3(t *testing.T) { func TestMigrationV1toV4(t *testing.T) {
v2Config := `config_version: 1 v1Config := `config_version: 1
server: server:
port: 6123 port: 6123
address: http://localhost:6123 address: http://localhost:6123
@ -31,12 +36,12 @@ profiles:
- --audio-format - --audio-format
- mp3 - mp3
` `
cs := configServiceFromString(v2Config) cs := configServiceFromString(v1Config)
err := cs.LoadConfig() err := cs.LoadConfig()
if err != nil { if err != nil {
t.Errorf("got error when loading config: %s", err) t.Errorf("got error when loading config: %s", err)
} }
if cs.Config.ConfigVersion != 3 { if cs.Config.ConfigVersion != 4 {
t.Errorf("did not migrate version (it is '%d')", cs.Config.ConfigVersion) t.Errorf("did not migrate version (it is '%d')", cs.Config.ConfigVersion)
} }
if cs.Config.Server.MaximumActiveDownloads != 2 { if cs.Config.Server.MaximumActiveDownloads != 2 {
@ -48,13 +53,168 @@ profiles:
os.Remove(cs.ConfigPath) os.Remove(cs.ConfigPath)
} }
func TestMigrateV3toV4(t *testing.T) {
v3Config := `config_version: 3
server:
port: 6123
address: http://localhost:6123
download_path: /tmp/Downloads
maximum_active_downloads_per_domain: 2
ui:
popup_width: 900
popup_height: 900
destinations:
- name: cool destination
path: /tmp/coolness
profiles:
- name: standard video
command: yt-dlp
args:
- --newline
- --write-info-json
- -f
- bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best
- name: standard mp3
command: yt-dlp
args:
- --newline
- --write-info-json
- --extract-audio
- --audio-format
- mp3`
cs := configServiceFromString(v3Config)
err := cs.LoadConfig()
if err != nil {
t.Errorf("got error when loading config: %s", err)
}
if cs.Config.ConfigVersion != 4 {
t.Errorf("did not migrate version (it is '%d')", cs.Config.ConfigVersion)
}
if cs.Config.Server.MaximumActiveDownloads != 2 {
t.Error("did not add MaximumActiveDownloads")
}
if len(cs.Config.Destinations) != 0 {
t.Error("incorrect number of destinations from migrated file")
}
if assert.Len(t, cs.Config.DownloadOptions, 1) {
if assert.Len(t, cs.Config.DownloadOptions[0].Args, 2) {
assert.Equal(t, "-o", cs.Config.DownloadOptions[0].Args[0])
assert.Equal(t, "/tmp/coolness/%(title)s [%(id)s].%(ext)s", cs.Config.DownloadOptions[0].Args[1])
}
}
os.Remove(cs.ConfigPath)
}
func 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)
}
}

View File

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

View File

@ -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"

View File

@ -27,7 +27,7 @@ type Download struct {
ExitCode int `json:"exit_code"` ExitCode int `json:"exit_code"`
State State `json:"state"` State State `json:"state"`
DownloadProfile config.DownloadProfile `json:"download_profile"` DownloadProfile config.DownloadProfile `json:"download_profile"`
Destination *config.Destination `json:"destination"` DownloadOption *config.DownloadOption `json:"download_option"`
Finished bool `json:"finished"` Finished bool `json:"finished"`
FinishedTS time.Time `json:"finished_ts"` FinishedTS time.Time `json:"finished_ts"`
Files []string `json:"files"` Files []string `json:"files"`
@ -82,8 +82,7 @@ func (m *Manager) ManageQueue() {
m.Lock.Lock() m.Lock.Lock()
m.startQueued(m.MaxPerDomain) m.startQueued(m.MaxPerDomain)
m.moveToDest() m.cleanup()
// m.cleanup()
m.Lock.Unlock() m.Lock.Unlock()
time.Sleep(time.Second) time.Sleep(time.Second)
@ -102,32 +101,6 @@ func (m *Manager) DownloadsAsJSON() ([]byte, error) {
return b, err return b, err
} }
func (m *Manager) moveToDest() {
// move any downloads that are complete and have a dest
for _, dl := range m.Downloads {
dl.Lock.Lock()
if dl.Destination != nil && dl.State == STATE_COMPLETE {
dl.State = STATE_MOVED
for _, fn := range dl.Files {
src := filepath.Join(dl.Config.Server.DownloadPath, fn)
dst := filepath.Join(dl.Destination.Path, fn)
err := os.Rename(src, dst)
if err != nil {
log.Printf("%s", err)
dl.Log = append(dl.Log, fmt.Sprintf("Could not move %s to %s - %s", fn, dl.Destination.Path, err))
break
} else {
dl.Log = append(dl.Log, fmt.Sprintf("Moved %s to %s", fn, dl.Destination.Path))
}
}
}
dl.Lock.Unlock()
}
}
// startQueued starts any downloads that have been queued, we would not exceed // startQueued starts any downloads that have been queued, we would not exceed
// maxRunning. If maxRunning is 0, there is no limit. // maxRunning. If maxRunning is 0, there is no limit.
func (m *Manager) startQueued(maxRunning int) { func (m *Manager) startQueued(maxRunning int) {
@ -167,16 +140,17 @@ func (m *Manager) startQueued(maxRunning int) {
} }
// cleanup removes old downloads from the list. Hardcoded to remove them one hour // cleanup removes old downloads from the list. Hardcoded to remove them one hour
// completion. // completion. Expects the Manager to be locked.
func (m *Manager) XXXcleanup() { func (m *Manager) cleanup() {
newDLs := []*Download{} newDLs := []*Download{}
for _, dl := range m.Downloads { for _, dl := range m.Downloads {
dl.Lock.Lock()
if dl.Finished && time.Since(dl.FinishedTS) > time.Duration(time.Hour) { if dl.Finished && time.Since(dl.FinishedTS) > time.Duration(time.Hour) {
// do nothing // do nothing
} else { } else {
newDLs = append(newDLs, dl) newDLs = append(newDLs, dl)
} }
dl.Lock.Unlock()
} }
m.Downloads = newDLs m.Downloads = newDLs
@ -201,17 +175,6 @@ func (m *Manager) Queue(dl *Download) {
dl.State = STATE_QUEUED dl.State = STATE_QUEUED
} }
func (m *Manager) ChangeDestination(dl *Download, dest *config.Destination) {
dl.Lock.Lock()
// we can only change destination is certain cases...
if dl.State != STATE_FAILED && dl.State != STATE_MOVED {
dl.Destination = dest
}
dl.Lock.Unlock()
}
func NewDownload(url string, conf *config.Config) *Download { func NewDownload(url string, conf *config.Config) *Download {
atomic.AddInt32(&downloadId, 1) atomic.AddInt32(&downloadId, 1)
dl := Download{ dl := Download{
@ -250,7 +213,10 @@ func (dl *Download) Stop() {
dl.Lock.Lock() dl.Lock.Lock()
defer dl.Lock.Unlock() defer dl.Lock.Unlock()
dl.Log = append(dl.Log, "aborted by user") dl.Log = append(dl.Log, "aborted by user")
dl.Process.Kill() err := dl.Process.Kill()
if err != nil {
log.Printf("could not send kill to process: %s", err)
}
} }
// domain returns a domain for this Download. Download should be locked. // domain returns a domain for this Download. Download should be locked.
@ -270,18 +236,66 @@ func (dl *Download) domain() string {
// It blocks until the download is complete. // It blocks until the download is complete.
func (dl *Download) Begin() { func (dl *Download) Begin() {
dl.Lock.Lock() dl.Lock.Lock()
u, err := url.Parse(dl.Url)
if err != nil {
log.Printf("Bad url '%s': %s", dl.Url, err.Error())
}
// grab the host and path for substitutions
host := u.Host
path := u.Path
// strip the leading /
if strings.Index(path, "/") == 0 {
path = path[1:]
}
// escape them in a way that should mean we can use them as a filepath
host = strings.ReplaceAll(host, string(filepath.Separator), "_")
host = strings.ReplaceAll(host, string(filepath.ListSeparator), "_")
path = strings.ReplaceAll(path, string(filepath.Separator), "_")
path = strings.ReplaceAll(path, string(filepath.ListSeparator), "_")
dl.State = STATE_DOWNLOADING dl.State = STATE_DOWNLOADING
cmdSlice := []string{} cmdSlice := []string{}
cmdSlice = append(cmdSlice, dl.DownloadProfile.Args...)
for i := range dl.DownloadProfile.Args {
arg := dl.DownloadProfile.Args[i]
arg = strings.ReplaceAll(arg, "%GROPPLE_HOST%", host)
arg = strings.ReplaceAll(arg, "%GROPPLE_PATH%", path)
cmdSlice = append(cmdSlice, arg)
}
// add the option, if any
if dl.DownloadOption != nil {
for i := range dl.DownloadOption.Args {
arg := dl.DownloadOption.Args[i]
arg = strings.ReplaceAll(arg, "%GROPPLE_HOST%", host)
arg = strings.ReplaceAll(arg, "%GROPPLE_PATH%", path)
cmdSlice = append(cmdSlice, arg)
}
}
// only add the url if it's not empty or an example URL. This helps us with testing // only add the url if it's not empty or an example URL. This helps us with testing
if !(dl.Url == "" || strings.Contains(dl.domain(), "example.org")) { if dl.Url != "" && !strings.Contains(dl.domain(), "example.org") {
cmdSlice = append(cmdSlice, dl.Url) cmdSlice = append(cmdSlice, dl.Url)
} }
cmd := exec.Command(dl.DownloadProfile.Command, cmdSlice...) cmdPath, err := config.AbsPathToExecutable(dl.DownloadProfile.Command)
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 {
@ -290,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
} }
@ -305,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()
@ -335,22 +349,32 @@ func (dl *Download) Begin() {
}() }()
wg.Wait() wg.Wait()
cmd.Wait()
err = cmd.Wait()
dl.Lock.Lock() dl.Lock.Lock()
log.Printf("Process finished for id: %d (%v)", dl.Id, cmd) if err != nil {
log.Printf("process failed for id: %d: %s", dl.Id, err)
dl.State = STATE_COMPLETE
dl.Finished = true
dl.FinishedTS = time.Now()
dl.ExitCode = cmd.ProcessState.ExitCode()
if dl.ExitCode != 0 {
dl.State = STATE_FAILED dl.State = STATE_FAILED
dl.Finished = true
dl.FinishedTS = time.Now()
dl.ExitCode = cmd.ProcessState.ExitCode()
} else {
log.Printf("process finished for id: %d (%v)", dl.Id, cmd)
dl.State = STATE_COMPLETE
dl.Finished = true
dl.FinishedTS = time.Now()
dl.ExitCode = cmd.ProcessState.ExitCode()
if dl.ExitCode != 0 {
dl.State = STATE_FAILED
}
} }
dl.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
@ -405,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)
} }
} }

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) 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"
@ -237,15 +237,15 @@ func TestUpdateMetadataPlaylist(t *testing.T) {
output := ` output := `
start of log... start of log...
[download] Downloading playlist: niceuser [download] Downloading playlist: nice_user
[RedGifsUser] niceuser: Downloading JSON metadata page 1 [RedGifsUser] nice_user: Downloading JSON metadata page 1
[RedGifsUser] niceuser: Downloading JSON metadata page 2 [RedGifsUser] nice_user: Downloading JSON metadata page 2
[RedGifsUser] niceuser: Downloading JSON metadata page 3 [RedGifsUser] nice_user: Downloading JSON metadata page 3
[RedGifsUser] niceuser: Downloading JSON metadata page 4 [RedGifsUser] nice_user: Downloading JSON metadata page 4
[RedGifsUser] niceuser: Downloading JSON metadata page 5 [RedGifsUser] nice_user: Downloading JSON metadata page 5
[RedGifsUser] niceuser: Downloading JSON metadata page 6 [RedGifsUser] nice_user: Downloading JSON metadata page 6
[info] Writing playlist metadata as JSON to: niceuser [niceuser].info.json [info] Writing playlist metadata as JSON to: nice_user [nice_user].info.json
[RedGifsUser] playlist niceuser: Downloading 3 videos [RedGifsUser] playlist nice_user: Downloading 3 videos
[download] Downloading video 1 of 3 [download] Downloading video 1 of 3
[info] wrongpreciouschrysomelid: Downloading 1 format(s): hd [info] wrongpreciouschrysomelid: Downloading 1 format(s): hd
[info] Writing video metadata as JSON to: Splendid Wonderful Speaker Power Chocolate Drop [wrongpreciouschrysomelid].info.json [info] Writing video metadata as JSON to: Splendid Wonderful Speaker Power Chocolate Drop [wrongpreciouschrysomelid].info.json
@ -279,8 +279,8 @@ start of log...
[download] 69.1% of 2.89MiB at 11.63MiB/s ETA 00:00 [download] 69.1% of 2.89MiB at 11.63MiB/s ETA 00:00
[download] 100% of 2.89MiB at 14.25MiB/s ETA 00:00 [download] 100% of 2.89MiB at 14.25MiB/s ETA 00:00
[download] 100% of 2.89MiB in 00:00 [download] 100% of 2.89MiB in 00:00
[info] Writing updated playlist metadata as JSON to: niceuser [niceuser].info.json [info] Writing updated playlist metadata as JSON to: nice_user [nice_user].info.json
[download] Finished downloading playlist: niceuser [download] Finished downloading playlist: nice_user
` `
newD := Download{} newD := Download{}
@ -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

View File

@ -11,13 +11,26 @@ func (m *Manager) AddStressTestData(c *config.ConfigService) {
"https://www.youtube.com/watch?v=ZUzhZpQAU40", "https://www.youtube.com/watch?v=ZUzhZpQAU40",
"https://www.youtube.com/watch?v=kVxM3eRWGak", "https://www.youtube.com/watch?v=kVxM3eRWGak",
"https://www.youtube.com/watch?v=pl-y9869y0w", "https://www.youtube.com/watch?v=pl-y9869y0w",
"https://vimeo.com/783453809",
"https://www.youtube.com/watch?v=Uw4NEPE4l3A", "https://www.youtube.com/watch?v=Uw4NEPE4l3A",
"https://www.youtube.com/watch?v=2RF0lcTuuYE", "https://www.youtube.com/watch?v=2RF0lcTuuYE",
"https://www.youtube.com/watch?v=lymwNQY0dus", "https://www.youtube.com/watch?v=lymwNQY0dus",
"https://www.youtube.com/watch?v=NTc-I4Z_duc", "https://www.youtube.com/watch?v=NTc-I4Z_duc",
"https://www.youtube.com/watch?v=wNSm1TJ84Ac", "https://www.youtube.com/watch?v=wNSm1TJ84Ac",
"https://www.youtube.com/watch?v=tyixMpuGEL8",
"https://www.youtube.com/watch?v=VnxbkH_3E_4",
"https://www.youtube.com/watch?v=VStscvYLYLs",
"https://www.youtube.com/watch?v=vYMiSz-WlEY",
"https://vimeo.com/786570322", "https://vimeo.com/786570322",
"https://vimeo.com/783453809",
"https://www.gamespot.com/videos/survival-fps-how-metro-2033-solidified-a-subgenre/2300-6408243/",
"https://www.gamespot.com/videos/dirt-3-right-back-where-you-started-gameplay-movie/2300-6314712/",
"https://www.gamespot.com/videos/the-b-list-driver-san-francisco/2300-6405593/",
"https://www.imdb.com/video/vi1914750745/?listId=ls053181649&ref_=hm_hp_i_hero-video-1_1",
"https://www.imdb.com/video/vi3879585561/?listId=ls053181649&ref_=vp_pl_ap_6",
"https://www.imdb.com/video/vi54445849/?listId=ls053181649&ref_=vp_nxt_btn",
} }
for _, u := range urls { for _, u := range urls {
d := NewDownload(u, c.Config) d := NewDownload(u, c.Config)

15
go.mod
View File

@ -1,9 +1,18 @@
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.0 github.com/gorilla/mux v1.8.1
golang.org/x/mod v0.9.0 github.com/stretchr/testify v1.9.0
golang.org/x/mod v0.24.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

16
go.sum
View File

@ -1,8 +1,16 @@
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
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=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

356
main.go
View File

@ -1,53 +1,35 @@
package main package main
import ( import (
"embed"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"html/template"
"io"
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
"strconv"
"github.com/gorilla/mux"
"github.com/tardisx/gropple/config" "github.com/tardisx/gropple/config"
"github.com/tardisx/gropple/download" "github.com/tardisx/gropple/download"
"github.com/tardisx/gropple/version" v "github.com/tardisx/gropple/version"
"github.com/tardisx/gropple/web"
) )
var dm *download.Manager var (
var configService *config.ConfigService version = "dev"
)
var versionInfo = version.Manager{
VersionInfo: version.Info{CurrentVersion: "v0.6.0-alpha.3"},
}
//go:embed web
var webFS embed.FS
type successResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type errorResponse struct {
Success bool `json:"success"`
Error string `json:"error"`
}
func main() { func main() {
versionInfo := &v.Manager{
// 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)
var configPath string var configPath string
flag.StringVar(&configPath, "config-path", "", "path to config file") flag.StringVar(&configPath, "config-path", "", "path to config file")
flag.Parse() flag.Parse()
configService = &config.ConfigService{} configService := &config.ConfigService{}
if configPath != "" { if configPath != "" {
configService.ConfigPath = configPath configService.ConfigPath = configPath
} else { } else {
@ -72,23 +54,10 @@ func main() {
} }
// create the download manager // create the download manager
dm = &download.Manager{MaxPerDomain: configService.Config.Server.MaximumActiveDownloads} downloadManager := &download.Manager{MaxPerDomain: configService.Config.Server.MaximumActiveDownloads}
r := mux.NewRouter() // create the web handlers
r.HandleFunc("/", homeHandler) r := web.CreateRoutes(configService, downloadManager, versionInfo)
r.HandleFunc("/static/{filename}", staticHandler)
r.HandleFunc("/config", configHandler)
r.HandleFunc("/fetch", fetchHandler)
r.HandleFunc("/fetch/{id}", fetchHandler)
// info for the list
r.HandleFunc("/rest/fetch", fetchInfoRESTHandler)
// info for one, including update
r.HandleFunc("/rest/fetch/{id}", fetchInfoOneRESTHandler)
r.HandleFunc("/rest/version", versionRESTHandler)
r.HandleFunc("/rest/config", configRESTHandler)
http.Handle("/", r)
srv := &http.Server{ srv := &http.Server{
Handler: r, Handler: r,
@ -101,303 +70,22 @@ func main() {
// check for a new version every 4 hours // check for a new version every 4 hours
go func() { go func() {
for { for {
versionInfo.UpdateGitHubVersion() err := versionInfo.UpdateGitHubVersion()
if err != nil {
log.Printf("could not get version info: %s", err)
}
time.Sleep(time.Hour * 4) time.Sleep(time.Hour * 4)
} }
}() }()
// start downloading queued downloads when slots available, and clean up // start downloading queued downloads when slots available, and clean up
// old entries // old entries
go dm.ManageQueue() go downloadManager.ManageQueue()
dm.AddStressTestData(configService)
// add testdata if compiled with the '-tags testdata' flag
downloadManager.AddStressTestData(configService)
log.Printf("Visit %s for details on installing the bookmarklet and to check status", configService.Config.Server.Address) log.Printf("Visit %s for details on installing the bookmarklet and to check status", configService.Config.Server.Address)
log.Fatal(srv.ListenAndServe()) log.Fatal(srv.ListenAndServe())
} }
// versionRESTHandler returns the version information, if we have up-to-date info from github
func versionRESTHandler(w http.ResponseWriter, r *http.Request) {
if versionInfo.GetInfo().GithubVersionFetched {
b, _ := json.Marshal(versionInfo.GetInfo())
w.Write(b)
} else {
w.WriteHeader(400)
}
}
// homeHandler returns the main index page
func homeHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
bookmarkletURL := fmt.Sprintf("javascript:(function(f,s,n,o){window.open(f+encodeURIComponent(s),n,o)}('%s/fetch?url=',window.location,'yourform','width=%d,height=%d'));", configService.Config.Server.Address, configService.Config.UI.PopupWidth, configService.Config.UI.PopupHeight)
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/menu.tmpl", "web/index.html")
if err != nil {
panic(err)
}
type Info struct {
Manager *download.Manager
BookmarkletURL template.URL
Config *config.Config
Version version.Info
}
info := Info{
Manager: dm,
BookmarkletURL: template.URL(bookmarkletURL),
Config: configService.Config,
Version: versionInfo.GetInfo(),
}
dm.Lock.Lock()
defer dm.Lock.Unlock()
err = t.ExecuteTemplate(w, "layout", info)
if err != nil {
panic(err)
}
}
// staticHandler handles requests for static files
func staticHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
if strings.Index(filename, ".js") == len(filename)-3 {
f, err := webFS.Open("web/" + filename)
if err != nil {
log.Printf("error accessing %s - %v", filename, err)
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
io.Copy(w, f)
return
}
w.WriteHeader(http.StatusNotFound)
}
// configHandler returns the configuration page
func configHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/menu.tmpl", "web/config.html")
if err != nil {
panic(err)
}
err = t.ExecuteTemplate(w, "layout", nil)
if err != nil {
panic(err)
}
}
// configRESTHandler handles both reading and writing of the configuration
func configRESTHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
log.Printf("Updating config")
b, err := io.ReadAll(r.Body)
if err != nil {
panic(err)
}
err = configService.Config.UpdateFromJSON(b)
if err != nil {
errorRes := errorResponse{Success: false, Error: err.Error()}
errorResB, _ := json.Marshal(errorRes)
w.WriteHeader(400)
w.Write(errorResB)
return
}
configService.WriteConfig()
}
b, _ := json.Marshal(configService.Config)
w.Write(b)
}
func fetchInfoOneRESTHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idString := vars["id"]
if idString != "" {
id, err := strconv.Atoi(idString)
if err != nil {
http.NotFound(w, r)
return
}
thisDownload, err := dm.GetDlById(id)
if err != nil {
http.NotFound(w, r)
return
}
if thisDownload == nil {
panic("should not happen")
}
if r.Method == "POST" {
type updateRequest struct {
Action string `json:"action"`
Profile string `json:"profile"`
Destination string `json:"destination"`
}
thisReq := updateRequest{}
b, err := io.ReadAll(r.Body)
if err != nil {
panic(err)
}
err = json.Unmarshal(b, &thisReq)
if err != nil {
errorRes := errorResponse{Success: false, Error: err.Error()}
errorResB, _ := json.Marshal(errorRes)
w.WriteHeader(400)
w.Write(errorResB)
return
}
if thisReq.Action == "start" {
// find the profile they asked for
profile := configService.Config.ProfileCalled(thisReq.Profile)
if profile == nil {
panic("bad profile name?")
}
// set the profile
thisDownload.Lock.Lock()
thisDownload.DownloadProfile = *profile
thisDownload.Lock.Unlock()
dm.Queue(thisDownload)
succRes := successResponse{Success: true, Message: "download started"}
succResB, _ := json.Marshal(succRes)
w.Write(succResB)
return
}
if thisReq.Action == "change_destination" {
// nil means (probably) that they chose "don't move" - which is fine,
// and maps to nil on the Download (the default state).
destination := configService.Config.DestinationCalled(thisReq.Destination)
dm.ChangeDestination(thisDownload, destination)
// log.Printf("%#v", thisDownload)
succRes := successResponse{Success: true, Message: "destination changed"}
succResB, _ := json.Marshal(succRes)
w.Write(succResB)
return
}
if thisReq.Action == "stop" {
thisDownload.Stop()
succRes := successResponse{Success: true, Message: "download stopped"}
succResB, _ := json.Marshal(succRes)
w.Write(succResB)
return
}
}
// just a get, return the object
thisDownload.Lock.Lock()
defer thisDownload.Lock.Unlock()
b, _ := json.Marshal(thisDownload)
w.Write(b)
return
} else {
http.NotFound(w, r)
}
}
func fetchInfoRESTHandler(w http.ResponseWriter, r *http.Request) {
b, err := dm.DownloadsAsJSON()
if err != nil {
panic(err)
}
w.Write(b)
}
func fetchHandler(w http.ResponseWriter, r *http.Request) {
// if they refreshed the popup, just load the existing object, don't
// create a new one
vars := mux.Vars(r)
idString := vars["id"]
idInt, err := strconv.ParseInt(idString, 10, 32)
// existing, load it up
if err == nil && idInt > 0 {
dl, err := dm.GetDlById(int(idInt))
if err != nil {
log.Printf("not found")
w.WriteHeader(404)
return
}
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/popup.html")
if err != nil {
panic(err)
}
templateData := map[string]interface{}{"dl": dl, "config": configService.Config, "canStop": download.CanStopDownload}
err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil {
panic(err)
}
return
}
query := r.URL.Query()
url, present := query["url"]
if !present {
w.WriteHeader(400)
fmt.Fprint(w, "No url supplied")
return
} else {
log.Printf("popup for %s", url)
// check the URL for a sudden but inevitable betrayal
if strings.Contains(url[0], configService.Config.Server.Address) {
w.WriteHeader(400)
fmt.Fprint(w, "you mustn't gropple your gropple :-)")
return
}
// create the new download
log.Print("creating")
newDL := download.NewDownload(url[0], configService.Config)
log.Print("adding")
dm.AddDownload(newDL)
log.Print("done")
t, err := template.ParseFS(webFS, "web/layout.tmpl", "web/popup.html")
if err != nil {
panic(err)
}
log.Print("lock dl")
newDL.Lock.Lock()
defer newDL.Lock.Unlock()
templateData := map[string]interface{}{"Version": versionInfo.GetInfo(), "dl": newDL, "config": configService.Config, "canStop": download.CanStopDownload}
err = t.ExecuteTemplate(w, "layout", templateData)
if err != nil {
panic(err)
}
log.Print("unlock dl because rendered")
}
}

View File

@ -26,7 +26,6 @@ type Manager struct {
} }
func (m *Manager) GetInfo() Info { func (m *Manager) GetInfo() Info {
// log.Print("getting info... b4 lock")
m.lock.Lock() m.lock.Lock()
defer m.lock.Unlock() defer m.lock.Unlock()

View 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>&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

@ -11,14 +11,14 @@
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1"> <div class="pure-u-1">
<button class="pure-button pure-button-primary" @click="save_config();" href="#">Save Config</button> <button class="button-small pure-button button-small pure-button-primary" @click="save_config();" href="#">Save Config</button>
</div> </div>
</div> </div>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-lg-1-3 pure-u-1 l-box"> <div class="pure-u-lg-1-3 pure-u-1 l-box">
<form class="pure-form pure-form-stacked gropple-config"> <form class="pure-form pure-form-stacked gropple-config">
<fieldset> <fieldset>
@ -65,8 +65,8 @@
<legend>Download Profiles</legend> <legend>Download Profiles</legend>
<p>Gropple supports multiple download profiles. Each profile specifies a different youtube-dl <p>Gropple supports multiple download profiles. Each profile specifies a different youtube-dl
compatible command, and arguments. When starting a download, you may choose which profile compatible command, and arguments. When starting a download, you may choose which profile
to use. The URL will be appended to the argument list at the end. to use. The URL will be appended to the argument list at the end.
</p> </p>
@ -75,16 +75,19 @@
<template x-for="(profile, i) in config.profiles"> <template x-for="(profile, i) in config.profiles">
<div> <div>
<label x-bind:for="'config-profiles-'+i+'-name'">Name of profile <span x-text="i+1"></span> <label x-bind:for="'config-profiles-'+i+'-name'">Name of profile <span x-text="i+1"></span>
</label> </label>
<input type="text" x-bind:id="'config-profiles-'+i+'-name'" class="input-long" placeholder="name" x-model="profile.name" /> <input type="text" x-bind:id="'config-profiles-'+i+'-name'" class="input-long" placeholder="name" x-model="profile.name" />
<button class="pure-button button-del" href="#" @click.prevent="config.profiles.splice(i, 1);;">delete profile</button> <button class="button-small pure-button button-del" href="#" @click.prevent="config.profiles.splice(i, 1);;">delete profile</button>
<span class="pure-form-message">The name of this profile. For your information only.</span> <span class="pure-form-message">The name of this profile. For your information only.</span>
<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>
@ -92,11 +95,11 @@
<template x-for="(arg, j) in profile.args"> <template x-for="(arg, j) in profile.args">
<div> <div>
<input type="text" x-bind:id="'config-profiles-'+i+'-arg-'+j" placeholder="arg" x-model="profile.args[j]" /> <input type="text" x-bind:id="'config-profiles-'+i+'-arg-'+j" placeholder="arg" x-model="profile.args[j]" />
<button class="pure-button button-del" href="#" @click.prevent="profile.args.splice(j, 1);;">delete arg</button> <button class="button-small pure-button button-del" href="#" @click.prevent="profile.args.splice(j, 1);;">delete arg</button>
</div> </div>
</template> </template>
<button class="pure-button button-add" href="#" @click.prevent="profile.args.push('');">add arg</button> <button class="button-small pure-button button-add" href="#" @click.prevent="profile.args.push('');">add arg</button>
<span class="pure-form-message">Arguments for the command. Note that the shell is not used, so there is no need to quote or escape arguments, including those with spaces.</span> <span class="pure-form-message">Arguments for the command. Note that the shell is not used, so there is no need to quote or escape arguments, including those with spaces.</span>
<hr> <hr>
@ -104,7 +107,7 @@
</div> </div>
</template> </template>
<button class="pure-button button-add" href="#" @click.prevent="config.profiles.push({name: 'new profile', command: 'youtube-dl', args: []});">add profile</button> <button class="button-small pure-button button-add" href="#" @click.prevent="config.profiles.push({name: 'new profile', command: 'youtube-dl', args: []});">add profile</button>
</fieldset> </fieldset>
</form> </form>
@ -113,39 +116,44 @@
<div class="pure-u-lg-1-3 pure-u-1 l-box"> <div class="pure-u-lg-1-3 pure-u-1 l-box">
<form class="pure-form gropple-config"> <form class="pure-form gropple-config">
<fieldset> <fieldset>
<legend>Destinations</legend> <legend>Download Options</legend>
<p>You can specify custom destinations (directories) here. Downloads can be <p>You can specify custom download options here. These are (optionally) selectable in addition
moved to one of these directories after completion from the index page, to the profile when starting a download. They append extra arguments to the downloader command.
if you do not want them to be left in the download path above.</p> The most common use is to specify a particular <tt>-o</tt> argument to <tt>yt-dlp</tt> to allow files to be downloaded
to a custom path.</p>
</p> </p>
<template x-for="(dest, i) in config.destinations"> <template x-for="(download_option, i) in config.download_options">
<div> <div>
<label x-bind:for="'config-destinations-'+i+'-name'">Name of destination <span x-text="i+1"></span> <label x-bind:for="'config-download-option-'+i+'-name'">Name of option <span x-text="i+1"></span>
</label> </label>
<input type="text" x-bind:id="'config-destinations-'+i+'-name'" class="input-long" placeholder="name" x-model="dest.name" />
<span class="pure-form-message">The name of this destination. For your information only.</span> <input type="text" x-bind:id="'config-download-option-'+i+'-name'" class="input-long" placeholder="name" x-model="download_option.name" />
<label x-bind:for="'config-destinations-'+i+'-command'">Path</label> <span class="pure-form-message">The name of this option. For your information only.</span>
<input type="text" x-bind:id="'config-destinations-'+i+'-command'" class="input-long" placeholder="name" x-model="dest.path" />
<span class="pure-form-message">Path to move completed downloads to.</span>
<button class="pure-button button-del" href="#" @click.prevent="config.destinations.splice(i, 1);">delete destination</button> <label>Arguments</label>
<template x-for="(arg, j) in download_option.args">
<div>
<input type="text" x-bind:id="'config-download-option-'+i+'-arg-'+j" placeholder="arg" x-model="download_option.args[j]" />
<button class="button-small pure-button button-del" href="#" @click.prevent="download_option.args.splice(j, 1);;">delete arg</button>
</div>
</template>
<button class="button-small pure-button button-del" href="#" @click.prevent="config.download_options.splice(i, 1);">delete option</button>
<hr> <hr>
</div> </div>
</template> </template>
<button class="pure-button button-add" href="#" @click.prevent="config.destinations.push({name: 'new destination', path: '/tmp'});">add destination</button> <button class="button-small pure-button button-add" href="#" @click.prevent="config.download_options.push({name: 'new option', args: ['-o', 'someting']});">add option</button>
</fieldset> </fieldset>
</form> </form>
</div> </div>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1"> <div class="pure-u-1">
<button class="pure-button pure-button-primary" @click="save_config();" href="#">Save Config</button> <button class="button-small pure-button button-small pure-button-primary" @click="save_config();" href="#">Save Config</button>
</div> </div>
</div> </div>
@ -157,8 +165,8 @@
{{ define "js" }} {{ define "js" }}
<script> <script>
function config() { function config() {
return { return {
config: { server : {}, ui : {}, profiles: [], destinations: []}, config: { server : {}, ui : {}, profiles: [], download_options: []},
error_message: '', error_message: '',
success_message: '', success_message: '',

View File

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

View File

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

View File

@ -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>

View File

@ -2,43 +2,33 @@
<div id="layout" class="pure-g pure-u-1" x-data="popup()" x-init="fetch_data()"> <div id="layout" class="pure-g pure-u-1" x-data="popup()" x-init="fetch_data()">
<h2>Download started</h2> <h2>Download started</h2>
<p>Fetching <tt>{{ .dl.Url }}</tt></p> <p>Fetching <tt>{{ .dl.Url }}</tt></p>
<form class="pure-form">
<table class="pure-table" > <table class="pure-table" >
<tr> <tr>
<th>profile</th> <th>profile</th>
<td> <td>{{ .dl.DownloadProfile.Name }}</td>
<select x-bind:disabled="profile_chosen" x-on:change="update_profile()" class="pure-input-1-2" x-model="profile_chosen">
<option value="">choose a profile to start</option>
{{ range $i := .config.DownloadProfiles }}
<option>{{ $i.Name }}</option>
{{ end }}
</select>
</td>
</tr> </tr>
<tr><th>current filename</th><td x-text="filename"></td></tr> <tr><th>current filename</th><td x-text="filename"></td></tr>
<tr> <tr>
<th>destination</th> <th>option</th>
<td> <td>
<select x-on:change="update_destination()" class="pure-input-1-2" x-model="destination_chosen"> {{ if .dl.DownloadOption }} {{ .dl.DownloadOption.Name }} {{ else }} n/a {{ end }}
<option value="-">leave in {{ .config.Server.DownloadPath }}</option>
{{ range $i := .config.Destinations }}
<option>{{ $i.Name }}</option>
{{ end }}
</select>
</td> </td>
</tr> </tr>
<tr><th>state</th><td x-text="state"></td></tr> <tr><th>state</th><td x-text="state"></td></tr>
<tr x-show="playlist_total > 0"><th>playlist progress</th><td x-text="playlist_current + '/' + playlist_total"></td></tr> <tr x-show="playlist_total > 0"><th>playlist progress</th><td x-text="playlist_current + '/' + playlist_total"></td></tr>
<tr><th>progress</th><td x-text="percent"></td></tr> <tr><th>progress</th><td x-text="percent"></td></tr>
<tr><th>ETA</th><td x-text="eta"></td></tr> <tr><th>ETA</th><td x-text="eta"></td></tr>
</table> </table>
<p>You can close this window and your download will continue. Check the <a href="/" target="_gropple_status">Status page</a> to see all downloads in progress.</p> <p>You can close this window and your download will continue. Check the <a href="/" target="_gropple_status">Status page</a> to see all downloads in progress.</p>
{{ if .canStop }} {{ if .canStop }}
<button x-show="state=='downloading'" class="pure-button" @click="stop()">stop</button> <button x-show="state=='Downloading'" class="button-small pure-button" @click="stop()">stop</button>
{{ end }} {{ end }}
</form>
<div> <div>
<h4>Logs</h4> <h4>Logs</h4>
<pre x-text="log"> <pre x-text="log" style="height: auto;">
</pre> </pre>
</div> </div>
</div> </div>
@ -47,39 +37,9 @@
<script> <script>
function popup() { function popup() {
history.replaceState(null, '', ['/fetch/{{ .dl.Id }}']) history.replaceState(null, '', ['/fetch/{{ .dl.Id }}'])
return { return {
eta: '', percent: 0.0, state: '??', filename: '', finished: false, log :'', eta: '', percent: 0.0, state: '??', filename: '', finished: false, log :'',
playlist_current: 0, playlist_total: 0, playlist_current: 0, playlist_total: 0,
profile_chosen: null,
destination_chosen: null,
watch_profile() {
this.$watch('profile_chosen', value => this.profile_chosen(value))
},
update_profile(name) {
console.log('you chose name', this.profile_chosen);
let op = {
method: 'POST',
body: JSON.stringify({action: 'start', profile: this.profile_chosen}),
headers: { 'Content-Type': 'application/json' }
};
fetch('/rest/fetch/{{ .dl.Id }}', op)
.then(response => response.json())
.then(info => {
console.log(info)
})
},
update_destination(name) {
let op = {
method: 'POST',
body: JSON.stringify({action: 'change_destination', destination: this.destination_chosen}),
headers: { 'Content-Type': 'application/json' }
};
fetch('/rest/fetch/{{ .dl.Id }}', op)
.then(response => response.json())
.then(info => {
console.log(info)
})
},
stop() { stop() {
let op = { let op = {
method: 'POST', method: 'POST',
@ -101,13 +61,6 @@
this.state = info.state; this.state = info.state;
this.playlist_current = info.playlist_current; this.playlist_current = info.playlist_current;
this.playlist_total = info.playlist_total; this.playlist_total = info.playlist_total;
this.destination_chosen = null;
if (info.destination) {
this.destination_chosen = info.destination.name;
}
if (this.state != 'Choose Profile') {
this.profile_chosen = true;
}
this.finished = info.finished; this.finished = info.finished;
if (info.files && info.files.length > 0) { if (info.files && info.files.length > 0) {
this.filename = info.files[info.files.length - 1]; this.filename = info.files[info.files.length - 1];

View File

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

544
web/web.go Normal file
View File

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