Merge pull request #304 from arabcoders/dev
Use simver starting from this pull request.
This commit is contained in:
commit
6e05efb146
24 changed files with 515 additions and 292 deletions
107
.github/scripts/generate_changelog.py
vendored
Normal file
107
.github/scripts/generate_changelog.py
vendored
Normal 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)
|
||||
93
.github/workflows/main.yml
vendored
93
.github/workflows/main.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
# compiled output
|
||||
/frontend/dist
|
||||
/ui/dist
|
||||
/ui/exported/CHANGELOG.json
|
||||
|
||||
# dependencies
|
||||
**/node_modules/*
|
||||
|
|
|
|||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -76,6 +76,7 @@
|
|||
"socketio",
|
||||
"timespec",
|
||||
"tmpfilename",
|
||||
"ungroup",
|
||||
"upgrader",
|
||||
"urandom",
|
||||
"urlsafe",
|
||||
|
|
|
|||
78
README.md
78
README.md
|
|
@ -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` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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("/"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -332,3 +332,7 @@ hr {
|
|||
.is-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.fa-spin-10 {
|
||||
--fa-animation-iteration-count: 10;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -250,6 +250,8 @@
|
|||
and automatically queues any you haven’t downloaded yet.</li>
|
||||
<li>To opt out of RSS monitoring for a specific task, append <code>[no_handler]</code> to that task’s name.
|
||||
</li>
|
||||
<li>To have the task only monitor RSS feed, do not set timer and add <code>[only_handler]</code> to that
|
||||
task’s 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
|
||||
|
|
|
|||
|
|
@ -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"> ({{ 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}`">
|
||||
({{ config?.app?.version || 'unknown' }})</span>
|
||||
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
|
||||
<span class="is-hidden-mobile"> ({{ config?.app?.ytdlp_version || 'unknown' }})</span>
|
||||
- <NuxtLink :to="`/changelog?version=${config?.app?.version || 'unknown'}`">CHANGELOG</NuxtLink>
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ const CONFIG_KEYS = {
|
|||
ytdlp_cli: '',
|
||||
file_logging: false,
|
||||
is_native: false,
|
||||
app_version: '',
|
||||
app_commit_sha: '',
|
||||
app_build_date: '',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue