Update: Modern glass UI, timer fix, Instagram/TikTok cookies, Docker support

This commit is contained in:
Khoa.vo 2026-04-04 08:23:56 +07:00
parent 4b16bebf7d
commit 8428d7ed42
55 changed files with 1412 additions and 198 deletions

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

View file

@ -30,6 +30,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
@ -44,4 +45,6 @@ RUN yt-dlp --update --update-to nightly
RUN yt-dlp --version && \ RUN yt-dlp --version && \
ffmpeg -version ffmpeg -version
EXPOSE 9292
ENTRYPOINT ["/app/media-roller"] ENTRYPOINT ["/app/media-roller"]

13
app.log Normal file
View file

@ -0,0 +1,13 @@
{"level":"info","time":"2026-04-04T08:21:08+07:00","message":"GET /"}
{"level":"info","time":"2026-04-04T08:21:08+07:00","message":"GET /api/download"}
{"level":"info","time":"2026-04-04T08:21:08+07:00","message":"GET /download"}
{"level":"info","time":"2026-04-04T08:21:08+07:00","message":"GET /fetch"}
{"level":"info","time":"2026-04-04T08:21:08+07:00","message":"GET /static"}
{"level":"info","time":"2026-04-04T08:21:08+07:00","message":"GET /static/*"}
{"level":"info","time":"2026-04-04T08:21:09+07:00","message":"yt-dlp version: 2026.03.17"}
{"level":"info","time":"2026-04-04T08:21:09+07:00","message":"Updateing yt-dlp to nightly"}
Requirement already satisfied: yt-dlp in /Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages (2026.3.17)
[notice] A new release of pip is available: 25.3 -> 26.0.1
[notice] To update, run: pip3 install --upgrade pip
{"level":"info","time":"2026-04-04T08:21:10+07:00","message":"Done updating yt-dlp. Version=2026.03.17"}

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"

367
doc/FORGEJO_CI_CD_SETUP.md Normal file
View file

@ -0,0 +1,367 @@
# Forgejo CI/CD Setup Guide
This guide explains how to set up Forgejo with an integrated runner for automated Docker image builds.
## Overview
- **Forgejo** runs on your NAS at `http://100.89.182.37:3050`
- **Forgejo Runner** builds Docker images automatically when code is pushed
- **Docker Registry** stores the built images (optional)
---
## Prerequisites
- Docker and Docker Compose installed on your NAS
- SSH access to your NAS (port 2212)
- A Docker network already created: `kv-tube_default`
```bash
docker network create kv-tube_default
```
---
## Part 1: Docker Compose File
Create `docker-compose.forgejo.yml`:
```yaml
services:
forgejo:
image: codeberg.org/forgejo/forgejo:7.0.16
container_name: forgejo
environment:
- USER_UID=1026
- USER_GID=100
- GITEA__database__DB_TYPE=sqlite3
- TZ=Asia/Ho_Chi_Minh
- GITEA__actions__ENABLED=true
- INSTALL_LOCK=true
- FORGEJO__server__ROOT_URL=http://100.89.182.37:3050/
restart: always
volumes:
- ./forgejo-data:/data
ports:
- "3050:3000"
- "2222:22"
networks:
- kv-tube_internal
forgejo-runner:
image: code.forgejo.org/forgejo/runner:6.0.1
container_name: forgejo_runner
restart: always
user: "0:0"
privileged: true
depends_on:
- forgejo
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./forgejo-runner-data:/data
entrypoint:
- sh
- -c
- |
apk add --no-cache docker-cli
if [ ! -f /data/.runner ]; then
forgejo-runner register --no-interactive \
--instance http://forgejo:3000 \
--token YOUR_RUNNER_TOKEN \
--name synology-runner \
--labels ubuntu-latest,ubuntu-22.04,docker:host
fi
forgejo-runner daemon
environment:
- TZ=Asia/Ho_Chi_Minh
networks:
- kv-tube_internal
networks:
kv-tube_internal:
name: kv-tube_default
external: true
```
**Notes:**
- Replace `100.89.182.37` with your NAS IP
- Replace `YOUR_RUNNER_TOKEN` with the token you generate in Forgejo UI
---
## Part 2: Start Services
### 1. Create directories on NAS
```bash
ssh -p 2212 superadmin@100.89.182.37
mkdir -p kv-tube/forgejo-data kv-tube/forgejo-runner-data
cd kv-tube
```
### 2. Copy docker-compose.forgejo.yml to NAS
### 3. Start services
```bash
docker-compose -f docker-compose.forgejo.yml up -d
```
---
## Part 3: Configure Forgejo
### 1. Initial Setup
1. Open **http://100.89.182.37:3050** in browser
2. Complete the initial setup:
- Database: **SQLite3**
- Administrator username and password
3. Login with your admin account
### 2. Enable Actions
Settings should already have `GITEA__actions__ENABLED=true` in the compose file.
### 3. Generate Runner Token
1. Go to **Settings****Actions** → **Runners**
2. Click **Generate Registration Token**
3. Copy the token and update `docker-compose.forgejo.yml`
4. Restart the runner:
```bash
docker-compose -f docker-compose.forgejo.yml restart forgejo-runner
```
### 4. Create Repository
1. Click **+** → **New Repository**
2. Name it (e.g., `my-project`)
3. Click **Create Repository**
---
## Part 4: Configure Git Remote
Add Forgejo as a remote:
```bash
git remote add forgejo http://100.89.182.37:3050/your_username/my-project.git
```
Or update existing remote:
```bash
git remote set-url forgejo http://username:TOKEN@100.89.182.37:3050/username/my-project.git
```
To generate an access token:
1. Go to **Settings****Applications** → **Tokens**
2. Click **Generate New Token**
3. Use the token in the remote URL
---
## Part 5: Create Workflow File
Create `.forgejo/workflows/docker-build.yml`:
```yaml
name: Build & Push Docker Image
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
apk add --no-cache git
cd /tmp
rm -rf my-project
git clone http://username:TOKEN@forgejo:3000/username/my-project.git
cd my-project
git checkout ${GITEA_SHA:-main}
- name: Build Docker Image
run: |
cd /tmp/my-project
SHA_SHORT=$(git rev-parse --short HEAD)
docker build --no-cache -t my-project:${SHA_SHORT} .
docker images | grep my-project
```
**Notes:**
- Replace `username` and `TOKEN` with your Forgejo credentials
- Replace `my-project` with your project name
- This workflow builds the image but does NOT push to external registry (use manual push below)
**Why no auto push?** The runner runs inside Docker on your NAS and may have network issues reaching external registries. The image is built and saved locally - you can push manually or set up a local registry.
---
## Part 6: Push to Trigger Build
```bash
git add .forgejo/workflows/docker-build.yml
git commit -m "Add CI/CD workflow"
git push forgejo main
```
---
## Part 7: Monitor Workflow
1. Go to **http://100.89.182.37:3050/username/my-project/actions**
2. Click on the workflow run to see logs
3. Verify the image is built successfully
---
## Run the Built Image
On your NAS:
```bash
# List built images
docker images | grep my-project
# Run the container
docker run -d -p 3000:3000 -p 8080:8080 my-project:<tag>
```
---
## Push to External Registry (Manual)
If the CI/CD cannot push to your external registry (e.g., `git.khoavo.myds.me`) due to network issues, you can manually push from your NAS:
```bash
# 1. Find the built image tag
docker images | grep my-project
# 2. Tag for your registry
docker tag my-project:<commit-sha> git.khoavo.myds.me/username/my-project:<tag>
# 3. Push to registry
docker push git.khoavo.myds.me/username/my-project:<tag>
```
**Example:**
```bash
# List images
docker images | grep kv-tube
# Tag
docker tag kv-tube:abc1234 git.khoavo.myds.me/vndangkhoa/kv-tube:v11
# Push
docker push git.khoavo.myds.me/vndangkhoa/kv-tube:v11
```
---
## Optional: Set Up Docker Registry
If you want to push images to a registry:
### 1. Start Registry Container
```bash
docker run -d \
--network kv-tube_default \
--name registry \
-p 5180:5000 \
-v registry-data:/var/lib/registry \
registry:2
```
### 2. Configure Insecure Registry (on NAS)
```bash
echo '{"insecure-registries":["172.27.0.3:5000"]}' | sudo tee /etc/docker/daemon.json
# Restart Docker daemon
```
### 3. Update Workflow to Push
```yaml
- name: Build and Push
run: |
cd /tmp/my-project
SHA_SHORT=$(git rev-parse --short HEAD)
docker build --no-cache -t my-project:${SHA_SHORT} .
docker tag my-project:${SHA_SHORT} 172.27.0.3:5000/username/my-project:${SHA_SHORT}
docker push 172.27.0.3:5000/username/my-project:${SHA_SHORT}
```
**Note:** Getting Docker to trust the registry requires proper TLS certificates or daemon configuration.
---
## Troubleshooting
### Runner not picking up jobs
```bash
# Check runner logs
docker logs forgejo_runner
# Restart runner
docker-compose -f docker-compose.forgejo.yml restart forgejo_runner
```
### "Could not resolve host: nas"
In workflow, use `forgejo:3000` (internal Docker network) instead of `nas:3050`
### Docker not available in runner
Ensure the runner has:
- `privileged: true` in compose
- `docker-cli` installed in entrypoint
- `/var/run/docker.sock` mounted
### Push fails with authentication error
Generate a new access token in **Settings****Applications****Tokens** and update both the git remote URL and the workflow file.
---
## File Structure
```
my-project/
├── .forgejo/
│ └── workflows/
│ └── docker-build.yml
├── docker-compose.forgejo.yml
└── (your project files)
```
---
## Useful Commands
```bash
# Start Forgejo + Runner
docker-compose -f docker-compose.forgejo.yml up -d
# View logs
docker logs forgejo
docker logs forgejo_runner
# Restart runner
docker-compose -f docker-compose.forgejo.yml restart forgejo-runner
# Stop services
docker-compose -f docker-compose.forgejo.yml down
# Check running containers
docker ps
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"id": "RDLH1NaKHuBLE", "title": "Mix - Anh Vội Quên Remix - DJ HYENA - Nguyễn Thạc Bảo Ngọc | Ta Đã Từng Chung Điểm Dừng Mà Sao Không Xứng", "_type": "playlist", "webpage_url": "https://www.youtube.com/watch?v=LH1NaKHuBLE&list=RDLH1NaKHuBLE&start_radio=1", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube:tab", "extractor_key": "YoutubeTab", "playlist_count": 233, "epoch": 1775263815, "_version": {"version": "2026.03.17", "release_git_head": "04d6974f502bbdfaed72c624344f262e30ad9708", "repository": "yt-dlp/yt-dlp"}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -25,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/")
@ -43,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())
@ -87,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()

View file

@ -1,45 +0,0 @@
package media
import (
"html/template"
"media-roller/src/utils"
"net/http"
"regexp"
"strings"
"github.com/matishsiao/goInfo"
"github.com/rs/zerolog/log"
)
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),
"deno": strings.TrimPrefix(utils.RunCommand("deno", "--version"), "go version "),
"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)
@ -233,13 +217,10 @@ func downloadMedia(url string, requestArgs map[string]string) (string, string, e
return id, "", nil return id, "", nil
} }
// Returns the relative directory containing the media file, with a trailing slash.
// Id is expected to be pre validated
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 +230,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 +258,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 +306,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,60 +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>deno</td><td>{{ $.deno }}</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>

BIN
test.mp4 Normal file

Binary file not shown.