Compare commits

...

10 commits

22 changed files with 1190 additions and 223 deletions

View file

@ -1 +1,9 @@
downloads/
download/
.git/
.github/
.forgejo/
.idea/
.DS_Store
*.md
media-roller

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

@ -0,0 +1 @@
golang 1.25.3

23
Dockerfile Executable file → Normal file
View 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"]

View file

@ -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,33 +12,93 @@ Note: This was written to run on a home network and should not be exposed to pub
![Screenshot 2](https://i.imgur.com/TWAtM7k.png)
# 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
```
Or for docker locally:
```bash
./docker-build.sh
./docker-run.sh
./docker-build.sh
./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 witht he `--proxy` argument. Defaults to empty
* `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
View file

38
cookies.txt Normal file
View 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
View file

29
docker-compose.yml Normal file
View 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
View file

4
go.mod
View file

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

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

Binary file not shown.

0
run.sh Executable file → Normal file
View file

View file

@ -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 += "/"
}

View file

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

View file

@ -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",
"--trim-filenames": "100",
"--recode-video": "mp4",
"--format-sort": "codec:h264",
"--restrict-filenames": "",
"--write-info-json": "",
"--verbose": "",
"--output": name,
"--format": "best",
"--trim-filenames": "100",
"--recode-video": "mp4",
"--restrict-filenames": "",
"--write-info-json": "",
"--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 != "" {

View file

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

View file

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

View file

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

View file

@ -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>
</form>
{{ if .media }}
<div style="margin-top:20px;">
<h2>Done!</h2>
{{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>
</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 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 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 }}
{{ if .error }}
<h4 style="margin-top:20px;">Error</h4>
<pre style="background-color:white;text-align:left;white-space: pre-wrap;">{{ .error }}</pre>
{{ 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>
{{ end }}
{{ if .error }}
<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 }}
<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
View 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