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
default: false
type: boolean
create_release:
description: "Create Release (requires build)"
required: false
default: false
type: boolean
push:
branches:
- dev
- master
tags:
- "v*"
paths-ignore:
- "**.md"
- ".github/**"
@ -96,11 +93,13 @@ jobs:
packages: write
contents: write
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:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
@ -120,13 +119,30 @@ jobs:
pnpm install --production --prefer-offline --frozen-lockfile
pnpm run generate
- name: Update Version File
uses: ArabCoders/write-version-to-file@master
with:
filename: "/app/library/version.py"
placeholder: "dev-master"
with_date: "true"
with_branch: "true"
- name: Install GitPython
run: pip install gitpython
- name: Generate CHANGELOG.json
run: |
python3 .github/scripts/generate_changelog.py -p . -f ./ui/exported/CHANGELOG.json
- 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
uses: docker/setup-qemu-action@v3
@ -142,10 +158,9 @@ jobs:
${{ env.DOCKERHUB_SLUG }}
${{ env.GHCR_SLUG }}
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=tag
type=raw,value={{branch}}{{base_ref}}-{{date 'YYYYMMDD'}}-{{sha}}
flavor: |
latest=false
@ -173,13 +188,49 @@ jobs:
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, mode=max, scope=${{ github.workflow }}
- name: Version tag
uses: arabcoders/action-python-autotagger@master
- name: Overwrite GitHub release notes
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/github-script@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
repo_name: arabcoders/ytptube
path: app/library/version.py
regex: 'APP_VERSION\s=\s\"(.+)\"'
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const tag = context.ref.replace('refs/tags/', '');
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:
runs-on: ubuntu-latest

1
.gitignore vendored
View file

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

View file

@ -76,6 +76,7 @@
"socketio",
"timespec",
"tmpfilename",
"ungroup",
"upgrader",
"urandom",
"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.
| Environment Variable | Description | Default |
| ------------------------- | ------------------------------------------------------------------ | ---------------------------------- |
| 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_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `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_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_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| 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_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` |
| 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_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` |
| 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_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_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_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_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_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| 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_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
| Environment Variable | Description | Default |
| ------------------------------ | ------------------------------------------------------------------ | ---------------------------------- |
| 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_INSTANCE_TITLE | The title of the instance | `empty string` |
| YTP_FILE_LOGGING | Whether to log to file | `false` |
| YTP_DOWNLOAD_PATH | Path to where the downloads will be saved | `/downloads` |
| YTP_MAX_WORKERS | How many works to use for downloads | `1` |
| YTP_AUTH_USERNAME | Username for basic authentication | `empty string` |
| YTP_AUTH_PASSWORD | Password for basic authentication | `empty string` |
| YTP_CONSOLE_ENABLED | Whether to enable the console | `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_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_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| 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_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` |
| 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_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` |
| 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_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_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_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_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_BROWSER_ENABLED | Whether to enable the file browser | `false` |
| 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_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}")
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", [])
playlistCount = int(entry.get("playlist_count", len(entries)))
results = []
@ -221,6 +269,8 @@ class DownloadQueue(metaclass=Singleton):
if "thumbnail" not in etr and "youtube:" in entry.get("extractor", ""):
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(
await self.add(
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):
return {
"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 = 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
except Exception as e:
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)
if time_now < premiere_ends:
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

View file

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

View file

@ -18,8 +18,6 @@ class Packages:
if file.exists() and os.access(str(file), os.R_OK):
with open(file) as f:
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.upgrade = bool(upgrade)

View file

@ -1,9 +1,11 @@
import inspect
import logging
from typing import Any, TypeVar
from app.library.Singleton import Singleton
T = TypeVar("T")
LOG: logging.Logger = logging.getLogger(__name__)
class Services(metaclass=Singleton):
@ -50,6 +52,10 @@ class Services(metaclass=Singleton):
sig = inspect.signature(handler)
expected_args = sig.parameters.keys()
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)
def handle_sync(self, handler: callable, **kwargs) -> Any:
@ -58,4 +64,8 @@ class Services(metaclass=Singleton):
sig = inspect.signature(handler)
expected_args = sig.parameters.keys()
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)

View file

@ -74,7 +74,7 @@ class Tasks(metaclass=Singleton):
self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
self._scheduler: Scheduler = scheduler or Scheduler.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:
try:
@ -152,7 +152,7 @@ class Tasks(metaclass=Singleton):
self._tasks.append(task)
if not task.timer:
if not task.timer or "[only_handler]" in task.name:
continue
try:
@ -342,19 +342,31 @@ class Tasks(metaclass=Singleton):
class HandleTask:
_tasks: Tasks
def __init__(self, scheduler: Scheduler, tasks: Tasks) -> None:
def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None:
self._tasks = tasks
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(
timer="15 */1 * * *",
timer=timer,
func=self._dispatcher,
id=f"{__class__.__name__}._dispatcher",
)
def _dispatcher(self):
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
try:

View file

@ -12,7 +12,7 @@ import coloredlogs
from dotenv import load_dotenv
from .Utils import FileLogFormatter, arg_converter
from .version import APP_VERSION
from .version import APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
class Config:
@ -113,12 +113,18 @@ class Config:
version: str = APP_VERSION
"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
"The instance of the class."
new_version_available: bool = False
"A new version of the application is available."
started: int = 0
"The time the application was started."
@ -143,6 +149,9 @@ class Config:
secret_key: str
"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
"Enable direct access to yt-dlp console."
@ -164,6 +173,9 @@ class Config:
prevent_live_premiere: bool = False
"""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] = [
"https://unsplash.it/1920/1080?random",
"https://picsum.photos/1920/1080",
@ -182,12 +194,13 @@ class Config:
"version",
"__instance",
"ytdl_options",
"tasks",
"new_version_available",
"started",
"ytdlp_cli",
"_ytdlp_cli_mutable",
"is_native",
"app_version",
"app_commit_sha",
"app_build_date",
)
"The variables that are immutable."
@ -197,6 +210,7 @@ class Config:
"socket_timeout",
"extract_info_timeout",
"debugpy_port",
"playlist_items_concurrency",
)
"The variables that are integers."
@ -239,6 +253,10 @@ class Config:
"base_path",
"is_native",
"app_env",
"tasks_handler_timer",
"app_version",
"app_commit_sha",
"app_build_date",
)
"The variables that are relevant to the frontend."
@ -434,9 +452,6 @@ class Config:
)
raise ValueError(msg)
if self.version == "dev-master":
self._version_via_git()
def _get_attributes(self) -> dict:
attrs: dict = {}
vClass: str = self.__class__
@ -504,59 +519,3 @@ class Config:
return YTDLP_VERSION
except ImportError:
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
from pathlib import Path
from app.library.cache import Cache
from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
@ -17,7 +16,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
class YoutubeHandler:
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}"
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
@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,
and return a list of videos with metadata.
Args:
task (Task): The task containing the YouTube URL.
cache (Cache): The cache instance for storing feed data.
notify (EventBus): The event bus for notifications.
config (Config): The configuration instance.
queue (DownloadQueue): The download queue instance.
@ -58,57 +56,46 @@ class YoutubeHandler:
parsed = YoutubeHandler.parse(task.url)
if not parsed:
LOG.error(f"Cannot parse URL: {task.url}")
LOG.error(f"Cannot parse '{task.id}: {task.name}' URL: {task.url}")
return
feed_id = parsed["id"]
feed_type = parsed["type"]
feed_url = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
if "channel" == feed_type:
feed_url = YoutubeHandler.FEED_CHANNEL.format(id=feed_id)
else:
feed_url = YoutubeHandler.FEED_PLAYLIST.format(id=feed_id)
LOG.debug(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",
)
},
}
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 = []
for entry in root.findall("atom:entry", ns):
vid_elem = entry.find("yt:videoId", ns)
title_elem = entry.find("atom:title", ns)
pub_elem = entry.find("atom:published", ns)
vid = vid_elem.text if vid_elem is not None else ""
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,
}
)
async with httpx.AsyncClient(**opts) as client:
response = await client.request(method="GET", url=feed_url, timeout=120)
response.raise_for_status()
root = fromstring(response.text)
ns = {"atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015"}
for entry in root.findall("atom:entry", ns):
vid_elem = entry.find("yt:videoId", ns)
title_elem = entry.find("atom:title", ns)
pub_elem = entry.find("atom:published", ns)
vid = vid_elem.text if vid_elem is not None else ""
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:
LOG.warning(f"No entries found in '{task.id}: {task.name}' feed. URL: {feed_url}")
@ -129,7 +116,7 @@ class YoutubeHandler:
filtered.append(item)
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
LOG.info(f"Found '{len(filtered)}' new items from '{task.id}: {task.name}' feed.")
@ -152,7 +139,7 @@ class YoutubeHandler:
try:
await queued
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
@staticmethod
@ -194,10 +181,10 @@ class YoutubeHandler:
"""
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):
return {"type": "playlist", "id": m.group("id")}
return {"type": "playlist_id", "id": m.group("id")}
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"
"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.Presets import Presets
from app.library.Scheduler import Scheduler
from app.library.Services import Services
from app.library.Tasks import Tasks
LOG = logging.getLogger("app")
@ -38,6 +39,8 @@ class Main:
self._config = Config.get_instance(is_native=is_native)
self._app = web.Application()
Services.get_instance().add("app", self._app)
if self._config.debug:
loop = asyncio.get_event_loop()
loop.set_debug(True)
@ -117,7 +120,7 @@ class Main:
def started(_):
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)
if cb:
cb()
@ -126,7 +129,9 @@ class Main:
if self._config.access_log:
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(
self._app,

View file

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

View file

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

View file

@ -22,17 +22,16 @@
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasDownloaded">
<button type="button" class="button is-fullwidth is-link" :disabled="!hasSelected" @click="downloadSelected">
<div class="column is-half-mobile" v-if="hasDownloaded && hasSelected">
<button type="button" class="button is-fullwidth is-link" @click="downloadSelected">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-download" /></span>
<span>Download</span>
</span>
</button>
</div>
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-danger" :disabled="!hasSelected"
@click="deleteSelectedItems">
<div class="column is-half-mobile" v-if="hasSelected">
<button type="button" class="button is-fullwidth is-danger" @click="deleteSelectedItems">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>{{ config.app.remove_files ? 'Remove' : 'Clear' }}</span>
@ -154,16 +153,6 @@
</td>
<td class="is-vcentered is-items-center">
<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">
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Re-queue video'"
@click="(event) => reQueueItem(item, event)">
@ -182,9 +171,23 @@
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
</button>
</div>
<div class="control" v-if="item.url && !config.app.basic_mode">
<Dropdown icons="fa-solid fa-cogs" label="" @open_state="s => table_container = !s"
:button_classes="'is-small'">
<div class="control is-expanded" v-if="item.url && !config.app.basic_mode">
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s"
: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)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span>
@ -235,27 +238,26 @@
</div>
<div class="card-header-icon">
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
<div class="field is-grouped">
<div class="control">
<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">
<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>
<div v-if="showThumbnails" class="card-image">
@ -298,10 +300,10 @@
</span>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"
v-if="item.live_in && 'not_live' === item.status">
<span :date-datetime="item.live_in" class="user-hint"
v-tooltip="'Will automatically be requeued at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')"
v-rtime="item.live_in" />
v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)">
<span :date-datetime="item.live_in || item.extras?.release_in" class="user-hint"
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 || item.extras?.release_in" />
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="user-hint" :date-datetime="item.datetime"
@ -311,13 +313,6 @@
v-if="item.file_size">
{{ formatBytes(item.file_size) }}
</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 class="columns is-mobile is-multiline">
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
@ -346,8 +341,24 @@
</span>
</a>
</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">
<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)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span>
@ -417,12 +428,12 @@
</template>
<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 { 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'])
@ -849,6 +860,6 @@ const is_queued = item => {
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>

View file

@ -89,7 +89,7 @@
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="field">
<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>
</label>
<div class="control">
@ -103,7 +103,7 @@
</div>
<span class="help is-bold">
<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>
</div>
</div>

View file

@ -142,17 +142,31 @@
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
<div class="card-header-icon">
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
<div class="field is-grouped">
<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 class="control">
<span class="tag is-info" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
</div>
<div class="control" v-if="!showThumbnails && isEmbedable(item.url)">
<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>
</header>
<div v-if="showThumbnails" class="card-image">
@ -194,29 +208,19 @@
v-if="item.downloaded_bytes">
{{ formatBytes(item.downloaded_bytes) }}
</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 class="columns is-multiline is-mobile">
<div class="column is-half-mobile">
<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>Cancel</span>
</span>
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span>
</button>
</div>
<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)">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span>
</span>
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Information</span>
</button>
</div>
</div>

View file

@ -250,6 +250,8 @@
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>
<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
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

View file

@ -107,6 +107,7 @@
<div>
<Settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<NuxtLoadingIndicator />
<NuxtPage />
</div>
@ -114,7 +115,9 @@
<div class="column is-8-mobile">
<div class="has-text-left" v-if="config.app?.version">
© {{ 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>
<span class="is-hidden-mobile">&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span>
- <NuxtLink :to="`/changelog?version=${config?.app?.version || 'unknown'}`">CHANGELOG</NuxtLink>

View file

@ -27,7 +27,7 @@
<div class="content p-0 m-0">
<h1 class="is-4">
<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>
<hr>
<ul>
@ -61,7 +61,7 @@ useHead({ title: 'CHANGELOG' })
const PROJECT = 'ytptube'
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 api_version = ref('')
@ -73,18 +73,6 @@ const branch = computed(() => {
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 => {
if (!value) {
return
@ -104,8 +92,24 @@ const loadContent = async () => {
isLoading.value = true
try {
const changes = await fetch(REPO_URL.replace('{branch}', branch.value).replace('{version}', api_version.value))
logs.value = await changes.json()
try{
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) {
console.error(e)
toast.error('error', 'Error', `Failed to fetch changelog. ${e.message}`)

View file

@ -23,7 +23,7 @@
</p>
<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">
<span class="icon"><i class="fa-solid fa-plus" /></span>
</button>

View file

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