Compare commits
10 commits
05bfa58df8
...
1dab610bb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dab610bb6 | ||
|
|
db7dacf64a | ||
|
|
d707e5502b | ||
|
|
7fa9f00cce | ||
|
|
dc047c2412 | ||
|
|
8428d7ed42 | ||
|
|
4b16bebf7d | ||
|
|
d2e1556c3c | ||
|
|
b05152474a | ||
|
|
8bd20aae17 |
22 changed files with 1190 additions and 223 deletions
|
|
@ -1 +1,9 @@
|
|||
downloads/
|
||||
download/
|
||||
.git/
|
||||
.github/
|
||||
.forgejo/
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.md
|
||||
media-roller
|
||||
|
|
|
|||
51
.forgejo/workflows/docker.yml
Normal file
51
.forgejo/workflows/docker.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
on:
|
||||
push:
|
||||
branches: [ master, main ]
|
||||
pull_request:
|
||||
branches: [ master, main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: kv-download:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Login to Docker Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.khoavo.myds.me
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and Push Docker image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
git.khoavo.myds.me/vndangkhoa/kv-download:latest
|
||||
git.khoavo.myds.me/vndangkhoa/kv-download:{{ sha }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
|
|
@ -0,0 +1 @@
|
|||
golang 1.25.3
|
||||
23
Dockerfile
Executable file → Normal file
23
Dockerfile
Executable file → Normal file
|
|
@ -1,5 +1,8 @@
|
|||
FROM golang:1.25.1-alpine3.22 AS builder
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.3-alpine3.22 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
|
@ -10,15 +13,23 @@ COPY go.mod go.mod
|
|||
COPY go.sum go.sum
|
||||
|
||||
RUN go mod download
|
||||
RUN go build -x -o media-roller ./src
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -x -o media-roller ./src
|
||||
|
||||
# yt-dlp needs python
|
||||
FROM python:3.13.7-alpine3.22
|
||||
FROM --platform=$TARGETPLATFORM python:3.13.7-alpine3.22
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
LABEL org.opencontainers.image.source="https://git.khoavo.myds.me/vndangkhoa/kv-download"
|
||||
LABEL org.opencontainers.image.description="Media Roller - Mobile friendly video downloader"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# This is where the downloaded files will be saved in the container.
|
||||
ENV MR_DOWNLOAD_DIR="/download"
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
# https://github.com/yt-dlp/yt-dlp/issues/14404 \
|
||||
deno \
|
||||
curl
|
||||
|
||||
# https://hub.docker.com/r/mwader/static-ffmpeg/tags
|
||||
|
|
@ -28,6 +39,7 @@ COPY --from=mwader/static-ffmpeg:8.0 /ffprobe /usr/local/bin/
|
|||
COPY --from=builder /app/media-roller /app/media-roller
|
||||
COPY templates /app/templates
|
||||
COPY static /app/static
|
||||
COPY cookies.txt /app/cookies.txt
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -42,4 +54,9 @@ RUN yt-dlp --update --update-to nightly
|
|||
RUN yt-dlp --version && \
|
||||
ffmpeg -version
|
||||
|
||||
EXPOSE 9292
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:9292/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/media-roller"]
|
||||
|
|
|
|||
87
README.md
87
README.md
|
|
@ -1,6 +1,7 @@
|
|||
# Media Roller
|
||||
# KV-Download (Media Roller)
|
||||
|
||||
A mobile friendly tool for downloading videos from social media.
|
||||
The backend is a Golang server that will take a URL (YouTube, Reddit, Twitter, etc),
|
||||
The backend is a Golang server that will take a URL (YouTube, Reddit, Twitter, TikTok, Instagram, etc),
|
||||
download the video file, and return a URL to directly download the video. The video will be transcoded to produce a single mp4 file.
|
||||
|
||||
This is built on [yt-dlp](https://github.com/yt-dlp/yt-dlp). yt-dlp will auto update every 12 hours to make sure it's running the latest nightly build.
|
||||
|
|
@ -11,8 +12,8 @@ Note: This was written to run on a home network and should not be exposed to pub
|
|||
|
||||

|
||||
|
||||
|
||||
# Running
|
||||
|
||||
Make sure you have [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [FFmpeg](https://github.com/FFmpeg/FFmpeg) installed then pull the repo and run:
|
||||
```bash
|
||||
./run.sh
|
||||
|
|
@ -23,21 +24,81 @@ Or for docker locally:
|
|||
./docker-run.sh
|
||||
```
|
||||
|
||||
With Docker, published to both dockerhub and github.
|
||||
* ghcr: `docker pull ghcr.io/rroller/media-roller:master`
|
||||
* dockerhub: `docker pull ronnieroller/media-roller`
|
||||
## Docker Image
|
||||
|
||||
See:
|
||||
* https://github.com/rroller/media-roller/pkgs/container/media-roller
|
||||
* https://hub.docker.com/repository/docker/ronnieroller/media-roller
|
||||
The Docker image is available from the Forgejo container registry:
|
||||
|
||||
The files are saved to the /download directory which you can mount as needed.
|
||||
```bash
|
||||
docker pull git.khoavo.myds.me/vndangkhoa/kv-download:v1
|
||||
```
|
||||
|
||||
## Docker Environemnt Variables
|
||||
### Deploy on Synology NAS (CLI)
|
||||
|
||||
1. Login to the registry (one-time):
|
||||
```bash
|
||||
docker login git.khoavo.myds.me -u vndangkhoa -p Thieugia19
|
||||
```
|
||||
|
||||
2. Run with docker-compose:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Deploy on Synology NAS (Container Manager GUI)
|
||||
|
||||
1. Open **Container Manager** → **Registry** → **Add**
|
||||
- Server: `git.khoavo.myds.me`
|
||||
- Username: `vndangkhoa`
|
||||
- Password: `Thieugia19`
|
||||
|
||||
2. Search for `vndangkhoa/kv-download` and download the `v1` tag
|
||||
|
||||
3. Go to **Container Manager** → **Project** → **Create**
|
||||
- Project name: `kv-download`
|
||||
- Paste this `docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
kv-download:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-download:v1
|
||||
container_name: kv-download
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9292:9292"
|
||||
volumes:
|
||||
- ./downloads:/download
|
||||
environment:
|
||||
- MR_DOWNLOAD_DIR=/download
|
||||
- TZ=Asia/Ho_Chi_Minh
|
||||
```
|
||||
|
||||
4. Click **Next** → **Done** to start the container
|
||||
|
||||
5. Access the app at `http://<NAS-IP>:9292`
|
||||
|
||||
### Build Your Own Image
|
||||
|
||||
```bash
|
||||
docker build -t git.khoavo.myds.me/vndangkhoa/kv-download:v1 --platform linux/amd64 .
|
||||
docker push git.khoavo.myds.me/vndangkhoa/kv-download:v1
|
||||
```
|
||||
|
||||
## Docker Environment Variables
|
||||
* `MR_DOWNLOAD_DIR` where videos are saved. Defaults to `/download`
|
||||
* `MR_PROXY` will pass the value to yt-dlp with the `--proxy` argument. Defaults to empty
|
||||
|
||||
## File Structure
|
||||
|
||||
After downloading videos, files are organized as follows:
|
||||
```
|
||||
/download/
|
||||
├── <hash>/
|
||||
│ └── <video-id>.mp4 # Video files
|
||||
└── json/
|
||||
└── <video-id>.info.json # Metadata files (separate folder)
|
||||
```
|
||||
|
||||
# API
|
||||
|
||||
To download a video directly, use the API endpoint:
|
||||
|
||||
```
|
||||
|
|
@ -47,13 +108,15 @@ To download a video directly, use the API endpoint:
|
|||
Create a bookmarklet, allowing one click downloads (From a PC):
|
||||
|
||||
```
|
||||
javascript:(location.href="http://127.0.0.1:3000/fetch?url="+encodeURIComponent(location.href));
|
||||
javascript:(location.href="http://127.0.0.1:9292/fetch?url="+encodeURIComponent(location.href));
|
||||
```
|
||||
|
||||
# Integrating with mobile
|
||||
|
||||
After you have your server up, install this shortcut. Update the endpoint to your server address by editing the shortcut before running it.
|
||||
|
||||
https://www.icloud.com/shortcuts/d3b05b78eb434496ab28dd91e1c79615
|
||||
|
||||
# Unraid
|
||||
|
||||
media-roller is available in Unraid and can be found on the "Apps" tab by searching its name.
|
||||
|
|
|
|||
0
build.sh
Executable file → Normal file
0
build.sh
Executable file → Normal file
38
cookies.txt
Normal file
38
cookies.txt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Netscape HTTP Cookie File
|
||||
# This file is generated by yt-dlp. Do not edit.
|
||||
|
||||
.tiktok.com TRUE / TRUE 1806650669 oec_lucifer AQEBABzs8iPLCKbEoFgHKUYpxlO6NcoDgKbdg0PL9XSvCjEH6feosZuE9YmDGjl5yYMB4qr3fNOt8d3DYkvdsgUisYMAdA9dIA==
|
||||
.tiktok.com TRUE / TRUE 1806800289 tt-target-idc-sign rUWONX5DYRemkvisqgHd6xA2PXPQSc_BBpnV-Zs9fENTiB-FJSGlxaYfHEZ35rBlh_ASjMW2kSRobziB72ONFFCizl1uPS2TX3KKNNOsG6QH3YChdkW6a3mQky58GlLNe7C9Pc7d3BDofx-MXuENceY0wfHlmDQwEM-zDm3qx33arxjTipJ6J6hHRGEQ2xW8xC8IFP7dyH3-Yli977jMb4H4u_PuB919ZXEW11Qanj9-2LSjZRGRVBcS7aJOj9VVTpmNBDIC056DnvUFVRfmX2UeGMPuU1F4zSgoljkFEfxALLG0pbYZfTKoa5wRg4ySRtC4ti-4L5onv2XHaWcLdUe3QA4ZhX5VlqGSt7xOVcHqTV0cpUO5VmL7uXookZn3ofjox1qja-GSOkmvRcQ7j59TTz2kuHjCslp0ufjrf7HDGoByQhvCL_Tt4COiPrq4PTJ8sBpK3DXnyCs-_rB9K-Csrek6p50wOOr605EqgQukiVjlo_HBnzLR8szu1N7N
|
||||
.tiktok.com TRUE / TRUE 1776128949 msToken h647mrB-GBKGl0LZuY5yKHBAneKmq5cp4Dqy29OdRGHFolU9hBCzJJv1nnA8FyY-R-ubKcCB4dhze6gRMxkZFvetulAFsf5G68vCGLuPbAtiG3qSpBWmlJn4wgBJj4R76apDoncpg8rUSzUGHV-5aD2wTw==
|
||||
.tiktok.com TRUE / TRUE 1789197544 tt_session_tlb_tag sttt%7C2%7CGV9HbEyDYtmlqc0D4ZKhp_________-y9aTuNp59B0MhwOb8U9XPDlxrlo7UQPwJ_5dRW_o7UDI%3D
|
||||
.tiktok.com TRUE / TRUE 1804749544 sid_guard 195f476c4c8362d9a5a9cd03e192a1a7%7C1773645544%7C15552000%7CSat%2C+12-Sep-2026+07%3A19%3A04+GMT
|
||||
.tiktok.com TRUE / TRUE 1806800285 ttwid 1%7CsvVCTyUJee35ab8lHZruu5IqR_sJOB3tGpK-S1FSvyw%7C1775264284%7C3d3627d3bec00f38a2d79b6f095407435a2354fc12e50933240bde4e0d95f433
|
||||
.tiktok.com TRUE / TRUE 1806800289 store-country-code-src uid
|
||||
.tiktok.com TRUE / TRUE 1789197544 uid_tt 68ff971221dd4f484eceea1c9bdebe8e8272bc1dcd08a93432ef77b086239037
|
||||
.tiktok.com TRUE / TRUE 1806800289 store-country-sign MEIEDHhAcBv49_ELRRW4WAQg84IiH96I-ucnCYKOtAMecFAK98OzDuvvkoILgNMvEd4EENKoZ_cDtClu0Hc_5uoeCw8
|
||||
.tiktok.com TRUE / TRUE 1806800289 store-idc alisg
|
||||
.tiktok.com TRUE / TRUE 1789197544 ssid_ucp_v1 1.0.1-KDk0MzcwOTkwOTU1Mzk2ZmNlNmU0NmZhOTA2YjE0YTY3ZTZiNzVmY2QKIgiBiIHG4PKvxV8Q6N3ezQYYswsgDDC50ZfDBjgHQPQHSAQQAxoCbXkiIDE5NWY0NzZjNGM4MzYyZDlhNWE5Y2QwM2UxOTJhMWE3Mk4KIM-9aQBiR0rfGYP7K_3gzGgxvB0u-dtCPB0mEogsFzWQEiAa3AkgDWsgerqx_IC9svK2UkaiA-3leYarzFWg4gw42BgDIgZ0aWt0b2s
|
||||
.tiktok.com TRUE / TRUE 1808873705 _ttp 3B3baLAjWddiLmq7CLkCKFZxrQo
|
||||
.tiktok.com TRUE / TRUE 1778829544 cmpl_token AgQYAPOF_hfkTtKPtFExgPKdOPPsdmRuX7-FDmCgl0M
|
||||
.tiktok.com TRUE / TRUE 1778829544 multi_sids 6884525631502042113%3A195f476c4c8362d9a5a9cd03e192a1a7
|
||||
.tiktok.com TRUE / TRUE 1806800290 odin_tt ef9f339802d99422f2b73913264e9d58048fb6ee89ae2b5b6cccb08e2081f897f39c0051a6e8b3c153dd39ce714fc598860ec6189b18a70544c92e2c48ee24de
|
||||
.tiktok.com TRUE / TRUE 1789197544 sessionid 195f476c4c8362d9a5a9cd03e192a1a7
|
||||
.tiktok.com TRUE / TRUE 1789197544 sessionid_ss 195f476c4c8362d9a5a9cd03e192a1a7
|
||||
.tiktok.com TRUE / TRUE 1789197544 sid_tt 195f476c4c8362d9a5a9cd03e192a1a7
|
||||
.tiktok.com TRUE / TRUE 1789197544 sid_ucp_v1 1.0.1-KDk0MzcwOTkwOTU1Mzk2ZmNlNmU0NmZhOTA2YjE0YTY3ZTZiNzVmY2QKIgiBiIHG4PKvxV8Q6N3ezQYYswsgDDC50ZfDBjgHQPQHSAQQAxoCbXkiIDE5NWY0NzZjNGM4MzYyZDlhNWE5Y2QwM2UxOTJhMWE3Mk4KIM-9aQBiR0rfGYP7K_3gzGgxvB0u-dtCPB0mEogsFzWQEiAa3AkgDWsgerqx_IC9svK2UkaiA-3leYarzFWg4gw42BgDIgZ0aWt0b2s
|
||||
.tiktok.com TRUE / TRUE 1806800289 store-country-code vn
|
||||
.tiktok.com TRUE / TRUE 1806800289 tt-target-idc alisg
|
||||
.tiktok.com TRUE / TRUE 1790816949 tt_chain_token B/0Q/IMBm9CZIHpqJ660Aw==
|
||||
.tiktok.com TRUE / TRUE 0 tt_csrf_token yk0ZUXi1-9sW83y9OlfRPhQq1MLM9EI94Zl4
|
||||
.tiktok.com TRUE / TRUE 1789197544 uid_tt_ss 68ff971221dd4f484eceea1c9bdebe8e8272bc1dcd08a93432ef77b086239037
|
||||
.instagram.com TRUE / TRUE 1809825269 ps_n 1
|
||||
.instagram.com TRUE / TRUE 1807499948 datr rOqsafQjOiI2XRZZieRuKNXW
|
||||
.instagram.com TRUE / TRUE 1783041412 ds_user_id 19963905
|
||||
.instagram.com TRUE / TRUE 1809825412 csrftoken CoLcTkIBKFE661prFGLwlsbaxEcH2gji
|
||||
.instagram.com TRUE / TRUE 1804475948 ig_did 045F5DE4-A9A2-4CA5-A9DB-DBEE6622B86E
|
||||
.instagram.com TRUE / TRUE 1809825269 ps_l 1
|
||||
.instagram.com TRUE / TRUE 1775870071 wd 1300x1090
|
||||
.instagram.com TRUE / TRUE 1807499948 mid aazqrAAEAAHNgMdp9PyV5xbtPQ8q
|
||||
.instagram.com TRUE / TRUE 1806801194 sessionid 19963905%3At39VRopYo9NHXK%3A21%3AAYhTTFGxZnwu9BX2gLekIjAt7tmT-6LviYscubFW6g
|
||||
.instagram.com TRUE / TRUE 1775870071 dpr 1
|
||||
.instagram.com TRUE / TRUE 0 rur "EAG\05419963905\0541806801412:01fe95c90b362914fb64d5ffc171179eb41149bf5be93a78fd311f6a7697b7f68010842b"
|
||||
0
docker-build.sh
Executable file → Normal file
0
docker-build.sh
Executable file → Normal file
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
services:
|
||||
kv-download:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-download:v1
|
||||
pull_policy: always
|
||||
container_name: kv-download
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9292:9292"
|
||||
volumes:
|
||||
- ./downloads:/download
|
||||
environment:
|
||||
- MR_DOWNLOAD_DIR=/download
|
||||
- TZ=Asia/Ho_Chi_Minh
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9292/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
cpus: "1.0"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
0
docker-run.sh
Executable file → Normal file
0
docker-run.sh
Executable file → Normal file
4
go.mod
4
go.mod
|
|
@ -1,6 +1,6 @@
|
|||
module media-roller
|
||||
|
||||
go 1.25.1
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
|
|
@ -13,5 +13,5 @@ require (
|
|||
require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
)
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -22,5 +22,5 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
|
|
|||
BIN
kv-download.tar
Normal file
BIN
kv-download.tar
Normal file
Binary file not shown.
0
run.sh
Executable file → Normal file
0
run.sh
Executable file → Normal file
12
src/main.go
12
src/main.go
|
|
@ -3,8 +3,6 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
"media-roller/src/media"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -14,6 +12,9 @@ import (
|
|||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -24,7 +25,6 @@ func main() {
|
|||
router.Get("/fetch", media.FetchMedia)
|
||||
router.Get("/api/download", media.FetchMediaApi)
|
||||
router.Get("/download", media.ServeMedia)
|
||||
router.Get("/about", media.AboutIndex)
|
||||
})
|
||||
fileServer(router, "/static", "static/")
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ func main() {
|
|||
go startYtDlpUpdater()
|
||||
|
||||
// The HTTP Server
|
||||
server := &http.Server{Addr: ":3000", Handler: router}
|
||||
server := &http.Server{Addr: ":9292", Handler: router}
|
||||
|
||||
// Server run context
|
||||
serverCtx, serverStopCtx := context.WithCancel(context.Background())
|
||||
|
|
@ -86,7 +86,7 @@ func main() {
|
|||
// startYtDlpUpdater will update the yt-dlp to the latest nightly version ever few hours
|
||||
func startYtDlpUpdater() {
|
||||
log.Info().Msgf("yt-dlp version: %s", media.GetInstalledVersion())
|
||||
ticker := time.NewTicker(12 * time.Hour)
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
|
||||
// Do one update now
|
||||
_ = media.UpdateYtDlp()
|
||||
|
|
@ -119,7 +119,7 @@ func fileServer(r chi.Router, public string, static string) {
|
|||
fs := http.StripPrefix(public, http.FileServer(http.Dir(root)))
|
||||
|
||||
if public != "/" && public[len(public)-1] != '/' {
|
||||
r.Get(public, http.RedirectHandler(public+"/", 301).ServeHTTP)
|
||||
r.Get(public, http.RedirectHandler(public+"/", http.StatusMovedPermanently).ServeHTTP)
|
||||
public += "/"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
package media
|
||||
|
||||
import (
|
||||
"github.com/matishsiao/goInfo"
|
||||
"github.com/rs/zerolog/log"
|
||||
"html/template"
|
||||
"media-roller/src/utils"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var aboutIndexTmpl = template.Must(template.ParseFiles("templates/media/about.html"))
|
||||
|
||||
var newlineRegex = regexp.MustCompile("\r?\n")
|
||||
|
||||
func AboutIndex(w http.ResponseWriter, _ *http.Request) {
|
||||
pythonVersion := utils.RunCommand("python3", "--version")
|
||||
if pythonVersion == "" {
|
||||
pythonVersion = utils.RunCommand("python", "--version")
|
||||
}
|
||||
|
||||
gi, _ := goInfo.GetInfo()
|
||||
|
||||
data := map[string]interface{}{
|
||||
"ytDlpVersion": CachedYtDlpVersion,
|
||||
"goVersion": strings.TrimPrefix(utils.RunCommand("go", "version"), "go version "),
|
||||
"pythonVersion": strings.TrimPrefix(pythonVersion, "Python "),
|
||||
"ffmpegVersion": newlineRegex.Split(utils.RunCommand("ffmpeg", "-version"), -1),
|
||||
"ffprobeVersion": newlineRegex.Split(utils.RunCommand("ffprobe", "-version"), -1),
|
||||
"os": gi.OS,
|
||||
"kernel": gi.Kernel,
|
||||
"core": gi.Core,
|
||||
"platform": gi.Platform,
|
||||
"hostname": gi.Hostname,
|
||||
"cpus": gi.CPUs,
|
||||
}
|
||||
|
||||
if err := aboutIndexTmpl.Execute(w, data); err != nil {
|
||||
log.Error().Msgf("Error rendering template: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,25 @@
|
|||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"html/template"
|
||||
"io"
|
||||
"media-roller/src/utils"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
/**
|
||||
This file will download the media from a URL and save it to disk.
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
Id string
|
||||
Name string
|
||||
|
|
@ -36,7 +29,6 @@ type Media struct {
|
|||
|
||||
var fetchIndexTmpl = template.Must(template.ParseFiles("templates/media/index.html"))
|
||||
|
||||
// Where the media files are saved. Always has a trailing slash
|
||||
var downloadDir = getDownloadDir()
|
||||
var idCharSet = regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString
|
||||
|
||||
|
|
@ -85,14 +77,12 @@ func FetchMediaApi(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// just take the first one
|
||||
streamFileToClientById(w, r, medias[0].Id)
|
||||
}
|
||||
|
||||
func getUrl(r *http.Request) (string, map[string]string) {
|
||||
u := strings.TrimSpace(r.URL.Query().Get("url"))
|
||||
|
||||
// Support yt-dlp arguments passed in via the url. We'll assume anything starting with a dash - is an argument
|
||||
args := make(map[string]string)
|
||||
for k, v := range r.URL.Query() {
|
||||
if strings.HasPrefix(k, "-") {
|
||||
|
|
@ -115,18 +105,12 @@ func getMediaResults(inputUrl string, args map[string]string) ([]Media, string,
|
|||
url := utils.NormalizeUrl(inputUrl)
|
||||
log.Info().Msgf("Got input '%s' and extracted '%s' with args %v", inputUrl, url, args)
|
||||
|
||||
// NOTE: This system is for a simple use case, meant to run at home. This is not a great design for a robust system.
|
||||
// We are hashing the URL here and writing files to disk to a consistent directory based on the ID. You can imagine
|
||||
// concurrent users would break this for the same URL. That's fine given this is for a simple home system.
|
||||
// Future work can make this more sophisticated.
|
||||
id := GetMD5Hash(url, args)
|
||||
// Look to see if we already have the media on disk
|
||||
medias, err := getAllFilesForId(id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(medias) == 0 {
|
||||
// We don't, so go fetch it
|
||||
errMessage := ""
|
||||
id, errMessage, err = downloadMedia(url, args)
|
||||
if err != nil {
|
||||
|
|
@ -141,29 +125,31 @@ func getMediaResults(inputUrl string, args map[string]string) ([]Media, string,
|
|||
return medias, "", nil
|
||||
}
|
||||
|
||||
// returns the ID of the file, and error message, and an error
|
||||
func downloadMedia(url string, requestArgs map[string]string) (string, string, error) {
|
||||
// The id will be used as the name of the parent directory of the output files
|
||||
id := GetMD5Hash(url, requestArgs)
|
||||
name := getMediaDirectory(id) + "%(id)s.%(ext)s"
|
||||
|
||||
log.Info().Msgf("Downloading %s to %s", url, name)
|
||||
|
||||
cookiesPath := getCookiesPath()
|
||||
|
||||
defaultArgs := map[string]string{
|
||||
"--format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
||||
"--merge-output-format": "mp4",
|
||||
"--format": "best",
|
||||
"--trim-filenames": "100",
|
||||
"--recode-video": "mp4",
|
||||
"--format-sort": "codec:h264",
|
||||
"--restrict-filenames": "",
|
||||
"--write-info-json": "",
|
||||
"--verbose": "",
|
||||
"--output": name,
|
||||
"--no-check-certificates": "",
|
||||
"--extractor-args": "instagram:image_persist=1",
|
||||
}
|
||||
|
||||
if _, err := os.Stat(cookiesPath); err == nil {
|
||||
defaultArgs["--cookies"] = cookiesPath
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
|
||||
// First add all default arguments that were not supplied as request level arguments
|
||||
for arg, value := range defaultArgs {
|
||||
if _, has := requestArgs[arg]; !has {
|
||||
args = append(args, arg)
|
||||
|
|
@ -173,7 +159,6 @@ func downloadMedia(url string, requestArgs map[string]string) (string, string, e
|
|||
}
|
||||
}
|
||||
|
||||
// Now add all request level arguments
|
||||
for arg, value := range requestArgs {
|
||||
args = append(args, arg)
|
||||
if value != "" {
|
||||
|
|
@ -181,7 +166,6 @@ func downloadMedia(url string, requestArgs map[string]string) (string, string, e
|
|||
}
|
||||
}
|
||||
|
||||
// And finally add any environment level arguments not supplied as request level args
|
||||
for arg, value := range getEnvVars() {
|
||||
if _, has := requestArgs[arg]; !has {
|
||||
args = append(args, arg)
|
||||
|
|
@ -230,16 +214,44 @@ func downloadMedia(url string, requestArgs map[string]string) (string, string, e
|
|||
log.Error().Msgf("failed to capture stderr: %v", errStderr)
|
||||
}
|
||||
|
||||
moveJsonFiles(id)
|
||||
|
||||
return id, "", nil
|
||||
}
|
||||
|
||||
// Returns the relative directory containing the media file, with a trailing slash.
|
||||
// Id is expected to be pre validated
|
||||
func moveJsonFiles(id string) {
|
||||
root := getMediaDirectory(id)
|
||||
jsonDir := downloadDir + "json/"
|
||||
|
||||
if err := os.MkdirAll(jsonDir, 0755); err != nil {
|
||||
log.Error().Msgf("failed to create json dir: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(root)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
files, _ := file.Readdirnames(0)
|
||||
file.Close()
|
||||
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, ".json") {
|
||||
src := root + f
|
||||
dst := jsonDir + f
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
log.Error().Msgf("failed to move json %s: %v", f, err)
|
||||
} else {
|
||||
log.Info().Msgf("moved json metadata to %s", dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getMediaDirectory(id string) string {
|
||||
return downloadDir + id + "/"
|
||||
}
|
||||
|
||||
// id is expected to be validated prior to calling this func
|
||||
func getAllFilesForId(id string) ([]Media, error) {
|
||||
root := getMediaDirectory(id)
|
||||
file, err := os.Open(root)
|
||||
|
|
@ -249,14 +261,13 @@ func getAllFilesForId(id string) ([]Media, error) {
|
|||
}
|
||||
return nil, err
|
||||
}
|
||||
files, _ := file.Readdirnames(0) // 0 to read all files and folders
|
||||
files, _ := file.Readdirnames(0)
|
||||
if len(files) == 0 {
|
||||
return nil, errors.New("ID not found: " + id)
|
||||
}
|
||||
|
||||
var medias []Media
|
||||
|
||||
// We expect two files to be produced for each video, a json manifest and an mp4.
|
||||
for _, f := range files {
|
||||
if !strings.HasSuffix(f, ".json") {
|
||||
fi, err2 := os.Stat(root + f)
|
||||
|
|
@ -278,24 +289,19 @@ func getAllFilesForId(id string) ([]Media, error) {
|
|||
return medias, nil
|
||||
}
|
||||
|
||||
// id is expected to be validated prior to calling this func
|
||||
// TODO: This needs to handle multiple files in the directory
|
||||
func getFileFromId(id string) (string, error) {
|
||||
root := getMediaDirectory(id)
|
||||
file, err := os.Open(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
files, _ := file.Readdirnames(0) // 0 to read all files and folders
|
||||
files, _ := file.Readdirnames(0)
|
||||
if len(files) == 0 {
|
||||
return "", errors.New("ID not found")
|
||||
}
|
||||
|
||||
// We expect two files to be produced, a json manifest and an mp4. We want to return the mp4
|
||||
// Sometimes the video file might not have an mp4 extension, so filter out the json file
|
||||
for _, f := range files {
|
||||
if !strings.HasSuffix(f, ".json") {
|
||||
// TODO: This is just returning the first file found. We need to handle multiple
|
||||
return root + f, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -331,6 +337,11 @@ func getDownloadDir() string {
|
|||
return "downloads/"
|
||||
}
|
||||
|
||||
func getCookiesPath() string {
|
||||
execDir, _ := filepath.Abs(filepath.Dir(os.Args[0]))
|
||||
return filepath.Join(execDir, "cookies.txt")
|
||||
}
|
||||
|
||||
func getEnvVars() map[string]string {
|
||||
vars := make(map[string]string)
|
||||
if ev := strings.TrimSpace(os.Getenv("MR_PROXY")); ev != "" {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,9 @@ import (
|
|||
var CachedYtDlpVersion = ""
|
||||
|
||||
func UpdateYtDlp() error {
|
||||
log.Info().Msgf("Updateing yt-dlp")
|
||||
log.Info().Msgf("Updateing yt-dlp to nightly")
|
||||
|
||||
cmd := exec.Command("yt-dlp",
|
||||
"--update",
|
||||
"--update-to", "nightly",
|
||||
)
|
||||
cmd := exec.Command("pip3", "install", "-U", "yt-dlp")
|
||||
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
stdoutIn, _ := cmd.StdoutPipe()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,758 @@
|
|||
body {
|
||||
background-color: #43464a;
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100vh;
|
||||
:root {
|
||||
--bg-primary: #000000;
|
||||
--bg-glass: rgba(255, 255, 255, 0.025);
|
||||
--bg-glass-hover: rgba(255, 255, 255, 0.04);
|
||||
--bg-glass-active: rgba(255, 255, 255, 0.06);
|
||||
--glass-border: rgba(255, 255, 255, 0.06);
|
||||
--glass-border-hover: rgba(255, 255, 255, 0.12);
|
||||
--text-primary: rgba(255, 255, 255, 0.92);
|
||||
--text-secondary: rgba(255, 255, 255, 0.5);
|
||||
--text-tertiary: rgba(255, 255, 255, 0.35);
|
||||
--accent-blue: #2997ff;
|
||||
--accent-blue-light: #64d2ff;
|
||||
--accent-purple: #bf5af2;
|
||||
--accent-green: #30d158;
|
||||
--accent-orange: #ff9f0a;
|
||||
--accent-red: #ff453a;
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 22px;
|
||||
--radius-xl: 28px;
|
||||
--blur-glass: blur(50px) saturate(180%);
|
||||
--blur-heavy: blur(80px) saturate(220%);
|
||||
--shadow-glow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lift: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
--transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition-smooth: all 0.7s cubic-bezier(0.08, 0.82, 0.17, 1);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: var(--text-primary);
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 40% at 15% 0%, rgba(41, 151, 255, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 50% 35% at 85% 5%, rgba(191, 90, 242, 0.06) 0%, transparent 45%),
|
||||
radial-gradient(ellipse 70% 50% at 50% 100%, rgba(48, 209, 88, 0.04) 0%, transparent 40%),
|
||||
radial-gradient(ellipse 40% 30% at 90% 95%, rgba(255, 159, 10, 0.03) 0%, transparent 35%);
|
||||
pointer-events: none;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(circle at 50% 50%, transparent 0%, rgba(0,0,0,0.3) 100%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px 60px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
text-align: center;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
animation: fadeInUp 1s cubic-bezier(0.08, 0.82, 0.17, 1) forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
text-shadow: 0 4px 30px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.app-title i {
|
||||
font-size: 1.6rem;
|
||||
background: linear-gradient(135deg, var(--accent-blue-light), var(--accent-purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 36px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.url-form {
|
||||
margin-bottom: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: var(--blur-glass);
|
||||
-webkit-backdrop-filter: var(--blur-glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 6px;
|
||||
transition: var(--transition);
|
||||
box-shadow: var(--shadow-glow), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
box-shadow: var(--shadow-lift), 0 0 60px rgba(41, 151, 255, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.glass-card:focus-within {
|
||||
border-color: rgba(41, 151, 255, 0.4);
|
||||
box-shadow: var(--shadow-lift), 0 0 0 1px rgba(41, 151, 255, 0.15), 0 20px 60px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.glass-card:focus-within .input-icon {
|
||||
color: var(--accent-blue-light);
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
padding: 16px 10px;
|
||||
outline: none;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.url-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: linear-gradient(135deg, var(--accent-blue) 0%, #0066cc 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px 28px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 6px 24px rgba(41, 151, 255, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-download::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: 0.5s;
|
||||
}
|
||||
|
||||
.btn-download:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 12px 36px rgba(41, 151, 255, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-download:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-download:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-download:disabled::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-download i {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.platforms-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: var(--blur-glass);
|
||||
-webkit-backdrop-filter: var(--blur-glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: var(--transition);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.platform-badge:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--glass-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.platform-badge i {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-top: 48px;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
animation: fadeInUp 0.8s 0.3s cubic-bezier(0.08, 0.82, 0.17, 1) forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.section-header i {
|
||||
font-size: 1.4rem;
|
||||
color: var(--accent-green);
|
||||
filter: drop-shadow(0 0 15px rgba(48, 209, 88, 0.5));
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { filter: drop-shadow(0 0 10px rgba(48, 209, 88, 0.4)); }
|
||||
50% { filter: drop-shadow(0 0 20px rgba(48, 209, 88, 0.7)); }
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-green);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: var(--blur-glass);
|
||||
-webkit-backdrop-filter: var(--blur-glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: var(--transition-smooth);
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
flex: 0 1 320px;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.media-card:hover {
|
||||
transform: translateY(-10px) scale(1.02);
|
||||
box-shadow: var(--shadow-lift), 0 0 40px rgba(41, 151, 255, 0.15);
|
||||
border-color: rgba(41, 151, 255, 0.3);
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
background: #080808;
|
||||
aspect-ratio: 16/9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-preview::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.5) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.media-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.media-card:hover .media-video {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.media-info {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.media-name i {
|
||||
color: var(--accent-blue-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.media-size {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.media-size i {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #24a158 100%);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 6px 24px rgba(48, 209, 88, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 10px 32px rgba(48, 209, 88, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-save i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
margin-top: 40px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: rgba(255, 69, 58, 0.06);
|
||||
border: 1px solid rgba(255, 69, 58, 0.15);
|
||||
backdrop-filter: var(--blur-glass);
|
||||
-webkit-backdrop-filter: var(--blur-glass);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.error-card i {
|
||||
font-size: 2.4rem;
|
||||
color: var(--accent-red);
|
||||
margin-bottom: 16px;
|
||||
filter: drop-shadow(0 0 15px rgba(255, 69, 58, 0.5));
|
||||
}
|
||||
|
||||
.error-card h4 {
|
||||
color: var(--accent-red);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.error-card pre {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
text-align: left;
|
||||
color: rgba(255, 180, 180, 0.85);
|
||||
font-size: 0.72rem;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Outfit', 'SF Mono', monospace;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
text-align: center;
|
||||
padding: 52px 60px;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: var(--blur-heavy);
|
||||
-webkit-backdrop-filter: var(--blur-heavy);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
animation: scaleIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
box-shadow: var(--shadow-lift);
|
||||
max-width: 380px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(41, 151, 255, 0.1) 0%, transparent 50%);
|
||||
animation: rotate 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 28px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loading-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 3px solid rgba(41, 151, 255, 0.15);
|
||||
border-top-color: var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.loading-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid rgba(100, 210, 255, 0.15);
|
||||
border-top-color: var(--accent-blue-light);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loading-title i {
|
||||
font-size: 1.15rem;
|
||||
color: var(--accent-blue-light);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 28px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loading-timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 28px;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 18px;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent-blue-light);
|
||||
letter-spacing: 0.06em;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 0 30px rgba(41, 151, 255, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loading-timer i {
|
||||
font-size: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24px 20px;
|
||||
text-align: center;
|
||||
background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
color: var(--accent-blue-light);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.app-footer a:hover {
|
||||
color: var(--accent-blue);
|
||||
text-shadow: 0 0 20px rgba(41, 151, 255, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-title {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
text-align: center;
|
||||
background: var(--bg-glass);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
max-width: 100%;
|
||||
flex: 0 1 100%;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
margin: 0 16px;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>about - media-roller</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css//bootstrap.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container d-flex flex-column text-light">
|
||||
<div class="flex-grow-1"></div>
|
||||
<div class="jumbotron bg-transparent flex-grow-1">
|
||||
<h1 class="display-4"><a class="text-light" href="/">media roller</a></h1>
|
||||
<div>
|
||||
{{ .details }}
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:110px;">Source</td>
|
||||
<td><a href="https://github.com/rroller/media-roller">https://github.com/rroller/media-roller</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
||||
<td>{{ $.ytDlpVersion }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://go.dev/doc/install">Golang</a></td>
|
||||
<td>{{ $.goVersion }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://www.python.org/downloads/">Python</a></td>
|
||||
<td>{{ $.pythonVersion }}</td>
|
||||
</tr>
|
||||
<tr><td>os</td><td>{{ $.os }}</td></tr>
|
||||
<tr><td>kernel</td><td>{{ $.kernel }}</td></tr>
|
||||
<tr><td>core</td><td>{{ $.core }}</td></tr>
|
||||
<tr><td>platform</td><td>{{ $.platform }}</td></tr>
|
||||
<tr><td>hostname</td><td>{{ $.hostname }}</td></tr>
|
||||
<tr><td>cpus</td><td>{{ $.cpus }}</td></tr>
|
||||
<tr>
|
||||
<td style="vertical-align: top"><a href="https://www.ffmpeg.org/download.html">ffmpeg</a></td>
|
||||
<td>{{range $element := .ffmpegVersion}}{{ $element }}<br>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: top"><a href="https://www.ffmpeg.org/download.html">ffprobe</a></td>
|
||||
<td>{{range $element := .ffprobeVersion}}{{ $element }}<br>{{end}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,54 +1,158 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>media-roller</title>
|
||||
<title>KV Download</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css//bootstrap.min.css">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/style.css">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container d-flex flex-column text-light text-center">
|
||||
<div class="flex-grow-1"></div>
|
||||
<div class="jumbotron bg-transparent flex-grow-1">
|
||||
<h1 class="display-4"><a class="text-light" href="/">media roller</a></h1>
|
||||
<p>
|
||||
Mobile friendly tool for downloading videos from social media
|
||||
</p>
|
||||
<div>
|
||||
<form action="/fetch" method="GET">
|
||||
<div class="input-group">
|
||||
<input name="url" type="url" class="form-control" placeholder="URL" aria-label="URL"
|
||||
aria-describedby="button-submit" value="{{.url}}" autofocus>
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" type="submit" id="button-submit">Submit</button>
|
||||
<div class="app-container">
|
||||
<div class="hero-content">
|
||||
<h1 class="app-title">
|
||||
<i class="bi bi-cloud-arrow-down"></i>
|
||||
KV Download
|
||||
</h1>
|
||||
<p class="app-subtitle">Download videos from social media platforms</p>
|
||||
|
||||
<form action="/fetch" method="GET" class="url-form" id="downloadForm">
|
||||
<div class="glass-card">
|
||||
<div class="glass-input">
|
||||
<div class="input-icon">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</div>
|
||||
<input name="url" type="url" class="url-input"
|
||||
placeholder="Paste video URL here..." required
|
||||
value="{{.url}}" autofocus id="urlInput">
|
||||
<button class="btn-download" type="submit" id="submitBtn">
|
||||
<i class="bi bi-download"></i>
|
||||
<span class="btn-text">Download</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="platforms-wrapper">
|
||||
<span class="platform-badge"><i class="bi bi-youtube"></i> YouTube</span>
|
||||
<span class="platform-badge"><i class="bi bi-tiktok"></i> TikTok</span>
|
||||
<span class="platform-badge"><i class="bi bi-instagram"></i> Instagram</span>
|
||||
<span class="platform-badge"><i class="bi bi-twitter-x"></i> Twitter/X</span>
|
||||
<span class="platform-badge"><i class="bi bi-vimeo"></i> Vimeo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading-overlay d-none" id="loadingOverlay">
|
||||
<div class="loading-card">
|
||||
<div class="loading-icon"></div>
|
||||
<h3 class="loading-title"><i class="bi bi-cloud-arrow-down"></i> Downloading</h3>
|
||||
<p class="loading-text">Please wait while we fetch your video</p>
|
||||
<div class="loading-timer">
|
||||
<i class="bi bi-clock"></i>
|
||||
<span id="timer">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if .media }}
|
||||
<div style="margin-top:20px;">
|
||||
<h2>Done!</h2>
|
||||
<div class="results-section">
|
||||
<div class="section-header">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<h2>Download Complete</h2>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{{range .media}}
|
||||
<div>
|
||||
<a style="color: dodgerblue" href="/download?id={{.Id}}">{{.Name}}</a> <small>{{.HumanSize}}</small><br>
|
||||
<video controls loop width="250">
|
||||
<source src="/download?id={{.Id}}" type="video/mp4"/>
|
||||
<div class="media-card">
|
||||
<div class="media-preview">
|
||||
<video controls loop class="media-video">
|
||||
<source src="/download?id={{.Id}}" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="media-info">
|
||||
<h5 class="media-name"><i class="bi bi-film"></i> {{.Name}}</h5>
|
||||
<div class="media-meta">
|
||||
<span class="media-size"><i class="bi bi-hdd"></i> {{.HumanSize}}</span>
|
||||
<a href="/download?id={{.Id}}" class="btn-save">
|
||||
<i class="bi bi-download"></i> Save
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .error }}
|
||||
<h4 style="margin-top:20px;">Error</h4>
|
||||
<pre style="background-color:white;text-align:left;white-space: pre-wrap;">{{ .error }}</pre>
|
||||
<div class="error-container">
|
||||
<div class="error-card">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<h4>Download Failed</h4>
|
||||
<pre>{{ .error }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<div>
|
||||
<a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a> version {{ $.ytDlpVersion }}<br>
|
||||
<p><a href="/about">about media-roller</a></p>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Powered by <a href="https://github.com/yt-dlp/yt-dlp" target="_blank">yt-dlp</a> {{ $.ytDlpVersion }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var timerInterval = null;
|
||||
var startTime = null;
|
||||
|
||||
function formatTime(seconds) {
|
||||
var mins = Math.floor(seconds / 60);
|
||||
var secs = seconds % 60;
|
||||
return mins + ':' + (secs < 10 ? '0' : '') + secs;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
startTime = Date.now();
|
||||
var timerEl = document.getElementById('timer');
|
||||
if (timerEl) {
|
||||
timerEl.textContent = '0:00';
|
||||
}
|
||||
timerInterval = setInterval(function() {
|
||||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
if (timerEl) {
|
||||
timerEl.textContent = formatTime(elapsed);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
var form = document.getElementById('downloadForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
var btn = document.getElementById('submitBtn');
|
||||
var btnText = btn ? btn.querySelector('.btn-text') : null;
|
||||
var overlay = document.getElementById('loadingOverlay');
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
if (btnText) btnText.textContent = 'Downloading...';
|
||||
if (overlay) overlay.classList.remove('d-none');
|
||||
|
||||
startTimer();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', function() {
|
||||
stopTimer();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
5
tidy.sh
Executable file → Normal file
5
tidy.sh
Executable file → Normal file
|
|
@ -5,6 +5,5 @@ go get -u ./...
|
|||
go mod tidy
|
||||
go fmt ./...
|
||||
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.64.5
|
||||
$(go env GOPATH)/bin/golangci-lint --version
|
||||
$(go env GOPATH)/bin/golangci-lint run
|
||||
golangci-lint --version
|
||||
golangci-lint run
|
||||
|
|
|
|||
Loading…
Reference in a new issue