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

40 Commits

Author SHA1 Message Date
darkzoul5 bfb55f28c6 chore: bump yt-dlp to 2026.6.9 2026-06-10 11:24:24 +00:00
dark_zoul 48bcf2c9df add new plan 2026-06-04 23:17:19 +03:00
dark_zoul 5f6df549ab switch to pyside6 essentials instead of full pyside6 2026-06-03 21:49:23 +03:00
dark_zoul d7f3b98be4 change default tray behaiviour to no tray 2026-06-03 21:23:47 +03:00
dark_zoul 7afdb24302 feat: new colour scheme 2026-06-03 21:22:23 +03:00
dark_zoul e8f350805b refactor: simplify about page while keeping look same 2026-06-03 20:25:14 +03:00
dark_zoul df4c7d504b feat: add app version to about page 2026-06-03 18:07:11 +03:00
dark_zoul ac5a98a09c refactor: about page now uses buttons for links 2026-06-03 18:00:56 +03:00
dark_zoul 811ff45dc9 feat: dynamic navbar width hopefully 2026-06-03 17:56:43 +03:00
dark_zoul c658b9a90d refactor: change about page layout 2026-06-03 17:53:24 +03:00
dark_zoul b06ab55f99 change about page formating 2026-06-03 17:46:30 +03:00
dark_zoul de315d07e0 feat: add issues link to abut page 2026-06-03 17:46:12 +03:00
dark_zoul 4dc7d95123 add about page 2026-06-03 17:42:16 +03:00
darkzoul5 42ba6310a3 Merge pull request #13 from darkzoul5/chore/refresh-yt-dlp
chore: bump yt-dlp to 2026.3.17
2026-06-03 17:16:31 +03:00
dark_zoul 0a49676c72 ci: bump gh action version 2026-06-03 17:15:46 +03:00
dark_zoul 8ec894fc1f ci: change name of yt-dlp workflow 2026-06-03 17:14:51 +03:00
darkzoul5 868b419d9c chore: bump yt-dlp to 2026.3.17 2026-06-03 14:10:25 +00:00
dark_zoul 56d3ed7fa2 change yt-dlp to an older version for testing 2026-06-03 17:08:57 +03:00
dark_zoul b741ca1783 ci(yt-dlp): fix labels 2026-06-03 17:04:11 +03:00
dark_zoul f4589cd895 refactor: simplify yt-dlp dependency update workflow 2026-06-03 16:59:26 +03:00
dark_zoul 93c87fcd73 update project name 2026-06-03 16:46:37 +03:00
dark_zoul 1817468ed5 add comments to executor .py 2026-06-03 16:25:21 +03:00
dark_zoul 9f65e6e70d add comments to playlist scanner.py and exector.py 2026-06-03 16:24:58 +03:00
dark_zoul ecc37bb1fa add code comments to queue manager and service.py 2026-06-02 23:09:11 +03:00
dark_zoul 8d291ba5e9 add code comments to db.py 2026-06-02 22:51:33 +03:00
dark_zoul 9597928ffb fix: update pyproject.toml 2026-06-02 22:46:42 +03:00
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
27 changed files with 894 additions and 95 deletions
+23 -4
View File
@@ -107,6 +107,12 @@ jobs:
echo "tag=${TAG}" >> "$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
with:
python-version: "3.12"
@@ -123,14 +129,15 @@ jobs:
shell: pwsh
run: |
$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" --add-data "$ws/version.txt;." --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" --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
shell: bash
@@ -252,7 +259,7 @@ jobs:
with:
path: artifacts
- name: Generate release notes (since last tag)
- name: Generate release notes
shell: bash
env:
TAG: ${{ inputs.tag }}
@@ -260,10 +267,13 @@ jobs:
set -euo pipefail
git fetch --tags --force
VERSION="${TAG#v}"
REPO="${GITHUB_REPOSITORY}"
prev_tag="$(git tag --sort=-creatordate | grep -Fxv "$TAG" | head -n 1 || true)"
{
echo "## Changes"
echo "### Changes"
echo
if [[ -n "$prev_tag" ]]; then
echo "Compared to \`$prev_tag\`:"
@@ -274,6 +284,15 @@ jobs:
echo
git log "${TAG}" --no-merges --pretty=format:'- %s (%h)' || true
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
} > release-notes.md
+2 -2
View File
@@ -34,10 +34,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'
cache: 'pip'
+92
View File
@@ -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
+2 -1
View File
@@ -7,7 +7,8 @@ config/yt-playlist-config.json
/*/tmp*
*.code-workspace
/bin/*
/app/data
/db/*
plans
# Byte-compiled / optimized / DLL files
__pycache__/
+29 -44
View File
@@ -1,35 +1,37 @@
# 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)
[![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)
![Release](https://img.shields.io/github/v/release/darkzoul5/YoutubePlaylistSync?style=flat-square&label=Release)
![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/).
Supports audio, video, or both download modes, music and videos are numbered as they are on your youtube playlist, playlist cleanup, and configurable parallel download options.
Local-first YouTube playlist synchronization client.
## Whats Included
## What's Included
- GUI (PySide6 Essentials) playlist manager + sync runner
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
- Safe reordering via two-pass rename, recycle deletions
- Async download queue with simple retry (yt-dlp Python API)
- SQLite metadata; DB updates on rename/download/delete; `last_sync`
- Optional event publishing for future GUI/logs
- SQLite metadata (`last_sync`, download state)
## Requirements
- Python 3.10+
- `ffmpeg` (needed for `audio` and `both` modes)
- If you download a `-ffmpeg` release: no extra dependencies
- If you download a non-ffmpeg release: install `ffmpeg` and ensure it's on PATH (needed for `audio` and `both` modes)
Quick start:
## Download
Download the latest release from [releases](https://github.com/darkzoul5/YoutubePlaylistSyncThing/releases) page
Download the latest release from this repo's Releases page and pick one:
- `ytpl-sync-windows-{version}-ffmpeg.zip` / `ytpl-sync-linux-{version}-ffmpeg.tar.gz` (ffmpeg bundled)
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled)
## Configure
On first run, the app will auto-create a default `config/yt-playlist-config.json` (if missing).
Create/edit `config/yt-playlist-config.json`:
Application uses a json config that canbe edited from UI or manually
```json
{
@@ -42,33 +44,24 @@ Create/edit `config/yt-playlist-config.json`:
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
"download_mode": "video",
"max_download_quality": "1080p",
"save_path": "./downloads"
"save_path": "./downloads",
"name": "my favorite playlist"
}
]
}
```
Defaults:
- `ffmpeg_path`: `./bin/ffmpeg.exe` (Windows) or `./bin/ffmpeg` (Linux)
- `download_mode`: `video`
- `max_download_quality`: `1080p`
- `save_path`: `./downloads`
- `max_parallel_downloads`: `2`
- `retry_max_retries`: `2`
- `retry_delay_seconds`: `1.5`
`max_download_quality`:
- Limits yt-dlp download quality (e.g. `"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.
`download_mode`:
- `video`: download playlist videos as muxed `.mp4` (no ffmpeg processing)
- `audio`: download muxed `.mp4`, extract `.mp3`, delete the `.mp4`
- `both`: download muxed `.mp4`, extract `.mp3`, keep both files
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
- `audio`: download video, extract `.mp3`, delete the video file
- `both`: download video, extract `.mp3`, keep both files
Queue / retry:
@@ -78,32 +71,24 @@ Queue / retry:
## Run
- Compute-only:
- Run `ytpl-sync.exe` (GUI).
```bash
python -m app.cli
```
## Tray
- Apply actions:
```bash
python -m app.cli --apply
```
- Single playlist (0-based index):
```bash
python -m app.cli --apply --playlist 0
```
- The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit.
- Tray behavior settings (Settings page):
- `close_to_tray`: close hides to tray (keeps running).
- `minimize_to_tray`: minimize hides to tray.
- `start_minimized_to_tray`: start hidden in tray.
## Data & Layout
- Database: `app/data/app.db`
- Database: `db/app.db`
- Outputs: `<save_path>/audio` and/or `<save_path>/video`
- Recycle bin: `<save_path>/.recycle/{audio,video}`
## Roadmap (short)
- Scheduler (periodic sync), richer retries/logging
- GUI (PySide6) wired to EventBus
- Enhanced config validation
- UX polish (settings, progress, error messages)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+9 -1
View File
@@ -3,12 +3,20 @@
"max_parallel_downloads": 2,
"retry_max_retries": 2,
"retry_delay_seconds": 1.5,
"ui": {
"tray": {
"close_to_tray": false,
"minimize_to_tray": false,
"start_minimized_to_tray": false
}
},
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
"download_mode": "video",
"max_download_quality": "1080p",
"save_path": "./downloads"
"save_path": "./downloads",
"name": "my favorite playlist"
}
]
}
+161
View File
@@ -0,0 +1,161 @@
# MP3 Metadata Plan
## Subject Area
- Add MP3 tag writing for downloaded YouTube playlist items.
- Scope is limited to `.mp3` outputs produced by `audio` mode and the MP3 side of `both` mode.
- Metadata is sourced from YouTube/yt-dlp and embedded after audio extraction.
## Goal
- Write useful MP3 metadata for downloaded playlist items without affecting video-only downloads.
- Keep the implementation reliable when optional fields are missing.
- Preserve successful downloads even when metadata embedding partially fails.
- Provide a setting to enable or disable MP3 metadata embedding.
## Required Metadata
- `title` ← video title
- `artist` ← uploader, fallback to channel
- `album` ← album name if present
- `tracknumber` ← playlist index
- `date` / `year` ← upload date
- `comment` ← source URL
- `genre` ← if available
- `album_art` ← thumbnail
## Configuration Requirement
- Add a setting to turn MP3 metadata embedding on or off.
- Default should be explicitly defined during implementation; recommended default is `enabled` for new configs.
- The setting should only affect `.mp3` metadata writing and should not change download selection, extraction, or `.mp4` handling.
## Current Constraints
- The current playlist scan keeps only a minimal item shape: title, video id, and playlist index.
- The scanner uses flat extraction, which is sufficient for diffing but not for full tag data.
- MP3 extraction currently transcodes audio but does not write ID3 metadata.
## Implementation Strategy
- Keep playlist diffing fast by retaining the current flat scan for remote playlist structure.
- Fetch full metadata only for items that are actually going to be downloaded or repaired.
- Write metadata only after MP3 extraction completes successfully.
- Treat metadata embedding as a post-processing step that can fail softly without discarding the MP3.
## Work Breakdown
### 1. Extend the metadata model
- Add optional fields to `PlaylistItem` for:
- uploader
- channel
- album
- upload_date
- genre
- thumbnail_url
- webpage_url
- Keep `artist` as a derived value instead of storing a separate field.
### 2. Fetch full per-video metadata
- Introduce a metadata fetch step for each item selected for download.
- Use yt-dlp per-video extraction to retrieve richer fields than the flat playlist entry provides.
- Prefer canonical values from the video page payload for upload date, uploader/channel, album, genre, thumbnail, and source URL.
### 3. Carry metadata through the download pipeline
- Ensure the enriched `PlaylistItem` reaches the download job and post-processing stage.
- Keep this propagation in-memory unless restart-safe metadata persistence becomes necessary later.
- Avoid changing unrelated sync behavior for video-only items.
- Carry the MP3 metadata enabled/disabled setting into the post-processing step.
### 4. Add an MP3 tag writer
- Add `mutagen` as the ID3 writing dependency.
- Implement a focused tagging component that maps `PlaylistItem` metadata into ID3 frames.
- Omit fields when the source value is missing instead of writing placeholders.
### 5. Map fields into ID3 tags
- `title` → video title
- `artist` → uploader, fallback to channel
- `album` → album if present
- `tracknumber` → playlist index
- `date/year` → parsed upload date
- `comment` → canonical source URL
- `genre` → genre if present
### 6. Embed album art
- Download the selected thumbnail for the video after the media download succeeds.
- Attach thumbnail data as embedded cover art when the image type is supported.
- Fail soft if thumbnail retrieval or embedding fails, and keep the MP3 intact.
### 7. Integrate into modes
- `audio` mode:
- download source media
- extract MP3
- write MP3 tags only when the setting is enabled
- delete temporary/source MP4 if configured
- `both` mode:
- download source media
- extract MP3
- write MP3 tags only when the setting is enabled
- keep MP4 unchanged
- `video` mode:
- no MP3 tagging path
### 8. Add configuration surface
- Add the new setting to the config model and default config output.
- Expose the setting in the GUI/settings surface if MP3 behavior is already user-configurable there.
- Keep the naming explicit, for example `write_mp3_metadata` or `embed_mp3_metadata`.
## Error Handling Rules
- If download fails, no tagging runs.
- If extraction fails, no tagging runs.
- If metadata embedding is disabled, skip the tagging step entirely.
- If tagging fails, mark the tag step as failed in logs/events but keep the MP3 file.
- If thumbnail embedding fails, continue with text metadata only.
- Missing `album` or `genre` is normal and should not be treated as an error.
## Testing Plan
- Unit test metadata mapping from yt-dlp info to the internal metadata model.
- Unit test ID3 writing against a temporary MP3 fixture.
- Unit test fallback behavior:
- uploader missing, channel present
- album missing
- genre missing
- thumbnail missing
- Integration test the audio post-processing path with tagging mocked.
- Integration test the both-mode MP3 path with tagging mocked.
## Documentation Updates
- Document that MP3 tags are written only for `.mp3` outputs.
- Document the new setting that enables or disables MP3 metadata embedding.
- Document the field fallback rules, especially artist and album behavior.
- Document that album art comes from the video thumbnail, not playlist artwork.
- Document that some YouTube items will not expose album or genre information.
## Dependency Decision
- Recommended library: `mutagen`
- Reason:
- direct ID3 support
- reliable field-level control
- suitable for embedding cover art
- avoids depending on ffmpeg metadata flags for all tag logic
## Delivery Order
- First: add config setting and defaults
- Second: extend metadata model and add full metadata fetch
- Third: add MP3 tag writer and field mapping
- Fourth: add thumbnail embedding
- Fifth: wire tagging into `audio` and `both`
- Sixth: add tests and docs
@@ -1,8 +1,5 @@
# YouTube Playlist Sync — Project Conversion Plan
Repository:
- [darkzoul5/YoutubePlaylistDownloader](https://github.com/darkzoul5/YoutubePlaylistDownloader?utm_source=chatgpt.com)
---
+5 -5
View File
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
[project]
name = "ytpl-sync"
version = "1.1.1"
description = "YouTube playlist Sync Thing"
version = "2.1.1"
description = "YouTube playlist Sync"
readme = "README.md"
authors = [ { name = "Dark_Zoul" } ]
license = { file = "LICENSE" }
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
requires-python = ">=3.10"
dependencies = [
"yt-dlp>=2026.3.17",
"PySide6",
"yt-dlp>=2026.6.9",
"PySide6_Essentials>=6.11.1",
]
[project.optional-dependencies]
test = [
@@ -23,7 +23,7 @@ test = [
]
[project.urls]
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
Home = "https://github.com/darkzoul5/YoutubePlaylistSync"
[project.scripts]
ytpl-sync = "app.cli:main"
+1 -1
View File
@@ -19,7 +19,7 @@ from .core.utils.logging_setup import configure_logging
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="YouTube Playlist Sync — compute/apply actions")
parser.add_argument("--apply", action="store_true", help="Apply actions (otherwise compute-only)")
parser.add_argument("--db", type=Path, default=Path("app/data/app.db"), help="Path to SQLite database")
parser.add_argument("--db", type=Path, default=Path("db/app.db"), help="Path to SQLite database")
parser.add_argument("--playlist", type=int, default=None, help="Only run for a specific playlist index (0-based)")
parser.add_argument("--verbose", action="store_true", help="Print detailed events (rename/recycle/start)")
parser.add_argument("--debug", action="store_true", help="Enable debug logging to console + app/data/app.log")
+16
View File
@@ -33,6 +33,13 @@ CREATE TABLE IF NOT EXISTS playlist_items (
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:
self.path = db_path
self.path.parent.mkdir(parents=True, exist_ok=True)
@@ -41,10 +48,12 @@ class Database:
self._migrate()
def _migrate(self) -> None:
"""Create the schema if this database has not been initialized yet."""
with self._conn:
self._conn.executescript(SCHEMA)
def upsert_playlist_items(self, rows: Iterable[tuple]):
"""Insert or refresh the cached metadata for playlist entries."""
sql = (
"INSERT INTO playlist_items (playlist_id, video_id, title, playlist_index, local_filename, downloaded, last_seen) "
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
@@ -56,6 +65,7 @@ class Database:
self._conn.executemany(sql, rows)
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(
"SELECT * FROM playlist_items WHERE playlist_id = ?",
(playlist_id,),
@@ -63,6 +73,7 @@ class Database:
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:
"""Insert or update the playlist configuration row."""
sql = (
"INSERT INTO playlists (id, name, url, path, mode, auto_sync, sync_interval_minutes, last_sync) "
"VALUES (?, ?, ?, ?, ?, ?, ?, NULL) "
@@ -73,6 +84,7 @@ class Database:
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:
"""Record the current filename associated with a playlist item."""
with self._conn:
self._conn.execute(
"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:
"""Mark whether a playlist item is present on disk."""
with self._conn:
self._conn.execute(
"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:
"""Clear filename and downloaded flags after a deletion or recycle."""
with self._conn:
self._conn.execute(
"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:
"""Store the timestamp of the most recent successful sync."""
with self._conn:
self._conn.execute(
"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:
"""Return the last sync timestamp for a playlist, if any."""
cur = self._conn.execute("SELECT last_sync FROM playlists WHERE id = ?", (playlist_id,))
row = cur.fetchone()
if not row:
+11
View File
@@ -37,6 +37,13 @@ class DownloadJob:
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:
self._queue: "asyncio.Queue[DownloadJob]" = asyncio.Queue()
self._concurrency = max(1, concurrency)
@@ -44,6 +51,7 @@ class QueueManager:
self._stopped = asyncio.Event()
async def start(self, worker_coro):
"""Start the worker tasks that drain the queue."""
async def runner(idx: int):
while not self._stopped.is_set():
job = await self._queue.get()
@@ -55,13 +63,16 @@ class QueueManager:
self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
async def stop(self):
"""Cancel all worker tasks and mark the queue as stopped."""
self._stopped.set()
for w in self._workers:
w.cancel()
self._workers.clear()
async def enqueue(self, job: DownloadJob):
"""Add a job to the shared queue."""
await self._queue.put(job)
async def join(self) -> None:
"""Block until every queued job has been acknowledged."""
await self._queue.join()
+6 -3
View File
@@ -8,16 +8,19 @@ from ..models import PlaylistItem
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
yt_dlp is unavailable, call sites should handle the raised ImportError.
The scanner is deliberately lightweight: it extracts remote metadata only
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:
pass
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:
import yt_dlp # type: ignore
except Exception as exc: # pragma: no cover - environment dependent
+16
View File
@@ -18,12 +18,25 @@ from ..utils.rate_limit import is_youtube_rate_limit_error
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:
self.concurrency = max(1, concurrency)
self.db = db
self.bus = event_bus
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)
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
start = time.monotonic()
@@ -123,6 +136,7 @@ class ActionExecutor:
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:
"""Apply all rename actions in batches separated by output type."""
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
audio_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})
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", "")
recycle_audio = audio_root.parent / ".recycle" / "audio"
recycle_video = video_root.parent / ".recycle" / "video"
@@ -198,6 +213,7 @@ class ActionExecutor:
cancel_check=None,
pause_check=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", "")
loop = asyncio.get_running_loop()
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
+13
View File
@@ -13,6 +13,13 @@ from ..utils.yt import extract_playlist_id
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:
self.db = db
self.scanner = PlaylistScanner()
@@ -28,6 +35,12 @@ class SyncService:
return [".mp4"]
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")
mode: str = playlist_cfg.get("download_mode", "video")
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
+38
View File
@@ -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"
+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)
+241 -28
View File
@@ -8,11 +8,14 @@ from PySide6 import QtCore, QtGui, QtWidgets
from ..config.settings import Settings
from ..core.events.event_bus import EventBus
from .bus_bridge import BusBridge
from .app_icon import load_app_icon
from .config_store import load_config
from .runner import SyncRequest, SyncRunner
from .pages.playlists import PlaylistManagerPage
from .pages.queue import QueuePage
from .pages.logs import LogsPage
from .pages.settings import SettingsPage
from .pages.about import AboutPage
class MainWindow(QtWidgets.QMainWindow):
@@ -20,6 +23,7 @@ class MainWindow(QtWidgets.QMainWindow):
super().__init__()
self.setWindowTitle("ytpl-sync")
self.resize(1100, 700)
self.setWindowIcon(load_app_icon())
self._settings = Settings()
self._bus = EventBus()
@@ -29,33 +33,43 @@ class MainWindow(QtWidgets.QMainWindow):
self._runner: SyncRunner | None = None
self._cancel_flag: threading.Event | None = None
self._pause_flag: threading.Event | None = None
self._tray: QtWidgets.QSystemTrayIcon | None = None
self._tray_notified = False
# Sidebar navigation
self._nav = QtWidgets.QListWidget()
self._nav.setObjectName("sidebar")
self._nav.setFixedWidth(220)
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.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._playlists_page = PlaylistManagerPage(self._settings)
self._queue_page = QueuePage()
self._logs_page = LogsPage()
self._settings_page = SettingsPage()
self._about_page = AboutPage()
self._pages: list[QtWidgets.QWidget] = [
self._playlists_page,
self._queue_page,
self._logs_page,
self._settings_page,
self._about_page,
]
for p in self._pages:
self._stack.addWidget(p)
for label in ("Playlists", "Queue", "Logs", "Settings"):
item = QtWidgets.QListWidgetItem(label)
item.setSizeHint(QtCore.QSize(200, 36))
self._nav.addItem(item)
for label in ("Playlists", "Queue", "Logs", "Settings", "About"):
self._add_sidebar_item(label)
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
self._nav.setCurrentRow(0)
@@ -87,6 +101,138 @@ class MainWindow(QtWidgets.QMainWindow):
self._playlists_page.resume_requested.connect(self._resume_sync)
self._refresh_queue_labels()
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:
# 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", False))
def _minimize_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("minimize_to_tray", False))
def _start_minimized_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("start_minimized_to_tray", False))
def should_start_minimized_to_tray(self) -> bool:
return self._tray is not None and self._start_minimized_to_tray_enabled()
def _init_tray(self) -> None:
# Tray support is optional and platform-dependent (e.g., some Linux DEs).
try:
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
return
except Exception:
return
icon = load_app_icon()
tray = QtWidgets.QSystemTrayIcon(icon, self)
tray.setToolTip("ytpl-sync")
menu = QtWidgets.QMenu()
act_toggle = menu.addAction("Show/Hide")
act_quit = menu.addAction("Quit")
tray.setContextMenu(menu)
act_toggle.triggered.connect(self._toggle_visible)
act_quit.triggered.connect(self._quit_from_tray)
tray.activated.connect(self._on_tray_activated)
tray.show()
self._tray = tray
def _toggle_visible(self) -> None:
if self.isVisible():
self.hide()
else:
self.show()
self.raise_()
self.activateWindow()
def _quit_from_tray(self) -> None:
# Ensure the closeEvent doesn't just hide the window.
self._tray = None
QtWidgets.QApplication.quit()
def _on_tray_activated(self, reason: QtWidgets.QSystemTrayIcon.ActivationReason) -> None:
if reason in (
QtWidgets.QSystemTrayIcon.ActivationReason.Trigger,
QtWidgets.QSystemTrayIcon.ActivationReason.DoubleClick,
):
self._toggle_visible()
def closeEvent(self, event: QtGui.QCloseEvent) -> None: # type: ignore[override]
# If tray is active and configured, close-to-tray.
if self._tray is not None and self._close_to_tray_enabled():
event.ignore()
self.hide()
if not self._tray_notified:
self._tray_notified = True
try:
self._tray.showMessage(
"ytpl-sync",
"Still running in the tray. Use the tray icon menu to quit.",
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
3000,
)
except Exception:
pass
return
if self._tray is not None and not self._close_to_tray_enabled():
# Explicitly quit, because the app may be configured to keep running without windows.
try:
event.accept()
except Exception:
pass
QtWidgets.QApplication.quit()
return
super().closeEvent(event)
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
try:
if event.type() == QtCore.QEvent.Type.WindowStateChange:
if self._tray is not None and self._minimize_to_tray_enabled():
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized):
QtCore.QTimer.singleShot(0, self.hide)
except Exception:
pass
super().changeEvent(event)
def _refresh_queue_labels(self) -> None:
try:
@@ -210,13 +356,24 @@ class MainWindow(QtWidgets.QMainWindow):
def _apply_style(self) -> None:
self.setStyleSheet(
"""
QMainWindow { background: #0f1115; color: #e6e6e6; }
QWidget { font-size: 13px; }
QMainWindow { background: #0f1218; color: #d7dce4; }
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#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 {
background: #0b0d11;
border-right: 1px solid #20242d;
background: #0d1015;
border-right: 1px solid #2a3140;
padding: 8px;
}
QListWidget#sidebar::item {
@@ -225,42 +382,94 @@ class MainWindow(QtWidgets.QMainWindow):
padding: 8px 10px;
}
QListWidget#sidebar::item:selected {
background: #1e2633;
background: #21304a;
color: #ffffff;
}
QTableWidget {
background: #0f1115;
gridline-color: #20242d;
border: 1px solid #20242d;
background: #171b22;
gridline-color: #2a3140;
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 {
background: #0b0d11;
color: #cfd3da;
border: 1px solid #20242d;
background: #171b22;
color: #d7dce4;
border: 1px solid #2a3140;
padding: 6px;
}
QPushButton {
background: #1e2633;
border: 1px solid #2a3140;
background: #1e2631;
border: 1px solid #31405a;
padding: 6px 10px;
border-radius: 8px;
color: #e6e6e6;
color: #d7dce4;
}
QPushButton:hover { background: #243044; }
QPushButton:hover { background: #26344a; }
QPushButton:pressed { background: #1a2433; }
QFrame#playlistCard {
background: #0b0d11;
border: 1px solid #20242d;
border-radius: 10px;
background: #171b22;
border: 1px solid #2a3140;
border-radius: 12px;
padding: 10px;
}
QLineEdit, QComboBox {
background: #0f1115;
border: 1px solid #20242d;
background: #11151c;
border: 1px solid #2a3140;
border-radius: 8px;
padding: 6px 8px;
color: #e6e6e6;
color: #d7dce4;
}
QLineEdit:focus, QComboBox:focus {
border: 1px solid #6c8bff;
}
"""
)
@@ -270,7 +479,8 @@ def main() -> int:
app = QtWidgets.QApplication(sys.argv)
app.setApplicationName("ytpl-sync")
app.setOrganizationName("ytpl-sync")
app.setWindowIcon(QtGui.QIcon())
app.setWindowIcon(load_app_icon())
app.setQuitOnLastWindowClosed(False)
# Avoid Qt warnings when a font with invalid point size is inherited from the environment.
f = app.font()
@@ -279,7 +489,10 @@ def main() -> int:
app.setFont(f)
w = MainWindow()
w.show()
if w.should_start_minimized_to_tray():
w.hide()
else:
w.show()
return app.exec()
+112
View File
@@ -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
+1
View File
@@ -10,6 +10,7 @@ from ..smooth_scroll import enable_smooth_scrolling
class LogsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("logsPage")
layout = QtWidgets.QVBoxLayout(self)
title = QtWidgets.QLabel("Logs")
title.setObjectName("pageTitle")
+27 -1
View File
@@ -36,6 +36,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
parent: QtWidgets.QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("playlistsPage")
self._settings = settings
self._config_path = getattr(settings, "path", None)
self._config: dict[str, Any] = {}
@@ -50,6 +51,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
header.setObjectName("pageTitle")
self._list = QtWidgets.QListWidget()
self._list.setObjectName("playlistList")
# Selection-based UI is intentionally disabled; actions happen per-card.
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
self._list.setSpacing(8)
@@ -57,6 +59,20 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._list.setWordWrap(True)
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
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.clicked.connect(self._add_playlist)
@@ -130,7 +146,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
last_sync_by_id: dict[str, str] = {}
try:
db = Database(Path("app/data/app.db").resolve())
db = Database(Path("db/app.db").resolve())
for r in rows:
pid = extract_playlist_id(r.url) or r.url
ls = db.get_playlist_last_sync(pid)
@@ -410,6 +426,16 @@ class _PlaylistCard(QtWidgets.QFrame):
super().__init__(parent)
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
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._active = False
self._paused = False
+1
View File
@@ -10,6 +10,7 @@ class QueuePage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("queuePage")
# 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._pending_by_key: dict[tuple[str, str], dict] = {}
+40
View File
@@ -11,6 +11,7 @@ from ..config_store import load_config, save_config
class SettingsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("settingsPage")
self._config_path: Path | None = None
self._config: dict[str, Any] = {}
@@ -49,6 +50,23 @@ class SettingsPage(QtWidgets.QWidget):
form_box.setLayout(form)
layout.addWidget(form_box)
tray_form = QtWidgets.QFormLayout()
self._close_to_tray = QtWidgets.QCheckBox()
self._close_to_tray.setChecked(False)
tray_form.addRow("close_to_tray", self._close_to_tray)
self._minimize_to_tray = QtWidgets.QCheckBox()
self._minimize_to_tray.setChecked(False)
tray_form.addRow("minimize_to_tray", self._minimize_to_tray)
self._start_minimized_to_tray = QtWidgets.QCheckBox()
self._start_minimized_to_tray.setChecked(False)
tray_form.addRow("start_minimized_to_tray", self._start_minimized_to_tray)
tray_box = QtWidgets.QGroupBox("Tray behavior")
tray_box.setLayout(tray_form)
layout.addWidget(tray_box)
btns = QtWidgets.QHBoxLayout()
self._reload_btn = QtWidgets.QPushButton("Reload")
self._reload_btn.clicked.connect(self.reload_from_config)
@@ -75,6 +93,9 @@ class SettingsPage(QtWidgets.QWidget):
self._retry_max.valueChanged.connect(lambda _v: self._schedule_autosave())
self._retry_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
self._download_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
self._close_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
self._minimize_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
self._start_minimized_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
def set_config_path(self, path: Path) -> None:
self._config_path = path
@@ -96,6 +117,14 @@ class SettingsPage(QtWidgets.QWidget):
self._retry_delay.setValue(float(self._config.get("retry_delay_seconds") or 1.5))
self._download_delay.setValue(float(self._config.get("delay_between_downloads_seconds") or 0.0))
ui = self._config.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", 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._status.setText(f"Loaded settings from {self._config_path}.")
except Exception as exc:
self._status.setText(f"Failed to load settings: {exc}")
@@ -119,6 +148,17 @@ class SettingsPage(QtWidgets.QWidget):
data["retry_max_retries"] = int(self._retry_max.value())
data["retry_delay_seconds"] = float(self._retry_delay.value())
data["delay_between_downloads_seconds"] = float(self._download_delay.value())
ui = data.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
tray["close_to_tray"] = bool(self._close_to_tray.isChecked())
tray["minimize_to_tray"] = bool(self._minimize_to_tray.isChecked())
tray["start_minimized_to_tray"] = bool(self._start_minimized_to_tray.isChecked())
ui["tray"] = tray
data["ui"] = ui
save_config(self._config_path, data)
self._status.setText(f"Saved settings to {self._config_path}.")
except Exception as exc:
+1 -1
View File
@@ -18,7 +18,7 @@ from ..core.events.event_bus import EventBus
class SyncRequest:
playlist_cfg: Dict[str, Any]
apply: bool = True
db_path: Path = Path("app/data/app.db")
db_path: Path = Path("db/app.db")
cancel_flag: threading.Event | None = None
pause_flag: threading.Event | None = None
+1 -1
View File
@@ -20,7 +20,7 @@ from .core.utils.deps import DependencyError
def bootstrap(db_path: Path | None = None) -> None:
settings = Settings()
db = Database((db_path or Path("app/data/app.db")).resolve())
db = Database((db_path or Path("db/app.db")).resolve())
service = SyncService(db)
executor = ActionExecutor(db)