mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-04 04:53:58 +03:00
Compare commits
6 Commits
v2.0.0
...
9ec8974496
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ec8974496 | |||
| 410984bc09 | |||
| 49fedecd43 | |||
| 3291c0c88f | |||
| b0c531389e | |||
| b0eaa9d2eb |
@@ -123,14 +123,15 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||
$ws = "${{ github.workspace }}"
|
||||
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||
|
||||
- name: Build binary (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
|
||||
|
||||
- name: Stage package
|
||||
shell: bash
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ config/yt-playlist-config.json
|
||||
/*/tmp*
|
||||
*.code-workspace
|
||||
/bin/*
|
||||
/app/data
|
||||
/db/*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -8,28 +8,28 @@ A cross-platform tool for downloading and keeping in sync a local copy of entire
|
||||
Supports audio, video, or both download modes, music and videos are numbered as they are on your youtube playlist, playlist cleanup, and configurable parallel download options.
|
||||
Local-first YouTube playlist synchronization client.
|
||||
|
||||
## What’s Included
|
||||
## What's Included
|
||||
|
||||
- GUI (PySide6) playlist manager + sync runner
|
||||
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
|
||||
- Safe reordering via two-pass rename, recycle deletions
|
||||
- Async download queue with simple retry (yt-dlp Python API)
|
||||
- SQLite metadata; DB updates on rename/download/delete; `last_sync`
|
||||
- Optional event publishing for future GUI/logs
|
||||
- SQLite metadata (`last_sync`, download state)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- `ffmpeg` (needed for `audio` and `both` modes)
|
||||
- If you download a `-ffmpeg` release: no extra dependencies
|
||||
- If you download a non-ffmpeg release: install `ffmpeg` and ensure it's on PATH (needed for `audio` and `both` modes)
|
||||
|
||||
Quick start:
|
||||
## Download
|
||||
|
||||
Download the latest release from [releases](https://github.com/darkzoul5/YoutubePlaylistSyncThing/releases) page
|
||||
Download the latest release from this repo's Releases page and pick one:
|
||||
|
||||
- `ytpl-sync-windows-{version}-ffmpeg.zip` / `ytpl-sync-linux-{version}-ffmpeg.tar.gz` (ffmpeg bundled)
|
||||
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled)
|
||||
|
||||
## Configure
|
||||
|
||||
On first run, the app will auto-create a default `config/yt-playlist-config.json` (if missing).
|
||||
|
||||
Create/edit `config/yt-playlist-config.json`:
|
||||
Application uses a json config that canbe edited from UI or manually
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -42,33 +42,24 @@ Create/edit `config/yt-playlist-config.json`:
|
||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
||||
"download_mode": "video",
|
||||
"max_download_quality": "1080p",
|
||||
"save_path": "./downloads"
|
||||
"save_path": "./downloads",
|
||||
"name": "my favorite playlist"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- `ffmpeg_path`: `./bin/ffmpeg.exe` (Windows) or `./bin/ffmpeg` (Linux)
|
||||
- `download_mode`: `video`
|
||||
- `max_download_quality`: `1080p`
|
||||
- `save_path`: `./downloads`
|
||||
- `max_parallel_downloads`: `2`
|
||||
- `retry_max_retries`: `2`
|
||||
- `retry_delay_seconds`: `1.5`
|
||||
|
||||
`max_download_quality`:
|
||||
|
||||
- Limits yt-dlp download quality (e.g. `"2160p"`, `"1440p"`, `"1080p"`, `"720p"`, `"360p"`). This only affects the downloaded video format selection.
|
||||
- Use `"best"` (or `"auto"`) for no height cap (highest available muxed MP4).
|
||||
- Use `"best"` for no height cap (highest available).
|
||||
- If the requested max quality isn't available for a video, the best available quality is chosen.
|
||||
|
||||
`download_mode`:
|
||||
|
||||
- `video`: download playlist videos as muxed `.mp4` (no ffmpeg processing)
|
||||
- `audio`: download muxed `.mp4`, extract `.mp3`, delete the `.mp4`
|
||||
- `both`: download muxed `.mp4`, extract `.mp3`, keep both files
|
||||
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
|
||||
- `audio`: download video, extract `.mp3`, delete the video file
|
||||
- `both`: download video, extract `.mp3`, keep both files
|
||||
|
||||
Queue / retry:
|
||||
|
||||
@@ -78,32 +69,24 @@ Queue / retry:
|
||||
|
||||
## Run
|
||||
|
||||
- Compute-only:
|
||||
- Run `ytpl-sync.exe` (GUI).
|
||||
|
||||
```bash
|
||||
python -m app.cli
|
||||
```
|
||||
|
||||
- Apply actions:
|
||||
|
||||
```bash
|
||||
python -m app.cli --apply
|
||||
```
|
||||
|
||||
- Single playlist (0-based index):
|
||||
|
||||
```bash
|
||||
python -m app.cli --apply --playlist 0
|
||||
```
|
||||
## 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).
|
||||
- `minimize_to_tray`: minimize hides to tray.
|
||||
- `start_minimized_to_tray`: start hidden in tray.
|
||||
|
||||
## Data & Layout
|
||||
|
||||
- Database: `app/data/app.db`
|
||||
- Database: `db/app.db`
|
||||
- Outputs: `<save_path>/audio` and/or `<save_path>/video`
|
||||
- Recycle bin: `<save_path>/.recycle/{audio,video}`
|
||||
|
||||
## Roadmap (short)
|
||||
|
||||
- Scheduler (periodic sync), richer retries/logging
|
||||
- GUI (PySide6) wired to EventBus
|
||||
- Enhanced config validation
|
||||
- UX polish (settings, progress, error messages)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -3,12 +3,20 @@
|
||||
"max_parallel_downloads": 2,
|
||||
"retry_max_retries": 2,
|
||||
"retry_delay_seconds": 1.5,
|
||||
"ui": {
|
||||
"tray": {
|
||||
"close_to_tray": true,
|
||||
"minimize_to_tray": false,
|
||||
"start_minimized_to_tray": false
|
||||
}
|
||||
},
|
||||
"playlists": [
|
||||
{
|
||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
|
||||
"download_mode": "video",
|
||||
"max_download_quality": "1080p",
|
||||
"save_path": "./downloads"
|
||||
"save_path": "./downloads",
|
||||
"name": "my favorite playlist"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ from .core.utils.logging_setup import configure_logging
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="YouTube Playlist Sync — compute/apply actions")
|
||||
parser.add_argument("--apply", action="store_true", help="Apply actions (otherwise compute-only)")
|
||||
parser.add_argument("--db", type=Path, default=Path("app/data/app.db"), help="Path to SQLite database")
|
||||
parser.add_argument("--db", type=Path, default=Path("db/app.db"), help="Path to SQLite database")
|
||||
parser.add_argument("--playlist", type=int, default=None, help="Only run for a specific playlist index (0-based)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print detailed events (rename/recycle/start)")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging to console + app/data/app.log")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6 import QtGui, QtWidgets
|
||||
|
||||
|
||||
def _resource_base() -> Path:
|
||||
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
|
||||
base = getattr(sys, "_MEIPASS", None)
|
||||
if base:
|
||||
return Path(str(base))
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def load_app_icon() -> QtGui.QIcon:
|
||||
"""
|
||||
Best-effort app icon loader.
|
||||
|
||||
Looks for `assets/icon.png` in the current working directory (dev),
|
||||
or in the PyInstaller bundle root (packaged).
|
||||
"""
|
||||
candidates = [
|
||||
Path("assets/icon.png"),
|
||||
_resource_base() / "assets" / "icon.png",
|
||||
]
|
||||
for p in candidates:
|
||||
try:
|
||||
if p.exists():
|
||||
icon = QtGui.QIcon(str(p))
|
||||
if not icon.isNull():
|
||||
return icon
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to a platform theme icon (Linux) or a generic icon.
|
||||
try:
|
||||
themed = QtGui.QIcon.fromTheme("applications-multimedia")
|
||||
if not themed.isNull():
|
||||
return themed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
|
||||
|
||||
+120
-2
@@ -8,6 +8,8 @@ from PySide6 import QtCore, QtGui, QtWidgets
|
||||
from ..config.settings import Settings
|
||||
from ..core.events.event_bus import EventBus
|
||||
from .bus_bridge import BusBridge
|
||||
from .app_icon import load_app_icon
|
||||
from .config_store import load_config
|
||||
from .runner import SyncRequest, SyncRunner
|
||||
from .pages.playlists import PlaylistManagerPage
|
||||
from .pages.queue import QueuePage
|
||||
@@ -20,6 +22,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
super().__init__()
|
||||
self.setWindowTitle("ytpl-sync")
|
||||
self.resize(1100, 700)
|
||||
self.setWindowIcon(load_app_icon())
|
||||
|
||||
self._settings = Settings()
|
||||
self._bus = EventBus()
|
||||
@@ -29,6 +32,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self._runner: SyncRunner | None = None
|
||||
self._cancel_flag: threading.Event | None = None
|
||||
self._pause_flag: threading.Event | None = None
|
||||
self._tray: QtWidgets.QSystemTrayIcon | None = None
|
||||
self._tray_notified = False
|
||||
|
||||
# Sidebar navigation
|
||||
self._nav = QtWidgets.QListWidget()
|
||||
@@ -87,6 +92,115 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self._playlists_page.resume_requested.connect(self._resume_sync)
|
||||
|
||||
self._refresh_queue_labels()
|
||||
self._init_tray()
|
||||
|
||||
def _tray_config(self) -> dict:
|
||||
# Read from disk so toggles apply immediately (no restart required).
|
||||
try:
|
||||
cfg_path = getattr(self._settings, "path", None)
|
||||
if cfg_path is None:
|
||||
return {}
|
||||
raw = load_config(cfg_path).data
|
||||
ui = raw.get("ui")
|
||||
ui = ui if isinstance(ui, dict) else {}
|
||||
tray = ui.get("tray")
|
||||
tray = tray if isinstance(tray, dict) else {}
|
||||
return dict(tray)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _close_to_tray_enabled(self) -> bool:
|
||||
return bool(self._tray_config().get("close_to_tray", True))
|
||||
|
||||
def _minimize_to_tray_enabled(self) -> bool:
|
||||
return bool(self._tray_config().get("minimize_to_tray", False))
|
||||
|
||||
def _start_minimized_to_tray_enabled(self) -> bool:
|
||||
return bool(self._tray_config().get("start_minimized_to_tray", False))
|
||||
|
||||
def should_start_minimized_to_tray(self) -> bool:
|
||||
return self._tray is not None and self._start_minimized_to_tray_enabled()
|
||||
|
||||
def _init_tray(self) -> None:
|
||||
# Tray support is optional and platform-dependent (e.g., some Linux DEs).
|
||||
try:
|
||||
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
icon = load_app_icon()
|
||||
tray = QtWidgets.QSystemTrayIcon(icon, self)
|
||||
tray.setToolTip("ytpl-sync")
|
||||
|
||||
menu = QtWidgets.QMenu()
|
||||
act_toggle = menu.addAction("Show/Hide")
|
||||
act_quit = menu.addAction("Quit")
|
||||
tray.setContextMenu(menu)
|
||||
|
||||
act_toggle.triggered.connect(self._toggle_visible)
|
||||
act_quit.triggered.connect(self._quit_from_tray)
|
||||
tray.activated.connect(self._on_tray_activated)
|
||||
|
||||
tray.show()
|
||||
self._tray = tray
|
||||
|
||||
def _toggle_visible(self) -> None:
|
||||
if self.isVisible():
|
||||
self.hide()
|
||||
else:
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
|
||||
def _quit_from_tray(self) -> None:
|
||||
# Ensure the closeEvent doesn't just hide the window.
|
||||
self._tray = None
|
||||
QtWidgets.QApplication.quit()
|
||||
|
||||
def _on_tray_activated(self, reason: QtWidgets.QSystemTrayIcon.ActivationReason) -> None:
|
||||
if reason in (
|
||||
QtWidgets.QSystemTrayIcon.ActivationReason.Trigger,
|
||||
QtWidgets.QSystemTrayIcon.ActivationReason.DoubleClick,
|
||||
):
|
||||
self._toggle_visible()
|
||||
|
||||
def closeEvent(self, event: QtGui.QCloseEvent) -> None: # type: ignore[override]
|
||||
# If tray is active and configured, close-to-tray.
|
||||
if self._tray is not None and self._close_to_tray_enabled():
|
||||
event.ignore()
|
||||
self.hide()
|
||||
if not self._tray_notified:
|
||||
self._tray_notified = True
|
||||
try:
|
||||
self._tray.showMessage(
|
||||
"ytpl-sync",
|
||||
"Still running in the tray. Use the tray icon menu to quit.",
|
||||
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
|
||||
3000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if self._tray is not None and not self._close_to_tray_enabled():
|
||||
# Explicitly quit, because the app may be configured to keep running without windows.
|
||||
try:
|
||||
event.accept()
|
||||
except Exception:
|
||||
pass
|
||||
QtWidgets.QApplication.quit()
|
||||
return
|
||||
super().closeEvent(event)
|
||||
|
||||
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
|
||||
try:
|
||||
if event.type() == QtCore.QEvent.Type.WindowStateChange:
|
||||
if self._tray is not None and self._minimize_to_tray_enabled():
|
||||
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized):
|
||||
QtCore.QTimer.singleShot(0, self.hide)
|
||||
except Exception:
|
||||
pass
|
||||
super().changeEvent(event)
|
||||
|
||||
def _refresh_queue_labels(self) -> None:
|
||||
try:
|
||||
@@ -270,7 +384,8 @@ def main() -> int:
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
app.setApplicationName("ytpl-sync")
|
||||
app.setOrganizationName("ytpl-sync")
|
||||
app.setWindowIcon(QtGui.QIcon())
|
||||
app.setWindowIcon(load_app_icon())
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
# Avoid Qt warnings when a font with invalid point size is inherited from the environment.
|
||||
f = app.font()
|
||||
@@ -279,7 +394,10 @@ def main() -> int:
|
||||
app.setFont(f)
|
||||
|
||||
w = MainWindow()
|
||||
w.show()
|
||||
if w.should_start_minimized_to_tray():
|
||||
w.hide()
|
||||
else:
|
||||
w.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
|
||||
last_sync_by_id: dict[str, str] = {}
|
||||
try:
|
||||
db = Database(Path("app/data/app.db").resolve())
|
||||
db = Database(Path("db/app.db").resolve())
|
||||
for r in rows:
|
||||
pid = extract_playlist_id(r.url) or r.url
|
||||
ls = db.get_playlist_last_sync(pid)
|
||||
|
||||
@@ -49,6 +49,23 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
form_box.setLayout(form)
|
||||
layout.addWidget(form_box)
|
||||
|
||||
tray_form = QtWidgets.QFormLayout()
|
||||
self._close_to_tray = QtWidgets.QCheckBox()
|
||||
self._close_to_tray.setChecked(True)
|
||||
tray_form.addRow("close_to_tray", self._close_to_tray)
|
||||
|
||||
self._minimize_to_tray = QtWidgets.QCheckBox()
|
||||
self._minimize_to_tray.setChecked(False)
|
||||
tray_form.addRow("minimize_to_tray", self._minimize_to_tray)
|
||||
|
||||
self._start_minimized_to_tray = QtWidgets.QCheckBox()
|
||||
self._start_minimized_to_tray.setChecked(False)
|
||||
tray_form.addRow("start_minimized_to_tray", self._start_minimized_to_tray)
|
||||
|
||||
tray_box = QtWidgets.QGroupBox("Tray behavior")
|
||||
tray_box.setLayout(tray_form)
|
||||
layout.addWidget(tray_box)
|
||||
|
||||
btns = QtWidgets.QHBoxLayout()
|
||||
self._reload_btn = QtWidgets.QPushButton("Reload")
|
||||
self._reload_btn.clicked.connect(self.reload_from_config)
|
||||
@@ -75,6 +92,9 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
self._retry_max.valueChanged.connect(lambda _v: self._schedule_autosave())
|
||||
self._retry_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
|
||||
self._download_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
|
||||
self._close_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
|
||||
self._minimize_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
|
||||
self._start_minimized_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
|
||||
|
||||
def set_config_path(self, path: Path) -> None:
|
||||
self._config_path = path
|
||||
@@ -96,6 +116,14 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
self._retry_delay.setValue(float(self._config.get("retry_delay_seconds") or 1.5))
|
||||
self._download_delay.setValue(float(self._config.get("delay_between_downloads_seconds") or 0.0))
|
||||
|
||||
ui = self._config.get("ui")
|
||||
ui = ui if isinstance(ui, dict) else {}
|
||||
tray = ui.get("tray")
|
||||
tray = tray if isinstance(tray, dict) else {}
|
||||
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", True)))
|
||||
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
|
||||
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
|
||||
|
||||
self._status.setText(f"Loaded settings from {self._config_path}.")
|
||||
except Exception as exc:
|
||||
self._status.setText(f"Failed to load settings: {exc}")
|
||||
@@ -119,6 +147,17 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
data["retry_max_retries"] = int(self._retry_max.value())
|
||||
data["retry_delay_seconds"] = float(self._retry_delay.value())
|
||||
data["delay_between_downloads_seconds"] = float(self._download_delay.value())
|
||||
|
||||
ui = data.get("ui")
|
||||
ui = ui if isinstance(ui, dict) else {}
|
||||
tray = ui.get("tray")
|
||||
tray = tray if isinstance(tray, dict) else {}
|
||||
tray["close_to_tray"] = bool(self._close_to_tray.isChecked())
|
||||
tray["minimize_to_tray"] = bool(self._minimize_to_tray.isChecked())
|
||||
tray["start_minimized_to_tray"] = bool(self._start_minimized_to_tray.isChecked())
|
||||
ui["tray"] = tray
|
||||
data["ui"] = ui
|
||||
|
||||
save_config(self._config_path, data)
|
||||
self._status.setText(f"Saved settings to {self._config_path}.")
|
||||
except Exception as exc:
|
||||
|
||||
@@ -18,7 +18,7 @@ from ..core.events.event_bus import EventBus
|
||||
class SyncRequest:
|
||||
playlist_cfg: Dict[str, Any]
|
||||
apply: bool = True
|
||||
db_path: Path = Path("app/data/app.db")
|
||||
db_path: Path = Path("db/app.db")
|
||||
cancel_flag: threading.Event | None = None
|
||||
pause_flag: threading.Event | None = None
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ from .core.utils.deps import DependencyError
|
||||
|
||||
def bootstrap(db_path: Path | None = None) -> None:
|
||||
settings = Settings()
|
||||
db = Database((db_path or Path("app/data/app.db")).resolve())
|
||||
db = Database((db_path or Path("db/app.db")).resolve())
|
||||
service = SyncService(db)
|
||||
executor = ActionExecutor(db)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user