14 Commits
Author SHA1 Message Date
darkzoul5 bc5ead4d19 readme: update badges 2026-05-23 23:50:34 +03:00
darkzoul5 a6c2da7c75 Update README.md 2026-05-23 23:47:45 +03:00
darkzoul5 9a8a1d8660 Update README.md 2026-05-23 23:47:27 +03:00
dark_zoul 7e142fd9c4 ci: add download count badges to release notes 2026-05-23 23:38:48 +03:00
darkzoul5 981e254346 Merge pull request #12 from darkzoul5/dependabot/github_actions/actions/setup-python-6
chore(deps): bump actions/setup-python from 5 to 6
2026-05-23 23:33:36 +03:00
darkzoul5 fc20c02a09 Merge pull request #11 from darkzoul5/dependabot/github_actions/actions/checkout-6
chore(deps): bump actions/checkout from 4 to 6
2026-05-23 23:33:17 +03:00
dependabot[bot] 20abe8243c chore(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 17:12:44 +00:00
dependabot[bot] b715802059 chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 17:12:40 +00:00
dark_zoul 9ec8974496 fix(build): change asset path to absolute 2026-05-17 14:38:25 +03:00
dark_zoul 410984bc09 feat: add “start minimized to tray” setting 2026-05-17 13:56:33 +03:00
dark_zoul 49fedecd43 feat: add app icon;
feat: add app to tray;
2026-05-17 13:51:15 +03:00
dark_zoul 3291c0c88f readme: add playlist name to config example 2026-05-17 13:38:44 +03:00
dark_zoul b0c531389e readme: update with latest info 2026-05-17 13:36:30 +03:00
dark_zoul b0eaa9d2eb Changed the default SQLite DB location from app/data/ to db/ 2026-05-17 13:27:46 +03:00
14 changed files with 268 additions and 60 deletions
+17 -4
View File
@@ -123,14 +123,15 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py" $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) - 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" --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" --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
@@ -252,7 +253,7 @@ jobs:
with: with:
path: artifacts path: artifacts
- name: Generate release notes (since last tag) - name: Generate release notes
shell: bash shell: bash
env: env:
TAG: ${{ inputs.tag }} TAG: ${{ inputs.tag }}
@@ -260,10 +261,13 @@ jobs:
set -euo pipefail set -euo pipefail
git fetch --tags --force git fetch --tags --force
VERSION="${TAG#v}"
REPO="${GITHUB_REPOSITORY}"
prev_tag="$(git tag --sort=-creatordate | grep -Fxv "$TAG" | head -n 1 || true)" prev_tag="$(git tag --sort=-creatordate | grep -Fxv "$TAG" | head -n 1 || true)"
{ {
echo "## Changes" echo "### Changes"
echo echo
if [[ -n "$prev_tag" ]]; then if [[ -n "$prev_tag" ]]; then
echo "Compared to \`$prev_tag\`:" echo "Compared to \`$prev_tag\`:"
@@ -274,6 +278,15 @@ jobs:
echo echo
git log "${TAG}" --no-merges --pretty=format:'- %s (%h)' || true git log "${TAG}" --no-merges --pretty=format:'- %s (%h)' || true
fi fi
echo
echo
echo "### Reports"
echo "![Downloads](https://img.shields.io/github/downloads/${REPO}/${TAG}/total?style=flat-square&logo=github&label=Downloads)"
echo "![Linux FFmpeg](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-linux-${VERSION}-ffmpeg.tar.gz?style=flat-square&label=Linux+FFmpeg)"
echo "![Linux](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-linux-${VERSION}.tar.gz?style=flat-square&label=Linux)"
echo "![Windows FFmpeg](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-windows-${VERSION}-ffmpeg.zip?style=flat-square&label=Windows+FFmpeg)"
echo "![Windows](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-windows-${VERSION}.zip?style=flat-square&label=Windows)"
echo echo
} > release-notes.md } > release-notes.md
+2 -2
View File
@@ -34,10 +34,10 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: '3.11' python-version: '3.11'
cache: 'pip' cache: 'pip'
+1 -1
View File
@@ -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__/
+30 -46
View File
@@ -1,35 +1,36 @@
# YouTube Playlist Sync # YouTube Playlist Sync
[![Build Release](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build-release.yml/badge.svg)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build-release.yml) ![Release](https://img.shields.io/github/v/release/darkzoul5/YoutubePlaylistSync?style=flat-square&label=Release)
[![Unit tests](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml) ![Build-Release](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/build-release.yml?style=flat-square&label=Build-Release)
![Unit Tests](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/unit-tests.yml?style=flat-square&label=unit-tests)
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/).
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.
## Whats 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,33 +43,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. `"2160p"`, `"1440p"`, `"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"` (or `"auto"`) for no height cap (highest available muxed MP4). - 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:
@@ -78,32 +70,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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+9 -1
View File
@@ -3,12 +3,20 @@
"max_parallel_downloads": 2, "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 -1
View File
@@ -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")
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import sys
from pathlib import Path
from PySide6 import QtGui, QtWidgets
def _resource_base() -> Path:
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
base = getattr(sys, "_MEIPASS", None)
if base:
return Path(str(base))
return Path.cwd()
def load_app_icon() -> QtGui.QIcon:
"""
Best-effort app icon loader.
Looks for `assets/icon.png` in the current working directory (dev),
or in the PyInstaller bundle root (packaged).
"""
candidates = [
Path("assets/icon.png"),
_resource_base() / "assets" / "icon.png",
]
for p in candidates:
try:
if p.exists():
icon = QtGui.QIcon(str(p))
if not icon.isNull():
return icon
except Exception:
pass
# Fallback to a platform theme icon (Linux) or a generic icon.
try:
themed = QtGui.QIcon.fromTheme("applications-multimedia")
if not themed.isNull():
return themed
except Exception:
pass
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
+120 -2
View File
@@ -8,6 +8,8 @@ from PySide6 import QtCore, QtGui, QtWidgets
from ..config.settings import Settings from ..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()
+1 -1
View File
@@ -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)
+39
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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)