mirror of
https://github.com/alexta69/metube.git
synced 2026-06-15 16:20:06 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a38ba3de0 | |||
| a2740375be | |||
| 2736425e19 | |||
| 0d905c0b61 | |||
| 6de4a56f28 | |||
| 1f4c4df847 | |||
| d211f24e00 | |||
| 13acd5b309 | |||
| fc5f8cf8ca | |||
| 4565d5abb3 | |||
| 54e25484c5 | |||
| 7cfb0c3a1d |
+10
-20
@@ -6,24 +6,6 @@ RUN corepack enable && corepack prepare pnpm --activate
|
|||||||
RUN CI=true pnpm install && pnpm run build
|
RUN CI=true pnpm install && pnpm run build
|
||||||
|
|
||||||
|
|
||||||
FROM rust:1.93-slim AS bgutil-builder
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
curl \
|
|
||||||
ca-certificates \
|
|
||||||
build-essential \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
python3 && \
|
|
||||||
BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
|
|
||||||
curl -L "https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/archive/refs/tags/${BGUTIL_TAG}.tar.gz" \
|
|
||||||
| tar -xz --strip-components=1 && \
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -52,14 +34,22 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
|||||||
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
|
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
|
||||||
uv cache clean && \
|
uv cache clean && \
|
||||||
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
|
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
|
||||||
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \
|
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y v2.7.2 && \
|
||||||
apt-get purge -y --auto-remove build-essential && \
|
apt-get purge -y --auto-remove build-essential && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
mkdir /.cache && chmod 777 /.cache
|
mkdir /.cache && chmod 777 /.cache
|
||||||
|
|
||||||
COPY --from=bgutil-builder /src/target/release/bgutil-pot /usr/local/bin/bgutil-pot
|
ARG TARGETARCH
|
||||||
|
|
||||||
RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
|
RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
|
||||||
|
case "$TARGETARCH" in \
|
||||||
|
amd64) BGUTIL_ARCH="x86_64" ;; \
|
||||||
|
arm64) BGUTIL_ARCH="aarch64" ;; \
|
||||||
|
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
|
||||||
|
esac && \
|
||||||
|
curl -L -o /usr/local/bin/bgutil-pot \
|
||||||
|
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-pot-linux-${BGUTIL_ARCH}" && \
|
||||||
|
chmod +x /usr/local/bin/bgutil-pot && \
|
||||||
PLUGIN_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" && \
|
PLUGIN_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" && \
|
||||||
curl -L -o /tmp/bgutil-ytdlp-pot-provider-rs.zip \
|
curl -L -o /tmp/bgutil-ytdlp-pot-provider-rs.zip \
|
||||||
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-ytdlp-pot-provider-rs.zip" && \
|
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-ytdlp-pot-provider-rs.zip" && \
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
|
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
|
||||||
* __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`.
|
||||||
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
||||||
|
* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled).
|
||||||
|
|
||||||
### 📁 Storage & Directories
|
### 📁 Storage & Directories
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ 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`. Set to empty to use `OUTPUT_TEMPLATE` instead.
|
||||||
* __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.
|
* __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`. Set to empty to use `OUTPUT_TEMPLATE` instead.
|
||||||
* __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.
|
||||||
|
|
||||||
@@ -89,21 +90,13 @@ The project's Wiki contains examples of useful configurations contributed by use
|
|||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- /path/to/cookies:/cookies
|
|
||||||
environment:
|
|
||||||
- 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 save/export them as `cookies.txt`.
|
||||||
* Drop the file in the folder you configured in the docker-compose.yml above
|
* In MeTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
|
||||||
* Restart the container
|
* After upload, the cookie indicator should show as active.
|
||||||
|
* Use **Delete Cookies** in the same section to remove uploaded cookies.
|
||||||
|
|
||||||
## 🔌 Browser extensions
|
## 🔌 Browser extensions
|
||||||
|
|
||||||
|
|||||||
+108
-2
@@ -60,6 +60,7 @@ class Config:
|
|||||||
'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',
|
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
|
||||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
||||||
|
'CLEAR_COMPLETED_AFTER': '0',
|
||||||
'YTDL_OPTIONS': '{}',
|
'YTDL_OPTIONS': '{}',
|
||||||
'YTDL_OPTIONS_FILE': '',
|
'YTDL_OPTIONS_FILE': '',
|
||||||
'ROBOTS_TXT': '',
|
'ROBOTS_TXT': '',
|
||||||
@@ -97,10 +98,42 @@ class Config:
|
|||||||
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
||||||
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
||||||
|
|
||||||
|
self._runtime_overrides = {}
|
||||||
|
|
||||||
success,_ = self.load_ytdl_options()
|
success,_ = self.load_ytdl_options()
|
||||||
if not success:
|
if not success:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
def set_runtime_override(self, key, value):
|
||||||
|
self._runtime_overrides[key] = value
|
||||||
|
self.YTDL_OPTIONS[key] = value
|
||||||
|
|
||||||
|
def remove_runtime_override(self, key):
|
||||||
|
self._runtime_overrides.pop(key, None)
|
||||||
|
self.YTDL_OPTIONS.pop(key, None)
|
||||||
|
|
||||||
|
def _apply_runtime_overrides(self):
|
||||||
|
self.YTDL_OPTIONS.update(self._runtime_overrides)
|
||||||
|
|
||||||
|
# Keys sent to the browser. Sensitive or server-only keys (YTDL_OPTIONS,
|
||||||
|
# paths, TLS config, etc.) are intentionally excluded.
|
||||||
|
_FRONTEND_KEYS = (
|
||||||
|
'CUSTOM_DIRS',
|
||||||
|
'CREATE_CUSTOM_DIRS',
|
||||||
|
'OUTPUT_TEMPLATE_CHAPTER',
|
||||||
|
'PUBLIC_HOST_URL',
|
||||||
|
'PUBLIC_HOST_AUDIO_URL',
|
||||||
|
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
||||||
|
)
|
||||||
|
|
||||||
|
def frontend_safe(self) -> dict:
|
||||||
|
"""Return only the config keys that are safe to expose to browser clients.
|
||||||
|
|
||||||
|
Sensitive or server-only keys (YTDL_OPTIONS, file-system paths, TLS
|
||||||
|
settings, etc.) are intentionally excluded.
|
||||||
|
"""
|
||||||
|
return {k: getattr(self, k) for k in self._FRONTEND_KEYS}
|
||||||
|
|
||||||
def load_ytdl_options(self) -> tuple[bool, str]:
|
def load_ytdl_options(self) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
|
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
|
||||||
@@ -111,6 +144,7 @@ class Config:
|
|||||||
return (False, msg)
|
return (False, msg)
|
||||||
|
|
||||||
if not self.YTDL_OPTIONS_FILE:
|
if not self.YTDL_OPTIONS_FILE:
|
||||||
|
self._apply_runtime_overrides()
|
||||||
return (True, '')
|
return (True, '')
|
||||||
|
|
||||||
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
|
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
|
||||||
@@ -128,6 +162,7 @@ class Config:
|
|||||||
return (False, msg)
|
return (False, msg)
|
||||||
|
|
||||||
self.YTDL_OPTIONS.update(opts)
|
self.YTDL_OPTIONS.update(opts)
|
||||||
|
self._apply_runtime_overrides()
|
||||||
return (True, '')
|
return (True, '')
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
@@ -327,6 +362,65 @@ async def start(request):
|
|||||||
status = await dqueue.start_pending(ids)
|
status = await dqueue.start_pending(ids)
|
||||||
return web.Response(text=serializer.encode(status))
|
return web.Response(text=serializer.encode(status))
|
||||||
|
|
||||||
|
|
||||||
|
COOKIES_PATH = os.path.join(config.STATE_DIR, 'cookies.txt')
|
||||||
|
|
||||||
|
@routes.post(config.URL_PREFIX + 'upload-cookies')
|
||||||
|
async def upload_cookies(request):
|
||||||
|
reader = await request.multipart()
|
||||||
|
field = await reader.next()
|
||||||
|
if field is None or field.name != 'cookies':
|
||||||
|
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'}))
|
||||||
|
size = 0
|
||||||
|
with open(COOKIES_PATH, 'wb') as f:
|
||||||
|
while True:
|
||||||
|
chunk = await field.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > 1_000_000: # 1MB limit
|
||||||
|
os.remove(COOKIES_PATH)
|
||||||
|
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'}))
|
||||||
|
f.write(chunk)
|
||||||
|
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||||
|
log.info(f'Cookies file uploaded ({size} bytes)')
|
||||||
|
return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'}))
|
||||||
|
|
||||||
|
@routes.post(config.URL_PREFIX + 'delete-cookies')
|
||||||
|
async def delete_cookies(request):
|
||||||
|
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
|
||||||
|
configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
|
||||||
|
has_manual_cookiefile = isinstance(configured_cookiefile, str) and configured_cookiefile and configured_cookiefile != COOKIES_PATH
|
||||||
|
|
||||||
|
if not has_uploaded_cookies:
|
||||||
|
if has_manual_cookiefile:
|
||||||
|
return web.Response(
|
||||||
|
status=400,
|
||||||
|
text=serializer.encode({
|
||||||
|
'status': 'error',
|
||||||
|
'msg': 'Cookies are configured manually via YTDL_OPTIONS (cookiefile). Remove or change that setting manually; UI delete only removes uploaded cookies.'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No uploaded cookies to delete'}))
|
||||||
|
|
||||||
|
os.remove(COOKIES_PATH)
|
||||||
|
config.remove_runtime_override('cookiefile')
|
||||||
|
success, msg = config.load_ytdl_options()
|
||||||
|
if not success:
|
||||||
|
log.error(f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}')
|
||||||
|
return web.Response(status=500, text=serializer.encode({'status': 'error', 'msg': f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}'}))
|
||||||
|
|
||||||
|
log.info('Cookies file deleted')
|
||||||
|
return web.Response(text=serializer.encode({'status': 'ok'}))
|
||||||
|
|
||||||
|
@routes.get(config.URL_PREFIX + 'cookie-status')
|
||||||
|
async def cookie_status(request):
|
||||||
|
configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
|
||||||
|
has_configured_cookies = isinstance(configured_cookiefile, str) and os.path.exists(configured_cookiefile)
|
||||||
|
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
|
||||||
|
exists = has_uploaded_cookies or has_configured_cookies
|
||||||
|
return web.Response(text=serializer.encode({'status': 'ok', 'has_cookies': exists}))
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX + 'history')
|
@routes.get(config.URL_PREFIX + 'history')
|
||||||
async def history(request):
|
async def history(request):
|
||||||
history = { 'done': [], 'queue': [], 'pending': []}
|
history = { 'done': [], 'queue': [], 'pending': []}
|
||||||
@@ -345,7 +439,7 @@ async def history(request):
|
|||||||
async def connect(sid, environ):
|
async def connect(sid, environ):
|
||||||
log.info(f"Client connected: {sid}")
|
log.info(f"Client connected: {sid}")
|
||||||
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
||||||
await sio.emit('configuration', serializer.encode(config), to=sid)
|
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
|
||||||
if config.CUSTOM_DIRS:
|
if config.CUSTOM_DIRS:
|
||||||
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
||||||
if config.YTDL_OPTIONS_FILE:
|
if config.YTDL_OPTIONS_FILE:
|
||||||
@@ -373,8 +467,12 @@ def get_custom_dirs():
|
|||||||
else:
|
else:
|
||||||
return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None
|
return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None
|
||||||
|
|
||||||
# Recursively lists all subdirectories of DOWNLOAD_DIR
|
# Recursively lists all subdirectories of DOWNLOAD_DIR.
|
||||||
|
# Always include '' (the base directory itself) even when the
|
||||||
|
# directory is empty or does not yet exist.
|
||||||
dirs = list(filter(include_dir, map(convert, path.glob('**/'))))
|
dirs = list(filter(include_dir, map(convert, path.glob('**/'))))
|
||||||
|
if '' not in dirs:
|
||||||
|
dirs.insert(0, '')
|
||||||
|
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
@@ -439,6 +537,8 @@ async def add_cors(request):
|
|||||||
|
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
|
||||||
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
|
||||||
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
|
||||||
|
|
||||||
async def on_prepare(request, response):
|
async def on_prepare(request, response):
|
||||||
if 'Origin' in request.headers:
|
if 'Origin' in request.headers:
|
||||||
@@ -466,6 +566,12 @@ if __name__ == '__main__':
|
|||||||
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
|
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
|
||||||
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
||||||
|
|
||||||
|
|
||||||
|
# Auto-detect cookie file on startup
|
||||||
|
if os.path.exists(COOKIES_PATH):
|
||||||
|
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||||
|
log.info(f'Cookie file detected at {COOKIES_PATH}')
|
||||||
|
|
||||||
if config.HTTPS:
|
if config.HTTPS:
|
||||||
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||||
ssl_context.load_cert_chain(certfile=config.CERTFILE, keyfile=config.KEYFILE)
|
ssl_context.load_cert_chain(certfile=config.CERTFILE, keyfile=config.KEYFILE)
|
||||||
|
|||||||
+36
-2
@@ -29,6 +29,26 @@ def _compile_outtmpl_pattern(field: str) -> re.Pattern:
|
|||||||
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
|
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
|
||||||
|
|
||||||
|
|
||||||
|
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
||||||
|
# sanitised when substituting playlist/channel titles into output templates so
|
||||||
|
# that downloads do not fail on NTFS-mounted volumes or Windows Docker hosts.
|
||||||
|
_WINDOWS_INVALID_PATH_CHARS = re.compile(r'[\\:*?"<>|]')
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_path_component(value: Any) -> Any:
|
||||||
|
"""Replace characters that are invalid in Windows path components with '_'.
|
||||||
|
|
||||||
|
Non-string values (int, float, None, …) are passed through unchanged so
|
||||||
|
that ``_outtmpl_substitute_field`` can still coerce them with format specs
|
||||||
|
(e.g. ``%(playlist_index)02d``). Only string values are sanitised because
|
||||||
|
Windows-invalid characters are only a concern for human-readable strings
|
||||||
|
(titles, channel names, etc.) that may end up as directory names.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return _WINDOWS_INVALID_PATH_CHARS.sub('_', value)
|
||||||
|
|
||||||
|
|
||||||
def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str:
|
def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str:
|
||||||
"""Substitute a single field in an output template, applying any format specifiers to the value."""
|
"""Substitute a single field in an output template, applying any format specifiers to the value."""
|
||||||
pattern = _compile_outtmpl_pattern(field)
|
pattern = _compile_outtmpl_pattern(field)
|
||||||
@@ -573,6 +593,20 @@ class DownloadQueue:
|
|||||||
else:
|
else:
|
||||||
self.done.put(download)
|
self.done.put(download)
|
||||||
asyncio.create_task(self.notifier.completed(download.info))
|
asyncio.create_task(self.notifier.completed(download.info))
|
||||||
|
try:
|
||||||
|
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
||||||
|
except ValueError:
|
||||||
|
log.error(f'CLEAR_COMPLETED_AFTER is set to an invalid value "{self.config.CLEAR_COMPLETED_AFTER}", expected an integer number of seconds')
|
||||||
|
clear_after = 0
|
||||||
|
if clear_after > 0:
|
||||||
|
task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
|
||||||
|
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
|
||||||
|
|
||||||
|
async def __auto_clear_after_delay(self, url, delay_seconds):
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
if self.done.exists(url):
|
||||||
|
log.debug(f'Auto-clearing completed download: {url}')
|
||||||
|
await self.clear([url])
|
||||||
|
|
||||||
def __extract_info(self, url):
|
def __extract_info(self, url):
|
||||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||||
@@ -617,13 +651,13 @@ class DownloadQueue:
|
|||||||
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("playlist"):
|
if property.startswith("playlist"):
|
||||||
output = _outtmpl_substitute_field(output, property, value)
|
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value))
|
||||||
if entry is not None and entry.get('channel_index') is not None:
|
if entry is not None and entry.get('channel_index') is not None:
|
||||||
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
||||||
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("channel"):
|
if property.startswith("channel"):
|
||||||
output = _outtmpl_substitute_field(output, property, value)
|
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(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:
|
||||||
|
|||||||
+12
-12
@@ -23,14 +23,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.0",
|
"@angular/animations": "^21.2.1",
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.1",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.1",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.1",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.1",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.1",
|
||||||
"@angular/platform-browser-dynamic": "^21.2.0",
|
"@angular/platform-browser-dynamic": "^21.2.1",
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.1",
|
||||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "21.1.0",
|
"@angular-eslint/builder": "21.1.0",
|
||||||
"@angular/build": "^21.2.0",
|
"@angular/build": "^21.2.1",
|
||||||
"@angular/cli": "^21.2.0",
|
"@angular/cli": "^21.2.1",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.1",
|
||||||
"@angular/localize": "^21.2.0",
|
"@angular/localize": "^21.2.1",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"angular-eslint": "21.1.0",
|
"angular-eslint": "21.1.0",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.3",
|
||||||
|
|||||||
Generated
+298
-304
File diff suppressed because it is too large
Load Diff
+63
-22
@@ -308,30 +308,71 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
<div class="row g-2">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<button type="button"
|
<div class="action-group-label">Cookies</div>
|
||||||
class="btn btn-secondary w-100"
|
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
|
||||||
(click)="openBatchImportModal()">
|
(change)="onCookieFileSelect($event)"
|
||||||
<fa-icon [icon]="faFileImport" class="me-2" />
|
[disabled]="cookieUploadInProgress || addInProgress">
|
||||||
Import URLs
|
<div class="btn-group w-100" role="group">
|
||||||
</button>
|
<label class="btn mb-0"
|
||||||
|
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
||||||
|
[class.disabled]="cookieUploadInProgress || addInProgress"
|
||||||
|
for="cookie-upload"
|
||||||
|
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
|
||||||
|
@if (cookieUploadInProgress) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon [icon]="faUpload" class="me-2" />
|
||||||
|
}
|
||||||
|
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
|
||||||
|
</label>
|
||||||
|
@if (hasCookies) {
|
||||||
|
<button type="button" class="btn btn-outline-danger"
|
||||||
|
(click)="deleteCookies()"
|
||||||
|
[disabled]="cookieUploadInProgress || addInProgress"
|
||||||
|
ngbTooltip="Remove uploaded cookies">
|
||||||
|
<fa-icon [icon]="faTrashAlt" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="cookie-status" [class.active]="hasCookies">
|
||||||
|
@if (hasCookies) {
|
||||||
|
<fa-icon [icon]="faCheckCircle" class="me-1" />
|
||||||
|
Cookies active
|
||||||
|
} @else {
|
||||||
|
No cookies configured
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-8">
|
||||||
<button type="button"
|
<div class="action-group-label">Bulk Actions</div>
|
||||||
class="btn btn-secondary w-100"
|
<div class="row g-2">
|
||||||
(click)="exportBatchUrls('all')">
|
<div class="col-4">
|
||||||
<fa-icon [icon]="faFileExport" class="me-2" />
|
<button type="button"
|
||||||
Export URLs
|
class="btn btn-secondary w-100"
|
||||||
</button>
|
(click)="openBatchImportModal()">
|
||||||
</div>
|
<fa-icon [icon]="faFileImport" class="me-2" />
|
||||||
<div class="col-md-4">
|
Import URLs
|
||||||
<button type="button"
|
</button>
|
||||||
class="btn btn-secondary w-100"
|
</div>
|
||||||
(click)="copyBatchUrls('all')">
|
<div class="col-4">
|
||||||
<fa-icon [icon]="faCopy" class="me-2" />
|
<button type="button"
|
||||||
Copy URLs
|
class="btn btn-secondary w-100"
|
||||||
</button>
|
(click)="exportBatchUrls('all')">
|
||||||
|
<fa-icon [icon]="faFileExport" class="me-2" />
|
||||||
|
Export URLs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-secondary w-100"
|
||||||
|
(click)="copyBatchUrls('all')">
|
||||||
|
<fa-icon [icon]="faCopy" class="me-2" />
|
||||||
|
Copy URLs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -209,3 +209,48 @@ main
|
|||||||
|
|
||||||
span
|
span
|
||||||
white-space: nowrap
|
white-space: nowrap
|
||||||
|
|
||||||
|
.cookie-btn
|
||||||
|
flex: 1 1 auto
|
||||||
|
background-color: var(--bs-secondary-bg)
|
||||||
|
border-color: var(--bs-border-color)
|
||||||
|
color: var(--bs-emphasis-color)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: var(--bs-tertiary-bg)
|
||||||
|
border-color: var(--bs-secondary)
|
||||||
|
color: var(--bs-emphasis-color)
|
||||||
|
|
||||||
|
&.disabled
|
||||||
|
opacity: 0.65
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
.cookie-active-btn
|
||||||
|
flex: 1 1 auto
|
||||||
|
background-color: var(--bs-success-bg-subtle)
|
||||||
|
border-color: var(--bs-success-border-subtle)
|
||||||
|
color: var(--bs-success-text-emphasis)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: var(--bs-success-bg-subtle)
|
||||||
|
border-color: var(--bs-success)
|
||||||
|
color: var(--bs-success-text-emphasis)
|
||||||
|
|
||||||
|
&.disabled
|
||||||
|
opacity: 0.65
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
.action-group-label
|
||||||
|
font-size: 0.7rem
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: 0.05em
|
||||||
|
color: var(--bs-secondary-color)
|
||||||
|
margin-bottom: 0.4rem
|
||||||
|
|
||||||
|
.cookie-status
|
||||||
|
font-size: 0.8rem
|
||||||
|
margin-top: 0.35rem
|
||||||
|
color: var(--bs-secondary-color)
|
||||||
|
|
||||||
|
&.active
|
||||||
|
color: var(--bs-success-text-emphasis)
|
||||||
|
|||||||
+75
-1
@@ -6,7 +6,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faUpload } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
import { DownloadsService } from './services/downloads.service';
|
import { DownloadsService } from './services/downloads.service';
|
||||||
@@ -54,6 +54,8 @@ export class App implements AfterViewInit, OnInit {
|
|||||||
subtitleMode: string;
|
subtitleMode: string;
|
||||||
addInProgress = false;
|
addInProgress = false;
|
||||||
cancelRequested = false;
|
cancelRequested = false;
|
||||||
|
hasCookies = false;
|
||||||
|
cookieUploadInProgress = false;
|
||||||
themes: Theme[] = Themes;
|
themes: Theme[] = Themes;
|
||||||
activeTheme: Theme | undefined;
|
activeTheme: Theme | undefined;
|
||||||
customDirs$!: Observable<string[]>;
|
customDirs$!: Observable<string[]>;
|
||||||
@@ -108,6 +110,7 @@ export class App implements AfterViewInit, OnInit {
|
|||||||
faSortAmountDown = faSortAmountDown;
|
faSortAmountDown = faSortAmountDown;
|
||||||
faSortAmountUp = faSortAmountUp;
|
faSortAmountUp = faSortAmountUp;
|
||||||
faChevronRight = faChevronRight;
|
faChevronRight = faChevronRight;
|
||||||
|
faUpload = faUpload;
|
||||||
subtitleFormats = [
|
subtitleFormats = [
|
||||||
{ id: 'srt', text: 'SRT' },
|
{ id: 'srt', text: 'SRT' },
|
||||||
{ id: 'txt', text: 'TXT (Text only)' },
|
{ id: 'txt', text: 'TXT (Text only)' },
|
||||||
@@ -205,6 +208,9 @@ export class App implements AfterViewInit, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
this.downloads.getCookieStatus().subscribe(data => {
|
||||||
|
this.hasCookies = data?.has_cookies || false;
|
||||||
|
});
|
||||||
this.getConfiguration();
|
this.getConfiguration();
|
||||||
this.getYtdlOptionsUpdateTime();
|
this.getYtdlOptionsUpdateTime();
|
||||||
this.customDirs$ = this.getMatchingCustomDir();
|
this.customDirs$ = this.getMatchingCustomDir();
|
||||||
@@ -782,6 +788,74 @@ export class App implements AfterViewInit, OnInit {
|
|||||||
return this.expandedErrors.has(id);
|
return this.expandedErrors.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCookieFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (!input.files?.length) return;
|
||||||
|
this.cookieUploadInProgress = true;
|
||||||
|
this.downloads.uploadCookies(input.files[0]).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response?.status === 'ok') {
|
||||||
|
this.hasCookies = true;
|
||||||
|
} else {
|
||||||
|
this.refreshCookieStatus();
|
||||||
|
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||||
|
}
|
||||||
|
this.cookieUploadInProgress = false;
|
||||||
|
input.value = '';
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.refreshCookieStatus();
|
||||||
|
this.cookieUploadInProgress = false;
|
||||||
|
input.value = '';
|
||||||
|
alert('Error uploading cookies.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatErrorMessage(error: unknown): string {
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
|
const obj = error as Record<string, unknown>;
|
||||||
|
for (const key of ['msg', 'reason', 'error', 'detail']) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(error);
|
||||||
|
} catch {
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCookies() {
|
||||||
|
this.downloads.deleteCookies().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response?.status === 'ok') {
|
||||||
|
this.refreshCookieStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.refreshCookieStatus();
|
||||||
|
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.refreshCookieStatus();
|
||||||
|
alert('Error deleting cookies.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshCookieStatus() {
|
||||||
|
this.downloads.getCookieStatus().subscribe(data => {
|
||||||
|
this.hasCookies = data?.has_cookies || false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private updateMetrics() {
|
private updateMetrics() {
|
||||||
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length;
|
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length;
|
||||||
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;
|
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;
|
||||||
|
|||||||
@@ -213,4 +213,24 @@ export class DownloadsService {
|
|||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploadCookies(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('cookies', file);
|
||||||
|
return this.http.post<any>('upload-cookies', formData).pipe(
|
||||||
|
catchError(this.handleHTTPError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCookies() {
|
||||||
|
return this.http.post<any>('delete-cookies', {}).pipe(
|
||||||
|
catchError(this.handleHTTPError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookieStatus() {
|
||||||
|
return this.http.get<any>('cookie-status').pipe(
|
||||||
|
catchError(this.handleHTTPError)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user