4 Commits

Author SHA1 Message Date
Alex Shnitman
79d0c3895e consolidate adding logic 2026-02-07 17:33:48 +02:00
Adam Fendley
ffe1112dc6 Fix __add_entry parameters; remove duplicated code between channel and playlist processing 2026-02-07 17:30:40 +02:00
Adam Fendley
393add34b1 Add support for downloading an entire channel 2026-02-07 17:30:40 +02:00
Alex Shnitman
96e1863a68 change UID/GID to PUID/PGID; legacy name also supported (#889) 2026-02-06 15:31:01 +02:00
6 changed files with 44 additions and 33 deletions

View File

@@ -69,8 +69,8 @@ RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com
COPY app ./app COPY app ./app
COPY --from=builder /metube/dist/metube ./ui/dist/metube COPY --from=builder /metube/dist/metube ./ui/dist/metube
ENV UID=1000 ENV PUID=1000
ENV GID=1000 ENV PGID=1000
ENV UMASK=022 ENV UMASK=022
ENV DOWNLOAD_DIR /downloads ENV DOWNLOAD_DIR /downloads

View File

@@ -56,6 +56,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __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`.
* __OUTPUT_TEMPLATE_CHAPTER__: The template for the filenames of the downloaded videos when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`. * __OUTPUT_TEMPLATE_CHAPTER__: The template for the filenames of the downloaded videos when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`.
* __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used. * __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used.
* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. When empty, then `OUTPUT_TEMPLATE` is used.
* __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). 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. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`. * __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). 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. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`.
* __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected.
@@ -73,8 +74,8 @@ Certain values can be set via environment variables, using the `-e` parameter on
### 🏠 Basic Setup ### 🏠 Basic Setup
* __UID__: User under which MeTube will run. Defaults to `1000`. * __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported).
* __GID__: Group under which MeTube will run. Defaults to `1000`. * __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported).
* __UMASK__: Umask value used by MeTube. Defaults to `022`. * __UMASK__: Umask value used by MeTube. Defaults to `022`.
* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`. * __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`.
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`. * __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.

View File

@@ -58,6 +58,7 @@ class Config:
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s', 'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s',
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
'YTDL_OPTIONS': '{}', 'YTDL_OPTIONS': '{}',
'YTDL_OPTIONS_FILE': '', 'YTDL_OPTIONS_FILE': '',

View File

@@ -452,6 +452,12 @@ class DownloadQueue:
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))
if entry is not None and 'channel' in entry and entry['channel'] is not None:
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
output = self.config.OUTPUT_TEMPLATE_CHANNEL
for property, value in entry.items():
if property.startswith("channel"):
output = output.replace(f"%({property})s", str(value))
ytdl_options = dict(self.config.YTDL_OPTIONS) ytdl_options = dict(self.config.YTDL_OPTIONS)
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0) playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
if playlist_item_limit > 0: if playlist_item_limit > 0:
@@ -480,27 +486,27 @@ class DownloadQueue:
etype = entry.get('_type') or 'video' etype = entry.get('_type') or 'video'
if etype.startswith('url'): if etype.startswith('url'):
log.debug('Processing as an url') log.debug('Processing as a url')
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already) return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)
elif etype == 'playlist': elif etype == 'playlist' or etype == 'channel':
log.debug('Processing as a playlist') log.debug(f'Processing as a {etype}')
entries = entry['entries'] entries = entry['entries']
# Convert generator to list if needed (for len() and slicing operations) # Convert generator to list if needed (for len() and slicing operations)
if isinstance(entries, types.GeneratorType): if isinstance(entries, types.GeneratorType):
entries = list(entries) entries = list(entries)
log.info(f'playlist detected with {len(entries)} entries') log.info(f'{etype} detected with {len(entries)} entries')
playlist_index_digits = len(str(len(entries))) index_digits = len(str(len(entries)))
results = [] results = []
if playlist_item_limit > 0: if playlist_item_limit > 0:
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries') log.info(f'Item limit is set. Processing only first {playlist_item_limit} entries')
entries = entries[:playlist_item_limit] entries = entries[:playlist_item_limit]
for index, etr in enumerate(entries, start=1): for index, etr in enumerate(entries, start=1):
etr["_type"] = "video" etr["_type"] = "video"
etr["playlist"] = entry["id"] etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
etr["playlist_index"] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index) etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(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"{etype}_{property}"] = entry[property]
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)) results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, 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 {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}

View File

@@ -1,22 +1,25 @@
#!/bin/sh #!/bin/sh
PUID="${UID:-$PUID}"
PGID="${GID:-$PGID}"
echo "Setting umask to ${UMASK}" echo "Setting umask to ${UMASK}"
umask ${UMASK} umask ${UMASK}
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})" echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_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 [ "${PUID}" -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 PUID/PGID (or legacy UID/GID) environment variables"
fi fi
if [ "${CHOWN_DIRS:-true}" != "false" ]; then if [ "${CHOWN_DIRS:-true}" != "false" ]; then
echo "Changing ownership of download and state directories to ${UID}:${GID}" echo "Changing ownership of download and state directories to ${PUID}:${PGID}"
chown -R "${UID}":"${GID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
fi fi
echo "Starting BgUtils POT Provider" echo "Starting BgUtils POT Provider"
gosu "${UID}":"${GID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 & gosu "${PUID}":"${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
echo "Running MeTube as user ${UID}:${GID}" echo "Running MeTube as user ${PUID}:${PGID}"
exec gosu "${UID}":"${GID}" python3 app/main.py exec gosu "${PUID}":"${PGID}" python3 app/main.py
else else
echo "User set by docker; running MeTube as `id -u`:`id -g`" echo "User set by docker; running MeTube as `id -u`:`id -g`"
echo "Starting BgUtils POT Provider" echo "Starting BgUtils POT Provider"

View File

@@ -94,7 +94,7 @@
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
class="form-control form-control-lg" class="form-control form-control-lg"
placeholder="Enter video or playlist URL" placeholder="Enter video, channel, or playlist URL"
name="addUrl" name="addUrl"
[(ngModel)]="addUrl" [(ngModel)]="addUrl"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@@ -215,7 +215,7 @@
(keydown)="isNumber($event)" (keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit" [(ngModel)]="playlistItemLimit"
[disabled]="addInProgress || downloads.loading" [disabled]="addInProgress || downloads.loading"
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)"> ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">