mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-03 04:23:59 +03:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfb55f28c6 | |||
| 48bcf2c9df | |||
| 5f6df549ab | |||
| d7f3b98be4 | |||
| 7afdb24302 | |||
| e8f350805b | |||
| df4c7d504b | |||
| ac5a98a09c | |||
| 811ff45dc9 | |||
| c658b9a90d | |||
| b06ab55f99 | |||
| de315d07e0 | |||
| 4dc7d95123 | |||
| 42ba6310a3 | |||
| 0a49676c72 | |||
| 8ec894fc1f |
@@ -107,6 +107,12 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write bundled version file
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
@@ -124,14 +130,14 @@ jobs:
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ws = "${{ github.workspace }}"
|
||||
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --add-data "$ws/version.txt;." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||
|
||||
- name: Build binary (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --add-data "${GITHUB_WORKSPACE}/version.txt:." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||
|
||||
- name: Stage package
|
||||
shell: bash
|
||||
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
name: update yt-dlp and open PR
|
||||
|
||||
name: update yt-dlp
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 10 * * *"
|
||||
@@ -77,7 +76,7 @@ jobs:
|
||||
|
||||
- name: Create or update pull request
|
||||
if: steps.detect.outputs.needs_update == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
branch: chore/refresh-yt-dlp
|
||||
commit-message: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
|
||||
@@ -11,7 +11,7 @@ Local-first YouTube playlist synchronization client.
|
||||
|
||||
## 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
|
||||
- Safe reordering via two-pass rename, recycle deletions
|
||||
- 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)
|
||||
|
||||
## Configure
|
||||
|
||||
Application uses a json config that canbe edited from UI or manually
|
||||
|
||||
```json
|
||||
@@ -73,7 +74,7 @@ Queue / retry:
|
||||
- Run `ytpl-sync.exe` (GUI).
|
||||
|
||||
## Tray
|
||||
|
||||
|
||||
- The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit.
|
||||
- Tray behavior settings (Settings page):
|
||||
- `close_to_tray`: close hides to tray (keeps running).
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"retry_delay_seconds": 1.5,
|
||||
"ui": {
|
||||
"tray": {
|
||||
"close_to_tray": true,
|
||||
"close_to_tray": false,
|
||||
"minimize_to_tray": false,
|
||||
"start_minimized_to_tray": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# MP3 Metadata Plan
|
||||
|
||||
## Subject Area
|
||||
|
||||
- Add MP3 tag writing for downloaded YouTube playlist items.
|
||||
- Scope is limited to `.mp3` outputs produced by `audio` mode and the MP3 side of `both` mode.
|
||||
- Metadata is sourced from YouTube/yt-dlp and embedded after audio extraction.
|
||||
|
||||
## Goal
|
||||
|
||||
- Write useful MP3 metadata for downloaded playlist items without affecting video-only downloads.
|
||||
- Keep the implementation reliable when optional fields are missing.
|
||||
- Preserve successful downloads even when metadata embedding partially fails.
|
||||
- Provide a setting to enable or disable MP3 metadata embedding.
|
||||
|
||||
## Required Metadata
|
||||
|
||||
- `title` ← video title
|
||||
- `artist` ← uploader, fallback to channel
|
||||
- `album` ← album name if present
|
||||
- `tracknumber` ← playlist index
|
||||
- `date` / `year` ← upload date
|
||||
- `comment` ← source URL
|
||||
- `genre` ← if available
|
||||
- `album_art` ← thumbnail
|
||||
|
||||
## Configuration Requirement
|
||||
|
||||
- Add a setting to turn MP3 metadata embedding on or off.
|
||||
- Default should be explicitly defined during implementation; recommended default is `enabled` for new configs.
|
||||
- The setting should only affect `.mp3` metadata writing and should not change download selection, extraction, or `.mp4` handling.
|
||||
|
||||
## Current Constraints
|
||||
|
||||
- The current playlist scan keeps only a minimal item shape: title, video id, and playlist index.
|
||||
- The scanner uses flat extraction, which is sufficient for diffing but not for full tag data.
|
||||
- MP3 extraction currently transcodes audio but does not write ID3 metadata.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
- Keep playlist diffing fast by retaining the current flat scan for remote playlist structure.
|
||||
- Fetch full metadata only for items that are actually going to be downloaded or repaired.
|
||||
- Write metadata only after MP3 extraction completes successfully.
|
||||
- Treat metadata embedding as a post-processing step that can fail softly without discarding the MP3.
|
||||
|
||||
## Work Breakdown
|
||||
|
||||
### 1. Extend the metadata model
|
||||
|
||||
- Add optional fields to `PlaylistItem` for:
|
||||
- uploader
|
||||
- channel
|
||||
- album
|
||||
- upload_date
|
||||
- genre
|
||||
- thumbnail_url
|
||||
- webpage_url
|
||||
- Keep `artist` as a derived value instead of storing a separate field.
|
||||
|
||||
### 2. Fetch full per-video metadata
|
||||
|
||||
- Introduce a metadata fetch step for each item selected for download.
|
||||
- Use yt-dlp per-video extraction to retrieve richer fields than the flat playlist entry provides.
|
||||
- Prefer canonical values from the video page payload for upload date, uploader/channel, album, genre, thumbnail, and source URL.
|
||||
|
||||
### 3. Carry metadata through the download pipeline
|
||||
|
||||
- Ensure the enriched `PlaylistItem` reaches the download job and post-processing stage.
|
||||
- Keep this propagation in-memory unless restart-safe metadata persistence becomes necessary later.
|
||||
- Avoid changing unrelated sync behavior for video-only items.
|
||||
- Carry the MP3 metadata enabled/disabled setting into the post-processing step.
|
||||
|
||||
### 4. Add an MP3 tag writer
|
||||
|
||||
- Add `mutagen` as the ID3 writing dependency.
|
||||
- Implement a focused tagging component that maps `PlaylistItem` metadata into ID3 frames.
|
||||
- Omit fields when the source value is missing instead of writing placeholders.
|
||||
|
||||
### 5. Map fields into ID3 tags
|
||||
|
||||
- `title` → video title
|
||||
- `artist` → uploader, fallback to channel
|
||||
- `album` → album if present
|
||||
- `tracknumber` → playlist index
|
||||
- `date/year` → parsed upload date
|
||||
- `comment` → canonical source URL
|
||||
- `genre` → genre if present
|
||||
|
||||
### 6. Embed album art
|
||||
|
||||
- Download the selected thumbnail for the video after the media download succeeds.
|
||||
- Attach thumbnail data as embedded cover art when the image type is supported.
|
||||
- Fail soft if thumbnail retrieval or embedding fails, and keep the MP3 intact.
|
||||
|
||||
### 7. Integrate into modes
|
||||
|
||||
- `audio` mode:
|
||||
- download source media
|
||||
- extract MP3
|
||||
- write MP3 tags only when the setting is enabled
|
||||
- delete temporary/source MP4 if configured
|
||||
- `both` mode:
|
||||
- download source media
|
||||
- extract MP3
|
||||
- write MP3 tags only when the setting is enabled
|
||||
- keep MP4 unchanged
|
||||
- `video` mode:
|
||||
- no MP3 tagging path
|
||||
|
||||
### 8. Add configuration surface
|
||||
|
||||
- Add the new setting to the config model and default config output.
|
||||
- Expose the setting in the GUI/settings surface if MP3 behavior is already user-configurable there.
|
||||
- Keep the naming explicit, for example `write_mp3_metadata` or `embed_mp3_metadata`.
|
||||
|
||||
## Error Handling Rules
|
||||
|
||||
- If download fails, no tagging runs.
|
||||
- If extraction fails, no tagging runs.
|
||||
- If metadata embedding is disabled, skip the tagging step entirely.
|
||||
- If tagging fails, mark the tag step as failed in logs/events but keep the MP3 file.
|
||||
- If thumbnail embedding fails, continue with text metadata only.
|
||||
- Missing `album` or `genre` is normal and should not be treated as an error.
|
||||
|
||||
## Testing Plan
|
||||
|
||||
- Unit test metadata mapping from yt-dlp info to the internal metadata model.
|
||||
- Unit test ID3 writing against a temporary MP3 fixture.
|
||||
- Unit test fallback behavior:
|
||||
- uploader missing, channel present
|
||||
- album missing
|
||||
- genre missing
|
||||
- thumbnail missing
|
||||
- Integration test the audio post-processing path with tagging mocked.
|
||||
- Integration test the both-mode MP3 path with tagging mocked.
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
- Document that MP3 tags are written only for `.mp3` outputs.
|
||||
- Document the new setting that enables or disables MP3 metadata embedding.
|
||||
- Document the field fallback rules, especially artist and album behavior.
|
||||
- Document that album art comes from the video thumbnail, not playlist artwork.
|
||||
- Document that some YouTube items will not expose album or genre information.
|
||||
|
||||
## Dependency Decision
|
||||
|
||||
- Recommended library: `mutagen`
|
||||
- Reason:
|
||||
- direct ID3 support
|
||||
- reliable field-level control
|
||||
- suitable for embedding cover art
|
||||
- avoids depending on ffmpeg metadata flags for all tag logic
|
||||
|
||||
## Delivery Order
|
||||
|
||||
- First: add config setting and defaults
|
||||
- Second: extend metadata model and add full metadata fetch
|
||||
- Third: add MP3 tag writer and field mapping
|
||||
- Fourth: add thumbnail embedding
|
||||
- Fifth: wire tagging into `audio` and `both`
|
||||
- Sixth: add tests and docs
|
||||
+2
-2
@@ -12,8 +12,8 @@ license = { file = "LICENSE" }
|
||||
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"yt-dlp>=2026.3.17",
|
||||
"PySide6",
|
||||
"yt-dlp>=2026.6.9",
|
||||
"PySide6_Essentials>=6.11.1",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _resource_base() -> Path:
|
||||
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
|
||||
base = getattr(sys, "_MEIPASS", None)
|
||||
if base:
|
||||
return Path(str(base))
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str | None:
|
||||
try:
|
||||
if path.exists():
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
return text or None
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
def get_app_version() -> str:
|
||||
"""
|
||||
Returns the packaged app version.
|
||||
|
||||
In release builds this reads from `version.txt` bundled into the EXE.
|
||||
"""
|
||||
candidates = [
|
||||
Path("version.txt"),
|
||||
_resource_base() / "version.txt",
|
||||
]
|
||||
for candidate in candidates:
|
||||
text = _read_text(candidate)
|
||||
if text:
|
||||
return text
|
||||
|
||||
return "dev"
|
||||
+122
-27
@@ -15,6 +15,7 @@ from .pages.playlists import PlaylistManagerPage
|
||||
from .pages.queue import QueuePage
|
||||
from .pages.logs import LogsPage
|
||||
from .pages.settings import SettingsPage
|
||||
from .pages.about import AboutPage
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow):
|
||||
@@ -38,29 +39,37 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
# Sidebar navigation
|
||||
self._nav = QtWidgets.QListWidget()
|
||||
self._nav.setObjectName("sidebar")
|
||||
self._nav.setFixedWidth(220)
|
||||
self._nav.setSpacing(2)
|
||||
self._nav.setHorizontalScrollBarPolicy(
|
||||
QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
self._nav.setVerticalScrollBarPolicy(
|
||||
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self._nav.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self._nav.model().rowsInserted.connect(self._update_sidebar_width)
|
||||
self._nav.model().dataChanged.connect(self._update_sidebar_width)
|
||||
self._nav.model().rowsRemoved.connect(self._update_sidebar_width)
|
||||
|
||||
self._stack = QtWidgets.QStackedWidget()
|
||||
self._playlists_page = PlaylistManagerPage(self._settings)
|
||||
self._queue_page = QueuePage()
|
||||
self._logs_page = LogsPage()
|
||||
self._settings_page = SettingsPage()
|
||||
self._about_page = AboutPage()
|
||||
|
||||
self._pages: list[QtWidgets.QWidget] = [
|
||||
self._playlists_page,
|
||||
self._queue_page,
|
||||
self._logs_page,
|
||||
self._settings_page,
|
||||
self._about_page,
|
||||
]
|
||||
for p in self._pages:
|
||||
self._stack.addWidget(p)
|
||||
|
||||
for label in ("Playlists", "Queue", "Logs", "Settings"):
|
||||
item = QtWidgets.QListWidgetItem(label)
|
||||
item.setSizeHint(QtCore.QSize(200, 36))
|
||||
self._nav.addItem(item)
|
||||
for label in ("Playlists", "Queue", "Logs", "Settings", "About"):
|
||||
self._add_sidebar_item(label)
|
||||
|
||||
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
|
||||
self._nav.setCurrentRow(0)
|
||||
@@ -93,6 +102,29 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
|
||||
self._refresh_queue_labels()
|
||||
self._init_tray()
|
||||
QtCore.QTimer.singleShot(0, self._update_sidebar_width)
|
||||
|
||||
def _add_sidebar_item(self, label: str) -> None:
|
||||
item = QtWidgets.QListWidgetItem(label)
|
||||
self._nav.addItem(item)
|
||||
self._update_sidebar_width()
|
||||
|
||||
def _update_sidebar_width(self, *_args: object) -> None:
|
||||
metrics = self._nav.fontMetrics()
|
||||
max_text_width = 0
|
||||
for row in range(self._nav.count()):
|
||||
item = self._nav.item(row)
|
||||
if item is None:
|
||||
continue
|
||||
max_text_width = max(max_text_width, metrics.horizontalAdvance(item.text()))
|
||||
|
||||
if max_text_width <= 0:
|
||||
return
|
||||
|
||||
frame = self._nav.frameWidth() * 2
|
||||
padding = 44
|
||||
target_width = max_text_width + frame + padding
|
||||
self._nav.setFixedWidth(max(120, min(220, target_width)))
|
||||
|
||||
def _tray_config(self) -> dict:
|
||||
# Read from disk so toggles apply immediately (no restart required).
|
||||
@@ -110,7 +142,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
return {}
|
||||
|
||||
def _close_to_tray_enabled(self) -> bool:
|
||||
return bool(self._tray_config().get("close_to_tray", True))
|
||||
return bool(self._tray_config().get("close_to_tray", False))
|
||||
|
||||
def _minimize_to_tray_enabled(self) -> bool:
|
||||
return bool(self._tray_config().get("minimize_to_tray", False))
|
||||
@@ -324,13 +356,24 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
def _apply_style(self) -> None:
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QMainWindow { background: #0f1115; color: #e6e6e6; }
|
||||
QWidget { font-size: 13px; }
|
||||
QMainWindow { background: #0f1218; color: #d7dce4; }
|
||||
QWidget { font-size: 13px; color: #d7dce4; }
|
||||
QWidget#playlistsPage,
|
||||
QWidget#queuePage,
|
||||
QWidget#logsPage,
|
||||
QWidget#settingsPage,
|
||||
QWidget#aboutPage {
|
||||
background: #0f1218;
|
||||
}
|
||||
QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; }
|
||||
QLabel#cardTitle { font-size: 15px; font-weight: 600; color: #eef2f8; }
|
||||
QLabel[muted="true"] { color: #9aa3b2; }
|
||||
QLabel[link="true"] { color: #6c8bff; }
|
||||
QLabel[link="true"]:hover { color: #8ea7ff; }
|
||||
|
||||
QListWidget#sidebar {
|
||||
background: #0b0d11;
|
||||
border-right: 1px solid #20242d;
|
||||
background: #0d1015;
|
||||
border-right: 1px solid #2a3140;
|
||||
padding: 8px;
|
||||
}
|
||||
QListWidget#sidebar::item {
|
||||
@@ -339,42 +382,94 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
padding: 8px 10px;
|
||||
}
|
||||
QListWidget#sidebar::item:selected {
|
||||
background: #1e2633;
|
||||
background: #21304a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QTableWidget {
|
||||
background: #0f1115;
|
||||
gridline-color: #20242d;
|
||||
border: 1px solid #20242d;
|
||||
background: #171b22;
|
||||
gridline-color: #2a3140;
|
||||
border: 1px solid #2a3140;
|
||||
}
|
||||
QTableWidget::item {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
QPlainTextEdit {
|
||||
background: #11151c;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 10px;
|
||||
color: #d7dce4;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
background: #0f1218;
|
||||
width: 12px;
|
||||
margin: 0px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #34465f;
|
||||
min-height: 24px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #456183;
|
||||
}
|
||||
QScrollBar::add-line:vertical,
|
||||
QScrollBar::sub-line:vertical,
|
||||
QScrollBar::add-page:vertical,
|
||||
QScrollBar::sub-page:vertical {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
QGroupBox {
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 12px;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
background: #171b22;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 12px;
|
||||
padding: 0 6px;
|
||||
color: #e2e7ef;
|
||||
background: #171b22;
|
||||
}
|
||||
QFrame#aboutCard {
|
||||
background: #171b22;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 14px;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #0b0d11;
|
||||
color: #cfd3da;
|
||||
border: 1px solid #20242d;
|
||||
background: #171b22;
|
||||
color: #d7dce4;
|
||||
border: 1px solid #2a3140;
|
||||
padding: 6px;
|
||||
}
|
||||
QPushButton {
|
||||
background: #1e2633;
|
||||
border: 1px solid #2a3140;
|
||||
background: #1e2631;
|
||||
border: 1px solid #31405a;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
color: #e6e6e6;
|
||||
color: #d7dce4;
|
||||
}
|
||||
QPushButton:hover { background: #243044; }
|
||||
QPushButton:hover { background: #26344a; }
|
||||
QPushButton:pressed { background: #1a2433; }
|
||||
|
||||
QFrame#playlistCard {
|
||||
background: #0b0d11;
|
||||
border: 1px solid #20242d;
|
||||
border-radius: 10px;
|
||||
background: #171b22;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
QLineEdit, QComboBox {
|
||||
background: #0f1115;
|
||||
border: 1px solid #20242d;
|
||||
background: #11151c;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
color: #e6e6e6;
|
||||
color: #d7dce4;
|
||||
}
|
||||
QLineEdit:focus, QComboBox:focus {
|
||||
border: 1px solid #6c8bff;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from ...core.utils.version import get_app_version
|
||||
|
||||
|
||||
class AboutPage(QtWidgets.QWidget):
|
||||
REPO_URL = "https://github.com/darkzoul5/YoutubePlaylistSync"
|
||||
ISSUES_URL = f"{REPO_URL}/issues"
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("aboutPage")
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(14)
|
||||
|
||||
title = QtWidgets.QLabel("About")
|
||||
title.setObjectName("pageTitle")
|
||||
layout.addWidget(title)
|
||||
|
||||
for card in (
|
||||
self._hero_card(),
|
||||
self._project_card(),
|
||||
self._suggestions_card(),
|
||||
):
|
||||
layout.addWidget(card)
|
||||
layout.addStretch(1)
|
||||
|
||||
def _card(self, title: str) -> tuple[QtWidgets.QFrame, QtWidgets.QVBoxLayout]:
|
||||
card = QtWidgets.QFrame()
|
||||
card.setObjectName("aboutCard")
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(card)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
layout.addWidget(self._card_title(title))
|
||||
return card, layout
|
||||
|
||||
def _card_title(self, text: str) -> QtWidgets.QLabel:
|
||||
label = QtWidgets.QLabel(text)
|
||||
label.setObjectName("cardTitle")
|
||||
return label
|
||||
|
||||
def _muted_label(self, text: str) -> QtWidgets.QLabel:
|
||||
label = QtWidgets.QLabel(text)
|
||||
label.setWordWrap(True)
|
||||
label.setProperty("muted", True)
|
||||
return label
|
||||
|
||||
def _link_button(self, text: str, url: str) -> QtWidgets.QPushButton:
|
||||
button = QtWidgets.QPushButton(text)
|
||||
button.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
button.clicked.connect(lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl(url)))
|
||||
return button
|
||||
|
||||
def _action_row(self, text: str, url: str) -> QtWidgets.QWidget:
|
||||
row = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
layout.addWidget(self._link_button(text, url))
|
||||
layout.addStretch(1)
|
||||
return row
|
||||
|
||||
def _hero_card(self) -> QtWidgets.QFrame:
|
||||
card, layout = self._card("About this project")
|
||||
layout.insertWidget(
|
||||
1,
|
||||
self._muted_label(
|
||||
"ytpl-sync is a desktop app for keeping local copies of YouTube playlists in sync."
|
||||
),
|
||||
)
|
||||
layout.insertWidget(2, self._muted_label("This is a student project."))
|
||||
return card
|
||||
|
||||
def _project_card(self) -> QtWidgets.QFrame:
|
||||
card, layout = self._card("Project")
|
||||
|
||||
form = QtWidgets.QFormLayout()
|
||||
form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
|
||||
form.setFormAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignTop | QtCore.Qt.AlignmentFlag.AlignLeft
|
||||
)
|
||||
form.setHorizontalSpacing(14)
|
||||
form.setVerticalSpacing(10)
|
||||
|
||||
version_text = get_app_version()
|
||||
version = f"v{version_text}" if version_text != "dev" else version_text
|
||||
rows = [
|
||||
("Author", self._muted_label("Dark_Zoul")),
|
||||
("Version", self._muted_label(version)),
|
||||
("Repository", self._action_row("Open", self.REPO_URL)),
|
||||
("Issues", self._action_row("Open", self.ISSUES_URL)),
|
||||
]
|
||||
for label, widget in rows:
|
||||
form.addRow(label, widget)
|
||||
|
||||
layout.addLayout(form)
|
||||
return card
|
||||
|
||||
def _suggestions_card(self) -> QtWidgets.QFrame:
|
||||
card, layout = self._card("Suggestions")
|
||||
layout.addWidget(
|
||||
self._muted_label(
|
||||
"• Keep the app updated regularly so that YouTube extraction stays reliable."
|
||||
)
|
||||
)
|
||||
layout.addStretch(1)
|
||||
return card
|
||||
@@ -10,6 +10,7 @@ from ..smooth_scroll import enable_smooth_scrolling
|
||||
class LogsPage(QtWidgets.QWidget):
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("logsPage")
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
title = QtWidgets.QLabel("Logs")
|
||||
title.setObjectName("pageTitle")
|
||||
|
||||
@@ -36,6 +36,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("playlistsPage")
|
||||
self._settings = settings
|
||||
self._config_path = getattr(settings, "path", None)
|
||||
self._config: dict[str, Any] = {}
|
||||
@@ -50,6 +51,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
header.setObjectName("pageTitle")
|
||||
|
||||
self._list = QtWidgets.QListWidget()
|
||||
self._list.setObjectName("playlistList")
|
||||
# Selection-based UI is intentionally disabled; actions happen per-card.
|
||||
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
|
||||
self._list.setSpacing(8)
|
||||
@@ -57,6 +59,20 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
self._list.setWordWrap(True)
|
||||
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
enable_smooth_scrolling(self._list)
|
||||
self._list.setStyleSheet(
|
||||
"""
|
||||
QListWidget#playlistList {
|
||||
background: #0f1218;
|
||||
border: none;
|
||||
}
|
||||
QListWidget#playlistList::viewport {
|
||||
background: #0f1218;
|
||||
}
|
||||
QListWidget#playlistList::item {
|
||||
background: transparent;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
self._add_btn = QtWidgets.QPushButton("Add")
|
||||
self._add_btn.clicked.connect(self._add_playlist)
|
||||
@@ -410,6 +426,16 @@ class _PlaylistCard(QtWidgets.QFrame):
|
||||
super().__init__(parent)
|
||||
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
|
||||
self.setObjectName("playlistCard")
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QFrame#playlistCard {
|
||||
background: #171b22;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 12px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self._index = index
|
||||
self._active = False
|
||||
self._paused = False
|
||||
|
||||
@@ -10,6 +10,7 @@ class QueuePage(QtWidgets.QWidget):
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("queuePage")
|
||||
# Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves.
|
||||
self._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
|
||||
self._pending_by_key: dict[tuple[str, str], dict] = {}
|
||||
|
||||
@@ -11,6 +11,7 @@ from ..config_store import load_config, save_config
|
||||
class SettingsPage(QtWidgets.QWidget):
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("settingsPage")
|
||||
self._config_path: Path | None = None
|
||||
self._config: dict[str, Any] = {}
|
||||
|
||||
@@ -51,7 +52,7 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
|
||||
tray_form = QtWidgets.QFormLayout()
|
||||
self._close_to_tray = QtWidgets.QCheckBox()
|
||||
self._close_to_tray.setChecked(True)
|
||||
self._close_to_tray.setChecked(False)
|
||||
tray_form.addRow("close_to_tray", self._close_to_tray)
|
||||
|
||||
self._minimize_to_tray = QtWidgets.QCheckBox()
|
||||
@@ -120,7 +121,7 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
ui = ui if isinstance(ui, dict) else {}
|
||||
tray = ui.get("tray")
|
||||
tray = tray if isinstance(tray, dict) else {}
|
||||
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", True)))
|
||||
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", False)))
|
||||
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
|
||||
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user