1
0
mirror of https://github.com/darkzoul5/YoutubePlaylistSync.git synced 2026-07-03 04:23:59 +03:00

19 Commits

Author SHA1 Message Date
dark_zoul 9ec8974496 fix(build): change asset path to absolute 2026-05-17 14:38:25 +03:00
dark_zoul 410984bc09 feat: add “start minimized to tray” setting 2026-05-17 13:56:33 +03:00
dark_zoul 49fedecd43 feat: add app icon;
feat: add app to tray;
2026-05-17 13:51:15 +03:00
dark_zoul 3291c0c88f readme: add playlist name to config example 2026-05-17 13:38:44 +03:00
dark_zoul b0c531389e readme: update with latest info 2026-05-17 13:36:30 +03:00
dark_zoul b0eaa9d2eb Changed the default SQLite DB location from app/data/ to db/ 2026-05-17 13:27:46 +03:00
dark_zoul 5c6f4b92ef fix: change pyinstaller entrypoint from cli to gui 2026-05-17 13:14:22 +03:00
dark_zoul 6e948f16f2 ci: change default tag input 2026-05-17 13:07:36 +03:00
dark_zoul e9d8c55b1c ci: run tests only on code changes 2026-05-17 12:34:44 +03:00
dark_zoul 9ebed4b92a ci: fix name 2026-05-17 12:32:29 +03:00
dark_zoul 988c938a9e feat: add quality options to include 2160p, 1440p, and a best setting. 2026-05-17 12:30:06 +03:00
dark_zoul a1217a78c3 fix(ci): tighten artifact upload path regex 2026-05-17 12:25:22 +03:00
dark_zoul e2af3f3bfd feat(ci): add commits since last tag to release notes 2026-05-17 12:22:43 +03:00
dark_zoul 45eb29fa4d ci(build): change job order 2026-05-17 12:14:38 +03:00
dark_zoul 945688b0a6 readme: fix badge again 2026-05-17 12:11:54 +03:00
dark_zoul 6bd502580b readme: fix badge 2026-05-17 12:11:20 +03:00
dark_zoul 9d19beec08 lint: fix all ruff errorrs 2026-05-17 12:09:58 +03:00
dark_zoul 65b2d4f89c refactor(build): fully rewrite build workflow + release; remove unused workflows 2026-05-17 12:00:41 +03:00
dark_zoul c046c59fd2 ci(build): remove yt-dlp.exe dependency from build workflow 2026-05-17 11:21:14 +03:00
21 changed files with 562 additions and 290 deletions
+289
View File
@@ -0,0 +1,289 @@
name: Build & Release
on:
workflow_dispatch:
inputs:
tag:
description: "Tag to create (e.g., v2.0.0)"
required: true
default: "v2.0.0"
type: string
force_tag:
description: "Recreate tag if it already exists"
required: false
default: false
type: boolean
draft:
description: "Create release as draft"
required: false
default: true
type: boolean
permissions:
contents: write
jobs:
tag:
name: Create tag
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate input tag
shell: bash
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
if [[ -z "$TAG" ]]; then
echo "tag is required" >&2
exit 1
fi
if [[ "$TAG" != v* ]]; then
echo "tag must start with 'v' (got: $TAG)" >&2
exit 1
fi
- name: Create/push tag
shell: bash
env:
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force_tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
if [[ "${FORCE}" != "true" ]]; then
echo "Tag ${TAG} already exists. Re-run with force_tag=true to recreate." >&2
exit 1
fi
git tag -d "${TAG}" || true
git push origin ":refs/tags/${TAG}" || true
fi
git tag "${TAG}" "${GITHUB_SHA}"
git push origin "${TAG}"
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -e .
pip install pytest
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}/src
run: pytest
build:
name: Build packages
needs: tag
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
ffmpeg: [bundled, none]
steps:
- uses: actions/checkout@v6
- name: Derive version
id: version
shell: bash
run: |
TAG="${{ inputs.tag }}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install build deps
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pyinstaller
- name: Build binary (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$ws = "${{ github.workspace }}"
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
- name: Build binary (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
- name: Stage package
shell: bash
run: |
set -euo pipefail
OS="${{ runner.os }}"
FFMPEG="${{ matrix.ffmpeg }}"
VERSION="${{ steps.version.outputs.version }}"
PKG_ROOT="$GITHUB_WORKSPACE/package"
rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/config" "$PKG_ROOT/bin"
# Binary
if [[ "$OS" == "Windows" ]]; then
cp "$GITHUB_WORKSPACE/dist/pyinstaller/ytpl-sync.exe" "$PKG_ROOT/ytpl-sync.exe"
else
cp "$GITHUB_WORKSPACE/dist/pyinstaller/ytpl-sync" "$PKG_ROOT/ytpl-sync.exe"
chmod +x "$PKG_ROOT/ytpl-sync.exe"
fi
# Config: ship example as the default config
python - <<'PY'
import json
import os
from pathlib import Path
workspace = Path(os.environ["GITHUB_WORKSPACE"])
pkg_root = workspace / "package"
src = workspace / "config" / "yt-playlist-config.example.json"
dst = pkg_root / "config" / "yt-playlist-config.json"
data = json.loads(src.read_text(encoding="utf-8"))
os_name = os.environ["RUNNER_OS"]
ffmpeg_mode = os.environ["FFMPEG_MODE"]
if ffmpeg_mode == "bundled":
data["ffmpeg_path"] = "./bin/ffmpeg.exe" if os_name == "Windows" else "./bin/ffmpeg"
else:
data["ffmpeg_path"] = "ffmpeg"
dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
PY
env:
FFMPEG_MODE: ${{ matrix.ffmpeg }}
- name: Bundle FFmpeg (Windows)
if: runner.os == 'Windows' && matrix.ffmpeg == 'bundled'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Force -Path "package/bin" | Out-Null
Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "ffmpeg.zip"
Expand-Archive "ffmpeg.zip" -DestinationPath "ffmpeg_tmp"
$ffmpegExe = Get-ChildItem -Path "ffmpeg_tmp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1
if (-not $ffmpegExe) { throw "ffmpeg.exe not found in archive" }
Copy-Item $ffmpegExe.FullName "package/bin/ffmpeg.exe"
Remove-Item -Force "ffmpeg.zip"
Remove-Item -Recurse -Force "ffmpeg_tmp"
- name: Bundle FFmpeg (Linux)
if: runner.os == 'Linux' && matrix.ffmpeg == 'bundled'
shell: bash
run: |
set -euo pipefail
mkdir -p package/bin ffmpeg_tmp
curl -L "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o ffmpeg.tar.xz
tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1
mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg
chmod +x package/bin/ffmpeg
- name: Archive (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ steps.version.outputs.version }}"
$variant = "${{ matrix.ffmpeg }}"
if ($variant -eq "bundled") {
$name = "ytpl-sync-windows-$version-ffmpeg.zip"
} else {
$name = "ytpl-sync-windows-$version.zip"
}
Compress-Archive -Path "package/*" -DestinationPath $name
- name: Archive (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
version="${{ steps.version.outputs.version }}"
variant="${{ matrix.ffmpeg }}"
if [[ "$variant" == "bundled" ]]; then
name="ytpl-sync-linux-${version}-ffmpeg.tar.gz"
else
name="ytpl-sync-linux-${version}.tar.gz"
fi
tar -C package -czf "$name" .
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: packages-${{ runner.os }}-${{ matrix.ffmpeg }}
path: |
ytpl-sync-*.zip
ytpl-sync-*.tar.gz
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Generate release notes (since last tag)
shell: bash
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
git fetch --tags --force
prev_tag="$(git tag --sort=-creatordate | grep -Fxv "$TAG" | head -n 1 || true)"
{
echo "## Changes"
echo
if [[ -n "$prev_tag" ]]; then
echo "Compared to \`$prev_tag\`:"
echo
git log "${prev_tag}..${TAG}" --no-merges --pretty=format:'- %s (%h)' || true
else
echo "First tagged release:"
echo
git log "${TAG}" --no-merges --pretty=format:'- %s (%h)' || true
fi
echo
} > release-notes.md
- name: Create release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ inputs.tag }}
draft: ${{ inputs.draft }}
body_path: release-notes.md
files: |
artifacts/**/*.zip
artifacts/**/*.tar.gz
-162
View File
@@ -1,162 +0,0 @@
name: Build Release V2
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v0.1.0)"
required: true
default: "v0.1.0"
type: string
permissions:
contents: write
packages: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}
run: pytest
build:
needs: test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: windows
os: windows-latest
artifact_name: windows-release
- platform: linux
os: ubuntu-latest
artifact_name: linux-release
steps:
- uses: actions/checkout@v6
- name: Get version
id: version
shell: bash
run: |
TAG="${{ github.event.inputs.tag || inputs.tag }}"
VERSION="${TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
# --- WINDOWS BUILD ---
- name: Build Windows Package
if: matrix.platform == 'windows'
shell: pwsh
run: |
$VERSION = "${{ steps.version.outputs.version }}"
$WORKSPACE = "${{ github.workspace }}"
New-Item -ItemType Directory -Force -Path "$WORKSPACE/dist/windows/bin"
# yt-dlp
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" -OutFile "$WORKSPACE/dist/windows/bin/yt-dlp.exe"
# FFmpeg
Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "$WORKSPACE/dist/windows/ffmpeg.zip"
Expand-Archive "$WORKSPACE/dist/windows/ffmpeg.zip" -DestinationPath "$WORKSPACE/dist/windows/ffmpeg_temp"
$ffmpegExe = Get-ChildItem -Path "$WORKSPACE/dist/windows/ffmpeg_temp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1
Move-Item $ffmpegExe.FullName "$WORKSPACE/dist/windows/bin/ffmpeg.exe"
# Build .exe using PyInstaller
# Use --add-data to include the src folder so internal imports (like 'import cli') work
pip install pyinstaller
pyinstaller --onefile --name "yt-playlist-downloader" --workpath "$WORKSPACE/build" --specpath "$WORKSPACE" --distpath "$WORKSPACE/dist" --add-data "$WORKSPACE/src;src" "$WORKSPACE/yt-playlist-main.py"
Move-Item "$WORKSPACE/dist/yt-playlist-downloader.exe" "$WORKSPACE/dist/windows/yt-playlist-downloader.exe"
# Cleanup & Archive
Remove-Item -Recurse -Force "$WORKSPACE/dist/windows/ffmpeg_temp", "$WORKSPACE/dist/windows/aria2_temp", "$WORKSPACE/dist/windows/*.zip"
Compress-Archive -Path "$WORKSPACE/dist/windows/*" -DestinationPath "$WORKSPACE/yt-playlist-windows-$VERSION.zip"
# --- LINUX BUILD ---
- name: Build Linux Package
if: matrix.platform == 'linux'
run: |
VERSION="${{ steps.version.outputs.version }}"
mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin"
cp "$GITHUB_WORKSPACE/yt-playlist-main.py" "$GITHUB_WORKSPACE/dist/linux/"
cp -r "$GITHUB_WORKSPACE/src" "$GITHUB_WORKSPACE/dist/linux/"
# yt-dlp
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o "$GITHUB_WORKSPACE/dist/linux/bin/yt-dlp"
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/yt-dlp"
# FFmpeg (static)
curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o "$GITHUB_WORKSPACE/ffmpeg.tar.xz"
mkdir -p "$GITHUB_WORKSPACE/ffmpeg_temp"
tar -xf "$GITHUB_WORKSPACE/ffmpeg.tar.xz" -C "$GITHUB_WORKSPACE/ffmpeg_temp" --strip-components=1
mv "$GITHUB_WORKSPACE/ffmpeg_temp/ffmpeg" "$GITHUB_WORKSPACE/dist/linux/bin/"
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg"
- name: Archive Linux Package
if: matrix.platform == 'linux'
run: |
VERSION="${{ steps.version.outputs.version }}"
# Archive
cd "$GITHUB_WORKSPACE/dist/linux" && tar -czf "$GITHUB_WORKSPACE/yt-playlist-linux-$VERSION.tar.gz" *
- name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact_name }}
path: |
${{ github.workspace }}/*.zip
${{ github.workspace }}/*.tar.gz
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.event.inputs.tag, 'v')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: ${{ github.workspace }}/artifacts
- name: Get version
id: version
shell: bash
run: |
TAG="${{ github.event.inputs.tag || inputs.tag }}"
VERSION="${TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push Docker images
run: |
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar"
docker push ghcr.io/${GITHUB_ACTOR}/ytpl-Sync:${{ steps.version.outputs.version }}
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image-latest.tar"
docker push ghcr.io/${GITHUB_ACTOR}/ytpl-Sync:latest
- name: Create Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.event.inputs.tag }}
draft: true
files: |
${{ github.workspace }}/artifacts/**/*.zip
${{ github.workspace }}/artifacts/**/*.tar.gz
-64
View File
@@ -1,64 +0,0 @@
name: dependency-updates
on:
workflow_dispatch:
schedule:
- cron: "0 12 * * 1"
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
update-bundled-binaries:
name: Refresh pinned build dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Discover latest aria2 release
id: aria2
shell: bash
run: |
set -euo pipefail
latest_version="$(
git ls-remote --tags --refs https://github.com/aria2/aria2.git 'refs/tags/release-*' \
| awk -F/ '{print $NF}' \
| sed 's/^release-//' \
| sort -V \
| tail -n 1
)"
if [ -z "$latest_version" ]; then
echo "Unable to determine the latest aria2 release." >&2
exit 1
fi
echo "version=$latest_version" >> "$GITHUB_OUTPUT"
- name: Update release workflow pins
shell: bash
env:
ARIA2_VERSION: ${{ steps.aria2.outputs.version }}
run: |
set -euo pipefail
python3 -c 'from pathlib import Path; import os; path = Path(".github/workflows/build_v2.yml"); text = path.read_text(encoding="utf-8"); version = os.environ["ARIA2_VERSION"]; updated = text.replace("release-1.37.0", f"release-{version}").replace("aria2-1.37.0", f"aria2-{version}").replace("aria2c-linux-1.37.0", f"aria2c-linux-{version}"); path.write_text(updated, encoding="utf-8") if updated != text else None; print(f"Updated aria2 references to {version}." if updated != text else "No aria2 references changed.")'
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: refresh aria2 build pins"
title: "Refresh aria2 build pins"
body: |
Automated maintenance update for the bundled aria2 release references used by the release workflow.
branch: dependency-updates/aria2
delete-branch: true
labels: dependencies, maintenance
+14 -2
View File
@@ -5,9 +5,21 @@ on:
push:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "pyproject.toml"
- "pytest.ini"
- "ytpl-sync-entry.py"
pull_request:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "pyproject.toml"
- "pytest.ini"
- "ytpl-sync-entry.py"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -22,10 +34,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
+1 -1
View File
@@ -7,7 +7,7 @@ config/yt-playlist-config.json
/*/tmp*
*.code-workspace
/bin/*
/app/data
/db/*
# Byte-compiled / optimized / DLL files
__pycache__/
+29 -45
View File
@@ -1,6 +1,6 @@
# YouTube Playlist Sync
[![Build Release](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build_v2.yml/badge.svg)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build_v2.yml)
[![Build Release](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build-release.yml/badge.svg)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build-release.yml)
[![Unit tests](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml)
A cross-platform tool for downloading and keeping in sync a local copy of entire YouTube playlists as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp) & [ffmpeg](https://ffmpeg.org/).
@@ -8,28 +8,28 @@ A cross-platform tool for downloading and keeping in sync a local copy of entire
Supports audio, video, or both download modes, music and videos are numbered as they are on your youtube playlist, playlist cleanup, and configurable parallel download options.
Local-first YouTube playlist synchronization client.
## Whats Included
## What's Included
- GUI (PySide6) playlist manager + sync runner
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
- Safe reordering via two-pass rename, recycle deletions
- Async download queue with simple retry (yt-dlp Python API)
- SQLite metadata; DB updates on rename/download/delete; `last_sync`
- Optional event publishing for future GUI/logs
- SQLite metadata (`last_sync`, download state)
## Requirements
- Python 3.10+
- `ffmpeg` (needed for `audio` and `both` modes)
- If you download a `-ffmpeg` release: no extra dependencies
- If you download a non-ffmpeg release: install `ffmpeg` and ensure it's on PATH (needed for `audio` and `both` modes)
Quick start:
## Download
Download the latest release from [releases](https://github.com/darkzoul5/YoutubePlaylistSyncThing/releases) page
Download the latest release from this repo's Releases page and pick one:
- `ytpl-sync-windows-{version}-ffmpeg.zip` / `ytpl-sync-linux-{version}-ffmpeg.tar.gz` (ffmpeg bundled)
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled)
## Configure
On first run, the app will auto-create a default `config/yt-playlist-config.json` (if missing).
Create/edit `config/yt-playlist-config.json`:
Application uses a json config that canbe edited from UI or manually
```json
{
@@ -42,32 +42,24 @@ Create/edit `config/yt-playlist-config.json`:
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
"download_mode": "video",
"max_download_quality": "1080p",
"save_path": "./downloads"
"save_path": "./downloads",
"name": "my favorite playlist"
}
]
}
```
Defaults:
- `ffmpeg_path`: `./bin/ffmpeg.exe` (Windows) or `./bin/ffmpeg` (Linux)
- `download_mode`: `video`
- `max_download_quality`: `1080p`
- `save_path`: `./downloads`
- `max_parallel_downloads`: `2`
- `retry_max_retries`: `2`
- `retry_delay_seconds`: `1.5`
`max_download_quality`:
- Limits yt-dlp download quality (e.g. `"1080p"`, `"720p"`, `"360p"`). This only affects the downloaded video format selection.
- Limits yt-dlp download quality (e.g. `"2160p"`, `"1440p"`, `"1080p"`, `"720p"`, `"360p"`). This only affects the downloaded video format selection.
- Use `"best"` for no height cap (highest available).
- If the requested max quality isn't available for a video, the best available quality is chosen.
`download_mode`:
- `video`: download playlist videos as muxed `.mp4` (no ffmpeg processing)
- `audio`: download muxed `.mp4`, extract `.mp3`, delete the `.mp4`
- `both`: download muxed `.mp4`, extract `.mp3`, keep both files
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
- `audio`: download video, extract `.mp3`, delete the video file
- `both`: download video, extract `.mp3`, keep both files
Queue / retry:
@@ -77,32 +69,24 @@ Queue / retry:
## Run
- Compute-only:
- Run `ytpl-sync.exe` (GUI).
```bash
python -m app.cli
```
- Apply actions:
```bash
python -m app.cli --apply
```
- Single playlist (0-based index):
```bash
python -m app.cli --apply --playlist 0
```
## Tray
- The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit.
- Tray behavior settings (Settings page):
- `close_to_tray`: close hides to tray (keeps running).
- `minimize_to_tray`: minimize hides to tray.
- `start_minimized_to_tray`: start hidden in tray.
## Data & Layout
- Database: `app/data/app.db`
- Database: `db/app.db`
- Outputs: `<save_path>/audio` and/or `<save_path>/video`
- Recycle bin: `<save_path>/.recycle/{audio,video}`
## Roadmap (short)
- Scheduler (periodic sync), richer retries/logging
- GUI (PySide6) wired to EventBus
- Enhanced config validation
- UX polish (settings, progress, error messages)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+9 -1
View File
@@ -3,12 +3,20 @@
"max_parallel_downloads": 2,
"retry_max_retries": 2,
"retry_delay_seconds": 1.5,
"ui": {
"tray": {
"close_to_tray": true,
"minimize_to_tray": false,
"start_minimized_to_tray": false
}
},
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
"download_mode": "video",
"max_download_quality": "1080p",
"save_path": "./downloads"
"save_path": "./downloads",
"name": "my favorite playlist"
}
]
}
+1 -2
View File
@@ -19,7 +19,7 @@ from .core.utils.logging_setup import configure_logging
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="YouTube Playlist Sync — compute/apply actions")
parser.add_argument("--apply", action="store_true", help="Apply actions (otherwise compute-only)")
parser.add_argument("--db", type=Path, default=Path("app/data/app.db"), help="Path to SQLite database")
parser.add_argument("--db", type=Path, default=Path("db/app.db"), help="Path to SQLite database")
parser.add_argument("--playlist", type=int, default=None, help="Only run for a specific playlist index (0-based)")
parser.add_argument("--verbose", action="store_true", help="Print detailed events (rename/recycle/start)")
parser.add_argument("--debug", action="store_true", help="Enable debug logging to console + app/data/app.log")
@@ -45,7 +45,6 @@ def main(argv: list[str] | None = None) -> int:
print(f"START: {vid}{target}")
async def on_completed(payload):
pid = payload.get("playlist_id")
vid = payload.get("video_id")
target = payload.get("target")
print(f"OK: {vid}{target}")
+1 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
def _default_ffmpeg_path() -> str:
+1 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from pathlib import Path
from typing import Iterable, List, Sequence
from typing import List, Sequence
from ..models import FilesystemEntry
-3
View File
@@ -1,8 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass
ILLEGAL_CHARS = '<>:"/\\|?*'
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import sys
from pathlib import Path
from PySide6 import QtGui, QtWidgets
def _resource_base() -> Path:
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
base = getattr(sys, "_MEIPASS", None)
if base:
return Path(str(base))
return Path.cwd()
def load_app_icon() -> QtGui.QIcon:
"""
Best-effort app icon loader.
Looks for `assets/icon.png` in the current working directory (dev),
or in the PyInstaller bundle root (packaged).
"""
candidates = [
Path("assets/icon.png"),
_resource_base() / "assets" / "icon.png",
]
for p in candidates:
try:
if p.exists():
icon = QtGui.QIcon(str(p))
if not icon.isNull():
return icon
except Exception:
pass
# Fallback to a platform theme icon (Linux) or a generic icon.
try:
themed = QtGui.QIcon.fromTheme("applications-multimedia")
if not themed.isNull():
return themed
except Exception:
pass
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
+120 -2
View File
@@ -8,6 +8,8 @@ from PySide6 import QtCore, QtGui, QtWidgets
from ..config.settings import Settings
from ..core.events.event_bus import EventBus
from .bus_bridge import BusBridge
from .app_icon import load_app_icon
from .config_store import load_config
from .runner import SyncRequest, SyncRunner
from .pages.playlists import PlaylistManagerPage
from .pages.queue import QueuePage
@@ -20,6 +22,7 @@ class MainWindow(QtWidgets.QMainWindow):
super().__init__()
self.setWindowTitle("ytpl-sync")
self.resize(1100, 700)
self.setWindowIcon(load_app_icon())
self._settings = Settings()
self._bus = EventBus()
@@ -29,6 +32,8 @@ class MainWindow(QtWidgets.QMainWindow):
self._runner: SyncRunner | None = None
self._cancel_flag: threading.Event | None = None
self._pause_flag: threading.Event | None = None
self._tray: QtWidgets.QSystemTrayIcon | None = None
self._tray_notified = False
# Sidebar navigation
self._nav = QtWidgets.QListWidget()
@@ -87,6 +92,115 @@ class MainWindow(QtWidgets.QMainWindow):
self._playlists_page.resume_requested.connect(self._resume_sync)
self._refresh_queue_labels()
self._init_tray()
def _tray_config(self) -> dict:
# Read from disk so toggles apply immediately (no restart required).
try:
cfg_path = getattr(self._settings, "path", None)
if cfg_path is None:
return {}
raw = load_config(cfg_path).data
ui = raw.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
return dict(tray)
except Exception:
return {}
def _close_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("close_to_tray", True))
def _minimize_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("minimize_to_tray", False))
def _start_minimized_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("start_minimized_to_tray", False))
def should_start_minimized_to_tray(self) -> bool:
return self._tray is not None and self._start_minimized_to_tray_enabled()
def _init_tray(self) -> None:
# Tray support is optional and platform-dependent (e.g., some Linux DEs).
try:
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
return
except Exception:
return
icon = load_app_icon()
tray = QtWidgets.QSystemTrayIcon(icon, self)
tray.setToolTip("ytpl-sync")
menu = QtWidgets.QMenu()
act_toggle = menu.addAction("Show/Hide")
act_quit = menu.addAction("Quit")
tray.setContextMenu(menu)
act_toggle.triggered.connect(self._toggle_visible)
act_quit.triggered.connect(self._quit_from_tray)
tray.activated.connect(self._on_tray_activated)
tray.show()
self._tray = tray
def _toggle_visible(self) -> None:
if self.isVisible():
self.hide()
else:
self.show()
self.raise_()
self.activateWindow()
def _quit_from_tray(self) -> None:
# Ensure the closeEvent doesn't just hide the window.
self._tray = None
QtWidgets.QApplication.quit()
def _on_tray_activated(self, reason: QtWidgets.QSystemTrayIcon.ActivationReason) -> None:
if reason in (
QtWidgets.QSystemTrayIcon.ActivationReason.Trigger,
QtWidgets.QSystemTrayIcon.ActivationReason.DoubleClick,
):
self._toggle_visible()
def closeEvent(self, event: QtGui.QCloseEvent) -> None: # type: ignore[override]
# If tray is active and configured, close-to-tray.
if self._tray is not None and self._close_to_tray_enabled():
event.ignore()
self.hide()
if not self._tray_notified:
self._tray_notified = True
try:
self._tray.showMessage(
"ytpl-sync",
"Still running in the tray. Use the tray icon menu to quit.",
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
3000,
)
except Exception:
pass
return
if self._tray is not None and not self._close_to_tray_enabled():
# Explicitly quit, because the app may be configured to keep running without windows.
try:
event.accept()
except Exception:
pass
QtWidgets.QApplication.quit()
return
super().closeEvent(event)
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
try:
if event.type() == QtCore.QEvent.Type.WindowStateChange:
if self._tray is not None and self._minimize_to_tray_enabled():
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized):
QtCore.QTimer.singleShot(0, self.hide)
except Exception:
pass
super().changeEvent(event)
def _refresh_queue_labels(self) -> None:
try:
@@ -270,7 +384,8 @@ def main() -> int:
app = QtWidgets.QApplication(sys.argv)
app.setApplicationName("ytpl-sync")
app.setOrganizationName("ytpl-sync")
app.setWindowIcon(QtGui.QIcon())
app.setWindowIcon(load_app_icon())
app.setQuitOnLastWindowClosed(False)
# Avoid Qt warnings when a font with invalid point size is inherited from the environment.
f = app.font()
@@ -279,7 +394,10 @@ def main() -> int:
app.setFont(f)
w = MainWindow()
w.show()
if w.should_start_minimized_to_tray():
w.hide()
else:
w.show()
return app.exec()
+2 -2
View File
@@ -130,7 +130,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
last_sync_by_id: dict[str, str] = {}
try:
db = Database(Path("app/data/app.db").resolve())
db = Database(Path("db/app.db").resolve())
for r in rows:
pid = extract_playlist_id(r.url) or r.url
ls = db.get_playlist_last_sync(pid)
@@ -433,7 +433,7 @@ class _PlaylistCard(QtWidgets.QFrame):
self._mode.setCurrentText(row.download_mode or "video")
self._quality = QtWidgets.QComboBox()
self._quality.addItems(["1080p", "720p", "480p", "360p"])
self._quality.addItems(["best", "2160p", "1440p", "1080p", "720p", "480p", "360p"])
self._quality.setEditable(False)
self._quality.setCurrentText(row.max_download_quality or "1080p")
+39
View File
@@ -49,6 +49,23 @@ class SettingsPage(QtWidgets.QWidget):
form_box.setLayout(form)
layout.addWidget(form_box)
tray_form = QtWidgets.QFormLayout()
self._close_to_tray = QtWidgets.QCheckBox()
self._close_to_tray.setChecked(True)
tray_form.addRow("close_to_tray", self._close_to_tray)
self._minimize_to_tray = QtWidgets.QCheckBox()
self._minimize_to_tray.setChecked(False)
tray_form.addRow("minimize_to_tray", self._minimize_to_tray)
self._start_minimized_to_tray = QtWidgets.QCheckBox()
self._start_minimized_to_tray.setChecked(False)
tray_form.addRow("start_minimized_to_tray", self._start_minimized_to_tray)
tray_box = QtWidgets.QGroupBox("Tray behavior")
tray_box.setLayout(tray_form)
layout.addWidget(tray_box)
btns = QtWidgets.QHBoxLayout()
self._reload_btn = QtWidgets.QPushButton("Reload")
self._reload_btn.clicked.connect(self.reload_from_config)
@@ -75,6 +92,9 @@ class SettingsPage(QtWidgets.QWidget):
self._retry_max.valueChanged.connect(lambda _v: self._schedule_autosave())
self._retry_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
self._download_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
self._close_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
self._minimize_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
self._start_minimized_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
def set_config_path(self, path: Path) -> None:
self._config_path = path
@@ -96,6 +116,14 @@ class SettingsPage(QtWidgets.QWidget):
self._retry_delay.setValue(float(self._config.get("retry_delay_seconds") or 1.5))
self._download_delay.setValue(float(self._config.get("delay_between_downloads_seconds") or 0.0))
ui = self._config.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", True)))
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
self._status.setText(f"Loaded settings from {self._config_path}.")
except Exception as exc:
self._status.setText(f"Failed to load settings: {exc}")
@@ -119,6 +147,17 @@ class SettingsPage(QtWidgets.QWidget):
data["retry_max_retries"] = int(self._retry_max.value())
data["retry_delay_seconds"] = float(self._retry_delay.value())
data["delay_between_downloads_seconds"] = float(self._download_delay.value())
ui = data.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
tray["close_to_tray"] = bool(self._close_to_tray.isChecked())
tray["minimize_to_tray"] = bool(self._minimize_to_tray.isChecked())
tray["start_minimized_to_tray"] = bool(self._start_minimized_to_tray.isChecked())
ui["tray"] = tray
data["ui"] = ui
save_config(self._config_path, data)
self._status.setText(f"Saved settings to {self._config_path}.")
except Exception as exc:
+2 -2
View File
@@ -4,7 +4,7 @@ import asyncio
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict
from PySide6 import QtCore
@@ -18,7 +18,7 @@ from ..core.events.event_bus import EventBus
class SyncRequest:
playlist_cfg: Dict[str, Any]
apply: bool = True
db_path: Path = Path("app/data/app.db")
db_path: Path = Path("db/app.db")
cancel_flag: threading.Event | None = None
pause_flag: threading.Event | None = None
+1 -1
View File
@@ -20,7 +20,7 @@ from .core.utils.deps import DependencyError
def bootstrap(db_path: Path | None = None) -> None:
settings = Settings()
db = Database((db_path or Path("app/data/app.db")).resolve())
db = Database((db_path or Path("db/app.db")).resolve())
service = SyncService(db)
executor = ActionExecutor(db)
-1
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from app.core.download.downloader import Downloader
from app.core.download.queue_manager import DownloadJob
+7
View File
@@ -0,0 +1,7 @@
from __future__ import annotations
from app.gui.main import main
if __name__ == "__main__":
raise SystemExit(main())