mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-04 04:53:58 +03:00
Compare commits
19 Commits
235d18ada6
...
9ec8974496
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ec8974496 | |||
| 410984bc09 | |||
| 49fedecd43 | |||
| 3291c0c88f | |||
| b0c531389e | |||
| b0eaa9d2eb | |||
| 5c6f4b92ef | |||
| 6e948f16f2 | |||
| e9d8c55b1c | |||
| 9ebed4b92a | |||
| 988c938a9e | |||
| a1217a78c3 | |||
| e2af3f3bfd | |||
| 45eb29fa4d | |||
| 945688b0a6 | |||
| 6bd502580b | |||
| 9d19beec08 | |||
| 65b2d4f89c | |||
| c046c59fd2 |
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -5,9 +5,21 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths:
|
||||||
|
- "src/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- "pytest.ini"
|
||||||
|
- "ytpl-sync-entry.py"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths:
|
||||||
|
- "src/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- "pytest.ini"
|
||||||
|
- "ytpl-sync-entry.py"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -22,10 +34,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ config/yt-playlist-config.json
|
|||||||
/*/tmp*
|
/*/tmp*
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
/bin/*
|
/bin/*
|
||||||
/app/data
|
/db/*
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# YouTube Playlist Sync
|
# YouTube Playlist Sync
|
||||||
|
|
||||||
[](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build_v2.yml)
|
[](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build-release.yml)
|
||||||
[](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml)
|
[](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/).
|
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.
|
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.
|
Local-first YouTube playlist synchronization client.
|
||||||
|
|
||||||
## What’s Included
|
## What's Included
|
||||||
|
|
||||||
|
- GUI (PySide6) playlist manager + sync runner
|
||||||
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
|
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
|
||||||
- Safe reordering via two-pass rename, recycle deletions
|
- Safe reordering via two-pass rename, recycle deletions
|
||||||
- Async download queue with simple retry (yt-dlp Python API)
|
- Async download queue with simple retry (yt-dlp Python API)
|
||||||
- SQLite metadata; DB updates on rename/download/delete; `last_sync`
|
- SQLite metadata (`last_sync`, download state)
|
||||||
- Optional event publishing for future GUI/logs
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- If you download a `-ffmpeg` release: no extra dependencies
|
||||||
- `ffmpeg` (needed for `audio` and `both` modes)
|
- 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
|
## Configure
|
||||||
|
Application uses a json config that canbe edited from UI or manually
|
||||||
On first run, the app will auto-create a default `config/yt-playlist-config.json` (if missing).
|
|
||||||
|
|
||||||
Create/edit `config/yt-playlist-config.json`:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -42,32 +42,24 @@ Create/edit `config/yt-playlist-config.json`:
|
|||||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
||||||
"download_mode": "video",
|
"download_mode": "video",
|
||||||
"max_download_quality": "1080p",
|
"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`:
|
`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.
|
- If the requested max quality isn't available for a video, the best available quality is chosen.
|
||||||
|
|
||||||
`download_mode`:
|
`download_mode`:
|
||||||
|
|
||||||
- `video`: download playlist videos as muxed `.mp4` (no ffmpeg processing)
|
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
|
||||||
- `audio`: download muxed `.mp4`, extract `.mp3`, delete the `.mp4`
|
- `audio`: download video, extract `.mp3`, delete the video file
|
||||||
- `both`: download muxed `.mp4`, extract `.mp3`, keep both files
|
- `both`: download video, extract `.mp3`, keep both files
|
||||||
|
|
||||||
Queue / retry:
|
Queue / retry:
|
||||||
|
|
||||||
@@ -77,32 +69,24 @@ Queue / retry:
|
|||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
- Compute-only:
|
- Run `ytpl-sync.exe` (GUI).
|
||||||
|
|
||||||
```bash
|
## Tray
|
||||||
python -m app.cli
|
|
||||||
```
|
- 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):
|
||||||
- Apply actions:
|
- `close_to_tray`: close hides to tray (keeps running).
|
||||||
|
- `minimize_to_tray`: minimize hides to tray.
|
||||||
```bash
|
- `start_minimized_to_tray`: start hidden in tray.
|
||||||
python -m app.cli --apply
|
|
||||||
```
|
|
||||||
|
|
||||||
- Single playlist (0-based index):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m app.cli --apply --playlist 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data & Layout
|
## Data & Layout
|
||||||
|
|
||||||
- Database: `app/data/app.db`
|
- Database: `db/app.db`
|
||||||
- Outputs: `<save_path>/audio` and/or `<save_path>/video`
|
- Outputs: `<save_path>/audio` and/or `<save_path>/video`
|
||||||
- Recycle bin: `<save_path>/.recycle/{audio,video}`
|
- Recycle bin: `<save_path>/.recycle/{audio,video}`
|
||||||
|
|
||||||
## Roadmap (short)
|
## Roadmap (short)
|
||||||
|
|
||||||
- Scheduler (periodic sync), richer retries/logging
|
- Scheduler (periodic sync), richer retries/logging
|
||||||
- GUI (PySide6) wired to EventBus
|
|
||||||
- Enhanced config validation
|
- Enhanced config validation
|
||||||
|
- UX polish (settings, progress, error messages)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -3,12 +3,20 @@
|
|||||||
"max_parallel_downloads": 2,
|
"max_parallel_downloads": 2,
|
||||||
"retry_max_retries": 2,
|
"retry_max_retries": 2,
|
||||||
"retry_delay_seconds": 1.5,
|
"retry_delay_seconds": 1.5,
|
||||||
|
"ui": {
|
||||||
|
"tray": {
|
||||||
|
"close_to_tray": true,
|
||||||
|
"minimize_to_tray": false,
|
||||||
|
"start_minimized_to_tray": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"playlists": [
|
"playlists": [
|
||||||
{
|
{
|
||||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
|
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
|
||||||
"download_mode": "video",
|
"download_mode": "video",
|
||||||
"max_download_quality": "1080p",
|
"max_download_quality": "1080p",
|
||||||
"save_path": "./downloads"
|
"save_path": "./downloads",
|
||||||
|
"name": "my favorite playlist"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -19,7 +19,7 @@ from .core.utils.logging_setup import configure_logging
|
|||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(description="YouTube Playlist Sync — compute/apply actions")
|
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("--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("--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("--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")
|
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}")
|
print(f"START: {vid} → {target}")
|
||||||
|
|
||||||
async def on_completed(payload):
|
async def on_completed(payload):
|
||||||
pid = payload.get("playlist_id")
|
|
||||||
vid = payload.get("video_id")
|
vid = payload.get("video_id")
|
||||||
target = payload.get("target")
|
target = payload.get("target")
|
||||||
print(f"OK: {vid} → {target}")
|
print(f"OK: {vid} → {target}")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
def _default_ffmpeg_path() -> str:
|
def _default_ffmpeg_path() -> str:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, List, Sequence
|
from typing import List, Sequence
|
||||||
|
|
||||||
from ..models import FilesystemEntry
|
from ..models import FilesystemEntry
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
ILLEGAL_CHARS = '<>:"/\\|?*'
|
ILLEGAL_CHARS = '<>:"/\\|?*'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -8,6 +8,8 @@ from PySide6 import QtCore, QtGui, QtWidgets
|
|||||||
from ..config.settings import Settings
|
from ..config.settings import Settings
|
||||||
from ..core.events.event_bus import EventBus
|
from ..core.events.event_bus import EventBus
|
||||||
from .bus_bridge import BusBridge
|
from .bus_bridge import BusBridge
|
||||||
|
from .app_icon import load_app_icon
|
||||||
|
from .config_store import load_config
|
||||||
from .runner import SyncRequest, SyncRunner
|
from .runner import SyncRequest, SyncRunner
|
||||||
from .pages.playlists import PlaylistManagerPage
|
from .pages.playlists import PlaylistManagerPage
|
||||||
from .pages.queue import QueuePage
|
from .pages.queue import QueuePage
|
||||||
@@ -20,6 +22,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle("ytpl-sync")
|
self.setWindowTitle("ytpl-sync")
|
||||||
self.resize(1100, 700)
|
self.resize(1100, 700)
|
||||||
|
self.setWindowIcon(load_app_icon())
|
||||||
|
|
||||||
self._settings = Settings()
|
self._settings = Settings()
|
||||||
self._bus = EventBus()
|
self._bus = EventBus()
|
||||||
@@ -29,6 +32,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
self._runner: SyncRunner | None = None
|
self._runner: SyncRunner | None = None
|
||||||
self._cancel_flag: threading.Event | None = None
|
self._cancel_flag: threading.Event | None = None
|
||||||
self._pause_flag: threading.Event | None = None
|
self._pause_flag: threading.Event | None = None
|
||||||
|
self._tray: QtWidgets.QSystemTrayIcon | None = None
|
||||||
|
self._tray_notified = False
|
||||||
|
|
||||||
# Sidebar navigation
|
# Sidebar navigation
|
||||||
self._nav = QtWidgets.QListWidget()
|
self._nav = QtWidgets.QListWidget()
|
||||||
@@ -87,6 +92,115 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
self._playlists_page.resume_requested.connect(self._resume_sync)
|
self._playlists_page.resume_requested.connect(self._resume_sync)
|
||||||
|
|
||||||
self._refresh_queue_labels()
|
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:
|
def _refresh_queue_labels(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -270,7 +384,8 @@ def main() -> int:
|
|||||||
app = QtWidgets.QApplication(sys.argv)
|
app = QtWidgets.QApplication(sys.argv)
|
||||||
app.setApplicationName("ytpl-sync")
|
app.setApplicationName("ytpl-sync")
|
||||||
app.setOrganizationName("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.
|
# Avoid Qt warnings when a font with invalid point size is inherited from the environment.
|
||||||
f = app.font()
|
f = app.font()
|
||||||
@@ -279,7 +394,10 @@ def main() -> int:
|
|||||||
app.setFont(f)
|
app.setFont(f)
|
||||||
|
|
||||||
w = MainWindow()
|
w = MainWindow()
|
||||||
w.show()
|
if w.should_start_minimized_to_tray():
|
||||||
|
w.hide()
|
||||||
|
else:
|
||||||
|
w.show()
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
|
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
|
||||||
last_sync_by_id: dict[str, str] = {}
|
last_sync_by_id: dict[str, str] = {}
|
||||||
try:
|
try:
|
||||||
db = Database(Path("app/data/app.db").resolve())
|
db = Database(Path("db/app.db").resolve())
|
||||||
for r in rows:
|
for r in rows:
|
||||||
pid = extract_playlist_id(r.url) or r.url
|
pid = extract_playlist_id(r.url) or r.url
|
||||||
ls = db.get_playlist_last_sync(pid)
|
ls = db.get_playlist_last_sync(pid)
|
||||||
@@ -433,7 +433,7 @@ class _PlaylistCard(QtWidgets.QFrame):
|
|||||||
self._mode.setCurrentText(row.download_mode or "video")
|
self._mode.setCurrentText(row.download_mode or "video")
|
||||||
|
|
||||||
self._quality = QtWidgets.QComboBox()
|
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.setEditable(False)
|
||||||
self._quality.setCurrentText(row.max_download_quality or "1080p")
|
self._quality.setCurrentText(row.max_download_quality or "1080p")
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ class SettingsPage(QtWidgets.QWidget):
|
|||||||
form_box.setLayout(form)
|
form_box.setLayout(form)
|
||||||
layout.addWidget(form_box)
|
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()
|
btns = QtWidgets.QHBoxLayout()
|
||||||
self._reload_btn = QtWidgets.QPushButton("Reload")
|
self._reload_btn = QtWidgets.QPushButton("Reload")
|
||||||
self._reload_btn.clicked.connect(self.reload_from_config)
|
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_max.valueChanged.connect(lambda _v: self._schedule_autosave())
|
||||||
self._retry_delay.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._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:
|
def set_config_path(self, path: Path) -> None:
|
||||||
self._config_path = path
|
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._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))
|
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}.")
|
self._status.setText(f"Loaded settings from {self._config_path}.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._status.setText(f"Failed to load settings: {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_max_retries"] = int(self._retry_max.value())
|
||||||
data["retry_delay_seconds"] = float(self._retry_delay.value())
|
data["retry_delay_seconds"] = float(self._retry_delay.value())
|
||||||
data["delay_between_downloads_seconds"] = float(self._download_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)
|
save_config(self._config_path, data)
|
||||||
self._status.setText(f"Saved settings to {self._config_path}.")
|
self._status.setText(f"Saved settings to {self._config_path}.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict
|
||||||
|
|
||||||
from PySide6 import QtCore
|
from PySide6 import QtCore
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ from ..core.events.event_bus import EventBus
|
|||||||
class SyncRequest:
|
class SyncRequest:
|
||||||
playlist_cfg: Dict[str, Any]
|
playlist_cfg: Dict[str, Any]
|
||||||
apply: bool = True
|
apply: bool = True
|
||||||
db_path: Path = Path("app/data/app.db")
|
db_path: Path = Path("db/app.db")
|
||||||
cancel_flag: threading.Event | None = None
|
cancel_flag: threading.Event | None = None
|
||||||
pause_flag: threading.Event | None = None
|
pause_flag: threading.Event | None = None
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ from .core.utils.deps import DependencyError
|
|||||||
|
|
||||||
def bootstrap(db_path: Path | None = None) -> None:
|
def bootstrap(db_path: Path | None = None) -> None:
|
||||||
settings = Settings()
|
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)
|
service = SyncService(db)
|
||||||
executor = ActionExecutor(db)
|
executor = ActionExecutor(db)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.core.download.downloader import Downloader
|
from app.core.download.downloader import Downloader
|
||||||
from app.core.download.queue_manager import DownloadJob
|
from app.core.download.queue_manager import DownloadJob
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.gui.main import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user