mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-04 04:53:58 +03:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d41df72930 | |||
| 8ec24d04f6 | |||
| c1a2227da8 | |||
| 789457828c | |||
| a7000d59e1 | |||
| 3c6e7342d2 | |||
| afebc35166 | |||
| 373a19510e | |||
| 9a47d87220 | |||
| b0e7dac4be | |||
| 9748cbd471 | |||
| de39cca57d | |||
| 2e2c21fc10 | |||
| b6aba1e67e | |||
| a7f1564581 | |||
| 3b1cdda19d | |||
| 2dc119a2f1 | |||
| 7d0c7aa1d5 | |||
| 15f2df0cbf | |||
| 22756f35db | |||
| 48bcf2c9df | |||
| 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
|
||||||
@@ -198,7 +204,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mkdir -p package/bin ffmpeg_tmp
|
mkdir -p package/bin ffmpeg_tmp
|
||||||
curl -L "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o ffmpeg.tar.xz
|
|
||||||
|
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"
|
||||||
|
curl_common=(
|
||||||
|
--fail
|
||||||
|
--location
|
||||||
|
--retry 5
|
||||||
|
--retry-all-errors
|
||||||
|
--retry-delay 2
|
||||||
|
--user-agent "Mozilla/5.0 (GitHub Actions; ytpl-sync release workflow)"
|
||||||
|
)
|
||||||
|
|
||||||
|
curl "${curl_common[@]}" "$primary_url" -o ffmpeg.tar.xz
|
||||||
|
curl "${curl_common[@]}" "${primary_url}.md5" -o ffmpeg.tar.xz.md5
|
||||||
|
|
||||||
|
expected_md5="$(awk '{print $1}' ffmpeg.tar.xz.md5)"
|
||||||
|
printf '%s *ffmpeg.tar.xz\n' "$expected_md5" | md5sum -c -
|
||||||
|
|
||||||
|
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
|
||||||
|
echo "Downloaded FFmpeg payload is not a valid tar archive" >&2
|
||||||
|
ls -l ffmpeg.tar.xz >&2 || true
|
||||||
|
head -c 256 ffmpeg.tar.xz >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1
|
tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1
|
||||||
mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg
|
mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg
|
||||||
chmod +x package/bin/ffmpeg
|
chmod +x package/bin/ffmpeg
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ name: Lint Python code
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- "assets/**"
|
||||||
|
- "README.md"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- "assets/**"
|
||||||
|
- "README.md"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -15,4 +25,4 @@ jobs:
|
|||||||
run: pip install ruff
|
run: pip install ruff
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|||||||
@@ -5,21 +5,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths-ignore:
|
||||||
- "src/**"
|
- "assets/**"
|
||||||
- "tests/**"
|
- "README.md"
|
||||||
- "pyproject.toml"
|
|
||||||
- "pytest.ini"
|
|
||||||
- "ytpl-sync-entry.py"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths-ignore:
|
||||||
- "src/**"
|
- "assets/**"
|
||||||
- "tests/**"
|
- "README.md"
|
||||||
- "pyproject.toml"
|
|
||||||
- "pytest.ini"
|
|
||||||
- "ytpl-sync-entry.py"
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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:
|
||||||
|
# Use a non-GITHUB_TOKEN credential so the resulting PR triggers CI workflows.
|
||||||
|
# Configure secrets.PR_WORKFLOW_TOKEN with contents:write and pull-requests:write.
|
||||||
|
token: ${{ secrets.PR_WORKFLOW_TOKEN || github.token }}
|
||||||
|
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__/
|
||||||
|
|||||||
@@ -4,23 +4,23 @@
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
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 a local copy of YouTube playlists in sync as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [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.
|
It supports audio, video, or both download modes, keeps files numbered to match the playlist order, handles playlist cleanup, and exposes configurable parallel download options.
|
||||||
Local-first YouTube playlist synchronization client.
|
Local-first YouTube playlist synchronization client.
|
||||||
|
|
||||||
## What's Included
|
## What's Included
|
||||||
|
|
||||||
- GUI (PySide6) playlist manager + sync runner
|
- GUI playlist manager and sync runner built with PySide6 Essentials
|
||||||
- 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 and recycle deletions
|
||||||
- Async download queue with simple retry (yt-dlp Python API)
|
- Async download queue with retry support (yt-dlp Python API)
|
||||||
- SQLite metadata (`last_sync`, download state)
|
- SQLite metadata for `last_sync` and download state
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- If you download a `-ffmpeg` release: no extra dependencies
|
- 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)
|
- If you download a non-ffmpeg release: install `ffmpeg` and ensure it is on PATH (needed for `audio` and `both` modes).
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
@@ -30,7 +30,8 @@ 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
|
|
||||||
|
The application uses a JSON config file that can be edited from the UI or manually.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,14 @@ Application uses a json config that canbe edited from UI or manually
|
|||||||
"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,
|
||||||
|
"delay_between_downloads_seconds": 0.0,
|
||||||
|
"ui": {
|
||||||
|
"tray": {
|
||||||
|
"close_to_tray": false,
|
||||||
|
"minimize_to_tray": false,
|
||||||
|
"start_minimized_to_tray": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"playlists": [
|
"playlists": [
|
||||||
{
|
{
|
||||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
||||||
@@ -59,21 +68,22 @@ Application uses a json config that canbe edited from UI or manually
|
|||||||
`download_mode`:
|
`download_mode`:
|
||||||
|
|
||||||
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
|
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
|
||||||
- `audio`: download video, extract `.mp3`, delete the video file
|
- `audio`: download the video, extract `.mp3`, and delete the video file
|
||||||
- `both`: download video, extract `.mp3`, keep both files
|
- `both`: download the video, extract `.mp3`, and keep both files
|
||||||
|
|
||||||
Queue / retry:
|
Queue / retry:
|
||||||
|
|
||||||
- `max_parallel_downloads`: number of concurrent download workers.
|
- `max_parallel_downloads`: number of concurrent download workers.
|
||||||
- `retry_max_retries`: how many times a failed download job is retried.
|
- `retry_max_retries`: how many times a failed download job is retried.
|
||||||
- `retry_delay_seconds`: base delay before retry; increases with backoff.
|
- `retry_delay_seconds`: base delay before retry; increases with backoff.
|
||||||
|
- `delay_between_downloads_seconds`: optional delay between download jobs.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
- Run `ytpl-sync.exe` (GUI).
|
- GUI: run `ytpl-sync-entry.py` or the packaged desktop exe from releases.
|
||||||
|
|
||||||
## 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,24 +0,0 @@
|
|||||||
# GUI Plan
|
|
||||||
|
|
||||||
## Python-first Desktop Architecture
|
|
||||||
|
|
||||||
- **Primary GUI framework**: `PySide6` (Qt for Python).
|
|
||||||
|
|
||||||
## Core Features to Implement
|
|
||||||
|
|
||||||
1. **Dashboard Overview**: List all tracked playlists, their status (Last Sync), and total size.
|
|
||||||
2. **Interactive Configuration**: Wizard-style setup for new playlists (URL detection, folder picker).
|
|
||||||
3. **Queue Manager**: Visual progress bars for active downloads, showing speed, ETA, and current video title.
|
|
||||||
4. **Log Viewer**: Real-time streaming of yt-dlp logs for troubleshooting.
|
|
||||||
5. **Settings Panel**: Global settings for binary paths (ffmpeg), max parallel jobs, and Docker detection toggle.
|
|
||||||
|
|
||||||
## Phase 1 Roadmap: "The Bridge"
|
|
||||||
|
|
||||||
- [ ] **PySide6 Skeleton**: Basic window with `QWebEngine` (if hybrid) or native `QWidget` dashboard.
|
|
||||||
- [ ] **Packaging**: `pyinstaller` configuration to bundle both backend and frontend into a single `.exe`.
|
|
||||||
|
|
||||||
## Packaging & Distribution (brief)
|
|
||||||
|
|
||||||
- Bundle the backend and GUI into one distributable.
|
|
||||||
- Windows: use `pyinstaller` or `briefcase` to create an executable/installer. Consider creating an MSI or Inno Setup installer for a polished UX.
|
|
||||||
- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution.
|
|
||||||
@@ -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 per-playlist 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 per-playlist 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 per-playlist 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 per-playlist setting to the playlist config model and default config output.
|
||||||
|
- Expose the setting in the playlist configuration UI, not as a global app setting.
|
||||||
|
- 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 per-playlist 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,781 +0,0 @@
|
|||||||
# YouTube Playlist Sync — Project Conversion Plan
|
|
||||||
|
|
||||||
Repository:
|
|
||||||
|
|
||||||
- [darkzoul5/YoutubePlaylistDownloader](https://github.com/darkzoul5/YoutubePlaylistDownloader?utm_source=chatgpt.com)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project Direction
|
|
||||||
|
|
||||||
Convert the project from:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Single-purpose YouTube playlist downloader
|
|
||||||
```
|
|
||||||
|
|
||||||
into:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Persistent YouTube playlist synchronization client
|
|
||||||
```
|
|
||||||
|
|
||||||
The application becomes state-driven.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Core Product Goals
|
|
||||||
|
|
||||||
## Main Features
|
|
||||||
|
|
||||||
- Sync playlists locally
|
|
||||||
- Download missing items
|
|
||||||
- Remove deleted playlist items
|
|
||||||
- Keep exact playlist ordering
|
|
||||||
- Support audio/video modes
|
|
||||||
- Multiple playlists
|
|
||||||
- Background auto-sync
|
|
||||||
- GUI configuration
|
|
||||||
- Queue management
|
|
||||||
- Logs/history
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Explicit Non-Goals (Current Scope)
|
|
||||||
|
|
||||||
Not planned right now:
|
|
||||||
|
|
||||||
- Built-in media playback
|
|
||||||
- Advanced naming templates
|
|
||||||
- Drag-and-drop manual ordering
|
|
||||||
- Private playlist sync
|
|
||||||
- Channel subscriptions
|
|
||||||
- Metadata editing
|
|
||||||
|
|
||||||
Design the architecture so these can be added later.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Recommended Stack
|
|
||||||
|
|
||||||
## Core Language
|
|
||||||
|
|
||||||
- Python 3.12+
|
|
||||||
|
|
||||||
Reason:
|
|
||||||
|
|
||||||
- Native yt-dlp ecosystem
|
|
||||||
- Easier async/background work
|
|
||||||
- Better packaging than many expect
|
|
||||||
- Simpler iteration speed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# GUI
|
|
||||||
|
|
||||||
## Recommended
|
|
||||||
|
|
||||||
- PySide6
|
|
||||||
|
|
||||||
Why:
|
|
||||||
|
|
||||||
- Modern Qt ecosystem
|
|
||||||
- Better long-term support
|
|
||||||
- Cleaner than Tkinter
|
|
||||||
- Easier dynamic playlist UI
|
|
||||||
- Good threading support
|
|
||||||
- Better styling
|
|
||||||
|
|
||||||
Avoid:
|
|
||||||
|
|
||||||
- Tkinter for this scale
|
|
||||||
- Electron/Tauri unless you want a web stack
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Downloader Backend
|
|
||||||
|
|
||||||
## Recommended
|
|
||||||
|
|
||||||
- yt-dlp Python API
|
|
||||||
|
|
||||||
Install:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install yt-dlp
|
|
||||||
```
|
|
||||||
|
|
||||||
Do NOT:
|
|
||||||
|
|
||||||
- scrape YouTube manually
|
|
||||||
- parse HTML yourself
|
|
||||||
- depend on external unofficial APIs
|
|
||||||
|
|
||||||
Use yt-dlp for:
|
|
||||||
|
|
||||||
- metadata extraction
|
|
||||||
- playlist scanning
|
|
||||||
- downloading
|
|
||||||
- postprocessing
|
|
||||||
|
|
||||||
Use your own app for:
|
|
||||||
|
|
||||||
- sync logic
|
|
||||||
- ordering
|
|
||||||
- deletion
|
|
||||||
- scheduling
|
|
||||||
- state tracking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Media Processing
|
|
||||||
|
|
||||||
## Recommended
|
|
||||||
|
|
||||||
- ffmpeg
|
|
||||||
|
|
||||||
Needed for:
|
|
||||||
|
|
||||||
- audio extraction
|
|
||||||
- remuxing
|
|
||||||
- conversions
|
|
||||||
- thumbnail embedding later
|
|
||||||
|
|
||||||
Recommended approach:
|
|
||||||
|
|
||||||
- auto-detect ffmpeg
|
|
||||||
- optionally bundle with packaged app
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Database
|
|
||||||
|
|
||||||
## Recommended
|
|
||||||
|
|
||||||
- SQLite
|
|
||||||
|
|
||||||
Reason:
|
|
||||||
|
|
||||||
- Zero setup
|
|
||||||
- Local-first architecture
|
|
||||||
- Perfect for sync metadata
|
|
||||||
- Easy migrations
|
|
||||||
- Reliable
|
|
||||||
|
|
||||||
SQLite is extremely important for this project.
|
|
||||||
|
|
||||||
Do NOT rely only on:
|
|
||||||
|
|
||||||
- filenames
|
|
||||||
- folders
|
|
||||||
- JSON
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Background Scheduling
|
|
||||||
|
|
||||||
## Recommended
|
|
||||||
|
|
||||||
- APScheduler
|
|
||||||
|
|
||||||
Use for:
|
|
||||||
|
|
||||||
- interval syncs
|
|
||||||
- delayed jobs
|
|
||||||
- retry jobs
|
|
||||||
- startup sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Async/Concurrency
|
|
||||||
|
|
||||||
## Recommended
|
|
||||||
|
|
||||||
- asyncio
|
|
||||||
|
|
||||||
Use for:
|
|
||||||
|
|
||||||
- concurrent playlist syncs
|
|
||||||
- GUI-safe task execution
|
|
||||||
- download queue
|
|
||||||
- cancellation
|
|
||||||
- progress updates
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
|
|
||||||
## Recommended
|
|
||||||
|
|
||||||
- loguru
|
|
||||||
|
|
||||||
or:
|
|
||||||
|
|
||||||
- standard logging module
|
|
||||||
|
|
||||||
Need:
|
|
||||||
|
|
||||||
- rotating logs
|
|
||||||
- GUI log panel
|
|
||||||
- error history
|
|
||||||
- debug support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Packaging
|
|
||||||
|
|
||||||
## Recommended
|
|
||||||
|
|
||||||
### During development
|
|
||||||
|
|
||||||
```text
|
|
||||||
venv + pip
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release builds
|
|
||||||
|
|
||||||
Choose one:
|
|
||||||
|
|
||||||
| Tool | Notes |
|
|
||||||
| ----------- | ------------------------------- |
|
|
||||||
| Nuitka | Best performance and protection |
|
|
||||||
| PyInstaller | Easier and common |
|
|
||||||
|
|
||||||
Nuitka is probably best long-term.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Major Architectural Changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 1. Move to State-Based Sync Architecture
|
|
||||||
|
|
||||||
Current downloader logic is likely:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Playlist URL
|
|
||||||
↓
|
|
||||||
Download everything
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Remote Playlist State
|
|
||||||
↓
|
|
||||||
Stored Local State
|
|
||||||
↓
|
|
||||||
Filesystem State
|
|
||||||
↓
|
|
||||||
Diff Engine
|
|
||||||
↓
|
|
||||||
Sync Actions
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the single most important change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 2. Introduce Playlist Metadata Database
|
|
||||||
|
|
||||||
Create persistent tracking.
|
|
||||||
|
|
||||||
Suggested tables:
|
|
||||||
|
|
||||||
## playlists
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE playlists (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT,
|
|
||||||
url TEXT,
|
|
||||||
path TEXT,
|
|
||||||
mode TEXT,
|
|
||||||
auto_sync INTEGER,
|
|
||||||
sync_interval_minutes INTEGER,
|
|
||||||
last_sync TEXT
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## playlist\_items
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE playlist_items (
|
|
||||||
playlist_id TEXT,
|
|
||||||
video_id TEXT,
|
|
||||||
title TEXT,
|
|
||||||
playlist_index INTEGER,
|
|
||||||
local_filename TEXT,
|
|
||||||
downloaded INTEGER,
|
|
||||||
last_seen TEXT,
|
|
||||||
PRIMARY KEY (playlist_id, video_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 3. Implement Playlist Scanner Layer
|
|
||||||
|
|
||||||
Create dedicated metadata extraction.
|
|
||||||
|
|
||||||
Suggested structure:
|
|
||||||
|
|
||||||
```text
|
|
||||||
core/
|
|
||||||
├── scanner/
|
|
||||||
│ └── playlist_scanner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
- fetch playlist entries
|
|
||||||
- extract video IDs
|
|
||||||
- detect unavailable videos
|
|
||||||
- return normalized playlist state
|
|
||||||
|
|
||||||
Use:
|
|
||||||
|
|
||||||
```python
|
|
||||||
extract_info(download=False)
|
|
||||||
```
|
|
||||||
|
|
||||||
This allows playlist scanning without downloading.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 4. Create Diff Engine
|
|
||||||
|
|
||||||
The app should compare:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Remote playlist
|
|
||||||
vs
|
|
||||||
Database state
|
|
||||||
vs
|
|
||||||
Filesystem state
|
|
||||||
```
|
|
||||||
|
|
||||||
Output actions:
|
|
||||||
|
|
||||||
```text
|
|
||||||
DOWNLOAD
|
|
||||||
DELETE
|
|
||||||
RENAME
|
|
||||||
REORDER
|
|
||||||
SKIP
|
|
||||||
REPAIR
|
|
||||||
```
|
|
||||||
|
|
||||||
Suggested file:
|
|
||||||
|
|
||||||
```text
|
|
||||||
core/sync/diff_engine.py
|
|
||||||
```
|
|
||||||
|
|
||||||
This becomes the heart of the application.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 5. Add Download Queue System
|
|
||||||
|
|
||||||
Multi-playlist sync requires a queue.
|
|
||||||
|
|
||||||
Suggested states:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Queued
|
|
||||||
Downloading
|
|
||||||
Converting
|
|
||||||
Completed
|
|
||||||
Failed
|
|
||||||
Skipped
|
|
||||||
Cancelled
|
|
||||||
```
|
|
||||||
|
|
||||||
Suggested structure:
|
|
||||||
|
|
||||||
```text
|
|
||||||
core/download/
|
|
||||||
├── queue_manager.py
|
|
||||||
├── downloader.py
|
|
||||||
└── workers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 6. Implement Stable File Naming
|
|
||||||
|
|
||||||
Recommended naming:
|
|
||||||
|
|
||||||
```text
|
|
||||||
0001 - Title.ext
|
|
||||||
```
|
|
||||||
|
|
||||||
Benefits:
|
|
||||||
|
|
||||||
- native filesystem sorting
|
|
||||||
- easy reorder support
|
|
||||||
- easy repairs
|
|
||||||
- user friendly
|
|
||||||
|
|
||||||
Use:
|
|
||||||
|
|
||||||
```python
|
|
||||||
%(playlist_index)04d - %(title)s.%(ext)s
|
|
||||||
```
|
|
||||||
|
|
||||||
through yt-dlp.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 7. Implement Safe Reordering
|
|
||||||
|
|
||||||
Playlist ordering changes frequently.
|
|
||||||
|
|
||||||
Never rename directly.
|
|
||||||
|
|
||||||
Use:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Temporary rename pass
|
|
||||||
↓
|
|
||||||
Final rename pass
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
0001.mp3 → temp_a
|
|
||||||
0002.mp3 → temp_b
|
|
||||||
↓
|
|
||||||
temp_a → 0002.mp3
|
|
||||||
```
|
|
||||||
|
|
||||||
Avoid collisions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 8. Implement Deletion Strategy
|
|
||||||
|
|
||||||
Recommended:
|
|
||||||
|
|
||||||
Instead of immediate delete:
|
|
||||||
|
|
||||||
```text
|
|
||||||
playlist/.recycle/
|
|
||||||
```
|
|
||||||
|
|
||||||
Move removed files there.
|
|
||||||
|
|
||||||
Benefits:
|
|
||||||
|
|
||||||
- safer
|
|
||||||
- recoverable
|
|
||||||
- easier debugging
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
|
|
||||||
- auto-clean after X days
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 9. Redesign GUI Around Playlists
|
|
||||||
|
|
||||||
Current downloader GUIs are usually task-oriented.
|
|
||||||
|
|
||||||
You should move to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Playlist-oriented UI
|
|
||||||
```
|
|
||||||
|
|
||||||
Recommended sections:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Sidebar
|
|
||||||
├── Playlists
|
|
||||||
├── Queue
|
|
||||||
├── History
|
|
||||||
├── Logs
|
|
||||||
└── Settings
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 10. Support Infinite Playlist Entries
|
|
||||||
|
|
||||||
Use dynamic UI generation.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class PlaylistConfig:
|
|
||||||
url: str
|
|
||||||
path: str
|
|
||||||
mode: str
|
|
||||||
auto_sync: bool
|
|
||||||
```
|
|
||||||
|
|
||||||
GUI should render from:
|
|
||||||
|
|
||||||
```python
|
|
||||||
list[PlaylistConfig]
|
|
||||||
```
|
|
||||||
|
|
||||||
Do NOT hardcode playlist pages.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 11. Add Background Sync
|
|
||||||
|
|
||||||
Start simple.
|
|
||||||
|
|
||||||
## Phase 1
|
|
||||||
|
|
||||||
- Timer-based sync
|
|
||||||
- Tray icon
|
|
||||||
- Run minimized
|
|
||||||
|
|
||||||
## Phase 2
|
|
||||||
|
|
||||||
- Background daemon/service
|
|
||||||
- Headless mode
|
|
||||||
- Autostart support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 12. Add Progress/Event System
|
|
||||||
|
|
||||||
Needed for GUI responsiveness.
|
|
||||||
|
|
||||||
Recommended:
|
|
||||||
|
|
||||||
```text
|
|
||||||
event_bus.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Events:
|
|
||||||
|
|
||||||
```text
|
|
||||||
DownloadStarted
|
|
||||||
DownloadProgress
|
|
||||||
SyncStarted
|
|
||||||
SyncFinished
|
|
||||||
FileDeleted
|
|
||||||
PlaylistUpdated
|
|
||||||
```
|
|
||||||
|
|
||||||
This decouples GUI from backend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 13. Introduce Config Management
|
|
||||||
|
|
||||||
Recommended:
|
|
||||||
|
|
||||||
```text
|
|
||||||
config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Only for:
|
|
||||||
|
|
||||||
- app settings
|
|
||||||
- UI preferences
|
|
||||||
- non-relational settings
|
|
||||||
|
|
||||||
Do NOT store sync state in JSON.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Suggested Folder Structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/
|
|
||||||
├── core/
|
|
||||||
│ ├── scanner/
|
|
||||||
│ ├── sync/
|
|
||||||
│ ├── download/
|
|
||||||
│ ├── database/
|
|
||||||
│ ├── scheduler/
|
|
||||||
│ └── events/
|
|
||||||
│
|
|
||||||
├── gui/
|
|
||||||
│ ├── pages/
|
|
||||||
│ ├── widgets/
|
|
||||||
│ ├── dialogs/
|
|
||||||
│ └── models/
|
|
||||||
│
|
|
||||||
├── config/
|
|
||||||
├── logs/
|
|
||||||
├── data/
|
|
||||||
└── main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Suggested Sync Flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
Load playlists
|
|
||||||
↓
|
|
||||||
Scheduler triggers sync
|
|
||||||
↓
|
|
||||||
Scanner fetches remote playlist
|
|
||||||
↓
|
|
||||||
Database state loaded
|
|
||||||
↓
|
|
||||||
Filesystem scanned
|
|
||||||
↓
|
|
||||||
Diff engine computes actions
|
|
||||||
↓
|
|
||||||
Queue downloads
|
|
||||||
↓
|
|
||||||
Reorder files
|
|
||||||
↓
|
|
||||||
Move removed files
|
|
||||||
↓
|
|
||||||
Update database
|
|
||||||
↓
|
|
||||||
Emit GUI events
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Recommended MVP Conversion Order
|
|
||||||
|
|
||||||
## Phase 1 — Backend Foundation
|
|
||||||
|
|
||||||
Implement:
|
|
||||||
|
|
||||||
- SQLite
|
|
||||||
- playlist scanner
|
|
||||||
- diff engine
|
|
||||||
- download wrapper
|
|
||||||
- basic sync logic
|
|
||||||
|
|
||||||
No GUI redesign yet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 2 — Stable Syncing
|
|
||||||
|
|
||||||
Implement:
|
|
||||||
|
|
||||||
- deletion handling
|
|
||||||
- reorder handling
|
|
||||||
- queue system
|
|
||||||
- retry system
|
|
||||||
- logs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 3 — GUI Rewrite
|
|
||||||
|
|
||||||
Implement:
|
|
||||||
|
|
||||||
- playlist manager UI
|
|
||||||
- queue page
|
|
||||||
- logs page
|
|
||||||
- settings page
|
|
||||||
- dynamic playlists
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase 4 — Automation
|
|
||||||
|
|
||||||
Implement:
|
|
||||||
|
|
||||||
- background sync
|
|
||||||
- tray mode
|
|
||||||
- startup sync
|
|
||||||
- periodic sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Important Recommendations
|
|
||||||
|
|
||||||
## Recommendation 1
|
|
||||||
|
|
||||||
Treat:
|
|
||||||
|
|
||||||
```text
|
|
||||||
video_id
|
|
||||||
```
|
|
||||||
|
|
||||||
as the canonical identity.
|
|
||||||
|
|
||||||
Never titles.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Recommendation 2
|
|
||||||
|
|
||||||
Do NOT rely on yt-dlp archive files alone.
|
|
||||||
|
|
||||||
Your own DB should be the source of truth.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Recommendation 3
|
|
||||||
|
|
||||||
Keep download logic isolated.
|
|
||||||
|
|
||||||
yt-dlp should be replaceable internally.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Recommendation 4
|
|
||||||
|
|
||||||
Do not overcomplicate the GUI early.
|
|
||||||
|
|
||||||
Focus on sync correctness first.
|
|
||||||
|
|
||||||
Sync reliability matters more than appearance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Recommendation 5
|
|
||||||
|
|
||||||
Design everything around interruption recovery.
|
|
||||||
|
|
||||||
The app should survive:
|
|
||||||
|
|
||||||
- crashes
|
|
||||||
- partial downloads
|
|
||||||
- force closes
|
|
||||||
- network failures
|
|
||||||
- playlist changes mid-sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Recommendation 6
|
|
||||||
|
|
||||||
Keep the application local-first.
|
|
||||||
|
|
||||||
No account system. No cloud backend. No telemetry.
|
|
||||||
|
|
||||||
That becomes a strong project identity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Final Recommended Identity
|
|
||||||
|
|
||||||
Instead of:
|
|
||||||
|
|
||||||
```text
|
|
||||||
YouTube Downloader GUI
|
|
||||||
```
|
|
||||||
|
|
||||||
Position the project as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Local-first YouTube playlist synchronization client.
|
|
||||||
```
|
|
||||||
|
|
||||||
That identity is:
|
|
||||||
|
|
||||||
- clearer
|
|
||||||
- more unique
|
|
||||||
- technically stronger
|
|
||||||
- easier to expand later
|
|
||||||
+5
-5
@@ -4,16 +4,16 @@ 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" }
|
||||||
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yt-dlp>=2026.3.17",
|
"yt-dlp>=2026.6.9",
|
||||||
"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"
|
||||||
|
|||||||
+45
-41
@@ -1,18 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config.settings import Settings
|
|
||||||
from .core.database.db import Database
|
|
||||||
from .core.sync.service import SyncService
|
|
||||||
from .core.sync.executor import ActionExecutor
|
|
||||||
from .core.events.event_bus import EventBus
|
from .core.events.event_bus import EventBus
|
||||||
import re
|
import re
|
||||||
from .core.utils.yt import extract_playlist_id
|
from .core.sync.runner import build_sync_stack, format_action_summary, run_sync_batch
|
||||||
from .core.utils.deps import DependencyError
|
|
||||||
from .core.utils.logging_setup import configure_logging
|
from .core.utils.logging_setup import configure_logging
|
||||||
|
|
||||||
|
|
||||||
@@ -28,11 +22,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
configure_logging(verbose=bool(args.debug), log_file=Path("app/data/app.log"))
|
configure_logging(verbose=bool(args.debug), log_file=Path("app/data/app.log"))
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
db = Database(args.db.resolve())
|
|
||||||
service = SyncService(db)
|
|
||||||
bus = EventBus()
|
bus = EventBus()
|
||||||
executor = ActionExecutor(db, event_bus=bus)
|
settings, db, service, executor = build_sync_stack(args.db, event_bus=bus)
|
||||||
|
|
||||||
seen_errors: set[str] = set()
|
seen_errors: set[str] = set()
|
||||||
|
|
||||||
@@ -73,39 +64,52 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
bus.subscribe("RenameApplied", on_rename)
|
bus.subscribe("RenameApplied", on_rename)
|
||||||
bus.subscribe("FileRecycled", on_recycle)
|
bus.subscribe("FileRecycled", on_recycle)
|
||||||
|
|
||||||
playlists = settings.playlists
|
selected_playlists = settings.playlists
|
||||||
if args.playlist is not None:
|
if args.playlist is not None:
|
||||||
playlists = [playlists[args.playlist]] if 0 <= args.playlist < len(playlists) else []
|
selected_playlists = [selected_playlists[args.playlist]] if 0 <= args.playlist < len(selected_playlists) else []
|
||||||
|
|
||||||
for pl in playlists:
|
def on_plan(pl: dict, playlist_id: str, actions, counts: dict[str, int]) -> None:
|
||||||
url = pl.get("url")
|
summary = format_action_summary(counts)
|
||||||
pid = extract_playlist_id(url) or (url or "")
|
print(f"Playlist {playlist_id}: {len(actions)} actions → {summary}")
|
||||||
try:
|
log.info("playlist=%s actions=%s summary=%s", playlist_id, len(actions), summary)
|
||||||
actions = service.sync_from_config(pl)
|
|
||||||
except ImportError as e:
|
|
||||||
msg = str(e)
|
|
||||||
if "yt_dlp" in msg or "yt-dlp" in msg:
|
|
||||||
print("yt-dlp Python package is required. Install with: pip install -U yt-dlp")
|
|
||||||
return 2
|
|
||||||
raise
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
for a in actions:
|
|
||||||
counts[a.type.name] = counts.get(a.type.name, 0) + 1
|
|
||||||
summary = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items()))
|
|
||||||
print(f"Playlist {pid}: {len(actions)} actions → {summary}")
|
|
||||||
log.info("playlist=%s actions=%s summary=%s", pid, len(actions), summary)
|
|
||||||
if args.apply and actions:
|
|
||||||
try:
|
|
||||||
asyncio.run(executor.execute(actions, pl))
|
|
||||||
except DependencyError as e:
|
|
||||||
print(f"ERROR: {e}")
|
|
||||||
log.error("dependency error: %s", e)
|
|
||||||
return 2
|
|
||||||
db.set_playlist_last_sync(pid)
|
|
||||||
print(f"Applied actions for {pid}.")
|
|
||||||
log.info("playlist=%s applied_actions=%s", pid, len(actions))
|
|
||||||
|
|
||||||
return 0
|
def on_no_actions(pl: dict, playlist_id: str) -> None:
|
||||||
|
del pl
|
||||||
|
print(f"Playlist {playlist_id}: 0 actions →")
|
||||||
|
log.info("playlist=%s actions=0 summary=", playlist_id)
|
||||||
|
|
||||||
|
def on_applied(pl: dict, playlist_id: str) -> None:
|
||||||
|
del pl
|
||||||
|
print(f"Applied actions for {playlist_id}.")
|
||||||
|
log.info("playlist=%s applied_actions=done", playlist_id)
|
||||||
|
|
||||||
|
def on_import_error(pl: dict, exc: Exception) -> bool:
|
||||||
|
del pl
|
||||||
|
msg = str(exc)
|
||||||
|
if "yt_dlp" in msg or "yt-dlp" in msg:
|
||||||
|
print("yt-dlp Python package is required. Install with: pip install -U yt-dlp")
|
||||||
|
else:
|
||||||
|
print(f"ERROR: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def on_dependency_error(pl: dict, exc: Exception) -> bool:
|
||||||
|
del pl
|
||||||
|
print(f"ERROR: {exc}")
|
||||||
|
log.error("dependency error: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return run_sync_batch(
|
||||||
|
selected_playlists,
|
||||||
|
db=db,
|
||||||
|
service=service,
|
||||||
|
executor=executor,
|
||||||
|
apply=bool(args.apply),
|
||||||
|
on_plan=on_plan,
|
||||||
|
on_no_actions=on_no_actions,
|
||||||
|
on_applied=on_applied,
|
||||||
|
on_import_error=on_import_error,
|
||||||
|
on_dependency_error=on_dependency_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+69
-10
@@ -24,11 +24,69 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> Dict[str, Any]:
|
||||||
|
"""Load configuration from a JSON file."""
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("config root must be a JSON object")
|
||||||
|
return raw
|
||||||
|
except Exception:
|
||||||
|
# Return empty dict if file doesn't exist or is invalid
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(path: Path, data: Dict[str, Any]) -> None:
|
||||||
|
"""Save configuration to a JSON file."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
path.write_text(payload, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Ensure basic expected shape for config dict. Keeps unknown keys intact."""
|
||||||
|
out = dict(data)
|
||||||
|
pls = out.get("playlists")
|
||||||
|
if not isinstance(pls, list):
|
||||||
|
out["playlists"] = []
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_tray_config(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Return the tray config as a safe dict copy."""
|
||||||
|
ui = data.get("ui")
|
||||||
|
ui = ui if isinstance(ui, dict) else {}
|
||||||
|
tray = ui.get("tray")
|
||||||
|
tray = tray if isinstance(tray, dict) else {}
|
||||||
|
return dict(tray)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tray_config(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Ensure the nested ui.tray dict exists and return it for mutation."""
|
||||||
|
ui = data.get("ui")
|
||||||
|
if not isinstance(ui, dict):
|
||||||
|
ui = {}
|
||||||
|
data["ui"] = ui
|
||||||
|
|
||||||
|
tray = ui.get("tray")
|
||||||
|
if not isinstance(tray, dict):
|
||||||
|
tray = {}
|
||||||
|
ui["tray"] = tray
|
||||||
|
|
||||||
|
return tray
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
def __init__(self) -> None:
|
"""Unified configuration loader that combines file I/O and playlist merging."""
|
||||||
base_dir = Path("config")
|
|
||||||
base_dir.mkdir(parents=True, exist_ok=True)
|
def __init__(self, config_path: Path | None = None) -> None:
|
||||||
self.path = (base_dir / "yt-playlist-config.json").resolve()
|
if config_path is None:
|
||||||
|
base_dir = Path("config")
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path = (base_dir / "yt-playlist-config.json").resolve()
|
||||||
|
else:
|
||||||
|
self.path = config_path.resolve()
|
||||||
|
|
||||||
self.data: Dict[str, Any] = dict(DEFAULT_CONFIG)
|
self.data: Dict[str, Any] = dict(DEFAULT_CONFIG)
|
||||||
|
|
||||||
# Ensure there is always a config file at the default path.
|
# Ensure there is always a config file at the default path.
|
||||||
@@ -38,13 +96,13 @@ class Settings:
|
|||||||
self._load_from_path(self.path)
|
self._load_from_path(self.path)
|
||||||
|
|
||||||
def _load_from_path(self, path: Path) -> None:
|
def _load_from_path(self, path: Path) -> None:
|
||||||
try:
|
"""Load and merge config from file."""
|
||||||
self.data.update(json.loads(path.read_text(encoding="utf-8")))
|
loaded = load_config(path)
|
||||||
except Exception:
|
if loaded:
|
||||||
# Leave defaults if invalid JSON; validation can be added later.
|
self.data.update(normalize_config(loaded))
|
||||||
pass
|
|
||||||
|
|
||||||
def _write_default_config(self, path: Path) -> None:
|
def _write_default_config(self, path: Path) -> None:
|
||||||
|
"""Write a default config file."""
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
default_payload: Dict[str, Any] = {
|
default_payload: Dict[str, Any] = {
|
||||||
"playlists": [
|
"playlists": [
|
||||||
@@ -57,10 +115,11 @@ class Settings:
|
|||||||
],
|
],
|
||||||
"ffmpeg_path": _default_ffmpeg_path(),
|
"ffmpeg_path": _default_ffmpeg_path(),
|
||||||
}
|
}
|
||||||
path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
|
save_config(path, default_payload)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def playlists(self) -> List[Dict[str, Any]]:
|
def playlists(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get playlists with global defaults merged in."""
|
||||||
global_defaults = {
|
global_defaults = {
|
||||||
"download_mode": self.data.get("download_mode", DEFAULT_CONFIG["download_mode"]),
|
"download_mode": self.data.get("download_mode", DEFAULT_CONFIG["download_mode"]),
|
||||||
"max_download_quality": self.data.get("max_download_quality", DEFAULT_CONFIG["max_download_quality"]),
|
"max_download_quality": self.data.get("max_download_quality", DEFAULT_CONFIG["max_download_quality"]),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -21,22 +21,29 @@ class JobState(str, Enum):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DownloadJob:
|
class DownloadJob:
|
||||||
|
"""Configuration and status for a single download job."""
|
||||||
item: PlaylistItem
|
item: PlaylistItem
|
||||||
output_path: Optional[Path] = None
|
output_path: Optional[Path] = None
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
mode: str = "audio" # audio|video
|
mode: str = "audio" # audio|video
|
||||||
state: JobState = JobState.QUEUED
|
|
||||||
error: Optional[str] = None
|
|
||||||
ffmpeg_path: Optional[str] = None
|
ffmpeg_path: Optional[str] = None
|
||||||
max_download_quality: Optional[str] = None
|
max_download_quality: Optional[str] = None
|
||||||
playlist_id: Optional[str] = None
|
|
||||||
progress_callback: Optional[Callable[[dict[str, Any]], None]] = None
|
|
||||||
cancel_check: Optional[Callable[[], bool]] = None
|
|
||||||
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
|
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
|
||||||
keep_video: bool = True
|
keep_video: bool = True
|
||||||
|
|
||||||
|
# Status fields (mutable during execution)
|
||||||
|
state: JobState = JobState.QUEUED
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
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,7 +51,8 @@ class QueueManager:
|
|||||||
self._stopped = asyncio.Event()
|
self._stopped = asyncio.Event()
|
||||||
|
|
||||||
async def start(self, worker_coro):
|
async def start(self, worker_coro):
|
||||||
async def runner(idx: int):
|
"""Start the worker tasks that drain the queue."""
|
||||||
|
async def runner():
|
||||||
while not self._stopped.is_set():
|
while not self._stopped.is_set():
|
||||||
job = await self._queue.get()
|
job = await self._queue.get()
|
||||||
try:
|
try:
|
||||||
@@ -52,16 +60,19 @@ class QueueManager:
|
|||||||
finally:
|
finally:
|
||||||
self._queue.task_done()
|
self._queue.task_done()
|
||||||
|
|
||||||
self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
|
self._workers = [asyncio.create_task(runner()) for _ 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()
|
||||||
|
|||||||
@@ -49,8 +49,3 @@ class SyncAction:
|
|||||||
from_name: Optional[str] = None
|
from_name: Optional[str] = None
|
||||||
to_name: Optional[str] = None
|
to_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FilesystemEntry:
|
|
||||||
name: str
|
|
||||||
path: Path
|
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ 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:
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Iterable, List, Mapping, Sequence
|
|
||||||
|
|
||||||
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
|
|
||||||
|
|
||||||
|
|
||||||
class DiffEngine:
|
|
||||||
"""
|
|
||||||
Compares remote playlist items, database state, and filesystem to
|
|
||||||
produce a list of actions. Initial MVP computes DOWNLOAD/RENAME/DELETE
|
|
||||||
based on simple filename scheme "0001 - Title.ext".
|
|
||||||
"""
|
|
||||||
|
|
||||||
def compute_actions(
|
|
||||||
self,
|
|
||||||
remote: Sequence[PlaylistItem],
|
|
||||||
db_index: Mapping[str, PlaylistItem],
|
|
||||||
fs_entries: Iterable[FilesystemEntry],
|
|
||||||
extension: str,
|
|
||||||
) -> List[SyncAction]:
|
|
||||||
actions: List[SyncAction] = []
|
|
||||||
|
|
||||||
desired_names = {
|
|
||||||
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
|
|
||||||
for item in remote
|
|
||||||
}
|
|
||||||
|
|
||||||
fs_by_name = {e.name: e for e in fs_entries}
|
|
||||||
|
|
||||||
for item in remote:
|
|
||||||
desired_name = desired_names[item.video_id]
|
|
||||||
# If DB knows the current local filename and it already matches and exists -> nothing to do
|
|
||||||
if item.local_filename == desired_name and desired_name in fs_by_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# If DB knows a different current filename and it exists -> plan a rename
|
|
||||||
if item.local_filename and item.local_filename in fs_by_name and item.local_filename != desired_name:
|
|
||||||
actions.append(
|
|
||||||
SyncAction(
|
|
||||||
SyncActionType.RENAME,
|
|
||||||
item=item,
|
|
||||||
from_name=item.local_filename,
|
|
||||||
to_name=desired_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# If the desired file already exists on disk but DB doesn't reflect it -> skip (already correct)
|
|
||||||
if desired_name in fs_by_name:
|
|
||||||
actions.append(SyncAction(SyncActionType.SKIP, item=item, to_name=desired_name))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Otherwise, we need to download
|
|
||||||
actions.append(SyncAction(SyncActionType.DOWNLOAD, item=item, to_name=desired_name))
|
|
||||||
|
|
||||||
known_ids = {i.video_id for i in remote}
|
|
||||||
for vid, db_item in db_index.items():
|
|
||||||
if vid not in known_ids and db_item.local_filename:
|
|
||||||
actions.append(SyncAction(SyncActionType.DELETE, item=db_item, from_name=db_item.local_filename))
|
|
||||||
|
|
||||||
return actions
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Sequence
|
from typing import List, Sequence
|
||||||
|
|
||||||
from ..models import FilesystemEntry
|
|
||||||
|
|
||||||
|
def list_files(root: Path, extensions: Sequence[str]) -> List[Path]:
|
||||||
def list_files(root: Path, extensions: Sequence[str]) -> List[FilesystemEntry]:
|
"""List all files in root directory with given extensions."""
|
||||||
exts = {e.lower() for e in extensions}
|
exts = {e.lower() for e in extensions}
|
||||||
results: List[FilesystemEntry] = []
|
results: List[Path] = []
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
return results
|
return results
|
||||||
for p in root.glob("**/*"):
|
for p in root.glob("**/*"):
|
||||||
if p.is_file() and p.suffix.lower() in exts:
|
if p.is_file() and p.suffix.lower() in exts:
|
||||||
results.append(FilesystemEntry(name=p.name, path=p))
|
results.append(p)
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Sequence
|
||||||
|
|
||||||
|
from ..database.db import Database
|
||||||
|
from ..events.event_bus import EventBus
|
||||||
|
from ..models import SyncAction
|
||||||
|
from ..utils.deps import DependencyError
|
||||||
|
from ..utils.yt import extract_playlist_id
|
||||||
|
from .executor import ActionExecutor
|
||||||
|
from .service import SyncService
|
||||||
|
from ...config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_sync_stack(db_path: Path | None = None, *, event_bus: EventBus | None = None) -> tuple[Settings, Database, SyncService, ActionExecutor]:
|
||||||
|
settings = Settings()
|
||||||
|
db = Database((db_path or Path("db/app.db")).resolve())
|
||||||
|
service = SyncService(db)
|
||||||
|
executor = ActionExecutor(db, event_bus=event_bus)
|
||||||
|
return settings, db, service, executor
|
||||||
|
|
||||||
|
|
||||||
|
def format_action_summary(counts: dict[str, int]) -> str:
|
||||||
|
return ", ".join(f"{name}:{count}" for name, count in sorted(counts.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync_batch(
|
||||||
|
playlists: Sequence[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
db: Database,
|
||||||
|
service: SyncService,
|
||||||
|
executor: ActionExecutor,
|
||||||
|
apply: bool,
|
||||||
|
on_plan: Callable[[dict[str, Any], str, list[SyncAction], dict[str, int]], None] | None = None,
|
||||||
|
on_no_actions: Callable[[dict[str, Any], str], None] | None = None,
|
||||||
|
on_applied: Callable[[dict[str, Any], str], None] | None = None,
|
||||||
|
on_import_error: Callable[[dict[str, Any], Exception], bool] | None = None,
|
||||||
|
on_dependency_error: Callable[[dict[str, Any], Exception], bool] | None = None,
|
||||||
|
) -> int:
|
||||||
|
for playlist_cfg in playlists:
|
||||||
|
playlist_url = str(playlist_cfg.get("url") or "")
|
||||||
|
playlist_id = extract_playlist_id(playlist_url) or playlist_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
actions = service.sync_from_config(playlist_cfg)
|
||||||
|
except ImportError as exc:
|
||||||
|
if on_import_error is not None and on_import_error(playlist_cfg, exc):
|
||||||
|
continue
|
||||||
|
return 2
|
||||||
|
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for action in actions:
|
||||||
|
counts[action.type.name] = counts.get(action.type.name, 0) + 1
|
||||||
|
|
||||||
|
if on_plan is not None:
|
||||||
|
on_plan(playlist_cfg, playlist_id, actions, counts)
|
||||||
|
|
||||||
|
if apply and actions:
|
||||||
|
try:
|
||||||
|
asyncio.run(executor.execute(actions, playlist_cfg))
|
||||||
|
except DependencyError as exc:
|
||||||
|
if on_dependency_error is not None and on_dependency_error(playlist_cfg, exc):
|
||||||
|
continue
|
||||||
|
return 2
|
||||||
|
db.set_playlist_last_sync(playlist_id)
|
||||||
|
if on_applied is not None:
|
||||||
|
on_applied(playlist_cfg, playlist_id)
|
||||||
|
elif on_no_actions is not None:
|
||||||
|
on_no_actions(playlist_cfg, playlist_id)
|
||||||
|
|
||||||
|
return 0
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import Iterable, List, Mapping, Sequence
|
||||||
|
|
||||||
from ..database.db import Database
|
from ..database.db import Database
|
||||||
from ..models import PlaylistItem, SyncAction
|
from ..models import PlaylistItem, SyncAction, SyncActionType
|
||||||
from ..scanner.playlist_scanner import PlaylistScanner
|
from ..scanner.playlist_scanner import PlaylistScanner
|
||||||
from ..sync.diff_engine import DiffEngine
|
|
||||||
from ..sync.filesystem import list_files
|
from ..sync.filesystem import list_files
|
||||||
from ..utils.naming import sanitize_title
|
from ..utils.naming import sanitize_title
|
||||||
from ..utils.yt import extract_playlist_id
|
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 compares the remote state
|
||||||
|
with the local filesystem to produce sync actions.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Database) -> None:
|
def __init__(self, db: Database) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.scanner = PlaylistScanner()
|
self.scanner = PlaylistScanner()
|
||||||
self.diff = DiffEngine()
|
|
||||||
|
|
||||||
def _mode_to_extensions(self, mode: str) -> list[str]:
|
def _mode_to_extensions(self, mode: str) -> list[str]:
|
||||||
if mode == "audio":
|
if mode == "audio":
|
||||||
@@ -27,7 +32,66 @@ class SyncService:
|
|||||||
return [".mp3", ".mp4"]
|
return [".mp3", ".mp4"]
|
||||||
return [".mp4"]
|
return [".mp4"]
|
||||||
|
|
||||||
|
def _compute_actions(
|
||||||
|
self,
|
||||||
|
remote: Sequence[PlaylistItem],
|
||||||
|
db_index: Mapping[str, PlaylistItem],
|
||||||
|
fs_entries: Iterable[Path],
|
||||||
|
extension: str,
|
||||||
|
) -> List[SyncAction]:
|
||||||
|
"""Compare remote items, database state, and filesystem to produce actions.
|
||||||
|
|
||||||
|
Computes DOWNLOAD/RENAME/DELETE based on the filename scheme "0001 - Title.ext".
|
||||||
|
"""
|
||||||
|
actions: List[SyncAction] = []
|
||||||
|
|
||||||
|
desired_names = {
|
||||||
|
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
|
||||||
|
for item in remote
|
||||||
|
}
|
||||||
|
|
||||||
|
fs_by_name = {p.name: p for p in fs_entries}
|
||||||
|
|
||||||
|
for item in remote:
|
||||||
|
desired_name = desired_names[item.video_id]
|
||||||
|
# If DB knows the current local filename and it already matches and exists -> nothing to do
|
||||||
|
if item.local_filename == desired_name and desired_name in fs_by_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If DB knows a different current filename and it exists -> plan a rename
|
||||||
|
if item.local_filename and item.local_filename in fs_by_name and item.local_filename != desired_name:
|
||||||
|
actions.append(
|
||||||
|
SyncAction(
|
||||||
|
SyncActionType.RENAME,
|
||||||
|
item=item,
|
||||||
|
from_name=item.local_filename,
|
||||||
|
to_name=desired_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If the desired file already exists on disk but DB doesn't reflect it -> skip (already correct)
|
||||||
|
if desired_name in fs_by_name:
|
||||||
|
actions.append(SyncAction(SyncActionType.SKIP, item=item, to_name=desired_name))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Otherwise, we need to download
|
||||||
|
actions.append(SyncAction(SyncActionType.DOWNLOAD, item=item, to_name=desired_name))
|
||||||
|
|
||||||
|
known_ids = {i.video_id for i in remote}
|
||||||
|
for vid, db_item in db_index.items():
|
||||||
|
if vid not in known_ids and db_item.local_filename:
|
||||||
|
actions.append(SyncAction(SyncActionType.DELETE, item=db_item, from_name=db_item.local_filename))
|
||||||
|
|
||||||
|
return actions
|
||||||
|
|
||||||
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()
|
||||||
@@ -115,7 +179,7 @@ class SyncService:
|
|||||||
fs = list_files(save_path / "video", [".mp4"])
|
fs = list_files(save_path / "video", [".mp4"])
|
||||||
else:
|
else:
|
||||||
fs = list_files(save_path, [ext])
|
fs = list_files(save_path, [ext])
|
||||||
actions = self.diff.compute_actions(augmented, db_index, fs, ext)
|
actions = self._compute_actions(augmented, db_index, fs, ext)
|
||||||
merged_actions.extend(actions)
|
merged_actions.extend(actions)
|
||||||
|
|
||||||
return merged_actions
|
return merged_actions
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Callable, Iterator
|
||||||
|
|
||||||
|
from PySide6 import QtCore
|
||||||
|
|
||||||
|
|
||||||
|
class DebouncedAutosave(QtCore.QObject):
|
||||||
|
"""Small helper for debounced autosave flows in Qt widgets."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QtCore.QObject, callback: Callable[[], None], interval_ms: int = 600) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._suppressed = False
|
||||||
|
self._timer = QtCore.QTimer(self)
|
||||||
|
self._timer.setSingleShot(True)
|
||||||
|
self._timer.setInterval(interval_ms)
|
||||||
|
self._timer.timeout.connect(callback)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def suppressed(self) -> Iterator[None]:
|
||||||
|
previous = self._suppressed
|
||||||
|
self._suppressed = True
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._suppressed = previous
|
||||||
|
|
||||||
|
def set_suppressed(self, suppressed: bool) -> None:
|
||||||
|
self._suppressed = bool(suppressed)
|
||||||
|
|
||||||
|
def schedule(self, *, enabled: bool = True) -> None:
|
||||||
|
if self._suppressed or not enabled:
|
||||||
|
return
|
||||||
|
self._timer.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._timer.stop()
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AppConfig:
|
|
||||||
data: Dict[str, Any]
|
|
||||||
path: Path
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: Path) -> AppConfig:
|
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError("config root must be a JSON object")
|
|
||||||
return AppConfig(data=raw, path=path)
|
|
||||||
|
|
||||||
|
|
||||||
def save_config(path: Path, data: Dict[str, Any]) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
||||||
path.write_text(payload, encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_config(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Ensure basic expected shape for config dict.
|
|
||||||
Keeps unknown keys intact.
|
|
||||||
"""
|
|
||||||
out = dict(data)
|
|
||||||
pls = out.get("playlists")
|
|
||||||
if not isinstance(pls, list):
|
|
||||||
out["playlists"] = []
|
|
||||||
return out
|
|
||||||
+138
-49
@@ -5,16 +5,16 @@ import threading
|
|||||||
|
|
||||||
from PySide6 import QtCore, QtGui, QtWidgets
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
from ..config.settings import Settings
|
from ..config.settings import Settings, get_tray_config, load_config
|
||||||
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 .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
|
||||||
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 +38,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 +101,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).
|
||||||
@@ -100,17 +131,13 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
cfg_path = getattr(self._settings, "path", None)
|
cfg_path = getattr(self._settings, "path", None)
|
||||||
if cfg_path is None:
|
if cfg_path is None:
|
||||||
return {}
|
return {}
|
||||||
raw = load_config(cfg_path).data
|
raw = load_config(cfg_path)
|
||||||
ui = raw.get("ui")
|
return get_tray_config(raw)
|
||||||
ui = ui if isinstance(ui, dict) else {}
|
|
||||||
tray = ui.get("tray")
|
|
||||||
tray = tray if isinstance(tray, dict) else {}
|
|
||||||
return dict(tray)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
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))
|
||||||
@@ -215,21 +242,20 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _emit_page_event(self, name: str, payload: dict) -> None:
|
||||||
|
for page in self._pages:
|
||||||
|
handler = getattr(page, "on_event", None)
|
||||||
|
if not callable(handler):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
handler(name, payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@QtCore.Slot(str, dict)
|
@QtCore.Slot(str, dict)
|
||||||
def _on_bus_event(self, name: str, payload: dict) -> None:
|
def _on_bus_event(self, name: str, payload: dict) -> None:
|
||||||
# Fan out to interested pages.
|
# Fan out to any page that exposes on_event().
|
||||||
try:
|
self._emit_page_event(name, payload)
|
||||||
self._queue_page.on_event(name, payload)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
self._logs_page.on_event(name, payload)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
self._playlists_page.on_event(name, payload)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Auto-pause on YouTube bot-check/rate-limit surface.
|
# Auto-pause on YouTube bot-check/rate-limit surface.
|
||||||
if name == "SyncPaused":
|
if name == "SyncPaused":
|
||||||
@@ -275,7 +301,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
@QtCore.Slot(bool, str)
|
@QtCore.Slot(bool, str)
|
||||||
def _on_sync_finished(self, ok: bool, message: str) -> None:
|
def _on_sync_finished(self, ok: bool, message: str) -> None:
|
||||||
if not ok:
|
if not ok:
|
||||||
self._logs_page.on_event("SyncError", {"error": message})
|
self._emit_page_event("SyncError", {"error": message})
|
||||||
self._playlists_page.set_running(False)
|
self._playlists_page.set_running(False)
|
||||||
|
|
||||||
# Mark idle so "Sync all" can be started again.
|
# Mark idle so "Sync all" can be started again.
|
||||||
@@ -324,13 +350,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 +376,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
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from PySide6 import QtWidgets
|
from PySide6 import QtGui, QtWidgets
|
||||||
|
|
||||||
from ..smooth_scroll import enable_smooth_scrolling
|
from ..smooth_scroll import enable_smooth_scrolling
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -40,4 +41,4 @@ class LogsPage(QtWidgets.QWidget):
|
|||||||
except Exception:
|
except Exception:
|
||||||
line = f"{name}: {payload}"
|
line = f"{name}: {payload}"
|
||||||
self._text.appendPlainText(line)
|
self._text.appendPlainText(line)
|
||||||
self._text.moveCursor(self._text.textCursor().End)
|
self._text.moveCursor(QtGui.QTextCursor.MoveOperation.End)
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from PySide6 import QtCore, QtGui, QtWidgets
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
from ...config.settings import Settings
|
from ...config.settings import Settings, load_config, normalize_config, save_config
|
||||||
from ...core.database.db import Database
|
from ...core.database.db import Database
|
||||||
from ...core.utils.yt import extract_playlist_id
|
from ...core.utils.yt import extract_playlist_id
|
||||||
|
from ..autosave import DebouncedAutosave
|
||||||
from ..smooth_scroll import enable_smooth_scrolling
|
from ..smooth_scroll import enable_smooth_scrolling
|
||||||
from ..config_store import load_config, normalize_config, save_config
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -36,20 +36,18 @@ 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] = {}
|
||||||
self._download_state_by_pid: dict[str, dict[str, Any]] = {}
|
self._download_state_by_pid: dict[str, dict[str, Any]] = {}
|
||||||
self._suppress_autosave = False
|
self._autosave = DebouncedAutosave(self, self._autosave_now)
|
||||||
self._autosave_timer = QtCore.QTimer(self)
|
|
||||||
self._autosave_timer.setSingleShot(True)
|
|
||||||
self._autosave_timer.setInterval(600)
|
|
||||||
self._autosave_timer.timeout.connect(self._autosave_now)
|
|
||||||
|
|
||||||
header = QtWidgets.QLabel("Playlists")
|
header = QtWidgets.QLabel("Playlists")
|
||||||
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 +55,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)
|
||||||
@@ -114,18 +126,16 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
@QtCore.Slot()
|
@QtCore.Slot()
|
||||||
def reload_from_config(self) -> None:
|
def reload_from_config(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._suppress_autosave = True
|
with self._autosave.suppressed():
|
||||||
self._settings = Settings()
|
self._settings = Settings()
|
||||||
self._config_path = getattr(self._settings, "path", None)
|
self._config_path = getattr(self._settings, "path", None)
|
||||||
if self._config_path is None:
|
if self._config_path is None:
|
||||||
raise RuntimeError("Config path not available")
|
raise RuntimeError("Config path not available")
|
||||||
self._config = normalize_config(load_config(self._config_path).data)
|
self._config = normalize_config(load_config(self._config_path))
|
||||||
rows = self._rows_from_settings()
|
rows = self._rows_from_settings()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._status.setText(f"Failed to load config: {exc}")
|
self._status.setText(f"Failed to load config: {exc}")
|
||||||
return
|
return
|
||||||
finally:
|
|
||||||
self._suppress_autosave = False
|
|
||||||
|
|
||||||
# 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] = {}
|
||||||
@@ -162,6 +172,19 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
self._status.setText("Cancelling…")
|
self._status.setText("Cancelling…")
|
||||||
self.cancel_requested.emit()
|
self.cancel_requested.emit()
|
||||||
|
|
||||||
|
def _iter_cards(self):
|
||||||
|
for i in range(self._list.count()):
|
||||||
|
item = self._list.item(i)
|
||||||
|
widget = self._list.itemWidget(item)
|
||||||
|
if isinstance(widget, _PlaylistCard):
|
||||||
|
yield widget
|
||||||
|
|
||||||
|
def _card_for_playlist_id(self, playlist_id: str) -> _PlaylistCard | None:
|
||||||
|
for card in self._iter_cards():
|
||||||
|
if card.playlist_id() == playlist_id:
|
||||||
|
return card
|
||||||
|
return None
|
||||||
|
|
||||||
def set_running(self, running: bool) -> None:
|
def set_running(self, running: bool) -> None:
|
||||||
self._sync_all_btn.setEnabled(not running)
|
self._sync_all_btn.setEnabled(not running)
|
||||||
self._cancel_btn.setEnabled(running)
|
self._cancel_btn.setEnabled(running)
|
||||||
@@ -171,11 +194,8 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
# Keep the list enabled so per-card Pause/Cancel remains clickable.
|
# Keep the list enabled so per-card Pause/Cancel remains clickable.
|
||||||
self._list.setEnabled(True)
|
self._list.setEnabled(True)
|
||||||
# But freeze editing while a sync is running to avoid racey config edits.
|
# But freeze editing while a sync is running to avoid racey config edits.
|
||||||
for i in range(self._list.count()):
|
for card in self._iter_cards():
|
||||||
item = self._list.item(i)
|
card.set_editing_enabled(not running)
|
||||||
w = self._list.itemWidget(item)
|
|
||||||
if isinstance(w, _PlaylistCard):
|
|
||||||
w.set_editing_enabled(not running)
|
|
||||||
|
|
||||||
@QtCore.Slot()
|
@QtCore.Slot()
|
||||||
def _add_playlist(self) -> None:
|
def _add_playlist(self) -> None:
|
||||||
@@ -217,13 +237,8 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
|
|
||||||
def _table_to_playlists(self) -> list[dict[str, Any]]:
|
def _table_to_playlists(self) -> list[dict[str, Any]]:
|
||||||
playlists: list[dict[str, Any]] = []
|
playlists: list[dict[str, Any]] = []
|
||||||
for i in range(self._list.count()):
|
for card in self._iter_cards():
|
||||||
item = self._list.item(i)
|
playlists.append(card.to_dict())
|
||||||
w = self._list.itemWidget(item)
|
|
||||||
if not isinstance(w, _PlaylistCard):
|
|
||||||
continue
|
|
||||||
pl = w.to_dict()
|
|
||||||
playlists.append(pl)
|
|
||||||
return playlists
|
return playlists
|
||||||
|
|
||||||
@QtCore.Slot()
|
@QtCore.Slot()
|
||||||
@@ -244,40 +259,28 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
self._status.setText(f"Failed to save config: {exc}")
|
self._status.setText(f"Failed to save config: {exc}")
|
||||||
|
|
||||||
def _reindex_cards(self) -> None:
|
def _reindex_cards(self) -> None:
|
||||||
for i in range(self._list.count()):
|
for i, card in enumerate(self._iter_cards()):
|
||||||
item = self._list.item(i)
|
card.set_index(i)
|
||||||
w = self._list.itemWidget(item)
|
|
||||||
if isinstance(w, _PlaylistCard):
|
|
||||||
w.set_index(i)
|
|
||||||
|
|
||||||
def _validate_all(self, *, show_status: bool) -> bool:
|
def _validate_all(self, *, show_status: bool) -> bool:
|
||||||
ok = True
|
ok = True
|
||||||
for i in range(self._list.count()):
|
for card in self._iter_cards():
|
||||||
item = self._list.item(i)
|
errs = card.validate()
|
||||||
w = self._list.itemWidget(item)
|
card.set_status("; ".join(errs) if errs else "")
|
||||||
if isinstance(w, _PlaylistCard):
|
if errs:
|
||||||
errs = w.validate()
|
ok = False
|
||||||
w.set_status("; ".join(errs) if errs else "")
|
|
||||||
if errs:
|
|
||||||
ok = False
|
|
||||||
if not ok and show_status:
|
if not ok and show_status:
|
||||||
self._status.setText("Fix invalid playlists before saving/syncing.")
|
self._status.setText("Fix invalid playlists before saving/syncing.")
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
@QtCore.Slot()
|
@QtCore.Slot()
|
||||||
def _schedule_autosave(self) -> None:
|
def _schedule_autosave(self) -> None:
|
||||||
if self._suppress_autosave:
|
self._autosave.schedule(enabled=self.isEnabled())
|
||||||
return
|
|
||||||
if not self.isEnabled():
|
|
||||||
return
|
|
||||||
self._autosave_timer.start()
|
|
||||||
|
|
||||||
@QtCore.Slot()
|
@QtCore.Slot()
|
||||||
def _autosave_now(self) -> None:
|
def _autosave_now(self) -> None:
|
||||||
if self._config_path is None:
|
if self._config_path is None:
|
||||||
return
|
return
|
||||||
if self._suppress_autosave:
|
|
||||||
return
|
|
||||||
if not self._validate_all(show_status=False):
|
if not self._validate_all(show_status=False):
|
||||||
# Don't autosave invalid configs; user sees inline errors.
|
# Don't autosave invalid configs; user sees inline errors.
|
||||||
return
|
return
|
||||||
@@ -294,22 +297,25 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
pid = payload.get("playlist_id")
|
pid = payload.get("playlist_id")
|
||||||
total = payload.get("actions_total")
|
total = payload.get("actions_total")
|
||||||
self._sync_state.setText(f"Sync started: {pid} ({total} actions)")
|
self._sync_state.setText(f"Sync started: {pid} ({total} actions)")
|
||||||
self._set_card_status(str(pid or ""), "running")
|
playlist_id = str(pid or "")
|
||||||
self._set_active_card(str(pid or ""), running=True, paused=False)
|
self._set_card_status(playlist_id, "running")
|
||||||
|
self._set_active_card(playlist_id, running=True, paused=False)
|
||||||
elif name == "SyncSummary":
|
elif name == "SyncSummary":
|
||||||
pid = payload.get("playlist_id")
|
pid = payload.get("playlist_id")
|
||||||
dur = payload.get("duration_s")
|
dur = payload.get("duration_s")
|
||||||
counts = payload.get("counts")
|
counts = payload.get("counts")
|
||||||
self._sync_state.setText(f"Sync summary: {pid} in {dur}s counts={counts}")
|
self._sync_state.setText(f"Sync summary: {pid} in {dur}s counts={counts}")
|
||||||
self._set_card_status(str(pid or ""), f"done in {dur}s")
|
playlist_id = str(pid or "")
|
||||||
|
self._set_card_status(playlist_id, f"done in {dur}s")
|
||||||
ls = payload.get("last_sync")
|
ls = payload.get("last_sync")
|
||||||
if ls:
|
if ls:
|
||||||
self._set_card_last_sync(str(pid or ""), str(ls))
|
self._set_card_last_sync(playlist_id, str(ls))
|
||||||
elif name == "SyncFinished":
|
elif name == "SyncFinished":
|
||||||
pid = payload.get("playlist_id")
|
pid = payload.get("playlist_id")
|
||||||
self._sync_state.setText(f"Sync finished: {pid}")
|
self._sync_state.setText(f"Sync finished: {pid}")
|
||||||
self._set_card_status(str(pid or ""), "finished")
|
playlist_id = str(pid or "")
|
||||||
self._set_active_card(str(pid or ""), running=False, paused=False)
|
self._set_card_status(playlist_id, "finished")
|
||||||
|
self._set_active_card(playlist_id, running=False, paused=False)
|
||||||
self.set_running(False)
|
self.set_running(False)
|
||||||
elif name == "SyncError":
|
elif name == "SyncError":
|
||||||
self._sync_state.setText(f"Sync error: {payload.get('error')}")
|
self._sync_state.setText(f"Sync error: {payload.get('error')}")
|
||||||
@@ -366,37 +372,26 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
self._set_active_card(pid, running=True, paused=True)
|
self._set_active_card(pid, running=True, paused=True)
|
||||||
|
|
||||||
def _set_card_progress(self, playlist_id: str, progress: float) -> None:
|
def _set_card_progress(self, playlist_id: str, progress: float) -> None:
|
||||||
for i in range(self._list.count()):
|
card = self._card_for_playlist_id(playlist_id)
|
||||||
item = self._list.item(i)
|
if card is not None:
|
||||||
w = self._list.itemWidget(item)
|
card.set_progress(progress)
|
||||||
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
|
|
||||||
w.set_progress(progress)
|
|
||||||
|
|
||||||
def _set_card_status(self, playlist_id: str, text: str) -> None:
|
def _set_card_status(self, playlist_id: str, text: str) -> None:
|
||||||
for i in range(self._list.count()):
|
card = self._card_for_playlist_id(playlist_id)
|
||||||
item = self._list.item(i)
|
if card is not None:
|
||||||
w = self._list.itemWidget(item)
|
card.set_status(text)
|
||||||
if isinstance(w, _PlaylistCard):
|
|
||||||
if w.playlist_id() == playlist_id:
|
|
||||||
w.set_status(text)
|
|
||||||
|
|
||||||
def _set_card_last_sync(self, playlist_id: str, last_sync: str) -> None:
|
def _set_card_last_sync(self, playlist_id: str, last_sync: str) -> None:
|
||||||
for i in range(self._list.count()):
|
card = self._card_for_playlist_id(playlist_id)
|
||||||
item = self._list.item(i)
|
if card is not None:
|
||||||
w = self._list.itemWidget(item)
|
card.set_last_sync(last_sync)
|
||||||
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
|
|
||||||
w.set_last_sync(last_sync)
|
|
||||||
|
|
||||||
def _set_active_card(self, playlist_id: str, *, running: bool, paused: bool) -> None:
|
def _set_active_card(self, playlist_id: str, *, running: bool, paused: bool) -> None:
|
||||||
for i in range(self._list.count()):
|
card = self._card_for_playlist_id(playlist_id)
|
||||||
item = self._list.item(i)
|
if card is None:
|
||||||
w = self._list.itemWidget(item)
|
return
|
||||||
if not isinstance(w, _PlaylistCard):
|
card.set_active(running)
|
||||||
continue
|
card.set_paused(paused)
|
||||||
is_active = w.playlist_id() == playlist_id
|
|
||||||
w.set_active(is_active and running)
|
|
||||||
if is_active:
|
|
||||||
w.set_paused(paused)
|
|
||||||
|
|
||||||
|
|
||||||
class _PlaylistCard(QtWidgets.QFrame):
|
class _PlaylistCard(QtWidgets.QFrame):
|
||||||
@@ -410,6 +405,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
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6 import QtCore, QtWidgets
|
from PySide6 import QtCore, QtWidgets
|
||||||
|
|
||||||
from ..smooth_scroll import enable_smooth_scrolling
|
from ..smooth_scroll import enable_smooth_scrolling
|
||||||
@@ -10,6 +12,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] = {}
|
||||||
@@ -108,6 +111,22 @@ class QueuePage(QtWidgets.QWidget):
|
|||||||
self._table.setItem(row, col, item)
|
self._table.setItem(row, col, item)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
def _target_text(self, payload: dict, current: str = "") -> str:
|
||||||
|
value = (
|
||||||
|
payload.get("target")
|
||||||
|
or payload.get("filename")
|
||||||
|
or payload.get("output_path")
|
||||||
|
or payload.get("path")
|
||||||
|
or payload.get("to")
|
||||||
|
or current
|
||||||
|
)
|
||||||
|
if not value:
|
||||||
|
return current
|
||||||
|
try:
|
||||||
|
return Path(str(value)).name or str(value)
|
||||||
|
except Exception:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
@QtCore.Slot()
|
@QtCore.Slot()
|
||||||
def _flush_pending(self) -> None:
|
def _flush_pending(self) -> None:
|
||||||
if not self._pending_by_key:
|
if not self._pending_by_key:
|
||||||
@@ -130,12 +149,13 @@ class QueuePage(QtWidgets.QWidget):
|
|||||||
speed_item = self._ensure_item(row, 4, "")
|
speed_item = self._ensure_item(row, 4, "")
|
||||||
eta_item = self._ensure_item(row, 5, "")
|
eta_item = self._ensure_item(row, 5, "")
|
||||||
target_item = self._ensure_item(row, 6, "")
|
target_item = self._ensure_item(row, 6, "")
|
||||||
|
target_text = target_item.text()
|
||||||
|
|
||||||
if name == "DownloadStarted":
|
if name == "DownloadStarted":
|
||||||
status_item.setText("started")
|
status_item.setText("started")
|
||||||
tgt = payload.get("target") or payload.get("filename") or ""
|
target_text = self._target_text(payload, target_text)
|
||||||
if tgt:
|
if target_text:
|
||||||
target_item.setText(str(tgt))
|
target_item.setText(target_text)
|
||||||
elif name == "DownloadProgress":
|
elif name == "DownloadProgress":
|
||||||
status_item.setText(str(payload.get("status") or "downloading"))
|
status_item.setText(str(payload.get("status") or "downloading"))
|
||||||
prog = payload.get("progress")
|
prog = payload.get("progress")
|
||||||
@@ -154,14 +174,14 @@ class QueuePage(QtWidgets.QWidget):
|
|||||||
et = payload.get("eta")
|
et = payload.get("eta")
|
||||||
if isinstance(et, (int, float)) and et >= 0:
|
if isinstance(et, (int, float)) and et >= 0:
|
||||||
eta_item.setText(f"{int(et)}s")
|
eta_item.setText(f"{int(et)}s")
|
||||||
fn = payload.get("filename")
|
target_text = self._target_text(payload, target_text)
|
||||||
if fn:
|
if target_text:
|
||||||
target_item.setText(str(fn))
|
target_item.setText(target_text)
|
||||||
elif name == "DownloadCompleted":
|
elif name == "DownloadCompleted":
|
||||||
status_item.setText("completed")
|
status_item.setText("completed")
|
||||||
tgt = payload.get("target") or ""
|
target_text = self._target_text(payload, target_text)
|
||||||
if tgt:
|
if target_text:
|
||||||
target_item.setText(str(tgt))
|
target_item.setText(target_text)
|
||||||
bar = self._table.cellWidget(row, 3)
|
bar = self._table.cellWidget(row, 3)
|
||||||
if bar is None:
|
if bar is None:
|
||||||
bar = QtWidgets.QProgressBar()
|
bar = QtWidgets.QProgressBar()
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ from typing import Any
|
|||||||
|
|
||||||
from PySide6 import QtCore, QtWidgets
|
from PySide6 import QtCore, QtWidgets
|
||||||
|
|
||||||
from ..config_store import load_config, save_config
|
from ...config.settings import ensure_tray_config, get_tray_config, load_config, save_config
|
||||||
|
from ..autosave import DebouncedAutosave
|
||||||
|
|
||||||
|
|
||||||
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 +53,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()
|
||||||
@@ -80,11 +82,7 @@ class SettingsPage(QtWidgets.QWidget):
|
|||||||
self._status.setWordWrap(True)
|
self._status.setWordWrap(True)
|
||||||
layout.addWidget(self._status)
|
layout.addWidget(self._status)
|
||||||
|
|
||||||
self._suppress_autosave = False
|
self._autosave = DebouncedAutosave(self, self.save_to_config)
|
||||||
self._autosave_timer = QtCore.QTimer(self)
|
|
||||||
self._autosave_timer.setSingleShot(True)
|
|
||||||
self._autosave_timer.setInterval(600)
|
|
||||||
self._autosave_timer.timeout.connect(self.save_to_config)
|
|
||||||
|
|
||||||
# Autosave on focus-out / change.
|
# Autosave on focus-out / change.
|
||||||
self._ffmpeg_path.editingFinished.connect(self._schedule_autosave)
|
self._ffmpeg_path.editingFinished.connect(self._schedule_autosave)
|
||||||
@@ -106,34 +104,27 @@ class SettingsPage(QtWidgets.QWidget):
|
|||||||
self._status.setText("No config loaded yet.")
|
self._status.setText("No config loaded yet.")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self._suppress_autosave = True
|
with self._autosave.suppressed():
|
||||||
cfg = load_config(self._config_path)
|
cfg = load_config(self._config_path)
|
||||||
self._config = dict(cfg.data)
|
self._config = dict(cfg)
|
||||||
|
|
||||||
self._ffmpeg_path.setText(str(self._config.get("ffmpeg_path") or ""))
|
self._ffmpeg_path.setText(str(self._config.get("ffmpeg_path") or ""))
|
||||||
self._max_parallel.setValue(int(self._config.get("max_parallel_downloads") or 2))
|
self._max_parallel.setValue(int(self._config.get("max_parallel_downloads") or 2))
|
||||||
self._retry_max.setValue(int(self._config.get("retry_max_retries") or 2))
|
self._retry_max.setValue(int(self._config.get("retry_max_retries") or 2))
|
||||||
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")
|
tray = get_tray_config(self._config)
|
||||||
ui = ui if isinstance(ui, dict) else {}
|
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", False)))
|
||||||
tray = ui.get("tray")
|
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
|
||||||
tray = tray if isinstance(tray, dict) else {}
|
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
|
||||||
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}")
|
||||||
finally:
|
|
||||||
self._suppress_autosave = False
|
|
||||||
|
|
||||||
def _schedule_autosave(self) -> None:
|
def _schedule_autosave(self) -> None:
|
||||||
if self._suppress_autosave:
|
self._autosave.schedule()
|
||||||
return
|
|
||||||
self._autosave_timer.start()
|
|
||||||
|
|
||||||
@QtCore.Slot()
|
@QtCore.Slot()
|
||||||
def save_to_config(self) -> None:
|
def save_to_config(self) -> None:
|
||||||
@@ -148,15 +139,10 @@ class SettingsPage(QtWidgets.QWidget):
|
|||||||
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")
|
tray = ensure_tray_config(data)
|
||||||
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["close_to_tray"] = bool(self._close_to_tray.isChecked())
|
||||||
tray["minimize_to_tray"] = bool(self._minimize_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())
|
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}.")
|
||||||
|
|||||||
+36
-38
@@ -7,50 +7,48 @@ Future iterations will wire up scheduler and a GUI.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config.settings import Settings
|
from .core.sync.runner import build_sync_stack, format_action_summary, run_sync_batch
|
||||||
from .core.database.db import Database
|
|
||||||
from .core.sync.service import SyncService
|
|
||||||
from .core.sync.executor import ActionExecutor
|
|
||||||
from .core.utils.yt import extract_playlist_id
|
|
||||||
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, db, service, executor = build_sync_stack(db_path)
|
||||||
db = Database((db_path or Path("db/app.db")).resolve())
|
|
||||||
service = SyncService(db)
|
|
||||||
executor = ActionExecutor(db)
|
|
||||||
|
|
||||||
# Iterate configured playlists and compute actions (no execution yet)
|
def on_plan(pl: dict, playlist_id: str, actions, counts: dict[str, int]) -> None:
|
||||||
for pl in settings.playlists:
|
del playlist_id
|
||||||
try:
|
print(f"Applying {len(actions)} actions for: {pl.get('url')}")
|
||||||
actions = service.sync_from_config(pl)
|
print(f"Plan → {format_action_summary(counts)}")
|
||||||
# Apply actions now
|
|
||||||
if actions:
|
def on_no_actions(pl: dict, playlist_id: str) -> None:
|
||||||
print(f"Applying {len(actions)} actions for: {pl.get('url')}")
|
del playlist_id
|
||||||
# Summarize before applying
|
print(f"No actions needed for: {pl.get('url')}")
|
||||||
counts = {}
|
|
||||||
for a in actions:
|
def on_applied(pl: dict, playlist_id: str) -> None:
|
||||||
counts[a.type] = counts.get(a.type, 0) + 1
|
del pl, playlist_id
|
||||||
summary = ", ".join(f"{k.name}:{v}" for k, v in counts.items())
|
print("Applied actions.")
|
||||||
print(f"Plan → {summary}")
|
|
||||||
# Execute
|
def on_import_error(pl: dict, exc: Exception) -> bool:
|
||||||
try:
|
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
|
||||||
asyncio.run(executor.execute(actions, pl))
|
return True
|
||||||
except DependencyError as e:
|
|
||||||
print(f"ERROR: {e}")
|
def on_dependency_error(pl: dict, exc: Exception) -> bool:
|
||||||
continue
|
del pl
|
||||||
# Post summary (no DB readback yet)
|
print(f"ERROR: {exc}")
|
||||||
pid = extract_playlist_id(pl.get('url', '')) or pl.get('url', '')
|
return True
|
||||||
db.set_playlist_last_sync(pid)
|
|
||||||
print("Applied actions.")
|
run_sync_batch(
|
||||||
else:
|
settings.playlists,
|
||||||
print(f"No actions needed for: {pl.get('url')}")
|
db=db,
|
||||||
except Exception as exc: # keep bootstrap resilient during early dev
|
service=service,
|
||||||
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
|
executor=executor,
|
||||||
|
apply=True,
|
||||||
|
on_plan=on_plan,
|
||||||
|
on_no_actions=on_no_actions,
|
||||||
|
on_applied=on_applied,
|
||||||
|
on_import_error=on_import_error,
|
||||||
|
on_dependency_error=on_dependency_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user