Compare commits

..

No commits in common. "master" and "v1.2.2" have entirely different histories.

9 changed files with 32 additions and 298 deletions

View file

@ -11,7 +11,7 @@ A self-hostable yt-dlp web frontend. Paste a URL, pick quality, download — wit
- **Real-time progress** — Server-Sent Events stream download progress to the browser instantly. - **Real-time progress** — Server-Sent Events stream download progress to the browser instantly.
- **Session isolation** — Each browser gets its own download queue. No cross-talk. - **Session isolation** — Each browser gets its own download queue. No cross-talk.
- **Playlist support** — Collapsible parent/child jobs with per-video status tracking. - **Playlist support** — Collapsible parent/child jobs with per-video status tracking.
- **9 built-in themes** — 5 dark (Cyberpunk, Dark, Midnight, Hacker, Neon) + 4 light (Light, Paper, Arctic, Solarized). Admin picks the pair, visitors toggle dark/light. - **Three built-in themes** — Cyberpunk (default), Dark, Light. Switch in the header.
- **Custom themes** — Drop a CSS file into `/themes` volume. No rebuild needed. - **Custom themes** — Drop a CSS file into `/themes` volume. No rebuild needed.
- **Admin panel** — Session management, storage info, manual purge, error logs. Protected by bcrypt auth. - **Admin panel** — Session management, storage info, manual purge, error logs. Protected by bcrypt auth.
- **Cookie auth** — Upload cookies.txt per session for paywalled/private content. - **Cookie auth** — Upload cookies.txt per session for paywalled/private content.
@ -21,32 +21,8 @@ A self-hostable yt-dlp web frontend. Paste a URL, pick quality, download — wit
## Quickstart ## Quickstart
The Docker image is published to GitHub Container Registry:
```
ghcr.io/xpltdco/media-rip:latest
```
Pull and run with Docker Compose (recommended):
```bash ```bash
# Download the compose file docker compose up
curl -O https://raw.githubusercontent.com/xpltdco/media-rip/master/docker-compose.yml
# Start the container
docker compose up -d
```
Or pull and run directly:
```bash
docker run -d \
--name mediarip \
-p 8080:8000 \
-v ./downloads:/downloads \
-v mediarip-data:/data \
--restart unless-stopped \
ghcr.io/xpltdco/media-rip:latest
``` ```
Open [http://localhost:8080](http://localhost:8080) and paste a URL. On first run, you'll set an admin password. Open [http://localhost:8080](http://localhost:8080) and paste a URL. On first run, you'll set an admin password.

View file

@ -1,10 +1,8 @@
"""SQLite database layer with async CRUD operations. """SQLite database layer with WAL mode and async CRUD operations.
Uses aiosqlite for async access. ``init_db`` sets critical PRAGMAs Uses aiosqlite for async access. ``init_db`` sets critical PRAGMAs
(busy_timeout, journal_mode, synchronous) *before* creating any tables so (busy_timeout, WAL, synchronous) *before* creating any tables so that
that concurrent download workers never hit ``SQLITE_BUSY``. WAL mode is concurrent download workers never hit ``SQLITE_BUSY``.
preferred on local filesystems; DELETE mode is used automatically when a
network filesystem (CIFS, NFS) is detected.
""" """
from __future__ import annotations from __future__ import annotations
@ -92,31 +90,19 @@ async def init_db(db_path: str) -> aiosqlite.Connection:
PRAGMA order matters: PRAGMA order matters:
1. ``busy_timeout`` prevents immediate ``SQLITE_BUSY`` on lock contention 1. ``busy_timeout`` prevents immediate ``SQLITE_BUSY`` on lock contention
2. ``journal_mode`` WAL for local filesystems, DELETE for network mounts 2. ``journal_mode=WAL`` enables concurrent readers + single writer
(CIFS/NFS lack the shared-memory primitives WAL requires) 3. ``synchronous=NORMAL`` safe durability level for WAL mode
3. ``synchronous=NORMAL`` safe durability level
Returns the ready-to-use connection. Returns the ready-to-use connection.
""" """
# Detect network filesystem *before* opening the DB so we never attempt
# WAL on CIFS/NFS (which creates broken SHM files that persist).
use_wal = not _is_network_filesystem(db_path)
db = await aiosqlite.connect(db_path) db = await aiosqlite.connect(db_path)
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
# --- PRAGMAs (before any DDL) --- # --- PRAGMAs (before any DDL) ---
await db.execute("PRAGMA busy_timeout = 5000") await db.execute("PRAGMA busy_timeout = 5000")
result = await db.execute("PRAGMA journal_mode = WAL")
if use_wal: row = await result.fetchone()
journal_mode = await _try_journal_mode(db, "wal") journal_mode = row[0] if row else "unknown"
else:
logger.info(
"Network filesystem detected for %s — using DELETE journal mode",
db_path,
)
journal_mode = await _try_journal_mode(db, "delete")
logger.info("journal_mode set to %s", journal_mode) logger.info("journal_mode set to %s", journal_mode)
await db.execute("PRAGMA synchronous = NORMAL") await db.execute("PRAGMA synchronous = NORMAL")
@ -129,54 +115,6 @@ async def init_db(db_path: str) -> aiosqlite.Connection:
return db return db
def _is_network_filesystem(db_path: str) -> bool:
"""Return True if *db_path* resides on a network filesystem (CIFS, NFS, etc.).
Parses ``/proc/mounts`` (Linux) to find the filesystem type of the
longest-prefix mount matching the database directory. Returns False
on non-Linux hosts or if detection fails.
"""
import os
network_fs_types = {"cifs", "nfs", "nfs4", "smb", "smbfs", "9p", "fuse.sshfs"}
try:
db_dir = os.path.dirname(os.path.abspath(db_path))
with open("/proc/mounts", "r") as f:
mounts = f.readlines()
best_match = ""
best_fstype = ""
for line in mounts:
parts = line.split()
if len(parts) < 3:
continue
mountpoint, fstype = parts[1], parts[2]
if db_dir.startswith(mountpoint) and len(mountpoint) > len(best_match):
best_match = mountpoint
best_fstype = fstype
is_net = best_fstype in network_fs_types
if is_net:
logger.info(
"Detected %s filesystem at %s for database %s",
best_fstype, best_match, db_path,
)
return is_net
except Exception:
return False
async def _try_journal_mode(
db: aiosqlite.Connection, mode: str,
) -> str:
"""Try setting *mode* and return the actual journal mode string."""
try:
result = await db.execute(f"PRAGMA journal_mode = {mode}")
row = await result.fetchone()
return (row[0] if row else "unknown").lower()
except Exception as exc:
logger.warning("PRAGMA journal_mode=%s failed: %s", mode, exc)
return "error"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CRUD helpers # CRUD helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -561,31 +561,6 @@ class DownloadService:
url_lower = url.lower() url_lower = url.lower()
return any(domain in url_lower for domain in audio_domains) return any(domain in url_lower for domain in audio_domains)
@staticmethod
def _url_or_ext_implies_video(url: str, ext: str | None) -> bool:
"""Return True if the URL path or reported extension is a known video container.
This acts as a fallback when yt-dlp's extract_flat mode strips codec
metadata (common for archive.org, direct-file URLs, etc.), which would
otherwise cause the UI to wrongly label the source as "audio only".
"""
video_extensions = {
"mp4", "mkv", "webm", "avi", "mov", "flv", "wmv", "mpg",
"mpeg", "m4v", "ts", "3gp", "ogv",
}
# Check the extension reported by yt-dlp
if ext and ext.lower() in video_extensions:
return True
# Check the URL path for a video file extension
from urllib.parse import urlparse
path = urlparse(url).path.lower()
# Strip any trailing slashes / query residue
path = path.rstrip("/")
for vext in video_extensions:
if path.endswith(f".{vext}"):
return True
return False
@staticmethod @staticmethod
def _get_auth_hint(url: str) -> str | None: def _get_auth_hint(url: str) -> str | None:
"""Return a user-facing hint for sites that commonly need auth.""" """Return a user-facing hint for sites that commonly need auth."""
@ -670,27 +645,13 @@ class DownloadService:
"url": e.get("url") or e.get("webpage_url", ""), "url": e.get("url") or e.get("webpage_url", ""),
"duration": e.get("duration"), "duration": e.get("duration"),
}) })
# Domain-based detection may miss video playlists on generic
# hosting sites (e.g. archive.org). If any entry URL looks like
# a video file, override domain_audio for the whole playlist.
playlist_audio = domain_audio
if playlist_audio:
for e_check in entries:
entry_url = e_check.get("url", "")
if self._url_or_ext_implies_video(entry_url, None):
playlist_audio = False
break
if not playlist_audio and not domain_audio:
# Also check the top-level URL itself
if self._url_or_ext_implies_video(url, info.get("ext")):
playlist_audio = False
result = { result = {
"type": "playlist", "type": "playlist",
"title": info.get("title", "Playlist"), "title": info.get("title", "Playlist"),
"count": len(entries), "count": len(entries),
"entries": entries, "entries": entries,
"is_audio_only": playlist_audio, "is_audio_only": domain_audio,
"default_ext": self._guess_ext_from_url(url, playlist_audio), "default_ext": self._guess_ext_from_url(url, domain_audio),
} }
if unavailable_count > 0: if unavailable_count > 0:
result["unavailable_count"] = unavailable_count result["unavailable_count"] = unavailable_count
@ -698,11 +659,6 @@ class DownloadService:
else: else:
# Single video/track # Single video/track
has_video = bool(info.get("vcodec") and info["vcodec"] != "none") has_video = bool(info.get("vcodec") and info["vcodec"] != "none")
# extract_flat mode often strips codec info, so also check the
# URL extension and the reported ext — if either is a known video
# container we should NOT mark it as audio-only.
if not has_video:
has_video = self._url_or_ext_implies_video(url, info.get("ext"))
is_audio_only = domain_audio or not has_video is_audio_only = domain_audio or not has_video
# Detect likely file extension # Detect likely file extension
ext = info.get("ext") ext = info.get("ext")

View file

@ -40,12 +40,6 @@ onMounted(async () => {
opacity: 0.7; opacity: 0.7;
} }
@media (max-width: 767px) {
.app-footer {
padding-bottom: calc(var(--mobile-nav-height) + var(--space-md));
}
}
.sep { .sep {
margin: 0 var(--space-sm); margin: 0 var(--space-sm);
opacity: 0.5; opacity: 0.5;

View file

@ -27,7 +27,6 @@ function goHome(): void {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
width: 100%;
height: var(--header-height); height: var(--header-height);
background: var(--color-surface); background: var(--color-surface);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
@ -45,12 +44,6 @@ function goHome(): void {
align-items: center; align-items: center;
} }
@media (max-width: 767px) {
.header-content {
max-width: none;
}
}
.header-title { .header-title {
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
font-weight: 700; font-weight: 700;

View file

@ -1,26 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useDownloadsStore } from '@/stores/downloads'
import WireframeBackground from './WireframeBackground.vue' import WireframeBackground from './WireframeBackground.vue'
const themeStore = useThemeStore() const themeStore = useThemeStore()
const downloadsStore = useDownloadsStore()
const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk') const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk')
type MobileTab = 'submit' | 'queue' type MobileTab = 'submit' | 'queue'
const activeTab = ref<MobileTab>('submit') const activeTab = ref<MobileTab>('submit')
/** Number of active (non-terminal) jobs — shown as badge on Queue tab */
const queueBadge = computed(() => {
let count = 0
for (const job of downloadsStore.jobs.values()) {
if (job.status === 'queued' || job.status === 'downloading' || job.status === 'extracting') {
count++
}
}
return count
})
</script> </script>
<template> <template>
@ -54,10 +41,7 @@ const queueBadge = computed(() => {
:class="{ active: activeTab === 'queue' }" :class="{ active: activeTab === 'queue' }"
@click="activeTab = 'queue'" @click="activeTab = 'queue'"
> >
<span class="nav-icon-wrap"> <span class="nav-icon"></span>
<span class="nav-icon"></span>
<span v-if="queueBadge > 0 && activeTab !== 'queue'" class="nav-badge">{{ queueBadge > 9 ? '9+' : queueBadge }}</span>
</span>
<span class="nav-label">Queue</span> <span class="nav-label">Queue</span>
</button> </button>
</nav> </nav>
@ -152,26 +136,5 @@ const queueBadge = computed(() => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.nav-icon-wrap {
position: relative;
display: inline-flex;
}
.nav-badge {
position: absolute;
top: -6px;
right: -10px;
background: var(--color-accent);
color: var(--color-bg);
font-size: 0.6rem;
font-weight: 700;
min-width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
border-radius: var(--radius-full);
padding: 0 3px;
}
} }
</style> </style>

View file

@ -144,7 +144,6 @@ function selectFormat(id: string | null): void {
cursor: pointer; cursor: pointer;
min-height: var(--touch-min); min-height: var(--touch-min);
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
gap: var(--space-sm);
} }
.format-option:hover { .format-option:hover {
@ -158,10 +157,6 @@ function selectFormat(id: string | null): void {
.format-label { .format-label {
font-size: var(--font-size-base); font-size: var(--font-size-base);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.format-hint { .format-hint {
@ -170,31 +165,9 @@ function selectFormat(id: string | null): void {
} }
.format-codecs { .format-codecs {
font-size: var(--font-size-xs); font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
font-family: var(--font-mono); font-family: var(--font-mono);
white-space: nowrap;
flex-shrink: 0;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 540px) {
.format-option {
flex-wrap: wrap;
gap: 2px;
}
.format-label {
flex: 1 1 100%;
font-size: var(--font-size-sm);
}
.format-codecs {
font-size: 0.6875rem;
max-width: none;
}
} }
.format-empty { .format-empty {

View file

@ -184,12 +184,10 @@ function onFormatSelect(formatId: string | null): void {
} }
function handlePaste(): void { function handlePaste(): void {
// Immediately signal that analysis is starting prevents the Download
// button from being briefly clickable between paste and analysis.
isAnalyzing.value = true
// Auto-extract on paste unified loading state // Auto-extract on paste unified loading state
setTimeout(async () => { setTimeout(async () => {
if (url.value.trim()) { if (url.value.trim()) {
isAnalyzing.value = true
analyzeError.value = null analyzeError.value = null
startAnalyzePhase() startAnalyzePhase()
try { try {
@ -206,9 +204,6 @@ function handlePaste(): void {
isAnalyzing.value = false isAnalyzing.value = false
stopAnalyzePhase() stopAnalyzePhase()
} }
} else {
// URL was cleared before timeout cancel analysis state
isAnalyzing.value = false
} }
}, 50) }, 50)
} }
@ -299,27 +294,16 @@ function formatTooltip(fmt: string): string {
<template> <template>
<div class="url-input"> <div class="url-input">
<!-- URL field with clear button --> <!-- URL field -->
<div class="url-field-wrap"> <input
<input v-model="url"
v-model="url" type="url"
type="url" placeholder="Paste a URL to download…"
placeholder="Paste a URL to download…" class="url-field"
class="url-field" @paste="handlePaste"
@paste="handlePaste" @keydown.enter="submitDownload"
@keydown.enter="submitDownload" :disabled="isAnalyzing || store.isSubmitting"
:disabled="store.isSubmitting" />
/>
<button
v-if="url.trim()"
class="url-clear"
@click="url = ''"
title="Clear URL"
aria-label="Clear URL"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Action row: gear, media toggle, download button --> <!-- Action row: gear, media toggle, download button -->
<div class="action-row"> <div class="action-row">
@ -467,46 +451,15 @@ function formatTooltip(fmt: string): string {
width: 100%; width: 100%;
} }
.url-field-wrap {
position: relative;
width: 100%;
}
.url-field { .url-field {
width: 100%; width: 100%;
font-size: var(--font-size-base); font-size: var(--font-size-base);
padding-right: 40px; /* room for clear button */
} }
.url-clear { /* Action row: gear | toggle | download */
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
color: var(--color-text-muted);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s ease, color 0.15s ease;
}
.url-clear:hover {
opacity: 1;
color: var(--color-text);
}
/* Action row: gear | toggle | download — all 42px height */
.action-row { .action-row {
display: flex; display: flex;
align-items: stretch; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
} }
@ -514,8 +467,8 @@ function formatTooltip(fmt: string): string {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 42px; width: 38px;
min-height: 42px; height: 38px;
padding: 0; padding: 0;
flex-shrink: 0; flex-shrink: 0;
background: var(--color-surface); background: var(--color-surface);
@ -555,7 +508,7 @@ function formatTooltip(fmt: string): string {
border: none; border: none;
border-radius: 0; border-radius: 0;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
min-height: 42px; min-height: 38px;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@ -582,7 +535,7 @@ function formatTooltip(fmt: string): string {
white-space: nowrap; white-space: nowrap;
padding: var(--space-sm) var(--space-lg); padding: var(--space-sm) var(--space-lg);
font-weight: 600; font-weight: 600;
min-height: 42px; min-height: 38px;
background: var(--color-accent); background: var(--color-accent);
color: var(--color-bg); color: var(--color-bg);
} }
@ -874,8 +827,6 @@ button:disabled {
.toggle-pill { .toggle-pill {
padding: var(--space-xs) var(--space-sm); padding: var(--space-xs) var(--space-sm);
min-width: 42px;
justify-content: center;
} }
} }

View file

@ -115,22 +115,12 @@ export const useThemeStore = defineStore('theme', () => {
} }
/** /**
* Update admin theme config and apply if current mode matches. * Update admin theme config (called after saving settings).
* Called after saving settings gives live preview without page reload.
*/ */
function updateAdminConfig(darkTheme: string, lightTheme: string, defaultMode: 'dark' | 'light'): void { function updateAdminConfig(darkTheme: string, lightTheme: string, defaultMode: 'dark' | 'light'): void {
adminDarkTheme.value = darkTheme adminDarkTheme.value = darkTheme
adminLightTheme.value = lightTheme adminLightTheme.value = lightTheme
adminDefaultMode.value = defaultMode adminDefaultMode.value = defaultMode
// Apply the new theme if user's current mode matches the changed side
if (currentMode.value === 'dark') {
currentTheme.value = darkTheme
_apply(darkTheme)
} else {
currentTheme.value = lightTheme
_apply(lightTheme)
}
} }
/** /**