From 54f0f89dc8e30831b1894688fe92488cd2e9556b Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 27 Dec 2025 20:46:05 +0300 Subject: [PATCH 1/6] Refactor: switch native builds to onedir --- .github/workflows/native-build.yml | 43 +++++++++++++++++++++++++----- app.spec | 40 +++++++++++++-------------- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/.github/workflows/native-build.yml b/.github/workflows/native-build.yml index 4a17e885..d1097d9b 100644 --- a/.github/workflows/native-build.yml +++ b/.github/workflows/native-build.yml @@ -60,6 +60,7 @@ jobs: - name: Replace app.spec with version from workflow branch if: ${{ github.event.inputs.useWorkflowSpec == 'true' }} + shell: bash run: cp temp-spec/app.spec ./app.spec - name: Cache Python venv @@ -78,6 +79,7 @@ jobs: architecture: "x64" - name: Install Python dependencies + shell: bash run: | pip install uv uv venv --system-site-packages --relocatable @@ -97,18 +99,34 @@ jobs: - name: Build frontend working-directory: ui + shell: bash run: | pnpm install --production --prefer-offline --frozen-lockfile pnpm run generate + - name: Clean build outputs (Unix) + if: runner.os != 'Windows' + shell: bash + run: rm -rf build dist release || true + + - name: Clean build outputs (Windows) + if: runner.os == 'Windows' + shell: powershell + run: | + if (Test-Path build) { Remove-Item -Recurse -Force build } + if (Test-Path dist) { Remove-Item -Recurse -Force dist } + if (Test-Path release) { Remove-Item -Recurse -Force release } + - name: Build native binary (Windows) if: matrix.os == 'windows-latest' + shell: powershell run: | .\.venv\Scripts\activate uv run pyinstaller ./app.spec - name: Build native binary (Linux/macOS) if: matrix.os != 'windows-latest' + shell: bash run: | . .venv/bin/activate uv run pyinstaller ./app.spec @@ -117,34 +135,45 @@ jobs: uses: actions/upload-artifact@v4 with: name: app-${{ runner.os }}-${{ matrix.arch }}-${{ env.TAG_NAME }} - path: dist/* + path: dist/YTPTube/** - name: Package artifact (Unix) if: startsWith(env.TAG_NAME, 'v') && runner.os != 'Windows' + shell: bash run: | mkdir -p release + PKG_DIR="YTPTube-${{ env.TAG_NAME }}" ZIP_NAME="ytptube-${{ runner.os }}-${{ matrix.arch }}-${{ env.TAG_NAME }}.zip" - if [ ! -d dist ] || [ -z "$(ls -A dist)" ]; then - echo "dist directory is empty, skipping packaging." + if [ ! -f "dist/YTPTube/YTPTube" ]; then + echo "Missing dist/YTPTube/YTPTube" + find dist -maxdepth 3 -type f | head -n 200 exit 1 fi - zip -r "release/${ZIP_NAME}" dist/ + rm -rf "dist/${PKG_DIR}" + mv "dist/YTPTube" "dist/${PKG_DIR}" + + zip -r "release/${ZIP_NAME}" "dist/${PKG_DIR}" - name: Package artifact (Windows) if: startsWith(env.TAG_NAME, 'v') && runner.os == 'Windows' shell: powershell run: | + $pkgDir = "YTPTube-${{ env.TAG_NAME }}" $zipName = "ytptube-${{ runner.os }}-${{ matrix.arch }}-${{ env.TAG_NAME }}.zip" New-Item -ItemType Directory -Force -Path release | Out-Null - if (!(Test-Path dist) -or !(Get-ChildItem -Path dist)) { - Write-Host "dist directory is empty, skipping packaging." + if (!(Test-Path "dist\YTPTube\YTPTube.exe")) { + Write-Host "Missing dist\YTPTube\YTPTube.exe" + Get-ChildItem -Recurse dist | Select-Object -First 200 | Format-Table -AutoSize exit 1 } - Compress-Archive -Path dist\* -DestinationPath "release\$zipName" + if (Test-Path "dist\$pkgDir") { Remove-Item -Recurse -Force "dist\$pkgDir" } + Move-Item "dist\YTPTube" "dist\$pkgDir" + + Compress-Archive -Path "dist\$pkgDir" -DestinationPath "release\$zipName" -Force - name: Upload to GitHub Release if: startsWith(env.TAG_NAME, 'v') diff --git a/app.spec b/app.spec index 3e33306c..0e783ff9 100644 --- a/app.spec +++ b/app.spec @@ -13,10 +13,6 @@ MANUAL_MAP = { def top_level_modules(dist_name: str) -> list[str]: - """ - Resolve distribution name -> importable top-level modules. - Honors MANUAL_MAP first; falls back to metadata or normalized name. - """ manual: list[str] | None = MANUAL_MAP.get(dist_name) or MANUAL_MAP.get(dist_name.replace("-", "_")) if manual: return manual @@ -24,7 +20,7 @@ def top_level_modules(dist_name: str) -> list[str]: try: dist = importlib.metadata.distribution(dist_name) - top_level = [] + top_level: list[str] = [] for f in dist.files or []: if f.name == "top_level.txt": txt = (dist.locate_file(f)).read_text().splitlines() @@ -44,7 +40,7 @@ def top_level_modules(dist_name: str) -> list[str]: return [dist_name.replace("-", "_")] -def parse_pyproject(path="pyproject.toml") -> set[str]: +def parse_pyproject(path: str = "pyproject.toml") -> set[str]: with open(path, "rb") as f: data = tomllib.load(f) @@ -52,12 +48,11 @@ def parse_pyproject(path="pyproject.toml") -> set[str]: for extra in data["project"].get("optional-dependencies", {}).values(): reqs.update(extra) - # Strip env markers and versions return {re.split(r"[ ;<>=]", r, 1)[0] for r in reqs} # noqa: B034 dist_names = parse_pyproject() -hidden = [] +hidden: list[str] = [] for dist in dist_names: if sys.platform != "win32" and dist in ("python-magic-bin", "tzdata"): @@ -69,14 +64,13 @@ for dist in dist_names: hidden += [ "engineio.async_drivers.aiohttp", "socketio.async_drivers.aiohttp", - "app", - "dotenv" - "socketio", + "socketio", # python-socketio top-level + "engineio", "aiohttp", - "engineio" + "dotenv", + "app", ] -# Deduplicate hidden = sorted(set(hidden)) a = Analysis( # noqa: F821 # type: ignore @@ -90,23 +84,29 @@ a = Analysis( # noqa: F821 # type: ignore hiddenimports=hidden, hookspath=[], runtime_hooks=[], - excludes=["libstdc++.so.6"], + excludes=[], # do NOT exclude libstdc++.so.6 cipher=block_cipher, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) # type: ignore # noqa: F821 -exe = EXE( # type: ignore # noqa: F821 +exe = EXE( # noqa: F821 # type: ignore pyz, a.scripts, - a.binaries, - a.zipfiles, - a.datas, name="YTPTube", debug=False, strip=False, - upx=True, console=True, icon="ui/public/favicon.ico", - onefile=True, + exclude_binaries=True, +) + +coll = COLLECT( # noqa: F821 # type: ignore + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + name="YTPTube", ) From 3268cd4791bc5445461b760012d93fe4f0e2d66a Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 27 Dec 2025 21:42:13 +0300 Subject: [PATCH 2/6] Fix: fix history items sort in simple view --- ui/app/components/Simple.vue | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/ui/app/components/Simple.vue b/ui/app/components/Simple.vue index dcc790fb..d40fbad6 100644 --- a/ui/app/components/Simple.vue +++ b/ui/app/components/Simple.vue @@ -243,27 +243,25 @@ const addInProgress = ref(false) const showExtras = ref(false) const dlFields = useStorage>('dl_fields', {}) const show_thumbnail = useStorage('show_thumbnail', true) - -const sortByNewest = (items: StoreItem[]): StoreItem[] => items.slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)) -const sortByOldest = (items: StoreItem[]): StoreItem[] => items.slice().sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)) - -const queueItems = computed(() => sortByNewest(Object.values(queue.value ?? {}))) -const historyEntries = computed(() => sortByOldest(Object.values(history.value ?? {}))) const paginationInfo = computed(() => stateStore.getPagination()) +const queueItems = computed(() => Object.values(queue.value ?? {}).slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))) + +const historyEntries = computed(() => { + const items = Object.values(history.value ?? {}) + return items.slice().sort((a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime()) +}) + const downloadingStatuses: ReadonlySet = new Set(['downloading', 'postprocessing', 'preparing']) const isDownloading = (status: ItemStatus | null): boolean => downloadingStatuses.has(status) -const downloadingItems = computed(() => queueItems.value.filter(item => isDownloading(item.status))) -const queuedItems = computed(() => queueItems.value.filter(item => !isDownloading(item.status))) - type DisplayEntry = { item: StoreItem; source: 'queue' | 'history' } const displayItems = computed(() => [ - ...downloadingItems.value.map(item => ({ item, source: 'queue' as const })), - ...queuedItems.value.map(item => ({ item, source: 'queue' as const })), - ...historyEntries.value.map(item => ({ item, source: 'history' as const })), + ...queueItems.value.filter(item => isDownloading(item.status)).map(item => ({ item, source: 'queue' as const })), + ...queueItems.value.filter(item => !isDownloading(item.status)).map(item => ({ item, source: 'queue' as const })), + ...historyEntries.value.map(item => ({ item, source: 'history' as const })) ]) const hasActiveQueue = computed(() => queueItems.value.length > 0) From 561f7d03875bc49e9bb93b28aeeeb501d6a6f705 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 27 Dec 2025 21:43:08 +0300 Subject: [PATCH 3/6] Refactor: remove direct as_posix calls --- app/library/Download.py | 2 +- app/library/DownloadQueue.py | 2 +- app/library/Presets.py | 16 ++++++++-------- app/library/config.py | 2 +- app/routes/api/_static.py | 1 - 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/app/library/Download.py b/app/library/Download.py index 1779f687..1b8e12d4 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -243,7 +243,7 @@ class Download: self.logger.debug( f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'." ) - params["cookiefile"] = str(create_cookies_file(self.info.cookies, cookie_file).as_posix()) + params["cookiefile"] = str(create_cookies_file(self.info.cookies, cookie_file)) except Exception as e: err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'." self.logger.error(err_msg) diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index ddbf4003..0262b3da 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -756,7 +756,7 @@ class DownloadQueue(metaclass=Singleton): if item.cookies: try: - yt_conf["cookiefile"] = str(create_cookies_file(item.cookies, cookie_file).as_posix()) + yt_conf["cookiefile"] = str(create_cookies_file(item.cookies, cookie_file)) except Exception as e: msg = f"Failed to create cookie file for '{item.url}'. '{e!s}'." LOG.error(msg) diff --git a/app/library/Presets.py b/app/library/Presets.py index e53f5562..9ddfbd74 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -19,34 +19,34 @@ DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [ "id": "3e163c6c-64eb-4448-924f-814b629b3810", "name": "default", "default": True, - "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log", + "cli": "--socket-timeout 30 --download-archive %(archive_file)s", "description": "Default preset for yt-dlp. It will download whatever yt-dlp decides is the best quality for the video and audio.", }, { "id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48d", "name": "Mobile", - "cli": '--socket-timeout 30 --download-archive %(config_path)s/archive.log\n-t mp4 --merge-output-format mp4 --add-chapters --remux-video mp4 \n--embed-metadata --embed-thumbnail \n--postprocessor-args "-movflags +faststart"', + "cli": '--socket-timeout 30 --download-archive %(archive_file)s\n-t mp4 --merge-output-format mp4 --add-chapters --remux-video mp4 \n--embed-metadata --embed-thumbnail \n--postprocessor-args "-movflags +faststart"', "default": True, "description": "This preset is designed for mobile devices. It will download the best quality video and audio in mp4 format, merge them, and add chapters, metadata, and thumbnail.", }, { "id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b", "name": "1080p", - "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log\n-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", + "cli": "--socket-timeout 30 --download-archive %(archive_file)s\n-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", "default": True, "description": "Download the best quality video and audio in mp4 format for 1080p resolution.", }, { "id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e", "name": "720p", - "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log\n-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", + "cli": "--socket-timeout 30 --download-archive %(archive_file)s\n-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", "default": True, "description": "Download the best quality video and audio in mp4 format for 720p resolution.", }, { "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330", "name": "Audio Only", - "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'", + "cli": "--socket-timeout 30 --download-archive %(archive_file)s\n--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'", "default": True, "description": "This preset is designed to download only the audio of the video. It will extract the audio, add chapters, metadata, and thumbnail.", }, @@ -55,7 +55,7 @@ DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [ "name": "Info Reader Plugin", "description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary', "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(id)s].%(ext)s", - "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata", + "cli": "--socket-timeout 30 --download-archive %(archive_file)s\n--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata", "default": True, }, { @@ -63,7 +63,7 @@ DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [ "name": "NFO Maker TV", "description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for episodes.", "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s", - "cli": '--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--use-postprocessor "NFOMakerPP:when=after_move" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail', + "cli": '--socket-timeout 30 --download-archive %(archive_file)s\n--use-postprocessor "NFOMakerPP:when=after_move" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail', "default": True, }, { @@ -71,7 +71,7 @@ DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [ "name": "NFO Maker Movie", "description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for movies.", "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s", - "cli": '--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--use-postprocessor "NFOMakerPP:when=after_move;mode=movie" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail', + "cli": '--socket-timeout 30 --download-archive %(archive_file)s\n--use-postprocessor "NFOMakerPP:when=after_move;mode=movie" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail', "default": True, }, ] diff --git a/app/library/config.py b/app/library/config.py index bf1a82bf..be384bbb 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -528,7 +528,7 @@ class Config(metaclass=Singleton): dict[str, str]: The replacer variables. """ - keys: tuple[str] = ("download_path", "temp_path", "config_path") + keys: tuple[str] = ("download_path", "temp_path", "config_path", "archive_file") return {k: getattr(self, k) for k in keys} @staticmethod diff --git a/app/routes/api/_static.py b/app/routes/api/_static.py index a06a20a9..7cf9155d 100644 --- a/app/routes/api/_static.py +++ b/app/routes/api/_static.py @@ -112,7 +112,6 @@ def preload_static(root_path: Path, config: Config) -> None: continue uri_path: str = f"/{file.relative_to(static_dir).as_posix()!s}" - # uri_path: str = f"/{str(file.as_posix()).replace(f'{static_dir.as_posix()!s}/', '')}" contentType = EXT_TO_MIME.get(file.suffix) if not contentType: contentType = MIME.from_file(str(file)) From b6b16666f653fc20473432d75c6f18211bb1af4a Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 27 Dec 2025 21:46:21 +0300 Subject: [PATCH 4/6] make the release notes retain the previous text --- .github/workflows/main.yml | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8092db4a..ba7ffe29 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -421,42 +421,53 @@ jobs: script: | const tag = context.ref.replace('refs/tags/', ''); - const latestRelease = await github.rest.repos.listReleases({ + const { data: releases } = await github.rest.repos.listReleases({ owner: context.repo.owner, repo: context.repo.repo, }); - const release = latestRelease.data.find(r => r.tag_name === tag); + const release = releases.find(r => r.tag_name === tag); if (!release) { core.setFailed(`Release with tag ${tag} not found`); return; } + const baseTag = releases[1]?.tag_name || ''; + if (!baseTag) { + core.setFailed('Could not determine a base tag (no previous release found)'); + return; + } + const { data: comparison } = await github.rest.repos.compareCommits({ owner: context.repo.owner, repo: context.repo.repo, - base: latestRelease.data[1]?.tag_name || '', + base: baseTag, head: tag, }); - const commits = comparison.commits.filter(c => 1 === c.parents.length); + const commits = comparison.commits.filter(c => c.parents.length === 1); - const changelog = commits.map( - c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}` - ).join('\n'); + const changelog = commits + .map(c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}`) + .join('\n'); if (!changelog) { core.setFailed('No commits found for the changelog'); return; } - console.log(`Changelog for ${tag}:\n${changelog}`); + const existingBody = (release.body || '').trim(); + + const separator = `\n\n---\n\n## Commits since ${baseTag}\n\n`; + const newBody = existingBody + ? `${existingBody}${separator}${changelog}` + : `## Commits since ${baseTag}\n\n${changelog}`; await github.rest.repos.updateRelease({ owner: context.repo.owner, repo: context.repo.repo, release_id: release.id, - body: changelog + body: newBody, }); dockerhub-sync-readme: From 4ad218e97dd67b79f44201b6782cf27bb4488d54 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 27 Dec 2025 21:52:41 +0300 Subject: [PATCH 5/6] Fix: returning response from finally block --- app/routes/api/browser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index 80cf1257..cafaa9b9 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -596,5 +596,5 @@ async def stream_zip_download(request: Request, config: Config, cache: Cache) -> LOG.info("Download cancelled by client.") except Exception as e: LOG.error(f"Streaming zip download error. {type(e).__name__}: {e}") - finally: - return response # noqa: B012 + + return response From 4773e73d88556f4943ecc6859f44b13fd29f2240 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 27 Dec 2025 21:58:32 +0300 Subject: [PATCH 6/6] refactor: dont raise GracefulExit on app_shutdown --- app/routes/api/system.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/routes/api/system.py b/app/routes/api/system.py index 79a538b8..a1edbd99 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -4,7 +4,6 @@ import time from aiohttp import web from aiohttp.web import Request, Response -from aiohttp.web_runner import GracefulExit from app.library.config import Config from app.library.DownloadQueue import DownloadQueue @@ -118,7 +117,6 @@ async def shutdown_system(request: Request, config: Config, encoder: Encoder, no await asyncio.sleep(0.5) await app.shutdown() await app.cleanup() - raise GracefulExit # Schedule shutdown after response asyncio.create_task(do_shutdown())