Compare commits

...

10 commits

22 changed files with 1190 additions and 223 deletions

View file

@ -1 +1,9 @@
downloads/ 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 RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
@ -10,15 +13,23 @@ COPY go.mod go.mod
COPY go.sum go.sum COPY go.sum go.sum
RUN go mod download 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 # 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. # This is where the downloaded files will be saved in the container.
ENV MR_DOWNLOAD_DIR="/download" ENV MR_DOWNLOAD_DIR="/download"
RUN apk add --update --no-cache \ RUN apk add --update --no-cache \
# https://github.com/yt-dlp/yt-dlp/issues/14404 \
deno \
curl curl
# https://hub.docker.com/r/mwader/static-ffmpeg/tags # 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 --from=builder /app/media-roller /app/media-roller
COPY templates /app/templates COPY templates /app/templates
COPY static /app/static COPY static /app/static
COPY cookies.txt /app/cookies.txt
WORKDIR /app WORKDIR /app
@ -42,4 +54,9 @@ RUN yt-dlp --update --update-to nightly
RUN yt-dlp --version && \ RUN yt-dlp --version && \
ffmpeg -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"] 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. 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. 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. 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) ![Screenshot 2](https://i.imgur.com/TWAtM7k.png)
# Running # 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: 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 ```bash
./run.sh ./run.sh
``` ```
Or for docker locally: Or for docker locally:
```bash ```bash
./docker-build.sh ./docker-build.sh
./docker-run.sh ./docker-run.sh
``` ```
With Docker, published to both dockerhub and github. ## Docker Image
* ghcr: `docker pull ghcr.io/rroller/media-roller:master`
* dockerhub: `docker pull ronnieroller/media-roller`
See: The Docker image is available from the Forgejo container registry:
* https://github.com/rroller/media-roller/pkgs/container/media-roller
* https://hub.docker.com/repository/docker/ronnieroller/media-roller
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_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 # API
To download a video directly, use the API endpoint: 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): 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 # 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. 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 https://www.icloud.com/shortcuts/d3b05b78eb434496ab28dd91e1c79615
# Unraid # Unraid
media-roller is available in Unraid and can be found on the "Apps" tab by searching its name. 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 module media-roller
go 1.25.1 go 1.25.3
require ( require (
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
@ -13,5 +13,5 @@ require (
require ( require (
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 ( import (
"context" "context"
"errors" "errors"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
"media-roller/src/media" "media-roller/src/media"
"net/http" "net/http"
"os" "os"
@ -14,6 +12,9 @@ import (
"strings" "strings"
"syscall" "syscall"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
) )
func main() { func main() {
@ -24,7 +25,6 @@ func main() {
router.Get("/fetch", media.FetchMedia) router.Get("/fetch", media.FetchMedia)
router.Get("/api/download", media.FetchMediaApi) router.Get("/api/download", media.FetchMediaApi)
router.Get("/download", media.ServeMedia) router.Get("/download", media.ServeMedia)
router.Get("/about", media.AboutIndex)
}) })
fileServer(router, "/static", "static/") fileServer(router, "/static", "static/")
@ -42,7 +42,7 @@ func main() {
go startYtDlpUpdater() go startYtDlpUpdater()
// The HTTP Server // The HTTP Server
server := &http.Server{Addr: ":3000", Handler: router} server := &http.Server{Addr: ":9292", Handler: router}
// Server run context // Server run context
serverCtx, serverStopCtx := context.WithCancel(context.Background()) 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 // startYtDlpUpdater will update the yt-dlp to the latest nightly version ever few hours
func startYtDlpUpdater() { func startYtDlpUpdater() {
log.Info().Msgf("yt-dlp version: %s", media.GetInstalledVersion()) 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 // Do one update now
_ = media.UpdateYtDlp() _ = media.UpdateYtDlp()
@ -119,7 +119,7 @@ func fileServer(r chi.Router, public string, static string) {
fs := http.StripPrefix(public, http.FileServer(http.Dir(root))) fs := http.StripPrefix(public, http.FileServer(http.Dir(root)))
if public != "/" && public[len(public)-1] != '/' { if public != "/" && public[len(public)-1] != '/' {
r.Get(public, http.RedirectHandler(public+"/", 301).ServeHTTP) r.Get(public, http.RedirectHandler(public+"/", http.StatusMovedPermanently).ServeHTTP)
public += "/" 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 package media
import ( import (
"bytes"
"crypto/md5" "crypto/md5"
"errors" "errors"
"fmt" "fmt"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/rs/zerolog/log"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"html/template" "html/template"
"io"
"media-roller/src/utils" "media-roller/src/utils"
"net/http" "net/http"
"os"
"os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "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 { type Media struct {
Id string Id string
Name string Name string
@ -36,7 +29,6 @@ type Media struct {
var fetchIndexTmpl = template.Must(template.ParseFiles("templates/media/index.html")) 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 downloadDir = getDownloadDir()
var idCharSet = regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString var idCharSet = regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString
@ -85,14 +77,12 @@ func FetchMediaApi(w http.ResponseWriter, r *http.Request) {
return return
} }
// just take the first one
streamFileToClientById(w, r, medias[0].Id) streamFileToClientById(w, r, medias[0].Id)
} }
func getUrl(r *http.Request) (string, map[string]string) { func getUrl(r *http.Request) (string, map[string]string) {
u := strings.TrimSpace(r.URL.Query().Get("url")) 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) args := make(map[string]string)
for k, v := range r.URL.Query() { for k, v := range r.URL.Query() {
if strings.HasPrefix(k, "-") { if strings.HasPrefix(k, "-") {
@ -115,18 +105,12 @@ func getMediaResults(inputUrl string, args map[string]string) ([]Media, string,
url := utils.NormalizeUrl(inputUrl) url := utils.NormalizeUrl(inputUrl)
log.Info().Msgf("Got input '%s' and extracted '%s' with args %v", inputUrl, url, args) 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) id := GetMD5Hash(url, args)
// Look to see if we already have the media on disk
medias, err := getAllFilesForId(id) medias, err := getAllFilesForId(id)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if len(medias) == 0 { if len(medias) == 0 {
// We don't, so go fetch it
errMessage := "" errMessage := ""
id, errMessage, err = downloadMedia(url, args) id, errMessage, err = downloadMedia(url, args)
if err != nil { if err != nil {
@ -141,29 +125,31 @@ func getMediaResults(inputUrl string, args map[string]string) ([]Media, string,
return medias, "", nil 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) { 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) id := GetMD5Hash(url, requestArgs)
name := getMediaDirectory(id) + "%(id)s.%(ext)s" name := getMediaDirectory(id) + "%(id)s.%(ext)s"
log.Info().Msgf("Downloading %s to %s", url, name) log.Info().Msgf("Downloading %s to %s", url, name)
cookiesPath := getCookiesPath()
defaultArgs := map[string]string{ defaultArgs := map[string]string{
"--format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best", "--format": "best",
"--merge-output-format": "mp4", "--trim-filenames": "100",
"--trim-filenames": "100", "--recode-video": "mp4",
"--recode-video": "mp4", "--restrict-filenames": "",
"--format-sort": "codec:h264", "--write-info-json": "",
"--restrict-filenames": "", "--output": name,
"--write-info-json": "", "--no-check-certificates": "",
"--verbose": "", "--extractor-args": "instagram:image_persist=1",
"--output": name, }
if _, err := os.Stat(cookiesPath); err == nil {
defaultArgs["--cookies"] = cookiesPath
} }
args := make([]string, 0) args := make([]string, 0)
// First add all default arguments that were not supplied as request level arguments
for arg, value := range defaultArgs { for arg, value := range defaultArgs {
if _, has := requestArgs[arg]; !has { if _, has := requestArgs[arg]; !has {
args = append(args, arg) 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 { for arg, value := range requestArgs {
args = append(args, arg) args = append(args, arg)
if value != "" { 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() { for arg, value := range getEnvVars() {
if _, has := requestArgs[arg]; !has { if _, has := requestArgs[arg]; !has {
args = append(args, arg) 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) log.Error().Msgf("failed to capture stderr: %v", errStderr)
} }
moveJsonFiles(id)
return id, "", nil return id, "", nil
} }
// Returns the relative directory containing the media file, with a trailing slash. func moveJsonFiles(id string) {
// Id is expected to be pre validated 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 { func getMediaDirectory(id string) string {
return downloadDir + id + "/" return downloadDir + id + "/"
} }
// id is expected to be validated prior to calling this func
func getAllFilesForId(id string) ([]Media, error) { func getAllFilesForId(id string) ([]Media, error) {
root := getMediaDirectory(id) root := getMediaDirectory(id)
file, err := os.Open(root) file, err := os.Open(root)
@ -249,14 +261,13 @@ func getAllFilesForId(id string) ([]Media, error) {
} }
return nil, err return nil, err
} }
files, _ := file.Readdirnames(0) // 0 to read all files and folders files, _ := file.Readdirnames(0)
if len(files) == 0 { if len(files) == 0 {
return nil, errors.New("ID not found: " + id) return nil, errors.New("ID not found: " + id)
} }
var medias []Media var medias []Media
// We expect two files to be produced for each video, a json manifest and an mp4.
for _, f := range files { for _, f := range files {
if !strings.HasSuffix(f, ".json") { if !strings.HasSuffix(f, ".json") {
fi, err2 := os.Stat(root + f) fi, err2 := os.Stat(root + f)
@ -278,24 +289,19 @@ func getAllFilesForId(id string) ([]Media, error) {
return medias, nil 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) { func getFileFromId(id string) (string, error) {
root := getMediaDirectory(id) root := getMediaDirectory(id)
file, err := os.Open(root) file, err := os.Open(root)
if err != nil { if err != nil {
return "", err return "", err
} }
files, _ := file.Readdirnames(0) // 0 to read all files and folders files, _ := file.Readdirnames(0)
if len(files) == 0 { if len(files) == 0 {
return "", errors.New("ID not found") 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 { for _, f := range files {
if !strings.HasSuffix(f, ".json") { if !strings.HasSuffix(f, ".json") {
// TODO: This is just returning the first file found. We need to handle multiple
return root + f, nil return root + f, nil
} }
} }
@ -331,6 +337,11 @@ func getDownloadDir() string {
return "downloads/" return "downloads/"
} }
func getCookiesPath() string {
execDir, _ := filepath.Abs(filepath.Dir(os.Args[0]))
return filepath.Join(execDir, "cookies.txt")
}
func getEnvVars() map[string]string { func getEnvVars() map[string]string {
vars := make(map[string]string) vars := make(map[string]string)
if ev := strings.TrimSpace(os.Getenv("MR_PROXY")); ev != "" { if ev := strings.TrimSpace(os.Getenv("MR_PROXY")); ev != "" {

View file

@ -13,12 +13,9 @@ import (
var CachedYtDlpVersion = "" var CachedYtDlpVersion = ""
func UpdateYtDlp() error { func UpdateYtDlp() error {
log.Info().Msgf("Updateing yt-dlp") log.Info().Msgf("Updateing yt-dlp to nightly")
cmd := exec.Command("yt-dlp", cmd := exec.Command("pip3", "install", "-U", "yt-dlp")
"--update",
"--update-to", "nightly",
)
var stdoutBuf, stderrBuf bytes.Buffer var stdoutBuf, stderrBuf bytes.Buffer
stdoutIn, _ := cmd.StdoutPipe() stdoutIn, _ := cmd.StdoutPipe()

View file

@ -1,7 +1,758 @@
body { @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
background-color: #43464a;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
} }
.container { :root {
height: 100vh; --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> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<title>media-roller</title> <title>KV Download</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <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"> <meta name="theme-color" content="#000000">
<link rel="stylesheet" type="text/css" href="/static/css/style.css"> <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> </head>
<body> <body>
<div class="container d-flex flex-column text-light text-center"> <div class="app-container">
<div class="flex-grow-1"></div> <div class="hero-content">
<div class="jumbotron bg-transparent flex-grow-1"> <h1 class="app-title">
<h1 class="display-4"><a class="text-light" href="/">media roller</a></h1> <i class="bi bi-cloud-arrow-down"></i>
<p> KV Download
Mobile friendly tool for downloading videos from social media </h1>
</p> <p class="app-subtitle">Download videos from social media platforms</p>
<div>
<form action="/fetch" method="GET"> <form action="/fetch" method="GET" class="url-form" id="downloadForm">
<div class="input-group"> <div class="glass-card">
<input name="url" type="url" class="form-control" placeholder="URL" aria-label="URL" <div class="glass-input">
aria-describedby="button-submit" value="{{.url}}" autofocus> <div class="input-icon">
<div class="input-group-append"> <i class="bi bi-link-45deg"></i>
<button class="btn btn-primary" type="submit" id="button-submit">Submit</button>
</div> </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>
{{ if .media }} </form>
<div style="margin-top:20px;">
<h2>Done!</h2> <div class="platforms-wrapper">
{{range .media}} <span class="platform-badge"><i class="bi bi-youtube"></i> YouTube</span>
<div> <span class="platform-badge"><i class="bi bi-tiktok"></i> TikTok</span>
<a style="color: dodgerblue" href="/download?id={{.Id}}">{{.Name}}</a> <small>{{.HumanSize}}</small><br> <span class="platform-badge"><i class="bi bi-instagram"></i> Instagram</span>
<video controls loop width="250"> <span class="platform-badge"><i class="bi bi-twitter-x"></i> Twitter/X</span>
<source src="/download?id={{.Id}}" type="video/mp4"/> <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> </video>
</div> </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> </div>
{{ end }} {{ 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>
</div> </div>
<footer> {{ end }}
<div>
<a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a> version {{ $.ytDlpVersion }}<br> {{ if .error }}
<p><a href="/about">about media-roller</a></p> <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>
</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> </footer>
</div> </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> </body>
</html> </html>

5
tidy.sh Executable file → Normal file
View file

@ -5,6 +5,5 @@ go get -u ./...
go mod tidy go mod tidy
go fmt ./... go fmt ./...
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.64.5 golangci-lint --version
$(go env GOPATH)/bin/golangci-lint --version golangci-lint run
$(go env GOPATH)/bin/golangci-lint run