mirror of
https://github.com/alexta69/metube.git
synced 2025-04-02 20:07:36 +03:00
Added support for yt-dlp 'temp' path
- Added support for yt-dlp emp path - Formatted with Black - Updated README to reflect new TEMP_DIR setting; linted - Modified Dockerfile to strip carriage return characters from docker-entrypoint.sh script to fix building the image on Windows - Added example docker-compose.yml config
This commit is contained in:
parent
17d668a2dd
commit
ccff77647c
6 changed files with 220 additions and 113 deletions
12
Dockerfile
12
Dockerfile
|
@ -10,10 +10,10 @@ FROM python:3.8-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY Pipfile* docker-entrypoint.sh ./
|
COPY Pipfile* .
|
||||||
|
|
||||||
RUN chmod +x docker-entrypoint.sh && \
|
# Install dependencies
|
||||||
apk add --update ffmpeg aria2 coreutils shadow su-exec && \
|
RUN apk add --update ffmpeg aria2 coreutils shadow su-exec && \
|
||||||
apk add --update --virtual .build-deps gcc g++ musl-dev && \
|
apk add --update --virtual .build-deps gcc g++ musl-dev && \
|
||||||
pip install --no-cache-dir pipenv && \
|
pip install --no-cache-dir pipenv && \
|
||||||
pipenv install --system --deploy --clear && \
|
pipenv install --system --deploy --clear && \
|
||||||
|
@ -23,6 +23,11 @@ RUN chmod +x docker-entrypoint.sh && \
|
||||||
mkdir /.cache && chmod 777 /.cache
|
mkdir /.cache && chmod 777 /.cache
|
||||||
|
|
||||||
COPY favicon ./favicon
|
COPY favicon ./favicon
|
||||||
|
COPY docker-entrypoint.sh .
|
||||||
|
|
||||||
|
# Use sed to strip carriage-return characters from the entrypoint script (in case building on Windows)
|
||||||
|
RUN sed -i 's/\r$//g' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY --from=builder /metube/dist/metube ./ui/dist/metube
|
COPY --from=builder /metube/dist/metube ./ui/dist/metube
|
||||||
|
|
||||||
|
@ -32,6 +37,7 @@ ENV UMASK=022
|
||||||
|
|
||||||
ENV DOWNLOAD_DIR /downloads
|
ENV DOWNLOAD_DIR /downloads
|
||||||
ENV STATE_DIR /downloads/.metube
|
ENV STATE_DIR /downloads/.metube
|
||||||
|
ENV TEMP_DIR /tmp
|
||||||
VOLUME /downloads
|
VOLUME /downloads
|
||||||
EXPOSE 8081
|
EXPOSE 8081
|
||||||
CMD [ "./docker-entrypoint.sh" ]
|
CMD [ "./docker-entrypoint.sh" ]
|
||||||
|
|
16
README.md
16
README.md
|
@ -3,7 +3,7 @@
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
Web GUI for youtube-dl (using the [yt-dlp](https://github.com/yt-dlp/yt-dlp) fork) with playlist support. Allows you to download videos from YouTube and dozens of other sites (https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
Web GUI for youtube-dl (using the [yt-dlp](https://github.com/yt-dlp/yt-dlp) fork) with playlist support. Allows you to download videos from YouTube and dozens of other sites (<https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md>).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ Web GUI for youtube-dl (using the [yt-dlp](https://github.com/yt-dlp/yt-dlp) for
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube
|
docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run using docker-compose
|
## Run using docker-compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
@ -40,6 +41,9 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||||
* __CUSTOM_DIRS__: whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a drop-down appears next to the Add button to specify the download directory. Defaults to `true`.
|
* __CUSTOM_DIRS__: whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a drop-down appears next to the Add button to specify the download directory. Defaults to `true`.
|
||||||
* __CREATE_CUSTOM_DIRS__: whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector becomes supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
|
* __CREATE_CUSTOM_DIRS__: whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector becomes supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
|
||||||
* __STATE_DIR__: path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the docker image, and `.` otherwise.
|
* __STATE_DIR__: path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the docker image, and `.` otherwise.
|
||||||
|
* __TEMP_DIR__: path where intermediary download files will be saved. Defaults to `/downloads/.metube` in the docker image, and `.` otherwise.
|
||||||
|
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance
|
||||||
|
* __Note__: Using a RAM filesystem may prevent downloads from being resumed
|
||||||
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
|
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
|
||||||
* __URL_PREFIX__: base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
* __URL_PREFIX__: base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
||||||
* __OUTPUT_TEMPLATE__: the template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
|
* __OUTPUT_TEMPLATE__: the template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
|
||||||
|
@ -47,11 +51,13 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||||
* __YTDL_OPTIONS__: Additional options to pass to youtube-dl, in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L183). They roughly correspond to command-line options, though some do not have exact equivalents here, for example `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores.
|
* __YTDL_OPTIONS__: Additional options to pass to youtube-dl, in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L183). They roughly correspond to command-line options, though some do not have exact equivalents here, for example `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores.
|
||||||
|
|
||||||
The following example value for `YTDL_OPTIONS` embeds English subtitles and chapter markers (for videos that have them), and also changes the permissions on the downloaded video:
|
The following example value for `YTDL_OPTIONS` embeds English subtitles and chapter markers (for videos that have them), and also changes the permissions on the downloaded video:
|
||||||
```
|
|
||||||
|
```json
|
||||||
{"writesubtitles": true, "subtitleslangs": ["en", "-live_chat"], "postprocessors": [{"key": "Exec", "exec_cmd": "chmod 0664", "when": "after_move"}, {"key": "FFmpegEmbedSubtitle", "already_have_subtitle": false}, {"key": "FFmpegMetadata", "add_chapters": true}]}
|
{"writesubtitles": true, "subtitleslangs": ["en", "-live_chat"], "postprocessors": [{"key": "Exec", "exec_cmd": "chmod 0664", "when": "after_move"}, {"key": "FFmpegEmbedSubtitle", "already_have_subtitle": false}, {"key": "FFmpegMetadata", "add_chapters": true}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using browser cookies
|
## Using browser cookies
|
||||||
|
|
||||||
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
|
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
|
||||||
|
|
||||||
* Add the following to your docker-compose.yml:
|
* Add the following to your docker-compose.yml:
|
||||||
|
@ -62,9 +68,10 @@ In case you need to use your browser's cookies with MeTube, for example to downl
|
||||||
environment:
|
environment:
|
||||||
- YTDL_OPTIONS={"cookiefile":"/cookies/cookies.txt"}
|
- YTDL_OPTIONS={"cookiefile":"/cookies/cookies.txt"}
|
||||||
```
|
```
|
||||||
|
|
||||||
* Install in your browser an extension to extract cookies:
|
* Install in your browser an extension to extract cookies:
|
||||||
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
|
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
|
||||||
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||||
* Extract the cookies you need with the extension and rename the file `cookies.txt`
|
* Extract the cookies you need with the extension and rename the file `cookies.txt`
|
||||||
* Drop the file in the folder you configured in the docker-compose.yml above
|
* Drop the file in the folder you configured in the docker-compose.yml above
|
||||||
* Restart the container
|
* Restart the container
|
||||||
|
@ -88,6 +95,7 @@ javascript:!function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.
|
||||||
```
|
```
|
||||||
|
|
||||||
[shoonya75](https://github.com/shoonya75) has contributed a Firefox version:
|
[shoonya75](https://github.com/shoonya75) has contributed a Firefox version:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
javascript:(function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}})();
|
javascript:(function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}})();
|
||||||
```
|
```
|
||||||
|
|
|
@ -17,6 +17,7 @@ class Config:
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
'DOWNLOAD_DIR': '.',
|
'DOWNLOAD_DIR': '.',
|
||||||
'AUDIO_DOWNLOAD_DIR': '%%DOWNLOAD_DIR',
|
'AUDIO_DOWNLOAD_DIR': '%%DOWNLOAD_DIR',
|
||||||
|
'TEMP_DIR': '%%DOWNLOAD_DIR',
|
||||||
'DOWNLOAD_DIRS_INDEXABLE': 'false',
|
'DOWNLOAD_DIRS_INDEXABLE': 'false',
|
||||||
'CUSTOM_DIRS': 'true',
|
'CUSTOM_DIRS': 'true',
|
||||||
'CREATE_CUSTOM_DIRS': 'true',
|
'CREATE_CUSTOM_DIRS': 'true',
|
||||||
|
@ -138,13 +139,13 @@ def get_custom_dirs():
|
||||||
dirs = list(filter(None, map(convert, path.glob('**'))))
|
dirs = list(filter(None, map(convert, path.glob('**'))))
|
||||||
|
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
download_dir = recursive_dirs(config.DOWNLOAD_DIR)
|
download_dir = recursive_dirs(config.DOWNLOAD_DIR)
|
||||||
|
|
||||||
audio_download_dir = download_dir
|
audio_download_dir = download_dir
|
||||||
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
|
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
|
||||||
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
|
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"download_dir": download_dir,
|
"download_dir": download_dir,
|
||||||
"audio_download_dir": audio_download_dir
|
"audio_download_dir": audio_download_dir
|
||||||
|
@ -192,4 +193,5 @@ app.on_response_prepare.append(on_prepare)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
||||||
web.run_app(app, host=config.HOST, port=config.PORT, reuse_port=True)
|
web.run_app(app, host=config.HOST, port=config.PORT, reuse_port=True)
|
||||||
|
|
263
app/ytdl.py
263
app/ytdl.py
|
@ -9,7 +9,8 @@ import logging
|
||||||
import re
|
import re
|
||||||
from dl_formats import get_format, get_opts, AUDIO_FORMATS
|
from dl_formats import get_format, get_opts, AUDIO_FORMATS
|
||||||
|
|
||||||
log = logging.getLogger('ytdl')
|
log = logging.getLogger("ytdl")
|
||||||
|
|
||||||
|
|
||||||
class DownloadQueueNotifier:
|
class DownloadQueueNotifier:
|
||||||
async def added(self, dl):
|
async def added(self, dl):
|
||||||
|
@ -27,10 +28,11 @@ class DownloadQueueNotifier:
|
||||||
async def cleared(self, id):
|
async def cleared(self, id):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class DownloadInfo:
|
class DownloadInfo:
|
||||||
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix):
|
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix):
|
||||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
self.id = id if len(custom_name_prefix) == 0 else f"{custom_name_prefix}.{id}"
|
||||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
self.title = title if len(custom_name_prefix) == 0 else f"{custom_name_prefix}.{title}"
|
||||||
self.url = url
|
self.url = url
|
||||||
self.quality = quality
|
self.quality = quality
|
||||||
self.format = format
|
self.format = format
|
||||||
|
@ -39,11 +41,15 @@ class DownloadInfo:
|
||||||
self.status = self.msg = self.percent = self.speed = self.eta = None
|
self.status = self.msg = self.percent = self.speed = self.eta = None
|
||||||
self.timestamp = time.time_ns()
|
self.timestamp = time.time_ns()
|
||||||
|
|
||||||
|
|
||||||
class Download:
|
class Download:
|
||||||
manager = None
|
manager = None
|
||||||
|
|
||||||
def __init__(self, download_dir, output_template, output_template_chapter, quality, format, ytdl_opts, info):
|
def __init__(
|
||||||
|
self, download_dir, temp_dir, output_template, output_template_chapter, quality, format, ytdl_opts, info
|
||||||
|
):
|
||||||
self.download_dir = download_dir
|
self.download_dir = download_dir
|
||||||
|
self.temp_dir = temp_dir
|
||||||
self.output_template = output_template
|
self.output_template = output_template
|
||||||
self.output_template_chapter = output_template_chapter
|
self.output_template_chapter = output_template_chapter
|
||||||
self.format = get_format(format, quality)
|
self.format = get_format(format, quality)
|
||||||
|
@ -58,40 +64,54 @@ class Download:
|
||||||
|
|
||||||
def _download(self):
|
def _download(self):
|
||||||
try:
|
try:
|
||||||
|
|
||||||
def put_status(st):
|
def put_status(st):
|
||||||
self.status_queue.put({k: v for k, v in st.items() if k in (
|
self.status_queue.put(
|
||||||
'tmpfilename',
|
{
|
||||||
'filename',
|
k: v
|
||||||
'status',
|
for k, v in st.items()
|
||||||
'msg',
|
if k
|
||||||
'total_bytes',
|
in (
|
||||||
'total_bytes_estimate',
|
"tmpfilename",
|
||||||
'downloaded_bytes',
|
"filename",
|
||||||
'speed',
|
"status",
|
||||||
'eta',
|
"msg",
|
||||||
)})
|
"total_bytes",
|
||||||
|
"total_bytes_estimate",
|
||||||
|
"downloaded_bytes",
|
||||||
|
"speed",
|
||||||
|
"eta",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def put_status_postprocessor(d):
|
def put_status_postprocessor(d):
|
||||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
if d["postprocessor"] == "MoveFiles" and d["status"] == "finished":
|
||||||
if '__finaldir' in d['info_dict']:
|
if "__finaldir" in d["info_dict"]:
|
||||||
filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath']))
|
filename = os.path.join(
|
||||||
|
d["info_dict"]["__finaldir"], os.path.basename(d["info_dict"]["filepath"])
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
filename = d['info_dict']['filepath']
|
filename = d["info_dict"]["filepath"]
|
||||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
self.status_queue.put({"status": "finished", "filename": filename})
|
||||||
ret = yt_dlp.YoutubeDL(params={
|
|
||||||
'quiet': True,
|
ret = yt_dlp.YoutubeDL(
|
||||||
'no_color': True,
|
params={
|
||||||
#'skip_download': True,
|
"quiet": True,
|
||||||
'paths': {"home": self.download_dir},
|
"no_color": True,
|
||||||
'outtmpl': { "default": self.output_template, "chapter": self.output_template_chapter },
|
#'skip_download': True,
|
||||||
'format': self.format,
|
"paths": {"home": self.download_dir, "temp": self.temp_dir},
|
||||||
'socket_timeout': 30,
|
"outtmpl": {"default": self.output_template, "chapter": self.output_template_chapter},
|
||||||
'progress_hooks': [put_status],
|
"format": self.format,
|
||||||
'postprocessor_hooks': [put_status_postprocessor],
|
"socket_timeout": 30,
|
||||||
**self.ytdl_opts,
|
"progress_hooks": [put_status],
|
||||||
}).download([self.info.url])
|
"postprocessor_hooks": [put_status_postprocessor],
|
||||||
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
|
**self.ytdl_opts,
|
||||||
|
}
|
||||||
|
).download([self.info.url])
|
||||||
|
self.status_queue.put({"status": "finished" if ret == 0 else "error"})
|
||||||
except yt_dlp.utils.YoutubeDLError as exc:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
self.status_queue.put({'status': 'error', 'msg': str(exc)})
|
self.status_queue.put({"status": "error", "msg": str(exc)})
|
||||||
|
|
||||||
async def start(self, notifier):
|
async def start(self, notifier):
|
||||||
if Download.manager is None:
|
if Download.manager is None:
|
||||||
|
@ -101,7 +121,7 @@ class Download:
|
||||||
self.proc.start()
|
self.proc.start()
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.info.status = 'preparing'
|
self.info.status = "preparing"
|
||||||
await self.notifier.updated(self.info)
|
await self.notifier.updated(self.info)
|
||||||
asyncio.create_task(self.update_status())
|
asyncio.create_task(self.update_status())
|
||||||
return await self.loop.run_in_executor(None, self.proc.join)
|
return await self.loop.run_in_executor(None, self.proc.join)
|
||||||
|
@ -130,65 +150,66 @@ class Download:
|
||||||
status = await self.loop.run_in_executor(None, self.status_queue.get)
|
status = await self.loop.run_in_executor(None, self.status_queue.get)
|
||||||
if status is None:
|
if status is None:
|
||||||
return
|
return
|
||||||
self.tmpfilename = status.get('tmpfilename')
|
self.tmpfilename = status.get("tmpfilename")
|
||||||
if 'filename' in status:
|
if "filename" in status:
|
||||||
self.info.filename = os.path.relpath(status.get('filename'), self.download_dir)
|
self.info.filename = os.path.relpath(status.get("filename"), self.download_dir)
|
||||||
|
|
||||||
# Set correct file extension for thumbnails
|
# Set correct file extension for thumbnails
|
||||||
if(self.info.format == 'thumbnail'):
|
if self.info.format == "thumbnail":
|
||||||
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
self.info.filename = re.sub(r"\.webm$", ".jpg", self.info.filename)
|
||||||
self.info.status = status['status']
|
self.info.status = status["status"]
|
||||||
self.info.msg = status.get('msg')
|
self.info.msg = status.get("msg")
|
||||||
if 'downloaded_bytes' in status:
|
if "downloaded_bytes" in status:
|
||||||
total = status.get('total_bytes') or status.get('total_bytes_estimate')
|
total = status.get("total_bytes") or status.get("total_bytes_estimate")
|
||||||
if total:
|
if total:
|
||||||
self.info.percent = status['downloaded_bytes'] / total * 100
|
self.info.percent = status["downloaded_bytes"] / total * 100
|
||||||
self.info.speed = status.get('speed')
|
self.info.speed = status.get("speed")
|
||||||
self.info.eta = status.get('eta')
|
self.info.eta = status.get("eta")
|
||||||
await self.notifier.updated(self.info)
|
await self.notifier.updated(self.info)
|
||||||
|
|
||||||
|
|
||||||
class PersistentQueue:
|
class PersistentQueue:
|
||||||
def __init__(self, path):
|
def __init__(self, path):
|
||||||
pdir = os.path.dirname(path)
|
pdir = os.path.dirname(path)
|
||||||
if not os.path.isdir(pdir):
|
if not os.path.isdir(pdir):
|
||||||
os.mkdir(pdir)
|
os.mkdir(pdir)
|
||||||
with shelve.open(path, 'c'):
|
with shelve.open(path, "c"):
|
||||||
pass
|
pass
|
||||||
self.path = path
|
self.path = path
|
||||||
self.dict = OrderedDict()
|
self.dict = OrderedDict()
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
for k, v in self.saved_items():
|
for k, v in self.saved_items():
|
||||||
self.dict[k] = Download(None, None, None, None, None, {}, v)
|
self.dict[k] = Download(None, None, None, None, None, None, {}, v)
|
||||||
|
|
||||||
def exists(self, key):
|
def exists(self, key):
|
||||||
return key in self.dict
|
return key in self.dict
|
||||||
|
|
||||||
def get(self, key):
|
def get(self, key):
|
||||||
return self.dict[key]
|
return self.dict[key]
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return self.dict.items()
|
return self.dict.items()
|
||||||
|
|
||||||
def saved_items(self):
|
def saved_items(self):
|
||||||
with shelve.open(self.path, 'r') as shelf:
|
with shelve.open(self.path, "r") as shelf:
|
||||||
return sorted(shelf.items(), key=lambda item: item[1].timestamp)
|
return sorted(shelf.items(), key=lambda item: item[1].timestamp)
|
||||||
|
|
||||||
def put(self, value):
|
def put(self, value):
|
||||||
key = value.info.url
|
key = value.info.url
|
||||||
self.dict[key] = value
|
self.dict[key] = value
|
||||||
with shelve.open(self.path, 'w') as shelf:
|
with shelve.open(self.path, "w") as shelf:
|
||||||
shelf[key] = value.info
|
shelf[key] = value.info
|
||||||
|
|
||||||
def delete(self, key):
|
def delete(self, key):
|
||||||
del self.dict[key]
|
del self.dict[key]
|
||||||
with shelve.open(self.path, 'w') as shelf:
|
with shelve.open(self.path, "w") as shelf:
|
||||||
shelf.pop(key)
|
shelf.pop(key)
|
||||||
|
|
||||||
def next(self):
|
def next(self):
|
||||||
k, v = next(iter(self.dict.items()))
|
k, v = next(iter(self.dict.items()))
|
||||||
return k, v
|
return k, v
|
||||||
|
|
||||||
def empty(self):
|
def empty(self):
|
||||||
return not bool(self.dict)
|
return not bool(self.dict)
|
||||||
|
|
||||||
|
@ -197,10 +218,10 @@ class DownloadQueue:
|
||||||
def __init__(self, config, notifier):
|
def __init__(self, config, notifier):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.queue = PersistentQueue(self.config.STATE_DIR + '/queue')
|
self.queue = PersistentQueue(self.config.STATE_DIR + "/queue")
|
||||||
self.done = PersistentQueue(self.config.STATE_DIR + '/completed')
|
self.done = PersistentQueue(self.config.STATE_DIR + "/completed")
|
||||||
self.done.load()
|
self.done.load()
|
||||||
|
|
||||||
async def __import_queue(self):
|
async def __import_queue(self):
|
||||||
for k, v in self.queue.saved_items():
|
for k, v in self.queue.saved_items():
|
||||||
await self.add(v.url, v.quality, v.format, v.folder, v.custom_name_prefix)
|
await self.add(v.url, v.quality, v.format, v.folder, v.custom_name_prefix)
|
||||||
|
@ -211,119 +232,159 @@ class DownloadQueue:
|
||||||
asyncio.create_task(self.__import_queue())
|
asyncio.create_task(self.__import_queue())
|
||||||
|
|
||||||
def __extract_info(self, url):
|
def __extract_info(self, url):
|
||||||
return yt_dlp.YoutubeDL(params={
|
return yt_dlp.YoutubeDL(
|
||||||
'quiet': True,
|
params={
|
||||||
'no_color': True,
|
"quiet": True,
|
||||||
'extract_flat': True,
|
"no_color": True,
|
||||||
**self.config.YTDL_OPTIONS,
|
"extract_flat": True,
|
||||||
}).extract_info(url, download=False)
|
**self.config.YTDL_OPTIONS,
|
||||||
|
}
|
||||||
|
).extract_info(url, download=False)
|
||||||
|
|
||||||
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, already):
|
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, already):
|
||||||
etype = entry.get('_type') or 'video'
|
etype = entry.get("_type") or "video"
|
||||||
if etype == 'playlist':
|
if etype == "playlist":
|
||||||
entries = entry['entries']
|
entries = entry["entries"]
|
||||||
log.info(f'playlist detected with {len(entries)} entries')
|
log.info(f"playlist detected with {len(entries)} entries")
|
||||||
playlist_index_digits = len(str(len(entries)))
|
playlist_index_digits = len(str(len(entries)))
|
||||||
results = []
|
results = []
|
||||||
for index, etr in enumerate(entries, start=1):
|
for index, etr in enumerate(entries, start=1):
|
||||||
etr["playlist"] = entry["id"]
|
etr["playlist"] = entry["id"]
|
||||||
etr["playlist_index"] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index)
|
etr["playlist_index"] = "{{0:0{0:d}d}}".format(playlist_index_digits).format(index)
|
||||||
for property in ("id", "title", "uploader", "uploader_id"):
|
for property in ("id", "title", "uploader", "uploader_id"):
|
||||||
if property in entry:
|
if property in entry:
|
||||||
etr[f"playlist_{property}"] = entry[property]
|
etr[f"playlist_{property}"] = entry[property]
|
||||||
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, already))
|
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, already))
|
||||||
if any(res['status'] == 'error' for res in results):
|
if any(res["status"] == "error" for res in results):
|
||||||
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
return {
|
||||||
return {'status': 'ok'}
|
"status": "error",
|
||||||
elif etype == 'video' or etype.startswith('url') and 'id' in entry and 'title' in entry:
|
"msg": ", ".join(res["msg"] for res in results if res["status"] == "error" and "msg" in res),
|
||||||
if not self.queue.exists(entry['id']):
|
}
|
||||||
dl = DownloadInfo(entry['id'], entry['title'], entry.get('webpage_url') or entry['url'], quality, format, folder, custom_name_prefix)
|
return {"status": "ok"}
|
||||||
|
elif etype == "video" or etype.startswith("url") and "id" in entry and "title" in entry:
|
||||||
|
if not self.queue.exists(entry["id"]):
|
||||||
|
dl = DownloadInfo(
|
||||||
|
entry["id"],
|
||||||
|
entry["title"],
|
||||||
|
entry.get("webpage_url") or entry["url"],
|
||||||
|
quality,
|
||||||
|
format,
|
||||||
|
folder,
|
||||||
|
custom_name_prefix,
|
||||||
|
)
|
||||||
# Keep consistent with frontend
|
# Keep consistent with frontend
|
||||||
base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR
|
base_directory = (
|
||||||
|
self.config.DOWNLOAD_DIR
|
||||||
|
if (quality != "audio" and format not in AUDIO_FORMATS)
|
||||||
|
else self.config.AUDIO_DOWNLOAD_DIR
|
||||||
|
)
|
||||||
if folder:
|
if folder:
|
||||||
if not self.config.CUSTOM_DIRS:
|
if not self.config.CUSTOM_DIRS:
|
||||||
return {'status': 'error', 'msg': f'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"msg": f"A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.",
|
||||||
|
}
|
||||||
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
||||||
real_base_directory = os.path.realpath(base_directory)
|
real_base_directory = os.path.realpath(base_directory)
|
||||||
if not dldirectory.startswith(real_base_directory):
|
if not dldirectory.startswith(real_base_directory):
|
||||||
return {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the base download directory "{real_base_directory}"'}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"msg": f'Folder "{folder}" must resolve inside the base download directory "{real_base_directory}"',
|
||||||
|
}
|
||||||
if not os.path.isdir(dldirectory):
|
if not os.path.isdir(dldirectory):
|
||||||
if not self.config.CREATE_CUSTOM_DIRS:
|
if not self.config.CREATE_CUSTOM_DIRS:
|
||||||
return {'status': 'error', 'msg': f'Folder "{folder}" for download does not exist inside base directory "{real_base_directory}", and CREATE_CUSTOM_DIRS is not true in the configuration.'}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"msg": f'Folder "{folder}" for download does not exist inside base directory "{real_base_directory}", and CREATE_CUSTOM_DIRS is not true in the configuration.',
|
||||||
|
}
|
||||||
os.makedirs(dldirectory, exist_ok=True)
|
os.makedirs(dldirectory, exist_ok=True)
|
||||||
else:
|
else:
|
||||||
dldirectory = base_directory
|
dldirectory = base_directory
|
||||||
output = self.config.OUTPUT_TEMPLATE if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
|
output = (
|
||||||
|
self.config.OUTPUT_TEMPLATE
|
||||||
|
if len(custom_name_prefix) == 0
|
||||||
|
else f"{custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}"
|
||||||
|
)
|
||||||
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("playlist"):
|
if property.startswith("playlist"):
|
||||||
output = output.replace(f"%({property})s", str(value))
|
output = output.replace(f"%({property})s", str(value))
|
||||||
self.queue.put(Download(dldirectory, output, output_chapter, quality, format, self.config.YTDL_OPTIONS, dl))
|
self.queue.put(
|
||||||
|
Download(
|
||||||
|
dldirectory,
|
||||||
|
self.config.TEMP_DIR,
|
||||||
|
output,
|
||||||
|
output_chapter,
|
||||||
|
quality,
|
||||||
|
format,
|
||||||
|
self.config.YTDL_OPTIONS,
|
||||||
|
dl,
|
||||||
|
)
|
||||||
|
)
|
||||||
self.event.set()
|
self.event.set()
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
return {'status': 'ok'}
|
return {"status": "ok"}
|
||||||
elif etype.startswith('url'):
|
elif etype.startswith("url"):
|
||||||
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, already)
|
return await self.add(entry["url"], quality, format, folder, custom_name_prefix, already)
|
||||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
return {"status": "error", "msg": f'Unsupported resource "{etype}"'}
|
||||||
|
|
||||||
async def add(self, url, quality, format, folder, custom_name_prefix, already=None):
|
async def add(self, url, quality, format, folder, custom_name_prefix, already=None):
|
||||||
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=}')
|
log.info(f"adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=}")
|
||||||
already = set() if already is None else already
|
already = set() if already is None else already
|
||||||
if url in already:
|
if url in already:
|
||||||
log.info('recursion detected, skipping')
|
log.info("recursion detected, skipping")
|
||||||
return {'status': 'ok'}
|
return {"status": "ok"}
|
||||||
else:
|
else:
|
||||||
already.add(url)
|
already.add(url)
|
||||||
try:
|
try:
|
||||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
||||||
except yt_dlp.utils.YoutubeDLError as exc:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
return {'status': 'error', 'msg': str(exc)}
|
return {"status": "error", "msg": str(exc)}
|
||||||
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, already)
|
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, already)
|
||||||
|
|
||||||
async def cancel(self, ids):
|
async def cancel(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.queue.exists(id):
|
if not self.queue.exists(id):
|
||||||
log.warn(f'requested cancel for non-existent download {id}')
|
log.warn(f"requested cancel for non-existent download {id}")
|
||||||
continue
|
continue
|
||||||
if self.queue.get(id).started():
|
if self.queue.get(id).started():
|
||||||
self.queue.get(id).cancel()
|
self.queue.get(id).cancel()
|
||||||
else:
|
else:
|
||||||
self.queue.delete(id)
|
self.queue.delete(id)
|
||||||
await self.notifier.canceled(id)
|
await self.notifier.canceled(id)
|
||||||
return {'status': 'ok'}
|
return {"status": "ok"}
|
||||||
|
|
||||||
async def clear(self, ids):
|
async def clear(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.done.exists(id):
|
if not self.done.exists(id):
|
||||||
log.warn(f'requested delete for non-existent download {id}')
|
log.warn(f"requested delete for non-existent download {id}")
|
||||||
continue
|
continue
|
||||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||||
dl = self.done.get(id)
|
dl = self.done.get(id)
|
||||||
os.remove(os.path.join(dl.download_dir, dl.info.filename))
|
os.remove(os.path.join(dl.download_dir, dl.info.filename))
|
||||||
self.done.delete(id)
|
self.done.delete(id)
|
||||||
await self.notifier.cleared(id)
|
await self.notifier.cleared(id)
|
||||||
return {'status': 'ok'}
|
return {"status": "ok"}
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
return(list((k, v.info) for k, v in self.queue.items()),
|
return (list((k, v.info) for k, v in self.queue.items()), list((k, v.info) for k, v in self.done.items()))
|
||||||
list((k, v.info) for k, v in self.done.items()))
|
|
||||||
|
|
||||||
async def __download(self):
|
async def __download(self):
|
||||||
while True:
|
while True:
|
||||||
while self.queue.empty():
|
while self.queue.empty():
|
||||||
log.info('waiting for item to download')
|
log.info("waiting for item to download")
|
||||||
await self.event.wait()
|
await self.event.wait()
|
||||||
self.event.clear()
|
self.event.clear()
|
||||||
id, entry = self.queue.next()
|
id, entry = self.queue.next()
|
||||||
log.info(f'downloading {entry.info.title}')
|
log.info(f"downloading {entry.info.title}")
|
||||||
await entry.start(self.notifier)
|
await entry.start(self.notifier)
|
||||||
if entry.info.status != 'finished':
|
if entry.info.status != "finished":
|
||||||
if entry.tmpfilename and os.path.isfile(entry.tmpfilename):
|
if entry.tmpfilename and os.path.isfile(entry.tmpfilename):
|
||||||
try:
|
try:
|
||||||
os.remove(entry.tmpfilename)
|
os.remove(entry.tmpfilename)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
entry.info.status = 'error'
|
entry.info.status = "error"
|
||||||
entry.close()
|
entry.close()
|
||||||
if self.queue.exists(id):
|
if self.queue.exists(id):
|
||||||
self.queue.delete(id)
|
self.queue.delete(id)
|
||||||
|
|
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
metube:
|
||||||
|
# Local Build: Use the following to build the Docker image locally
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
image: metube
|
||||||
|
# Official Image: Comment out the lines above and uncomment the following line to use the official image
|
||||||
|
# image: ghcr.io/alexta69/metube
|
||||||
|
container_name: metube
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
|
||||||
|
# Example environment variables
|
||||||
|
environment:
|
||||||
|
# UID: 1000
|
||||||
|
# GID: 1000
|
||||||
|
YTDL_OPTIONS: '{"writesubtitles": true, "subtitleslangs": ["en", "-live_chat"], "postprocessors": [{"key": "FFmpegEmbedSubtitle", "already_have_subtitle": false}, {"key": "FFmpegMetadata", "add_chapters": true}]}'
|
||||||
|
# Configure yt-dlp to download intermediary files to /temp
|
||||||
|
TEMP_DIR: /temp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./downloads:/downloads
|
||||||
|
# Mount /temp using tmpfs
|
||||||
|
# This will store the intermediary files in memory, making downloads faster; however, you won't be able to
|
||||||
|
# resume a download if you stop the container
|
||||||
|
- type: tmpfs
|
||||||
|
target: /temp:rw,nosuid,nodev,exec
|
|
@ -2,15 +2,15 @@
|
||||||
|
|
||||||
echo "Setting umask to ${UMASK}"
|
echo "Setting umask to ${UMASK}"
|
||||||
umask ${UMASK}
|
umask ${UMASK}
|
||||||
echo "Creating download directory ${DOWNLOAD_DIR} and state directory ${STATE_DIR}"
|
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
|
||||||
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}"
|
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||||
|
|
||||||
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
||||||
if [ "${UID}" -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 "Warning: it is not recommended to run as root user, please check your setting of the UID environment variable"
|
||||||
fi
|
fi
|
||||||
echo "Changing ownership of download and state directories to ${UID}:${GID}"
|
echo "Changing ownership of download and state directories to ${UID}:${GID}"
|
||||||
chown -R "${UID}":"${GID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}"
|
chown -R "${UID}":"${GID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||||
echo "Running MeTube as user ${UID}:${GID}"
|
echo "Running MeTube as user ${UID}:${GID}"
|
||||||
su-exec "${UID}":"${GID}" python3 app/main.py
|
su-exec "${UID}":"${GID}" python3 app/main.py
|
||||||
else
|
else
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue