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

8 Commits

13 changed files with 337 additions and 117 deletions
+11 -1
View File
@@ -2,7 +2,17 @@ name: Lint Python code
on: on:
push: push:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request: pull_request:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
jobs: jobs:
lint: lint:
@@ -15,4 +25,4 @@ jobs:
run: pip install ruff run: pip install ruff
- name: Run linter - name: Run linter
run: ruff check . run: ruff check .
+6 -12
View File
@@ -5,21 +5,15 @@ on:
push: push:
branches: branches:
- main - main
paths: paths-ignore:
- "src/**" - "assets/**"
- "tests/**" - "README.md"
- "pyproject.toml"
- "pytest.ini"
- "ytpl-sync-entry.py"
pull_request: pull_request:
branches: branches:
- main - main
paths: paths-ignore:
- "src/**" - "assets/**"
- "tests/**" - "README.md"
- "pyproject.toml"
- "pytest.ini"
- "ytpl-sync-entry.py"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
+3
View File
@@ -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 }}"
+3 -2
View File
@@ -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
@@ -73,7 +74,7 @@ Queue / retry:
- Run `ytpl-sync.exe` (GUI). - Run `ytpl-sync.exe` (GUI).
## Tray ## Tray
- The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit. - The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit.
- Tray behavior settings (Settings page): - Tray behavior settings (Settings page):
- `close_to_tray`: close hides to tray (keeps running). - `close_to_tray`: close hides to tray (keeps running).
+1 -1
View File
@@ -5,7 +5,7 @@
"retry_delay_seconds": 1.5, "retry_delay_seconds": 1.5,
"ui": { "ui": {
"tray": { "tray": {
"close_to_tray": true, "close_to_tray": false,
"minimize_to_tray": false, "minimize_to_tray": false,
"start_minimized_to_tray": false "start_minimized_to_tray": false
} }
+161
View File
@@ -0,0 +1,161 @@
# MP3 Metadata Plan
## Subject Area
- Add MP3 tag writing for downloaded YouTube playlist items.
- Scope is limited to `.mp3` outputs produced by `audio` mode and the MP3 side of `both` mode.
- Metadata is sourced from YouTube/yt-dlp and embedded after audio extraction.
## Goal
- Write useful MP3 metadata for downloaded playlist items without affecting video-only downloads.
- Keep the implementation reliable when optional fields are missing.
- Preserve successful downloads even when metadata embedding partially fails.
- Provide a 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
+2 -2
View File
@@ -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.6.9",
"PySide6", "PySide6_Essentials>=6.11.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
test = [ test = [
+73 -37
View File
@@ -142,7 +142,7 @@ class MainWindow(QtWidgets.QMainWindow):
return {} return {}
def _close_to_tray_enabled(self) -> bool: def _close_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("close_to_tray", True)) return bool(self._tray_config().get("close_to_tray", False))
def _minimize_to_tray_enabled(self) -> bool: def _minimize_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("minimize_to_tray", False)) return bool(self._tray_config().get("minimize_to_tray", False))
@@ -356,16 +356,24 @@ class MainWindow(QtWidgets.QMainWindow):
def _apply_style(self) -> None: def _apply_style(self) -> None:
self.setStyleSheet( self.setStyleSheet(
""" """
QMainWindow { background: #0f1115; color: #e6e6e6; } QMainWindow { background: #0f1218; color: #d7dce4; }
QWidget { font-size: 13px; color: #e6e6e6; } QWidget { font-size: 13px; color: #d7dce4; }
QWidget#playlistsPage,
QWidget#queuePage,
QWidget#logsPage,
QWidget#settingsPage,
QWidget#aboutPage {
background: #0f1218;
}
QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; } QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; }
QLabel[muted="true"] { color: #aeb6c2; } QLabel#cardTitle { font-size: 15px; font-weight: 600; color: #eef2f8; }
QLabel[link="true"] { color: #8fb8ff; } QLabel[muted="true"] { color: #9aa3b2; }
QLabel[link="true"]:hover { color: #b8d2ff; } QLabel[link="true"] { color: #6c8bff; }
QLabel[link="true"]:hover { color: #8ea7ff; }
QListWidget#sidebar { QListWidget#sidebar {
background: #0b0d11; background: #0d1015;
border-right: 1px solid #20242d; border-right: 1px solid #2a3140;
padding: 8px; padding: 8px;
} }
QListWidget#sidebar::item { QListWidget#sidebar::item {
@@ -374,66 +382,94 @@ class MainWindow(QtWidgets.QMainWindow):
padding: 8px 10px; padding: 8px 10px;
} }
QListWidget#sidebar::item:selected { QListWidget#sidebar::item:selected {
background: #1e2633; background: #21304a;
color: #ffffff; color: #ffffff;
} }
QTableWidget { QTableWidget {
background: #0f1115; background: #171b22;
gridline-color: #20242d; gridline-color: #2a3140;
border: 1px solid #20242d; border: 1px solid #2a3140;
}
QTableWidget::item {
padding: 6px 8px;
}
QPlainTextEdit {
background: #11151c;
border: 1px solid #2a3140;
border-radius: 10px;
color: #d7dce4;
}
QScrollBar:vertical {
background: #0f1218;
width: 12px;
margin: 0px;
}
QScrollBar::handle:vertical {
background: #34465f;
min-height: 24px;
border-radius: 6px;
}
QScrollBar::handle:vertical:hover {
background: #456183;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical,
QScrollBar::add-page:vertical,
QScrollBar::sub-page:vertical {
background: transparent;
border: none;
} }
QGroupBox { QGroupBox {
border: 1px solid #20242d; border: 1px solid #2a3140;
border-radius: 10px; border-radius: 12px;
margin-top: 14px; margin-top: 14px;
padding: 12px; padding: 12px;
background: #0b0d11; background: #171b22;
} }
QGroupBox::title { QGroupBox::title {
subcontrol-origin: margin; subcontrol-origin: margin;
left: 12px; left: 12px;
padding: 0 6px; padding: 0 6px;
color: #d5dae3; color: #e2e7ef;
background: #0b0d11; background: #171b22;
} }
QFrame#aboutCard { QFrame#aboutCard {
background: #0b0d11; background: #171b22;
border: 1px solid #20242d; border: 1px solid #2a3140;
border-radius: 14px; border-radius: 14px;
} }
QLabel#cardTitle {
font-size: 15px;
font-weight: 600;
color: #f2f4f8;
}
QHeaderView::section { QHeaderView::section {
background: #0b0d11; background: #171b22;
color: #cfd3da; color: #d7dce4;
border: 1px solid #20242d; border: 1px solid #2a3140;
padding: 6px; padding: 6px;
} }
QPushButton { QPushButton {
background: #1e2633; background: #1e2631;
border: 1px solid #2a3140; border: 1px solid #31405a;
padding: 6px 10px; padding: 6px 10px;
border-radius: 8px; border-radius: 8px;
color: #e6e6e6; color: #d7dce4;
} }
QPushButton:hover { background: #243044; } QPushButton:hover { background: #26344a; }
QPushButton:pressed { background: #1a2433; }
QFrame#playlistCard { QFrame#playlistCard {
background: #0b0d11; background: #171b22;
border: 1px solid #20242d; border: 1px solid #2a3140;
border-radius: 10px; border-radius: 12px;
padding: 10px; padding: 10px;
} }
QLineEdit, QComboBox { QLineEdit, QComboBox {
background: #0f1115; background: #11151c;
border: 1px solid #20242d; border: 1px solid #2a3140;
border-radius: 8px; border-radius: 8px;
padding: 6px 8px; padding: 6px 8px;
color: #e6e6e6; color: #d7dce4;
}
QLineEdit:focus, QComboBox:focus {
border: 1px solid #6c8bff;
} }
""" """
) )
+46 -60
View File
@@ -6,6 +6,9 @@ from ...core.utils.version import get_app_version
class AboutPage(QtWidgets.QWidget): 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: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("aboutPage") self.setObjectName("aboutPage")
@@ -18,19 +21,23 @@ class AboutPage(QtWidgets.QWidget):
title.setObjectName("pageTitle") title.setObjectName("pageTitle")
layout.addWidget(title) layout.addWidget(title)
layout.addWidget(self._hero_card()) for card in (
layout.addWidget(self._project_card()) self._hero_card(),
layout.addWidget(self._suggestions_card()) self._project_card(),
self._suggestions_card(),
):
layout.addWidget(card)
layout.addStretch(1) layout.addStretch(1)
def _card(self) -> tuple[QtWidgets.QFrame, QtWidgets.QVBoxLayout]: def _card(self, title: str) -> tuple[QtWidgets.QFrame, QtWidgets.QVBoxLayout]:
card = QtWidgets.QFrame() card = QtWidgets.QFrame()
card.setObjectName("aboutCard") card.setObjectName("aboutCard")
card_layout = QtWidgets.QVBoxLayout(card) layout = QtWidgets.QVBoxLayout(card)
card_layout.setContentsMargins(16, 16, 16, 16) layout.setContentsMargins(16, 16, 16, 16)
card_layout.setSpacing(10) layout.setSpacing(10)
return card, card_layout layout.addWidget(self._card_title(title))
return card, layout
def _card_title(self, text: str) -> QtWidgets.QLabel: def _card_title(self, text: str) -> QtWidgets.QLabel:
label = QtWidgets.QLabel(text) label = QtWidgets.QLabel(text)
@@ -46,29 +53,31 @@ class AboutPage(QtWidgets.QWidget):
def _link_button(self, text: str, url: str) -> QtWidgets.QPushButton: def _link_button(self, text: str, url: str) -> QtWidgets.QPushButton:
button = QtWidgets.QPushButton(text) button = QtWidgets.QPushButton(text)
button.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) button.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
button.clicked.connect( button.clicked.connect(lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl(url)))
lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
)
return button 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: def _hero_card(self) -> QtWidgets.QFrame:
card, layout = self._card() card, layout = self._card("About this project")
layout.addWidget(self._card_title("About this project")) layout.insertWidget(
layout.addWidget( 1,
self._muted_label( self._muted_label(
"ytpl-sync is a desktop app for keeping local copies of YouTube playlists in sync." "ytpl-sync is a desktop app for keeping local copies of YouTube playlists in sync."
) ),
)
layout.addWidget(
self._muted_label(
"This is a student project."
)
) )
layout.insertWidget(2, self._muted_label("This is a student project."))
return card return card
def _project_card(self) -> QtWidgets.QFrame: def _project_card(self) -> QtWidgets.QFrame:
card, layout = self._card() card, layout = self._card("Project")
layout.addWidget(self._card_title("Project"))
form = QtWidgets.QFormLayout() form = QtWidgets.QFormLayout()
form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
@@ -78,49 +87,26 @@ class AboutPage(QtWidgets.QWidget):
form.setHorizontalSpacing(14) form.setHorizontalSpacing(14)
form.setVerticalSpacing(10) form.setVerticalSpacing(10)
author = self._muted_label("Dark_Zoul")
form.addRow("Author", author)
version_text = get_app_version() version_text = get_app_version()
version = self._muted_label(f"v{version_text}" if version_text != "unknown" else version_text) version = f"v{version_text}" if version_text != "dev" else version_text
form.addRow("Version", version) rows = [
("Author", self._muted_label("Dark_Zoul")),
repo_row = QtWidgets.QHBoxLayout() ("Version", self._muted_label(version)),
repo_row.setContentsMargins(0, 0, 0, 0) ("Repository", self._action_row("Open", self.REPO_URL)),
repo_row.setSpacing(10) ("Issues", self._action_row("Open", self.ISSUES_URL)),
repo_row.addWidget( ]
self._link_button( for label, widget in rows:
"Open", form.addRow(label, widget)
"https://github.com/darkzoul5/YoutubePlaylistSync",
)
)
repo_row.addStretch(1)
form.addRow("Repository", repo_row)
issue_row = QtWidgets.QHBoxLayout()
issue_row.setContentsMargins(0, 0, 0, 0)
issue_row.setSpacing(10)
issue_row.addWidget(
self._link_button(
"Open",
"https://github.com/darkzoul5/YoutubePlaylistSync/issues",
)
)
issue_row.addStretch(1)
form.addRow("Issues", issue_row)
layout.addLayout(form) layout.addLayout(form)
return card return card
def _suggestions_card(self) -> QtWidgets.QFrame: def _suggestions_card(self) -> QtWidgets.QFrame:
card, layout = self._card() card, layout = self._card("Suggestions")
layout.addWidget(self._card_title("Suggestions")) layout.addWidget(
self._muted_label(
suggestions = [ "• Keep the app updated regularly so that YouTube extraction stays reliable."
"Keep the app updated regularly so that YouTube extraction stays reliable." )
] )
for text in suggestions:
layout.addWidget(self._muted_label(f"{text}"))
layout.addStretch(1) layout.addStretch(1)
return card return card
+1
View File
@@ -10,6 +10,7 @@ from ..smooth_scroll import enable_smooth_scrolling
class LogsPage(QtWidgets.QWidget): class LogsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("logsPage")
layout = QtWidgets.QVBoxLayout(self) layout = QtWidgets.QVBoxLayout(self)
title = QtWidgets.QLabel("Logs") title = QtWidgets.QLabel("Logs")
title.setObjectName("pageTitle") title.setObjectName("pageTitle")
+26
View File
@@ -36,6 +36,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
parent: QtWidgets.QWidget | None = None, parent: QtWidgets.QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("playlistsPage")
self._settings = settings self._settings = settings
self._config_path = getattr(settings, "path", None) self._config_path = getattr(settings, "path", None)
self._config: dict[str, Any] = {} self._config: dict[str, Any] = {}
@@ -50,6 +51,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
header.setObjectName("pageTitle") header.setObjectName("pageTitle")
self._list = QtWidgets.QListWidget() self._list = QtWidgets.QListWidget()
self._list.setObjectName("playlistList")
# Selection-based UI is intentionally disabled; actions happen per-card. # Selection-based UI is intentionally disabled; actions happen per-card.
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection) self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
self._list.setSpacing(8) self._list.setSpacing(8)
@@ -57,6 +59,20 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._list.setWordWrap(True) self._list.setWordWrap(True)
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
enable_smooth_scrolling(self._list) enable_smooth_scrolling(self._list)
self._list.setStyleSheet(
"""
QListWidget#playlistList {
background: #0f1218;
border: none;
}
QListWidget#playlistList::viewport {
background: #0f1218;
}
QListWidget#playlistList::item {
background: transparent;
}
"""
)
self._add_btn = QtWidgets.QPushButton("Add") self._add_btn = QtWidgets.QPushButton("Add")
self._add_btn.clicked.connect(self._add_playlist) self._add_btn.clicked.connect(self._add_playlist)
@@ -410,6 +426,16 @@ class _PlaylistCard(QtWidgets.QFrame):
super().__init__(parent) super().__init__(parent)
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.setObjectName("playlistCard") self.setObjectName("playlistCard")
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(
"""
QFrame#playlistCard {
background: #171b22;
border: 1px solid #2a3140;
border-radius: 12px;
}
"""
)
self._index = index self._index = index
self._active = False self._active = False
self._paused = False self._paused = False
+1
View File
@@ -10,6 +10,7 @@ class QueuePage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("queuePage")
# Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves. # Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves.
self._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {} self._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
self._pending_by_key: dict[tuple[str, str], dict] = {} self._pending_by_key: dict[tuple[str, str], dict] = {}
+3 -2
View File
@@ -11,6 +11,7 @@ from ..config_store import load_config, save_config
class SettingsPage(QtWidgets.QWidget): class SettingsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("settingsPage")
self._config_path: Path | None = None self._config_path: Path | None = None
self._config: dict[str, Any] = {} self._config: dict[str, Any] = {}
@@ -51,7 +52,7 @@ class SettingsPage(QtWidgets.QWidget):
tray_form = QtWidgets.QFormLayout() tray_form = QtWidgets.QFormLayout()
self._close_to_tray = QtWidgets.QCheckBox() self._close_to_tray = QtWidgets.QCheckBox()
self._close_to_tray.setChecked(True) self._close_to_tray.setChecked(False)
tray_form.addRow("close_to_tray", self._close_to_tray) tray_form.addRow("close_to_tray", self._close_to_tray)
self._minimize_to_tray = QtWidgets.QCheckBox() self._minimize_to_tray = QtWidgets.QCheckBox()
@@ -120,7 +121,7 @@ class SettingsPage(QtWidgets.QWidget):
ui = ui if isinstance(ui, dict) else {} ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray") tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {} 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._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False))) self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))