mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-11-05 07:55:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15c7e2b298 | ||
|
|
c6a98d30a5 | ||
|
|
15c7c8e514 | ||
|
|
e1aaf35c1f | ||
|
|
758dcf5080 | ||
|
|
99bbefce9c | ||
|
|
02a06a70e4 | ||
|
|
c578e42bf7 | ||
|
|
9a5deeefed | ||
|
|
389bd1b348 | ||
|
|
e65199a403 | ||
|
|
aefef55a2c | ||
|
|
8904e07597 | ||
|
|
972c8e1dbf | ||
|
|
b6fb314571 | ||
|
|
b8acce7623 | ||
|
|
a647685070 | ||
|
|
3494578c0e | ||
|
|
5b393c0bcd | ||
|
|
47f88bc6a4 | ||
|
|
835958cd94 | ||
|
|
e1fab69598 | ||
|
|
e97c419b47 | ||
|
|
a7f1564581 | ||
|
|
3b1cdda19d | ||
|
|
2dc119a2f1 | ||
|
|
7d0c7aa1d5 | ||
|
|
15f2df0cbf | ||
|
|
22756f35db | ||
|
|
48bcf2c9df | ||
|
|
5f6df549ab |
No files matched your search
@@ -29,7 +29,7 @@ jobs:
|
|||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -68,23 +68,10 @@ jobs:
|
|||||||
git push origin "${TAG}"
|
git push origin "${TAG}"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Unit tests
|
name: Test
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/test.yml
|
||||||
steps:
|
permissions:
|
||||||
- uses: actions/checkout@v6
|
contents: read
|
||||||
- uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
cache: "pip"
|
|
||||||
- name: Install
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -e ".[dev]" || pip install -e .
|
|
||||||
pip install pytest
|
|
||||||
- name: Run tests
|
|
||||||
env:
|
|
||||||
PYTHONPATH: ${{ github.workspace }}/src
|
|
||||||
run: pytest
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build packages
|
name: Build packages
|
||||||
@@ -97,7 +84,7 @@ jobs:
|
|||||||
ffmpeg: [bundled, none]
|
ffmpeg: [bundled, none]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Derive version
|
- name: Derive version
|
||||||
id: version
|
id: version
|
||||||
@@ -113,7 +100,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
|
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -204,9 +191,43 @@ 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
|
|
||||||
|
archive_name="ffmpeg-n9.0-latest-linux64-gpl-9.0.tar.xz"
|
||||||
|
release_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest"
|
||||||
|
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[@]}" "${release_url}/${archive_name}" -o ffmpeg.tar.xz
|
||||||
|
curl "${curl_common[@]}" "${release_url}/checksums.sha256" -o checksums.sha256
|
||||||
|
|
||||||
|
expected_sha256="$(awk -v archive="$archive_name" '$2 == archive || $2 == "*" archive { print $1; exit }' checksums.sha256)"
|
||||||
|
if [[ -z "$expected_sha256" ]]; then
|
||||||
|
echo "Checksum for ${archive_name} was not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s ffmpeg.tar.xz\n' "$expected_sha256" | sha256sum -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
|
ffmpeg_binary="$(find ffmpeg_tmp -type f -name ffmpeg -perm -u+x -print -quit)"
|
||||||
|
if [[ -z "$ffmpeg_binary" ]]; then
|
||||||
|
echo "Extracted archive does not contain an executable ffmpeg binary" >&2
|
||||||
|
find ffmpeg_tmp -maxdepth 3 -type f -print >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mv "$ffmpeg_binary" package/bin/ffmpeg
|
||||||
chmod +x package/bin/ffmpeg
|
chmod +x package/bin/ffmpeg
|
||||||
|
|
||||||
- name: Archive (Windows)
|
- name: Archive (Windows)
|
||||||
@@ -250,7 +271,7 @@ jobs:
|
|||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
name: Lint Python code
|
name: Lint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
pull_request:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install Ruff
|
- name: Install Ruff
|
||||||
run: pip install ruff
|
run: pip install ruff
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- "assets/**"
|
||||||
|
- "README.md"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- "assets/**"
|
||||||
|
- "README.md"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
uses: ./.github/workflows/lint_ruff.yml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
unit-tests:
|
||||||
|
name: Unit tests
|
||||||
|
uses: ./.github/workflows/unit-tests.yml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
@@ -1,29 +1,8 @@
|
|||||||
name: Unit tests
|
name: Unit Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_call:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "src/**"
|
|
||||||
- "tests/**"
|
|
||||||
- "pyproject.toml"
|
|
||||||
- "pytest.ini"
|
|
||||||
- "ytpl-sync-entry.py"
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "src/**"
|
|
||||||
- "tests/**"
|
|
||||||
- "pyproject.toml"
|
|
||||||
- "pytest.ini"
|
|
||||||
- "ytpl-sync-entry.py"
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
@@ -34,10 +13,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
@@ -78,6 +78,9 @@ jobs:
|
|||||||
if: steps.detect.outputs.needs_update == 'true'
|
if: steps.detect.outputs.needs_update == 'true'
|
||||||
uses: peter-evans/create-pull-request@v8
|
uses: peter-evans/create-pull-request@v8
|
||||||
with:
|
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
|
branch: chore/refresh-yt-dlp
|
||||||
commit-message: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_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 }}"
|
title: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Local-first YouTube playlist synchronization client.
|
|||||||
|
|
||||||
## What's Included
|
## What's Included
|
||||||
|
|
||||||
- GUI (PySide6) playlist manager + sync runner
|
- GUI (PySide6 Essentials) playlist manager + sync runner
|
||||||
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
|
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
|
||||||
- Safe reordering via two-pass rename, recycle deletions
|
- Safe reordering via two-pass rename, recycle deletions
|
||||||
- Async download queue with simple retry (yt-dlp Python API)
|
- Async download queue with simple retry (yt-dlp Python API)
|
||||||
@@ -30,6 +30,7 @@ Download the latest release from this repo's Releases page and pick one:
|
|||||||
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled)
|
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled)
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
Application uses a json config that canbe edited from UI or manually
|
Application uses a json config that canbe edited from UI or manually
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -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,778 +0,0 @@
|
|||||||
# YouTube Playlist Sync — Project Conversion Plan
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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
|
|
||||||
+3
-3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ytpl-sync"
|
name = "ytpl-sync"
|
||||||
version = "2.1.1"
|
version = "2.3.0"
|
||||||
description = "YouTube playlist Sync"
|
description = "YouTube playlist Sync"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [ { name = "Dark_Zoul" } ]
|
authors = [ { name = "Dark_Zoul" } ]
|
||||||
@@ -12,8 +12,8 @@ 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.8.19",
|
||||||
"PySide6",
|
"PySide6_Essentials>=6.11.1",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
test = [
|
test = [
|
||||||
|
|||||||
+4
-4
@@ -3,17 +3,17 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config.settings import Settings
|
from .config.settings import Settings
|
||||||
from .core.database.db import Database
|
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
|
from .core.sync.executor import ActionExecutor
|
||||||
from .core.utils.yt import extract_playlist_id
|
from .core.sync.service import SyncService
|
||||||
from .core.utils.deps import DependencyError
|
from .core.utils.deps import DependencyError
|
||||||
from .core.utils.logging_setup import configure_logging
|
from .core.utils.logging_setup import configure_logging
|
||||||
|
from .core.utils.yt import extract_playlist_id
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _default_ffmpeg_path() -> str:
|
def _default_ffmpeg_path() -> str:
|
||||||
@@ -12,7 +15,7 @@ def _default_ffmpeg_path() -> str:
|
|||||||
return "./bin/ffmpeg"
|
return "./bin/ffmpeg"
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CONFIG: Dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"playlists": [],
|
"playlists": [],
|
||||||
"download_mode": "video",
|
"download_mode": "video",
|
||||||
"max_download_quality": "1080p",
|
"max_download_quality": "1080p",
|
||||||
@@ -29,7 +32,7 @@ class Settings:
|
|||||||
base_dir = Path("config")
|
base_dir = Path("config")
|
||||||
base_dir.mkdir(parents=True, exist_ok=True)
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.path = (base_dir / "yt-playlist-config.json").resolve()
|
self.path = (base_dir / "yt-playlist-config.json").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.
|
||||||
if not self.path.exists():
|
if not self.path.exists():
|
||||||
@@ -40,13 +43,13 @@ class Settings:
|
|||||||
def _load_from_path(self, path: Path) -> None:
|
def _load_from_path(self, path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
self.data.update(json.loads(path.read_text(encoding="utf-8")))
|
self.data.update(json.loads(path.read_text(encoding="utf-8")))
|
||||||
except Exception:
|
except (OSError, TypeError, UnicodeError, ValueError) as exc:
|
||||||
# Leave defaults if invalid JSON; validation can be added later.
|
# Leave defaults if invalid JSON; validation can be added later.
|
||||||
pass
|
logger.debug("Failed to load config %s: %s", path, exc)
|
||||||
|
|
||||||
def _write_default_config(self, path: Path) -> None:
|
def _write_default_config(self, path: Path) -> None:
|
||||||
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": [
|
||||||
{
|
{
|
||||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
||||||
@@ -60,7 +63,7 @@ class Settings:
|
|||||||
path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def playlists(self) -> List[Dict[str, Any]]:
|
def playlists(self) -> list[dict[str, Any]]:
|
||||||
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"]),
|
||||||
@@ -71,7 +74,7 @@ class Settings:
|
|||||||
"retry_delay_seconds": self.data.get("retry_delay_seconds", DEFAULT_CONFIG["retry_delay_seconds"]),
|
"retry_delay_seconds": self.data.get("retry_delay_seconds", DEFAULT_CONFIG["retry_delay_seconds"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
results: List[Dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
for pl in list(self.data.get("playlists", [])):
|
for pl in list(self.data.get("playlists", [])):
|
||||||
if not isinstance(pl, dict):
|
if not isinstance(pl, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Core backend modules (scanner, sync, download, db, scheduler, events)."""
|
"""Core backend modules (scanner, sync, download, db, scheduler, events)."""
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"scanner",
|
|
||||||
"sync",
|
|
||||||
"download",
|
|
||||||
"database",
|
"database",
|
||||||
"scheduler",
|
"download",
|
||||||
"events",
|
"events",
|
||||||
|
"scanner",
|
||||||
|
"scheduler",
|
||||||
|
"sync",
|
||||||
]
|
]
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
PRAGMA journal_mode=WAL;
|
PRAGMA journal_mode=WAL;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
import logging
|
||||||
|
|
||||||
from .queue_manager import DownloadJob, JobState
|
from .queue_manager import DownloadJob, JobState
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Downloader:
|
class Downloader:
|
||||||
"""
|
"""
|
||||||
@@ -11,7 +13,7 @@ class Downloader:
|
|||||||
where actual download logic will land (audio/video/both).
|
where actual download logic will land (audio/video/both).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, yt_dlp_path: Optional[str] = None, ffmpeg_path: Optional[str] = None) -> None:
|
def __init__(self, yt_dlp_path: str | None = None, ffmpeg_path: str | None = None) -> None:
|
||||||
self.yt_dlp_path = yt_dlp_path
|
self.yt_dlp_path = yt_dlp_path
|
||||||
self.ffmpeg_path = ffmpeg_path
|
self.ffmpeg_path = ffmpeg_path
|
||||||
|
|
||||||
@@ -34,15 +36,15 @@ class Downloader:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
cap = int(digits)
|
cap = int(digits)
|
||||||
except Exception:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
return cap if cap > 0 else None
|
return cap if cap > 0 else None
|
||||||
|
|
||||||
cap = parse_height_cap(max_download_quality)
|
cap = parse_height_cap(max_download_quality)
|
||||||
if cap is not None:
|
if cap is not None:
|
||||||
#if the requested cap isn't available, we still download the best mp4.
|
# Prefer separate MP4 video and audio streams; yt-dlp merges them with ffmpeg.
|
||||||
return f"best[ext=mp4][acodec!=none][vcodec!=none][height<={cap}]/best[ext=mp4][height<={cap}]/best[ext=mp4]"
|
return f"bestvideo[ext=mp4][height<={cap}]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<={cap}]+bestaudio/bestvideo[ext=mp4][height<={cap}]/best[ext=mp4]"
|
||||||
return "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]"
|
return "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio/bestvideo[ext=mp4]/best[ext=mp4]"
|
||||||
|
|
||||||
async def handle_job(self, job: DownloadJob):
|
async def handle_job(self, job: DownloadJob):
|
||||||
try:
|
try:
|
||||||
@@ -60,10 +62,10 @@ class Downloader:
|
|||||||
if not job.keep_video and job.output_path:
|
if not job.keep_video and job.output_path:
|
||||||
try:
|
try:
|
||||||
job.output_path.unlink(missing_ok=True)
|
job.output_path.unlink(missing_ok=True)
|
||||||
except Exception:
|
except OSError as exc:
|
||||||
pass
|
logger.debug("Failed to remove temporary video file: %s", exc)
|
||||||
job.state = JobState.COMPLETED
|
job.state = JobState.COMPLETED
|
||||||
except Exception as exc: # pragma: no cover - environment dependent
|
except Exception as exc: # noqa: BLE001 # pragma: no cover - environment dependent
|
||||||
job.state = JobState.FAILED
|
job.state = JobState.FAILED
|
||||||
job.error = str(exc)
|
job.error = str(exc)
|
||||||
|
|
||||||
@@ -72,11 +74,12 @@ class Downloader:
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
import yt_dlp # type: ignore
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yt_dlp # type: ignore
|
||||||
try:
|
try:
|
||||||
from yt_dlp.utils import DownloadCancelled # type: ignore
|
from yt_dlp.utils import DownloadCancelled # type: ignore
|
||||||
except Exception: # pragma: no cover - optional
|
except (ImportError, AttributeError): # pragma: no cover - optional
|
||||||
DownloadCancelled = Exception # type: ignore[misc,assignment]
|
DownloadCancelled = Exception # type: ignore[misc,assignment]
|
||||||
|
|
||||||
class _QuietLogger:
|
class _QuietLogger:
|
||||||
@@ -94,10 +97,8 @@ class Downloader:
|
|||||||
|
|
||||||
fmt = self.build_format(getattr(job, "max_download_quality", None))
|
fmt = self.build_format(getattr(job, "max_download_quality", None))
|
||||||
|
|
||||||
# All modes download a single muxed mp4 when possible.
|
# Prefer MP4 video with a separate audio stream; yt-dlp merges them when needed.
|
||||||
# This avoids any ffmpeg-driven merging during the download step, satisfying:
|
# Audio extraction for audio/both modes happens separately after download.
|
||||||
# - video: "original file, no processing"
|
|
||||||
# - audio/both: extraction is done separately after download
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
"format": fmt,
|
"format": fmt,
|
||||||
"outtmpl": outtmpl,
|
"outtmpl": outtmpl,
|
||||||
@@ -137,19 +138,19 @@ class Downloader:
|
|||||||
pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None
|
pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None
|
||||||
|
|
||||||
should_emit = status in {"finished", "error"}
|
should_emit = status in {"finished", "error"}
|
||||||
if not should_emit:
|
if not should_emit and (
|
||||||
if now - last_ts >= 0.25:
|
now - last_ts >= 0.25
|
||||||
should_emit = True
|
or pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01)
|
||||||
elif pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01):
|
):
|
||||||
should_emit = True
|
should_emit = True
|
||||||
|
|
||||||
if should_emit:
|
if should_emit:
|
||||||
setattr(job, "_last_progress_emit_ts", now)
|
job._last_progress_emit_ts = now
|
||||||
if pct is not None:
|
if pct is not None:
|
||||||
setattr(job, "_last_progress_emit_pct", pct)
|
job._last_progress_emit_pct = pct
|
||||||
progress_cb(payload)
|
progress_cb(payload)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - progress hooks must not abort downloads
|
||||||
pass
|
logger.debug("Progress callback failed: %s", exc)
|
||||||
|
|
||||||
ydl_opts["progress_hooks"] = [hook]
|
ydl_opts["progress_hooks"] = [hook]
|
||||||
|
|
||||||
@@ -160,8 +161,8 @@ class Downloader:
|
|||||||
p = Path(str(ffmpeg_hint))
|
p = Path(str(ffmpeg_hint))
|
||||||
if p.exists():
|
if p.exists():
|
||||||
ydl_opts["ffmpeg_location"] = str(p)
|
ydl_opts["ffmpeg_location"] = str(p)
|
||||||
except Exception:
|
except (OSError, TypeError, ValueError) as exc:
|
||||||
pass
|
logger.debug("Invalid ffmpeg path: %s", exc)
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||||
ydl.download([job.url])
|
ydl.download([job.url])
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any
|
||||||
|
|
||||||
from ..models import PlaylistItem
|
from ..models import PlaylistItem
|
||||||
|
|
||||||
@@ -22,17 +23,17 @@ class JobState(str, Enum):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class DownloadJob:
|
class DownloadJob:
|
||||||
item: PlaylistItem
|
item: PlaylistItem
|
||||||
output_path: Optional[Path] = None
|
output_path: Path | None = None
|
||||||
url: Optional[str] = None
|
url: str | None = None
|
||||||
mode: str = "audio" # audio|video
|
mode: str = "audio" # audio|video
|
||||||
state: JobState = JobState.QUEUED
|
state: JobState = JobState.QUEUED
|
||||||
error: Optional[str] = None
|
error: str | None = None
|
||||||
ffmpeg_path: Optional[str] = None
|
ffmpeg_path: str | None = None
|
||||||
max_download_quality: Optional[str] = None
|
max_download_quality: str | None = None
|
||||||
playlist_id: Optional[str] = None
|
playlist_id: str | None = None
|
||||||
progress_callback: Optional[Callable[[dict[str, Any]], None]] = None
|
progress_callback: Callable[[dict[str, Any]], None] | None = None
|
||||||
cancel_check: Optional[Callable[[], bool]] = None
|
cancel_check: Callable[[], bool] | None = None
|
||||||
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
|
audio_output_path: Path | None = None # when mode=video and we also want mp3
|
||||||
keep_video: bool = True
|
keep_video: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ class QueueManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
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)
|
||||||
self._workers: list[asyncio.Task[None]] = []
|
self._workers: list[asyncio.Task[None]] = []
|
||||||
self._stopped = asyncio.Event()
|
self._stopped = asyncio.Event()
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from ..utils.rate_limit import is_youtube_rate_limit_error
|
||||||
from .downloader import Downloader
|
from .downloader import Downloader
|
||||||
from .queue_manager import DownloadJob, JobState
|
from .queue_manager import DownloadJob, JobState
|
||||||
from ..utils.rate_limit import is_youtube_rate_limit_error
|
|
||||||
|
|
||||||
|
|
||||||
async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5):
|
async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5):
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any, Awaitable, Callable, DefaultDict, Dict, List
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
||||||
EventHandler = Callable[[Dict[str, Any]], Awaitable[None]]
|
|
||||||
|
|
||||||
|
|
||||||
class EventBus:
|
class EventBus:
|
||||||
"""Simple async pub/sub event bus used by backend and (later) GUI."""
|
"""Simple async pub/sub event bus used by backend and (later) GUI."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._subs: DefaultDict[str, List[EventHandler]] = defaultdict(list)
|
self._subs: defaultdict[str, list[EventHandler]] = defaultdict(list)
|
||||||
|
|
||||||
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||||
self._subs[event_name].append(handler)
|
self._subs[event_name].append(handler)
|
||||||
|
|
||||||
async def publish(self, event_name: str, payload: Dict[str, Any]) -> None:
|
async def publish(self, event_name: str, payload: dict[str, Any]) -> None:
|
||||||
for h in list(self._subs.get(event_name, [])):
|
for h in list(self._subs.get(event_name, [])):
|
||||||
await h(payload)
|
await h(payload)
|
||||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadMode(str, Enum):
|
class DownloadMode(str, Enum):
|
||||||
@@ -15,7 +14,7 @@ class DownloadMode(str, Enum):
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Playlist:
|
class Playlist:
|
||||||
id: str
|
id: str
|
||||||
name: Optional[str]
|
name: str | None
|
||||||
url: str
|
url: str
|
||||||
path: Path
|
path: Path
|
||||||
mode: DownloadMode = DownloadMode.audio
|
mode: DownloadMode = DownloadMode.audio
|
||||||
@@ -29,7 +28,7 @@ class PlaylistItem:
|
|||||||
video_id: str
|
video_id: str
|
||||||
title: str
|
title: str
|
||||||
playlist_index: int
|
playlist_index: int
|
||||||
local_filename: Optional[str] = None
|
local_filename: str | None = None
|
||||||
downloaded: bool = False
|
downloaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -45,9 +44,9 @@ class SyncActionType(str, Enum):
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SyncAction:
|
class SyncAction:
|
||||||
type: SyncActionType
|
type: SyncActionType
|
||||||
item: Optional[PlaylistItem] = None
|
item: PlaylistItem | None = None
|
||||||
from_name: Optional[str] = None
|
from_name: str | None = None
|
||||||
to_name: Optional[str] = None
|
to_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from ..models import PlaylistItem
|
from ..models import PlaylistItem
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PlaylistScanner:
|
class PlaylistScanner:
|
||||||
"""
|
"""
|
||||||
@@ -19,7 +21,7 @@ class PlaylistScanner:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]:
|
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: str | None = None) -> list[PlaylistItem]:
|
||||||
"""Return the current remote playlist entries as `PlaylistItem` records."""
|
"""Return the current remote playlist entries as `PlaylistItem` records."""
|
||||||
try:
|
try:
|
||||||
import yt_dlp # type: ignore
|
import yt_dlp # type: ignore
|
||||||
@@ -39,14 +41,14 @@ class PlaylistScanner:
|
|||||||
p = Path(str(ffmpeg_path))
|
p = Path(str(ffmpeg_path))
|
||||||
if p.exists():
|
if p.exists():
|
||||||
ydl_opts["ffmpeg_location"] = str(p)
|
ydl_opts["ffmpeg_location"] = str(p)
|
||||||
except Exception:
|
except (OSError, TypeError, ValueError) as exc:
|
||||||
pass
|
logger.debug("Invalid ffmpeg path: %s", exc)
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||||
info = ydl.extract_info(playlist_url, download=False)
|
info = ydl.extract_info(playlist_url, download=False)
|
||||||
|
|
||||||
entries = info.get("entries", []) if isinstance(info, dict) else []
|
entries = info.get("entries", []) if isinstance(info, dict) else []
|
||||||
items: List[PlaylistItem] = []
|
items: list[PlaylistItem] = []
|
||||||
for idx, v in enumerate(entries, start=1):
|
for idx, v in enumerate(entries, start=1):
|
||||||
if not v:
|
if not v:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Awaitable, Callable
|
|
||||||
|
|
||||||
|
|
||||||
class Scheduler:
|
class Scheduler:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Iterable, List, Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
|
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ class DiffEngine:
|
|||||||
db_index: Mapping[str, PlaylistItem],
|
db_index: Mapping[str, PlaylistItem],
|
||||||
fs_entries: Iterable[FilesystemEntry],
|
fs_entries: Iterable[FilesystemEntry],
|
||||||
extension: str,
|
extension: str,
|
||||||
) -> List[SyncAction]:
|
) -> list[SyncAction]:
|
||||||
actions: List[SyncAction] = []
|
actions: list[SyncAction] = []
|
||||||
|
|
||||||
desired_names = {
|
desired_names = {
|
||||||
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
|
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, List
|
|
||||||
|
|
||||||
|
from ..database.db import Database
|
||||||
from ..download.queue_manager import DownloadJob, QueueManager
|
from ..download.queue_manager import DownloadJob, QueueManager
|
||||||
from ..download.workers import default_worker
|
from ..download.workers import default_worker
|
||||||
|
from ..events.event_bus import EventBus
|
||||||
from ..models import SyncAction, SyncActionType
|
from ..models import SyncAction, SyncActionType
|
||||||
from ..sync.reorder import safe_multi_rename
|
from ..sync.reorder import safe_multi_rename
|
||||||
from ..database.db import Database
|
|
||||||
from ..utils.yt import extract_playlist_id
|
|
||||||
from ..events.event_bus import EventBus
|
|
||||||
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
|
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
|
||||||
from ..utils.rate_limit import is_youtube_rate_limit_error
|
from ..utils.rate_limit import is_youtube_rate_limit_error
|
||||||
|
from ..utils.yt import extract_playlist_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ActionExecutor:
|
class ActionExecutor:
|
||||||
@@ -95,7 +98,8 @@ class ActionExecutor:
|
|||||||
try:
|
try:
|
||||||
self.db.set_playlist_last_sync(playlist_id)
|
self.db.set_playlist_last_sync(playlist_id)
|
||||||
last_sync = self.db.get_playlist_last_sync(playlist_id)
|
last_sync = self.db.get_playlist_last_sync(playlist_id)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - sync timestamp persistence is best effort
|
||||||
|
logger.debug("Failed to persist playlist sync timestamp: %s", exc)
|
||||||
last_sync = None
|
last_sync = None
|
||||||
summary = {
|
summary = {
|
||||||
"playlist_id": playlist_id,
|
"playlist_id": playlist_id,
|
||||||
@@ -140,7 +144,7 @@ class ActionExecutor:
|
|||||||
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 = []
|
||||||
applied: List[SyncAction] = []
|
applied: list[SyncAction] = []
|
||||||
for a in actions:
|
for a in actions:
|
||||||
if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name:
|
if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name:
|
||||||
continue
|
continue
|
||||||
@@ -160,8 +164,8 @@ class ActionExecutor:
|
|||||||
if a.item and a.to_name:
|
if a.item and a.to_name:
|
||||||
try:
|
try:
|
||||||
self.db.update_local_filename(playlist_id, a.item.video_id, a.to_name)
|
self.db.update_local_filename(playlist_id, a.item.video_id, a.to_name)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - database update is best effort
|
||||||
pass
|
logger.debug("Failed to update renamed file in database: %s", exc)
|
||||||
if self.bus:
|
if self.bus:
|
||||||
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})
|
||||||
|
|
||||||
@@ -187,18 +191,19 @@ class ActionExecutor:
|
|||||||
if dst.exists():
|
if dst.exists():
|
||||||
dst.unlink()
|
dst.unlink()
|
||||||
shutil.move(str(src), str(dst))
|
shutil.move(str(src), str(dst))
|
||||||
except Exception:
|
except OSError as exc:
|
||||||
|
logger.debug("Failed to recycle file %s: %s", src, exc)
|
||||||
# fallback to delete if move fails
|
# fallback to delete if move fails
|
||||||
try:
|
try:
|
||||||
src.unlink()
|
src.unlink()
|
||||||
except Exception:
|
except OSError as unlink_exc:
|
||||||
pass
|
logger.debug("Failed to delete unrecycled file %s: %s", src, unlink_exc)
|
||||||
# Update DB to clear file state
|
# Update DB to clear file state
|
||||||
if a.item:
|
if a.item:
|
||||||
try:
|
try:
|
||||||
self.db.clear_file_state(playlist_id, a.item.video_id)
|
self.db.clear_file_state(playlist_id, a.item.video_id)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - database update is best effort
|
||||||
pass
|
logger.debug("Failed to clear deleted file in database: %s", exc)
|
||||||
if self.bus:
|
if self.bus:
|
||||||
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
|
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
|
||||||
|
|
||||||
@@ -219,7 +224,7 @@ class ActionExecutor:
|
|||||||
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
|
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
|
||||||
try:
|
try:
|
||||||
concurrency = int(concurrency_cfg) if concurrency_cfg is not None else self.concurrency
|
concurrency = int(concurrency_cfg) if concurrency_cfg is not None else self.concurrency
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
concurrency = self.concurrency
|
concurrency = self.concurrency
|
||||||
queue = QueueManager(concurrency=concurrency)
|
queue = QueueManager(concurrency=concurrency)
|
||||||
|
|
||||||
@@ -227,17 +232,17 @@ class ActionExecutor:
|
|||||||
retry_delay_cfg = playlist_cfg.get("retry_delay_seconds", 1.5)
|
retry_delay_cfg = playlist_cfg.get("retry_delay_seconds", 1.5)
|
||||||
try:
|
try:
|
||||||
retry_max_retries = int(retry_max_cfg) if retry_max_cfg is not None else 2
|
retry_max_retries = int(retry_max_cfg) if retry_max_cfg is not None else 2
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
retry_max_retries = 2
|
retry_max_retries = 2
|
||||||
try:
|
try:
|
||||||
retry_delay_seconds = float(retry_delay_cfg) if retry_delay_cfg is not None else 1.5
|
retry_delay_seconds = float(retry_delay_cfg) if retry_delay_cfg is not None else 1.5
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
retry_delay_seconds = 1.5
|
retry_delay_seconds = 1.5
|
||||||
|
|
||||||
delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0)
|
delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0)
|
||||||
try:
|
try:
|
||||||
delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0
|
delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
delay_between_downloads_seconds = 0.0
|
delay_between_downloads_seconds = 0.0
|
||||||
|
|
||||||
rate_limit_pause = asyncio.Event()
|
rate_limit_pause = asyncio.Event()
|
||||||
@@ -284,7 +289,7 @@ class ActionExecutor:
|
|||||||
|
|
||||||
await queue.start(worker)
|
await queue.start(worker)
|
||||||
try:
|
try:
|
||||||
jobs: List[DownloadJob] = []
|
jobs: list[DownloadJob] = []
|
||||||
|
|
||||||
# Collapse 'both' into single video download + local audio extraction
|
# Collapse 'both' into single video download + local audio extraction
|
||||||
# Build per-video desired outputs
|
# Build per-video desired outputs
|
||||||
@@ -416,5 +421,5 @@ class ActionExecutor:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
|
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - one failed job must not stop finalization
|
||||||
pass
|
logger.debug("Failed to persist download result: %s", exc)
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Sequence
|
|
||||||
|
|
||||||
from ..models import FilesystemEntry
|
from ..models import FilesystemEntry
|
||||||
|
|
||||||
|
|
||||||
def list_files(root: Path, extensions: Sequence[str]) -> List[FilesystemEntry]:
|
def list_files(root: Path, extensions: Sequence[str]) -> list[FilesystemEntry]:
|
||||||
exts = {e.lower() for e in extensions}
|
exts = {e.lower() for e in extensions}
|
||||||
results: List[FilesystemEntry] = []
|
results: list[FilesystemEntry] = []
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
return results
|
return results
|
||||||
for p in root.glob("**/*"):
|
for p in root.glob("**/*"):
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Iterable, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
def safe_multi_rename(renames: Iterable[Tuple[Path, Path]]) -> None:
|
def safe_multi_rename(renames: Iterable[tuple[Path, Path]]) -> None:
|
||||||
"""
|
"""
|
||||||
Apply multiple renames safely using a two-pass strategy to avoid
|
Apply multiple renames safely using a two-pass strategy to avoid
|
||||||
name collisions. Each item is a tuple (src_path, dst_path).
|
name collisions. Each item is a tuple (src_path, dst_path).
|
||||||
@@ -14,7 +14,7 @@ def safe_multi_rename(renames: Iterable[Tuple[Path, Path]]) -> None:
|
|||||||
existing_dests = {dst for _, dst in planned}
|
existing_dests = {dst for _, dst in planned}
|
||||||
|
|
||||||
# Pass 1: move all sources that would collide to temporary names
|
# Pass 1: move all sources that would collide to temporary names
|
||||||
temps: Dict[Path, Path] = {}
|
temps: dict[Path, Path] = {}
|
||||||
for src, dst in planned:
|
for src, dst in planned:
|
||||||
if not src.exists():
|
if not src.exists():
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from ..database.db import Database
|
from ..database.db import Database
|
||||||
from ..models import PlaylistItem, SyncAction
|
from ..models import PlaylistItem, SyncAction
|
||||||
@@ -34,7 +33,7 @@ class SyncService:
|
|||||||
return [".mp3", ".mp4"]
|
return [".mp3", ".mp4"]
|
||||||
return [".mp4"]
|
return [".mp4"]
|
||||||
|
|
||||||
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
|
def sync_from_config(self, playlist_cfg: dict) -> list[SyncAction]:
|
||||||
"""Return the sync actions required to bring one playlist in sync.
|
"""Return the sync actions required to bring one playlist in sync.
|
||||||
|
|
||||||
This method does not apply any changes itself. It normalizes the
|
This method does not apply any changes itself. It normalizes the
|
||||||
@@ -60,7 +59,7 @@ class SyncService:
|
|||||||
ffmpeg_cfg = playlist_cfg.get("ffmpeg_path")
|
ffmpeg_cfg = playlist_cfg.get("ffmpeg_path")
|
||||||
items = self.scanner.scan(url, playlist_id, ffmpeg_path=str(ffmpeg_cfg) if ffmpeg_cfg is not None else None)
|
items = self.scanner.scan(url, playlist_id, ffmpeg_path=str(ffmpeg_cfg) if ffmpeg_cfg is not None else None)
|
||||||
|
|
||||||
sanitized: List[PlaylistItem] = []
|
sanitized: list[PlaylistItem] = []
|
||||||
for it in items:
|
for it in items:
|
||||||
safe_title = sanitize_title(it.title, it.video_id)
|
safe_title = sanitize_title(it.title, it.video_id)
|
||||||
sanitized.append(
|
sanitized.append(
|
||||||
@@ -100,7 +99,7 @@ class SyncService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Augment remote items with DB-known filenames/download flags
|
# Augment remote items with DB-known filenames/download flags
|
||||||
augmented: List[PlaylistItem] = []
|
augmented: list[PlaylistItem] = []
|
||||||
for it in sanitized:
|
for it in sanitized:
|
||||||
known = db_index.get(it.video_id)
|
known = db_index.get(it.video_id)
|
||||||
if known is None:
|
if known is None:
|
||||||
@@ -118,7 +117,7 @@ class SyncService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
exts = self._mode_to_extensions(mode)
|
exts = self._mode_to_extensions(mode)
|
||||||
merged_actions: List[SyncAction] = []
|
merged_actions: list[SyncAction] = []
|
||||||
|
|
||||||
# Compute per-extension actions against respective roots
|
# Compute per-extension actions against respective roots
|
||||||
for ext in exts:
|
for ext in exts:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import sys
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
class DependencyError(RuntimeError):
|
class DependencyError(RuntimeError):
|
||||||
@@ -16,20 +15,20 @@ class DependencyError(RuntimeError):
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DependencyStatus:
|
class DependencyStatus:
|
||||||
yt_dlp_ok: bool
|
yt_dlp_ok: bool
|
||||||
yt_dlp_version: Optional[str]
|
yt_dlp_version: str | None
|
||||||
ffmpeg_ok: bool
|
ffmpeg_ok: bool
|
||||||
ffmpeg_path: Optional[str]
|
ffmpeg_path: str | None
|
||||||
ffprobe_ok: bool
|
ffprobe_ok: bool
|
||||||
ffprobe_path: Optional[str]
|
ffprobe_path: str | None
|
||||||
|
|
||||||
|
|
||||||
def get_yt_dlp_version() -> Optional[str]:
|
def get_yt_dlp_version() -> str | None:
|
||||||
try:
|
try:
|
||||||
import yt_dlp # type: ignore
|
import yt_dlp # type: ignore
|
||||||
|
|
||||||
# yt-dlp exposes version in different places depending on install
|
# yt-dlp exposes version in different places depending on install
|
||||||
return getattr(yt_dlp, "__version__", None) or getattr(getattr(yt_dlp, "version", None), "__version__", None)
|
return getattr(yt_dlp, "__version__", None) or getattr(getattr(yt_dlp, "version", None), "__version__", None)
|
||||||
except Exception:
|
except (ImportError, AttributeError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ def ensure_yt_dlp_available() -> str:
|
|||||||
return get_yt_dlp_version() or "unknown"
|
return get_yt_dlp_version() or "unknown"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_tool_paths(tool_hint: Optional[str], exe_name: str) -> Tuple[Optional[str], Optional[str]]:
|
def _resolve_tool_paths(tool_hint: str | None, exe_name: str) -> tuple[str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
Returns (resolved_exe_path, resolved_dir_hint).
|
Returns (resolved_exe_path, resolved_dir_hint).
|
||||||
- If tool_hint is a file path, use it.
|
- If tool_hint is a file path, use it.
|
||||||
@@ -70,7 +69,7 @@ def _resolve_tool_paths(tool_hint: Optional[str], exe_name: str) -> Tuple[Option
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def ensure_ffmpeg_available(ffmpeg_hint: Optional[str]) -> Tuple[str, str]:
|
def ensure_ffmpeg_available(ffmpeg_hint: str | None) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Ensures ffmpeg is runnable. Returns (ffmpeg_path, ffmpeg_path).
|
Ensures ffmpeg is runnable. Returns (ffmpeg_path, ffmpeg_path).
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ def ensure_ffmpeg_available(ffmpeg_hint: Optional[str]) -> Tuple[str, str]:
|
|||||||
"""
|
"""
|
||||||
ffmpeg_exe = "ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"
|
ffmpeg_exe = "ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"
|
||||||
|
|
||||||
ffmpeg_path, ffmpeg_dir = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
|
ffmpeg_path, _ = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
|
||||||
if not ffmpeg_path:
|
if not ffmpeg_path:
|
||||||
raise DependencyError("ffmpeg not found. Install ffmpeg or set 'ffmpeg_path' in config.")
|
raise DependencyError("ffmpeg not found. Install ffmpeg or set 'ffmpeg_path' in config.")
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ def _read_text(path: Path) -> str | None:
|
|||||||
if path.exists():
|
if path.exists():
|
||||||
text = path.read_text(encoding="utf-8").strip()
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
return text or None
|
return text or None
|
||||||
except Exception:
|
except (OSError, UnicodeError):
|
||||||
pass
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_app_version() -> str:
|
def get_app_version() -> str:
|
||||||
"""
|
"""
|
||||||
Returns the packaged app version.
|
Returns the packaged app version.
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ def extract_playlist_id(url: str) -> str | None:
|
|||||||
if "list" in qs and qs.get("list"):
|
if "list" in qs and qs.get("list"):
|
||||||
return qs.get("list", [None])[0]
|
return qs.get("list", [None])[0]
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6 import QtGui, QtWidgets
|
from PySide6 import QtGui, QtWidgets
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _resource_base() -> Path:
|
def _resource_base() -> Path:
|
||||||
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
|
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
|
||||||
@@ -31,16 +34,16 @@ def load_app_icon() -> QtGui.QIcon:
|
|||||||
icon = QtGui.QIcon(str(p))
|
icon = QtGui.QIcon(str(p))
|
||||||
if not icon.isNull():
|
if not icon.isNull():
|
||||||
return icon
|
return icon
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - icon loading is best effort
|
||||||
pass
|
logger.debug("Failed to load app icon from %s: %s", p, exc)
|
||||||
|
|
||||||
# Fallback to a platform theme icon (Linux) or a generic icon.
|
# Fallback to a platform theme icon (Linux) or a generic icon.
|
||||||
try:
|
try:
|
||||||
themed = QtGui.QIcon.fromTheme("applications-multimedia")
|
themed = QtGui.QIcon.fromTheme("applications-multimedia")
|
||||||
if not themed.isNull():
|
if not themed.isNull():
|
||||||
return themed
|
return themed
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - themed icon loading is best effort
|
||||||
pass
|
logger.debug("Failed to load themed app icon: %s", exc)
|
||||||
|
|
||||||
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
|
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Any, Dict
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from PySide6 import QtCore
|
from PySide6 import QtCore
|
||||||
|
|
||||||
from ..core.events.event_bus import EventBus
|
from ..core.events.event_bus import EventBus
|
||||||
@@ -32,7 +34,7 @@ class BusBridge(QtCore.QObject):
|
|||||||
self._bus.subscribe(name, self._make_handler(name))
|
self._bus.subscribe(name, self._make_handler(name))
|
||||||
|
|
||||||
def _make_handler(self, name: str):
|
def _make_handler(self, name: str):
|
||||||
async def handler(payload: Dict[str, Any]) -> None:
|
async def handler(payload: dict[str, Any]) -> None:
|
||||||
# Ensure delivery on the Qt main thread.
|
# Ensure delivery on the Qt main thread.
|
||||||
self.event.emit(name, dict(payload))
|
self.event.emit(name, dict(payload))
|
||||||
|
|
||||||
|
|||||||
@@ -3,29 +3,29 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
data: Dict[str, Any]
|
data: dict[str, Any]
|
||||||
path: Path
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: Path) -> AppConfig:
|
def load_config(path: Path) -> AppConfig:
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise ValueError("config root must be a JSON object")
|
raise TypeError("config root must be a JSON object")
|
||||||
return AppConfig(data=raw, path=path)
|
return AppConfig(data=raw, path=path)
|
||||||
|
|
||||||
|
|
||||||
def save_config(path: Path, data: Dict[str, Any]) -> None:
|
def save_config(path: Path, data: dict[str, Any]) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||||
path.write_text(payload, encoding="utf-8")
|
path.write_text(payload, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def normalize_config(data: Dict[str, Any]) -> Dict[str, Any]:
|
def normalize_config(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Ensure basic expected shape for config dict.
|
Ensure basic expected shape for config dict.
|
||||||
Keeps unknown keys intact.
|
Keeps unknown keys intact.
|
||||||
|
|||||||
+60
-32
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@@ -7,15 +8,17 @@ from PySide6 import QtCore, QtGui, QtWidgets
|
|||||||
|
|
||||||
from ..config.settings import Settings
|
from ..config.settings import Settings
|
||||||
from ..core.events.event_bus import EventBus
|
from ..core.events.event_bus import EventBus
|
||||||
from .bus_bridge import BusBridge
|
|
||||||
from .app_icon import load_app_icon
|
from .app_icon import load_app_icon
|
||||||
|
from .bus_bridge import BusBridge
|
||||||
from .config_store import load_config
|
from .config_store import load_config
|
||||||
from .runner import SyncRequest, SyncRunner
|
from .pages.about import AboutPage
|
||||||
|
from .pages.logs import LogsPage
|
||||||
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.settings import SettingsPage
|
from .pages.settings import SettingsPage
|
||||||
from .pages.about import AboutPage
|
from .runner import SyncRequest, SyncRunner
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QtWidgets.QMainWindow):
|
class MainWindow(QtWidgets.QMainWindow):
|
||||||
@@ -90,8 +93,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
if cfg_path is not None:
|
if cfg_path is not None:
|
||||||
try:
|
try:
|
||||||
self._settings_page.set_config_path(cfg_path)
|
self._settings_page.set_config_path(cfg_path)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - optional settings-page wiring
|
||||||
pass
|
logger.debug("Failed to set settings config path: %s", exc)
|
||||||
|
|
||||||
self._playlists_page.cancel_requested.connect(self._cancel_sync)
|
self._playlists_page.cancel_requested.connect(self._cancel_sync)
|
||||||
self._queue_page.cancel_sync_requested.connect(self._cancel_sync)
|
self._queue_page.cancel_sync_requested.connect(self._cancel_sync)
|
||||||
@@ -138,7 +141,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
tray = ui.get("tray")
|
tray = ui.get("tray")
|
||||||
tray = tray if isinstance(tray, dict) else {}
|
tray = tray if isinstance(tray, dict) else {}
|
||||||
return dict(tray)
|
return dict(tray)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - config fallback keeps the UI usable
|
||||||
|
logger.debug("Failed to load tray configuration: %s", exc)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _close_to_tray_enabled(self) -> bool:
|
def _close_to_tray_enabled(self) -> bool:
|
||||||
@@ -158,7 +162,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
try:
|
try:
|
||||||
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
|
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - tray support is optional
|
||||||
|
logger.debug("Failed to check system tray availability: %s", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
icon = load_app_icon()
|
icon = load_app_icon()
|
||||||
@@ -211,27 +216,30 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
|
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
|
||||||
3000,
|
3000,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - tray notification is best effort
|
||||||
pass
|
logger.debug("Failed to show tray notification: %s", exc)
|
||||||
return
|
return
|
||||||
if self._tray is not None and not self._close_to_tray_enabled():
|
if self._tray is not None and not self._close_to_tray_enabled():
|
||||||
# Explicitly quit, because the app may be configured to keep running without windows.
|
# Explicitly quit, because the app may be configured to keep running without windows.
|
||||||
try:
|
try:
|
||||||
event.accept()
|
event.accept()
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - close event acceptance is best effort
|
||||||
pass
|
logger.debug("Failed to accept close event: %s", exc)
|
||||||
QtWidgets.QApplication.quit()
|
QtWidgets.QApplication.quit()
|
||||||
return
|
return
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
|
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
|
||||||
try:
|
try:
|
||||||
if event.type() == QtCore.QEvent.Type.WindowStateChange:
|
if (
|
||||||
if self._tray is not None and self._minimize_to_tray_enabled():
|
event.type() == QtCore.QEvent.Type.WindowStateChange
|
||||||
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized):
|
and self._tray is not None
|
||||||
QtCore.QTimer.singleShot(0, self.hide)
|
and self._minimize_to_tray_enabled()
|
||||||
except Exception:
|
and bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized)
|
||||||
pass
|
):
|
||||||
|
QtCore.QTimer.singleShot(0, self.hide)
|
||||||
|
except Exception as exc: # noqa: BLE001 - window state handling is best effort
|
||||||
|
logger.debug("Failed to handle window state change: %s", exc)
|
||||||
super().changeEvent(event)
|
super().changeEvent(event)
|
||||||
|
|
||||||
def _refresh_queue_labels(self) -> None:
|
def _refresh_queue_labels(self) -> None:
|
||||||
@@ -244,24 +252,24 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
pid = extract_playlist_id(url) or url
|
pid = extract_playlist_id(url) or url
|
||||||
labels[pid] = str(pl.get("name") or f"Playlist {idx}")
|
labels[pid] = str(pl.get("name") or f"Playlist {idx}")
|
||||||
self._queue_page.set_playlist_labels(labels)
|
self._queue_page.set_playlist_labels(labels)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - queue labels are best effort
|
||||||
pass
|
logger.debug("Failed to refresh queue labels: %s", exc)
|
||||||
|
|
||||||
@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 interested pages.
|
||||||
try:
|
try:
|
||||||
self._queue_page.on_event(name, payload)
|
self._queue_page.on_event(name, payload)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
|
||||||
pass
|
logger.debug("Queue page event handler failed: %s", exc)
|
||||||
try:
|
try:
|
||||||
self._logs_page.on_event(name, payload)
|
self._logs_page.on_event(name, payload)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
|
||||||
pass
|
logger.debug("Logs page event handler failed: %s", exc)
|
||||||
try:
|
try:
|
||||||
self._playlists_page.on_event(name, payload)
|
self._playlists_page.on_event(name, payload)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
|
||||||
pass
|
logger.debug("Playlists page event handler failed: %s", exc)
|
||||||
|
|
||||||
# Auto-pause on YouTube bot-check/rate-limit surface.
|
# Auto-pause on YouTube bot-check/rate-limit surface.
|
||||||
if name == "SyncPaused":
|
if name == "SyncPaused":
|
||||||
@@ -315,8 +323,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
try:
|
try:
|
||||||
self._thread.quit()
|
self._thread.quit()
|
||||||
self._thread.wait(2000)
|
self._thread.wait(2000)
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - thread shutdown is best effort
|
||||||
pass
|
logger.debug("Failed to stop sync thread: %s", exc)
|
||||||
self._runner = None
|
self._runner = None
|
||||||
self._cancel_flag = None
|
self._cancel_flag = None
|
||||||
self._pause_flag = None
|
self._pause_flag = None
|
||||||
@@ -330,12 +338,12 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
if cfg_path is not None:
|
if cfg_path is not None:
|
||||||
self._settings_page.set_config_path(cfg_path)
|
self._settings_page.set_config_path(cfg_path)
|
||||||
self._refresh_queue_labels()
|
self._refresh_queue_labels()
|
||||||
except Exception:
|
except Exception as exc: # noqa: BLE001 - config refresh is best effort
|
||||||
pass
|
logger.debug("Failed to refresh configuration after sync: %s", exc)
|
||||||
|
|
||||||
# Continue "sync all" chain if active.
|
# Continue "sync all" chain if active.
|
||||||
if hasattr(self, "_sync_queue") and getattr(self, "_sync_queue"):
|
if hasattr(self, "_sync_queue") and self._sync_queue:
|
||||||
nxt = getattr(self, "_sync_queue").pop(0)
|
nxt = self._sync_queue.pop(0)
|
||||||
self._sync_playlist_index(nxt)
|
self._sync_playlist_index(nxt)
|
||||||
|
|
||||||
@QtCore.Slot()
|
@QtCore.Slot()
|
||||||
@@ -400,6 +408,26 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #d7dce4;
|
color: #d7dce4;
|
||||||
}
|
}
|
||||||
|
QScrollArea#logsScroll {
|
||||||
|
background: #11151c;
|
||||||
|
border: 1px solid #2a3140;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
QWidget#logEntries {
|
||||||
|
background: #11151c;
|
||||||
|
}
|
||||||
|
QFrame#logEntry {
|
||||||
|
background: #171b22;
|
||||||
|
border: 1px solid #2a3140;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QLabel#logEventName {
|
||||||
|
color: #8ea7ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QLabel#logEntryBody {
|
||||||
|
color: #cfd3da;
|
||||||
|
}
|
||||||
QScrollBar:vertical {
|
QScrollBar:vertical {
|
||||||
background: #0f1218;
|
background: #0f1218;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
|
|||||||
+59
-12
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from PySide6 import QtWidgets
|
from PySide6 import QtCore, QtWidgets
|
||||||
|
|
||||||
from ..smooth_scroll import enable_smooth_scrolling
|
from ..smooth_scroll import enable_smooth_scrolling
|
||||||
|
|
||||||
@@ -23,22 +23,69 @@ class LogsPage(QtWidgets.QWidget):
|
|||||||
top.addWidget(clear_btn)
|
top.addWidget(clear_btn)
|
||||||
layout.addLayout(top)
|
layout.addLayout(top)
|
||||||
|
|
||||||
self._text = QtWidgets.QPlainTextEdit()
|
self._scroll = QtWidgets.QScrollArea()
|
||||||
self._text.setReadOnly(True)
|
self._scroll.setObjectName("logsScroll")
|
||||||
enable_smooth_scrolling(self._text)
|
self._scroll.setWidgetResizable(True)
|
||||||
layout.addWidget(self._text, 1)
|
self._scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
||||||
|
|
||||||
|
self._entries = QtWidgets.QWidget()
|
||||||
|
self._entries.setObjectName("logEntries")
|
||||||
|
self._entries_layout = QtWidgets.QVBoxLayout(self._entries)
|
||||||
|
self._entries_layout.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self._entries_layout.setSpacing(8)
|
||||||
|
self._entries_layout.addStretch(1)
|
||||||
|
self._scroll.setWidget(self._entries)
|
||||||
|
enable_smooth_scrolling(self._scroll)
|
||||||
|
layout.addWidget(self._scroll, 1)
|
||||||
|
|
||||||
def _clear(self) -> None:
|
def _clear(self) -> None:
|
||||||
self._text.clear()
|
while self._entries_layout.count() > 1:
|
||||||
|
item = self._entries_layout.takeAt(0)
|
||||||
|
widget = item.widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
def on_event(self, name: str, payload: dict) -> None:
|
def on_event(self, name: str, payload: dict) -> None:
|
||||||
# Avoid flooding the UI with high-frequency progress updates.
|
# Avoid flooding the UI with high-frequency progress updates.
|
||||||
if name == "DownloadProgress":
|
if name == "DownloadProgress":
|
||||||
return
|
return
|
||||||
# Keep this lightweight: append a single-line JSON entry.
|
# Keep the event name in the header and show only its payload here.
|
||||||
try:
|
try:
|
||||||
line = json.dumps({"event": name, **payload}, ensure_ascii=False)
|
line = json.dumps(payload, ensure_ascii=False)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 - fall back to a readable representation
|
||||||
line = f"{name}: {payload}"
|
line = str(payload)
|
||||||
self._text.appendPlainText(line)
|
entry = QtWidgets.QFrame()
|
||||||
self._text.moveCursor(self._text.textCursor().End)
|
entry.setObjectName("logEntry")
|
||||||
|
|
||||||
|
entry_layout = QtWidgets.QVBoxLayout(entry)
|
||||||
|
entry_layout.setContentsMargins(12, 9, 12, 9)
|
||||||
|
entry_layout.setSpacing(4)
|
||||||
|
|
||||||
|
header_layout = QtWidgets.QHBoxLayout()
|
||||||
|
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
event_label = QtWidgets.QLabel(name)
|
||||||
|
event_label.setObjectName("logEventName")
|
||||||
|
event_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
header_layout.addWidget(event_label)
|
||||||
|
header_layout.addStretch(1)
|
||||||
|
|
||||||
|
copy_btn = QtWidgets.QPushButton("Copy")
|
||||||
|
copy_btn.setToolTip("Copy this log entry")
|
||||||
|
copy_btn.clicked.connect(lambda: self._copy_entry(f"{name}\n{line}"))
|
||||||
|
header_layout.addWidget(copy_btn)
|
||||||
|
entry_layout.addLayout(header_layout)
|
||||||
|
|
||||||
|
body_label = QtWidgets.QLabel(line)
|
||||||
|
body_label.setObjectName("logEntryBody")
|
||||||
|
body_label.setWordWrap(True)
|
||||||
|
body_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
|
body_label.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Preferred)
|
||||||
|
entry_layout.addWidget(body_label)
|
||||||
|
|
||||||
|
self._entries_layout.insertWidget(self._entries_layout.count() - 1, entry)
|
||||||
|
self._scroll.verticalScrollBar().setValue(self._scroll.verticalScrollBar().maximum())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _copy_entry(text: str) -> None:
|
||||||
|
QtWidgets.QApplication.clipboard().setText(text)
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from PySide6 import QtCore, QtGui, QtWidgets
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
from ...config.settings import Settings
|
from ...config.settings import Settings
|
||||||
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 ..smooth_scroll import enable_smooth_scrolling
|
|
||||||
from ..config_store import load_config, normalize_config, save_config
|
from ..config_store import load_config, normalize_config, save_config
|
||||||
|
from ..smooth_scroll import enable_smooth_scrolling
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -137,7 +137,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
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).data)
|
||||||
rows = self._rows_from_settings()
|
rows = self._rows_from_settings()
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001 - show config load failures in the UI
|
||||||
self._status.setText(f"Failed to load config: {exc}")
|
self._status.setText(f"Failed to load config: {exc}")
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
@@ -152,7 +152,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
ls = db.get_playlist_last_sync(pid)
|
ls = db.get_playlist_last_sync(pid)
|
||||||
if ls:
|
if ls:
|
||||||
last_sync_by_id[pid] = str(ls)
|
last_sync_by_id[pid] = str(ls)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 - database metadata is optional
|
||||||
last_sync_by_id = {}
|
last_sync_by_id = {}
|
||||||
|
|
||||||
self._list.clear()
|
self._list.clear()
|
||||||
@@ -256,7 +256,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
self._status.setText(f"Saved {len(data['playlists'])} playlists to {self._config_path}.")
|
self._status.setText(f"Saved {len(data['playlists'])} playlists to {self._config_path}.")
|
||||||
# Reload settings to reflect merged defaults
|
# Reload settings to reflect merged defaults
|
||||||
self.reload_from_config()
|
self.reload_from_config()
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001 - show config save failures in the UI
|
||||||
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:
|
||||||
@@ -302,7 +302,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
data["playlists"] = self._table_to_playlists()
|
data["playlists"] = self._table_to_playlists()
|
||||||
save_config(self._config_path, data)
|
save_config(self._config_path, data)
|
||||||
self._status.setText(f"Autosaved to {self._config_path}.")
|
self._status.setText(f"Autosaved to {self._config_path}.")
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001 - show autosave failures in the UI
|
||||||
self._status.setText(f"Autosave failed: {exc}")
|
self._status.setText(f"Autosave failed: {exc}")
|
||||||
|
|
||||||
def on_event(self, name: str, payload: dict) -> None:
|
def on_event(self, name: str, payload: dict) -> None:
|
||||||
@@ -355,7 +355,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
self._download_state_by_pid[pid]["video_id"] = vid
|
self._download_state_by_pid[pid]["video_id"] = vid
|
||||||
self._download_state_by_pid[pid]["status"] = str(payload.get("status") or "downloading")
|
self._download_state_by_pid[pid]["status"] = str(payload.get("status") or "downloading")
|
||||||
self._set_card_progress(pid, p)
|
self._set_card_progress(pid, p)
|
||||||
pct = int(round(max(0.0, min(1.0, p)) * 100))
|
pct = round(max(0.0, min(1.0, p)) * 100)
|
||||||
st = str(payload.get("status") or "downloading")
|
st = str(payload.get("status") or "downloading")
|
||||||
tail = f"{vid} {pct}%" if vid else f"{pct}%"
|
tail = f"{vid} {pct}%" if vid else f"{pct}%"
|
||||||
self._set_card_status(pid, f"{st} {tail}".strip())
|
self._set_card_status(pid, f"{st} {tail}".strip())
|
||||||
@@ -392,9 +392,8 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
|||||||
for i in range(self._list.count()):
|
for i in range(self._list.count()):
|
||||||
item = self._list.item(i)
|
item = self._list.item(i)
|
||||||
w = self._list.itemWidget(item)
|
w = self._list.itemWidget(item)
|
||||||
if isinstance(w, _PlaylistCard):
|
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
|
||||||
if w.playlist_id() == playlist_id:
|
w.set_status(text)
|
||||||
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()):
|
for i in range(self._list.count()):
|
||||||
@@ -604,7 +603,7 @@ class _PlaylistCard(QtWidgets.QFrame):
|
|||||||
return extract_playlist_id(url) or url
|
return extract_playlist_id(url) or url
|
||||||
|
|
||||||
def set_progress(self, progress: float) -> None:
|
def set_progress(self, progress: float) -> None:
|
||||||
pct = max(0, min(100, int(round(progress * 100))))
|
pct = max(0, min(100, round(progress * 100)))
|
||||||
self._progress.setValue(pct)
|
self._progress.setValue(pct)
|
||||||
|
|
||||||
def set_last_sync(self, last_sync: str) -> None:
|
def set_last_sync(self, last_sync: str) -> None:
|
||||||
@@ -613,7 +612,7 @@ class _PlaylistCard(QtWidgets.QFrame):
|
|||||||
def validate(self) -> list[str]:
|
def validate(self) -> list[str]:
|
||||||
errs: list[str] = []
|
errs: list[str] = []
|
||||||
url = self._url.text().strip()
|
url = self._url.text().strip()
|
||||||
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
if not url or not url.startswith(("http://", "https://")):
|
||||||
errs.append("URL required")
|
errs.append("URL required")
|
||||||
mode = self._mode.currentText().strip()
|
mode = self._mode.currentText().strip()
|
||||||
if mode not in {"video", "audio", "both"}:
|
if mode not in {"video", "audio", "both"}:
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ class QueuePage(QtWidgets.QWidget):
|
|||||||
top.addStretch(1)
|
top.addStretch(1)
|
||||||
clear_btn = QtWidgets.QPushButton("Clear completed")
|
clear_btn = QtWidgets.QPushButton("Clear completed")
|
||||||
clear_btn.clicked.connect(self._clear_completed)
|
clear_btn.clicked.connect(self._clear_completed)
|
||||||
|
clear_all_btn = QtWidgets.QPushButton("Clear all")
|
||||||
|
clear_all_btn.clicked.connect(self._clear_all)
|
||||||
cancel_btn = QtWidgets.QPushButton("Cancel all")
|
cancel_btn = QtWidgets.QPushButton("Cancel all")
|
||||||
cancel_btn.clicked.connect(self.cancel_sync_requested.emit)
|
cancel_btn.clicked.connect(self.cancel_sync_requested.emit)
|
||||||
top.addWidget(clear_btn)
|
top.addWidget(clear_btn)
|
||||||
|
top.addWidget(clear_all_btn)
|
||||||
top.addWidget(cancel_btn)
|
top.addWidget(cancel_btn)
|
||||||
layout.addLayout(top)
|
layout.addLayout(top)
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ class QueuePage(QtWidgets.QWidget):
|
|||||||
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")
|
||||||
if isinstance(prog, (int, float)):
|
if isinstance(prog, (int, float)):
|
||||||
pct = max(0, min(100, int(round(prog * 100))))
|
pct = max(0, min(100, round(prog * 100)))
|
||||||
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()
|
||||||
@@ -213,3 +216,9 @@ class QueuePage(QtWidgets.QWidget):
|
|||||||
vid = v_item.text()
|
vid = v_item.text()
|
||||||
rebuilt[(str(pid), str(vid))] = v_item
|
rebuilt[(str(pid), str(vid))] = v_item
|
||||||
self._rows_by_key = rebuilt
|
self._rows_by_key = rebuilt
|
||||||
|
|
||||||
|
def _clear_all(self) -> None:
|
||||||
|
self._pending_by_key.clear()
|
||||||
|
self._rows_by_key.clear()
|
||||||
|
self._table.setRowCount(0)
|
||||||
|
self._hint.setText("Waiting for downloads…")
|
||||||
@@ -126,7 +126,7 @@ class SettingsPage(QtWidgets.QWidget):
|
|||||||
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
|
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
|
||||||
|
|
||||||
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: # noqa: BLE001 - show load failures in the UI
|
||||||
self._status.setText(f"Failed to load settings: {exc}")
|
self._status.setText(f"Failed to load settings: {exc}")
|
||||||
finally:
|
finally:
|
||||||
self._suppress_autosave = False
|
self._suppress_autosave = False
|
||||||
@@ -161,5 +161,5 @@ class SettingsPage(QtWidgets.QWidget):
|
|||||||
|
|
||||||
save_config(self._config_path, data)
|
save_config(self._config_path, data)
|
||||||
self._status.setText(f"Saved settings to {self._config_path}.")
|
self._status.setText(f"Saved settings to {self._config_path}.")
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001 - show save failures in the UI
|
||||||
self._status.setText(f"Failed to save settings: {exc}")
|
self._status.setText(f"Failed to save settings: {exc}")
|
||||||
@@ -4,19 +4,19 @@ import asyncio
|
|||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from PySide6 import QtCore
|
from PySide6 import QtCore
|
||||||
|
|
||||||
from ..core.database.db import Database
|
from ..core.database.db import Database
|
||||||
|
from ..core.events.event_bus import EventBus
|
||||||
from ..core.sync.executor import ActionExecutor
|
from ..core.sync.executor import ActionExecutor
|
||||||
from ..core.sync.service import SyncService
|
from ..core.sync.service import SyncService
|
||||||
from ..core.events.event_bus import EventBus
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SyncRequest:
|
class SyncRequest:
|
||||||
playlist_cfg: Dict[str, Any]
|
playlist_cfg: dict[str, Any]
|
||||||
apply: bool = True
|
apply: bool = True
|
||||||
db_path: Path = Path("db/app.db")
|
db_path: Path = Path("db/app.db")
|
||||||
cancel_flag: threading.Event | None = None
|
cancel_flag: threading.Event | None = None
|
||||||
@@ -70,5 +70,5 @@ class SyncRunner(QtCore.QObject):
|
|||||||
self.finished.emit(False, "cancelled")
|
self.finished.emit(False, "cancelled")
|
||||||
else:
|
else:
|
||||||
self.finished.emit(True, "done")
|
self.finished.emit(True, "done")
|
||||||
except Exception as exc:
|
except Exception as exc: # noqa: BLE001 - report worker failures through the UI signal
|
||||||
self.finished.emit(False, str(exc))
|
self.finished.emit(False, str(exc))
|
||||||
@@ -11,7 +11,7 @@ class _SmoothWheelFilter(QtCore.QObject):
|
|||||||
self._anim = QtCore.QPropertyAnimation(area.verticalScrollBar(), b"value", self)
|
self._anim = QtCore.QPropertyAnimation(area.verticalScrollBar(), b"value", self)
|
||||||
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic)
|
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic)
|
||||||
|
|
||||||
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: # noqa: N802
|
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool:
|
||||||
if event.type() != QtCore.QEvent.Type.Wheel:
|
if event.type() != QtCore.QEvent.Type.Wheel:
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ class _SmoothWheelFilter(QtCore.QObject):
|
|||||||
try:
|
try:
|
||||||
angle_y = wheel.angleDelta().y()
|
angle_y = wheel.angleDelta().y()
|
||||||
pixel_y = wheel.pixelDelta().y()
|
pixel_y = wheel.pixelDelta().y()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001 - fall back to native scrolling for unknown event types
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
dy = pixel_y if pixel_y else angle_y
|
dy = pixel_y if pixel_y else angle_y
|
||||||
@@ -34,7 +34,7 @@ class _SmoothWheelFilter(QtCore.QObject):
|
|||||||
if pixel_y:
|
if pixel_y:
|
||||||
delta = -dy
|
delta = -dy
|
||||||
else:
|
else:
|
||||||
delta = int(round(-dy / 120.0 * base_step))
|
delta = round(-dy / 120.0 * base_step)
|
||||||
|
|
||||||
target = max(sb.minimum(), min(sb.maximum(), start + delta))
|
target = max(sb.minimum(), min(sb.maximum(), start + delta))
|
||||||
if target == start:
|
if target == start:
|
||||||
|
|||||||
+3
-3
@@ -12,10 +12,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .config.settings import Settings
|
from .config.settings import Settings
|
||||||
from .core.database.db import Database
|
from .core.database.db import Database
|
||||||
from .core.sync.service import SyncService
|
|
||||||
from .core.sync.executor import ActionExecutor
|
from .core.sync.executor import ActionExecutor
|
||||||
from .core.utils.yt import extract_playlist_id
|
from .core.sync.service import SyncService
|
||||||
from .core.utils.deps import DependencyError
|
from .core.utils.deps import DependencyError
|
||||||
|
from .core.utils.yt import extract_playlist_id
|
||||||
|
|
||||||
|
|
||||||
def bootstrap(db_path: Path | None = None) -> None:
|
def bootstrap(db_path: Path | None = None) -> None:
|
||||||
@@ -49,7 +49,7 @@ def bootstrap(db_path: Path | None = None) -> None:
|
|||||||
print("Applied actions.")
|
print("Applied actions.")
|
||||||
else:
|
else:
|
||||||
print(f"No actions needed for: {pl.get('url')}")
|
print(f"No actions needed for: {pl.get('url')}")
|
||||||
except Exception as exc: # keep bootstrap resilient during early dev
|
except Exception as exc: # noqa: BLE001 - keep bootstrap resilient during early dev
|
||||||
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
|
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ from app.core.download.downloader import Downloader
|
|||||||
def test_build_format_defaults_to_best_mp4():
|
def test_build_format_defaults_to_best_mp4():
|
||||||
fmt = Downloader.build_format(None)
|
fmt = Downloader.build_format(None)
|
||||||
assert "height<=1080" in fmt
|
assert "height<=1080" in fmt
|
||||||
|
assert "bestvideo[ext=mp4][height<=1080]+bestaudio" in fmt
|
||||||
assert fmt.endswith("/best[ext=mp4]")
|
assert fmt.endswith("/best[ext=mp4]")
|
||||||
|
|
||||||
|
|
||||||
def test_build_format_applies_height_cap():
|
def test_build_format_applies_height_cap():
|
||||||
fmt = Downloader.build_format("720p")
|
fmt = Downloader.build_format("720p")
|
||||||
|
assert "bestvideo[ext=mp4]" in fmt
|
||||||
assert "height<=720" in fmt
|
assert "height<=720" in fmt
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ def test_executor_emits_sync_events(tmp_path):
|
|||||||
assert "SyncSummary" in names
|
assert "SyncSummary" in names
|
||||||
assert "SyncFinished" in names
|
assert "SyncFinished" in names
|
||||||
|
|
||||||
summary = [p for n, p in published if n == "SyncSummary"][0]
|
summary = next(p for n, p in published if n == "SyncSummary")
|
||||||
assert summary["playlist_id"] == "p"
|
assert summary["playlist_id"] == "p"
|
||||||
assert "duration_s" in summary
|
assert "duration_s" in summary
|
||||||
assert isinstance(summary["counts"], dict)
|
assert isinstance(summary["counts"], dict)
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.database.db import Database
|
from app.core.database.db import Database
|
||||||
from app.core.sync.executor import ActionExecutor
|
|
||||||
from app.core.events.event_bus import EventBus
|
from app.core.events.event_bus import EventBus
|
||||||
|
from app.core.sync.executor import ActionExecutor
|
||||||
from app.core.sync.service import SyncService
|
from app.core.sync.service import SyncService
|
||||||
|
|
||||||
|
|
||||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.database.db import Database
|
from app.core.database.db import Database
|
||||||
from app.core.sync.executor import ActionExecutor
|
|
||||||
from app.core.events.event_bus import EventBus
|
from app.core.events.event_bus import EventBus
|
||||||
|
from app.core.sync.executor import ActionExecutor
|
||||||
from app.core.sync.service import SyncService
|
from app.core.sync.service import SyncService
|
||||||
|
|
||||||
|
|
||||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.database.db import Database
|
from app.core.database.db import Database
|
||||||
from app.core.sync.executor import ActionExecutor
|
|
||||||
from app.core.events.event_bus import EventBus
|
from app.core.events.event_bus import EventBus
|
||||||
|
from app.core.sync.executor import ActionExecutor
|
||||||
from app.core.sync.service import SyncService
|
from app.core.sync.service import SyncService
|
||||||
|
|
||||||
|
|
||||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -1,7 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src_dir = Path(__file__).resolve().parent / "src"
|
||||||
|
if str(src_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(src_dir))
|
||||||
|
|
||||||
from app.gui.main import main
|
from app.gui.main import main
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
Reference in new issue
Block a user