Merge pull request #304 from arabcoders/dev

Use simver starting from this pull request.
This commit is contained in:
Abdulmohsen 2025-06-18 22:19:43 +03:00 committed by GitHub
commit 6e05efb146
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 515 additions and 292 deletions

107
.github/scripts/generate_changelog.py vendored Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
import argparse
import json
import sys
from datetime import UTC, datetime
try:
import git # type: ignore
except ImportError:
print("Please install GitPython: pip install GitPython", file=sys.stderr) # noqa: T201
sys.exit(1)
def get_sorted_tags(repo):
"""Returns all tags sorted by their commit date (newest first)."""
return sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
def get_commits_between(repo, start, end):
"""Return commits between two refs (no merges)."""
return list(repo.iter_commits(f"{start}..{end}", no_merges=True))
def generate_changelog(repo_path, changelog_path):
repo = git.Repo(repo_path)
tags = get_sorted_tags(repo)
changelog = []
if not tags:
start = repo.commit(repo.git.rev_list("--max-parents=0", "HEAD"))
commits = get_commits_between(repo, start.hexsha, "HEAD")
changelog.append(
{
"tag": "Initial Release",
"date": datetime.now(UTC).isoformat(),
"commits": [
{
"sha": c.hexsha[:8],
"full_sha": c.hexsha,
"message": c.message.strip(),
"author": c.author.name,
"date": c.committed_datetime.astimezone(UTC).isoformat(),
}
for c in commits
],
}
)
else:
for i in range(len(tags) - 1):
newer, older = tags[i], tags[i + 1]
commits = get_commits_between(repo, older.commit.hexsha, newer.commit.hexsha)
if not commits:
continue
changelog.append(
{
"tag": newer.name,
"full_sha": newer.commit.hexsha,
"date": newer.commit.committed_datetime.astimezone(UTC).isoformat(),
"commits": [
{
"sha": c.hexsha[:8],
"full_sha": c.hexsha,
"message": c.message.strip(),
"author": c.author.name,
"date": c.committed_datetime.astimezone(UTC).isoformat(),
}
for c in commits
],
}
)
# Add HEAD -> latest tag if ahead
head = repo.head.commit
if head.hexsha != tags[0].commit.hexsha:
commits = get_commits_between(repo, tags[0].commit.hexsha, head.hexsha)
if commits:
changelog.insert(
0,
{
"tag": f"Unreleased ({head.hexsha[:8]})",
"full_sha": head.hexsha,
"date": head.committed_datetime.astimezone(UTC).isoformat(),
"commits": [
{
"sha": c.hexsha[:8],
"full_sha": c.hexsha,
"message": c.message.strip(),
"author": c.author.name,
"date": c.committed_datetime.astimezone(UTC).isoformat(),
}
for c in commits
],
},
)
with open(changelog_path, "w", encoding="utf-8") as f:
json.dump(changelog, f, indent=2)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate CHANGELOG.json from Git tags.")
parser.add_argument("--repo_path", "-p", default=".", help="Path to git repo")
parser.add_argument("--changelog_path", "-f", default="CHANGELOG.json", help="Output file path")
args = parser.parse_args()
generate_changelog(args.repo_path, args.changelog_path)

View file

@ -22,16 +22,13 @@ on:
required: false required: false
default: false default: false
type: boolean type: boolean
create_release:
description: "Create Release (requires build)"
required: false
default: false
type: boolean
push: push:
branches: branches:
- dev - dev
- master - master
tags:
- "v*"
paths-ignore: paths-ignore:
- "**.md" - "**.md"
- ".github/**" - ".github/**"
@ -96,11 +93,13 @@ jobs:
packages: write packages: write
contents: write contents: write
env: env:
PLATFORMS: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && 'linux/amd64,linux/arm64' || 'linux/amd64' }} PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@ -120,13 +119,30 @@ jobs:
pnpm install --production --prefer-offline --frozen-lockfile pnpm install --production --prefer-offline --frozen-lockfile
pnpm run generate pnpm run generate
- name: Update Version File - name: Install GitPython
uses: ArabCoders/write-version-to-file@master run: pip install gitpython
with:
filename: "/app/library/version.py" - name: Generate CHANGELOG.json
placeholder: "dev-master" run: |
with_date: "true" python3 .github/scripts/generate_changelog.py -p . -f ./ui/exported/CHANGELOG.json
with_branch: "true"
- name: Update app/library/version.py
run: |
VERSION="${GITHUB_REF##*/}"
SHA=$(git rev-parse HEAD)
DATE=$(date -u +"%Y%m%d")
echo "Current version: ${VERSION}, SHA: ${SHA}, Date: ${DATE}"
echo "APP_VERSION=${VERSION}" >> $"$GITHUB_ENV"
echo "APP_SHA=${SHA}" >> "$GITHUB_ENV"
echo "APP_DATE=${DATE}" >> "$GITHUB_ENV"
sed -i \
-e "s/^APP_VERSION = \".*\"/APP_VERSION = \"${VERSION}\"/" \
-e "s/^APP_COMMIT_SHA = \".*\"/APP_COMMIT_SHA = \"${SHA}\"/" \
-e "s/^APP_BUILD_DATE = \".*\"/APP_BUILD_DATE = \"${DATE}\"/" \
app/library/version.py
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -142,10 +158,9 @@ jobs:
${{ env.DOCKERHUB_SLUG }} ${{ env.DOCKERHUB_SLUG }}
${{ env.GHCR_SLUG }} ${{ env.GHCR_SLUG }}
tags: | tags: |
type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=branch type=ref,event=branch
type=ref,event=tag type=ref,event=tag
type=raw,value={{branch}}{{base_ref}}-{{date 'YYYYMMDD'}}-{{sha}}
flavor: | flavor: |
latest=false latest=false
@ -173,13 +188,49 @@ jobs:
cache-from: type=gha, scope=${{ github.workflow }} cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, mode=max, scope=${{ github.workflow }} cache-to: type=gha, mode=max, scope=${{ github.workflow }}
- name: Version tag - name: Overwrite GitHub release notes
uses: arabcoders/action-python-autotagger@master if: startsWith(github.ref, 'refs/tags/v')
uses: actions/github-script@v6
with: with:
token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
repo_name: arabcoders/ytptube script: |
path: app/library/version.py const tag = context.ref.replace('refs/tags/', '');
regex: 'APP_VERSION\s=\s\"(.+)\"'
const latestRelease = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
});
const release = latestRelease.data.find(r => r.tag_name === tag);
if (!release) {
core.setFailed(`Release with tag ${tag} not found`);
return;
}
const { data: commits } = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: latestRelease.data[1]?.tag_name || '', // fallback to second latest tag
head: tag,
});
const changelog = commits.commits.map(
c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]} by ${c.commit.author.name}`
).join('\n');
if (!changelog) {
core.setFailed('No commits found for the changelog');
return;
}
console.log(`Changelog for ${tag}:\n${changelog}`);
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
body: changelog
});
dockerhub-sync-readme: dockerhub-sync-readme:
runs-on: ubuntu-latest runs-on: ubuntu-latest

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# compiled output # compiled output
/frontend/dist /frontend/dist
/ui/dist /ui/dist
/ui/exported/CHANGELOG.json
# dependencies # dependencies
**/node_modules/* **/node_modules/*

View file

@ -76,6 +76,7 @@
"socketio", "socketio",
"timespec", "timespec",
"tmpfilename", "tmpfilename",
"ungroup",
"upgrader", "upgrader",
"urandom", "urandom",
"urlsafe", "urlsafe",

View file

@ -274,42 +274,44 @@ If you feel like donating and appreciate my work, you can do so by donating to c
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in `compose.yaml` file. Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in `compose.yaml` file.
| Environment Variable | Description | Default | | Environment Variable | Description | Default |
| ------------------------- | ------------------------------------------------------------------ | ---------------------------------- | | ------------------------------ | ------------------------------------------------------------------ | ---------------------------------- |
| YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` | | YTP_OUTPUT_TEMPLATE | The template for the filenames of the downloaded videos | `%(title)s.%(ext)s` |
| YTP_DEFAULT_PRESET | The default preset to use for the download | `default` | | YTP_DEFAULT_PRESET | The default preset to use for the download | `default` |
| YTP_INSTANCE_TITLE | The title of the instance | `empty string` | | YTP_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` | | YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` | | YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` | | YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` | | YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` | | YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `false` | | YTP_CONSOLE_ENABLED | Whether to enable the console | `false` |
| YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` | | YTP_REMOVE_FILES | Remove the actual file when clicking the remove button | `false` |
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` | | YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` | | YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` | | YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` | | YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` | | YTP_YTDL_DEBUG | Whether to turn debug logging for the internal `yt-dlp` package | `false` |
| YTP_HOST | Which IP address to bind to | `0.0.0.0` | | YTP_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_PORT | Which port to bind to | `8081` | | YTP_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` | | YTP_LOG_LEVEL | Log level | `info` |
| YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` | | YTP_STREAMER_VCODEC | The video codec to use for in-browser streaming | `libx264` |
| YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` | | YTP_STREAMER_ACODEC | The audio codec to use for in-browser streaming | `aac` |
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` | | YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` | | YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` | | YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` | | YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` | | YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` | | YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` | | YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` | | YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` | | YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` | | YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` | | YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` | | YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` |
| YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` | | YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` | | YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` |
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` | | YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` | | YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer. | `*15 */1 * * *` |
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time. | `1` |

View file

@ -202,7 +202,55 @@ class DownloadQueue(metaclass=Singleton):
LOG.error(f"Failed to cancel downloads. {e!s}") LOG.error(f"Failed to cancel downloads. {e!s}")
async def _process_playlist(self, entry: dict, item: Item, already=None): async def _process_playlist(self, entry: dict, item: Item, already=None):
LOG.info(f"Processing playlist '{entry.get('id')}: {entry.get('title')}'.") if 1 == self.config.playlist_items_concurrency:
return await self._process_playlist_old(entry=entry, item=item, already=already)
LOG.info(f"Playlist '{entry.get('id')}: {entry.get('title')}' processing.")
entries = entry.get("entries", [])
playlistCount = int(entry.get("playlist_count", len(entries)))
results = []
semaphore = asyncio.Semaphore(self.config.playlist_items_concurrency)
async def process_entry(i, etr):
extras = {
"playlist": entry.get("id"),
"playlist_index": f"{{0:0{len(str(playlistCount))}d}}".format(i),
"playlist_autonumber": i,
}
for property in ("id", "title", "uploader", "uploader_id"):
if property in entry:
extras[f"playlist_{property}"] = entry.get(property)
LOG.debug(f"Processing entry {i}/{playlistCount} - ID: {etr.get('id')} - Title: {etr.get('title')}")
if "thumbnail" not in etr and "youtube:" in entry.get("extractor", ""):
extras["thumbnail"] = f"https://img.youtube.com/vi/{etr['id']}/maxresdefault.jpg"
async with semaphore:
return await self.add(
item=item.new_with(url=etr.get("url") or etr.get("webpage_url"), extras=extras),
already=already,
)
tasks = [process_entry(i, etr) for i, etr in enumerate(entries, start=1)]
results = await asyncio.gather(*tasks)
LOG.info(
f"Playlist '{entry.get('id')}: {entry.get('title')}' processing completed with '{len(results)}' entries."
)
if any("error" == res["status"] 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": "ok"}
async def _process_playlist_old(self, entry: dict, item: Item, already=None):
LOG.info(f"Playlist '{entry.get('id')}: {entry.get('title')}' processing.")
entries = entry.get("entries", []) entries = entry.get("entries", [])
playlistCount = int(entry.get("playlist_count", len(entries))) playlistCount = int(entry.get("playlist_count", len(entries)))
results = [] results = []
@ -221,6 +269,8 @@ class DownloadQueue(metaclass=Singleton):
if "thumbnail" not in etr and "youtube:" in entry.get("extractor", ""): if "thumbnail" not in etr and "youtube:" in entry.get("extractor", ""):
extras["thumbnail"] = f"https://img.youtube.com/vi/{etr['id']}/maxresdefault.jpg" extras["thumbnail"] = f"https://img.youtube.com/vi/{etr['id']}/maxresdefault.jpg"
LOG.debug(f"Processing entry {i}/{playlistCount} - ID: {etr.get('id')} - Title: {etr.get('title')}")
results.append( results.append(
await self.add( await self.add(
item=item.new_with(url=etr.get("url") or etr.get("webpage_url"), extras=extras), item=item.new_with(url=etr.get("url") or etr.get("webpage_url"), extras=extras),
@ -228,6 +278,10 @@ class DownloadQueue(metaclass=Singleton):
) )
) )
LOG.info(
f"Playlist '{entry.get('id')}: {entry.get('title')}' processing completed with '{len(results)}' entries."
)
if any("error" == res["status"] for res in results): if any("error" == res["status"] for res in results):
return { return {
"status": "error", "status": "error",
@ -360,7 +414,7 @@ class DownloadQueue(metaclass=Singleton):
starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC) starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
) )
starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0)) starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0))
dlInfo.info.error += f" Download will start at {starts_in.isoformat()}." dlInfo.info.error += f" Download will start at {starts_in.astimezone().isoformat()}."
_requeue = False _requeue = False
except Exception as e: except Exception as e:
LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}") LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}")
@ -917,7 +971,7 @@ class DownloadQueue(metaclass=Singleton):
premiere_ends: datetime = starts_in + timedelta(minutes=5, seconds=duration) premiere_ends: datetime = starts_in + timedelta(minutes=5, seconds=duration)
if time_now < premiere_ends: if time_now < premiere_ends:
LOG.debug( LOG.debug(
f"Item '{item_ref}' is premiering, download will start in '{(starts_in.astimezone() + timedelta(minutes=5, seconds=duration)).isoformat()}'" f"Item '{item_ref}' is premiering, download will start in '{(starts_in + timedelta(minutes=5, seconds=duration)).astimezone().isoformat()}'"
) )
continue continue

View file

@ -44,7 +44,6 @@ class HttpAPI:
"config": self.config, "config": self.config,
"notify": self._notify, "notify": self._notify,
"cache": self.cache, "cache": self.cache,
"app": self.app,
"http_api": self, "http_api": self,
"root_path": self.rootPath, "root_path": self.rootPath,
}.items() }.items()

View file

@ -18,8 +18,6 @@ class Packages:
if file.exists() and os.access(str(file), os.R_OK): if file.exists() and os.access(str(file), os.R_OK):
with open(file) as f: with open(file) as f:
from_file: list[str] = [pkg.strip() for pkg in f if pkg.strip()] from_file: list[str] = [pkg.strip() for pkg in f if pkg.strip()]
else:
LOG.error(f"pip packages file '{file}' doesn't exist or is not readable.")
self.packages: list[str] = list(set(from_env + from_file)) self.packages: list[str] = list(set(from_env + from_file))
self.upgrade = bool(upgrade) self.upgrade = bool(upgrade)

View file

@ -1,9 +1,11 @@
import inspect import inspect
import logging
from typing import Any, TypeVar from typing import Any, TypeVar
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
T = TypeVar("T") T = TypeVar("T")
LOG: logging.Logger = logging.getLogger(__name__)
class Services(metaclass=Singleton): class Services(metaclass=Singleton):
@ -50,6 +52,10 @@ class Services(metaclass=Singleton):
sig = inspect.signature(handler) sig = inspect.signature(handler)
expected_args = sig.parameters.keys() expected_args = sig.parameters.keys()
filtered = {k: v for k, v in context.items() if k in expected_args} filtered = {k: v for k, v in context.items() if k in expected_args}
if missing_args := expected_args - filtered.keys():
LOG.error(f"Missing arguments for handler '{handler.__name__}': {missing_args}")
return await handler(**filtered) return await handler(**filtered)
def handle_sync(self, handler: callable, **kwargs) -> Any: def handle_sync(self, handler: callable, **kwargs) -> Any:
@ -58,4 +64,8 @@ class Services(metaclass=Singleton):
sig = inspect.signature(handler) sig = inspect.signature(handler)
expected_args = sig.parameters.keys() expected_args = sig.parameters.keys()
filtered = {k: v for k, v in context.items() if k in expected_args} filtered = {k: v for k, v in context.items() if k in expected_args}
if missing_args := expected_args - filtered.keys():
LOG.error(f"Missing arguments for handler '{handler.__name__}': {missing_args}")
return handler(**filtered) return handler(**filtered)

View file

@ -74,7 +74,7 @@ class Tasks(metaclass=Singleton):
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
self._scheduler: Scheduler = scheduler or Scheduler.get_instance() self._scheduler: Scheduler = scheduler or Scheduler.get_instance()
self._notify: EventBus = EventBus.get_instance() self._notify: EventBus = EventBus.get_instance()
self._task_handler = HandleTask(self._scheduler, self) self._task_handler = HandleTask(self._scheduler, self, config)
if self._file.exists() and "600" != self._file.stat().st_mode: if self._file.exists() and "600" != self._file.stat().st_mode:
try: try:
@ -152,7 +152,7 @@ class Tasks(metaclass=Singleton):
self._tasks.append(task) self._tasks.append(task)
if not task.timer: if not task.timer or "[only_handler]" in task.name:
continue continue
try: try:
@ -342,19 +342,31 @@ class Tasks(metaclass=Singleton):
class HandleTask: class HandleTask:
_tasks: Tasks _tasks: Tasks
def __init__(self, scheduler: Scheduler, tasks: Tasks) -> None: def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None:
self._tasks = tasks self._tasks = tasks
self._handlers: list[type] = self._discover() self._handlers: list[type] = self._discover()
timer = config.tasks_handler_timer
try:
from cronsim import CronSim
CronSim(timer, datetime.now(UTC))
except Exception as e:
timer = "15 */1 * * *"
LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.")
scheduler.add( scheduler.add(
timer="15 */1 * * *", timer=timer,
func=self._dispatcher, func=self._dispatcher,
id=f"{__class__.__name__}._dispatcher", id=f"{__class__.__name__}._dispatcher",
) )
def _dispatcher(self): def _dispatcher(self):
for task in self._tasks.get_all(): for task in self._tasks.get_all():
if not task.timer or "[no_handler]" in task.name: if "[no_handler]" in task.name:
continue
if not task.timer and "[only_handler]" not in task.name:
continue continue
try: try:

View file

@ -12,7 +12,7 @@ import coloredlogs
from dotenv import load_dotenv from dotenv import load_dotenv
from .Utils import FileLogFormatter, arg_converter from .Utils import FileLogFormatter, arg_converter
from .version import APP_VERSION from .version import APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
class Config: class Config:
@ -113,12 +113,18 @@ class Config:
version: str = APP_VERSION version: str = APP_VERSION
"The version of the application." "The version of the application."
app_version: str = APP_VERSION
"The version of the application, same as `version`."
app_commit_sha: str = APP_COMMIT_SHA
"The commit SHA of the application."
app_build_date: str = APP_BUILD_DATE
"The build date of the application."
__instance = None __instance = None
"The instance of the class." "The instance of the class."
new_version_available: bool = False
"A new version of the application is available."
started: int = 0 started: int = 0
"The time the application was started." "The time the application was started."
@ -143,6 +149,9 @@ class Config:
secret_key: str secret_key: str
"The secret key to use for the application." "The secret key to use for the application."
tasks_handler_timer: str = "15 */1 * * *"
"""The cron expression for the tasks timer."""
console_enabled: bool = False console_enabled: bool = False
"Enable direct access to yt-dlp console." "Enable direct access to yt-dlp console."
@ -164,6 +173,9 @@ class Config:
prevent_live_premiere: bool = False prevent_live_premiere: bool = False
"""Prevent downloading of the initial premiere live broadcast.""" """Prevent downloading of the initial premiere live broadcast."""
playlist_items_concurrency: int = 1
"""The number of concurrent playlist items to be processed at same time."""
pictures_backends: list[str] = [ pictures_backends: list[str] = [
"https://unsplash.it/1920/1080?random", "https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080", "https://picsum.photos/1920/1080",
@ -182,12 +194,13 @@ class Config:
"version", "version",
"__instance", "__instance",
"ytdl_options", "ytdl_options",
"tasks",
"new_version_available",
"started", "started",
"ytdlp_cli", "ytdlp_cli",
"_ytdlp_cli_mutable", "_ytdlp_cli_mutable",
"is_native", "is_native",
"app_version",
"app_commit_sha",
"app_build_date",
) )
"The variables that are immutable." "The variables that are immutable."
@ -197,6 +210,7 @@ class Config:
"socket_timeout", "socket_timeout",
"extract_info_timeout", "extract_info_timeout",
"debugpy_port", "debugpy_port",
"playlist_items_concurrency",
) )
"The variables that are integers." "The variables that are integers."
@ -239,6 +253,10 @@ class Config:
"base_path", "base_path",
"is_native", "is_native",
"app_env", "app_env",
"tasks_handler_timer",
"app_version",
"app_commit_sha",
"app_build_date",
) )
"The variables that are relevant to the frontend." "The variables that are relevant to the frontend."
@ -434,9 +452,6 @@ class Config:
) )
raise ValueError(msg) raise ValueError(msg)
if self.version == "dev-master":
self._version_via_git()
def _get_attributes(self) -> dict: def _get_attributes(self) -> dict:
attrs: dict = {} attrs: dict = {}
vClass: str = self.__class__ vClass: str = self.__class__
@ -504,59 +519,3 @@ class Config:
return YTDLP_VERSION return YTDLP_VERSION
except ImportError: except ImportError:
return "0.0.0" return "0.0.0"
def _version_via_git(self):
"""
Updates the version of the application using git tags.
This is used to set the version to the latest git tag.
"""
git_path: Path = Path(__file__).parent / ".." / ".." / ".git"
if not git_path.exists():
logging.info(f"Git directory '{git_path}' does not exist. Cannot determine version.")
return
try:
import subprocess
branch_result = subprocess.run( # noqa: S603
["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
cwd=git_path.parent,
capture_output=True,
text=True,
check=False,
)
if 0 != branch_result.returncode:
logging.error(f"Git rev-parse failed: {branch_result.stderr.strip()}")
return
branch_name: str = branch_result.stdout.strip()
if not branch_name:
logging.warning("Git branch name is empty.")
return
commit_result = subprocess.run( # noqa: S603
["git", "log", "-1", "--format=%ct_%H"], # noqa: S607
cwd=git_path.parent,
capture_output=True,
text=True,
check=False,
)
if 0 != commit_result.returncode:
logging.error(f"Git log failed: {commit_result.stderr.strip()}")
return
commit_info: str = commit_result.stdout.strip()
if not commit_info:
logging.warning("Git commit info is empty.")
return
commit_date, commit_sha = commit_info.split("_", 1)
commit_date = time.strftime("%Y%m%d", time.localtime(int(commit_date)))
commit_sha = commit_sha[:8]
self.version = f"{branch_name}-{commit_date}-{commit_sha}"
logging.info(f"Application version set to '{self.version}' based on git data.")
except Exception as e:
logging.error(f"Error while getting git version: {e!s}")

View file

@ -3,7 +3,6 @@ import logging
import re import re
from pathlib import Path from pathlib import Path
from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
@ -17,7 +16,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
class YoutubeHandler: class YoutubeHandler:
queued_ids: set[str] = set() queued_ids: set[str] = set()
FEED_CHANNEL = "https://www.youtube.com/feeds/videos.xml?channel_id={id}" FEED = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
FEED_PLAYLIST = "https://www.youtube.com/feeds/videos.xml?playlist_id={id}" FEED_PLAYLIST = "https://www.youtube.com/feeds/videos.xml?playlist_id={id}"
CHANNEL_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$") CHANNEL_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$")
@ -35,14 +34,13 @@ class YoutubeHandler:
return YoutubeHandler.parse(task.url) is not None return YoutubeHandler.parse(task.url) is not None
@staticmethod @staticmethod
async def handle(task: Task, cache: Cache, notify: EventBus, config: Config, queue: DownloadQueue): async def handle(task: Task, notify: EventBus, config: Config, queue: DownloadQueue):
""" """
Fetch the Atom feed for a YouTube channel or playlist, parse entries, Fetch the Atom feed for a YouTube channel or playlist, parse entries,
and return a list of videos with metadata. and return a list of videos with metadata.
Args: Args:
task (Task): The task containing the YouTube URL. task (Task): The task containing the YouTube URL.
cache (Cache): The cache instance for storing feed data.
notify (EventBus): The event bus for notifications. notify (EventBus): The event bus for notifications.
config (Config): The configuration instance. config (Config): The configuration instance.
queue (DownloadQueue): The download queue instance. queue (DownloadQueue): The download queue instance.
@ -58,57 +56,46 @@ class YoutubeHandler:
parsed = YoutubeHandler.parse(task.url) parsed = YoutubeHandler.parse(task.url)
if not parsed: if not parsed:
LOG.error(f"Cannot parse URL: {task.url}") LOG.error(f"Cannot parse '{task.id}: {task.name}' URL: {task.url}")
return return
feed_id = parsed["id"] feed_url = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
feed_type = parsed["type"]
if "channel" == feed_type: LOG.debug(f"Fetching '{task.id}: {task.name}' feed.")
feed_url = YoutubeHandler.FEED_CHANNEL.format(id=feed_id) opts = {
else: "proxy": params.get("proxy", None),
feed_url = YoutubeHandler.FEED_PLAYLIST.format(id=feed_id) "headers": {
"User-Agent": params.get(
"user_agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
)
},
}
cache_key = f"youtube_feed_{feed_id}"
data = cache.get(cache_key)
if not data:
LOG.info(f"Fetching '{task.id}: {task.name}' feed.")
opts = {
"proxy": params.get("proxy", None),
"headers": {
"User-Agent": params.get(
"user_agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
)
},
}
async with httpx.AsyncClient(**opts) as client:
response = await client.request(method="GET", url=feed_url, timeout=60)
response.raise_for_status()
data = response.text
cache.set(cache_key, data, ttl=3600)
else:
LOG.info(f"Using cached '{task.id}: {task.name}' feed.")
root = fromstring(data)
ns = {"atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015"}
items = [] items = []
for entry in root.findall("atom:entry", ns): async with httpx.AsyncClient(**opts) as client:
vid_elem = entry.find("yt:videoId", ns) response = await client.request(method="GET", url=feed_url, timeout=120)
title_elem = entry.find("atom:title", ns) response.raise_for_status()
pub_elem = entry.find("atom:published", ns)
vid = vid_elem.text if vid_elem is not None else "" root = fromstring(response.text)
title = title_elem.text if title_elem is not None else "" ns = {"atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015"}
published = pub_elem.text if pub_elem is not None else ""
items.append( for entry in root.findall("atom:entry", ns):
{ vid_elem = entry.find("yt:videoId", ns)
"id": vid, title_elem = entry.find("atom:title", ns)
"url": f"https://www.youtube.com/watch?v={vid}", pub_elem = entry.find("atom:published", ns)
"title": title, vid = vid_elem.text if vid_elem is not None else ""
"published": published, title = title_elem.text if title_elem is not None else ""
} published = pub_elem.text if pub_elem is not None else ""
) items.append(
{
"id": vid,
"url": f"https://www.youtube.com/watch?v={vid}",
"title": title,
"published": published,
}
)
if len(items) < 1: if len(items) < 1:
LOG.warning(f"No entries found in '{task.id}: {task.name}' feed. URL: {feed_url}") LOG.warning(f"No entries found in '{task.id}: {task.name}' feed. URL: {feed_url}")
@ -129,7 +116,7 @@ class YoutubeHandler:
filtered.append(item) filtered.append(item)
if len(filtered) < 1: if len(filtered) < 1:
LOG.info(f"No new items found in '{task.id}: {task.name}' feed.") LOG.debug(f"No new items found in '{task.id}: {task.name}' feed.")
return return
LOG.info(f"Found '{len(filtered)}' new items from '{task.id}: {task.name}' feed.") LOG.info(f"Found '{len(filtered)}' new items from '{task.id}: {task.name}' feed.")
@ -152,7 +139,7 @@ class YoutubeHandler:
try: try:
await queued await queued
except Exception as e: except Exception as e:
LOG.error(f"Error while adding items to the queue: {e!s}") LOG.error(f"Error while adding items from '{task.id}: {task.name}'. {e!s}")
return return
@staticmethod @staticmethod
@ -194,10 +181,10 @@ class YoutubeHandler:
""" """
if m := YoutubeHandler.CHANNEL_REGEX.match(url): if m := YoutubeHandler.CHANNEL_REGEX.match(url):
return {"type": "channel", "id": m.group("id")} return {"type": "channel_id", "id": m.group("id")}
if m := YoutubeHandler.PLAYLIST_REGEX.match(url): if m := YoutubeHandler.PLAYLIST_REGEX.match(url):
return {"type": "playlist", "id": m.group("id")} return {"type": "playlist_id", "id": m.group("id")}
return None return None

View file

@ -5,3 +5,9 @@ This file is updated by the CI/CD pipeline, do not edit it manually.
APP_VERSION = "dev-master" APP_VERSION = "dev-master"
"Application version" "Application version"
APP_COMMIT_SHA = "unknown"
"Commit SHA of the application"
APP_BUILD_DATE = "unknown"
"Build date of the application"

View file

@ -25,6 +25,7 @@ from app.library.HttpSocket import HttpSocket
from app.library.Notifications import Notification from app.library.Notifications import Notification
from app.library.Presets import Presets from app.library.Presets import Presets
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
from app.library.Services import Services
from app.library.Tasks import Tasks from app.library.Tasks import Tasks
LOG = logging.getLogger("app") LOG = logging.getLogger("app")
@ -38,6 +39,8 @@ class Main:
self._config = Config.get_instance(is_native=is_native) self._config = Config.get_instance(is_native=is_native)
self._app = web.Application() self._app = web.Application()
Services.get_instance().add("app", self._app)
if self._config.debug: if self._config.debug:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
loop.set_debug(True) loop.set_debug(True)
@ -117,7 +120,7 @@ class Main:
def started(_): def started(_):
LOG.info("=" * 40) LOG.info("=" * 40)
LOG.info(f"YTPTube v{self._config.version} - started on http://{host}:{port}{self._config.base_path}") LOG.info(f"YTPTube {self._config.version} - started on http://{host}:{port}{self._config.base_path}")
LOG.info("=" * 40) LOG.info("=" * 40)
if cb: if cb:
cb() cb()
@ -126,7 +129,9 @@ class Main:
if self._config.access_log: if self._config.access_log:
from app.library.HttpAPI import LOG as HTTP_LOGGER from app.library.HttpAPI import LOG as HTTP_LOGGER
HTTP_LOGGER.addFilter(lambda record: f"GET {self._app.router['ping'].url_for()}" not in record.getMessage()) HTTP_LOGGER.addFilter(
lambda record: f"GET {str(self._app.router['ping'].url_for()).rstrip('/')}" not in record.getMessage()
)
web.run_app( web.run_app(
self._app, self._app,

View file

@ -173,7 +173,7 @@ async def segments_stream(request: Request, config: Config, app: web.Application
status=status, status=status,
headers={ headers={
"Location": str( "Location": str(
app.router["segments"].url_for( app.router["segments_stream"].url_for(
segment=segment, segment=segment,
file=str(realFile).replace(config.download_path, "").strip("/"), file=str(realFile).replace(config.download_path, "").strip("/"),
) )

View file

@ -332,3 +332,7 @@ hr {
.is-bold { .is-bold {
font-weight: bold; font-weight: bold;
} }
.fa-spin-10 {
--fa-animation-iteration-count: 10;
}

View file

@ -22,17 +22,16 @@
</span> </span>
</button> </button>
</div> </div>
<div class="column is-half-mobile" v-if="hasDownloaded"> <div class="column is-half-mobile" v-if="hasDownloaded && hasSelected">
<button type="button" class="button is-fullwidth is-link" :disabled="!hasSelected" @click="downloadSelected"> <button type="button" class="button is-fullwidth is-link" @click="downloadSelected">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-download" /></span> <span class="icon"><i class="fa-solid fa-download" /></span>
<span>Download</span> <span>Download</span>
</span> </span>
</button> </button>
</div> </div>
<div class="column is-half-mobile"> <div class="column is-half-mobile" v-if="hasSelected">
<button type="button" class="button is-fullwidth is-danger" :disabled="!hasSelected" <button type="button" class="button is-fullwidth is-danger" @click="deleteSelectedItems">
@click="deleteSelectedItems">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-trash-can" /></span> <span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>{{ config.app.remove_files ? 'Remove' : 'Clear' }}</span> <span>{{ config.app.remove_files ? 'Remove' : 'Clear' }}</span>
@ -154,16 +153,6 @@
</td> </td>
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control" v-if="('finished' === item.status && item.filename) || isEmbedable(item.url)">
<button v-if="'finished' === item.status && item.filename" @click="playVideo(item)"
v-tooltip="'Play video'" class="button is-danger is-light is-small">
<span class="icon"><i class="fa-solid fa-play" /></span>
</button>
<button v-else @click="embed_url = getEmbedable(item.url)" v-tooltip="'Play video'"
class="button is-danger is-small">
<span class="icon"><i class="fa-solid fa-play" /></span>
</button>
</div>
<div class="control" v-if="item.status != 'finished' || !item.filename"> <div class="control" v-if="item.status != 'finished' || !item.filename">
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Re-queue video'" <button class="button is-warning is-fullwidth is-small" v-tooltip="'Re-queue video'"
@click="(event) => reQueueItem(item, event)"> @click="(event) => reQueueItem(item, event)">
@ -182,9 +171,23 @@
<span class="icon"><i class="fa-solid fa-trash-can" /></span> <span class="icon"><i class="fa-solid fa-trash-can" /></span>
</button> </button>
</div> </div>
<div class="control" v-if="item.url && !config.app.basic_mode"> <div class="control is-expanded" v-if="item.url && !config.app.basic_mode">
<Dropdown icons="fa-solid fa-cogs" label="" @open_state="s => table_container = !s" <Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s"
:button_classes="'is-small'"> :button_classes="'is-small'" label="Actions">
<template v-if="'finished' === item.status && item.filename">
<NuxtLink @click="playVideo(item)" class="dropdown-item">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<template v-else-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)"> <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span> <span>Information</span>
@ -235,27 +238,26 @@
</div> </div>
<div class="card-header-icon"> <div class="card-header-icon">
<span class="tag is-info" v-if="item.extras?.duration"> <div class="field is-grouped">
{{ formatTime(item.extras.duration) }} <div class="control">
</span> <span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
</div>
<div class="control">
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
</button>
</div>
<div class="control">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms"
:id="'checkbox-' + item._id" :value="item._id">
</label>
</div>
<span v-if="!showThumbnails"> </div>
<a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)"
v-tooltip="'Play video.'">
<span class="icon"><i class="fa-solid fa-play" /></span>
</a>
<a v-else-if="isEmbedable(item.url)" href="#" @click.prevent="embed_url = getEmbedable(item.url)"
v-tooltip="'Play video.'" class="has-text-danger">
<span class="icon"><i class="fa-solid fa-play" /></span>
</a>
</span>
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a>
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span>
</button>
</div> </div>
</header> </header>
<div v-if="showThumbnails" class="card-image"> <div v-if="showThumbnails" class="card-image">
@ -298,10 +300,10 @@
</span> </span>
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable" <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"
v-if="item.live_in && 'not_live' === item.status"> v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)">
<span :date-datetime="item.live_in" class="user-hint" <span :date-datetime="item.live_in || item.extras?.release_in" class="user-hint"
v-tooltip="'Will automatically be requeued at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')" v-tooltip="'Will be downloaded at: ' + moment(item.live_in || item.extras?.release_in).format('YYYY-M-DD H:mm Z')"
v-rtime="item.live_in" /> v-rtime="item.live_in || item.extras?.release_in" />
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="user-hint" :date-datetime="item.datetime" <span class="user-hint" :date-datetime="item.datetime"
@ -311,13 +313,6 @@
v-if="item.file_size"> v-if="item.file_size">
{{ formatBytes(item.file_size) }} {{ formatBytes(item.file_size) }}
</div> </div>
<div class="column is-half-mobile has-text-centered is-unselectable">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
Select
</label>
</div>
</div> </div>
<div class="columns is-mobile is-multiline"> <div class="columns is-mobile is-multiline">
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename"> <div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
@ -346,8 +341,24 @@
</span> </span>
</a> </a>
</div> </div>
<div class="column is-half-mobile" v-if="item.url && !config.app.basic_mode"> <div class="column" v-if="item.url && !config.app.basic_mode">
<Dropdown icons="fa-solid fa-cogs" label="Actions"> <Dropdown icons="fa-solid fa-cogs" label="Actions">
<template v-if="'finished' === item.status && item.filename">
<NuxtLink @click="playVideo(item)" class="dropdown-item">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<template v-else-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)">
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)"> <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url)">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span> <span>Information</span>
@ -417,12 +428,12 @@
</template> </template>
<script setup> <script setup>
import moment from 'moment'
import { useStorage } from '@vueuse/core'
import { makeDownload, formatBytes, uri } from '~/utils/index'
import { isEmbedable, getEmbedable } from '~/utils/embedable'
import Dropdown from './Dropdown.vue'
import { NuxtLink } from '#components' import { NuxtLink } from '#components'
import { useStorage } from '@vueuse/core'
import moment from 'moment'
import { getEmbedable, isEmbedable } from '~/utils/embedable'
import { formatBytes, makeDownload, uri } from '~/utils/index'
import Dropdown from './Dropdown.vue'
const emitter = defineEmits(['getInfo', 'add_new']) const emitter = defineEmits(['getInfo', 'add_new'])
@ -849,6 +860,6 @@ const is_queued = item => {
return '' return ''
} }
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin' : '' return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
} }
</script> </script>

View file

@ -89,7 +89,7 @@
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode"> <div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="field"> <div class="field">
<label class="label is-inline is-unselectable"> <label class="label is-inline is-unselectable">
<span class="icon"><i class="fa-solid fa-comment" /></span> <span class="icon"><i class="fa-solid fa-object-ungroup" /></span>
<span>URLs Separator</span> <span>URLs Separator</span>
</label> </label>
<div class="control"> <div class="control">
@ -103,7 +103,7 @@
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this separate multiple URLs in the input field.</span> <span>Use this to separate multiple URLs in the input field.</span>
</span> </span>
</div> </div>
</div> </div>

View file

@ -142,17 +142,31 @@
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink> <NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div> </div>
<div class="card-header-icon"> <div class="card-header-icon">
<span class="tag is-info" v-if="item.extras?.duration"> <div class="field is-grouped">
{{ formatTime(item.extras.duration) }}
</span>
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'" @click.prevent="copyText(item.url)"> <div class="control">
<span class="icon"><i class="fa-solid fa-copy" /></span> <span class="tag is-info" v-if="item.extras?.duration">
</a> {{ formatTime(item.extras.duration) }}
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails"> </span>
<span class="icon"><i class="fa-solid" </div>
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail, }" /></span> <div class="control" v-if="!showThumbnails && isEmbedable(item.url)">
</button> <NuxtLink @click="embed_url = getEmbedable(item.url)" v-tooltip="'Play video'">
<span class="icon has-text-danger"><i class="fa-solid fa-play" /></span>
</NuxtLink>
</div>
<div class="control">
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }" /></span>
</button>
</div>
<div class="control">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms"
:id="'checkbox-' + item._id" :value="item._id">
</label>
</div>
</div>
</div> </div>
</header> </header>
<div v-if="showThumbnails" class="card-image"> <div v-if="showThumbnails" class="card-image">
@ -194,29 +208,19 @@
v-if="item.downloaded_bytes"> v-if="item.downloaded_bytes">
{{ formatBytes(item.downloaded_bytes) }} {{ formatBytes(item.downloaded_bytes) }}
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
Select
</label>
</div>
</div> </div>
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<div class="column is-half-mobile"> <div class="column is-half-mobile">
<button class="button is-warning is-fullwidth" @click="confirmCancel(item);"> <button class="button is-warning is-fullwidth" @click="confirmCancel(item);">
<span class="icon-text is-block"> <span class="icon"><i class="fa-solid fa-eject" /></span>
<span class="icon"><i class="fa-solid fa-eject" /></span> <span>Cancel</span>
<span>Cancel</span>
</span>
</button> </button>
</div> </div>
<div class="column is-half-mobile" v-if="item.url && !config.app.basic_mode"> <div class="column is-half-mobile" v-if="item.url && !config.app.basic_mode">
<button class="button is-info is-fullwidth" @click="emitter('getInfo', item.url)"> <button class="button is-info is-fullwidth" @click="emitter('getInfo', item.url)">
<span class="icon-text is-block"> <span class="icon"><i class="fa-solid fa-info" /></span>
<span class="icon"><i class="fa-solid fa-info" /></span> <span>Information</span>
<span>Information</span>
</span>
</button> </button>
</div> </div>
</div> </div>

View file

@ -250,6 +250,8 @@
and automatically queues any you havent downloaded yet.</li> and automatically queues any you havent downloaded yet.</li>
<li>To opt out of RSS monitoring for a specific task, append <code>[no_handler]</code> to that tasks name. <li>To opt out of RSS monitoring for a specific task, append <code>[no_handler]</code> to that tasks name.
</li> </li>
<li>To have the task only monitor RSS feed, do not set timer and add <code>[only_handler]</code> to that
tasks name.</li>
<li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in command options <li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in command options
for ytdlp.cli, preset or task.</li> for ytdlp.cli, preset or task.</li>
<li>If you don't have <code>--download-archive</code> set but <code>YTP_KEEP_ARCHIVE</code> environment <li>If you don't have <code>--download-archive</code> set but <code>YTP_KEEP_ARCHIVE</code> environment

View file

@ -107,6 +107,7 @@
<div> <div>
<Settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" /> <Settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<NuxtLoadingIndicator />
<NuxtPage /> <NuxtPage />
</div> </div>
@ -114,7 +115,9 @@
<div class="column is-8-mobile"> <div class="column is-8-mobile">
<div class="has-text-left" v-if="config.app?.version"> <div class="has-text-left" v-if="config.app?.version">
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink> © {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
<span class="is-hidden-mobile">&nbsp;({{ config?.app?.version || 'unknown' }})</span> <span class="is-hidden-mobile"
v-tooltip="`Build Date: ${config.app?.app_build_date}, commit: ${config.app?.app_commit_sha}`">
&nbsp;({{ config?.app?.version || 'unknown' }})</span>
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink> - <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
<span class="is-hidden-mobile">&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span> <span class="is-hidden-mobile">&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span>
- <NuxtLink :to="`/changelog?version=${config?.app?.version || 'unknown'}`">CHANGELOG</NuxtLink> - <NuxtLink :to="`/changelog?version=${config?.app?.version || 'unknown'}`">CHANGELOG</NuxtLink>

View file

@ -27,7 +27,7 @@
<div class="content p-0 m-0"> <div class="content p-0 m-0">
<h1 class="is-4"> <h1 class="is-4">
<span class="icon"><i class="fas fa-code-branch" /></span> <span class="icon"><i class="fas fa-code-branch" /></span>
{{ formatTag(log) }} <span class="tag has-text-success" v-if="isInstalled(log)">Installed</span> {{ log.tag }} <span class="tag has-text-success" v-if="isInstalled(log)">Installed</span>
</h1> </h1>
<hr> <hr>
<ul> <ul>
@ -61,7 +61,7 @@ useHead({ title: 'CHANGELOG' })
const PROJECT = 'ytptube' const PROJECT = 'ytptube'
const REPO = `https://github.com/arabcoders/${PROJECT}` const REPO = `https://github.com/arabcoders/${PROJECT}`
const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG-{branch}.json?version={version}` const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}`
const logs = ref([]) const logs = ref([])
const api_version = ref('') const api_version = ref('')
@ -73,18 +73,6 @@ const branch = computed(() => {
return ['master', 'dev'].includes(branch) ? branch : 'master' return ['master', 'dev'].includes(branch) ? branch : 'master'
}) })
const formatTag = log => {
const parts = log.tag.split('-')
if (parts.length < 3) {
return log.tag
}
const branch = parts[0]
const date = parts[1]
const shortSha = log.full_sha.substring(0, hashLength.value)
return `${ucFirst(branch)}: ${moment(date, 'YYYYMMDD').format('YYYY-MM-DD')} - ${shortSha}`
}
watch(() => config.app.version, async value => { watch(() => config.app.version, async value => {
if (!value) { if (!value) {
return return
@ -104,8 +92,24 @@ const loadContent = async () => {
isLoading.value = true isLoading.value = true
try { try {
const changes = await fetch(REPO_URL.replace('{branch}', branch.value).replace('{version}', api_version.value)) try{
logs.value = await changes.json() logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json()
}catch(e){
console.error(e)
const changes = await fetch(REPO_URL.replace('{branch}', branch.value).replace('{version}', api_version.value))
logs.value = await changes.json()
}
await nextTick()
logs.value = logs.value.slice(0, 10).map(log => {
log.commits = log.commits.map(commit => {
commit.full_sha = commit.sha.padEnd(hashLength.value, '0')
return commit
})
log.full_sha = log.tag.padEnd(hashLength.value, '0')
return log
})
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.error('error', 'Error', `Failed to fetch changelog. ${e.message}`) toast.error('error', 'Error', `Failed to fetch changelog. ${e.message}`)

View file

@ -23,7 +23,7 @@
</p> </p>
<p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode"> <p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode">
<button v-tooltip.bottom="'Toggle new download form'" class="button is-primary has-tooltip-bottom" <button v-tooltip.bottom="'Toggle download form'" class="button is-primary has-tooltip-bottom"
@click="config.showForm = !config.showForm"> @click="config.showForm = !config.showForm">
<span class="icon"><i class="fa-solid fa-plus" /></span> <span class="icon"><i class="fa-solid fa-plus" /></span>
</button> </button>

View file

@ -20,6 +20,9 @@ const CONFIG_KEYS = {
ytdlp_cli: '', ytdlp_cli: '',
file_logging: false, file_logging: false,
is_native: false, is_native: false,
app_version: '',
app_commit_sha: '',
app_build_date: '',
}, },
presets: [ presets: [
{ {