Compare commits

...

10 Commits

Author SHA1 Message Date
tigeren f6a9e6a1dd feat: 支持pornhub的playlist下载 2025-11-16 03:31:54 +00:00
tigeren 424c9c6f3c feat: mediadl已更新至1.1版 2025-11-15 15:42:50 +00:00
tigeren 95f855fd8a feat: 支持cookie发送、保存 2025-11-15 12:03:24 +00:00
tigeren c3fea47248 fix(docker): change default user to root and update entrypoint script
- Set default UID and GID environment variables to 0 (root) in Dockerfile
- Update entrypoint script to reflect running as root is now default behavior
- Remove ownership change commands for directories in entrypoint script
- Log the running user message with updated UID and GID values
- Ensure application runs with su-exec using root user credentials
2025-11-08 09:41:03 +00:00
AutoUpdater 3bef839e18 upgrade yt-dlp from 2025.10.14 to 2025.10.22 2025-10-23 00:08:21 +00:00
AutoUpdater 1b32d49fcf upgrade yt-dlp from 2025.9.26 to 2025.10.14 2025-10-15 00:08:15 +00:00
Alex c4d7dd9948
Merge pull request #789 from alexta69/copilot/fix-32a91bc7-90af-4cfd-a3e1-d04a36659489
Fix AttributeError when serializing objects without __dict__ attribute
2025-10-01 09:01:17 +03:00
copilot-swe-agent[bot] ecfc188388 Make ObjectSerializer handle all iterables including generators
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2025-10-01 05:59:48 +00:00
copilot-swe-agent[bot] 916ed330dd Fix AttributeError in ObjectSerializer by checking for __dict__ attribute
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2025-10-01 05:46:23 +00:00
copilot-swe-agent[bot] 136c722636 Initial plan 2025-10-01 05:39:15 +00:00
9 changed files with 317 additions and 14 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
PYTHONPATH=./app

3
DEPLOY.md Normal file
View File

@ -0,0 +1,3 @@
docker build -t 192.168.2.212:3000/tigeren/metube:1.1 .
docker push 192.168.2.212:3000/tigeren/metube:1.1

View File

@ -26,8 +26,8 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
COPY app ./app
COPY --from=builder /metube/dist/metube ./ui/dist/metube
ENV UID=1000
ENV GID=1000
ENV UID=0
ENV GID=0
ENV UMASK=022
ENV DOWNLOAD_DIR /downloads

View File

@ -14,6 +14,8 @@ import logging
import json
import pathlib
import re
import base64
from urllib.parse import urlparse
from watchfiles import DefaultFilter, Change, awatch
from ytdl import DownloadQueueNotifier, DownloadQueue
@ -30,7 +32,7 @@ class Config:
'CUSTOM_DIRS': 'true',
'CREATE_CUSTOM_DIRS': 'true',
'CUSTOM_DIRS_EXCLUDE_REGEX': r'(^|/)[.@].*$',
'DELETE_FILE_ON_TRASHCAN': 'false',
'DELETE_FILE_ON_TRASHCAN': 'true',
'STATE_DIR': '.',
'URL_PREFIX': '',
'PUBLIC_HOST_URL': 'download/',
@ -115,10 +117,18 @@ config = Config()
class ObjectSerializer(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, object):
# First try to use __dict__ for custom objects
if hasattr(obj, '__dict__'):
return obj.__dict__
else:
return json.JSONEncoder.default(self, obj)
# Convert iterables (generators, dict_items, etc.) to lists
# Exclude strings and bytes which are also iterable
elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)):
try:
return list(obj)
except:
pass
# Fall back to default behavior
return json.JSONEncoder.default(self, obj)
serializer = ObjectSerializer()
app = web.Application()
@ -230,6 +240,93 @@ async def add(request):
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start)
return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'cookie')
async def set_cookie(request):
"""Accept cookie string and save as cookie file for domain"""
log.info("Received request to set cookie")
post = await request.json()
url = post.get('url')
cookie = post.get('cookie')
domain = post.get('domain')
if not cookie:
log.error("Bad request: missing 'cookie'")
raise web.HTTPBadRequest()
# Determine domain from either explicit domain field or URL
if not domain:
if url:
parsed_url = urlparse(url)
domain = parsed_url.netloc
else:
log.error("Bad request: missing both 'url' and 'domain'")
raise web.HTTPBadRequest()
log.info(f"Processing cookie for domain: {domain}")
try:
# Decode base64 cookie if it appears to be encoded
try:
# Check if cookie is base64 encoded
decoded_cookie = base64.b64decode(cookie).decode('utf-8')
log.info(f"Cookie was base64 encoded, decoded successfully")
cookie = decoded_cookie
except Exception as e:
# If decoding fails, assume it's already plain text
log.info(f"Cookie is not base64 encoded or decode failed ({e}), using as-is")
log.debug(f"Cookie content: {cookie[:100]}...") # Log first 100 chars
# Create cookies directory if it doesn't exist
cookies_dir = os.path.join(config.STATE_DIR, 'cookies')
os.makedirs(cookies_dir, exist_ok=True)
# Use domain as filename (sanitized)
safe_domain = domain.replace(':', '_').replace('/', '_')
cookie_file = os.path.join(cookies_dir, f'{safe_domain}.txt')
log.info(f"Writing cookie file to: {cookie_file}")
# Convert cookie string to Netscape cookie file format
with open(cookie_file, 'w') as f:
f.write('# Netscape HTTP Cookie File\n')
f.write(f'# This file was generated by MeTube for {domain}\n')
f.write('# Edit at your own risk.\n\n')
# Parse cookie string (format: "key1=value1; key2=value2; ...")
cookie_count = 0
for cookie_pair in cookie.split(';'):
cookie_pair = cookie_pair.strip()
if '=' in cookie_pair:
key, value = cookie_pair.split('=', 1)
key = key.strip()
value = value.strip()
# Netscape format: domain\tflag\tpath\tsecure\texpiration\tname\tvalue
# domain: .domain.com (with leading dot for all subdomains)
# flag: TRUE (include subdomains)
# path: / (all paths)
# secure: FALSE (http and https)
# expiration: 2147483647 (max 32-bit timestamp - Jan 2038)
# name: cookie name
# value: cookie value
f.write(f'.{domain}\tTRUE\t/\tFALSE\t2147483647\t{key}\t{value}\n')
cookie_count += 1
log.debug(f"Added cookie: {key}={value[:20]}...")
log.info(f"Cookie file created successfully with {cookie_count} cookies at {cookie_file}")
return web.Response(text=serializer.encode({
'status': 'ok',
'cookie_file': cookie_file,
'cookie_count': cookie_count,
'msg': f'Cookie saved successfully for {domain} ({cookie_count} cookies)'
}))
except Exception as e:
log.error(f"Error saving cookie: {str(e)}", exc_info=True)
return web.Response(text=serializer.encode({
'status': 'error',
'msg': f'Failed to save cookie: {str(e)}'
}))
@routes.post(config.URL_PREFIX + 'delete')
async def delete(request):
post = await request.json()
@ -360,7 +457,11 @@ except ValueError as e:
async def add_cors(request):
return web.Response(text=serializer.encode({"status": "ok"}))
async def cookie_cors(request):
return web.Response(text=serializer.encode({"status": "ok"}))
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cookie', cookie_cors)
async def on_prepare(request, response):
if 'Origin' in request.headers:

View File

@ -7,6 +7,7 @@ import asyncio
import multiprocessing
import logging
import re
from urllib.parse import urlparse
import yt_dlp.networking.impersonate
from dl_formats import get_format, get_opts, AUDIO_FORMATS
@ -347,6 +348,51 @@ class DownloadQueue:
if playlist_item_limit > 0:
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
ytdl_options['playlistend'] = playlist_item_limit
# Check if cookie file exists for this domain
parsed_url = urlparse(dl.url)
domain = parsed_url.netloc
log.info(f"[Cookie] Checking for cookie file for domain: {domain}")
cookies_dir = os.path.join(self.config.STATE_DIR, 'cookies')
log.debug(f"[Cookie] Cookies directory: {cookies_dir}")
# Try domain-specific cookie file
safe_domain = domain.replace(':', '_').replace('/', '_')
cookie_file = os.path.join(cookies_dir, f'{safe_domain}.txt')
log.debug(f"[Cookie] Looking for cookie file at: {cookie_file}")
if os.path.exists(cookie_file):
log.info(f"[Cookie] Found cookie file: {cookie_file}")
# Verify file is readable and has content
try:
with open(cookie_file, 'r') as f:
lines = f.readlines()
cookie_lines = [l for l in lines if l.strip() and not l.startswith('#')]
log.info(f"[Cookie] Cookie file contains {len(cookie_lines)} cookie entries")
if len(cookie_lines) == 0:
log.warning(f"[Cookie] Cookie file exists but contains no cookies!")
else:
log.debug(f"[Cookie] First cookie entry: {cookie_lines[0][:50]}...")
except Exception as e:
log.error(f"[Cookie] Error reading cookie file: {e}", exc_info=True)
ytdl_options['cookiefile'] = cookie_file
log.info(f"[Cookie] Configured yt-dlp to use cookiefile: {cookie_file}")
else:
log.info(f"[Cookie] No cookie file found for domain {domain}")
log.debug(f"[Cookie] Checked path: {cookie_file}")
# List available cookie files for debugging
if os.path.exists(cookies_dir):
available_cookies = os.listdir(cookies_dir)
if available_cookies:
log.debug(f"[Cookie] Available cookie files: {available_cookies}")
else:
log.debug(f"[Cookie] Cookies directory is empty")
else:
log.debug(f"[Cookie] Cookies directory does not exist")
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
if auto_start is True:
self.queue.put(download)
@ -376,14 +422,21 @@ class DownloadQueue:
log.debug('Processing as a playlist')
entries = entry['entries']
log.info(f'playlist detected with {len(entries)} entries')
playlist_index_digits = len(str(len(entries)))
results = []
if playlist_item_limit > 0:
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries')
entries = entries[:playlist_item_limit]
# Verify playlist entry has 'id' before using it
playlist_id = entry.get("id", "unknown_playlist")
if "id" not in entry:
log.warning(f"Playlist entry missing 'id' field. Using fallback 'unknown_playlist'. Entry keys: {list(entry.keys())}")
for index, etr in enumerate(entries, start=1):
etr["_type"] = "video"
etr["playlist"] = entry["id"]
etr["playlist"] = playlist_id
etr["playlist_index"] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index)
for property in ("id", "title", "uploader", "uploader_id"):
if property in entry:
@ -394,9 +447,32 @@ class DownloadQueue:
return {'status': 'ok'}
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
log.debug('Processing as a video')
# Extract ID from entry, or derive from URL if missing
video_id = entry.get('id')
if not video_id:
# Try to extract ID from URL (e.g., viewkey parameter or URL path)
video_url = entry.get('url', '')
if 'viewkey=' in video_url:
# Extract viewkey parameter (common in PornHub, etc.)
match = re.search(r'viewkey=([^&]+)', video_url)
if match:
video_id = match.group(1)
log.info(f"Extracted video ID from viewkey: {video_id}")
elif 'webpage_url' in entry:
# Use webpage_url as fallback
video_id = entry['webpage_url']
else:
# Last resort: use the URL itself
video_id = video_url
if not video_id:
log.error(f"Video entry missing 'id' field and could not extract from URL. Entry keys: {list(entry.keys())}")
return {'status': 'error', 'msg': "Video entry missing required 'id' field and URL extraction failed"}
key = entry.get('webpage_url') or entry['url']
if not self.queue.exists(key):
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit)
dl = DownloadInfo(video_id, entry.get('title') or video_id, key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit)
await self.__add_download(dl, auto_start)
return {'status': 'ok'}
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}

104
docker-compose.yml Normal file
View File

@ -0,0 +1,104 @@
version: '3.8'
services:
metube:
build: .
image: metube:latest
container_name: metube
restart: unless-stopped
ports:
- "8081:8081"
volumes:
- ./downloads:/downloads
- ./metube-config:/config
# Optional: mount cookies file for authenticated downloads
# - ./cookies:/cookies:ro
environment:
# Basic configuration
- UID=0
- GID=0
- UMASK=022
# Download directories
- DOWNLOAD_DIR=/downloads
- STATE_DIR=/config
- TEMP_DIR=/downloads
# Download behavior
- DOWNLOAD_MODE=limited
- MAX_CONCURRENT_DOWNLOADS=3
- DELETE_FILE_ON_TRASHCAN=true
# Custom directories
- CUSTOM_DIRS=true
- CREATE_CUSTOM_DIRS=true
- CUSTOM_DIRS_EXCLUDE_REGEX=(^|/)[.@].*$
- DOWNLOAD_DIRS_INDEXABLE=false
# File naming
- OUTPUT_TEMPLATE=%(title)s.%(ext)s
- OUTPUT_TEMPLATE_CHAPTER=%(title)s - %(section_number)s %(section_title)s.%(ext)s
- OUTPUT_TEMPLATE_PLAYLIST=%(playlist_title)s/%(title)s.%(ext)s
# Playlist options
- DEFAULT_OPTION_PLAYLIST_STRICT_MODE=false
- DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT=0
# Web server
- URL_PREFIX=
- PUBLIC_HOST_URL=download/
- PUBLIC_HOST_AUDIO_URL=audio_download/
- HOST=0.0.0.0
- PORT=8081
# Logging
- LOGLEVEL=INFO
- ENABLE_ACCESSLOG=false
# Theme
- DEFAULT_THEME=auto
# Optional: yt-dlp options
# - YTDL_OPTIONS={}
# - YTDL_OPTIONS_FILE=/path/to/ytdl_options.json
# Optional: cookies for authenticated downloads
# - YTDL_OPTIONS={"cookiefile":"/cookies/cookies.txt"}
# Optional: HTTPS configuration
# - HTTPS=true
# - CERTFILE=/ssl/cert.pem
# - KEYFILE=/ssl/key.pem
# Optional: robots.txt
# - ROBOTS_TXT=/app/robots.txt
# Optional: health check
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081/version"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Optional: resource limits
# deploy:
# resources:
# limits:
# cpus: '2.0'
# memory: 2G
# reservations:
# cpus: '0.5'
# memory: 512M
# Optional: networks configuration
# networks:
# default:
# name: metube-network
# Optional: named volumes
# volumes:
# metube-downloads:
# driver: local
# metube-cookies:
# driver: local

View File

@ -7,10 +7,8 @@ mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
if [ "${UID}" -eq 0 ]; then
echo "Warning: it is not recommended to run as root user, please check your setting of the UID environment variable"
echo "Running as root user (UID=0, GID=0) - this is now the default configuration"
fi
echo "Changing ownership of download and state directories to ${UID}:${GID}"
chown -R "${UID}":"${GID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
echo "Running MeTube as user ${UID}:${GID}"
exec su-exec "${UID}":"${GID}" python3 app/main.py
else

20
pyrightconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.13",
"include": ["app"],
"executionEnvironments": [
{
"root": ".",
"pythonVersion": "3.13",
"extraPaths": [".", "app"]
}
],
"typeCheckingMode": "basic",
"reportMissingImports": "warning",
"reportOptionalMemberAccess": "warning",
"reportOptionalContextManager": "warning",
"reportAttributeAccessIssue": "warning",
"reportArgumentType": "warning",
"reportCallIssue": "warning"
}

View File

@ -744,11 +744,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.9.26"
version = "2025.10.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/8f/0daea0feec1ab85e7df85b98ec7cc8c85d706362e80efc5375c7007dc3dc/yt_dlp-2025.9.26.tar.gz", hash = "sha256:c148ae8233ac4ce6c5fbf6f70fcc390f13a00f59da3776d373cf88c5370bda86", size = 3037475, upload-time = "2025-09-26T22:23:42.882Z" }
sdist = { url = "https://files.pythonhosted.org/packages/08/70/cf4bd6c837ab0a709040888caa70d166aa2dfbb5018d1d5c983bf0b50254/yt_dlp-2025.10.22.tar.gz", hash = "sha256:db2d48133222b1d9508c6de757859c24b5cefb9568cf68ccad85dac20b07f77b", size = 3046863, upload-time = "2025-10-22T19:53:19.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/94/18210c5e6a9d7e622a3b3f4a73dde205f7adf0c46b42b27d0da8c6e5c872/yt_dlp-2025.9.26-py3-none-any.whl", hash = "sha256:36f5fbc153600f759abd48d257231f0e0a547a115ac7ffb05d5b64e5c7fdf8a2", size = 3241906, upload-time = "2025-09-26T22:23:39.976Z" },
{ url = "https://files.pythonhosted.org/packages/cc/2a/fd184bf97d570841aa86b4aeb84aee93e7957a34059dafd4982157c10bff/yt_dlp-2025.10.22-py3-none-any.whl", hash = "sha256:9c803a9598859f91d0d5bd3337f1506ecb40bbe97f6efbe93bc4461fed344fb2", size = 3248983, upload-time = "2025-10-22T19:53:16.483Z" },
]
[package.optional-dependencies]