diff --git a/ROADMAP.md b/ROADMAP.md index b24cf7e..f9f311c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -235,11 +235,11 @@ - [x] Fix edit not shown in production - [x] is it fine that supervisord from root starts nginx not appuser - [ ] imba feature: on startup we check music folder and for files there and setting the same user rights and group - backend + production dockerfiles -- [ ] can we show download progress somehow? -- [ ] after yt-dlp we have to fire modal with saving - there is "awaiting_review" status but does not work now +- [ ] critical mobile bug: on the end of track it switches to next but doesnt play automatically until open webpage. If i try to play from modal - does nothing first and when disappears - [ ] log what takes long on e2e testing - [ ] redis pub-sub for events - [ ] On Edit - if im not mistaken we do not check if file with same name exists, we should show UI warning and prevent save on such cases! that is server check +- [ ] use custom play icon on timeline when shuffle because its ugly on w11 - [ ] on close tab did not restore track - bug. Maybe we have to reimplement. Maybe we have to save that in local storage and send once in a while. UDP - do not need to wait 200. - [ ] Player footer desktop - on change windows calculate div for player controls - this will allow to have full size for artist-title - [ ] nginx not from root but from appuser - this will require some fixes because container won't run in that moment @@ -259,13 +259,13 @@ - [ ] has to be smaller by default and on hover it has to be bigger in size like now - [ ] styling for playing music - make it less colored but on hover make blue colored styling for slider - [ ] ~~Revert functionality UI~~ -- [ ] Remove non-docker development - not sure if thats needed - actually needed because AI doesnt understand what env im working in currently. Less commands is better +- [x] Remove non-docker development - not sure if thats needed - actually needed because AI doesnt understand what env im working in currently. Less commands is better - [ ] Get rid of SQLModel, only sqlalchemy. Remove all warnings disabling. remove all # noqa: F401 - actually think to move everything in redis so there will be no sql db. But - think of relationships such model. Redis might have relationships or something.. I mean we can give up some consistency.. - [ ] Sort tracks by different fields and ways - [x] Continue refactoring effects - [ ] fast search - has to be server side to look vk/yt - and download in future! -- [ ] Download track functionality? +- [ ] Download track functionality? only in edit window I think? - [ ] docker-compose - i think we dont need separated volumes for cover/db, might be single - [ ] production image nginx better logging. Logging across app (!!!) - Think about this more - [ ] Hotkeys for player controls @@ -283,6 +283,8 @@ - [ ] yt-dlp from yt (list domains) - [ ] yt-dlp from other sites? - [ ] functionality to automatically update yt-dlp? on startup? +- [ ] can we show download progress somehow? +- [ ] after yt-dlp we have to fire modal with saving - there is "awaiting_review" status but does not work now ## Phase vk / other services ? @@ -305,3 +307,4 @@ - [ ] Every minute check if folder still read-only or writeable - this might be one of the little things nobody notices - [ ] The part of adding a group/user/etc has to go on executing script - we read music folder and get user rights +- [ ] Autoplay slider on right sidebar diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 506db05..5711242 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "vulture>=2.14", "bandit>=1.8.5", "python-multipart>=0.0.20", - "yt-dlp>=2025.08.22", ] [build-system] diff --git a/backend/scripts/update_ytdlp.py b/backend/scripts/update_ytdlp.py new file mode 100755 index 0000000..210cfba --- /dev/null +++ b/backend/scripts/update_ytdlp.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Script to update/install yt-dlp binary and yt-dlp-proxy. +This script handles the installation and updating of both yt-dlp and yt-dlp-proxy. +""" + +import asyncio +import logging +import subprocess +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class YtDlpUpdater: + def __init__(self): + pass + + def _run_command(self, cmd: list[str], check: bool = True, timeout: int = 300, cwd: str = None) -> subprocess.CompletedProcess: + """Run a command with proper error handling.""" + logger.info(f"Running command: {' '.join(cmd)}") + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=check, + timeout=timeout, + cwd=cwd + ) + if result.stdout: + logger.debug(f"Command stdout: {result.stdout}") + if result.stderr: + logger.debug(f"Command stderr: {result.stderr}") + return result + except subprocess.TimeoutExpired as e: + logger.error(f"Command timed out after {timeout}s: {' '.join(cmd)}") + raise + except subprocess.CalledProcessError as e: + logger.error(f"Command failed with exit code {e.returncode}: {' '.join(cmd)}") + logger.error(f"stderr: {e.stderr}") + raise + + def check_yt_dlp_available(self) -> bool: + """Check if yt-dlp is available.""" + logger.info("Checking yt-dlp availability...") + + try: + result = self._run_command([sys.executable, "-m", "yt_dlp", "--version"]) + logger.info(f"yt-dlp version: {result.stdout.strip()}") + return True + except Exception as e: + logger.error(f"yt-dlp not available: {e}") + return False + + def install_yt_dlp_proxy(self) -> bool: + """Install or update yt-dlp-proxy from GitHub.""" + logger.info("Installing/updating yt-dlp-proxy...") + + import tempfile + import shutil + + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + repo_path = temp_path / "yt-dlp-proxy" + + # Clone the repository + self._run_command([ + "git", "clone", "https://github.com/Petrprogs/yt-dlp-proxy.git", + str(repo_path) + ]) + + # Install dependencies + self._run_command([ + "uv", "pip", "install", "-r", "requirements.txt" + ], cwd=str(repo_path)) + + # Create permanent installation directory + install_path = Path.home() / ".local" / "share" / "yt-dlp-proxy" + if install_path.exists(): + shutil.rmtree(install_path) + shutil.copytree(repo_path, install_path) + + # Create wrapper script + wrapper_path = Path.home() / ".local" / "bin" / "yt-dlp-proxy" + wrapper_path.parent.mkdir(parents=True, exist_ok=True) + + wrapper_content = f'''#!/usr/bin/env python3 +import sys +sys.path.insert(0, "{install_path}") +from main import main +if __name__ == "__main__": + main() +''' + wrapper_path.write_text(wrapper_content) + wrapper_path.chmod(0o755) + + # Verify installation + self._run_command([str(wrapper_path), "--help"]) + logger.info("yt-dlp-proxy installed successfully") + + return True + except Exception as e: + logger.error(f"An error occurred during yt-dlp-proxy installation: {e}", exc_info=True) + return False + + def update_yt_dlp_proxy(self, max_workers: int = 4) -> bool: + """Update yt-dlp-proxy with specified max workers.""" + logger.info(f"Updating yt-dlp-proxy with max-workers={max_workers}...") + proxy_path = str(Path.home() / ".local" / "bin" / "yt-dlp-proxy") + + try: + self._run_command([ + proxy_path, "update", + "--max-workers", str(max_workers) + ]) + logger.info("yt-dlp-proxy updated successfully") + return True + except Exception as e: + logger.error(f"An error occurred while updating yt-dlp-proxy proxy list: {e}", exc_info=True) + return False + + def update_all(self, max_workers: int = 4) -> bool: + """Update yt-dlp-proxy (yt-dlp is installed during container build).""" + logger.info("Starting update process...") + + if not self.check_yt_dlp_available(): + logger.error("yt-dlp not available - should be installed during container build") + return False + + if not self.install_yt_dlp_proxy(): + logger.error("Failed to install yt-dlp-proxy.") + return False + + if not self.update_yt_dlp_proxy(max_workers): + logger.warning("Failed to update proxy list for yt-dlp-proxy. The tool is installed but may use stale proxies.") + + logger.info("Update process completed.") + return True + + + + +async def main(): + """Main entry point for the script.""" + logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(name)s - %(message)s" + ) + + updater = YtDlpUpdater() + + max_workers = 4 + if len(sys.argv) > 1: + try: + max_workers = int(sys.argv[1]) + except ValueError: + logger.warning(f"Invalid max_workers value: {sys.argv[1]}, using default: 4") + + success = updater.update_all(max_workers) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/src/mus/infrastructure/jobs/download_jobs.py b/backend/src/mus/infrastructure/jobs/download_jobs.py index bdb85ac..29c869c 100644 --- a/backend/src/mus/infrastructure/jobs/download_jobs.py +++ b/backend/src/mus/infrastructure/jobs/download_jobs.py @@ -1,7 +1,5 @@ -import asyncio import logging import shutil -import subprocess # nosec B404 import tempfile from pathlib import Path from urllib.parse import urlparse @@ -11,6 +9,7 @@ from src.mus.core.streaq_broker import worker from src.mus.infrastructure.api.sse_handler import notify_sse_from_worker from src.mus.infrastructure.jobs.file_system_jobs import handle_file_created +from src.mus.infrastructure.services.ytdlp_service import ytdlp_service def _validate_url(url: str) -> bool: @@ -37,7 +36,7 @@ async def download_track_from_url(url: str): if not _validate_url(url): raise ValueError("Invalid URL format") - output_path = await asyncio.to_thread(_download_audio, url, logger) + output_path = await _download_audio(url, logger) await set_app_write_lock(output_path) @@ -76,7 +75,7 @@ async def download_track_from_url(url: str): logger.info(f"WORKER: Completed download for URL: {url}") -def _download_audio(url: str, logger: logging.Logger) -> str: +async def _download_audio(url: str, logger: logging.Logger) -> str: with tempfile.TemporaryDirectory(prefix="mus-download-") as temp_dir_str: temp_dir = Path(temp_dir_str) logger.info(f"WORKER: Using temporary directory for download: {temp_dir}") @@ -85,42 +84,12 @@ def _download_audio(url: str, logger: logging.Logger) -> str: temp_dir / "%(artist,uploader|Unknown Artist)s - %(title)s.%(ext)s" ) - cmd = [ - "yt-dlp", - "--format", - "bestaudio/best", - "--extract-audio", - "--audio-format", - "mp3", - "--audio-quality", - "0", - "-o", - output_template, - "--embed-thumbnail", - "--convert-thumbnails", - "jpg", - "--embed-metadata", - "--parse-metadata", - "title:%(title)s", - "--parse-metadata", - "artist:%(artist,uploader|Unknown Artist)s", - "--sponsorblock-remove", - "all", - "--embed-chapters", - "--concurrent-fragments", - "3", - "--throttled-rate", - "100K", - "--retries", - "10", - "--no-playlist", - url, - ] - try: - result = subprocess.run( - cmd, capture_output=True, text=True, check=True, timeout=600 - ) # nosec B603 + result = await ytdlp_service.download_with_fallback_update( + url=url, + output_template=output_template, + max_workers=4 + ) all_output = result.stdout + result.stderr downloaded_file_path = None @@ -137,7 +106,7 @@ def _download_audio(url: str, logger: logging.Logger) -> str: break else: logger.warning( - "WORKER: yt-dlp reported a file outside of temp directory: " + "WORKER: yt-dlp-proxy reported a file outside of temp directory: " f"{filename_str}" ) @@ -154,12 +123,6 @@ def _download_audio(url: str, logger: logging.Logger) -> str: return str(final_path) - except subprocess.TimeoutExpired as e: - logger.error(f"WORKER: yt-dlp subprocess timed out: {e.stderr}") - raise Exception("Download timed out after 10 minutes") from e - except subprocess.CalledProcessError as e: - logger.error(f"WORKER: yt-dlp subprocess error: {e.stderr}") - raise Exception(f"Download failed: {e.stderr}") from e except Exception as e: logger.error(f"WORKER: Download error: {str(e)}") raise diff --git a/backend/src/mus/infrastructure/services/ytdlp_service.py b/backend/src/mus/infrastructure/services/ytdlp_service.py new file mode 100644 index 0000000..790efd0 --- /dev/null +++ b/backend/src/mus/infrastructure/services/ytdlp_service.py @@ -0,0 +1,221 @@ +""" +Service for managing yt-dlp-proxy integration and updates. +""" + +import asyncio +import logging +import subprocess +import sys +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +class YtDlpService: + """Service for managing yt-dlp-proxy operations.""" + + def __init__(self): + self.update_script_path = Path(__file__).parent.parent.parent.parent.parent / "scripts" / "update_ytdlp.py" + self._update_lock = asyncio.Lock() + self._last_update_attempt: Optional[float] = None + self._update_cooldown = 300 # 5 minutes cooldown between update attempts + + async def run_update_script(self, max_workers: int = 4) -> bool: + """ + Run the yt-dlp update script. + + Args: + max_workers: Maximum number of workers for yt-dlp-proxy update + + Returns: + True if update was successful, False otherwise + """ + import time + + async with self._update_lock: + # Check cooldown + current_time = time.time() + if (self._last_update_attempt and + current_time - self._last_update_attempt < self._update_cooldown): + logger.info("Update script called too recently, skipping") + return False + + self._last_update_attempt = current_time + + logger.info(f"Running yt-dlp update script with max_workers={max_workers}") + + try: + result = await asyncio.to_thread( + subprocess.run, + [sys.executable, str(self.update_script_path), str(max_workers)], + capture_output=True, + text=True, + timeout=600 # 10 minutes timeout + ) + + if result.returncode == 0: + logger.info("yt-dlp update script completed successfully") + if result.stdout: + logger.debug(f"Update script stdout: {result.stdout}") + return True + else: + logger.error(f"yt-dlp update script failed with exit code {result.returncode}") + if result.stderr: + logger.error(f"Update script stderr: {result.stderr}") + return False + + except subprocess.TimeoutExpired: + logger.error("yt-dlp update script timed out") + return False + except Exception as e: + logger.error(f"Failed to run yt-dlp update script: {e}") + return False + + async def download_with_proxy( + self, + url: str, + output_template: str, + additional_args: Optional[list[str]] = None + ) -> subprocess.CompletedProcess: + """ + Download using yt-dlp-proxy instead of direct yt-dlp. + + Args: + url: URL to download + output_template: Output filename template + additional_args: Additional arguments for yt-dlp + + Returns: + CompletedProcess result + + Raises: + subprocess.CalledProcessError: If download fails + subprocess.TimeoutExpired: If download times out + """ + base_args = [ + "--format", "bestaudio/best", + "--extract-audio", + "--audio-format", "mp3", + "--audio-quality", "0", + "-o", output_template, + "--embed-thumbnail", + "--convert-thumbnails", "jpg", + "--embed-metadata", + "--parse-metadata", "title:%(title)s", + "--parse-metadata", "artist:%(artist,uploader|Unknown Artist)s", + "--sponsorblock-remove", "all", + "--embed-chapters", + "--concurrent-fragments", "3", + "--throttled-rate", "100K", + "--retries", "10", + "--no-playlist", + ] + + if additional_args: + base_args.extend(additional_args) + + base_args.append(url) + + # Use yt-dlp-proxy from expected location + proxy_path = str(Path.home() / ".local" / "bin" / "yt-dlp-proxy") + cmd = [proxy_path] + base_args + + logger.info(f"Running yt-dlp-proxy command: {' '.join(cmd[:5])}... (truncated)") + + try: + result = await asyncio.to_thread( + subprocess.run, + cmd, + capture_output=True, + text=True, + check=True, + timeout=600 # 10 minutes timeout + ) + + logger.info("yt-dlp-proxy download completed successfully") + return result + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.error(f"yt-dlp-proxy download failed: {e}") + raise + + async def download_with_fallback_update( + self, + url: str, + output_template: str, + additional_args: Optional[list[str]] = None, + max_workers: int = 4 + ) -> subprocess.CompletedProcess: + """ + Download with automatic fallback to update if download fails. + + Args: + url: URL to download + output_template: Output filename template + additional_args: Additional arguments for yt-dlp + max_workers: Maximum workers for update if needed + + Returns: + CompletedProcess result + + Raises: + Exception: If download fails even after update attempt + """ + try: + # First attempt + return await self.download_with_proxy(url, output_template, additional_args) + + except Exception as first_error: + logger.warning(f"First download attempt failed: {first_error}") + logger.info("Attempting to update yt-dlp and yt-dlp-proxy...") + + # Try to update + update_success = await self.run_update_script(max_workers) + + if not update_success: + logger.error("Update failed, re-raising original download error") + raise first_error + + logger.info("Update completed, retrying download...") + + try: + # Second attempt after update + return await self.download_with_proxy(url, output_template, additional_args) + + except Exception as second_error: + logger.error(f"Download failed even after update: {second_error}") + raise second_error + + async def ensure_ytdlp_proxy_available(self) -> bool: + """ + Ensure yt-dlp-proxy is available and working. + + Returns: + True if yt-dlp-proxy is available, False otherwise + """ + proxy_path = str(Path.home() / ".local" / "bin" / "yt-dlp-proxy") + + try: + result = await asyncio.to_thread( + subprocess.run, + [proxy_path, "--help"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0: + logger.info("yt-dlp-proxy is available") + return True + else: + logger.warning("yt-dlp-proxy not working properly") + return False + + except Exception as e: + logger.warning(f"yt-dlp-proxy not available: {e}") + return False + + +# Global service instance +ytdlp_service = YtDlpService() diff --git a/backend/src/mus/main.py b/backend/src/mus/main.py index dfc550d..43b7592 100644 --- a/backend/src/mus/main.py +++ b/backend/src/mus/main.py @@ -22,6 +22,7 @@ from src.mus.infrastructure.api.sse_handler import router as sse_router from src.mus.infrastructure.database import create_db_and_tables from src.mus.infrastructure.file_watcher.watcher import watch_music_directory +from src.mus.infrastructure.services.ytdlp_service import ytdlp_service logging.basicConfig( level=settings.LOG_LEVEL.upper(), format="%(levelname)s: %(name)s - %(message)s" @@ -35,6 +36,13 @@ async def lifespan(_: FastAPI): await asyncio.to_thread(permissions_service.check_permissions) + # Check yt-dlp-proxy availability on startup + logger.info("Checking yt-dlp-proxy availability...") + if await ytdlp_service.ensure_ytdlp_proxy_available(): + logger.info("yt-dlp-proxy is available and ready") + else: + logger.warning("yt-dlp-proxy not available - will be installed during first download attempt") + fast_scanner = await FastInitialScanUseCase.create_default() await fast_scanner.execute() diff --git a/backend/tests/test_ytdlp_service.py b/backend/tests/test_ytdlp_service.py new file mode 100644 index 0000000..d6d2a6d --- /dev/null +++ b/backend/tests/test_ytdlp_service.py @@ -0,0 +1,181 @@ +""" +Tests for YtDlpService. +""" + +import asyncio +import subprocess +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.mus.infrastructure.services.ytdlp_service import YtDlpService + + +@pytest.fixture +def ytdlp_service(): + """Create a YtDlpService instance for testing.""" + return YtDlpService() + + +@pytest.mark.asyncio +async def test_ensure_ytdlp_proxy_available_success(ytdlp_service): + """Test successful yt-dlp-proxy availability check.""" + mock_result = MagicMock() + mock_result.returncode = 0 + + with patch('asyncio.to_thread', return_value=mock_result): + result = await ytdlp_service.ensure_ytdlp_proxy_available() + assert result is True + + +@pytest.mark.asyncio +async def test_ensure_ytdlp_proxy_available_failure(ytdlp_service): + """Test failed yt-dlp-proxy availability check.""" + mock_result = MagicMock() + mock_result.returncode = 1 + + with patch('asyncio.to_thread', return_value=mock_result): + result = await ytdlp_service.ensure_ytdlp_proxy_available() + assert result is False + + +@pytest.mark.asyncio +async def test_ensure_ytdlp_proxy_available_exception(ytdlp_service): + """Test yt-dlp-proxy availability check with exception.""" + with patch('asyncio.to_thread', side_effect=Exception("Test error")): + result = await ytdlp_service.ensure_ytdlp_proxy_available() + assert result is False + + +@pytest.mark.asyncio +async def test_download_with_proxy_success(ytdlp_service): + """Test successful download with yt-dlp-proxy.""" + mock_result = MagicMock() + mock_result.stdout = "Download completed" + mock_result.stderr = "" + + with patch('asyncio.to_thread', return_value=mock_result): + result = await ytdlp_service.download_with_proxy( + url="https://example.com/video", + output_template="/tmp/%(title)s.%(ext)s" + ) + assert result == mock_result + + +@pytest.mark.asyncio +async def test_download_with_proxy_failure(ytdlp_service): + """Test failed download with yt-dlp-proxy.""" + with patch('asyncio.to_thread', side_effect=subprocess.CalledProcessError(1, "cmd")): + with pytest.raises(subprocess.CalledProcessError): + await ytdlp_service.download_with_proxy( + url="https://example.com/video", + output_template="/tmp/%(title)s.%(ext)s" + ) + + +@pytest.mark.asyncio +async def test_run_update_script_success(ytdlp_service): + """Test successful update script execution.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Update completed" + mock_result.stderr = "" + + with patch('asyncio.to_thread', return_value=mock_result): + result = await ytdlp_service.run_update_script(max_workers=4) + assert result is True + + +@pytest.mark.asyncio +async def test_run_update_script_failure(ytdlp_service): + """Test failed update script execution.""" + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "Update failed" + + with patch('asyncio.to_thread', return_value=mock_result): + result = await ytdlp_service.run_update_script(max_workers=4) + assert result is False + + +@pytest.mark.asyncio +async def test_run_update_script_cooldown(ytdlp_service): + """Test update script cooldown mechanism.""" + import time + + # Set last update attempt to recent time + ytdlp_service._last_update_attempt = time.time() + + result = await ytdlp_service.run_update_script(max_workers=4) + assert result is False + + +@pytest.mark.asyncio +async def test_download_with_fallback_update_success_first_try(ytdlp_service): + """Test successful download on first try.""" + mock_result = MagicMock() + mock_result.stdout = "Download completed" + mock_result.stderr = "" + + with patch.object(ytdlp_service, 'download_with_proxy', return_value=mock_result): + result = await ytdlp_service.download_with_fallback_update( + url="https://example.com/video", + output_template="/tmp/%(title)s.%(ext)s" + ) + assert result == mock_result + + +@pytest.mark.asyncio +async def test_download_with_fallback_update_success_after_update(ytdlp_service): + """Test successful download after update.""" + mock_result = MagicMock() + mock_result.stdout = "Download completed" + mock_result.stderr = "" + + with patch.object(ytdlp_service, 'download_with_proxy', side_effect=[ + Exception("First attempt failed"), + mock_result + ]): + with patch.object(ytdlp_service, 'run_update_script', return_value=True): + result = await ytdlp_service.download_with_fallback_update( + url="https://example.com/video", + output_template="/tmp/%(title)s.%(ext)s" + ) + assert result == mock_result + + +@pytest.mark.asyncio +async def test_download_with_fallback_update_failure_after_update(ytdlp_service): + """Test failed download even after update.""" + first_error = Exception("First attempt failed") + second_error = Exception("Second attempt failed") + + with patch.object(ytdlp_service, 'download_with_proxy', side_effect=[ + first_error, + second_error + ]): + with patch.object(ytdlp_service, 'run_update_script', return_value=True): + with pytest.raises(Exception) as exc_info: + await ytdlp_service.download_with_fallback_update( + url="https://example.com/video", + output_template="/tmp/%(title)s.%(ext)s" + ) + assert exc_info.value == second_error + + +@pytest.mark.asyncio +async def test_download_with_fallback_update_failure_update_failed(ytdlp_service): + """Test download failure when update also fails.""" + first_error = Exception("First attempt failed") + + with patch.object(ytdlp_service, 'download_with_proxy', side_effect=first_error): + with patch.object(ytdlp_service, 'run_update_script', return_value=False): + with pytest.raises(Exception) as exc_info: + await ytdlp_service.download_with_fallback_update( + url="https://example.com/video", + output_template="/tmp/%(title)s.%(ext)s" + ) + assert exc_info.value == first_error diff --git a/backend/uv.lock b/backend/uv.lock index 5665c0a..74e0cb6 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -350,7 +350,6 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, { name = "vulture" }, { name = "watchfiles" }, - { name = "yt-dlp" }, ] [package.metadata] @@ -376,7 +375,6 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.2" }, { name = "vulture", specifier = ">=2.14" }, { name = "watchfiles", specifier = ">=0.21.0" }, - { name = "yt-dlp", specifier = ">=2025.8.22" }, ] [[package]] @@ -918,12 +916,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] - -[[package]] -name = "yt-dlp" -version = "2025.8.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/98/b077bebdc5c759a3f7af3ed3a2a5345ad1145c61963b476469b840ac84ce/yt_dlp-2025.8.22.tar.gz", hash = "sha256:d1846bbb7edbcd2a0d4a2d76c7a2124868de9ea3b3959a8cb8219e3f7cb5c335", size = 3037631, upload-time = "2025-08-23T00:07:02.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/06/e9e5e85969bd85142b004577915f33356ee0d5e20b79af8dd40b8bbfc96f/yt_dlp-2025.8.22-py3-none-any.whl", hash = "sha256:b8c71fe4516170dea60c6e5c54e2d45654693b8dc273cad060f22199476ec979", size = 3266783, upload-time = "2025-08-23T00:07:00.176Z" }, -] diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile index 5bb85c0..d63acf0 100644 --- a/docker/backend.Dockerfile +++ b/docker/backend.Dockerfile @@ -20,11 +20,13 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ libvips-dev \ ffmpeg \ curl \ + git \ && rm -rf /var/lib/apt/lists/* -RUN groupadd -g $GROUP_ID appgroup && useradd -u $USER_ID -g appgroup --create-home appuser \ +RUN (groupadd -g $GROUP_ID appgroup || true) \ + && useradd -u $USER_ID -g $(getent group $GROUP_ID | cut -d: -f1) --create-home appuser \ && mkdir -p $DATA_DIR_PATH/database $DATA_DIR_PATH/covers $DATA_DIR_PATH/music /opt/venv \ - && chown -R appuser:appgroup /app /opt/venv $DATA_DIR_PATH /home/appuser + && chown -R appuser:$(getent group $GROUP_ID | cut -d: -f1) /app $DATA_DIR_PATH /home/appuser /opt/venv USER appuser @@ -32,4 +34,4 @@ RUN uv venv /opt/venv EXPOSE 8001 -CMD ["sh", "-c", "[ -f /app/uv.lock ] && uv pip sync; uvicorn src.mus.main:app --host 0.0.0.0 --port 8001 --reload --timeout-graceful-shutdown 1"] +CMD ["sh", "-c", "uv sync --all-extras; uv pip install -U --pre 'yt-dlp[default]'; python scripts/update_ytdlp.py 4; uvicorn src.mus.main:app --host 0.0.0.0 --port 8001 --reload --timeout-graceful-shutdown 1"] diff --git a/docker/docker-compose.override.yml.example b/docker/docker-compose.override.yml.example index dcb5ff3..812c862 100644 --- a/docker/docker-compose.override.yml.example +++ b/docker/docker-compose.override.yml.example @@ -13,7 +13,7 @@ x-backend-environment: &x-backend-environment x-volumes: &x-volumes - ../backend:/app - - app_backend_venv:/opt/venv + - ./.venv:/opt/venv - /path/to/your/music:/app_data/music - app_data_database:/app_data/database - app_data_covers:/app_data/covers diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1f9278a..8dca986 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,10 +15,6 @@ services: build: context: .. dockerfile: docker/frontend.Dockerfile - # I forgot why I added that maybe thats not needed anymore with auth (not sure). - # Maybe thats because I need to communicate to backend:8000 exactly somewhere - extra_hosts: - - "localhost:host-gateway" redis: image: redis:7-alpine @@ -29,4 +25,4 @@ services: depends_on: redis: condition: service_started - command: sh -c "[ -f /app/uv.lock ] && uv pip sync; streaq src.mus.core.streaq_broker.worker" + command: sh -c "uv sync; streaq src.mus.core.streaq_broker.worker" diff --git a/makefiles/project.mk b/makefiles/project.mk index d17443c..6c96c64 100644 --- a/makefiles/project.mk +++ b/makefiles/project.mk @@ -9,6 +9,10 @@ up: down: @$(DOCKER_COMPOSE_CMD) down --remove-orphans +.PHONY: down-volumes +down-volumes: + @$(DOCKER_COMPOSE_CMD) down --remove-orphans --volumes + .PHONY: logs logs: @$(DOCKER_COMPOSE_CMD) logs --tail=5000 $(ARGS)