mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-03 04:23:59 +03:00
Compare commits
24 Commits
bc5ead4d19
...
5f6df549ab
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f6df549ab | |||
| d7f3b98be4 | |||
| 7afdb24302 | |||
| e8f350805b | |||
| df4c7d504b | |||
| ac5a98a09c | |||
| 811ff45dc9 | |||
| c658b9a90d | |||
| b06ab55f99 | |||
| de315d07e0 | |||
| 4dc7d95123 | |||
| 42ba6310a3 | |||
| 0a49676c72 | |||
| 8ec894fc1f | |||
| 868b419d9c | |||
| 56d3ed7fa2 | |||
| b741ca1783 | |||
| f4589cd895 | |||
| 93c87fcd73 | |||
| 1817468ed5 | |||
| 9f65e6e70d | |||
| ecc37bb1fa | |||
| 8d291ba5e9 | |||
| 9597928ffb |
@@ -107,6 +107,12 @@ jobs:
|
|||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Write bundled version file
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -124,14 +130,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$ws = "${{ github.workspace }}"
|
$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"
|
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --add-data "$ws/version.txt;." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||||
|
|
||||||
- name: Build binary (Linux)
|
- name: Build binary (Linux)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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"
|
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --add-data "${GITHUB_WORKSPACE}/version.txt:." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||||
|
|
||||||
- name: Stage package
|
- name: Stage package
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: update yt-dlp
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 10 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: refresh-yt-dlp-pr
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh:
|
||||||
|
name: Update yt-dlp dependency
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Check and bump yt-dlp
|
||||||
|
id: detect
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
python - <<'PY' >> "$GITHUB_OUTPUT"
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
def version_tuple(text: str) -> tuple[int, ...]:
|
||||||
|
parts = re.findall(r"\d+", text)
|
||||||
|
return tuple(int(p) for p in parts)
|
||||||
|
|
||||||
|
pyproject = Path("pyproject.toml")
|
||||||
|
text = pyproject.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
dep_match = re.search(r'^\s*"yt-dlp>=(?P<version>[^"]+)"\s*,?\s*$', text, re.MULTILINE)
|
||||||
|
if not dep_match:
|
||||||
|
raise SystemExit("Could not find yt-dlp dependency in pyproject.toml")
|
||||||
|
|
||||||
|
dep_version = dep_match.group("version")
|
||||||
|
|
||||||
|
latest_payload = urlopen("https://pypi.org/pypi/yt-dlp/json", timeout=30)
|
||||||
|
latest_version = json.load(latest_payload)["info"]["version"]
|
||||||
|
|
||||||
|
needs_update = version_tuple(latest_version) > version_tuple(dep_version)
|
||||||
|
|
||||||
|
print(f"needs_update={'true' if needs_update else 'false'}")
|
||||||
|
print(f"latest_yt_dlp={latest_version}")
|
||||||
|
print(f"current_yt_dlp={dep_version}")
|
||||||
|
|
||||||
|
if needs_update:
|
||||||
|
text = re.sub(
|
||||||
|
r'(^\s*"yt-dlp>=)[^"]+(")',
|
||||||
|
rf'\g<1>{latest_version}\2',
|
||||||
|
text,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
pyproject.write_text(text, encoding="utf-8")
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Create or update pull request
|
||||||
|
if: steps.detect.outputs.needs_update == 'true'
|
||||||
|
uses: peter-evans/create-pull-request@v8
|
||||||
|
with:
|
||||||
|
branch: chore/refresh-yt-dlp
|
||||||
|
commit-message: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
|
||||||
|
title: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
|
||||||
|
body: |
|
||||||
|
Automated yt-dlp dependency refresh.
|
||||||
|
|
||||||
|
- Current version: `${{ steps.detect.outputs.current_yt_dlp }}`
|
||||||
|
- Latest version: `${{ steps.detect.outputs.latest_yt_dlp }}`
|
||||||
|
labels: deps
|
||||||
|
delete-branch: false
|
||||||
|
add-paths: |
|
||||||
|
pyproject.toml
|
||||||
@@ -8,6 +8,7 @@ config/yt-playlist-config.json
|
|||||||
*.code-workspace
|
*.code-workspace
|
||||||
/bin/*
|
/bin/*
|
||||||
/db/*
|
/db/*
|
||||||
|
plans
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Local-first YouTube playlist synchronization client.
|
|||||||
|
|
||||||
## What's Included
|
## What's Included
|
||||||
|
|
||||||
- GUI (PySide6) playlist manager + sync runner
|
- GUI (PySide6 Essentials) 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)
|
||||||
@@ -30,6 +30,7 @@ Download the latest release from this repo's Releases page and pick one:
|
|||||||
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no 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
|
Application uses a json config that canbe edited from UI or manually
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -73,7 +74,7 @@ Queue / retry:
|
|||||||
- Run `ytpl-sync.exe` (GUI).
|
- Run `ytpl-sync.exe` (GUI).
|
||||||
|
|
||||||
## Tray
|
## Tray
|
||||||
|
|
||||||
- The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit.
|
- 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):
|
- Tray behavior settings (Settings page):
|
||||||
- `close_to_tray`: close hides to tray (keeps running).
|
- `close_to_tray`: close hides to tray (keeps running).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"retry_delay_seconds": 1.5,
|
"retry_delay_seconds": 1.5,
|
||||||
"ui": {
|
"ui": {
|
||||||
"tray": {
|
"tray": {
|
||||||
"close_to_tray": true,
|
"close_to_tray": false,
|
||||||
"minimize_to_tray": false,
|
"minimize_to_tray": false,
|
||||||
"start_minimized_to_tray": false
|
"start_minimized_to_tray": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# YouTube Playlist Sync — Project Conversion Plan
|
# YouTube Playlist Sync — Project Conversion Plan
|
||||||
|
|
||||||
Repository:
|
|
||||||
|
|
||||||
- [darkzoul5/YoutubePlaylistDownloader](https://github.com/darkzoul5/YoutubePlaylistDownloader?utm_source=chatgpt.com)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ytpl-sync"
|
name = "ytpl-sync"
|
||||||
version = "1.1.1"
|
version = "2.1.1"
|
||||||
description = "YouTube playlist Sync Thing"
|
description = "YouTube playlist Sync"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [ { name = "Dark_Zoul" } ]
|
authors = [ { name = "Dark_Zoul" } ]
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
@@ -13,7 +13,7 @@ keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yt-dlp>=2026.3.17",
|
"yt-dlp>=2026.3.17",
|
||||||
"PySide6",
|
"PySide6_Essentials>=6.11.1",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
test = [
|
test = [
|
||||||
@@ -23,7 +23,7 @@ test = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
|
Home = "https://github.com/darkzoul5/YoutubePlaylistSync"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ytpl-sync = "app.cli:main"
|
ytpl-sync = "app.cli:main"
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ CREATE TABLE IF NOT EXISTS playlist_items (
|
|||||||
|
|
||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
|
"""Thin SQLite persistence layer for playlists and playlist items.
|
||||||
|
|
||||||
|
The database stores the local synchronization state so the sync pipeline
|
||||||
|
can compare remote playlist data with what has already been downloaded,
|
||||||
|
renamed, or marked as removed.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, db_path: Path) -> None:
|
def __init__(self, db_path: Path) -> None:
|
||||||
self.path = db_path
|
self.path = db_path
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -41,10 +48,12 @@ class Database:
|
|||||||
self._migrate()
|
self._migrate()
|
||||||
|
|
||||||
def _migrate(self) -> None:
|
def _migrate(self) -> None:
|
||||||
|
"""Create the schema if this database has not been initialized yet."""
|
||||||
with self._conn:
|
with self._conn:
|
||||||
self._conn.executescript(SCHEMA)
|
self._conn.executescript(SCHEMA)
|
||||||
|
|
||||||
def upsert_playlist_items(self, rows: Iterable[tuple]):
|
def upsert_playlist_items(self, rows: Iterable[tuple]):
|
||||||
|
"""Insert or refresh the cached metadata for playlist entries."""
|
||||||
sql = (
|
sql = (
|
||||||
"INSERT INTO playlist_items (playlist_id, video_id, title, playlist_index, local_filename, downloaded, last_seen) "
|
"INSERT INTO playlist_items (playlist_id, video_id, title, playlist_index, local_filename, downloaded, last_seen) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
|
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
|
||||||
@@ -56,6 +65,7 @@ class Database:
|
|||||||
self._conn.executemany(sql, rows)
|
self._conn.executemany(sql, rows)
|
||||||
|
|
||||||
def get_items_index(self, playlist_id: str) -> dict[str, sqlite3.Row]:
|
def get_items_index(self, playlist_id: str) -> dict[str, sqlite3.Row]:
|
||||||
|
"""Return all cached items for a playlist keyed by video id."""
|
||||||
cur = self._conn.execute(
|
cur = self._conn.execute(
|
||||||
"SELECT * FROM playlist_items WHERE playlist_id = ?",
|
"SELECT * FROM playlist_items WHERE playlist_id = ?",
|
||||||
(playlist_id,),
|
(playlist_id,),
|
||||||
@@ -63,6 +73,7 @@ class Database:
|
|||||||
return {row["video_id"]: row for row in cur.fetchall()}
|
return {row["video_id"]: row for row in cur.fetchall()}
|
||||||
|
|
||||||
def upsert_playlist(self, *, id: str, name: str | None, url: str, path: str, mode: str, auto_sync: int = 0, sync_interval_minutes: int = 0) -> None:
|
def upsert_playlist(self, *, id: str, name: str | None, url: str, path: str, mode: str, auto_sync: int = 0, sync_interval_minutes: int = 0) -> None:
|
||||||
|
"""Insert or update the playlist configuration row."""
|
||||||
sql = (
|
sql = (
|
||||||
"INSERT INTO playlists (id, name, url, path, mode, auto_sync, sync_interval_minutes, last_sync) "
|
"INSERT INTO playlists (id, name, url, path, mode, auto_sync, sync_interval_minutes, last_sync) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, NULL) "
|
"VALUES (?, ?, ?, ?, ?, ?, ?, NULL) "
|
||||||
@@ -73,6 +84,7 @@ class Database:
|
|||||||
self._conn.execute(sql, (id, name, url, path, mode, auto_sync, sync_interval_minutes))
|
self._conn.execute(sql, (id, name, url, path, mode, auto_sync, sync_interval_minutes))
|
||||||
|
|
||||||
def update_local_filename(self, playlist_id: str, video_id: str, local_filename: str | None) -> None:
|
def update_local_filename(self, playlist_id: str, video_id: str, local_filename: str | None) -> None:
|
||||||
|
"""Record the current filename associated with a playlist item."""
|
||||||
with self._conn:
|
with self._conn:
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"UPDATE playlist_items SET local_filename = ?, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
|
"UPDATE playlist_items SET local_filename = ?, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
|
||||||
@@ -80,6 +92,7 @@ class Database:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def mark_downloaded(self, playlist_id: str, video_id: str, downloaded: bool) -> None:
|
def mark_downloaded(self, playlist_id: str, video_id: str, downloaded: bool) -> None:
|
||||||
|
"""Mark whether a playlist item is present on disk."""
|
||||||
with self._conn:
|
with self._conn:
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"UPDATE playlist_items SET downloaded = ?, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
|
"UPDATE playlist_items SET downloaded = ?, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
|
||||||
@@ -87,6 +100,7 @@ class Database:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def clear_file_state(self, playlist_id: str, video_id: str) -> None:
|
def clear_file_state(self, playlist_id: str, video_id: str) -> None:
|
||||||
|
"""Clear filename and downloaded flags after a deletion or recycle."""
|
||||||
with self._conn:
|
with self._conn:
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"UPDATE playlist_items SET local_filename = NULL, downloaded = 0, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
|
"UPDATE playlist_items SET local_filename = NULL, downloaded = 0, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
|
||||||
@@ -94,6 +108,7 @@ class Database:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def set_playlist_last_sync(self, playlist_id: str) -> None:
|
def set_playlist_last_sync(self, playlist_id: str) -> None:
|
||||||
|
"""Store the timestamp of the most recent successful sync."""
|
||||||
with self._conn:
|
with self._conn:
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
|
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
|
||||||
@@ -101,6 +116,7 @@ class Database:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_playlist_last_sync(self, playlist_id: str) -> str | None:
|
def get_playlist_last_sync(self, playlist_id: str) -> str | None:
|
||||||
|
"""Return the last sync timestamp for a playlist, if any."""
|
||||||
cur = self._conn.execute("SELECT last_sync FROM playlists WHERE id = ?", (playlist_id,))
|
cur = self._conn.execute("SELECT last_sync FROM playlists WHERE id = ?", (playlist_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ class DownloadJob:
|
|||||||
|
|
||||||
|
|
||||||
class QueueManager:
|
class QueueManager:
|
||||||
|
"""A small asyncio worker pool for download jobs.
|
||||||
|
|
||||||
|
Jobs are pushed into a shared queue and processed by a fixed number of
|
||||||
|
background tasks. This keeps the downloader concurrency bounded without
|
||||||
|
forcing the caller to manage worker lifetimes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, concurrency: int = 2) -> None:
|
def __init__(self, concurrency: int = 2) -> None:
|
||||||
self._queue: "asyncio.Queue[DownloadJob]" = asyncio.Queue()
|
self._queue: "asyncio.Queue[DownloadJob]" = asyncio.Queue()
|
||||||
self._concurrency = max(1, concurrency)
|
self._concurrency = max(1, concurrency)
|
||||||
@@ -44,6 +51,7 @@ class QueueManager:
|
|||||||
self._stopped = asyncio.Event()
|
self._stopped = asyncio.Event()
|
||||||
|
|
||||||
async def start(self, worker_coro):
|
async def start(self, worker_coro):
|
||||||
|
"""Start the worker tasks that drain the queue."""
|
||||||
async def runner(idx: int):
|
async def runner(idx: int):
|
||||||
while not self._stopped.is_set():
|
while not self._stopped.is_set():
|
||||||
job = await self._queue.get()
|
job = await self._queue.get()
|
||||||
@@ -55,13 +63,16 @@ class QueueManager:
|
|||||||
self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
|
self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
|
"""Cancel all worker tasks and mark the queue as stopped."""
|
||||||
self._stopped.set()
|
self._stopped.set()
|
||||||
for w in self._workers:
|
for w in self._workers:
|
||||||
w.cancel()
|
w.cancel()
|
||||||
self._workers.clear()
|
self._workers.clear()
|
||||||
|
|
||||||
async def enqueue(self, job: DownloadJob):
|
async def enqueue(self, job: DownloadJob):
|
||||||
|
"""Add a job to the shared queue."""
|
||||||
await self._queue.put(job)
|
await self._queue.put(job)
|
||||||
|
|
||||||
async def join(self) -> None:
|
async def join(self) -> None:
|
||||||
|
"""Block until every queued job has been acknowledged."""
|
||||||
await self._queue.join()
|
await self._queue.join()
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ from ..models import PlaylistItem
|
|||||||
|
|
||||||
class PlaylistScanner:
|
class PlaylistScanner:
|
||||||
"""
|
"""
|
||||||
Fetches remote playlist entries using yt-dlp (no downloads).
|
Fetches remote playlist entries using yt-dlp without downloading media.
|
||||||
|
|
||||||
This class intentionally avoids strict dependencies at import time. If
|
The scanner is deliberately lightweight: it extracts remote metadata only
|
||||||
yt_dlp is unavailable, call sites should handle the raised ImportError.
|
and leaves persistence, diffing, and download decisions to higher layers.
|
||||||
|
Import-time dependency checks are avoided so the rest of the application can
|
||||||
|
still start in environments where yt-dlp is unavailable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]:
|
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]:
|
||||||
|
"""Return the current remote playlist entries as `PlaylistItem` records."""
|
||||||
try:
|
try:
|
||||||
import yt_dlp # type: ignore
|
import yt_dlp # type: ignore
|
||||||
except Exception as exc: # pragma: no cover - environment dependent
|
except Exception as exc: # pragma: no cover - environment dependent
|
||||||
|
|||||||
@@ -18,12 +18,25 @@ from ..utils.rate_limit import is_youtube_rate_limit_error
|
|||||||
|
|
||||||
|
|
||||||
class ActionExecutor:
|
class ActionExecutor:
|
||||||
|
"""Apply sync actions against the filesystem and persist their outcome.
|
||||||
|
|
||||||
|
The executor is the imperative half of the sync pipeline: it publishes
|
||||||
|
lifecycle events, performs safe renames and deletions, coordinates the
|
||||||
|
download queue, and updates the database after each job completes.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Database, concurrency: int = 2, event_bus: EventBus | None = None) -> None:
|
def __init__(self, db: Database, concurrency: int = 2, event_bus: EventBus | None = None) -> None:
|
||||||
self.concurrency = max(1, concurrency)
|
self.concurrency = max(1, concurrency)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.bus = event_bus
|
self.bus = event_bus
|
||||||
|
|
||||||
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict, *, cancel_check=None, pause_check=None) -> None:
|
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict, *, cancel_check=None, pause_check=None) -> None:
|
||||||
|
"""Execute a batch of sync actions for one playlist.
|
||||||
|
|
||||||
|
The workflow is intentionally ordered: announce the sync, wait for any
|
||||||
|
pause state to clear, validate dependencies, perform renames, recycle
|
||||||
|
deletions, and finally run downloads with bounded concurrency.
|
||||||
|
"""
|
||||||
actions_list = list(actions)
|
actions_list = list(actions)
|
||||||
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
@@ -123,6 +136,7 @@ class ActionExecutor:
|
|||||||
ensure_ffmpeg_available(str(ffmpeg_hint) if ffmpeg_hint is not None else None)
|
ensure_ffmpeg_available(str(ffmpeg_hint) if ffmpeg_hint is not None else None)
|
||||||
|
|
||||||
async def _apply_renames(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
|
async def _apply_renames(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
|
||||||
|
"""Apply all rename actions in batches separated by output type."""
|
||||||
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||||
audio_renames = []
|
audio_renames = []
|
||||||
video_renames = []
|
video_renames = []
|
||||||
@@ -152,6 +166,7 @@ class ActionExecutor:
|
|||||||
await self.bus.publish("RenameApplied", {"playlist_id": playlist_id, "video_id": a.item.video_id, "to": a.to_name})
|
await self.bus.publish("RenameApplied", {"playlist_id": playlist_id, "video_id": a.item.video_id, "to": a.to_name})
|
||||||
|
|
||||||
def _apply_deletions(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
|
def _apply_deletions(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
|
||||||
|
"""Recycle or remove files that no longer belong to the playlist."""
|
||||||
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||||
recycle_audio = audio_root.parent / ".recycle" / "audio"
|
recycle_audio = audio_root.parent / ".recycle" / "audio"
|
||||||
recycle_video = video_root.parent / ".recycle" / "video"
|
recycle_video = video_root.parent / ".recycle" / "video"
|
||||||
@@ -198,6 +213,7 @@ class ActionExecutor:
|
|||||||
cancel_check=None,
|
cancel_check=None,
|
||||||
pause_check=None,
|
pause_check=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Queue and run download jobs, then persist their final state."""
|
||||||
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
|
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ from ..utils.yt import extract_playlist_id
|
|||||||
|
|
||||||
|
|
||||||
class SyncService:
|
class SyncService:
|
||||||
|
"""High-level orchestration for a single playlist sync pass.
|
||||||
|
|
||||||
|
The service pulls the latest remote playlist snapshot, persists the
|
||||||
|
playlist and item metadata in the database, and asks the diff engine to
|
||||||
|
compare the remote state with the local filesystem.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Database) -> None:
|
def __init__(self, db: Database) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.scanner = PlaylistScanner()
|
self.scanner = PlaylistScanner()
|
||||||
@@ -28,6 +35,12 @@ class SyncService:
|
|||||||
return [".mp4"]
|
return [".mp4"]
|
||||||
|
|
||||||
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
|
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
|
||||||
|
"""Return the sync actions required to bring one playlist in sync.
|
||||||
|
|
||||||
|
This method does not apply any changes itself. It normalizes the
|
||||||
|
configuration, refreshes the playlist/item records in SQLite, and then
|
||||||
|
computes the actions needed for the configured download mode.
|
||||||
|
"""
|
||||||
url: str = playlist_cfg.get("url")
|
url: str = playlist_cfg.get("url")
|
||||||
mode: str = playlist_cfg.get("download_mode", "video")
|
mode: str = playlist_cfg.get("download_mode", "video")
|
||||||
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
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 _read_text(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
|
return text or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
def get_app_version() -> str:
|
||||||
|
"""
|
||||||
|
Returns the packaged app version.
|
||||||
|
|
||||||
|
In release builds this reads from `version.txt` bundled into the EXE.
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
Path("version.txt"),
|
||||||
|
_resource_base() / "version.txt",
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
text = _read_text(candidate)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return "dev"
|
||||||
+122
-27
@@ -15,6 +15,7 @@ from .pages.playlists import PlaylistManagerPage
|
|||||||
from .pages.queue import QueuePage
|
from .pages.queue import QueuePage
|
||||||
from .pages.logs import LogsPage
|
from .pages.logs import LogsPage
|
||||||
from .pages.settings import SettingsPage
|
from .pages.settings import SettingsPage
|
||||||
|
from .pages.about import AboutPage
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QtWidgets.QMainWindow):
|
class MainWindow(QtWidgets.QMainWindow):
|
||||||
@@ -38,29 +39,37 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
# Sidebar navigation
|
# Sidebar navigation
|
||||||
self._nav = QtWidgets.QListWidget()
|
self._nav = QtWidgets.QListWidget()
|
||||||
self._nav.setObjectName("sidebar")
|
self._nav.setObjectName("sidebar")
|
||||||
self._nav.setFixedWidth(220)
|
|
||||||
self._nav.setSpacing(2)
|
self._nav.setSpacing(2)
|
||||||
|
self._nav.setHorizontalScrollBarPolicy(
|
||||||
|
QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||||
|
)
|
||||||
|
self._nav.setVerticalScrollBarPolicy(
|
||||||
|
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||||
|
)
|
||||||
self._nav.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
|
self._nav.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
|
self._nav.model().rowsInserted.connect(self._update_sidebar_width)
|
||||||
|
self._nav.model().dataChanged.connect(self._update_sidebar_width)
|
||||||
|
self._nav.model().rowsRemoved.connect(self._update_sidebar_width)
|
||||||
|
|
||||||
self._stack = QtWidgets.QStackedWidget()
|
self._stack = QtWidgets.QStackedWidget()
|
||||||
self._playlists_page = PlaylistManagerPage(self._settings)
|
self._playlists_page = PlaylistManagerPage(self._settings)
|
||||||
self._queue_page = QueuePage()
|
self._queue_page = QueuePage()
|
||||||
self._logs_page = LogsPage()
|
self._logs_page = LogsPage()
|
||||||
self._settings_page = SettingsPage()
|
self._settings_page = SettingsPage()
|
||||||
|
self._about_page = AboutPage()
|
||||||
|
|
||||||
self._pages: list[QtWidgets.QWidget] = [
|
self._pages: list[QtWidgets.QWidget] = [
|
||||||
self._playlists_page,
|
self._playlists_page,
|
||||||
self._queue_page,
|
self._queue_page,
|
||||||
self._logs_page,
|
self._logs_page,
|
||||||
self._settings_page,
|
self._settings_page,
|
||||||
|
self._about_page,
|
||||||
]
|
]
|
||||||
for p in self._pages:
|
for p in self._pages:
|
||||||
self._stack.addWidget(p)
|
self._stack.addWidget(p)
|
||||||
|
|
||||||
for label in ("Playlists", "Queue", "Logs", "Settings"):
|
for label in ("Playlists", "Queue", "Logs", "Settings", "About"):
|
||||||
item = QtWidgets.QListWidgetItem(label)
|
self._add_sidebar_item(label)
|
||||||
item.setSizeHint(QtCore.QSize(200, 36))
|
|
||||||
self._nav.addItem(item)
|
|
||||||
|
|
||||||
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
|
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
|
||||||
self._nav.setCurrentRow(0)
|
self._nav.setCurrentRow(0)
|
||||||
@@ -93,6 +102,29 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
|
|
||||||
self._refresh_queue_labels()
|
self._refresh_queue_labels()
|
||||||
self._init_tray()
|
self._init_tray()
|
||||||
|
QtCore.QTimer.singleShot(0, self._update_sidebar_width)
|
||||||
|
|
||||||
|
def _add_sidebar_item(self, label: str) -> None:
|
||||||
|
item = QtWidgets.QListWidgetItem(label)
|
||||||
|
self._nav.addItem(item)
|
||||||
|
self._update_sidebar_width()
|
||||||
|
|
||||||
|
def _update_sidebar_width(self, *_args: object) -> None:
|
||||||
|
metrics = self._nav.fontMetrics()
|
||||||
|
max_text_width = 0
|
||||||
|
for row in range(self._nav.count()):
|
||||||
|
item = self._nav.item(row)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
max_text_width = max(max_text_width, metrics.horizontalAdvance(item.text()))
|
||||||
|
|
||||||
|
if max_text_width <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
frame = self._nav.frameWidth() * 2
|
||||||
|
padding = 44
|
||||||
|
target_width = max_text_width + frame + padding
|
||||||
|
self._nav.setFixedWidth(max(120, min(220, target_width)))
|
||||||
|
|
||||||
def _tray_config(self) -> dict:
|
def _tray_config(self) -> dict:
|
||||||
# Read from disk so toggles apply immediately (no restart required).
|
# Read from disk so toggles apply immediately (no restart required).
|
||||||
@@ -110,7 +142,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _close_to_tray_enabled(self) -> bool:
|
def _close_to_tray_enabled(self) -> bool:
|
||||||
return bool(self._tray_config().get("close_to_tray", True))
|
return bool(self._tray_config().get("close_to_tray", False))
|
||||||
|
|
||||||
def _minimize_to_tray_enabled(self) -> bool:
|
def _minimize_to_tray_enabled(self) -> bool:
|
||||||
return bool(self._tray_config().get("minimize_to_tray", False))
|
return bool(self._tray_config().get("minimize_to_tray", False))
|
||||||
@@ -324,13 +356,24 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
def _apply_style(self) -> None:
|
def _apply_style(self) -> None:
|
||||||
self.setStyleSheet(
|
self.setStyleSheet(
|
||||||
"""
|
"""
|
||||||
QMainWindow { background: #0f1115; color: #e6e6e6; }
|
QMainWindow { background: #0f1218; color: #d7dce4; }
|
||||||
QWidget { font-size: 13px; }
|
QWidget { font-size: 13px; color: #d7dce4; }
|
||||||
|
QWidget#playlistsPage,
|
||||||
|
QWidget#queuePage,
|
||||||
|
QWidget#logsPage,
|
||||||
|
QWidget#settingsPage,
|
||||||
|
QWidget#aboutPage {
|
||||||
|
background: #0f1218;
|
||||||
|
}
|
||||||
QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; }
|
QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; }
|
||||||
|
QLabel#cardTitle { font-size: 15px; font-weight: 600; color: #eef2f8; }
|
||||||
|
QLabel[muted="true"] { color: #9aa3b2; }
|
||||||
|
QLabel[link="true"] { color: #6c8bff; }
|
||||||
|
QLabel[link="true"]:hover { color: #8ea7ff; }
|
||||||
|
|
||||||
QListWidget#sidebar {
|
QListWidget#sidebar {
|
||||||
background: #0b0d11;
|
background: #0d1015;
|
||||||
border-right: 1px solid #20242d;
|
border-right: 1px solid #2a3140;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
QListWidget#sidebar::item {
|
QListWidget#sidebar::item {
|
||||||
@@ -339,42 +382,94 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
QListWidget#sidebar::item:selected {
|
QListWidget#sidebar::item:selected {
|
||||||
background: #1e2633;
|
background: #21304a;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
QTableWidget {
|
QTableWidget {
|
||||||
background: #0f1115;
|
background: #171b22;
|
||||||
gridline-color: #20242d;
|
gridline-color: #2a3140;
|
||||||
border: 1px solid #20242d;
|
border: 1px solid #2a3140;
|
||||||
|
}
|
||||||
|
QTableWidget::item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
QPlainTextEdit {
|
||||||
|
background: #11151c;
|
||||||
|
border: 1px solid #2a3140;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #d7dce4;
|
||||||
|
}
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: #0f1218;
|
||||||
|
width: 12px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: #34465f;
|
||||||
|
min-height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical:hover {
|
||||||
|
background: #456183;
|
||||||
|
}
|
||||||
|
QScrollBar::add-line:vertical,
|
||||||
|
QScrollBar::sub-line:vertical,
|
||||||
|
QScrollBar::add-page:vertical,
|
||||||
|
QScrollBar::sub-page:vertical {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
QGroupBox {
|
||||||
|
border: 1px solid #2a3140;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #171b22;
|
||||||
|
}
|
||||||
|
QGroupBox::title {
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
left: 12px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #e2e7ef;
|
||||||
|
background: #171b22;
|
||||||
|
}
|
||||||
|
QFrame#aboutCard {
|
||||||
|
background: #171b22;
|
||||||
|
border: 1px solid #2a3140;
|
||||||
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
QHeaderView::section {
|
QHeaderView::section {
|
||||||
background: #0b0d11;
|
background: #171b22;
|
||||||
color: #cfd3da;
|
color: #d7dce4;
|
||||||
border: 1px solid #20242d;
|
border: 1px solid #2a3140;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background: #1e2633;
|
background: #1e2631;
|
||||||
border: 1px solid #2a3140;
|
border: 1px solid #31405a;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #e6e6e6;
|
color: #d7dce4;
|
||||||
}
|
}
|
||||||
QPushButton:hover { background: #243044; }
|
QPushButton:hover { background: #26344a; }
|
||||||
|
QPushButton:pressed { background: #1a2433; }
|
||||||
|
|
||||||
QFrame#playlistCard {
|
QFrame#playlistCard {
|
||||||
background: #0b0d11;
|
background: #171b22;
|
||||||
border: 1px solid #20242d;
|
border: 1px solid #2a3140;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
QLineEdit, QComboBox {
|
QLineEdit, QComboBox {
|
||||||
background: #0f1115;
|
background: #11151c;
|
||||||
border: 1px solid #20242d;
|
border: 1px solid #2a3140;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
color: #e6e6e6;
|
color: #d7dce4;
|
||||||
|
}
|
||||||
|
QLineEdit:focus, QComboBox:focus {
|
||||||
|
border: 1px solid #6c8bff;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
from ...core.utils.version import get_app_version
|
||||||
|
|
||||||
|
|
||||||
|
class AboutPage(QtWidgets.QWidget):
|
||||||
|
REPO_URL = "https://github.com/darkzoul5/YoutubePlaylistSync"
|
||||||
|
ISSUES_URL = f"{REPO_URL}/issues"
|
||||||
|
|
||||||
|
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("aboutPage")
|
||||||
|
|
||||||
|
layout = QtWidgets.QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
layout.setSpacing(14)
|
||||||
|
|
||||||
|
title = QtWidgets.QLabel("About")
|
||||||
|
title.setObjectName("pageTitle")
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
for card in (
|
||||||
|
self._hero_card(),
|
||||||
|
self._project_card(),
|
||||||
|
self._suggestions_card(),
|
||||||
|
):
|
||||||
|
layout.addWidget(card)
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
def _card(self, title: str) -> tuple[QtWidgets.QFrame, QtWidgets.QVBoxLayout]:
|
||||||
|
card = QtWidgets.QFrame()
|
||||||
|
card.setObjectName("aboutCard")
|
||||||
|
|
||||||
|
layout = QtWidgets.QVBoxLayout(card)
|
||||||
|
layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
layout.addWidget(self._card_title(title))
|
||||||
|
return card, layout
|
||||||
|
|
||||||
|
def _card_title(self, text: str) -> QtWidgets.QLabel:
|
||||||
|
label = QtWidgets.QLabel(text)
|
||||||
|
label.setObjectName("cardTitle")
|
||||||
|
return label
|
||||||
|
|
||||||
|
def _muted_label(self, text: str) -> QtWidgets.QLabel:
|
||||||
|
label = QtWidgets.QLabel(text)
|
||||||
|
label.setWordWrap(True)
|
||||||
|
label.setProperty("muted", True)
|
||||||
|
return label
|
||||||
|
|
||||||
|
def _link_button(self, text: str, url: str) -> QtWidgets.QPushButton:
|
||||||
|
button = QtWidgets.QPushButton(text)
|
||||||
|
button.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||||
|
button.clicked.connect(lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl(url)))
|
||||||
|
return button
|
||||||
|
|
||||||
|
def _action_row(self, text: str, url: str) -> QtWidgets.QWidget:
|
||||||
|
row = QtWidgets.QWidget()
|
||||||
|
layout = QtWidgets.QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
layout.addWidget(self._link_button(text, url))
|
||||||
|
layout.addStretch(1)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _hero_card(self) -> QtWidgets.QFrame:
|
||||||
|
card, layout = self._card("About this project")
|
||||||
|
layout.insertWidget(
|
||||||
|
1,
|
||||||
|
self._muted_label(
|
||||||
|
"ytpl-sync is a desktop app for keeping local copies of YouTube playlists in sync."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
layout.insertWidget(2, self._muted_label("This is a student project."))
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _project_card(self) -> QtWidgets.QFrame:
|
||||||
|
card, layout = self._card("Project")
|
||||||
|
|
||||||
|
form = QtWidgets.QFormLayout()
|
||||||
|
form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
|
||||||
|
form.setFormAlignment(
|
||||||
|
QtCore.Qt.AlignmentFlag.AlignTop | QtCore.Qt.AlignmentFlag.AlignLeft
|
||||||
|
)
|
||||||
|
form.setHorizontalSpacing(14)
|
||||||
|
form.setVerticalSpacing(10)
|
||||||
|
|
||||||
|
version_text = get_app_version()
|
||||||
|
version = f"v{version_text}" if version_text != "dev" else version_text
|
||||||
|
rows = [
|
||||||
|
("Author", self._muted_label("Dark_Zoul")),
|
||||||
|
("Version", self._muted_label(version)),
|
||||||
|
("Repository", self._action_row("Open", self.REPO_URL)),
|
||||||
|
("Issues", self._action_row("Open", self.ISSUES_URL)),
|
||||||
|
]
|
||||||
|
for label, widget in rows:
|
||||||
|
form.addRow(label, widget)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
return card
|
||||||
|
|
||||||
|
def _suggestions_card(self) -> QtWidgets.QFrame:
|
||||||
|
card, layout = self._card("Suggestions")
|
||||||
|
layout.addWidget(
|
||||||
|
self._muted_label(
|
||||||
|
"• Keep the app updated regularly so that YouTube extraction stays reliable."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
layout.addStretch(1)
|
||||||
|
return card
|
||||||
@@ -10,6 +10,7 @@ from ..smooth_scroll import enable_smooth_scrolling
|
|||||||
class LogsPage(QtWidgets.QWidget):
|
class LogsPage(QtWidgets.QWidget):
|
||||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.setObjectName("logsPage")
|
||||||
layout = QtWidgets.QVBoxLayout(self)
|
layout = QtWidgets.QVBoxLayout(self)
|
||||||
title = QtWidgets.QLabel("Logs")
|
title = QtWidgets.QLabel("Logs")
|
||||||
title.setObjectName("pageTitle")
|
title.setObjectName("pageTitle")
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
parent: QtWidgets.QWidget | None = None,
|
parent: QtWidgets.QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.setObjectName("playlistsPage")
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._config_path = getattr(settings, "path", None)
|
self._config_path = getattr(settings, "path", None)
|
||||||
self._config: dict[str, Any] = {}
|
self._config: dict[str, Any] = {}
|
||||||
@@ -50,6 +51,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
header.setObjectName("pageTitle")
|
header.setObjectName("pageTitle")
|
||||||
|
|
||||||
self._list = QtWidgets.QListWidget()
|
self._list = QtWidgets.QListWidget()
|
||||||
|
self._list.setObjectName("playlistList")
|
||||||
# Selection-based UI is intentionally disabled; actions happen per-card.
|
# Selection-based UI is intentionally disabled; actions happen per-card.
|
||||||
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
|
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
|
||||||
self._list.setSpacing(8)
|
self._list.setSpacing(8)
|
||||||
@@ -57,6 +59,20 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
self._list.setWordWrap(True)
|
self._list.setWordWrap(True)
|
||||||
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
|
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||||
enable_smooth_scrolling(self._list)
|
enable_smooth_scrolling(self._list)
|
||||||
|
self._list.setStyleSheet(
|
||||||
|
"""
|
||||||
|
QListWidget#playlistList {
|
||||||
|
background: #0f1218;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
QListWidget#playlistList::viewport {
|
||||||
|
background: #0f1218;
|
||||||
|
}
|
||||||
|
QListWidget#playlistList::item {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
self._add_btn = QtWidgets.QPushButton("Add")
|
self._add_btn = QtWidgets.QPushButton("Add")
|
||||||
self._add_btn.clicked.connect(self._add_playlist)
|
self._add_btn.clicked.connect(self._add_playlist)
|
||||||
@@ -410,6 +426,16 @@ class _PlaylistCard(QtWidgets.QFrame):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
|
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
|
||||||
self.setObjectName("playlistCard")
|
self.setObjectName("playlistCard")
|
||||||
|
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||||
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
|
QFrame#playlistCard {
|
||||||
|
background: #171b22;
|
||||||
|
border: 1px solid #2a3140;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
self._index = index
|
self._index = index
|
||||||
self._active = False
|
self._active = False
|
||||||
self._paused = False
|
self._paused = False
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class QueuePage(QtWidgets.QWidget):
|
|||||||
|
|
||||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.setObjectName("queuePage")
|
||||||
# Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves.
|
# Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves.
|
||||||
self._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
|
self._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
|
||||||
self._pending_by_key: dict[tuple[str, str], dict] = {}
|
self._pending_by_key: dict[tuple[str, str], dict] = {}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from ..config_store import load_config, save_config
|
|||||||
class SettingsPage(QtWidgets.QWidget):
|
class SettingsPage(QtWidgets.QWidget):
|
||||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.setObjectName("settingsPage")
|
||||||
self._config_path: Path | None = None
|
self._config_path: Path | None = None
|
||||||
self._config: dict[str, Any] = {}
|
self._config: dict[str, Any] = {}
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ class SettingsPage(QtWidgets.QWidget):
|
|||||||
|
|
||||||
tray_form = QtWidgets.QFormLayout()
|
tray_form = QtWidgets.QFormLayout()
|
||||||
self._close_to_tray = QtWidgets.QCheckBox()
|
self._close_to_tray = QtWidgets.QCheckBox()
|
||||||
self._close_to_tray.setChecked(True)
|
self._close_to_tray.setChecked(False)
|
||||||
tray_form.addRow("close_to_tray", self._close_to_tray)
|
tray_form.addRow("close_to_tray", self._close_to_tray)
|
||||||
|
|
||||||
self._minimize_to_tray = QtWidgets.QCheckBox()
|
self._minimize_to_tray = QtWidgets.QCheckBox()
|
||||||
@@ -120,7 +121,7 @@ class SettingsPage(QtWidgets.QWidget):
|
|||||||
ui = ui if isinstance(ui, dict) else {}
|
ui = ui if isinstance(ui, dict) else {}
|
||||||
tray = ui.get("tray")
|
tray = ui.get("tray")
|
||||||
tray = tray if isinstance(tray, dict) else {}
|
tray = tray if isinstance(tray, dict) else {}
|
||||||
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", True)))
|
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", False)))
|
||||||
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
|
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._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user