├── testing ├── __init__.py ├── testing_youtube_downloader.py ├── testing_playlist_spotify_data.py ├── testig_download_playlists.py ├── testing_theaudiodb_wrapper.py └── testing_spotify_api.py ├── spotifysaver ├── api │ ├── routers │ │ └── __init__.py │ ├── __init__.py │ ├── services │ │ ├── __init__.py │ │ └── download_service.py │ ├── main.py │ ├── config.py │ ├── app.py │ ├── schemas.py │ └── README.md ├── config │ ├── __init__.py │ └── setting_environment.py ├── cli │ ├── __init__.py │ ├── commands │ │ ├── log │ │ │ ├── __init__.py │ │ │ └── log.py │ │ ├── download │ │ │ ├── __init__.py │ │ │ ├── download.py │ │ │ ├── track.py │ │ │ ├── playlist.py │ │ │ └── album.py │ │ ├── inspect │ │ │ ├── __init__.py │ │ │ ├── track_info.py │ │ │ ├── album_info.py │ │ │ ├── playlist_info.py │ │ │ └── inspect.py │ │ ├── __init__.py │ │ ├── version.py │ │ └── init.py │ └── cli.py ├── enums │ ├── __init__.py │ ├── audio_formats_enum.py │ └── bitrates_enum.py ├── __main__.py ├── metadata │ ├── __init__.py │ ├── nfo_generator.py │ └── music_file_metadata.py ├── models │ ├── __init__.py │ ├── album.py │ ├── playlist.py │ ├── artist.py │ └── track.py ├── spotlog │ ├── __init__.py │ ├── ydd_logger.py │ ├── logger.py │ └── log_config.py ├── services │ ├── schemas │ │ ├── __init__.py │ │ ├── track_audiodb_schema.py │ │ ├── artist_audiodb_schema.py │ │ └── album_audiodb_schema.py │ ├── __init__.py │ ├── errors │ │ └── errors.py │ ├── lrclib_api.py │ ├── audiodb_parser.py │ ├── score_match_calculator.py │ └── the_audio_db_service.py ├── downloader │ ├── __init__.py │ └── image_downloader.py ├── __init__.py └── ui │ ├── README.md │ ├── static │ └── js │ │ ├── state-manager.js │ │ ├── app.js │ │ └── api-client.js │ └── index.html ├── .vscode ├── settings.json └── launch.json ├── docker-compose.development.yml ├── .github ├── RELEASE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ ├── bug_report.yml │ ├── documentation_request.yml │ ├── discussion.yml │ ├── integration_request.yml │ └── rfc.yml ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── python-publish.yml │ └── docker-build.yml └── CONTRIBUTING.md ├── .bumpver.toml ├── .dockerignore ├── .env.example ├── LICENSE ├── docker-compose.yml ├── Dockerfile ├── requirements.txt ├── .gitignore ├── pyproject.toml ├── test_api.py ├── API_IMPLEMENTATION_SUMMARY.md ├── SPOTIFYSAVER_UI_SUMMARY.md └── README_ES.md /testing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spotifysaver/api/routers/__init__.py: -------------------------------------------------------------------------------- 1 | """API Routers Package""" 2 | 3 | # Importing routers to make them available 4 | -------------------------------------------------------------------------------- /spotifysaver/api/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver API Package""" 2 | 3 | from .app import create_app 4 | 5 | __all__ = ["create_app"] 6 | -------------------------------------------------------------------------------- /spotifysaver/config/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver configuration module.""" 2 | 3 | from spotifysaver.config.setting_environment import Config 4 | -------------------------------------------------------------------------------- /spotifysaver/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver CLI package initialization.""" 2 | 3 | from spotifysaver.cli.cli import cli 4 | 5 | __all__ = ["cli"] 6 | -------------------------------------------------------------------------------- /spotifysaver/api/services/__init__.py: -------------------------------------------------------------------------------- 1 | """API Services Package""" 2 | 3 | from .download_service import DownloadService 4 | 5 | __all__ = ["DownloadService"] 6 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/log/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver CLI Log Command""" 2 | 3 | from spotifysaver.cli.commands.log.log import show_log 4 | 5 | __all__ = ["show_log"] 6 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/download/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver CLI Download Command.""" 2 | 3 | from spotifysaver.cli.commands.download.download import download 4 | 5 | __all__ = ["download"] 6 | -------------------------------------------------------------------------------- /spotifysaver/enums/__init__.py: -------------------------------------------------------------------------------- 1 | from spotifysaver.enums.audio_formats_enum import AudioFormat 2 | from spotifysaver.enums.bitrates_enum import Bitrate 3 | 4 | __all__ = ["AudioFormat", "Bitrate"] -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python-envs.defaultEnvManager": "ms-python.python:conda", 3 | "python-envs.defaultPackageManager": "ms-python.python:conda", 4 | "python-envs.pythonProjects": [] 5 | } -------------------------------------------------------------------------------- /spotifysaver/enums/audio_formats_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class AudioFormat(Enum): 4 | """Enum for supported audio formats.""" 5 | 6 | M4A = "m4a" 7 | MP3 = "mp3" 8 | OPUS = "opus" -------------------------------------------------------------------------------- /spotifysaver/enums/bitrates_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class Bitrate(Enum): 4 | """Enum for supported audio bitrates.""" 5 | 6 | B96 = 96 7 | B128 = 128 8 | B192 = 192 9 | B256 = 256 -------------------------------------------------------------------------------- /docker-compose.development.yml: -------------------------------------------------------------------------------- 1 | services: 2 | spotifysaver: 3 | build: 4 | context: . 5 | image: spotifysaver:dev 6 | volumes: 7 | - ./music:/music 8 | environment: 9 | - LOG_LEVEL="DEBUG" 10 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/inspect/__init__.py: -------------------------------------------------------------------------------- 1 | """This module provides the inspect command for the SpotifySaver CLI.""" 2 | 3 | from spotifysaver.cli.commands.inspect.inspect import inspect 4 | 5 | __all__ = [ 6 | "inspect", 7 | ] 8 | -------------------------------------------------------------------------------- /spotifysaver/__main__.py: -------------------------------------------------------------------------------- 1 | """Main entry point for the SpotifySaver application. 2 | This module serves as the entry point for the SpotifySaver command-line interface. 3 | """ 4 | 5 | from spotifysaver.cli import cli 6 | 7 | if __name__ == "__main__": 8 | cli() 9 | -------------------------------------------------------------------------------- /spotifysaver/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver Metadata Module""" 2 | 3 | from spotifysaver.metadata.nfo_generator import NFOGenerator 4 | from spotifysaver.metadata.music_file_metadata import MusicFileMetadata 5 | 6 | __all__ = [ 7 | "NFOGenerator", 8 | "MusicFileMetadata", 9 | ] 10 | -------------------------------------------------------------------------------- /.github/RELEASE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Version 2 | 3 | Example: v1.2.0 4 | 5 | ## Main Changes 6 | 7 | - Summary of Included PRs 8 | - New Features 9 | - Bug Fixes 10 | - Dependency Updates 11 | 12 | ## Migration Instructions (if applicable) 13 | 14 | What do users need to know to upgrade? 15 | 16 | ## Changelog -------------------------------------------------------------------------------- /spotifysaver/models/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver Models Package""" 2 | 3 | from spotifysaver.models.album import Album 4 | from spotifysaver.models.track import Track 5 | from spotifysaver.models.artist import Artist 6 | from spotifysaver.models.playlist import Playlist 7 | 8 | __all__ = ["Album", "Track", "Artist", "Playlist"] 9 | -------------------------------------------------------------------------------- /spotifysaver/spotlog/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for logging functionality in Spotify Saver.""" 2 | 3 | from spotifysaver.spotlog.logger import get_logger 4 | from spotifysaver.spotlog.log_config import LoggerConfig 5 | from spotifysaver.spotlog.ydd_logger import YDLLogger 6 | 7 | __all__ = ["get_logger", "LoggerConfig", "YDLLogger"] 8 | -------------------------------------------------------------------------------- /spotifysaver/services/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from spotifysaver.services.schemas.track_audiodb_schema import TrackADBResponse 2 | from spotifysaver.services.schemas.album_audiodb_schema import AlbumADBResponse, MediaAlbumURLs, AlbumDescription 3 | from spotifysaver.services.schemas.artist_audiodb_schema import ArtistADBResponse, ArtistBiography, MediaArtistURLs 4 | -------------------------------------------------------------------------------- /spotifysaver/downloader/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver Downloader Module""" 2 | 3 | from spotifysaver.downloader.youtube_downloader import YouTubeDownloader 4 | from spotifysaver.downloader.youtube_downloader_for_cli import YouTubeDownloaderForCLI 5 | from spotifysaver.downloader.image_downloader import ImageDownloader 6 | 7 | __all__ = ["YouTubeDownloader", "YouTubeDownloaderForCLI", "ImageDownloader"] 8 | -------------------------------------------------------------------------------- /.bumpver.toml: -------------------------------------------------------------------------------- 1 | [bumpver] 2 | current_version = "0.7.5" 3 | version_pattern = "MAJOR.MINOR.PATCH" 4 | commit = true 5 | tag = true 6 | push = false 7 | commit_message = "bump version {old_version} -> {new_version}" 8 | tag_message = "{new_version}" 9 | tag_scope = "global" 10 | 11 | [bumpver.file_patterns] 12 | "pyproject.toml" = [ 13 | 'version = "{version}"' 14 | ] 15 | "spotifysaver/__init__.py" = [ 16 | '__version__ = "{version}"' 17 | ] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: 💬 Community Questions or Ideas 5 | url: https://github.com/gabrielbaute/spotify-saver/discussions 6 | about: Use Discussions for general questions, ideas, or help from the community. 7 | 8 | - name: 📚 Documentation & Wiki 9 | url: https://github.com/gabrielbaute/spotify-saver/wiki 10 | about: Check the Wiki for guides, FAQs, and usage examples. -------------------------------------------------------------------------------- /spotifysaver/cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides the command line interface commands for SpotifySaver. 3 | """ 4 | 5 | from spotifysaver.cli.commands.download import download 6 | from spotifysaver.cli.commands.version import version 7 | from spotifysaver.cli.commands.inspect import inspect 8 | from spotifysaver.cli.commands.log import show_log 9 | from spotifysaver.cli.commands.init import init 10 | 11 | __all__ = ["download", "version", "inspect", "show_log", "init"] 12 | -------------------------------------------------------------------------------- /spotifysaver/spotlog/ydd_logger.py: -------------------------------------------------------------------------------- 1 | from spotifysaver.spotlog.logger import get_logger 2 | 3 | class YDLLogger: 4 | def __init__(self): 5 | self.logger = get_logger("YT-DLP") 6 | 7 | def debug(self, msg): 8 | self.logger.debug(f"[yt-dlp] {msg}") 9 | 10 | def info(self, msg): 11 | self.logger.info(f"[yt-dlp] {msg}") 12 | 13 | def warning(self, msg): 14 | self.logger.warning(f"[yt-dlp] {msg}") 15 | 16 | def error(self, msg): 17 | self.logger.error(f"[yt-dlp] {msg}") -------------------------------------------------------------------------------- /spotifysaver/services/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver Services Module""" 2 | 3 | from spotifysaver.services.spotify_api import SpotifyAPI 4 | from spotifysaver.services.youtube_api import YoutubeMusicSearcher 5 | from spotifysaver.services.lrclib_api import LrclibAPI 6 | from spotifysaver.services.score_match_calculator import ScoreMatchCalculator 7 | from spotifysaver.services.the_audio_db_service import TheAudioDBService 8 | 9 | __all__ = ["SpotifyAPI", "YoutubeMusicSearcher", "LrclibAPI", "ScoreMatchCalculator", "TheAudioDBService"] 10 | -------------------------------------------------------------------------------- /spotifysaver/spotlog/logger.py: -------------------------------------------------------------------------------- 1 | """Logger utility module for SpotifySaver application. 2 | 3 | This module provides a simple interface for getting configured loggers 4 | throughout the application. 5 | """ 6 | 7 | import logging 8 | 9 | 10 | def get_logger(name): 11 | """Get a configured logger instance for the specified module. 12 | 13 | Args: 14 | name: Name of the logger, typically the module name 15 | 16 | Returns: 17 | logging.Logger: Configured logger instance 18 | """ 19 | return logging.getLogger(name) 20 | -------------------------------------------------------------------------------- /spotifysaver/api/main.py: -------------------------------------------------------------------------------- 1 | """FastAPI server entry point for SpotifySaver API""" 2 | 3 | import uvicorn 4 | from spotifysaver.api import create_app 5 | from spotifysaver.api.config import APIConfig 6 | 7 | 8 | app = create_app() 9 | 10 | def run_server(): 11 | """Run the FastAPI server.""" 12 | uvicorn.run( 13 | "spotifysaver.api.main:app", 14 | host=APIConfig.API_HOST, 15 | port=APIConfig.API_PORT, 16 | reload=True, 17 | log_level=APIConfig.LOG_LEVEL, 18 | ) 19 | 20 | if __name__ == "__main__": 21 | run_server() 22 | -------------------------------------------------------------------------------- /testing/testing_youtube_downloader.py: -------------------------------------------------------------------------------- 1 | from spotifysaver.services import SpotifyAPI, YoutubeMusicSearcher 2 | from spotifysaver.downloader import YouTubeDownloader 3 | from spotifysaver.spotlog import get_logger 4 | 5 | logger = get_logger("main") 6 | 7 | spotify = SpotifyAPI() 8 | searcher = YoutubeMusicSearcher() 9 | downloader = YouTubeDownloader() 10 | 11 | album = spotify.get_album("https://open.spotify.com/album/4aoy2NnmDpWvjWi9taOHYe") 12 | for track in album.tracks: 13 | yt_url = searcher.search_track(track) 14 | if yt_url: 15 | downloader.download_track(track, yt_url) -------------------------------------------------------------------------------- /spotifysaver/cli/commands/version.py: -------------------------------------------------------------------------------- 1 | """Version command module for SpotifySaver CLI. 2 | 3 | This module provides the version command that displays the current version 4 | of the SpotifySaver application. 5 | """ 6 | 7 | import click 8 | 9 | from spotifysaver import __version__ 10 | 11 | 12 | @click.command("version") 13 | def version(): 14 | """Display the current version of SpotifySaver. 15 | 16 | Shows the installed version number of the SpotifySaver application. 17 | This is useful for troubleshooting and ensuring you have the correct version. 18 | """ 19 | click.echo(f"spotifysaver v{__version__}") 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Briefly describe what changes you are introducing and why. 4 | 5 | ## Type of change 6 | 7 | Mark with an "x" what applies: 8 | 9 | - [ ] Bug fix 🐞 10 | - [ ] New functionality ✨ 11 | - [ ] Code improvement/refactor 🔧 12 | - [ ] Documentation 📚 13 | - [ ] Other (please specify): 14 | 15 | ## Checklist 16 | 17 | - [ ] The branch is updated with `main` 18 | - [ ] The changes are tested and working 19 | - [ ] The tests have been updated (if applicable) 20 | - [ ] The documentation has been updated (if applicable) 21 | 22 | ## References 23 | 24 | Related issues, architectural decisions, etc. -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | *.db 7 | *.sqlite3 8 | *.env 9 | *.DS_Store 10 | node_modules/ 11 | .dockerignore 12 | # Python virtual environments 13 | venv/ 14 | env/ 15 | .venv/ 16 | .env/ 17 | # Logs and temporary files 18 | logs/ 19 | *.log 20 | .pytest_cache/ 21 | # IDE files 22 | .vscode/ 23 | .idea/ 24 | *.swp 25 | *.swo 26 | # OS files 27 | Thumbs.db 28 | # Development files 29 | .coverage 30 | .tox/ 31 | dist/ 32 | build/ 33 | *.egg-info/ 34 | # Docker files 35 | docker-compose.override.yml 36 | # Music and config directories (should be mounted) 37 | music/ 38 | Music/ 39 | config/ 40 | test_output/ 41 | string/ -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "name": "Python Debugger: FastAPI", 10 | "type": "debugpy", 11 | "request": "launch", 12 | "module": "uvicorn", 13 | "args": [ 14 | "spotifysaver.api.main:run_server", 15 | "--reload" 16 | ], 17 | "jinja": true 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /testing/testing_playlist_spotify_data.py: -------------------------------------------------------------------------------- 1 | from spotifysaver.services.spotify_api import SpotifyAPI 2 | 3 | def get_playlist_tracks(playlist_url: str): 4 | """Obtiene los tracks de una playlist y los imprime.""" 5 | api = SpotifyAPI() 6 | playlist = api.get_playlist(playlist_url) 7 | 8 | print(f"Playlist: {playlist.name} ({playlist.uri})") 9 | for track in playlist.tracks: 10 | print(f"{track.number}. {track.name} - {', '.join(track.artists)} - Spotify URI: {track.uri}") 11 | 12 | if __name__ == "__main__": 13 | # Reemplaza con la URL de tu playlist 14 | playlist_url = "https://open.spotify.com/playlist/01X3ID1BGl5PPky2KFnQ0P" 15 | get_playlist_tracks(playlist_url) -------------------------------------------------------------------------------- /testing/testig_download_playlists.py: -------------------------------------------------------------------------------- 1 | from spotifysaver.services import SpotifyAPI, YoutubeMusicSearcher 2 | from spotifysaver.downloader import YouTubeDownloader 3 | from spotifysaver.spotlog import get_logger, LoggerConfig 4 | 5 | LoggerConfig.setup() 6 | logger = get_logger("main") 7 | 8 | url = "https://open.spotify.com/playlist/01X3ID1BGl5PPky2KFnQ0P" 9 | spotify = SpotifyAPI() 10 | searcher = YoutubeMusicSearcher() 11 | downloader = YouTubeDownloader() 12 | 13 | playlist = spotify.get_playlist(url) 14 | for track in playlist.tracks: 15 | yt_url = searcher.search_track(track) 16 | if yt_url: 17 | downloader.download_track(track, yt_url) 18 | else: 19 | logger.warning(f"No se encontró YouTube URL para el track: {track.name}") -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Spotify API credentials 2 | # Get these from: https://developer.spotify.com/dashboard/applications 3 | SPOTIFY_CLIENT_ID=your_spotify_client_id_here 4 | SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here 5 | SPOTIFY_REDIRECT_URI="http://localhost:8888/callback" 6 | 7 | # Optional: Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 8 | LOG_LEVEL="INFO" 9 | 10 | # Optional: Custom output directory (defaults to "Music") 11 | SPOTIFYSAVER_OUTPUT_DIR=Music 12 | 13 | # Optional: YouTube cookies file path for age-restricted content 14 | YTDLP_COOKIES_PATH="cookies.txt" 15 | 16 | # Optional: API Configuration 17 | # API_PORT: Port for the API server (default is 8000) 18 | API_PORT=8000 19 | # API_HOST: Host for the API server (default is "0.0.0.0") 20 | API_HOST="0.0.0.0" -------------------------------------------------------------------------------- /spotifysaver/api/config.py: -------------------------------------------------------------------------------- 1 | """API Configuration Settings""" 2 | 3 | import os 4 | from typing import List 5 | 6 | 7 | class APIConfig: 8 | """Configuration settings for the FastAPI application.""" 9 | 10 | # CORS settings 11 | ALLOWED_ORIGINS: List[str] = ["*"] 12 | 13 | # API settings 14 | DEFAULT_OUTPUT_DIR: str = "Music" 15 | MAX_CONCURRENT_DOWNLOADS: int = 3 16 | 17 | # File settings 18 | ALLOWED_FORMATS: List[str] = ["m4a", "mp3"] 19 | DEFAULT_FORMAT: str = "m4a" 20 | 21 | # Service settings 22 | API_PORT: int = 8000 23 | API_HOST: str = "0.0.0.0" 24 | LOG_LEVEL: str = "info" 25 | 26 | @classmethod 27 | def get_output_dir(cls) -> str: 28 | """Get the output directory from environment or default.""" 29 | return os.getenv("SPOTIFYSAVER_OUTPUT_DIR", cls.DEFAULT_OUTPUT_DIR) 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Functionality request 2 | description: Suggest an improvement or new feature 3 | title: "[FEAT] Brief description of functionality" 4 | labels: ["feature", "enhancement"] 5 | assignees: [] 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thank you for your proposal! Tell us more about the functionality you envision. 12 | 13 | - type: input 14 | id: resumen 15 | attributes: 16 | label: Summary 17 | description: What functionality do you propose? 18 | placeholder: "e.g., Add multiple download sources..." 19 | 20 | - type: textarea 21 | id: implementation 22 | attributes: 23 | label: Implementation ideas 24 | description: Do you have any idea how it could be done? 25 | placeholder: "A button could be added to..." 26 | 27 | - type: textarea 28 | id: considerations 29 | attributes: 30 | label: Additional considerations 31 | description: Risks, dependencies, etc. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Gabriel Baute 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v5 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.x' 24 | 25 | - name: Install Poetry 26 | run: | 27 | curl -sSL https://install.python-poetry.org | python3 - 28 | echo "$HOME/.local/bin" >> $GITHUB_PATH 29 | 30 | - name: Build package 31 | run: poetry build 32 | 33 | - name: Publish package 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | user: __token__ 37 | password: ${{ secrets.PYPI_API_TOKEN }} 38 | -------------------------------------------------------------------------------- /spotifysaver/cli/cli.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver Command Line Interface Module. 2 | 3 | This module provides the main CLI application for SpotifySaver, a tool that downloads 4 | music from Spotify by searching and downloading equivalent tracks from YouTube Music. 5 | The CLI supports downloading individual tracks, albums, and playlists with metadata 6 | preservation and organization features. 7 | """ 8 | 9 | from click import group 10 | 11 | from spotifysaver.cli.commands import ( 12 | download, 13 | version, 14 | inspect, 15 | show_log, 16 | init as init_command, 17 | ) 18 | 19 | 20 | @group() 21 | def cli(): 22 | """SpotifySaver - Download music from Spotify via YouTube Music. 23 | 24 | A comprehensive tool for downloading Spotify tracks, albums, and playlists 25 | by finding equivalent content on YouTube Music. Features include metadata 26 | preservation, lyrics fetching, and organized file management. 27 | """ 28 | pass 29 | 30 | 31 | # Register all available commands 32 | cli.add_command(download) 33 | cli.add_command(inspect) 34 | cli.add_command(version) 35 | cli.add_command(show_log) 36 | cli.add_command(init_command) 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Report a bug you've found 3 | title: "[BUG] Brief description of the error" 4 | labels: ["bug"] 5 | assignees: [] 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thank you for helping us improve the app. Please complete the following information. 12 | 13 | - type: input 14 | id: context 15 | attributes: 16 | label: Context 17 | description: Where did the error occur? What were you doing? 18 | placeholder: "For example, when downloading an album in the CLI..." 19 | 20 | - type: textarea 21 | id: steps 22 | attributes: 23 | label: Steps to reproduce the error 24 | description: Describe the steps so we can replicate it 25 | placeholder: | 26 | 1. Go to... 27 | 2. Click on... 28 | 3. View the error... 29 | 30 | - type: textarea 31 | id: expected_behavior 32 | attributes: 33 | label: Expected behavior 34 | description: What should have happened? 35 | placeholder: "The system should have..." 36 | 37 | - type: textarea 38 | id: evidence 39 | attributes: 40 | label: Evidence 41 | description: Screenshots, logs, etc. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation_request.yml: -------------------------------------------------------------------------------- 1 | name: 📚 Documentation Request 2 | description: Suggest missing documentation or improvements to existing docs 3 | title: "[DOC] Brief description of the request" 4 | labels: ["documentation"] 5 | assignees: [] 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thank you for helping us improve the documentation. Please complete the following fields. 12 | 13 | - type: textarea 14 | id: request_description 15 | attributes: 16 | label: Request Description 17 | description: Describe what documentation is missing or could be improved. 18 | placeholder: "Explain the gap or improvement needed..." 19 | 20 | - type: input 21 | id: affected_module 22 | attributes: 23 | label: Affected Module/Feature 24 | description: Which part of the repository does this refer to? 25 | placeholder: "e.g., CLI downloader, API client, frontend dashboard..." 26 | 27 | - type: textarea 28 | id: expected_content 29 | attributes: 30 | label: Sample Expected Content 31 | description: Add examples, screenshots, links, or details of what should be included. 32 | placeholder: "Provide examples, screenshots, or links to clarify..." 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | spotifysaver: 3 | container_name: spotifysaver 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | ports: 8 | - "${API_PORT:-8000}:8000" 9 | environment: 10 | # Spotify API (requerido) 11 | - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} 12 | - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET} 13 | - SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI:-http://localhost:8000/callback} 14 | # Configuración de la app 15 | - SPOTIFYSAVER_LOG_LEVEL=${LOG_LEVEL:-INFO} 16 | - YTDLP_FORMAT=${YTDLP_FORMAT:-best[ext=m4a]/best} 17 | # Configurar cookies en directorio escribible 18 | - YTDLP_COOKIES_PATH=/config/cookies.txt 19 | volumes: 20 | - ${MUSIC_DIR:-./music}:/music 21 | - ${CONFIG_DIR:-./config}:/config 22 | - spotifysaver_logs:/logs 23 | command: ["spotifysaver-api"] 24 | restart: unless-stopped 25 | # Configuración de seguridad 26 | security_opt: 27 | - no-new-privileges:true 28 | # Healthcheck 29 | healthcheck: 30 | test: ["CMD", "curl", "-f", "http://localhost:8000/health"] 31 | interval: 30s 32 | timeout: 10s 33 | retries: 3 34 | start_period: 60s 35 | 36 | # Volúmenes 37 | volumes: 38 | spotifysaver_logs: 39 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/inspect/track_info.py: -------------------------------------------------------------------------------- 1 | """Track Information Display Module. 2 | 3 | This module provides functionality to display detailed track information and metadata 4 | from Spotify tracks in a formatted, user-friendly way through the CLI interface. 5 | """ 6 | 7 | import click 8 | 9 | from spotifysaver.models import Track 10 | 11 | 12 | def show_track_info(track: Track, verbose: bool): 13 | """Display comprehensive track metadata and information. 14 | 15 | Shows formatted track information including name, artists, duration, and 16 | optionally technical details like URI and genres when verbose mode is enabled. 17 | 18 | Args: 19 | track (Track): The track object containing metadata to display 20 | verbose (bool): Whether to show detailed technical information including 21 | URI and genres 22 | """ 23 | click.secho(f"\n🎵 Track: {track.name}", fg="cyan", bold=True) 24 | click.echo(f"👤 Artist(s): {', '.join(track.artists)}") 25 | click.echo(f"⏱ Duration: {track.duration // 60}:{track.duration % 60:02d}") 26 | 27 | if verbose: 28 | click.echo(f"\n🔍 Detalles técnicos:") 29 | click.echo(f"URI: {track.uri}") 30 | click.echo(f"Géneros: {', '.join(track.genres) if track.genres else 'N/A'}") 31 | -------------------------------------------------------------------------------- /spotifysaver/models/album.py: -------------------------------------------------------------------------------- 1 | """Album model for Spotify Saver.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import List 5 | 6 | from .track import Track 7 | 8 | 9 | @dataclass 10 | class Album: 11 | """Represents an album and its tracks. 12 | 13 | This class encapsulates all information about a music album including 14 | basic metadata and a collection of tracks. 15 | 16 | Attributes: 17 | name: The name/title of the album 18 | artists: List of artist names who created the album 19 | release_date: Release date of the album 20 | genres: List of genres associated with the album 21 | cover_url: URL to the album cover art 22 | tracks: List of Track objects contained in the album 23 | """ 24 | 25 | name: str 26 | artists: List[str] 27 | release_date: str 28 | genres: List[str] 29 | cover_url: str 30 | tracks: List[Track] 31 | 32 | def get_track_by_uri(self, uri: str) -> Track | None: 33 | """Find a track by its Spotify URI. 34 | 35 | Args: 36 | uri: The Spotify URI to search for 37 | 38 | Returns: 39 | Track: The track with matching URI, or None if not found 40 | """ 41 | return next((t for t in self.tracks if t.uri == uri), None) 42 | -------------------------------------------------------------------------------- /spotifysaver/models/playlist.py: -------------------------------------------------------------------------------- 1 | """Playlist model for Spotify Saver.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import List 5 | 6 | from .track import Track 7 | 8 | 9 | @dataclass 10 | class Playlist: 11 | """Represents a Spotify playlist and its tracks. 12 | 13 | This class encapsulates all information about a playlist including 14 | metadata and the collection of tracks it contains. 15 | 16 | Attributes: 17 | name: The playlist name/title 18 | description: Description or summary of the playlist 19 | owner: Username of the playlist owner 20 | uri: Spotify URI for the playlist 21 | cover_url: URL to the playlist cover image 22 | tracks: List of Track objects in the playlist 23 | """ 24 | 25 | name: str 26 | description: str 27 | owner: str 28 | uri: str 29 | cover_url: str 30 | tracks: List[Track] 31 | 32 | def get_track_by_uri(self, uri: str) -> Track | None: 33 | """Find a track by its URI (similar to Album method). 34 | 35 | Args: 36 | uri: The Spotify URI to search for 37 | 38 | Returns: 39 | Track: The track with matching URI, or None if not found 40 | """ 41 | return next((t for t in self.tracks if t.uri == uri), None) 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | 3 | # Instalar dependencias del sistema y crear usuario no privilegiado 4 | RUN apk add --no-cache ffmpeg curl && \ 5 | addgroup -g 1000 spotifysaver && \ 6 | adduser -D -s /bin/sh -u 1000 -G spotifysaver spotifysaver 7 | 8 | ENV PYTHONUNBUFFERED=1 9 | ENV PIP_NO_CACHE_DIR=1 10 | 11 | WORKDIR /app 12 | 13 | # Copiar solo archivos necesarios primero (mejor cache de layers) 14 | COPY requirements.txt pyproject.toml ./ 15 | 16 | # Instalar dependencias Python 17 | RUN pip install --no-cache-dir -r requirements.txt && rm -rf ~/.cache/pip 18 | 19 | # Copiar código fuente 20 | COPY --chown=spotifysaver:spotifysaver . . 21 | 22 | # Instalar la aplicación 23 | RUN pip install . && \ 24 | mkdir -p /music /config /logs && \ 25 | touch /config/cookies.txt && \ 26 | chown -R spotifysaver:spotifysaver /music /config /logs 27 | 28 | ENV SPOTIFYSAVER_OUTPUT_DIR="/music" \ 29 | YTDLP_COOKIES_PATH="/config/cookies.txt" \ 30 | SPOTIFYSAVER_AUTO_OPEN_BROWSER="false" \ 31 | SPOTIFYSAVER_UI_HOST="0.0.0.0" 32 | 33 | # Cambiar a usuario no privilegiado 34 | USER spotifysaver 35 | 36 | # Healthcheck 37 | HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ 38 | CMD curl -f http://localhost:8000/health || exit 1 39 | 40 | # Exponer puertos 41 | EXPOSE 8000 42 | 43 | # Comando por defecto 44 | CMD ["spotifysaver-api"] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | annotated-types==0.7.0 2 | anyio==4.11.0 3 | black==23.12.1 4 | Brotli==1.1.0 5 | build==1.2.2.post1 6 | bumpver==2024.1130 7 | certifi==2025.8.3 8 | charset-normalizer==3.4.3 9 | click==8.1.8 10 | colorama==0.4.6 11 | docopt==0.6.2 12 | docutils==0.21.2 13 | fastapi==0.115.14 14 | h11==0.16.0 15 | httptools==0.6.4 16 | id==1.5.0 17 | idna==3.10 18 | iniconfig==2.1.0 19 | jaraco.classes==3.4.0 20 | jaraco.context==6.0.1 21 | jaraco.functools==4.1.0 22 | keyring==25.6.0 23 | lexid==2021.1006 24 | markdown-it-py==3.0.0 25 | mdurl==0.1.2 26 | more-itertools==10.7.0 27 | mutagen==1.47.0 28 | mypy==1.18.2 29 | mypy_extensions==1.1.0 30 | nh3==0.2.21 31 | packaging==25.0 32 | pathspec==0.12.1 33 | pipreqs==0.4.13 34 | platformdirs==4.4.0 35 | pluggy==1.6.0 36 | pycryptodomex==3.23.0 37 | pydantic==2.11.9 38 | pydantic_core==2.33.2 39 | Pygments==2.19.1 40 | pyproject_hooks==1.2.0 41 | pytest==7.4.4 42 | python-dotenv==1.1.1 43 | pywin32-ctypes==0.2.3 44 | PyYAML==6.0.3 45 | readme_renderer==44.0 46 | redis==6.4.0 47 | requests==2.32.5 48 | requests-toolbelt==1.0.0 49 | rfc3986==2.0.0 50 | rich==14.0.0 51 | six==1.17.0 52 | sniffio==1.3.1 53 | spotipy==2.25.1 54 | starlette==0.46.2 55 | toml==0.10.2 56 | twine==6.1.0 57 | typing-inspection==0.4.1 58 | typing_extensions==4.15.0 59 | urllib3==2.5.0 60 | uvicorn==0.34.3 61 | watchfiles==1.1.0 62 | websockets==15.0.1 63 | yarg==0.1.10 64 | yt-dlp==2025.9.26 65 | ytmusicapi==1.10.3 66 | -------------------------------------------------------------------------------- /spotifysaver/models/artist.py: -------------------------------------------------------------------------------- 1 | """Artist model for Spotify Saver.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import List 5 | 6 | 7 | @dataclass 8 | class Artist: 9 | """Represents an individual artist with metadata. 10 | 11 | This class encapsulates all information about a music artist including 12 | basic information and statistics. 13 | 14 | Attributes: 15 | name: The artist's name 16 | uri: Spotify URI for the artist 17 | genres: List of genres associated with the artist 18 | popularity: Popularity score (0-100) from Spotify 19 | followers: Number of followers on Spotify 20 | image_url: URL to the artist's profile image 21 | """ 22 | 23 | name: str 24 | uri: str 25 | cover: str 26 | genres: List[str] = None 27 | popularity: int = None 28 | followers: int = None 29 | image_url: str = None 30 | 31 | def to_dict(self) -> dict: 32 | """Convert the artist object to a dictionary for serialization. 33 | 34 | Returns: 35 | dict: Dictionary representation of the artist with all metadata 36 | """ 37 | return { 38 | "name": self.name, 39 | "uri": self.uri, 40 | "genres": self.genres or [], 41 | "popularity": self.popularity, 42 | "followers": self.followers, 43 | "image_url": self.image_url, 44 | } 45 | -------------------------------------------------------------------------------- /testing/testing_theaudiodb_wrapper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from spotifysaver.services.the_audio_db_service import TheAudioDBService 3 | 4 | logging.basicConfig(level=logging.INFO) 5 | audiodb = TheAudioDBService() 6 | 7 | def get_artist_data(artist: str): 8 | artist = audiodb.get_artist_metadata(artist) 9 | return artist 10 | 11 | def get_album_data(artist: str, album: str): 12 | album = audiodb.get_album_metadata(artist, album) 13 | return album 14 | 15 | def get_track_data(artist: str, track: str): 16 | track = audiodb.get_track_metadata(track, artist) 17 | return track 18 | 19 | def get_tracks_from_album(album_id: str): 20 | tracks = audiodb._get_tracks_from_an_album(album_id) 21 | return tracks 22 | 23 | if __name__ == "__main__": 24 | artist = get_artist_data("Paramore") 25 | album = get_album_data("Paramore", "Paramore") 26 | #track = get_track_data("Queen", "Under Pressure") 27 | #album_id = album.get("idAlbum") 28 | #tracks = get_tracks_from_album(album_id) 29 | #for track in tracks: 30 | # print(f"{track.get('intTrackNumber')} - {track.get('strTrack')} - {track.get('strAlbum')} ({int(track.get('intDuration'))/1000}) - Genre: {track.get('strGenre')}") 31 | #print(f"Tipo: {type(track)}") 32 | #print("=================") 33 | #for item in track: 34 | # print(f"Item: {item} - Tipo: {type(item)}") 35 | print(f"Tipo: {type(album)}") 36 | print(album.model_dump_json(indent=2)) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/discussion.yml: -------------------------------------------------------------------------------- 1 | name: 💬 Discussion 2 | description: Start a discussion about a topic, proposal, or idea 3 | title: "[DISCUSSION] Brief description of topic" 4 | labels: ["discussion"] 5 | assignees: [] 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Use this template to start a discussion. Provide context and raise questions for the community. 12 | 13 | - type: input 14 | id: topic 15 | attributes: 16 | label: Discussion Topic 17 | description: Briefly describe the topic or proposal. 18 | placeholder: "Summarize the idea or issue to discuss..." 19 | 20 | - type: textarea 21 | id: motivation 22 | attributes: 23 | label: Motivation 24 | description: Why is this discussion relevant? What problem or improvement does it address? 25 | placeholder: "Explain the reason for raising this discussion..." 26 | 27 | - type: textarea 28 | id: context 29 | attributes: 30 | label: Additional Context 31 | description: Include documentation, links, examples, or diagrams if necessary. 32 | placeholder: "Provide supporting details, references, or examples..." 33 | 34 | - type: textarea 35 | id: questions 36 | attributes: 37 | label: Questions Raised 38 | description: What questions should be considered in this discussion? 39 | placeholder: | 40 | - What alternatives exist? 41 | - Does it affect the architecture? 42 | - Does it require significant changes? 43 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/inspect/album_info.py: -------------------------------------------------------------------------------- 1 | """Album Information Display Module. 2 | 3 | This module provides functionality to display comprehensive album information 4 | and metadata from Spotify albums, including tracklist details and technical 5 | information through the CLI interface. 6 | """ 7 | 8 | import click 9 | 10 | from spotifysaver.models import Album 11 | 12 | 13 | def show_album_info(album: Album, verbose: bool): 14 | """Display comprehensive album metadata and tracklist information. 15 | 16 | Shows formatted album information including name, artists, release date, 17 | complete tracklist with durations, and optionally technical details 18 | like genres when verbose mode is enabled. 19 | 20 | Args: 21 | album (Album): The album object containing metadata and tracks to display 22 | verbose (bool): Whether to show detailed technical information including 23 | genres and additional metadata 24 | """ 25 | click.secho(f"\n💿 Álbum: {album.name}", fg="magenta", bold=True) 26 | click.echo(f"👥 Artist(s): {', '.join(album.artists)}") 27 | click.echo(f"📅 Release date: {album.release_date}") 28 | click.echo(f"🎶 Tracks: {len(album.tracks)}") 29 | 30 | click.echo("Tracklist:") 31 | for track in album.tracks: 32 | click.echo( 33 | f" - {track.name} ({track.duration // 60}:{track.duration % 60:02d})" 34 | ) 35 | 36 | if verbose: 37 | click.echo(f"\n🔍 Detalles técnicos:") 38 | click.echo(f"Géneros: {', '.join(album.genres) if album.genres else 'N/A'}") 39 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/inspect/playlist_info.py: -------------------------------------------------------------------------------- 1 | """Playlist Information Display Module. 2 | 3 | This module provides functionality to display comprehensive playlist information 4 | and metadata from Spotify playlists, including owner details, description, 5 | and track count through the CLI interface. 6 | """ 7 | 8 | import click 9 | 10 | from spotifysaver.models import Playlist 11 | 12 | 13 | def show_playlist_info(playlist: Playlist, verbose: bool): 14 | """Display comprehensive playlist metadata and information. 15 | 16 | Shows formatted playlist information including name, creator/owner, 17 | description, track count, and optionally technical details like 18 | cover URL when verbose mode is enabled. 19 | 20 | Args: 21 | playlist (Playlist): The playlist object containing metadata to display 22 | verbose (bool): Whether to show detailed technical information including 23 | cover URL and additional metadata 24 | """ 25 | click.secho(f"\n🎧 Playlist: {playlist.name}", fg="green", bold=True) 26 | click.echo(f"🛠 Creator: {playlist.owner}") 27 | click.echo(f"📝 Description: {playlist.description or 'N/A'}") 28 | click.echo(f"🎵 Tracks: {len(playlist.tracks)}") 29 | 30 | click.echo("Tracklist:") 31 | for track in playlist.tracks: 32 | click.echo(f" - {track.name} by {', '.join(track.artists)} ({track.duration // 60}:{track.duration % 60:02d})") 33 | 34 | if verbose: 35 | click.echo(f"\n🔍 Detalles técnicos:") 36 | click.echo(f"URL de portada: {playlist.cover_url or 'N/A'}") 37 | -------------------------------------------------------------------------------- /spotifysaver/services/schemas/track_audiodb_schema.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import List, Optional 3 | 4 | class TrackADBResponse(BaseModel): 5 | """ 6 | Response Schema for Track metadata from TheAudioDB. 7 | 8 | keywords: 9 | id (int): ID of the track in TheAudioDB 10 | name (str): Name of the track 11 | album_id (int): ID of the album containing the track in TheAudioDB 12 | album_name (str): Name of the album containing the track 13 | artist_id (List[int]): List of IDs of the artists in TheAudioDB 14 | artist_name (List[str]): List of names of the artists 15 | duration (int): Duration of the track in seconds 16 | track_number (int): Track number in the album 17 | genre (Optional[str]): Genre of the track 18 | mood (Optional[str]): Mood of the track 19 | style (Optional[str]): Style of the track 20 | lyrics (Optional[str]): Lyrics of the track 21 | musicbrainz_id (Optional[str]): MusicBrainz ID of the track 22 | album_musicbrainz_id (Optional[str]): MusicBrainz ID of the album 23 | artist_musicbrainz_id (Optional[str]): MusicBrainz ID of the artist 24 | """ 25 | id: int 26 | name: str 27 | album_id: int 28 | album_name: str 29 | artist_id: List[int] 30 | artist_name: List[str] 31 | duration: int 32 | track_number: int 33 | genre: Optional[str] 34 | mood: Optional[str] 35 | style: Optional[str] 36 | lyrics: Optional[str] 37 | musicbrainz_id: Optional[str] 38 | album_musicbrainz_id: Optional[str] 39 | artist_musicbrainz_id: Optional[str] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/integration_request.yml: -------------------------------------------------------------------------------- 1 | name: 🔗 Integration Request 2 | description: Propose integration with an external service or API 3 | title: "[INTEGRATION] Brief description of service" 4 | labels: ["integration", "enhancement"] 5 | assignees: [] 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Please provide details about the service you want to integrate. 12 | 13 | - type: input 14 | id: service_name 15 | attributes: 16 | label: Service Name 17 | description: Name of the service to be integrated. 18 | placeholder: "e.g., Spotify, Last.fm, Goodreads..." 19 | 20 | - type: input 21 | id: service_api 22 | attributes: 23 | label: Official API/Link 24 | description: Provide the official API documentation or link. 25 | placeholder: "https://developer.spotify.com/documentation/web-api/" 26 | 27 | - type: textarea 28 | id: service_features 29 | attributes: 30 | label: Main Features 31 | description: What are the main features of this service? 32 | placeholder: "List the features relevant to integration..." 33 | 34 | - type: textarea 35 | id: justification 36 | attributes: 37 | label: Justification 38 | description: Why would this integration be useful? 39 | placeholder: "Explain the value or benefit of integrating this service..." 40 | 41 | - type: textarea 42 | id: technical_considerations 43 | attributes: 44 | label: Technical Considerations 45 | description: Dependencies, anticipated changes, or risks. 46 | placeholder: "Mention new dependencies, architectural changes, or other considerations..." 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/rfc.yml: -------------------------------------------------------------------------------- 1 | name: 📄 RFC (Request for Comments) 2 | description: Propose a significant technical or architectural change for review 3 | title: "[RFC] Brief description of proposal" 4 | labels: ["rfc", "discussion"] 5 | assignees: [] 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Please provide details for your RFC proposal. This template helps us evaluate technical changes. 12 | 13 | - type: textarea 14 | id: proposal 15 | attributes: 16 | label: Proposal 17 | description: Briefly describe the technical proposal. 18 | placeholder: "Summarize the change you are suggesting..." 19 | 20 | - type: textarea 21 | id: motivation 22 | attributes: 23 | label: Motivation 24 | description: Why do you believe this change is necessary? 25 | placeholder: "Explain the problem or need driving this proposal..." 26 | 27 | - type: textarea 28 | id: alternatives 29 | attributes: 30 | label: Alternatives Considered 31 | description: Mention other options and why they were discarded. 32 | placeholder: "List alternatives and reasons for rejection..." 33 | 34 | - type: textarea 35 | id: impact 36 | attributes: 37 | label: Impact 38 | description: Notes on migrations, backward compatibility, etc. 39 | placeholder: "Describe potential impacts on users, data, or compatibility..." 40 | 41 | - type: textarea 42 | id: implementation 43 | attributes: 44 | label: Implementation Proposal 45 | description: Brief action plan for implementing the change. 46 | placeholder: "Outline steps, phases, or tasks for implementation..." 47 | -------------------------------------------------------------------------------- /spotifysaver/__init__.py: -------------------------------------------------------------------------------- 1 | """SpotifySaver - Download Music from Spotify via YouTube Music. 2 | 3 | SpotifySaver is a comprehensive tool that allows users to download music from Spotify 4 | by finding equivalent tracks on YouTube Music. It preserves metadata, fetches lyrics, 5 | and organizes files in a structured manner compatible with media servers like Jellyfin. 6 | 7 | Features: 8 | - Download individual tracks, albums, and playlists from Spotify 9 | - Intelligent YouTube Music search with multiple matching strategies 10 | - Metadata preservation including artist, album, track info, and cover art 11 | - Lyrics fetching from LRCLib with synchronized timestamps 12 | - NFO file generation for media server compatibility 13 | - Organized file structure with proper naming conventions 14 | - Progress tracking and comprehensive logging 15 | - CLI interface with detailed inspection capabilities 16 | 17 | The application uses the Spotify Web API to fetch metadata and YouTube Music 18 | to source the actual audio files, ensuring high-quality downloads with complete 19 | metadata preservation. 20 | """ 21 | 22 | __version__ = "0.7.5" 23 | 24 | 25 | # Verify if ffmpeg is installed 26 | import subprocess 27 | 28 | 29 | def check_ffmpeg_installed(): 30 | """Check if ffmpeg is installed on the system.""" 31 | try: 32 | subprocess.run( 33 | ["ffmpeg", "-version"], 34 | stdout=subprocess.DEVNULL, 35 | stderr=subprocess.DEVNULL, 36 | check=True, 37 | ) 38 | return True 39 | except FileNotFoundError: 40 | return False 41 | 42 | 43 | if not check_ffmpeg_installed(): 44 | raise EnvironmentError( 45 | "ffmpeg is not installed. Please install ffmpeg to use SpotifySaver." 46 | ) 47 | -------------------------------------------------------------------------------- /testing/testing_spotify_api.py: -------------------------------------------------------------------------------- 1 | from spotifysaver.services.spotify_api import SpotifyAPI 2 | from spotifysaver.spotlog.logger import get_logger 3 | 4 | logger = get_logger("main") 5 | 6 | def download_album(album_url: str): 7 | api = SpotifyAPI() 8 | album = api.get_album(album_url) 9 | 10 | logger.info(f"Descargando álbum: {album.name} ({', '.join(album.artists)})") 11 | for track in album.tracks: 12 | print(f"{album.name} - #{track.number} - {track.name}") 13 | 14 | def download_track(track_url: str): 15 | api = SpotifyAPI() 16 | track = api.get_track(track_url) 17 | logger.info(f"Descargando track: {track.name} de {', '.join(track.artists)}") 18 | print(f"Track Duration: {track.duration} seconds") 19 | print(f"Track name: {track.name}") 20 | print(f"Track URI: {track.uri}") 21 | print(f"Track artists: {', '.join(track.artists)}") 22 | print(f"Track album: {track.album_name}") 23 | 24 | def download_artist(artist_url: str): 25 | api = SpotifyAPI() 26 | artist = api.get_artist(artist_url) 27 | 28 | logger.info(f"Descargando artista: {artist.name}") 29 | print(f"Artist Name: {artist.name}") 30 | print(f"Artist URI: {artist.uri}") 31 | print(f"Genres: {', '.join(artist.genres)}") 32 | print(f"Popularity: {artist.popularity}") 33 | print(f"Followers: {artist.followers}") 34 | print(f"Image URL: {artist.image_url}") 35 | 36 | if __name__ == "__main__": 37 | print("----\n") 38 | download_track("https://open.spotify.com/intl-es/track/15NOEMM5HitpLT2QLI5lHC") 39 | print("----\n") 40 | download_album("https://open.spotify.com/intl-es/album/4aoy2NnmDpWvjWi9taOHYe") 41 | print("----\n") 42 | download_artist("https://open.spotify.com/intl-es/artist/7jxJ25p0pPjk0MStloN6o6") 43 | print("----\n") -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' # Triggers on any tag 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | 26 | - name: Log in to Container Registry 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Extract metadata 34 | id: meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 38 | tags: | 39 | type=ref,event=tag 40 | type=semver,pattern={{version}} 41 | type=semver,pattern={{major}}.{{minor}} 42 | type=raw,value=latest,enable={{is_default_branch}} 43 | 44 | - name: Build and push Docker image 45 | uses: docker/build-push-action@v5 46 | with: 47 | context: . 48 | file: ./Dockerfile 49 | platforms: linux/amd64,linux/arm64 50 | push: true 51 | tags: ${{ steps.meta.outputs.tags }} 52 | labels: ${{ steps.meta.outputs.labels }} 53 | cache-from: type=gha 54 | cache-to: type=gha,mode=max 55 | build-args: | 56 | BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} 57 | VCS_REF=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rename this file to .gitignore and place in project root. 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | /.idea/ 5 | .idea/workspace.xml 6 | .idea/* 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | .idea/ 31 | venv/ 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # IPython Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # dotenv 84 | cookies.txt 85 | .env 86 | 87 | # cache 88 | .cache/ 89 | *.cache 90 | 91 | # virtualenv 92 | .venv/ 93 | venv/ 94 | ENV/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # Project Specific settings 103 | # Add here additional items that are specific to the individual project 104 | todo.txt 105 | build.sh 106 | publish.sh 107 | 108 | # Descargas 109 | downloads/ 110 | Music/ 111 | testing.py 112 | 113 | # docker 114 | docker-compose.override.yml -------------------------------------------------------------------------------- /spotifysaver/cli/commands/inspect/inspect.py: -------------------------------------------------------------------------------- 1 | """Inspect command for displaying Spotify metadata without downloading. 2 | 3 | This module provides the inspect command that fetches and displays detailed 4 | metadata about Spotify tracks, albums, or playlists without downloading any content. 5 | """ 6 | 7 | import click 8 | 9 | from spotifysaver.spotlog import LoggerConfig 10 | from spotifysaver.services import SpotifyAPI 11 | from spotifysaver.cli.commands.inspect.track_info import show_track_info 12 | from spotifysaver.cli.commands.inspect.album_info import show_album_info 13 | from spotifysaver.cli.commands.inspect.playlist_info import show_playlist_info 14 | 15 | 16 | @click.command("inspect") 17 | @click.argument("spotify_url") 18 | @click.option("--verbose", is_flag=True, help="Shows technical details") 19 | def inspect(spotify_url: str, verbose: bool): 20 | """Display detailed metadata of Spotify content without downloading. 21 | 22 | Fetches and displays comprehensive metadata about Spotify tracks, albums, 23 | or playlists including artist information, release dates, track listings, 24 | and technical details when verbose mode is enabled. 25 | 26 | Args: 27 | spotify_url: Spotify URL for track, album, or playlist 28 | verbose: Whether to show additional technical details 29 | """ 30 | LoggerConfig.setup(level="DEBUG" if verbose else "INFO") 31 | 32 | try: 33 | spotify = SpotifyAPI() 34 | 35 | if "track" in spotify_url: 36 | obj = spotify.get_track(spotify_url) 37 | show_track_info(obj, verbose) 38 | elif "album" in spotify_url: 39 | obj = spotify.get_album(spotify_url) 40 | show_album_info(obj, verbose) 41 | elif "playlist" in spotify_url: 42 | obj = spotify.get_playlist(spotify_url) 43 | show_playlist_info(obj, verbose) 44 | else: 45 | click.secho("⚠ Invalid URL. Must be a track, album, or playlist.", fg="red") 46 | 47 | except Exception as e: 48 | click.secho(f"Error: {str(e)}", fg="red", err=True) 49 | if verbose: 50 | import traceback 51 | 52 | traceback.print_exc() 53 | -------------------------------------------------------------------------------- /spotifysaver/services/schemas/artist_audiodb_schema.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import List, Optional 3 | 4 | class ArtistBiography(BaseModel): 5 | """ 6 | Response Schema for Artist metadata from TheAudioDB. 7 | 8 | keywords: 9 | language (str): Language of the biography 10 | biography (str): Biography of the artist 11 | """ 12 | language: str 13 | biography: str 14 | 15 | class MediaArtistURLs(BaseModel): 16 | """ 17 | Response Schema for Artist URLs from TheAudioDB. 18 | 19 | keywords: 20 | thumb_url (Optional[str]): URL of the artist thumbnail 21 | logo_url (Optional[str]): URL of the artist logo 22 | clearart_url (Optional[str]): URL of the artist clear art 23 | wide_thumb_url (Optional[str]): URL of the artist wide thumbnail 24 | banner_url (Optional[str]): URL of the artist banner 25 | fanarts (Optional[str]): URL of the artist fanart 26 | """ 27 | thumb_url: Optional[str] 28 | logo_url: Optional[str] 29 | clearart_url: Optional[str] 30 | wide_thumb_url: Optional[str] 31 | banner_url: Optional[str] 32 | fanarts: Optional[str] 33 | 34 | class ArtistADBResponse(BaseModel): 35 | """ 36 | Response Schema for Artist metadata from TheAudioDB. 37 | 38 | keywords: 39 | id (int): ID of the artist in TheAudioDB 40 | name (str): Name of the artist 41 | gender (Optional[str]): Gender of the artist 42 | country (Optional[str]): Country of the artist 43 | born_year (Optional[int]): Year of birth of the artist 44 | die_year (Optional[int]): Year of death of the artist 45 | style (Optional[str]): Style of the artist 46 | genre (Optional[str]): Genre of the artist 47 | mood (Optional[str]): Mood of the artist 48 | musicbrainz_id (Optional[str]): MusicBrainz ID of the artist 49 | media_artist (Optional[MediaArtistURLs]): URLs of the artist media 50 | biography (Optional[ArtistBiography]): Biography of the artist 51 | """ 52 | id: int 53 | name: str 54 | gender: Optional[str] 55 | country: Optional[str] 56 | born_year: Optional[int] 57 | die_year: Optional[int] 58 | style: Optional[str] 59 | genre: Optional[str] 60 | mood: Optional[str] 61 | musicbrainz_id: Optional[str] 62 | media_artist: Optional[MediaArtistURLs] 63 | briographies: Optional[List[ArtistBiography]] -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 📘 CONTRIBUTING.md — Guía para contribuir a CERCAPP 2 | 3 | Welcome to Spotifysaver. This document establishes some conventions and good practices for contributing to the project in an organized and collaborative way. Suggestions and improvements are welcome! 4 | 5 | --- 6 | 7 | ### 🧰 Requirements 8 | Before you begin, make sure you have installed: 9 | 10 | - Python (> 3.9) 11 | - Poetry (https://python-poetry.org/) 12 | - Git (https://git-scm.com/) 13 | - An editor like VS Code (it is recommended to use the shared configuration in `.vscode/`) 14 | 15 | --- 16 | 17 | ### 🌱 Basic workflow 18 | 19 | 1. **Clone the repository**: 20 | ```bash 21 | git clone git@github.com:gabrielbaute/spotify-saver.git 22 | cd spotify-saver 23 | ``` 24 | 25 | 2. **Create a branch for your contribution**: 26 | Use a clear convention: 27 | - `ft-feature-name` → new feature 28 | - `fix-bug-name` → bug fix 29 | - `refactor-module-name` → code improvement 30 | - `doc-section-name` → documentation 31 | 32 | Example: 33 | ```bash 34 | git checkout -b ft-fix-single-track-download-error 35 | ``` 36 | 37 | 3. **Make clear and atomic commits**: 38 | - Use the present tense: `Add Spotify link validation` 39 | - Avoid generic commits like `miscellaneous changes` 40 | 41 | 4. **Sync with `main` before doing PR**: 42 | ```bash 43 | git fetch origin 44 | git rebase origin/main 45 | ``` 46 | 47 | 5. **Open a Pull Request**: 48 | - Use the automatic template 49 | - Match the PR to the corresponding issue (if applies) 50 | - Make sure you pass the tests and validations (if applies) 51 | 52 | --- 53 | 54 | ### 🧪 Validations and Testing 55 | 56 | Before submitting your PR: 57 | 58 | - Verify that the current flow is not broken 59 | - If you modify critical logic, document the changes 60 | - If you add features, consider writing tests (although we don't currently use unit tests, we plan to move towards testability) 61 | 62 | --- 63 | 64 | ### 🧭 Good Practices 65 | 66 | - Maintain modularity: separate logic, validations, and views 67 | - Document new features and services 68 | - Use enums or constants for roles, states, and permissions 69 | - Avoid unnecessary coupling between frontend and backend 70 | - If you refactor, explain why in the pull request 71 | 72 | --- 73 | 74 | ### 📌 Enlaces útiles 75 | 76 | - [README del proyecto](./README.md) 77 | - [Plantillas de Issues](.github/ISSUE_TEMPLATE/) 78 | - [Plantilla de Pull Request](.github/PULL_REQUEST_TEMPLATE.md) -------------------------------------------------------------------------------- /spotifysaver/cli/commands/log/log.py: -------------------------------------------------------------------------------- 1 | """Log Display Command Module. 2 | 3 | This module provides CLI functionality to display and filter application log files, 4 | allowing users to view recent log entries, filter by log level, and get log file 5 | path information for debugging and monitoring purposes. 6 | """ 7 | 8 | from pathlib import Path 9 | from typing import Optional 10 | 11 | import click 12 | 13 | from spotifysaver.spotlog import LoggerConfig # Import configuration 14 | 15 | 16 | @click.command("show-log") 17 | @click.option( 18 | "--lines", type=int, default=10, help="Number of lines to display (default: 10)" 19 | ) 20 | @click.option( 21 | "--level", 22 | type=click.Choice( 23 | ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False 24 | ), 25 | help="Filter by log level", 26 | ) 27 | @click.option( 28 | "--path", 29 | is_flag=True, 30 | help="Show only the path of the log file (no content will be displayed)", 31 | ) 32 | def show_log(lines: int, level: Optional[str], path: bool): 33 | """Display the last lines of the application log file with optional filtering. 34 | 35 | This command provides access to application logs with filtering capabilities 36 | by log level and line count. It can also display just the log file path 37 | for external log viewing tools. 38 | 39 | Args: 40 | lines (int): Number of recent log lines to display (default: 10) 41 | level (Optional[str]): Filter logs by specific level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 42 | path (bool): If True, only display the log file path without content 43 | """ 44 | log_file = Path(LoggerConfig.get_log_path()) 45 | 46 | if path: 47 | click.echo(f"📁 Log path: {log_file.absolute()}") 48 | return 49 | 50 | if not log_file.exists(): 51 | click.secho(f"⚠ Log file not found at: {log_file.absolute()}", fg="yellow") 52 | return 53 | 54 | try: 55 | with open(log_file, "r", encoding="latin-1") as f: 56 | all_lines = f.readlines() # Filter by level if specified 57 | filtered_lines = ( 58 | [line for line in all_lines if not level or f"[{level.upper()}]" in line] 59 | if level 60 | else all_lines 61 | ) 62 | 63 | # Display the last N lines 64 | last_n_lines = filtered_lines[-lines:] if lines > 0 else filtered_lines 65 | click.echo_via_pager("".join(last_n_lines)) 66 | 67 | except Exception as e: 68 | click.secho(f"❌ Error reading the log file: {str(e)}", fg="red") 69 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/init.py: -------------------------------------------------------------------------------- 1 | """ 2 | Init command to configure environment variables for SpotifySaver. 3 | """ 4 | 5 | from pathlib import Path 6 | import click 7 | 8 | 9 | @click.command("init", short_help="Initialize SpotifySaver configuration") 10 | def init(): 11 | """Initialize SpotifySaver configuration by setting up environment variables.""" 12 | 13 | # Create the .spotify-saver directory in user's home 14 | config_dir = Path.home() / ".spotify-saver" 15 | config_dir.mkdir(exist_ok=True) 16 | 17 | env_file = config_dir / ".env" 18 | 19 | click.echo("🎵 SpotifySaver Configuration Setup") 20 | click.echo("=" * 40) 21 | 22 | # Prompt for environment variables 23 | spotify_client_id = click.prompt("Enter your Spotify Client ID", type=str) 24 | spotify_client_secret = click.prompt( 25 | "Enter your Spotify Client Secret", type=str, hide_input=True 26 | ) 27 | spotify_redirect_uri = click.prompt( 28 | "Enter your Spotify Redirect URI", 29 | default="http://localhost:8888/callback", 30 | type=str, 31 | ) 32 | 33 | # Optional variables with defaults 34 | download_path = click.prompt( 35 | "Enter download path", 36 | default=str(Path.home() / "Downloads" / "SpotifySaver"), 37 | type=str, 38 | ) 39 | 40 | youtube_dl_cookies_path = click.prompt( 41 | "Enter YouTube DL cookies path (optional)", 42 | default="cookies.txt", 43 | type=str, 44 | ) 45 | 46 | api_port = click.prompt( 47 | "Enter API port", 48 | default=8000, 49 | type=int, 50 | ) 51 | api_host = click.prompt( 52 | "Enter API host", 53 | default="0.0.0.0", 54 | type=str, 55 | ) 56 | 57 | # Write to .env file 58 | env_content = f"""# SpotifySaver Configuration generated by init command 59 | SPOTIFY_REDIRECT_URI={spotify_redirect_uri} 60 | SPOTIFY_CLIENT_ID={spotify_client_id} 61 | SPOTIFY_CLIENT_SECRET={spotify_client_secret} 62 | SPOTIFYSAVER_OUTPUT_DIR={download_path} 63 | YTDLP_COOKIES_PATH={youtube_dl_cookies_path or ""} 64 | API_PORT={api_port} 65 | API_HOST={api_host} 66 | """ 67 | 68 | with open(env_file, "w", encoding="utf-8") as f: 69 | f.write(env_content) 70 | # Set restrictive permissions (chmod 600) to the .env file 71 | import os 72 | os.chmod(env_file, 0o600) 73 | 74 | click.echo(f"\n✅ Configuration saved to: {env_file}") 75 | click.echo("You can now run the SpotifySaver commands with your configured settings.") 76 | click.echo("You can run init again to update settings or create a new configuration.") 77 | -------------------------------------------------------------------------------- /spotifysaver/services/schemas/album_audiodb_schema.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import List, Optional 3 | 4 | class AlbumDescription(BaseModel): 5 | """ 6 | Response Schema for Album metadata from TheAudioDB. 7 | 8 | keywords: 9 | language (str): Language of the description 10 | description (str): Description of the album 11 | """ 12 | language: str 13 | description: str 14 | 15 | class MediaAlbumURLs(BaseModel): 16 | """ 17 | Response Schema for Album URLs from TheAudioDB. 18 | 19 | keywords: 20 | thumb_url (Optional[str]): URL of the album thumbnail 21 | back_url (Optional[str]): URL of the album back cover 22 | cd_art_url (Optional[str]): URL of the album CD art 23 | case_url (Optional[str]): URL of the album 3D case 24 | face_url (Optional[str]): URL of the album face 25 | flat_url (Optional[str]): URL of the album 3D flat 26 | cd_thumb_url (Optional[str]): URL of the album 3D thumbnail 27 | """ 28 | thumb_url: Optional[str] 29 | back_url: Optional[str] 30 | cd_art_url: Optional[str] 31 | case_url: Optional[str] 32 | face_url: Optional[str] 33 | flat_url: Optional[str] 34 | cd_thumb_url: Optional[str] 35 | 36 | class AlbumADBResponse(BaseModel): 37 | """ 38 | Response Schema for Album metadata from TheAudioDB. 39 | 40 | keywords: 41 | id (int): ID of the album in TheAudioDB 42 | name (str): Name of the album 43 | artist_id (int): ID of the artist in TheAudioDB 44 | artist_name (str): Name of the artist 45 | genre (Optional[str]): Genre of the album 46 | style (Optional[str]): Style of the album 47 | mood (Optional[str]): Mood of the album 48 | musicbrainz_id (Optional[str]): MusicBrainz ID of the album 49 | artist_musicbrainz_id (Optional[str]): MusicBrainz ID of the artist 50 | release_format (Optional[str]): Release format of the album 51 | release_date (Optional[str]): Release date of the album 52 | description (Optional[List[AlbumDescription]]): List of descriptions of the album 53 | media_album (Optional[MediaAlbumURLs]): URLs of the album media 54 | """ 55 | id: int 56 | name: str 57 | artist_id: int 58 | artist_name: str 59 | genre: Optional[str] 60 | style: Optional[str] 61 | mood: Optional[str] 62 | musicbrainz_id: Optional[str] 63 | artist_musicbrainz_id: Optional[str] 64 | release_format: Optional[str] 65 | release_date: Optional[str] 66 | description: Optional[List[AlbumDescription]] 67 | media_album: Optional[MediaAlbumURLs] -------------------------------------------------------------------------------- /spotifysaver/downloader/image_downloader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | from pathlib import Path 4 | from typing import Optional 5 | from spotifysaver.config import Config 6 | from spotifysaver.spotlog import get_logger 7 | 8 | class ImageDownloader: 9 | """Downloads images from URLs.""" 10 | 11 | def __init__(self): 12 | """Initialize the ImageDownloader.""" 13 | self.logger = get_logger(f"{self.__class__.__name__}") 14 | 15 | def download_image(self, url: str, output_path: Path) -> Optional[Path]: 16 | """Download an image from a URL and save it to a specified path. 17 | 18 | Args: 19 | url: The URL of the image to download. 20 | output_path: The full path (including filename and extension) 21 | where the image should be saved. 22 | 23 | Returns: 24 | Path: The path to the downloaded image if successful, None otherwise. 25 | """ 26 | if not url: 27 | self.logger.warning("No URL provided for image download.") 28 | return None 29 | 30 | try: 31 | response = requests.get(url, timeout=Config.DOWNLOAD_TIMEOUT) 32 | response.raise_for_status() # Raise an exception for HTTP errors 33 | 34 | output_path.parent.mkdir(parents=True, exist_ok=True) 35 | output_path.write_bytes(response.content) 36 | self.logger.debug(f"Image downloaded successfully to: {output_path}") 37 | return output_path 38 | except requests.exceptions.RequestException as e: 39 | self.logger.error(f"Error downloading image from {url}: {e}") 40 | return None 41 | except Exception as e: 42 | self.logger.error(f"An unexpected error occurred while downloading image from {url}: {e}") 43 | return None 44 | 45 | def get_image_from_url(self, url: str) -> Optional[bytes]: 46 | """Get an image from a URL. 47 | 48 | Args: 49 | url: The URL of the image to download. 50 | 51 | Returns: 52 | bytes: The image data if successful, None otherwise. 53 | """ 54 | try: 55 | response = requests.get(url, timeout=Config.DOWNLOAD_TIMEOUT) 56 | response.raise_for_status() # Raise an exception for HTTP errors 57 | self.logger.debug(f"Image downloaded successfully from {url}") 58 | return response.content if response.status_code == 200 else None 59 | except requests.exceptions.RequestException as e: 60 | self.logger.error(f"Error downloading image from {url}: {e}") 61 | return None 62 | -------------------------------------------------------------------------------- /spotifysaver/config/setting_environment.py: -------------------------------------------------------------------------------- 1 | """Application configuration module for environment settings and validation. 2 | 3 | This module handles loading environment variables from .env files and provides 4 | configuration settings for the SpotifySaver application. 5 | """ 6 | 7 | import os 8 | from pathlib import Path 9 | from dotenv import load_dotenv 10 | 11 | 12 | def load_config(): 13 | """Load configuration from ~/.spotify-saver/.env file with fallback.""" 14 | # Primary: Load from ~/.spotify-saver/.env (created by init command) 15 | config_dir = Path.home() / ".spotify-saver" 16 | env_file = config_dir / ".env" 17 | 18 | if env_file.exists(): 19 | load_dotenv(env_file) 20 | else: 21 | # Fallback: Load from project root .env (backward compatibility) 22 | env_path = Path(__file__).resolve().parents[2] / ".env" 23 | load_dotenv(env_path) 24 | 25 | 26 | # Load configuration when module is imported 27 | load_config() 28 | 29 | 30 | class Config: 31 | """Configuration class for managing application settings. 32 | 33 | This class loads and manages configuration settings from environment variables, 34 | including Spotify API credentials, logging levels, and YouTube download settings. 35 | 36 | Attributes: 37 | SPOTIFY_CLIENT_ID: Spotify API client ID from environment 38 | SPOTIFY_CLIENT_SECRET: Spotify API client secret from environment 39 | SPOTIFY_REDIRECT_URI: OAuth redirect URI for Spotify authentication 40 | LOG_LEVEL: Application logging level (default: 'info') 41 | YTDLP_COOKIES_PATH: Path to YouTube Music cookies file for age-restricted content 42 | """ 43 | 44 | SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") 45 | SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") 46 | SPOTIFY_REDIRECT_URI = os.getenv( 47 | "SPOTIFY_REDIRECT_URI", "http://localhost:8888/callback" 48 | ) 49 | 50 | # Logger configuration 51 | LOG_LEVEL = os.getenv("LOG_LEVEL", "info").lower() 52 | 53 | # YouTube cookies file for bypassing age restrictions 54 | YTDLP_COOKIES_PATH = os.getenv("YTDLP_COOKIES_PATH", None) 55 | 56 | # Default output directory 57 | OUTPUT_DIR = os.getenv("SPOTIFYSAVER_OUTPUT_DIR", "Music") 58 | 59 | # Downloader configuration 60 | DOWNLOAD_TIMEOUT = os.getenv("DOWNLOAD_TIMEOUT", 10) 61 | 62 | @classmethod 63 | def validate(cls): 64 | """Validate that critical environment variables are configured. 65 | 66 | Raises: 67 | ValueError: If required Spotify API credentials are missing 68 | """ 69 | if not cls.SPOTIFY_CLIENT_ID or not cls.SPOTIFY_CLIENT_SECRET: 70 | raise ValueError("Spotify API credentials missing in .env file") 71 | -------------------------------------------------------------------------------- /spotifysaver/spotlog/log_config.py: -------------------------------------------------------------------------------- 1 | """Module for configuring logging in the Spotify Saver application.""" 2 | 3 | from typing import Optional 4 | 5 | import logging 6 | import os 7 | 8 | from spotifysaver.config import Config 9 | 10 | 11 | class LoggerConfig: 12 | """Configuration class for the application logging system. 13 | 14 | This class manages logging configuration including file paths, log levels, 15 | and handler setup for both file and console output. 16 | 17 | Attributes: 18 | LOG_DIR: Directory where log files are stored 19 | LOG_FILE: Path to the main application log file 20 | """ 21 | 22 | LOG_DIR = "logs" 23 | LOG_FILE = os.path.join(LOG_DIR, "app.log") 24 | 25 | @classmethod 26 | def get_log_path(cls) -> str: 27 | """Get the absolute path to the log file. 28 | 29 | Returns: 30 | str: Absolute path to the application log file 31 | """ 32 | return os.path.abspath(cls.LOG_FILE) 33 | 34 | @classmethod 35 | def get_log_level(cls) -> int: 36 | """Get the logging level from environment variables. 37 | 38 | Returns: 39 | int: Logging level constant from the logging module 40 | """ 41 | level_map = { 42 | "debug": logging.DEBUG, 43 | "info": logging.INFO, 44 | "warning": logging.WARNING, 45 | "error": logging.ERROR, 46 | "critical": logging.CRITICAL, 47 | } 48 | level_str = Config.LOG_LEVEL 49 | return level_map.get(level_str, logging.INFO) 50 | 51 | @classmethod 52 | def setup(cls, level: Optional[int] = None): 53 | """Initialize the logging system with file and console handlers. 54 | 55 | Sets up logging configuration with appropriate formatters and handlers. 56 | Creates the log directory if it doesn't exist and configures both 57 | file logging and optional console output for debug mode. 58 | 59 | Args: 60 | level: Optional logging level override. If None, uses environment setting 61 | """ 62 | os.makedirs(cls.LOG_DIR, exist_ok=True) 63 | 64 | log_level = level if level is not None else cls.get_log_level() 65 | 66 | logging.basicConfig( 67 | level=log_level, 68 | format="%(asctime)s [%(levelname)s] [%(name)s]: %(message)s", 69 | handlers=[ 70 | logging.FileHandler(cls.LOG_FILE, encoding='utf-8'), 71 | ( 72 | logging.StreamHandler() 73 | if log_level == logging.DEBUG 74 | else logging.NullHandler() 75 | ), 76 | ], 77 | ) 78 | logging.info(f"Logging configured at level: {logging.getLevelName(log_level)}") 79 | -------------------------------------------------------------------------------- /spotifysaver/api/app.py: -------------------------------------------------------------------------------- 1 | """FastAPI Application Factory""" 2 | 3 | from pathlib import Path 4 | from fastapi import FastAPI 5 | from fastapi.staticfiles import StaticFiles 6 | from fastapi.responses import FileResponse 7 | from fastapi.middleware.cors import CORSMiddleware 8 | 9 | from .routers import download 10 | from .config import APIConfig 11 | from .. import __version__ 12 | 13 | # Get the absolute path to the UI directory 14 | UI_DIR = Path(__file__).parent.parent / "ui" 15 | STATIC_DIR = UI_DIR / "static" 16 | INDEX_HTML = UI_DIR / "index.html" 17 | 18 | def create_app() -> FastAPI: 19 | """Create and configure the FastAPI application. 20 | 21 | Returns: 22 | FastAPI: Configured FastAPI application instance 23 | """ 24 | app = FastAPI( 25 | title="SpotifySaver API", 26 | description="Download music from Spotify via YouTube Music with metadata preservation", 27 | version=__version__, 28 | docs_url="/docs", 29 | redoc_url="/redoc", 30 | ) 31 | 32 | # Mount static files (only if directory exists) 33 | if STATIC_DIR.exists(): 34 | app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") 35 | 36 | # Configure CORS 37 | app.add_middleware( 38 | CORSMiddleware, 39 | allow_origins=APIConfig.ALLOWED_ORIGINS, 40 | allow_credentials=True, 41 | allow_methods=["*"], 42 | allow_headers=["*"], 43 | ) 44 | 45 | # Include routers 46 | app.include_router(download.router, prefix="/api/v1", tags=["download"]) 47 | 48 | # Serve the main HTML file 49 | @app.get("/", tags=["UI"]) 50 | async def read_index(): 51 | """Serve the main index.html file for the UI.""" 52 | if INDEX_HTML.exists(): 53 | return FileResponse(str(INDEX_HTML)) 54 | return {"message": "UI not available", "docs": "/docs"} 55 | 56 | @app.get("/api/v1/", tags=["Info"]) 57 | async def root(): 58 | """API root endpoint providing basic info. 59 | name: SpotifySaver API 60 | version: Current version of the API 61 | description: Brief description of the API 62 | """ 63 | return { 64 | "name": "SpotifySaver API", 65 | "version": __version__, 66 | "description": "Download music from Spotify via YouTube Music", 67 | "docs": "/docs", 68 | "redoc": "/redoc", 69 | } 70 | 71 | @app.get("/health", tags=["Info"]) 72 | async def health_check(): 73 | """Health check endpoint to verify API is running.""" 74 | return {"status": "healthy", "service": "SpotifySaver API"} 75 | 76 | @app.get("/version", tags=["Info"]) 77 | async def get_version(): 78 | """Get the current version of the API.""" 79 | return {"version": __version__} 80 | 81 | return app 82 | -------------------------------------------------------------------------------- /spotifysaver/services/errors/errors.py: -------------------------------------------------------------------------------- 1 | """Custom exception classes for API errors in Spotify Saver. 2 | 3 | This module defines custom exception classes for handling various types of API 4 | errors that can occur when interacting with external services like Spotify, 5 | YouTube Music, and LRC Lib. 6 | """ 7 | 8 | 9 | class APIError(Exception): 10 | """Base exception class for all API-related errors. 11 | 12 | This is the parent class for all API exceptions in the application. 13 | It provides a common interface for error handling with optional status codes. 14 | 15 | Attributes: 16 | status_code: HTTP status code associated with the error (if applicable) 17 | """ 18 | 19 | def __init__(self, message: str, status_code: int = None): 20 | """Initialize the APIError with a message and optional status code. 21 | 22 | Args: 23 | message: Human-readable error message 24 | status_code: HTTP status code (optional) 25 | """ 26 | self.status_code = status_code 27 | super().__init__(message) 28 | 29 | 30 | class SpotifyAPIError(APIError): 31 | """Exception for Spotify Web API specific errors. 32 | 33 | Raised when errors occur while interacting with the Spotify Web API, 34 | such as authentication failures, invalid requests, or service unavailability. 35 | """ 36 | 37 | 38 | class YouTubeAPIError(APIError): 39 | """Exception for YouTube Music API specific errors. 40 | 41 | Raised when errors occur while searching or downloading from YouTube Music, 42 | including search failures, download errors, or API quota exceeded. 43 | """ 44 | 45 | 46 | class RateLimitExceeded(APIError): 47 | """Exception for HTTP 429 Too Many Requests errors. 48 | 49 | Raised when API rate limits are exceeded for any service. Includes 50 | information about which service triggered the limit and retry timing. 51 | """ 52 | 53 | def __init__(self, service: str, retry_after: int = None): 54 | """Initialize rate limit exception with service and retry information. 55 | 56 | Args: 57 | service: Name of the service that triggered the rate limit 58 | retry_after: Suggested retry delay in seconds (optional) 59 | """ 60 | message = f"Rate limit exceeded for {service}" 61 | if retry_after: 62 | message += f". Retry after {retry_after} seconds" 63 | super().__init__(message, 429) 64 | 65 | 66 | class AlbumNotFoundError(APIError): 67 | """Exception raised when a requested album cannot be found. 68 | 69 | This error occurs when trying to fetch album data that doesn't exist 70 | or is not accessible through the API. 71 | """ 72 | 73 | pass 74 | 75 | 76 | class InvalidResultError(APIError): 77 | """Exception raised when API returns unexpected or malformed data. 78 | 79 | This error occurs when the API response doesn't match the expected format 80 | or contains invalid data that cannot be processed. 81 | """ 82 | 83 | pass 84 | -------------------------------------------------------------------------------- /spotifysaver/models/track.py: -------------------------------------------------------------------------------- 1 | """Track model for SpotifySaver.""" 2 | 3 | from dataclasses import dataclass, replace 4 | from typing import List, Optional 5 | 6 | 7 | @dataclass(frozen=True) 8 | class Track: 9 | """Represents an individual track with its metadata. 10 | 11 | This class encapsulates all the information about a music track including 12 | basic metadata, source information, and optional features like lyrics. 13 | 14 | Attributes: 15 | number: Track number in the album/playlist 16 | total_tracks: Total number of tracks in the source collection 17 | name: The name/title of the track 18 | duration: Duration of the track in seconds 19 | uri: Spotify URI for the track 20 | artists: List of artist names 21 | release_date: Release date of the track/album 22 | disc_number: Disc number (for multi-disc albums) 23 | source_type: Type of source ("album" or "playlist") 24 | playlist_name: Name of the playlist if source is playlist 25 | genres: List of genres associated with the track 26 | album_name: Name of the album containing this track 27 | cover_url: URL to the cover art image 28 | has_lyrics: Whether lyrics have been successfully downloaded 29 | """ 30 | 31 | number: int 32 | total_tracks: int 33 | name: str 34 | duration: int 35 | uri: str 36 | artists: List[str] 37 | album_artist: List[str] 38 | release_date: str 39 | disc_number: int = 1 40 | source_type: str = "album" 41 | playlist_name: Optional[str] = None 42 | genres: List[str] = None 43 | album_name: str = None 44 | cover_url: str = None 45 | has_lyrics: bool = False 46 | 47 | def __hash__(self): 48 | """Generate hash based on track name, artists, and duration. 49 | 50 | Returns: 51 | int: Hash value for the track instance 52 | """ 53 | return hash((self.name, tuple(self.artists), self.duration)) 54 | 55 | def with_lyrics_status(self, success: bool) -> "Track": 56 | """Return a new Track instance with updated lyrics status. 57 | 58 | Args: 59 | success: Whether lyrics were successfully downloaded 60 | 61 | Returns: 62 | Track: New Track instance with updated has_lyrics field 63 | """ 64 | return replace(self, has_lyrics=success) 65 | 66 | @property 67 | def lyrics_filename(self) -> str: 68 | """Generate a safe filename for LRC lyrics files. 69 | 70 | Returns: 71 | str: Safe filename with .lrc extension 72 | """ 73 | return f"{self.name.replace('/', '-')}.lrc" 74 | 75 | def to_dict(self) -> dict: 76 | """Convert track to dictionary format. 77 | 78 | Returns: 79 | dict: Dictionary representation of the track with lyrics_available field 80 | """ 81 | return { 82 | **{k: v for k, v in self.__dict__.items() if k != "has_lyrics"}, 83 | "lyrics_available": self.has_lyrics, 84 | } 85 | -------------------------------------------------------------------------------- /spotifysaver/ui/README.md: -------------------------------------------------------------------------------- 1 | # SpotifySaver UI - Modular Architecture 2 | 3 | This directory contains the frontend for SpotifySaver, organized into modular JavaScript files for better maintainability. 4 | 5 | ## File Structure 6 | 7 | ### Core Modules 8 | 9 | 1. **`app.js`** - Main Application Controller 10 | - Initializes and coordinates all other modules 11 | - Handles application lifecycle and state persistence 12 | - Entry point for the application 13 | 14 | 2. **`api-client.js`** - API Communication Layer 15 | - Handles all HTTP requests to the SpotifySaver API 16 | - Includes retry mechanisms and timeout handling 17 | - Methods: health checks, inspect URLs, start downloads, get status 18 | 19 | 3. **`state-manager.js`** - State Persistence Manager 20 | - Manages localStorage operations 21 | - Handles saving/loading application state 22 | - Preserves download progress across page reloads 23 | 24 | 4. **`download-manager.js`** - Download Process Controller 25 | - Orchestrates the download workflow 26 | - Manages download progress monitoring 27 | - Handles track state management and progress simulation 28 | 29 | 5. **`ui-manager.js`** - UI Updates & Visualization 30 | - Controls all DOM updates and visual feedback 31 | - Manages logs, progress indicators, and track status icons 32 | - Handles rendering of inspection data 33 | 34 | ### Supporting Files 35 | 36 | - **`index.html`** - Main HTML structure 37 | - **`styles.css`** - CSS styling 38 | - **`script.js.backup`** - Original monolithic script (backup) 39 | 40 | ## Dependencies 41 | 42 | The modules are loaded in dependency order: 43 | ```html 44 | 45 | 46 | 47 | 48 | 49 | ``` 50 | 51 | ## Key Features Preserved 52 | 53 | - ✅ SPA routing support 54 | - ✅ Download progress monitoring 55 | - ✅ State persistence across reloads 56 | - ✅ Log deduplication with cooldown 57 | - ✅ Track status icons with real-time updates 58 | - ✅ Error handling and retry mechanisms 59 | - ✅ API connectivity checks 60 | - ✅ Responsive UI updates 61 | 62 | ## Architecture Benefits 63 | 64 | - **Separation of Concerns**: Each module has a single responsibility 65 | - **Maintainability**: Easier to modify specific functionality 66 | - **Testability**: Individual modules can be tested in isolation 67 | - **Readability**: Smaller, focused files are easier to understand 68 | - **Scalability**: New features can be added as separate modules 69 | 70 | ## Module Communication 71 | 72 | - **App.js** orchestrates all modules and passes callbacks for state synchronization 73 | - **Download Manager** uses UI Manager for visual updates 74 | - **All modules** can trigger state saves through callback functions 75 | - **Error handling** is centralized in the main app controller 76 | 77 | ## Usage 78 | 79 | The application auto-initializes when the page loads via: 80 | ```javascript 81 | document.addEventListener('DOMContentLoaded', () => { 82 | new SpotifySaverUI(); 83 | }); 84 | ``` 85 | 86 | No manual initialization required. -------------------------------------------------------------------------------- /spotifysaver/services/lrclib_api.py: -------------------------------------------------------------------------------- 1 | """LRC Lib API Client.""" 2 | 3 | from typing import Optional, Dict 4 | 5 | import requests 6 | 7 | from spotifysaver.models import Track 8 | from spotifysaver.spotlog import get_logger 9 | from spotifysaver.services.errors.errors import APIError 10 | 11 | 12 | class LrclibAPI: 13 | """LRC Lib API client for fetching synchronized lyrics. 14 | 15 | This class provides an interface to the LRC Lib API for retrieving 16 | synchronized and plain lyrics for music tracks. 17 | 18 | Attributes: 19 | BASE_URL: Base URL for the LRC Lib API 20 | session: HTTP session for making requests """ 21 | 22 | BASE_URL = "https://lrclib.net/api" 23 | 24 | def __init__(self): 25 | """Initialize the LRC Lib API client. 26 | 27 | Sets up the HTTP session with appropriate timeout settings. 28 | """ 29 | self.session = requests.Session() 30 | self.logger = get_logger(f"{self.__class__.__name__}") 31 | self.session.timeout = 10 # 10 seconds timeout 32 | 33 | def get_lyrics(self, track: Track, synced: bool = True) -> Optional[str]: 34 | """Get synchronized or plain lyrics for a track. 35 | 36 | Args: 37 | track: Track object with metadata for lyrics search 38 | synced: If True, returns synchronized lyrics (.lrc format) 39 | 40 | Returns: 41 | str: Lyrics in requested format, or None if not found/error occurred 42 | 43 | Raises: 44 | APIError: If there's an error with the API request 45 | """ 46 | try: 47 | params = { 48 | "track_name": track.name, 49 | "artist_name": track.artists[0], 50 | "album_name": track.album_name, 51 | "duration": int(track.duration), 52 | } 53 | 54 | response = self.session.get( 55 | f"{self.BASE_URL}/get", 56 | params=params, 57 | headers={"Accept": "application/json"}, 58 | ) 59 | 60 | if response.status_code == 404: 61 | self.logger.debug(f"Lyrics not found for: {track.name}") 62 | return None 63 | 64 | response.raise_for_status() 65 | data = response.json() 66 | 67 | lyric_type = "syncedLyrics" if synced else "plainLyrics" 68 | self.logger.info(f"Song lyrics obtained: {lyric_type}") 69 | return data.get(lyric_type) 70 | 71 | except requests.exceptions.RequestException as e: 72 | self.logger.error(f"Error in the LRC Lib API: {str(e)}") 73 | raise APIError(f"LRC Lib API error: {str(e)}") 74 | except Exception as e: 75 | self.logger.error(f"Unexpected error: {str(e)}") 76 | raise APIError(f"Unexpected error: {str(e)}") 77 | 78 | def get_lyrics_with_fallback(self, track: Track) -> Optional[str]: 79 | """Attempt to get synchronized lyrics, fallback to plain lyrics if failed. 80 | 81 | Args: 82 | track: Track object with metadata for lyrics search 83 | 84 | Returns: 85 | str: Lyrics (synchronized preferred, plain as fallback), or None if unavailable 86 | """ 87 | try: 88 | return self.get_lyrics(track, synced=True) or self.get_lyrics( 89 | track, synced=False 90 | ) 91 | except APIError: 92 | return None 93 | -------------------------------------------------------------------------------- /spotifysaver/api/schemas.py: -------------------------------------------------------------------------------- 1 | """Pydantic schemas for API requests and responses""" 2 | 3 | from typing import List, Optional 4 | from pydantic import BaseModel, HttpUrl, Field 5 | 6 | 7 | class DownloadRequest(BaseModel): 8 | """Schema for download request.""" 9 | 10 | spotify_url: HttpUrl = Field( 11 | ..., 12 | description="Spotify URL for track, album, or playlist", 13 | example="https://open.spotify.com/track/2kd0T6zgABT8P0s2h9QU5O", 14 | ) 15 | download_lyrics: bool = Field( 16 | default=False, description="Whether to download synchronized lyrics" 17 | ) 18 | download_cover: bool = Field( 19 | default=True, description="Whether to download cover art" 20 | ) 21 | generate_nfo: bool = Field( 22 | default=False, description="Whether to generate NFO metadata files" 23 | ) 24 | output_format: str = Field( 25 | default="m4a", 26 | description="Audio format for downloaded files", 27 | pattern="^(m4a|mp3)$", 28 | ) 29 | bit_rate: int = Field( 30 | default=128, 31 | description="Bit rate for audio files in kbps", 32 | ge=64, le=320, # Valid range for MP3 bit rates 33 | ) 34 | output_dir: Optional[str] = Field( 35 | default="Music", description="Custom output directory (optional)" 36 | ) 37 | 38 | 39 | class TrackInfo(BaseModel): 40 | """Schema for track information.""" 41 | 42 | name: str 43 | artists: List[str] 44 | album_name: Optional[str] = None 45 | duration: int # in seconds 46 | number: int 47 | uri: str 48 | 49 | 50 | class AlbumInfo(BaseModel): 51 | """Schema for album information.""" 52 | 53 | name: str 54 | artists: List[str] 55 | release_date: str 56 | total_tracks: int 57 | cover_url: Optional[str] = None 58 | tracks: List[TrackInfo] 59 | 60 | 61 | class PlaylistInfo(BaseModel): 62 | """Schema for playlist information.""" 63 | 64 | name: str 65 | owner: str 66 | description: Optional[str] = None 67 | total_tracks: int 68 | cover_url: Optional[str] = None 69 | tracks: List[TrackInfo] 70 | 71 | 72 | class DownloadResponse(BaseModel): 73 | """Schema for download response.""" 74 | 75 | task_id: str = Field(..., description="Unique task identifier") 76 | status: str = Field(..., description="Current status of the download") 77 | spotify_url: str = Field(..., description="Original Spotify URL") 78 | content_type: str = Field( 79 | ..., description="Type of content (track, album, playlist)" 80 | ) 81 | message: str = Field(..., description="Status message") 82 | 83 | 84 | class DownloadStatus(BaseModel): 85 | """Schema for download status.""" 86 | 87 | task_id: str 88 | status: str # pending, processing, completed, failed 89 | progress: int # 0-100 90 | current_track: Optional[str] = None 91 | total_tracks: int = 0 92 | completed_tracks: int = 0 93 | failed_tracks: int = 0 94 | output_directory: Optional[str] = None 95 | output_format: str = "m4a" 96 | bit_rate: Optional[int] = None 97 | error_message: Optional[str] = None 98 | started_at: Optional[str] = None 99 | completed_at: Optional[str] = None 100 | 101 | 102 | class ErrorResponse(BaseModel): 103 | """Schema for error responses.""" 104 | 105 | error: str = Field(..., description="Error type") 106 | message: str = Field(..., description="Error message") 107 | details: Optional[str] = Field(None, description="Additional error details") 108 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/download/download.py: -------------------------------------------------------------------------------- 1 | """Main download command module for SpotifySaver CLI. 2 | 3 | This module provides the primary download command that handles downloading 4 | tracks, albums, or playlists from Spotify by finding matching content on 5 | YouTube Music and applying Spotify metadata. 6 | """ 7 | 8 | from pathlib import Path 9 | 10 | import click 11 | 12 | from spotifysaver.config import Config 13 | from spotifysaver.services import SpotifyAPI, YoutubeMusicSearcher 14 | from spotifysaver.downloader import YouTubeDownloaderForCLI 15 | from spotifysaver.spotlog import LoggerConfig 16 | from spotifysaver.cli.commands.download.album import process_album 17 | from spotifysaver.cli.commands.download.playlist import process_playlist 18 | from spotifysaver.cli.commands.download.track import process_track 19 | 20 | 21 | @click.command("download") 22 | @click.argument("spotify_url") 23 | @click.option("--lyrics", is_flag=True, help="Download synced lyrics (.lrc)") 24 | @click.option("--nfo", is_flag=True, help="Generate Jellyfin NFO file for albums") 25 | @click.option("--cover", is_flag=True, help="Download album cover art") 26 | @click.option("--output", type=Path, default=Config.OUTPUT_DIR, help="Output directory")#"Music", help="Output directory") 27 | @click.option("--format", type=click.Choice(["m4a", "mp3", "opus"]), default="m4a") 28 | @click.option("--bitrate", type=int, default=128, help="Audio bitrate in kbps") 29 | @click.option("--verbose", is_flag=True, help="Show debug output") 30 | @click.option("--explain", is_flag=True, help="Show score breakdown for each track without downloading (for error analysis)") 31 | @click.option("--dry-run", is_flag=True, help="Simulate download without saving files") 32 | 33 | def download( 34 | spotify_url: str, 35 | lyrics: bool, 36 | nfo: bool, 37 | cover: bool, 38 | output: Path, 39 | format: str, 40 | bitrate: int, 41 | verbose: bool, 42 | explain: bool, 43 | dry_run: bool, 44 | ): 45 | """Download music from Spotify URLs via YouTube Music with metadata. 46 | 47 | This command downloads audio content from YouTube Music that matches 48 | Spotify tracks, albums, or playlists, then applies the original Spotify 49 | metadata to create properly organized music files. 50 | 51 | Args: 52 | spotify_url: Spotify URL for track, album, or playlist 53 | lyrics: Whether to download synchronized lyrics files 54 | nfo: Whether to generate Jellyfin-compatible metadata files 55 | cover: Whether to download album/playlist cover art 56 | output: Base directory for downloaded files 57 | format: Audio format for downloaded files 58 | bitrate: Audio bitrate in kbps (96, 128, 192, 256) 59 | verbose: Whether to show detailed debug information 60 | explain: Whether to show score breakdown for each track without downloading 61 | """ 62 | LoggerConfig.setup(level="DEBUG" if verbose else "INFO") 63 | 64 | try: 65 | spotify = SpotifyAPI() 66 | searcher = YoutubeMusicSearcher() 67 | downloader = YouTubeDownloaderForCLI(base_dir=output) 68 | 69 | if "album" in spotify_url: 70 | process_album( 71 | spotify, searcher, downloader, spotify_url, lyrics, nfo, cover, format, bitrate, explain, dry_run 72 | ) 73 | elif "playlist" in spotify_url: 74 | process_playlist( 75 | spotify, searcher, downloader, spotify_url, lyrics, nfo, cover, format, bitrate, dry_run 76 | ) 77 | else: 78 | process_track(spotify, searcher, downloader, spotify_url, lyrics, format, bitrate, explain, dry_run) 79 | 80 | except Exception as e: 81 | click.secho(f"Error: {str(e)}", fg="red", err=True) 82 | if verbose: 83 | import traceback 84 | 85 | traceback.print_exc() 86 | raise click.Abort() 87 | -------------------------------------------------------------------------------- /spotifysaver/ui/static/js/state-manager.js: -------------------------------------------------------------------------------- 1 | class StateManager { 2 | constructor() { 3 | this.storageKey = 'spotifysaver_state'; 4 | } 5 | 6 | saveState(appState) { 7 | const state = { 8 | downloadInProgress: appState.downloadInProgress, 9 | currentTaskId: appState.currentTaskId, 10 | downloadStartTime: appState.downloadStartTime, 11 | lastUrl: document.getElementById('spotify-url').value, 12 | logs: this.getLogs(), 13 | inspectData: this.getInspectData(), 14 | timestamp: Date.now() 15 | }; 16 | localStorage.setItem(this.storageKey, JSON.stringify(state)); 17 | } 18 | 19 | loadPersistedState() { 20 | try { 21 | const saved = localStorage.getItem(this.storageKey); 22 | if (!saved) return null; 23 | 24 | const state = JSON.parse(saved); 25 | const maxAge = 24 * 60 * 60 * 1000; // 24 horas 26 | 27 | // Verificar si el estado no es muy antiguo 28 | if (Date.now() - state.timestamp > maxAge) { 29 | localStorage.removeItem(this.storageKey); 30 | return null; 31 | } 32 | 33 | return state; 34 | } catch (error) { 35 | console.warn('Error loading persisted state:', error); 36 | localStorage.removeItem(this.storageKey); 37 | return null; 38 | } 39 | } 40 | 41 | clearPersistedState() { 42 | localStorage.removeItem(this.storageKey); 43 | } 44 | 45 | getLogs() { 46 | const logEntries = document.querySelectorAll('.log-entry'); 47 | // Invertir para mantener el orden cronológico original al guardar 48 | return Array.from(logEntries).reverse().map(entry => ({ 49 | text: entry.textContent, 50 | className: entry.className 51 | })); 52 | } 53 | 54 | restoreLogs(logs) { 55 | const logContent = document.getElementById('log-content'); 56 | logContent.innerHTML = ''; 57 | // Invertir el orden de los logs para mostrar los más recientes primero 58 | logs.reverse().forEach(log => { 59 | const entry = document.createElement('div'); 60 | entry.className = log.className; 61 | entry.textContent = log.text; 62 | logContent.appendChild(entry); 63 | }); 64 | logContent.scrollTop = 0; 65 | } 66 | 67 | getInspectData() { 68 | const container = document.getElementById('inspect-details'); 69 | if (container.classList.contains('hidden') || !container.innerHTML.trim()) { 70 | return null; 71 | } 72 | return { 73 | html: container.innerHTML, 74 | visible: !container.classList.contains('hidden') 75 | }; 76 | } 77 | 78 | restoreInspectData(data) { 79 | if (!data || !data.html) return; 80 | 81 | const container = document.getElementById('inspect-details'); 82 | const message = document.getElementById('inspect-message'); 83 | 84 | container.innerHTML = data.html; 85 | if (data.visible) { 86 | message.classList.add('hidden'); 87 | container.classList.remove('hidden'); 88 | } 89 | } 90 | 91 | restoreFormData(state) { 92 | // Restaurar URL 93 | if (state.lastUrl) { 94 | document.getElementById('spotify-url').value = state.lastUrl; 95 | } 96 | 97 | // Restaurar logs 98 | if (state.logs && state.logs.length > 0) { 99 | this.restoreLogs(state.logs); 100 | } 101 | 102 | // Restaurar detalles de inspección 103 | if (state.inspectData) { 104 | this.restoreInspectData(state.inspectData); 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "spotifysaver" 3 | version = "0.7.5" 4 | description = "Download Spotify tracks/albums with metadata via YouTube Music (Perfect for Jellyfin libraries!)" 5 | authors = [ 6 | "Gabriel Baute ", 7 | "Rafnix Guzman " 8 | ] 9 | maintainers = [ 10 | "Gabriel Baute ", 11 | "Rafnix Guzman " 12 | ] 13 | license = "MIT" 14 | readme = "README.md" 15 | homepage = "https://github.com/gabrielbaute/spotify-saver" 16 | repository = "https://github.com/gabrielbaute/spotify-saver" 17 | documentation = "https://deepwiki.com/gabrielbaute/spotify-saver" 18 | keywords = [ 19 | "spotify", 20 | "youtube-music", 21 | "music-downloader", 22 | "jellyfin", 23 | "metadata", 24 | "cli-tool", 25 | "audio-conversion", 26 | "playlist-downloader" 27 | ] 28 | classifiers = [ 29 | "Development Status :: 4 - Beta", 30 | "Intended Audience :: End Users/Desktop", 31 | "Intended Audience :: Developers", 32 | "License :: OSI Approved :: MIT License", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | "Programming Language :: Python :: 3.13", 39 | "Topic :: Multimedia :: Sound/Audio", 40 | "Topic :: Multimedia :: Sound/Audio :: Conversion", 41 | "Topic :: Software Development :: Libraries :: Python Modules", 42 | "Topic :: Utilities", 43 | "Environment :: Console", 44 | "Environment :: Web Environment", 45 | "Operating System :: OS Independent", 46 | "Natural Language :: English", 47 | ] 48 | packages = [{include = "spotifysaver"}] 49 | include = [ 50 | "spotifysaver/ui/**/*.html", 51 | "spotifysaver/ui/**/*.css", 52 | "spotifysaver/ui/**/*.js", 53 | "spotifysaver/ui/**/*.md", 54 | ] 55 | exclude = [ 56 | "tests/*", 57 | "testing/*", 58 | "*.backup", 59 | ".git/*", 60 | ".github/*", 61 | ] 62 | 63 | [tool.poetry.dependencies] 64 | python = "^3.9" 65 | mutagen = "^1.47.0" 66 | spotipy = "^2.25.1" 67 | yt-dlp = "^2025.10.14" 68 | ytmusicapi = "^1.10.3" 69 | requests = "^2.32.5" 70 | click = "^8.1.8" 71 | pydantic = "^2.11.9" 72 | python-dotenv = "^1.1.1" 73 | fastapi = "^0.115.14" 74 | uvicorn = {extras = ["standard"], version = "^0.34.0"} 75 | 76 | [tool.poetry.group.dev.dependencies] 77 | black = "^23.7.0" 78 | pytest = "^7.4.0" 79 | pytest-cov = "^4.1.0" 80 | mypy = "^1.5.1" 81 | ruff = "^0.1.0" 82 | 83 | [tool.poetry.group.docs.dependencies] 84 | mkdocs = {version = "^1.5.0", optional = true} 85 | mkdocs-material = {version = "^9.4.0", optional = true} 86 | 87 | [tool.poetry.extras] 88 | docs = ["mkdocs", "mkdocs-material"] 89 | 90 | [tool.poetry.scripts] 91 | spotifysaver = "spotifysaver.__main__:cli" 92 | spotifysaver-api = "spotifysaver.api.main:run_server" 93 | 94 | [tool.black] 95 | line-length = 100 96 | target-version = ['py39', 'py310', 'py311', 'py312'] 97 | include = '\.pyi?$' 98 | extend-exclude = ''' 99 | /( 100 | # directories 101 | \.eggs 102 | | \.git 103 | | \.hg 104 | | \.mypy_cache 105 | | \.tox 106 | | \.venv 107 | | build 108 | | dist 109 | )/ 110 | ''' 111 | 112 | [tool.ruff] 113 | line-length = 100 114 | target-version = "py39" 115 | select = ["E", "F", "W", "I", "N", "UP"] 116 | ignore = ["E501"] 117 | 118 | [tool.pytest.ini_options] 119 | minversion = "7.0" 120 | addopts = "-ra -q --strict-markers --cov=spotifysaver --cov-report=term-missing" 121 | testpaths = ["tests"] 122 | python_files = ["test_*.py"] 123 | python_classes = ["Test*"] 124 | python_functions = ["test_*"] 125 | 126 | [tool.mypy] 127 | python_version = "3.9" 128 | warn_return_any = true 129 | warn_unused_configs = true 130 | disallow_untyped_defs = false 131 | ignore_missing_imports = true 132 | 133 | [build-system] 134 | requires = ["poetry-core>=1.0.0"] 135 | build-backend = "poetry.core.masonry.api" 136 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/download/track.py: -------------------------------------------------------------------------------- 1 | """Single track download command module for SpotifySaver CLI. 2 | 3 | This module handles the download process for individual Spotify tracks, 4 | including YouTube Music search and metadata application. 5 | """ 6 | 7 | import click 8 | from spotifysaver.downloader import YouTubeDownloaderForCLI, YouTubeDownloader 9 | from spotifysaver.services import SpotifyAPI, YoutubeMusicSearcher, ScoreMatchCalculator 10 | 11 | def process_track( 12 | spotify: SpotifyAPI, 13 | searcher: YoutubeMusicSearcher, 14 | downloader: YouTubeDownloaderForCLI, 15 | url, 16 | lyrics, 17 | output_format, 18 | bitrate, 19 | explain=False, 20 | dry_run=False 21 | ): 22 | """Process and download a single Spotify track. 23 | 24 | Downloads a single track from Spotify by finding a matching track on 25 | YouTube Music and applying the original Spotify metadata. 26 | 27 | Args: 28 | spotify: SpotifyAPI instance for fetching track data 29 | searcher: YoutubeMusicSearcher for finding YouTube matches 30 | downloader: YouTubeDownloader for downloading and processing files 31 | url: Spotify track URL 32 | lyrics: Whether to download synchronized lyrics 33 | output_format: Audio format for downloaded files 34 | bitrate: Audio bitrate in kbps (96, 128, 192, 256) 35 | explain: Whether to show score breakdown without downloading 36 | """ 37 | track = spotify.get_track(url) 38 | 39 | # Explain mode: show score breakdown without downloading 40 | if explain: 41 | scorer = ScoreMatchCalculator() 42 | click.secho(f"\n🔍 Explaining matches for track: {track.name}", fg="cyan") 43 | 44 | click.secho(f"\n🎵 Track: {track.name}", fg="yellow") 45 | results = searcher.search_raw(track) 46 | 47 | if not results: 48 | click.echo(" ⚠ No candidates found.") 49 | return 50 | 51 | for result in results: 52 | explanation = scorer.explain_score(result, track, strict=True) 53 | click.echo(f" - Candidate: {explanation['yt_title']}") 54 | click.echo(f" Video ID: {explanation['yt_videoId']}") 55 | click.echo(f" Duration: {explanation['duration_score']}") 56 | click.echo(f" Artist: {explanation['artist_score']}") 57 | click.echo(f" Title: {explanation['title_score']}") 58 | click.echo(f" Album: {explanation['album_bonus']}") 59 | click.echo(f" → Total: {explanation['total_score']} (passed: {explanation['passed']})") 60 | click.echo("-" * 40) 61 | 62 | best = max(results, key=lambda r: scorer.explain_score(r, track)["total_score"]) 63 | best_expl = scorer.explain_score(best, track) 64 | click.secho(f"\n✅ Best candidate: {best_expl['yt_title']} (score: {best_expl['total_score']})", fg="green") 65 | return 66 | 67 | # Dry run mode: explain matches without downloading 68 | if dry_run: 69 | scorer = ScoreMatchCalculator() 70 | click.secho(f"\n🧪 Dry run for track: {track.name}", fg="cyan") 71 | result = searcher.search_track(track) 72 | explanation = scorer.explain_score(result, track, strict=True) 73 | click.echo(f" → Selected candidate: {explanation['yt_title']}") 74 | click.echo(f" Video ID: {explanation['yt_videoId']}") 75 | click.echo(f" Total score: {explanation['total_score']} (passed: {explanation['passed']})") 76 | return 77 | 78 | with click.progressbar( 79 | length=1, 80 | label=" Processing", 81 | fill_char="█", 82 | show_percent=True, 83 | item_show_func=lambda t: t.name[:25] + "..." if t else "", 84 | ) as bar: 85 | 86 | def update_progress(idx, total, name): 87 | bar.label = ( 88 | f" Downloading: {name[:20]}..." 89 | if len(name) > 20 90 | else f" Downloading: {name}" 91 | ) 92 | bar.update(1) 93 | 94 | audio_path, updated_track = downloader.download_track_cli( 95 | track, 96 | output_format=YouTubeDownloader.string_to_audio_format(output_format), 97 | bitrate=YouTubeDownloader.int_to_bitrate(bitrate), 98 | download_lyrics=lyrics, 99 | progress_callback=update_progress, 100 | ) 101 | 102 | 103 | if audio_path: 104 | msg = f"Downloaded: {track.name}" 105 | if lyrics and updated_track.has_lyrics: 106 | msg += " (+ lyrics)" 107 | click.secho(msg, fg="green") 108 | else: 109 | click.secho(f"Failed to download: {track.name}", fg="yellow") 110 | -------------------------------------------------------------------------------- /spotifysaver/ui/static/js/app.js: -------------------------------------------------------------------------------- 1 | class SpotifySaverUI { 2 | constructor() { 3 | this.apiClient = new ApiClient(); 4 | this.stateManager = new StateManager(); 5 | this.uiManager = new UIManager(() => this.saveState()); 6 | this.downloadManager = new DownloadManager(this.apiClient, this.uiManager, () => this.saveState()); 7 | 8 | this.isInitialized = false; 9 | this.retryCount = 0; 10 | 11 | this.initialize(); 12 | } 13 | 14 | async initialize() { 15 | try { 16 | this.initializeEventListeners(); 17 | this.loadPersistedState(); 18 | 19 | // Check API status with retry mechanism 20 | const apiAvailable = await this.apiClient.checkApiStatusWithRetry(); 21 | 22 | if (apiAvailable) { 23 | this.uiManager.updateStatus('API connected and ready', 'success'); 24 | await this.setDefaultOutputDir(); 25 | await this.loadAppVersion(); 26 | } else { 27 | this.uiManager.updateStatus('API not available. Make sure it is running.', 'error'); 28 | } 29 | 30 | this.isInitialized = true; 31 | 32 | // If there was a download in progress, try to reconnect 33 | if (this.downloadManager.isDownloadInProgress && this.downloadManager.taskId) { 34 | this.downloadManager.startProgressMonitoring(this.downloadManager.taskId); 35 | } 36 | 37 | } catch (error) { 38 | console.error('Failed to initialize UI:', error); 39 | this.uiManager.updateStatus('Failed to initialize. Please refresh the page.', 'error'); 40 | } 41 | } 42 | 43 | initializeEventListeners() { 44 | const downloadBtn = document.getElementById('download-btn'); 45 | const spotifyUrl = document.getElementById('spotify-url'); 46 | const clearLogsBtn = document.getElementById('clear-logs-btn'); 47 | 48 | downloadBtn.addEventListener('click', () => this.downloadManager.startDownload()); 49 | 50 | // Permitir iniciar descarga con Enter 51 | spotifyUrl.addEventListener('keypress', (e) => { 52 | if (e.key === 'Enter' && !this.downloadManager.isDownloadInProgress) { 53 | this.downloadManager.startDownload(); 54 | } 55 | }); 56 | 57 | // Botón para limpiar logs y estado 58 | clearLogsBtn.addEventListener('click', () => { 59 | if (confirm('Are you sure you want to clear logs and state? This cannot be undone.')) { 60 | this.uiManager.clearLog(); 61 | this.uiManager.clearInspect(); 62 | this.downloadManager.clearStates(); 63 | this.stateManager.clearPersistedState(); 64 | this.uiManager.updateStatus('API connected and ready', 'info'); 65 | this.uiManager.addLogEntry('Logs and state manually cleared', 'info'); 66 | } 67 | }); 68 | } 69 | 70 | loadPersistedState() { 71 | const state = this.stateManager.loadPersistedState(); 72 | if (!state) return; 73 | 74 | // Restaurar datos del formulario y UI 75 | this.stateManager.restoreFormData(state); 76 | 77 | // Restaurar estado de descarga 78 | if (state.downloadInProgress && state.currentTaskId) { 79 | this.downloadManager.restoreDownloadState( 80 | state.downloadInProgress, 81 | state.currentTaskId, 82 | state.downloadStartTime 83 | ); 84 | this.uiManager.updateUI(true); 85 | this.uiManager.updateStatus('Reconnecting to download...', 'info'); 86 | this.uiManager.addLogEntry('Reconnected - resuming download monitoring', 'info'); 87 | } 88 | } 89 | 90 | saveState() { 91 | const appState = { 92 | downloadInProgress: this.downloadManager.isDownloadInProgress, 93 | currentTaskId: this.downloadManager.taskId, 94 | downloadStartTime: this.downloadManager.startTime 95 | }; 96 | this.stateManager.saveState(appState); 97 | } 98 | 99 | async setDefaultOutputDir() { 100 | try { 101 | const defaultDir = await this.apiClient.getDefaultOutputDir(); 102 | await this.uiManager.setDefaultOutputDir(defaultDir); 103 | } catch (error) { 104 | console.warn('Could not set default output directory:', error); 105 | } 106 | } 107 | 108 | async loadAppVersion() { 109 | try { 110 | const version = await this.apiClient.getAppVersion(); 111 | if (version) { 112 | await this.uiManager.setAppVersion(version); 113 | } 114 | } catch (error) { 115 | console.warn('Could not load app version:', error); 116 | } 117 | } 118 | } 119 | 120 | // Inicializar la aplicación cuando se carga la página 121 | document.addEventListener('DOMContentLoaded', () => { 122 | new SpotifySaverUI(); 123 | }); -------------------------------------------------------------------------------- /spotifysaver/metadata/nfo_generator.py: -------------------------------------------------------------------------------- 1 | """NFO Generator for Jellyfin-compatible XML metadata files. 2 | 3 | This module generates XML metadata files (.nfo) that are compatible with Jellyfin 4 | media server for organizing music libraries with proper metadata. 5 | """ 6 | 7 | from dataclasses import dataclass 8 | from typing import List, Optional 9 | from pathlib import Path 10 | from datetime import datetime 11 | from xml.etree import ElementTree as ET 12 | from xml.dom import minidom 13 | 14 | from spotifysaver.models.album import Album 15 | from spotifysaver.services.schemas import AlbumADBResponse 16 | from spotifysaver.services import TheAudioDBService 17 | 18 | 19 | class NFOGenerator: 20 | """Generator for Jellyfin-compatible NFO metadata files. 21 | 22 | This class provides static methods to generate XML metadata files for albums 23 | that are compatible with Jellyfin media server. These files contain detailed 24 | information about albums, tracks, artists, and other metadata. 25 | """ 26 | 27 | @staticmethod 28 | def _get_theaudiodb_data(album: Album) -> Optional[AlbumADBResponse]: 29 | service = TheAudioDBService() 30 | return service.get_album_metadata(album.artists[0], album.name) 31 | 32 | @staticmethod 33 | def _format_duration(seconds: int) -> str: 34 | """Convert seconds to MM:SS format. 35 | 36 | Args: 37 | seconds: Duration in seconds 38 | 39 | Returns: 40 | str: Formatted duration string in MM:SS format 41 | """ 42 | minutes, seconds = divmod(seconds, 60) 43 | return f"{minutes:02d}:{seconds:02d}" 44 | 45 | @staticmethod 46 | def generate(album: Album, output_dir: Path): 47 | """Generate an album.nfo file in the specified directory. 48 | 49 | Creates a Jellyfin-compatible XML metadata file containing album information, 50 | track listings, artist details, genres, and other metadata required for 51 | proper media library organization. 52 | 53 | Args: 54 | album: Album object containing the album information and tracks 55 | output_dir: Directory where the album.nfo file will be saved 56 | """ 57 | # Root element 58 | root = ET.Element("album") 59 | 60 | # Datos base de Spotify 61 | ET.SubElement(root, "title").text = album.name 62 | if album.release_date: 63 | ET.SubElement(root, "year").text = album.release_date[:4] 64 | ET.SubElement(root, "premiered").text = album.release_date 65 | ET.SubElement(root, "releasedate").text = album.release_date 66 | 67 | total_seconds = sum(t.duration for t in album.tracks) 68 | runtime_minutes = total_seconds // 60 69 | ET.SubElement(root, "runtime").text = str(runtime_minutes) 70 | 71 | if album.genres: 72 | for genre in album.genres: 73 | ET.SubElement(root, "genre").text = genre 74 | 75 | for artist in album.artists: 76 | ET.SubElement(root, "artist").text = artist 77 | ET.SubElement(root, "albumartist").text = ", ".join(album.artists) 78 | 79 | # Tracks 80 | for track in album.tracks: 81 | track_elem = ET.SubElement(root, "track") 82 | ET.SubElement(track_elem, "position").text = str(track.number) 83 | ET.SubElement(track_elem, "title").text = track.name 84 | ET.SubElement(track_elem, "duration").text = NFOGenerator._format_duration(track.duration) 85 | 86 | # Datos extra de TheAudioDB 87 | adb_data = NFOGenerator._get_theaudiodb_data(album) 88 | if adb_data: 89 | if adb_data.genre: 90 | ET.SubElement(root, "genre").text = adb_data.genre 91 | if adb_data.description: 92 | # description es lista de AlbumDescription → tomar la primera 93 | desc = adb_data.description[0].description if adb_data.description else "" 94 | ET.SubElement(root, "review").text = desc 95 | ET.SubElement(root, "outline").text = desc 96 | if adb_data.id: 97 | ET.SubElement(root, "audiodbalbumid").text = str(adb_data.id) 98 | if adb_data.artist_id: 99 | ET.SubElement(root, "audiodbartistid").text = str(adb_data.artist_id) 100 | if adb_data.musicbrainz_id: 101 | ET.SubElement(root, "musicbrainzalbumid").text = adb_data.musicbrainz_id 102 | if adb_data.artist_musicbrainz_id: 103 | ET.SubElement(root, "musicbrainzalbumartistid").text = adb_data.artist_musicbrainz_id 104 | 105 | # Static fields 106 | ET.SubElement(root, "lockdata").text = "false" 107 | ET.SubElement(root, "dateadded").text = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 108 | 109 | # Pretty XML 110 | xml_str = ET.tostring(root, encoding="utf-8", method="xml") 111 | pretty_xml = minidom.parseString(xml_str).toprettyxml(indent=" ") 112 | 113 | nfo_path = output_dir / "album.nfo" 114 | with open(nfo_path, "w", encoding="utf-8") as f: 115 | f.write(pretty_xml) 116 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/download/playlist.py: -------------------------------------------------------------------------------- 1 | """Playlist download command module for SpotifySaver CLI. 2 | 3 | This module handles the download process for complete Spotify playlists, 4 | including progress tracking and optional metadata generation. 5 | """ 6 | 7 | import click 8 | from spotifysaver.downloader import YouTubeDownloader, YouTubeDownloaderForCLI 9 | from spotifysaver.services import SpotifyAPI, YoutubeMusicSearcher, ScoreMatchCalculator 10 | 11 | 12 | def process_playlist( 13 | spotify: SpotifyAPI, 14 | searcher: YoutubeMusicSearcher, 15 | downloader: YouTubeDownloaderForCLI, 16 | url, 17 | lyrics, 18 | nfo, 19 | cover, 20 | output_format, 21 | bitrate, 22 | dry_run=False 23 | ): 24 | """Process and download a complete Spotify playlist with progress tracking. 25 | 26 | Downloads all tracks from a Spotify playlist, showing a progress bar and 27 | handling optional features like lyrics and cover art. NFO generation for 28 | playlists is currently in development. 29 | 30 | Args: 31 | spotify: SpotifyAPI instance for fetching playlist data 32 | searcher: YoutubeMusicSearcher for finding YouTube matches 33 | downloader: YouTubeDownloader for downloading and processing files 34 | url: Spotify playlist URL 35 | lyrics: Whether to download synchronized lyrics 36 | nfo: Whether to generate metadata files (in development) 37 | cover: Whether to download playlist cover art 38 | output_format: Audio format for downloaded files 39 | """ 40 | playlist = spotify.get_playlist(url) 41 | click.secho(f"\nDownloading playlist: {playlist.name}", fg="magenta") 42 | 43 | # Dry run mode: explain matches without downloading 44 | if dry_run: 45 | scorer = ScoreMatchCalculator() 46 | click.secho(f"\n🧪 Dry run for playlist: {playlist.name}", fg="magenta") 47 | 48 | for track in playlist.tracks: 49 | result = searcher.search_track(track) 50 | explanation = scorer.explain_score(result, track, strict=True) 51 | click.secho(f"\n🎵 Track: {track.name}", fg="yellow") 52 | click.echo(f" → Selected candidate: {explanation['yt_title']}") 53 | click.echo(f" Video ID: {explanation['yt_videoId']}") 54 | click.echo(f" Total score: {explanation['total_score']} (passed: {explanation['passed']})") 55 | return 56 | 57 | # Configure progress bar 58 | with click.progressbar( 59 | length=len(playlist.tracks), 60 | label=" Processing", 61 | fill_char="█", 62 | show_percent=True, 63 | item_show_func=lambda t: t.name[:25] + "..." if t else "", 64 | ) as bar: 65 | 66 | def update_progress(idx, total, name): 67 | bar.label = ( 68 | f" Downloading: {name[:20]}..." 69 | if len(name) > 20 70 | else f" Downloading: {name}" 71 | ) 72 | bar.update(1) 73 | 74 | # Delegate everything to the downloader 75 | success, total = downloader.download_playlist_cli( 76 | playlist, 77 | download_lyrics=lyrics, 78 | output_format=YouTubeDownloader.string_to_audio_format(output_format), 79 | bitrate=YouTubeDownloader.int_to_bitrate(bitrate), 80 | cover=cover, 81 | progress_callback=update_progress, 82 | ) 83 | 84 | # Display results 85 | if success > 0: 86 | click.secho(f"\n✔ Downloaded {success}/{total} tracks", fg="green") 87 | if nfo: 88 | click.secho( 89 | f"\nGenerating NFO for playlist: method in development", fg="magenta" 90 | ) 91 | # generate_nfo_for_playlist(downloader, playlist, cover) 92 | else: 93 | click.secho("\n⚠ No tracks downloaded", fg="yellow") 94 | 95 | 96 | def generate_nfo_for_playlist(downloader, playlist, cover=False): 97 | """Generate NFO metadata file for a playlist (similar to albums). 98 | 99 | Creates a Jellyfin-compatible NFO file with playlist metadata and optionally 100 | downloads the playlist cover art. This function is currently in development. 101 | 102 | Args: 103 | downloader: YouTubeDownloader instance for file operations 104 | playlist: Playlist object with metadata 105 | cover: Whether to download playlist cover art 106 | """ 107 | try: 108 | from spotifysaver.metadata import NFOGenerator 109 | 110 | playlist_dir = downloader.base_dir / playlist.name 111 | NFOGenerator.generate_playlist(playlist, playlist_dir) 112 | 113 | if cover and playlist.cover_url: 114 | cover_path = playlist_dir / "cover.jpg" 115 | if not cover_path.exists(): 116 | downloader._save_cover_album(playlist.cover_url, cover_path) 117 | click.secho(f"✔ Saved playlist cover: {cover_path}", fg="green") 118 | 119 | click.secho( 120 | f"\n✔ Generated playlist metadata: {playlist_dir}/playlist.nfo", fg="green" 121 | ) 122 | except Exception as e: 123 | click.secho(f"\n⚠ Failed to generate NFO: {str(e)}", fg="yellow") 124 | -------------------------------------------------------------------------------- /test_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple test script for SpotifySaver API 4 | Tests basic API functionality without requiring Spotify credentials. 5 | """ 6 | 7 | import requests 8 | import json 9 | import time 10 | from typing import Dict, Any 11 | 12 | 13 | class SpotifySaverAPIClient: 14 | """Simple client for SpotifySaver API.""" 15 | 16 | def __init__(self, base_url: str = "http://localhost:8000"): 17 | self.base_url = base_url 18 | self.session = requests.Session() 19 | 20 | def test_basic_endpoints(self) -> Dict[str, Any]: 21 | """Test basic API endpoints that don't require authentication.""" 22 | results = {} 23 | 24 | # Test root endpoint 25 | try: 26 | response = self.session.get(f"{self.base_url}/") 27 | results["root"] = { 28 | "status": response.status_code, 29 | "data": ( 30 | response.json() if response.status_code == 200 else response.text 31 | ), 32 | } 33 | except Exception as e: 34 | results["root"] = {"error": str(e)} 35 | 36 | # Test health endpoint 37 | try: 38 | response = self.session.get(f"{self.base_url}/health") 39 | results["health"] = { 40 | "status": response.status_code, 41 | "data": ( 42 | response.json() if response.status_code == 200 else response.text 43 | ), 44 | } 45 | except Exception as e: 46 | results["health"] = {"error": str(e)} 47 | 48 | # Test downloads list 49 | try: 50 | response = self.session.get(f"{self.base_url}/api/v1/downloads") 51 | results["downloads"] = { 52 | "status": response.status_code, 53 | "data": ( 54 | response.json() if response.status_code == 200 else response.text 55 | ), 56 | } 57 | except Exception as e: 58 | results["downloads"] = {"error": str(e)} 59 | 60 | return results 61 | 62 | def test_inspect_endpoint(self, spotify_url: str) -> Dict[str, Any]: 63 | """Test the inspect endpoint (requires Spotify credentials).""" 64 | try: 65 | response = self.session.get( 66 | f"{self.base_url}/api/v1/inspect", params={"spotify_url": spotify_url} 67 | ) 68 | return { 69 | "status": response.status_code, 70 | "data": ( 71 | response.json() if response.status_code == 200 else response.text 72 | ), 73 | } 74 | except Exception as e: 75 | return {"error": str(e)} 76 | 77 | def test_download_endpoint(self, spotify_url: str, **kwargs) -> Dict[str, Any]: 78 | """Test the download endpoint (requires Spotify credentials).""" 79 | payload = { 80 | "spotify_url": spotify_url, 81 | "download_lyrics": kwargs.get("download_lyrics", False), 82 | "download_cover": kwargs.get("download_cover", True), 83 | "generate_nfo": kwargs.get("generate_nfo", False), 84 | "output_format": kwargs.get("output_format", "m4a"), 85 | "output_dir": kwargs.get("output_dir", None), 86 | } 87 | 88 | try: 89 | response = self.session.post( 90 | f"{self.base_url}/api/v1/download", json=payload 91 | ) 92 | return { 93 | "status": response.status_code, 94 | "data": ( 95 | response.json() if response.status_code == 200 else response.text 96 | ), 97 | } 98 | except Exception as e: 99 | return {"error": str(e)} 100 | 101 | 102 | def main(): 103 | """Main test function.""" 104 | print("🎵 SpotifySaver API Test Script") 105 | print("=" * 40) 106 | 107 | # Initialize client 108 | client = SpotifySaverAPIClient() 109 | 110 | # Test basic endpoints 111 | print("\n1. Testing basic endpoints...") 112 | basic_results = client.test_basic_endpoints() 113 | 114 | for endpoint, result in basic_results.items(): 115 | status = result.get("status", "ERROR") 116 | print(f" {endpoint}: {status}") 117 | if status == 200: 118 | print(f" ✅ Success: {result['data']}") 119 | else: 120 | print(f" ❌ Error: {result.get('data', result.get('error'))}") 121 | 122 | # Test inspect endpoint (will fail without credentials) 123 | print("\n2. Testing inspect endpoint (without credentials)...") 124 | inspect_result = client.test_inspect_endpoint( 125 | "https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh" 126 | ) 127 | print(f" Status: {inspect_result.get('status', 'ERROR')}") 128 | print(f" Result: {inspect_result.get('data', inspect_result.get('error'))}") 129 | 130 | # Instructions for full testing 131 | print("\n3. Full API Testing:") 132 | print(" To test download functionality:") 133 | print(" 1. Set up Spotify API credentials in .env file") 134 | print(" 2. Copy .env.example to .env") 135 | print(" 3. Add your SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET") 136 | print(" 4. Run this script again") 137 | 138 | print("\n✅ Basic API tests completed!") 139 | print("📚 API Documentation: http://localhost:8000/docs") 140 | 141 | 142 | if __name__ == "__main__": 143 | main() 144 | -------------------------------------------------------------------------------- /API_IMPLEMENTATION_SUMMARY.md: -------------------------------------------------------------------------------- 1 | # SpotifySaver API - Implementation Summary 2 | 3 | ## ✅ Completed Tasks 4 | 5 | ### 1. API Structure Created 6 | - **FastAPI Application**: Complete REST API structure in `spotifysaver/api/` folder 7 | - **Modular Design**: Organized into routers, services, and schemas 8 | - **Configuration**: Environment-based configuration with `.env` support 9 | - **Documentation**: Automatic OpenAPI/Swagger documentation 10 | 11 | ### 2. Core API Files 12 | - `__init__.py` - Package initialization and exports 13 | - `app.py` - FastAPI application factory with CORS 14 | - `config.py` - API configuration settings 15 | - `schemas.py` - Pydantic models for request/response validation 16 | - `main.py` - Server entry point with uvicorn 17 | - `README.md` - Comprehensive API documentation in Spanish 18 | 19 | ### 3. Router Implementation 20 | - `routers/download.py` - Download endpoints with background task support 21 | - **Endpoints implemented**: 22 | - `POST /api/v1/download` - Start download tasks 23 | - `GET /api/v1/download/{task_id}/status` - Check task status 24 | - `GET /api/v1/download/{task_id}/cancel` - Cancel tasks 25 | - `GET /api/v1/downloads` - List all tasks 26 | - `GET /api/v1/inspect` - Inspect Spotify URLs 27 | 28 | ### 4. Service Layer 29 | - `services/download_service.py` - Async wrapper for existing SpotifySaver functionality 30 | - **Background task processing** for downloads 31 | - **Integration** with existing YouTube downloader 32 | - **Async/await** pattern for non-blocking operations 33 | 34 | ### 5. Project Configuration 35 | - **Updated `pyproject.toml`** with FastAPI dependencies 36 | - **Added script entry point**: `spotifysaver-api` 37 | - **Dependencies installed**: FastAPI, uvicorn, and all project requirements 38 | - **Environment setup**: `.env.example` template for configuration 39 | 40 | ### 6. Testing & Validation 41 | - **Server startup**: Successfully running on `http://localhost:8000` 42 | - **API endpoints**: All basic endpoints responding correctly 43 | - **Documentation**: Available at `http://localhost:8000/docs` 44 | - **Health checks**: Working properly 45 | - **Error handling**: Proper error responses for missing credentials 46 | 47 | ## 🔗 API Endpoints Summary 48 | 49 | | Method | Endpoint | Description | Status | 50 | |--------|----------|-------------|---------| 51 | | GET | `/` | API information | ✅ Working | 52 | | GET | `/health` | Health check | ✅ Working | 53 | | GET | `/api/v1/inspect` | Inspect Spotify URL | ⚠️ Requires credentials | 54 | | POST | `/api/v1/download` | Start download | ⚠️ Requires credentials | 55 | | GET | `/api/v1/download/{task_id}/status` | Check download status | ✅ Working | 56 | | GET | `/api/v1/download/{task_id}/cancel` | Cancel download | ✅ Working | 57 | | GET | `/api/v1/downloads` | List all downloads | ✅ Working | 58 | 59 | ## 🛠️ Usage Instructions 60 | 61 | ### 1. Start the API Server 62 | ```bash 63 | cd /c/projectos/spotify-saver 64 | python -m spotifysaver.api.main 65 | ``` 66 | 67 | ### 2. Access Documentation 68 | - **Swagger UI**: http://localhost:8000/docs 69 | - **ReDoc**: http://localhost:8000/redoc 70 | 71 | ### 3. Setup Spotify Credentials (for full functionality) 72 | ```bash 73 | cp .env.example .env 74 | # Edit .env with your Spotify API credentials 75 | ``` 76 | 77 | ### 4. Test API Endpoints 78 | ```bash 79 | # Basic endpoints 80 | curl http://localhost:8000/ 81 | curl http://localhost:8000/health 82 | curl http://localhost:8000/api/v1/downloads 83 | 84 | # With credentials (example) 85 | curl -X POST "http://localhost:8000/api/v1/download" \ 86 | -H "Content-Type: application/json" \ 87 | -d '{ 88 | "spotify_url": "https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh", 89 | "download_lyrics": true, 90 | "download_cover": true, 91 | "output_format": "m4a" 92 | }' 93 | ``` 94 | 95 | ## 🎯 Key Features Implemented 96 | 97 | 1. **Async Background Processing**: Downloads run in background tasks 98 | 2. **CORS Support**: Configured for web browser access 99 | 3. **Request Validation**: Pydantic schemas for type safety 100 | 4. **Error Handling**: Proper HTTP status codes and error messages 101 | 5. **Task Management**: Track download progress and cancel tasks 102 | 6. **Flexible Configuration**: Environment-based settings 103 | 7. **Integration**: Seamless integration with existing SpotifySaver functionality 104 | 105 | ## 📁 Files Created/Modified 106 | 107 | ### New API Files 108 | - `spotifysaver/api/__init__.py` 109 | - `spotifysaver/api/app.py` 110 | - `spotifysaver/api/config.py` 111 | - `spotifysaver/api/schemas.py` 112 | - `spotifysaver/api/main.py` 113 | - `spotifysaver/api/README.md` 114 | - `spotifysaver/api/examples.py` 115 | - `spotifysaver/api/routers/__init__.py` 116 | - `spotifysaver/api/routers/download.py` 117 | - `spotifysaver/api/services/__init__.py` 118 | - `spotifysaver/api/services/download_service.py` 119 | - `.env.example` 120 | - `test_api.py` 121 | 122 | ### Modified Files 123 | - `pyproject.toml` - Added FastAPI dependencies 124 | 125 | ## 🚀 Next Steps 126 | 127 | 1. **Add Spotify Credentials**: Set up `.env` file with real credentials 128 | 2. **Test Download Functionality**: Test actual downloads with Spotify URLs 129 | 3. **Frontend Integration**: Use the API from web applications 130 | 4. **Production Deployment**: Deploy with proper ASGI server (gunicorn/uvicorn) 131 | 5. **Authentication**: Add API key authentication if needed 132 | 6. **Rate Limiting**: Implement rate limiting for production use 133 | 134 | The FastAPI-based SpotifySaver API is now fully implemented and ready for use! 135 | -------------------------------------------------------------------------------- /spotifysaver/ui/static/js/api-client.js: -------------------------------------------------------------------------------- 1 | class ApiClient { 2 | constructor() { 3 | this.apiUrl = `${window.location.protocol}//${window.location.hostname}:8000/api/v1`; 4 | this.apiUrlHealth = `${window.location.protocol}//${window.location.hostname}:8000/health`; 5 | this.apiUrlVersion = `${window.location.protocol}//${window.location.hostname}:8000/version`; 6 | this.maxRetries = 3; 7 | } 8 | 9 | async checkApiStatusWithRetry() { 10 | for (let i = 0; i < this.maxRetries; i++) { 11 | const success = await this.checkApiStatus(); 12 | if (success) { 13 | return true; 14 | } 15 | 16 | if (i < this.maxRetries - 1) { 17 | await new Promise(resolve => setTimeout(resolve, 2000 * (i + 1))); // Exponential backoff 18 | } 19 | } 20 | return false; 21 | } 22 | 23 | async checkApiStatus() { 24 | try { 25 | const controller = new AbortController(); 26 | const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout 27 | 28 | const response = await fetch(this.apiUrlHealth, { 29 | signal: controller.signal, 30 | method: 'GET', 31 | mode: 'cors', 32 | cache: 'no-cache' 33 | }); 34 | 35 | clearTimeout(timeoutId); 36 | 37 | if (response.ok) { 38 | return true; 39 | } else { 40 | return false; 41 | } 42 | } catch (error) { 43 | console.warn('API status check failed:', error); 44 | return false; 45 | } 46 | } 47 | 48 | async getDefaultOutputDir() { 49 | try { 50 | const controller = new AbortController(); 51 | const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout 52 | 53 | const response = await fetch(`${this.apiUrl}/config/output_dir`, { 54 | signal: controller.signal, 55 | method: 'GET', 56 | mode: 'cors', 57 | cache: 'no-cache' 58 | }); 59 | 60 | clearTimeout(timeoutId); 61 | 62 | if (response.ok) { 63 | const data = await response.json(); 64 | return data.output_dir || 'Music'; 65 | } else { 66 | return 'Music'; 67 | } 68 | } catch (error) { 69 | console.warn('Could not fetch default output directory, using fallback:', error); 70 | return 'Music'; 71 | } 72 | } 73 | 74 | async getAppVersion() { 75 | try { 76 | const controller = new AbortController(); 77 | const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout 78 | 79 | const response = await fetch(this.apiUrlVersion, { 80 | signal: controller.signal, 81 | method: 'GET', 82 | mode: 'cors', 83 | cache: 'no-cache' 84 | }); 85 | 86 | clearTimeout(timeoutId); 87 | 88 | if (response.ok) { 89 | const data = await response.json(); 90 | return data.version || null; 91 | } else { 92 | console.warn('Could not fetch app version from API'); 93 | return null; 94 | } 95 | } catch (error) { 96 | console.warn('Could not fetch app version, using fallback:', error); 97 | return null; 98 | } 99 | } 100 | 101 | async inspectSpotifyUrl(spotifyUrl) { 102 | const controller = new AbortController(); 103 | const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout 104 | 105 | const response = await fetch(`${this.apiUrl}/inspect?spotify_url=${encodeURIComponent(spotifyUrl)}`, { 106 | signal: controller.signal, 107 | method: 'GET', 108 | headers: { 109 | 'Content-Type': 'application/json', 110 | 'Accept': 'application/json' 111 | } 112 | }); 113 | 114 | clearTimeout(timeoutId); 115 | 116 | if (!response.ok) { 117 | const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); 118 | throw new Error(errorData.detail || 'Error inspecting URL'); 119 | } 120 | 121 | return await response.json(); 122 | } 123 | 124 | async startDownload(formData) { 125 | const controller = new AbortController(); 126 | const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout 127 | 128 | const response = await fetch(`${this.apiUrl}/download`, { 129 | method: 'POST', 130 | headers: { 131 | 'Content-Type': 'application/json', 132 | 'Accept': 'application/json' 133 | }, 134 | body: JSON.stringify(formData), 135 | signal: controller.signal 136 | }); 137 | 138 | clearTimeout(timeoutId); 139 | 140 | if (!response.ok) { 141 | const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); 142 | throw new Error(errorData.detail || 'Download error'); 143 | } 144 | 145 | return await response.json(); 146 | } 147 | 148 | async getDownloadStatus(taskId) { 149 | try { 150 | const response = await fetch(`${this.apiUrl}/download/${taskId}/status`); 151 | if (response.ok) { 152 | return await response.json(); 153 | } 154 | return null; 155 | } catch (error) { 156 | console.warn('Error checking download status:', error); 157 | return null; 158 | } 159 | } 160 | } -------------------------------------------------------------------------------- /SPOTIFYSAVER_UI_SUMMARY.md: -------------------------------------------------------------------------------- 1 | # SpotifySaver Web UI - Resumen de Implementación 2 | 3 | ## ✅ Implementación Completada 4 | 5 | La interfaz web de SpotifySaver ahora está **integrada directamente en el comando `spotifysaver-api`**, proporcionando una solución unificada para la API y la interfaz web. 6 | 7 | ### 🎯 Características Principales 8 | 9 | 1. **Servidor Unificado `spotifysaver-api`** 10 | - Sirve tanto la API como la interfaz web en un solo puerto (8000) 11 | - La interfaz web está disponible en `http://localhost:8000` 12 | - La documentación de la API en `http://localhost:8000/docs` 13 | - Configuración simplificada con un solo servidor 14 | 15 | 2. **Interfaz Web Moderna** 16 | - Diseño responsive y atractivo 17 | - Validación de URLs de Spotify 18 | - Configuración completa de parámetros de descarga 19 | - Monitoreo de progreso en tiempo real 20 | - Registro de actividad con timestamps 21 | 22 | 3. **Configuración Flexible** 23 | - Formato de audio (M4A/MP3) 24 | - Bitrate configurable (128-320 kbps) 25 | - Directorio de salida personalizable 26 | - Opciones para letras y archivos NFO 27 | - Puerto configurable (default: 8000) 28 | 29 | ### 🔧 Arquitectura Técnica 30 | 31 | #### Backend 32 | - **Servidor Unificado**: FastAPI sirviendo tanto API como UI en puerto 8000 33 | - **Archivos Estáticos**: Servidos desde `spotifysaver/ui/` 34 | - **Rutas Absolutas**: Usa Path para resolver rutas independientemente del sistema operativo 35 | - **Configuración**: Variables de entorno y argumentos CLI 36 | 37 | #### Frontend 38 | - **Arquitectura Modular**: Código JavaScript organizado en 5 módulos especializados 39 | - `api-client.js` - Comunicación con API 40 | - `state-manager.js` - Persistencia de estado 41 | - `ui-manager.js` - Actualizaciones de interfaz 42 | - `download-manager.js` - Gestión de descargas 43 | - `app.js` - Controlador principal 44 | - **HTML5**: Estructura semántica moderna 45 | - **CSS3**: Diseño gradient, animaciones, responsive 46 | - **UX**: Validación, feedback visual, logging en tiempo real 47 | 48 | ### 📁 Estructura de Archivos 49 | 50 | ``` 51 | spotifysaver/ 52 | ├── api/ 53 | │ ├── app.py # Aplicación FastAPI integrada con UI 54 | │ └── ... 55 | ├── ui/ 56 | │ ├── index.html # Página principal 57 | │ ├── static/ 58 | │ │ ├── css/ 59 | │ │ │ └── styles.css # Estilos 60 | │ │ └── js/ 61 | │ │ ├── api-client.js # Cliente API 62 | │ │ ├── state-manager.js # Gestión de estado 63 | │ │ ├── ui-manager.js # Gestión UI 64 | │ │ ├── download-manager.js # Gestión descargas 65 | │ │ └── app.js # Aplicación principal 66 | │ └── README.md # Documentación del UI 67 | ``` 68 | 69 | ### 🚀 Uso del Comando 70 | 71 | ```bash 72 | # Uso básico - Inicia API + UI en puerto 8000 73 | spotifysaver-api 74 | 75 | # Con puerto personalizado 76 | spotifysaver-api --port 8080 77 | 78 | # Con host específico 79 | spotifysaver-api --host 0.0.0.0 80 | ``` 81 | 82 | **Acceso:** 83 | - **Interfaz Web**: http://localhost:8000 84 | - **Documentación API**: http://localhost:8000/docs 85 | - **Redoc API**: http://localhost:8000/redoc 86 | 87 | ### 🌐 Funcionalidades Web 88 | 89 | 1. **Entrada de URL**: Campo validado para URLs de Spotify 90 | 2. **Configuración de Audio**: 91 | - Formato: M4A (recomendado) o MP3 92 | - Bitrate: 128, 192, 256, 320 kbps o "Mejor calidad" 93 | 3. **Opciones Avanzadas**: 94 | - Directorio de salida personalizable 95 | - Incluir letras sincronizadas 96 | - Generar archivos NFO para Jellyfin/Kodi 97 | 4. **Monitoreo**: 98 | - Barra de progreso visual 99 | - Estado de descarga en tiempo real 100 | - Log de actividad detallado 101 | 5. **Experiencia de Usuario**: 102 | - Validación de formularios 103 | - Feedback visual inmediato 104 | - Diseño responsive para móviles 105 | 106 | ### 🔧 Configuración Avanzada 107 | 108 | #### Variables de Entorno 109 | - `SPOTIFYSAVER_API_PORT`: Puerto del servidor (default: 8000) 110 | - `SPOTIFYSAVER_API_HOST`: Host del servidor (default: 0.0.0.0) 111 | 112 | #### Argumentos CLI 113 | - `--port`: Puerto del servidor 114 | - `--host`: Host del servidor 115 | 116 | ### 💡 Características Técnicas 117 | 118 | 1. **Arquitectura Integrada**: 119 | - FastAPI sirve tanto la API REST como la interfaz web 120 | - Servidor único en puerto 8000 121 | - Manejo limpio de shutdown (Ctrl+C) 122 | 123 | 2. **Comunicación**: 124 | - CORS configurado para desarrollo 125 | - Validación de formularios en frontend 126 | - API REST documentada con Swagger/ReDoc 127 | 128 | 3. **Compatibilidad**: 129 | - Rutas estáticas para CSS/JS 130 | - Manejo de errores robusto 131 | - Logging detallado 132 | 133 | ### 🎨 Diseño Visual 134 | 135 | - **Tema**: Gradient azul-púrpura moderno 136 | - **Responsivo**: Adapta a pantallas móviles 137 | - **Accesibilidad**: Etiquetas semánticas y contraste adecuado 138 | - **Animaciones**: Transiciones suaves y feedback visual 139 | - **Estados**: Colores diferenciados para éxito, error, advertencia 140 | 141 | ### 🔄 Actualización del Proyecto 142 | 143 | 1. **spotifysaver/api/app.py**: Integrada interfaz web en FastAPI 144 | 2. **pyproject.toml**: Configuración de archivos UI en package 145 | 3. **README.md**: Documentación del servidor unificado 146 | 4. **Instalación**: Compatible con instalación via pip/poetry existente 147 | 148 | ### ✅ Pruebas Realizadas 149 | 150 | - ✅ Instalación del paquete via pip/poetry 151 | - ✅ Inicio del servidor con `spotifysaver-api` 152 | - ✅ Acceso a interfaz web en http://localhost:8000 153 | - ✅ Interfaz web responsive 154 | - ✅ Comunicación frontend-backend 155 | - ✅ Validación de formularios 156 | - ✅ Manejo de errores 157 | - ✅ Compatibilidad cross-platform (Windows/Linux/macOS) 158 | 159 | La interfaz web está completamente integrada en `spotifysaver-api` y lista para uso. Proporciona una interfaz moderna y fácil de usar que hace que SpotifySaver sea accesible para usuarios que prefieren interfaces gráficas sobre la línea de comandos. 160 | -------------------------------------------------------------------------------- /spotifysaver/cli/commands/download/album.py: -------------------------------------------------------------------------------- 1 | """Album download command module for SpotifySaver CLI. 2 | 3 | This module handles the download process for complete Spotify albums, 4 | including progress tracking, metadata generation, and cover art download. 5 | """ 6 | 7 | import click 8 | from spotifysaver.downloader import YouTubeDownloader, YouTubeDownloaderForCLI 9 | from spotifysaver.services import SpotifyAPI, YoutubeMusicSearcher, ScoreMatchCalculator 10 | 11 | 12 | def process_album( 13 | spotify: SpotifyAPI, 14 | searcher: YoutubeMusicSearcher, 15 | downloader: YouTubeDownloaderForCLI, 16 | url, 17 | lyrics, 18 | nfo, 19 | cover, 20 | output_format, 21 | bitrate, 22 | explain=False, 23 | dry_run=False 24 | ): 25 | """Process and download a complete Spotify album with progress tracking. 26 | 27 | Downloads all tracks from a Spotify album, showing a progress bar and 28 | handling optional features like lyrics, NFO metadata, and cover art. 29 | 30 | Args: 31 | spotify: SpotifyAPI instance for fetching album data 32 | searcher: YoutubeMusicSearcher for finding YouTube matches 33 | downloader: YouTubeDownloader for downloading and processing files 34 | url: Spotify album URL 35 | lyrics: Whether to download synchronized lyrics 36 | nfo: Whether to generate Jellyfin metadata files 37 | cover: Whether to download album cover art 38 | format: Audio format for downloaded files 39 | bitrate: Audio bitrate in kbps (96, 128, 192, 256) 40 | explain: Whether to show score breakdown for each track without downloading 41 | """ 42 | album = spotify.get_album(url) 43 | click.secho(f"\nDownloading album: {album.name}", fg="cyan") 44 | 45 | # Explain mode: show score breakdown without downloading 46 | if explain: 47 | scorer = ScoreMatchCalculator() 48 | click.secho(f"\n🔍 Explaining matches for album: {album.name}", fg="cyan") 49 | 50 | for track in album.tracks: 51 | click.secho(f"\n🎵 Track: {track.name}", fg="yellow") 52 | results = searcher.search_raw(track) 53 | 54 | if not results: 55 | click.echo(" ⚠ No candidates found.") 56 | continue 57 | 58 | for result in results: 59 | explanation = scorer.explain_score(result, track, strict=True) 60 | click.echo(f" - Candidate: {explanation['yt_title']}") 61 | click.echo(f" Video ID: {explanation['yt_videoId']}") 62 | click.echo(f" Duration: {explanation['duration_score']}") 63 | click.echo(f" Artist: {explanation['artist_score']}") 64 | click.echo(f" Title: {explanation['title_score']}") 65 | click.echo(f" Album: {explanation['album_bonus']}") 66 | click.echo(f" → Total: {explanation['total_score']} (passed: {explanation['passed']})") 67 | click.echo("-" * 40) 68 | 69 | best = max(results, key=lambda r: scorer.explain_score(r, track)["total_score"]) 70 | best_expl = scorer.explain_score(best, track) 71 | click.secho(f"\n✅ Best candidate: {best_expl['yt_title']} (score: {best_expl['total_score']})", fg="green") 72 | 73 | return 74 | 75 | # Dry run mode: explain matches without downloading 76 | if dry_run: 77 | from spotifysaver.services.score_match_calculator import ScoreMatchCalculator 78 | 79 | scorer = ScoreMatchCalculator() 80 | click.secho(f"\n🧪 Dry run for album: {album.name}", fg="cyan") 81 | 82 | for track in album.tracks: 83 | result = searcher.search_track(track) 84 | explanation = scorer.explain_score(result, track, strict=True) 85 | click.secho(f"\n🎵 Track: {track.name}", fg="yellow") 86 | click.echo(f" → Selected candidate: {explanation['yt_title']}") 87 | click.echo(f" Video ID: {explanation['yt_videoId']}") 88 | click.echo(f" Total score: {explanation['total_score']} (passed: {explanation['passed']})") 89 | return 90 | 91 | 92 | with click.progressbar( 93 | length=len(album.tracks), 94 | label=" Processing", 95 | fill_char="█", 96 | show_percent=True, 97 | item_show_func=lambda t: t.name[:25] + "..." if t else "", 98 | ) as bar: 99 | 100 | def update_progress(idx, total, name): 101 | bar.label = ( 102 | f" Downloading: {name[:20]}..." 103 | if len(name) > 20 104 | else f" Downloading: {name}" 105 | ) 106 | bar.update(1) 107 | 108 | success, total = downloader.download_album_cli( 109 | album, 110 | download_lyrics=lyrics, 111 | output_format=YouTubeDownloader.string_to_audio_format(output_format), 112 | bitrate=YouTubeDownloader.int_to_bitrate(bitrate), 113 | nfo=nfo, 114 | cover=cover, 115 | progress_callback=update_progress, 116 | ) 117 | 118 | # Display summary 119 | if success > 0: 120 | click.secho(f"\n✔ Downloaded {success}/{total} tracks", fg="green") 121 | if nfo: 122 | click.secho("✔ Generated album metadata (NFO)", fg="green") 123 | else: 124 | click.secho("\n⚠ No tracks downloaded", fg="yellow") 125 | 126 | 127 | def generate_nfo_for_album(downloader, album, cover=False): 128 | """Generate NFO metadata file for an album. 129 | 130 | Creates a Jellyfin-compatible NFO file with album metadata and optionally 131 | downloads the album cover art. 132 | 133 | Args: 134 | downloader: YouTubeDownloader instance for file operations 135 | album: Album object with metadata 136 | cover: Whether to download album cover art 137 | """ 138 | try: 139 | from spotifysaver.metadata import NFOGenerator 140 | 141 | album_dir = downloader._get_album_dir(album) 142 | NFOGenerator.generate(album, album_dir) 143 | 144 | # Download cover if it doesn't exist 145 | if cover and album.cover_url: 146 | cover_path = album_dir / "cover.jpg" 147 | if not cover_path.exists() and album.cover_url: 148 | downloader._save_cover_album(album.cover_url, cover_path) 149 | click.secho(f"✔ Saved album cover: {album_dir}/cover.jpg", fg="green") 150 | 151 | click.secho( 152 | f"\n✔ Generated Jellyfin metadata: {album_dir}/album.nfo", fg="green" 153 | ) 154 | except Exception as e: 155 | click.secho(f"\n⚠ Failed to generate NFO: {str(e)}", fg="yellow") 156 | -------------------------------------------------------------------------------- /spotifysaver/metadata/music_file_metadata.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional, NoReturn 3 | from mutagen import File 4 | from mutagen.id3 import ID3, APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TPOS, TCON 5 | from mutagen.mp4 import MP4, MP4Cover 6 | from mutagen.oggopus import OggOpus 7 | 8 | from spotifysaver.models import Track 9 | from spotifysaver.spotlog import get_logger 10 | from spotifysaver.services import TheAudioDBService 11 | 12 | class MusicFileMetadata: 13 | """Handles metadata addition to music files in MP3, M4A, and Opus formats. 14 | 15 | Args: 16 | file_path: Path to the audio file 17 | track: Track metadata to add 18 | cover_data: Optional cover art binary data 19 | """ 20 | def __init__(self, file_path: Path, track: Track, cover_data: Optional[bytes] = None): 21 | self.file_path = file_path 22 | self.track = track 23 | self.cover_data = cover_data 24 | self.audiodb = TheAudioDBService() 25 | self.logger = get_logger(f"{self.__class__.__name__}") 26 | 27 | def safe_attr(self, obj, attr): 28 | """ 29 | Safely get an attribute from an object. 30 | 31 | Args: 32 | obj: Object to get attribute from 33 | attr: Attribute name 34 | """ 35 | return getattr(obj, attr, None) if obj else None 36 | 37 | def _get_genre(self, track: Track) -> Optional[str]: 38 | """Get the genre of a track. 39 | 40 | Args: 41 | track: Track metadata 42 | 43 | Returns: 44 | str: Genre of the track 45 | """ 46 | self.logger.debug(f"Getting genre for track: {track.number} - {track.name} from TheAudioDB") 47 | track_data = self.audiodb.get_track_metadata(track.name, track.artists[0]) 48 | album_data = self.audiodb.get_album_metadata(track.album_artist[0], track.album_name) 49 | 50 | if track_data: 51 | self.logger.debug(f"Track data type: {type(track_data)} - {track_data}") 52 | if album_data: 53 | self.logger.debug(f"Album data type: {type(album_data)} - {album_data}") 54 | 55 | track_genre = getattr(track_data, "genre", None) if track_data else None 56 | album_genre = getattr(album_data, "genre", None) if album_data else None 57 | 58 | if track_genre: 59 | self.logger.info(f"Genre found in track: {track_genre}") 60 | return track_genre 61 | elif album_genre: 62 | self.logger.info(f"Genre found in album: {album_genre}") 63 | return album_genre 64 | else: 65 | self.logger.warning("No genre found") 66 | return None 67 | 68 | def add_metadata(self) -> bool: 69 | """Add metadata to the audio file. 70 | 71 | Returns: 72 | bool: True if metadata was added successfully 73 | """ 74 | try: 75 | if not self.file_path.exists(): 76 | raise FileNotFoundError(f"File {self.file_path} not found") 77 | 78 | handler = { 79 | '.mp3': self._add_mp3_metadata, 80 | '.m4a': self._add_m4a_metadata, 81 | '.opus': self._add_opus_metadata 82 | }.get(self.file_path.suffix.lower()) 83 | 84 | if not handler: 85 | raise ValueError(f"Unsupported file format: {self.file_path.suffix}") 86 | 87 | handler() 88 | self.logger.info(f"Metadata added to {self.file_path}") 89 | return True 90 | 91 | except Exception as e: 92 | self.logger.error(f"Failed to add metadata: {str(e)}") 93 | return False 94 | 95 | def _add_mp3_metadata(self) -> NoReturn: 96 | """Add ID3 tags to MP3 files.""" 97 | audio = ID3(str(self.file_path)) 98 | 99 | 100 | # Text frames 101 | frames = [ 102 | TIT2(encoding=3, text=self.track.name), # Title 103 | TPE1(encoding=3, text="/".join(self.track.artists)), # Artists 104 | TPE2(encoding=3, text="/".join(self.track.album_artist)), # Album artists 105 | TALB(encoding=3, text=self.track.album_name), # Album name 106 | TRCK(encoding=3, text=f"{self.track.number}/{self.track.total_tracks}"), # Track number 107 | TPOS(encoding=3, text=str(self.track.disc_number)), # Disc number 108 | ] 109 | 110 | if self.track.release_date: 111 | frames.append(TDRC(encoding=3, text=self.track.release_date[:4])) # Release year 112 | 113 | genre = self._get_genre(self.track) 114 | if genre: 115 | try: 116 | frames.append(TCON(encoding=3, text=genre)) # Genres 117 | except: 118 | self.logger.error(f"Failed to add genre: {genre}") 119 | 120 | # Add all frames 121 | for frame in frames: 122 | audio.add(frame) 123 | 124 | # Add cover art if available 125 | if self.cover_data: 126 | audio.add(APIC( 127 | encoding=3, 128 | mime='image/jpeg', 129 | type=3, # Front cover 130 | data=self.cover_data 131 | )) 132 | 133 | audio.save(v2_version=3) # ID3v2.3 for maximum compatibility 134 | 135 | def _add_m4a_metadata(self) -> NoReturn: 136 | """Add metadata to M4A (MP4) files.""" 137 | audio = MP4(str(self.file_path)) 138 | 139 | 140 | audio.update({ 141 | '\xa9nam': [self.track.name], 142 | '\xa9ART': ["/".join(self.track.artists)], 143 | 'aART': ["/".join(self.track.album_artist)], 144 | '\xa9alb': [self.track.album_name], 145 | '\xa9day': [self.track.release_date[:4]], 146 | 'trkn': [(self.track.number, self.track.total_tracks)], 147 | 'disk': [(self.track.disc_number, 1)], 148 | }) 149 | 150 | genre = self._get_genre(self.track) 151 | if genre: 152 | audio['\xa9gen'] = [genre] 153 | 154 | if self.cover_data: 155 | audio['covr'] = [MP4Cover(self.cover_data, imageformat=MP4Cover.FORMAT_JPEG)] 156 | 157 | audio.save() 158 | 159 | def _add_opus_metadata(self) -> NoReturn: 160 | """Add metadata to Opus files.""" 161 | audio = OggOpus(str(self.file_path)) 162 | 163 | audio.update({ 164 | 'title': self.track.name, 165 | 'artist': "/".join(self.track.artists), 166 | 'album': self.track.album_name, 167 | 'date': self.track.release_date[:4], 168 | 'tracknumber': f"{self.track.number}/{self.track.total_tracks}", 169 | 'discnumber': str(self.track.disc_number), 170 | }) 171 | 172 | genre = self._get_genre(self.track) 173 | if genre: 174 | audio['genre'] = genre 175 | 176 | if self.cover_data: 177 | audio['cover'] = self.cover_data 178 | 179 | audio.save() -------------------------------------------------------------------------------- /spotifysaver/api/services/download_service.py: -------------------------------------------------------------------------------- 1 | """Download service for API operations""" 2 | 3 | import asyncio 4 | from pathlib import Path 5 | from typing import Dict, Optional, Callable, Any 6 | 7 | from ...services import SpotifyAPI, YoutubeMusicSearcher 8 | from ...downloader import YouTubeDownloader, YouTubeDownloaderForCLI 9 | from ...enums import AudioFormat, Bitrate 10 | from ...spotlog import get_logger 11 | from ..config import APIConfig 12 | 13 | logger = get_logger("DownloadService") 14 | 15 | 16 | class DownloadService: 17 | """Service class for handling download operations via API.""" 18 | 19 | def __init__( 20 | self, 21 | output_dir: Optional[str] = None, 22 | download_lyrics: bool = False, 23 | download_cover: bool = True, 24 | generate_nfo: bool = False, 25 | output_format: str = "m4a", 26 | bit_rate: int = 128, 27 | ): 28 | """Initialize the download service. 29 | 30 | Args: 31 | output_dir: Custom output directory 32 | download_lyrics: Whether to download lyrics 33 | download_cover: Whether to download cover art 34 | generate_nfo: Whether to generate NFO files 35 | output_format: Audio format for downloads 36 | """ 37 | self.output_dir = output_dir or APIConfig.get_output_dir() 38 | self.download_lyrics = download_lyrics 39 | self.download_cover = download_cover 40 | self.generate_nfo = generate_nfo 41 | # Convert string format to enum for internal use 42 | self.output_format = YouTubeDownloader.string_to_audio_format(output_format) 43 | self.bit_rate = YouTubeDownloader.int_to_bitrate(bit_rate) 44 | 45 | # Initialize services 46 | self.spotify = SpotifyAPI() 47 | self.searcher = YoutubeMusicSearcher() 48 | self.downloader = YouTubeDownloaderForCLI(base_dir=self.output_dir) 49 | 50 | async def download_from_url( 51 | self, 52 | spotify_url: str, 53 | progress_callback: Optional[Callable[[int, int, str], None]] = None, 54 | ) -> Dict[str, Any]: 55 | """Download content from a Spotify URL. 56 | 57 | Args: 58 | spotify_url: Spotify URL to download 59 | progress_callback: Optional callback for progress updates 60 | 61 | Returns: 62 | Dict containing download results and statistics 63 | """ 64 | try: 65 | if "track" in spotify_url: 66 | return await self._download_track(spotify_url, progress_callback) 67 | elif "album" in spotify_url: 68 | return await self._download_album(spotify_url, progress_callback) 69 | elif "playlist" in spotify_url: 70 | return await self._download_playlist(spotify_url, progress_callback) 71 | else: 72 | raise ValueError("Invalid Spotify URL type") 73 | 74 | except Exception as e: 75 | logger.error(f"Error downloading from {spotify_url}: {str(e)}") 76 | raise 77 | 78 | async def _download_track( 79 | self, 80 | track_url: str, 81 | progress_callback: Optional[Callable[[int, int, str], None]] = None, 82 | ) -> Dict[str, Any]: 83 | """Download a single track.""" 84 | track = self.spotify.get_track(track_url) 85 | 86 | if progress_callback: 87 | progress_callback(1, 1, track.name) 88 | 89 | # Run download in thread pool to avoid blocking 90 | loop = asyncio.get_event_loop() 91 | audio_path, updated_track = await loop.run_in_executor( 92 | None, self._download_track_sync, track 93 | ) 94 | 95 | return { 96 | "content_type": "track", 97 | "completed_tracks": 1 if audio_path else 0, 98 | "failed_tracks": 0 if audio_path else 1, 99 | "total_tracks": 1, 100 | "output_directory": str(audio_path.parent) if audio_path else None, 101 | } 102 | 103 | async def _download_album( 104 | self, 105 | album_url: str, 106 | progress_callback: Optional[Callable[[int, int, str], None]] = None, 107 | ) -> Dict[str, Any]: 108 | """Download an entire album.""" 109 | album = self.spotify.get_album(album_url) 110 | 111 | # Create a wrapper for the progress callback 112 | def sync_progress_callback(idx: int, total: int, name: str): 113 | if progress_callback: 114 | progress_callback(idx, total, name) 115 | 116 | # Run download in thread pool 117 | loop = asyncio.get_event_loop() 118 | success, total = await loop.run_in_executor( 119 | None, 120 | self.downloader.download_album_cli, 121 | album, 122 | self.download_lyrics, 123 | self.output_format, 124 | self.bit_rate, 125 | self.generate_nfo, 126 | self.download_cover, 127 | sync_progress_callback, 128 | ) 129 | 130 | output_dir = self.downloader._get_album_dir(album) 131 | 132 | return { 133 | "content_type": "album", 134 | "completed_tracks": success, 135 | "failed_tracks": total - success, 136 | "total_tracks": total, 137 | "output_directory": str(output_dir), 138 | } 139 | 140 | async def _download_playlist( 141 | self, 142 | playlist_url: str, 143 | progress_callback: Optional[Callable[[int, int, str], None]] = None, 144 | ) -> Dict[str, Any]: 145 | """Download an entire playlist.""" 146 | playlist = self.spotify.get_playlist(playlist_url) 147 | 148 | # Create a wrapper for the progress callback 149 | def sync_progress_callback(idx: int, total: int, name: str): 150 | if progress_callback: 151 | progress_callback(idx, total, name) 152 | 153 | # Run download in thread pool 154 | loop = asyncio.get_event_loop() 155 | success, total = await loop.run_in_executor( 156 | None, 157 | self.downloader.download_playlist_cli, 158 | playlist, 159 | self.output_format, 160 | self.bit_rate, 161 | self.download_lyrics, 162 | self.download_cover, 163 | sync_progress_callback, 164 | ) 165 | 166 | output_dir = Path(self.output_dir) / playlist.name 167 | 168 | return { 169 | "content_type": "playlist", 170 | "completed_tracks": success, 171 | "failed_tracks": total - success, 172 | "total_tracks": total, 173 | "output_directory": str(output_dir), 174 | } 175 | 176 | def _download_track_sync(self, track): 177 | """Synchronous track download helper.""" 178 | return self.downloader.download_track_cli( 179 | track, 180 | output_format=self.output_format, 181 | bitrate=self.bit_rate, 182 | download_lyrics=self.download_lyrics 183 | ) 184 | -------------------------------------------------------------------------------- /spotifysaver/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SpotifySaver - Save your music... 7 | 8 | 9 | 10 | 11 |
12 |

🎵 SpotifySaver

13 |

Download your music directly to your Jellyfin directory (or your preferred self-hosted music service)

14 |
15 | 16 | 17 |
18 |
19 | 20 |
21 | 22 |
23 |

Download Status

24 |
25 |
Waiting...
26 | 32 |
33 |
34 | 35 | 36 |
37 |

Download Settings

38 | 39 |
40 | 41 | 46 |
47 | 48 |
49 | 50 | 54 |
55 | 56 |
57 |
58 | 59 | 64 |
65 | 66 |
67 | 68 | 75 |
76 |
77 | 78 |
79 | 83 |
84 | 85 |
86 | 90 |
91 | 92 | 95 |
96 |
97 | 98 | 99 |
100 | 101 | 102 | 103 | 104 |
105 |

Details

106 |
107 |
Awaiting inspection...
108 | 109 |
110 |
111 | 112 | 113 |
114 |
115 |

Activity Log

116 | 117 |
118 |
119 |
120 |
121 |
122 | 123 |
124 |
125 |
126 | 127 | 128 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /spotifysaver/api/README.md: -------------------------------------------------------------------------------- 1 | # SpotifySaver API 2 | 3 | Una API REST construida con FastAPI para descargar música de Spotify vía YouTube Music. 4 | 5 | ## ⚙️ Configuración 6 | 7 | ### 1. Credenciales de Spotify API 8 | 9 | 1. Ve al [Dashboard de Spotify for Developers](https://developer.spotify.com/dashboard/applications) 10 | 2. Crea una nueva aplicación o usa una existente 11 | 3. Copia el `Client ID` y `Client Secret` 12 | 4. Crea un archivo `.env` en la raíz del proyecto: 13 | 14 | ```bash 15 | # Copia el archivo de ejemplo 16 | cp .env.example .env 17 | ``` 18 | 19 | 5. Edita el archivo `.env` con tus credenciales: 20 | 21 | ```env 22 | SPOTIFY_CLIENT_ID=tu_client_id_aqui 23 | SPOTIFY_CLIENT_SECRET=tu_client_secret_aqui 24 | SPOTIFYSAVER_OUTPUT_DIR=Music # Opcional 25 | ``` 26 | 27 | ### 2. Instalar dependencias 28 | 29 | ```bash 30 | # Instalar todas las dependencias 31 | pip install -r requirements.txt 32 | 33 | # O solo las dependencias de la API 34 | pip install fastapi uvicorn 35 | ``` 36 | 37 | ## 🚀 Inicio Rápido 38 | 39 | ### Ejecutar el servidor 40 | 41 | ```bash 42 | # Usando Poetry 43 | poetry run uvicorn spotifysaver.api.main:app --reload 44 | 45 | # O directamente 46 | python -m spotifysaver.api.main 47 | 48 | # O usando el script 49 | spotifysaver-api 50 | ``` 51 | 52 | El servidor estará disponible en: `http://localhost:8000` 53 | 54 | ## 📚 Documentación 55 | 56 | - **Swagger UI**: `http://localhost:8000/docs` 57 | - **ReDoc**: `http://localhost:8000/redoc` 58 | 59 | ## 🔌 Endpoints 60 | 61 | ### GET `/` 62 | Información básica de la API. 63 | 64 | ### GET `/health` 65 | Verificación de estado del servicio. 66 | 67 | ### GET `/api/v1/inspect` 68 | Inspecciona una URL de Spotify y devuelve los metadatos sin descargar. 69 | 70 | **Parámetros:** 71 | - `spotify_url` (string): URL de Spotify 72 | 73 | **Ejemplo:** 74 | ```bash 75 | curl "http://localhost:8000/api/v1/inspect?spotify_url=https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh" 76 | ``` 77 | 78 | ### POST `/api/v1/download` 79 | Inicia una tarea de descarga. 80 | 81 | **Cuerpo de la petición:** 82 | ```json 83 | { 84 | "spotify_url": "https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy", 85 | "download_lyrics": false, 86 | "download_cover": true, 87 | "generate_nfo": false, 88 | "output_format": "m4a", 89 | "output_dir": "Music" 90 | } 91 | ``` 92 | 93 | **Respuesta:** 94 | ```json 95 | { 96 | "task_id": "uuid-task-id", 97 | "status": "pending", 98 | "spotify_url": "https://open.spotify.com/album/...", 99 | "content_type": "album", 100 | "message": "Download task started for album" 101 | } 102 | ``` 103 | 104 | ### GET `/api/v1/download/{task_id}/status` 105 | Obtiene el estado de una tarea de descarga. 106 | 107 | **Respuesta:** 108 | ```json 109 | { 110 | "task_id": "uuid-task-id", 111 | "status": "processing", 112 | "progress": 45, 113 | "current_track": "Track Name", 114 | "total_tracks": 12, 115 | "completed_tracks": 5, 116 | "failed_tracks": 0, 117 | "output_directory": "/path/to/music", 118 | "started_at": "2024-01-01T12:00:00" 119 | } 120 | ``` 121 | 122 | ### GET `/api/v1/download/{task_id}/cancel` 123 | Cancela una tarea de descarga. 124 | 125 | ### GET `/api/v1/downloads` 126 | Lista todas las tareas de descarga. 127 | 128 | ## 💡 Ejemplos de Uso 129 | 130 | ### Python con requests 131 | 132 | ```python 133 | import requests 134 | 135 | # Inspeccionar un álbum 136 | response = requests.get( 137 | "http://localhost:8000/api/v1/inspect", 138 | params={"spotify_url": "https://open.spotify.com/album/..."} 139 | ) 140 | metadata = response.json() 141 | 142 | # Iniciar descarga 143 | download_request = { 144 | "spotify_url": "https://open.spotify.com/album/...", 145 | "download_lyrics": True, 146 | "download_cover": True, 147 | "generate_nfo": True 148 | } 149 | response = requests.post( 150 | "http://localhost:8000/api/v1/download", 151 | json=download_request 152 | ) 153 | task = response.json() 154 | 155 | # Verificar estado 156 | status_response = requests.get( 157 | f"http://localhost:8000/api/v1/download/{task['task_id']}/status" 158 | ) 159 | status = status_response.json() 160 | ``` 161 | 162 | ### JavaScript/Node.js 163 | 164 | ```javascript 165 | // Inspeccionar URL 166 | const inspectResponse = await fetch( 167 | `http://localhost:8000/api/v1/inspect?spotify_url=${encodeURIComponent(spotifyUrl)}` 168 | ); 169 | const metadata = await inspectResponse.json(); 170 | 171 | // Iniciar descarga 172 | const downloadResponse = await fetch('http://localhost:8000/api/v1/download', { 173 | method: 'POST', 174 | headers: { 'Content-Type': 'application/json' }, 175 | body: JSON.stringify({ 176 | spotify_url: spotifyUrl, 177 | download_lyrics: true, 178 | download_cover: true, 179 | generate_nfo: false 180 | }) 181 | }); 182 | const task = await downloadResponse.json(); 183 | 184 | // Verificar estado 185 | const statusResponse = await fetch( 186 | `http://localhost:8000/api/v1/download/${task.task_id}/status` 187 | ); 188 | const status = await statusResponse.json(); 189 | ``` 190 | 191 | ### cURL 192 | 193 | ```bash 194 | # Inspeccionar 195 | curl "http://localhost:8000/api/v1/inspect?spotify_url=https://open.spotify.com/track/..." 196 | 197 | # Iniciar descarga 198 | curl -X POST "http://localhost:8000/api/v1/download" \ 199 | -H "Content-Type: application/json" \ 200 | -d '{ 201 | "spotify_url": "https://open.spotify.com/album/...", 202 | "download_lyrics": true, 203 | "download_cover": true 204 | }' 205 | 206 | # Verificar estado 207 | curl "http://localhost:8000/api/v1/download/{task_id}/status" 208 | ``` 209 | 210 | ## ⚙️ Configuración 211 | 212 | ### Variables de Entorno 213 | 214 | ```bash 215 | # Archivo .env 216 | SPOTIFY_CLIENT_ID=tu_client_id 217 | SPOTIFY_CLIENT_SECRET=tu_client_secret 218 | YTDLP_COOKIES_PATH="cookies.txt" # Opcional 219 | SPOTIFYSAVER_OUTPUT_DIR="Music" # Directorio de salida por defecto 220 | ``` 221 | 222 | ### Configuración de CORS 223 | 224 | Por defecto, la API permite conexiones desde: 225 | - `http://localhost:*` 226 | - `http://127.0.0.1:*` 227 | 228 | Para modificar esto, edita `spotifysaver/api/config.py`. 229 | 230 | ## 🔄 Estados de Descarga 231 | 232 | - **`pending`**: Tarea creada, esperando procesamiento 233 | - **`processing`**: Descarga en progreso 234 | - **`completed`**: Descarga completada exitosamente 235 | - **`failed`**: Error durante la descarga 236 | - **`cancelled`**: Tarea cancelada por el usuario 237 | 238 | ## 📁 Estructura de Salida 239 | 240 | ``` 241 | Music/ 242 | ├── Artista/ 243 | │ ├── Álbum (Año)/ 244 | │ │ ├── 01 - Canción.m4a 245 | │ │ ├── 01 - Canción.lrc # Si se solicitan letras 246 | │ │ ├── album.nfo # Si se solicita NFO 247 | │ │ └── cover.jpg # Si se solicita portada 248 | │ └── ... 249 | └── Playlist Name/ 250 | ├── Track 01.m4a 251 | ├── Track 02.m4a 252 | └── cover.jpg 253 | ``` 254 | 255 | ## 🚨 Limitaciones 256 | 257 | - Las descargas son procesadas secuencialmente para evitar sobrecarga 258 | - El almacenamiento de tareas es en memoria (se reinicia con el servidor) 259 | - Se recomienda usar Redis o una base de datos para producción 260 | - Las cookies de YouTube Music pueden ser necesarias para contenido restringido 261 | 262 | ## 🛡️ Consideraciones de Seguridad 263 | 264 | - La API no incluye autenticación por defecto 265 | - No expongas la API directamente a internet sin autenticación 266 | - Considera usar un proxy reverso (nginx) para producción 267 | - Valida y sanitiza todas las URLs de entrada 268 | 269 | ## 📝 Logging 270 | 271 | Los logs se generan usando el sistema de logging de SpotifySaver. Para habilitar logs detallados: 272 | 273 | ```python 274 | from spotifysaver.spotlog import LoggerConfig 275 | LoggerConfig.setup(level="DEBUG") 276 | ``` 277 | -------------------------------------------------------------------------------- /spotifysaver/services/audiodb_parser.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from spotifysaver.services.schemas import ( 3 | TrackADBResponse, 4 | AlbumADBResponse, 5 | MediaAlbumURLs, 6 | AlbumDescription, 7 | MediaArtistURLs, 8 | ArtistBiography, 9 | ArtistADBResponse 10 | ) 11 | 12 | class AudioDBParser(): 13 | def __init__(self): 14 | pass 15 | 16 | def _safe_int(self, value: Optional[str]) -> Optional[int]: 17 | """ 18 | Safe method for converting a string to an integer. 19 | """ 20 | try: 21 | return int(value) if value not in (None, "None", "") else None 22 | except ValueError: 23 | return None 24 | 25 | 26 | def parse_track(self, raw_data: dict) -> Optional[TrackADBResponse]: 27 | """ 28 | Parse track data from TheAudioDB. 29 | 30 | Args: 31 | raw_data (dict): Raw track data from TheAudioDB. 32 | 33 | Returns: 34 | TrackADBResponse: Parsed track data. 35 | """ 36 | if not raw_data: 37 | return None 38 | 39 | track_data = TrackADBResponse( 40 | id=self._safe_int(raw_data.get("idTrack", None)), 41 | name=raw_data.get("strTrack", None), 42 | album_id=self._safe_int(raw_data.get("idAlbum", None)), 43 | album_name=raw_data.get("strAlbum", None), 44 | artist_id=[self._safe_int(raw_data.get("idArtist", None))], 45 | artist_name=[raw_data.get("strArtist", None)], 46 | duration=self._safe_int(raw_data.get("intDuration", None)), 47 | track_number=self._safe_int(raw_data.get("intTrackNumber", None)), 48 | genre=raw_data.get("strGenre", None), 49 | mood=raw_data.get("strMood", None), 50 | style=raw_data.get("strStyle", None), 51 | lyrics=raw_data.get("strLyrics", None), 52 | musicbrainz_id=raw_data.get("strMusicBrainzID", None), 53 | album_musicbrainz_id=raw_data.get("strMusicBrainzAlbumID", None), 54 | artist_musicbrainz_id=raw_data.get("strMusicBrainzArtistID", None) 55 | ) 56 | return track_data 57 | 58 | def parse_album_urls(self, raw_data: dict) -> Optional[MediaAlbumURLs]: 59 | """ 60 | Parse album URLs from TheAudioDB. 61 | 62 | Args: 63 | raw_data (dict): Raw album data from TheAudioDB. 64 | 65 | Returns: 66 | MediaAlbumURLs: Parsed album URLs. 67 | """ 68 | if not raw_data: 69 | return None 70 | 71 | url_data = MediaAlbumURLs( 72 | thumb_url=raw_data.get("strAlbumThumb", None), 73 | back_url=raw_data.get("strAlbumBack", None), 74 | cd_art_url=raw_data.get("strAlbumCDart", None), 75 | case_url=raw_data.get("strAlbum3DCase", None), 76 | face_url=raw_data.get("strAlbumFace", None), 77 | flat_url=raw_data.get("strAlbum3DFlat", None), 78 | cd_thumb_url=raw_data.get("strAlbum3DThumb", None) 79 | ) 80 | return url_data 81 | 82 | def parse_album_description(self, raw_data: dict) -> Optional[List[AlbumDescription]]: 83 | """ 84 | Parse album descriptions from TheAudioDB. 85 | 86 | Args: 87 | raw_data (dict): Raw album data from TheAudioDB. 88 | 89 | Returns: 90 | List[AlbumDescription]: Parsed album descriptions. 91 | """ 92 | if not raw_data: 93 | return None 94 | 95 | descriptions: List[AlbumDescription] = [] 96 | for key, value in raw_data.items(): 97 | if key.startswith("strDescription"): 98 | # Extraer el código de idioma (ej. "CN", "IT", "JP", "RU") 99 | lang_code = key.replace("strDescription", "") 100 | # Normalizar valor: si viene "None" o None, lo dejamos vacío 101 | desc_text = None if value in (None, "None") else value 102 | descriptions.append( 103 | AlbumDescription(language=lang_code, description=desc_text or "") 104 | ) 105 | 106 | return descriptions if descriptions else None 107 | 108 | def parse_album(self, raw_data: dict) -> AlbumADBResponse: 109 | """ 110 | Parse album data from TheAudioDB. 111 | 112 | Args: 113 | raw_data (dict): Raw album data from TheAudioDB. 114 | 115 | Returns: 116 | AlbumADBResponse: Parsed album data. 117 | """ 118 | if not raw_data: 119 | return None 120 | 121 | album_data = AlbumADBResponse( 122 | id=self._safe_int(raw_data.get("idAlbum", None)), 123 | name=raw_data.get("strAlbum", None), 124 | artist_id=self._safe_int(raw_data.get("idArtist", None)), 125 | artist_name=raw_data.get("strArtist", None), 126 | genre=raw_data.get("strGenre", None), 127 | style=raw_data.get("strStyle", None), 128 | mood=raw_data.get("strMood", None), 129 | musicbrainz_id=raw_data.get("strMusicBrainzID"), 130 | artist_musicbrainz_id=raw_data.get("strMusicBrainzArtistID"), 131 | release_format=raw_data.get("strReleaseFormat", None), 132 | release_date=raw_data.get("strReleaseDate", None), 133 | description=self.parse_album_description(raw_data), 134 | media_album=self.parse_album_urls(raw_data) 135 | ) 136 | return album_data 137 | 138 | def parse_artist_urls(self, raw_data: dict) -> MediaArtistURLs: 139 | """ 140 | Parse artist URLs from TheAudioDB. 141 | 142 | Args: 143 | raw_data (dict): Raw artist data from TheAudioDB. 144 | 145 | Returns: 146 | MediaArtistURLs: Parsed artist URLs. 147 | """ 148 | if not raw_data: 149 | return None 150 | 151 | url_data = MediaArtistURLs( 152 | thumb_url=raw_data.get("strArtistThumb", None), 153 | logo_url=raw_data.get("strArtistLogo", None), 154 | clearart_url=raw_data.get("strArtistClearart", None), 155 | wide_thumb_url=raw_data.get("strArtistWideThumb", None), 156 | banner_url=raw_data.get("strArtistBanner", None), 157 | fanarts=raw_data.get("strArtistFanart", None) 158 | ) 159 | return url_data 160 | 161 | def parse_artist_biography(self, raw_data: dict) -> ArtistBiography: 162 | """ 163 | Parse artist biography from TheAudioDB. 164 | 165 | Args: 166 | raw_data (dict): Raw artist data from TheAudioDB. 167 | 168 | Returns: 169 | ArtistBiography: Parsed artist biography. 170 | """ 171 | if not raw_data: 172 | return None 173 | 174 | biographies: List[ArtistBiography] = [] 175 | for key, value in raw_data.items(): 176 | if key.startswith("strBiography"): 177 | # 178 | lang_code = key.replace("strBiography", "") 179 | desc_text = None if value in (None, "None") else value 180 | biographies.append( 181 | ArtistBiography(language=lang_code, biography=desc_text or "") 182 | ) 183 | return biographies if biographies else None 184 | 185 | def parse_artist(self, raw_data: dict) -> ArtistADBResponse: 186 | """ 187 | Parse artist data from TheAudioDB 188 | 189 | Args: 190 | raw_data (dict): Raw artist data from TheAudioDB. 191 | 192 | Returns: 193 | ArtistADBResponse: Parsed artist data. 194 | """ 195 | if not raw_data: 196 | return None 197 | 198 | artist_data = ArtistADBResponse( 199 | id=self._safe_int(raw_data.get("idArtist", None)), 200 | name=raw_data.get("strArtist", None), 201 | gender=raw_data.get("strGender", None), 202 | country=raw_data.get("strCountry", None), 203 | born_year=self._safe_int(raw_data.get("intBornYear", None)), 204 | die_year=self._safe_int(raw_data.get("intDiedYear", None)), 205 | style=raw_data.get("strStyle", None), 206 | genre=raw_data.get("strGenre", None), 207 | mood=raw_data.get("strMood", None), 208 | musicbrainz_id=raw_data.get("strMusicBrainzID", None), 209 | media_artist=self.parse_artist_urls(raw_data), 210 | briographies=self.parse_artist_biography(raw_data) 211 | ) 212 | return artist_data -------------------------------------------------------------------------------- /spotifysaver/services/score_match_calculator.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Union 2 | from spotifysaver.models.track import Track 3 | from spotifysaver.spotlog import get_logger 4 | 5 | class ScoreMatchCalculator: 6 | """ 7 | Service to calculate match scores between YouTube Music results and Spotify tracks. 8 | """ 9 | def __init__(self): 10 | self.logger = get_logger(f"{self.__class__.__name__}") 11 | 12 | def _similar(self, a: str, b: str) -> float: 13 | """Calculate similarity between strings (0-1) using SequenceMatcher. 14 | 15 | Args: 16 | a: First string to compare 17 | b: Second string to compare 18 | 19 | Returns: 20 | float: Similarity ratio between 0.0 and 1.0 21 | """ 22 | from difflib import SequenceMatcher 23 | 24 | return SequenceMatcher(None, a, b).ratio() 25 | 26 | def _normalize(self, text: str) -> str: 27 | """Consistent text normalization for comparison. 28 | 29 | Removes common words and characters that might interfere with matching. 30 | 31 | Args: 32 | text: Text to normalize 33 | 34 | Returns: 35 | str: Normalized text string 36 | """ 37 | text = ( 38 | text.lower() 39 | .replace("official", "") 40 | .replace("video", "") 41 | .translate(str.maketrans("", "", "()[]-")) 42 | ) 43 | return " ".join([w for w in text.split() if w not in {"lyrics", "audio"}]) 44 | 45 | def _score_duration(self, yt_duration: int, sp_duration: int) -> float: 46 | """ 47 | Score based on duration difference. 48 | 49 | Args: 50 | yt_duration (int): YouTube Music duration in seconds 51 | sp_duration (int): Spotify track duration in seconds 52 | 53 | Returns: 54 | float: Duration score (0.0 to 0.3) 55 | """ 56 | diff = abs(yt_duration - sp_duration) 57 | return 1 if diff <= 2 else max(0, 1 - (diff / 5)) * 0.3 58 | 59 | def _score_artist_overlap(self, yt_artists_raw: List[Dict], sp_artists: List[str]) -> float: 60 | """ 61 | Score based on artist name overlap. 62 | 63 | Args: 64 | yt_artists_raw (List[Dict]): YouTube Music artist data 65 | sp_artists (List[str]): Spotify track artist names 66 | 67 | Returns: 68 | float: Artist overlap score (0.0 to 0.3) 69 | """ 70 | yt_artists = {a["name"].lower() for a in yt_artists_raw if isinstance(a, dict)} 71 | sp_artists_set = {a.lower() for a in sp_artists} 72 | overlap = len(yt_artists & sp_artists_set) / max(len(sp_artists_set), 1) 73 | main_match = sp_artists[0].lower() in yt_artists 74 | return overlap * 0.3 + (0.1 if main_match else 0) 75 | 76 | def _score_title_similarity(self, yt_title: str, sp_title: str) -> float: 77 | """ 78 | Score based on normalized title similarity and token overlap. 79 | 80 | Args: 81 | yt_title (str): YouTube Music title 82 | sp_title (str): Spotify track title 83 | Returns: 84 | float: Title similarity score (0.0 to 0.3) 85 | """ 86 | norm_yt = self._normalize(yt_title) 87 | norm_sp = self._normalize(sp_title) 88 | similarity = self._similar(norm_yt, norm_sp) 89 | 90 | # Penalize if token overlap is weak 91 | yt_tokens = set(norm_yt.split()) 92 | sp_tokens = set(norm_sp.split()) 93 | token_overlap = len(yt_tokens & sp_tokens) / max(len(sp_tokens), 1) 94 | if token_overlap < 0.3: 95 | similarity *= 0.5 96 | 97 | return similarity * 0.3 98 | 99 | def _score_album_bonus(self, album_data: Union[str, Dict], sp_album: str) -> float: 100 | """ 101 | Bonus if album name matches. 102 | 103 | Args: 104 | album_data (Union[str, Dict]): YouTube Music album data 105 | sp_album (str): Spotify album name 106 | 107 | Returns: 108 | float: Bonus score (0.1 or 0) 109 | """ 110 | if not album_data or not sp_album: 111 | return 0 112 | album_name = ( 113 | album_data["name"].lower() 114 | if isinstance(album_data, dict) 115 | else str(album_data).lower() 116 | ) 117 | return 0.1 if sp_album.lower() in album_name else 0 118 | 119 | def _calculate_match_score( 120 | self, yt_result: Dict, track: Track, strict: bool 121 | ) -> float: 122 | """ 123 | Refined scoring system for matching YouTube Music results to Spotify tracks. 124 | 125 | Args: 126 | yt_result (Dict): YouTube Music result data 127 | track (Track): Spotify track data 128 | strict (bool): Whether to use strict scoring thresholds 129 | 130 | Returns: 131 | float: Match score between 0.0 and 1.0+ 132 | """ 133 | try: 134 | # 1. Duration score (30%) 135 | duration_score = self._score_duration(yt_result.get("duration_seconds", 0), track.duration) 136 | 137 | # 2. Artist score (40%) 138 | artist_score = self._score_artist_overlap(yt_result.get("artists", []), track.artists) 139 | 140 | # 3. Title score (30%) 141 | title_score = self._score_title_similarity(yt_result.get("title", ""), track.name) 142 | 143 | # 4. Album bonus (+0.1) 144 | album_bonus = self._score_album_bonus(yt_result.get("album"), track.album_name) 145 | 146 | # Final threshold check 147 | total_score = duration_score + artist_score + title_score + album_bonus 148 | 149 | # Early exit for very low title similarity (experimental) 150 | if title_score < 0.1: 151 | total_score = min(total_score, 0.5) 152 | 153 | # Logging breakdown 154 | self.logger.debug(f"Scoring result for '{yt_result.get('title', 'Unknown')}'") 155 | self.logger.debug(f"Duration score: {duration_score:.3f}") 156 | self.logger.debug(f"Artist score: {artist_score:.3f}") 157 | self.logger.debug(f"Title score: {title_score:.3f}") 158 | self.logger.debug(f"Album bonus: {album_bonus:.3f}") 159 | self.logger.debug(f"Total score: {total_score:.3f}") 160 | 161 | # Apply strict threshold 162 | threshold = 0.7 if strict else 0.6 163 | return total_score if total_score >= threshold else 0 164 | 165 | except Exception as e: 166 | self.logger.error(f"Error calculating score: {str(e)}") 167 | self.logger.debug(f"Problematic result: {yt_result}") 168 | return 0 169 | 170 | def explain_score(self, yt_result: Dict, track: Track, strict: bool = False) -> Dict: 171 | """ 172 | Explain the score breakdown for a given YouTube result and Spotify track. 173 | 174 | Args: 175 | yt_result (Dict): YouTube Music result data 176 | track (Track): Spotify track data 177 | strict (bool): Whether to use strict scoring thresholds 178 | 179 | Returns: 180 | dict: Breakdown of each component and total score 181 | """ 182 | try: 183 | duration_score = self._score_duration(yt_result.get("duration_seconds", 0), track.duration) 184 | artist_score = self._score_artist_overlap(yt_result.get("artists", []), track.artists) 185 | title_score = self._score_title_similarity(yt_result.get("title", ""), track.name) 186 | album_bonus = self._score_album_bonus(yt_result.get("album"), track.album_name) 187 | 188 | total_score = duration_score + artist_score + title_score + album_bonus 189 | threshold = 0.7 if strict else 0.6 190 | passed = total_score >= threshold 191 | 192 | return { 193 | "yt_title": yt_result.get("title", ""), 194 | "yt_videoId": yt_result.get("videoId", ""), 195 | "duration_score": round(duration_score, 3), 196 | "artist_score": round(artist_score, 3), 197 | "title_score": round(title_score, 3), 198 | "album_bonus": round(album_bonus, 3), 199 | "total_score": round(total_score, 3), 200 | "threshold": threshold, 201 | "passed": passed, 202 | } 203 | 204 | except Exception as e: 205 | self.logger.error(f"Error explaining score: {str(e)}") 206 | return {"error": str(e)} 207 | -------------------------------------------------------------------------------- /README_ES.md: -------------------------------------------------------------------------------- 1 | # SpotifySaver 🎵✨ 2 | 3 | [![Python](https://img.shields.io/badge/Python-3.8%2B-blue?logo=python&logoColor=white)](https://www.python.org/) 4 | [![PyPI Version](https://img.shields.io/pypi/v/spotifysaver?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/spotifysaver/) 5 | [![FFmpeg](https://img.shields.io/badge/FFmpeg-Required-orange?logo=ffmpeg&logoColor=white)](https://ffmpeg.org/) 6 | [![yt-dlp](https://img.shields.io/badge/yt--dlp-2023.7.6%2B-red)](https://github.com/yt-dlp/yt-dlp) 7 | [![YouTube Music](https://img.shields.io/badge/YouTube_Music-API-yellow)](https://ytmusicapi.readthedocs.io/) 8 | [![Spotify](https://img.shields.io/badge/Spotify-API-1ED760?logo=spotify)](https://developer.spotify.com/) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) 10 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 11 | [![Preguntale a DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/gabrielbaute/spotify-saver) 12 | 13 | > ⚠️ Este repositorio está bajo una fuerte etapa de desarrollo, espere cambios constantes. Si encuentra algún error o bug, por favor abra un issue. 14 | 15 | Herramienta todo-en-uno para descargar y organizar música con metadata de Spotify para Jellyfin. 16 | 17 | La app se conecta a las API's de Spotify y de YoutubeMusic. El objetivo es generar un archivo .nfo en xml para completar la metadata que requiere jellyfin cuando construye las librerías de música. 18 | 19 | Lee este archivo en [inglés](README.md) 20 | 21 | ## 🌟 Características 22 | - ✅ Descarga audio de YouTube Music con metadata de Spotify 23 | - ✅ Letras sincronizadas (.lrc) desde LRC Lib 24 | - ✅ Generación de archivos `.info` compatibles con Jellyfin (aún hay cosas que mejorar aquí! ⚠️) 25 | - ✅ Estructura automática de carpetas (Artista/Álbum) 26 | - ✅ Interfaz de línea de comandos (CLI) 27 | - ✅ Soporte para playlist 28 | - ✅ API 29 | - ✅ Conversión a mp3 30 | - ✅ Soporte para varios bitrates (128, 180, 220, etc.) 31 | 32 | ### Requisitos 33 | - Python 3.8+ 34 | - FFmpeg 35 | - [Cuenta de desarrollador en Spotify](https://developer.spotify.com/dashboard/) 36 | 37 | ```bash 38 | # Instalación con Poetry (recomendado) 39 | git clone https://github.com/gabrielbaute/spotify-saver.git 40 | cd spotify-saver 41 | poetry install 42 | 43 | # O con pip 44 | pip install git+https://github.com/gabrielbaute/spotify-saver.git 45 | ``` 46 | 47 | ⚠️ IMPORTANTE: Debes acceder a tu cuenta de spotify como desarrollador, crear una app y obtener así una "client id" y un "client secret", debes colocar esa info en un archivo .env en el directorio raíz del proyecto. 48 | 49 | ## ⚙️ Configuración 50 | 51 | Una vez situado en el directorio de tu proyecto, ejecuta: 52 | 53 | ```bash 54 | spotifysaver init 55 | ``` 56 | Esto creará un archivo `.env` local con las variables de entorno que se solicitarán: 57 | 58 | | Variable | Descripción | Valor por defecto | 59 | |---------------------------|--------------------------------------------------|----------------------------------------------| 60 | | `SPOTIFY_CLIENT_ID` | ID de la app de spotify que creaste | - | 61 | | `SPOTIFY_CLIENT_SECRET` | Clave secreta generada para tu app de spotify | - | 62 | | `SPOTIFY_REDIRECT_URI` | URI de validación de la API de Spotify | `http://localhost:8888/callback` | 63 | | `SPOTIFYSAVER_OUTPUT_DIR` | Ruta para directorio personalizado (opcional) | `./Music ` | 64 | | `YTDLP_COOKIES_PATH` | Ruta para el archivo de cookies (opcional) | - | 65 | | `API_PORT` | Puerto del servidor de la API (opcional) | `8000` | 66 | | `API_HOST` | Host para la API (opcional) | `0.0.0.0` | 67 | 68 | 69 | La variable `YTDLP_COOKIES_PATH` indicará la ubicación del archivo con las cookies de Youtube Music, en caso de que tengamos problemas con restricciones a yt-dlp, en concreto es para casos en que youtube bloquee la app por "comportarse como bot" (guiño, guiño) 70 | 71 | También puedes consultar el archivo .example.env 72 | 73 | ## 📚 Documentación 74 | 75 | Mantenemos una [documentación con DeepWiki](https://deepwiki.com/gabrielbaute/spotify-saver), que trackea constantemente el repositorio. Pueden consultarla en todo momento. 76 | 77 | La **documentación para el uso de la API**, por su parte, pueden ubicarla en este mismo repositorio aquí: [Documentación de la API](API_IMPLEMENTATION_SUMMARY.md) 78 | 79 | ## 💻 Uso de la CLI 80 | 81 | ### Comandos disponibles 82 | 83 | | Comando | Descripción | Ejemplo | 84 | |------------------------|--------------------------------------------------|----------------------------------------------| 85 | | `init` | Configura las variables de entorno | `spotifysaver init"` | 86 | | `download [URL]` | Descarga track/álbum de Spotify | `spotifysaver download "URL_SPOTIFY"` | 87 | | `inspect` | Muestra la metadata de spotify (album, playlist) | `spotifysaver inspect "URL_SPOTIFY"` | 88 | | `show-log` | Muestra el log de la aplicación | `spotifysaver show-log` | 89 | | `version` | Muestra la versión instalada | `spotifysaver version` | 90 | 91 | ### Opciones de download 92 | 93 | | Opción | Descripción | Valores aceptados | 94 | |----------------------|------------------------------------------|------------------------------| 95 | | `--lyrics` | Descargar letras sincronizadas (.lrc) | Flag (sin valor) | 96 | | `--output DIR` | Directorio de salida | Ruta válida | 97 | | `--format FORMATO` | Formato de audio | `m4a` (default), `mp3` | 98 | | `--cover` | Descarga la portada del album (.jpg) | Flag (sin valor) | 99 | | `--nfo` | Genera un archivo .nfo con la metadata (para Jellyfin)| Flag (sin valor) | 100 | | `--explain` | Muestra (sin descargar) los puntajes de cada opción en youtube| Flag (sin valor) | 101 | | `--dry-run` | Simula la descarga de un link de spotify sin descargar nada| Flag (sin valor) | 102 | 103 | ### Opciones de show-log 104 | 105 | | Opción | Descripción | Valores aceptados | 106 | |-------------------|------------------------------------------|-------------------------------| 107 | | `--lines` | Número de líneas del log que mostrar | `--lines 25` --> `int` | 108 | | `--level` | Filtra por nivel de log | INFO, WARNING, DEBUG, ERROR | 109 | | `--path` | Muestra la ubicación del archivo de log | Flag (sin valor) | 110 | 111 | ## 💡 Ejemplos de uso 112 | ```bash 113 | # Establece la configuración para spotifysaver 114 | spotifysaver init 115 | 116 | # Descargar álbum con letras sincronizadas 117 | spotifysaver download "https://open.spotify.com/album/..." --lyrics 118 | 119 | # Descargar album con archivo de metadata e imagen de portada 120 | spotifysaver download "https://open.spotify.com/album/..." --nfo --cover 121 | 122 | # Descargar canción en formato MP3 123 | spotifysaver download "https://open.spotify.com/track/..." --format mp3 124 | ``` 125 | 126 | ## Usando la API 127 | 128 | Puedes usar la API de SpotifySaver para interactuar con la aplicación programáticamente. Aquí tienes un ejemplo básico de cómo hacerlo: 129 | 130 | ```bash 131 | spotifysaver-api 132 | ``` 133 | 134 | El servidor estara ejecutándose en `http://localhost:8000` por defecto. Podrán encontrár la [documentación de la API aquí](API_IMPLEMENTATION_SUMMARY.md), donde se describe con detalles los aspectos técnicos y su uso. 135 | 136 | 137 | ## 📂 Estructura de salida 138 | ``` 139 | Music/ 140 | ├── Artista/ 141 | │ ├── Álbum (Año)/ 142 | │ │ ├── 01 - Canción.m4a 143 | │ │ ├── 01 - Canción.lrc 144 | │ │ └── portada.jpg 145 | │ └── artist_info.nfo 146 | ``` 147 | 148 | ## 🤝 Contribuciones 149 | 1. Haz fork del proyecto 150 | 2. Crea tu rama (`git checkout -b feature/nueva-funcion`) 151 | 3. Haz commit de tus cambios (`git commit -m 'Agrega función increíble'`) 152 | 4. Push a la rama (`git push origin feature/nueva-funcion`) 153 | 5. Abre un Pull Request 154 | 155 | ## 📄 Licencia 156 | 157 | MIT © [TGabriel Baute](https://github.com/gabrielbaute) -------------------------------------------------------------------------------- /spotifysaver/services/the_audio_db_service.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from typing import Optional, Dict, Any, List 3 | 4 | from spotifysaver.spotlog import get_logger 5 | from spotifysaver.services.audiodb_parser import AudioDBParser 6 | from spotifysaver.services.schemas import TrackADBResponse, AlbumADBResponse, ArtistADBResponse 7 | 8 | class TheAudioDBService(): 9 | """ 10 | Search for metadata in TheAudioDB. 11 | 12 | This class provides methods to obtain metadata for artist, albums and tracks from TheAudioDB. 13 | """ 14 | def __init__(self): 15 | self.logger = get_logger(f"{__class__.__name__}") 16 | self.url_base = "https://www.theaudiodb.com/api/v1/json/123/" 17 | self.parser = AudioDBParser() 18 | 19 | def _search_artist_by_name(self, artist_name: str) -> Optional[Dict[str, Any]]: 20 | """ 21 | Search for an artist by name. 22 | 23 | Args: 24 | artist_name (str): The name of the artist to search for. 25 | 26 | Returns: 27 | Optional[Dict[str, Any]]: A dictionary containing the artist information if found, otherwise None. 28 | """ 29 | self.logger.info(f"Searching artist by name: {artist_name}") 30 | try: 31 | response = requests.get(f"{self.url_base}search.php?s={artist_name}") 32 | if response.status_code == 200: 33 | raw = response.json() 34 | artists = raw.get("artists") 35 | if artists and isinstance(artists, list) and len(artists) > 0: 36 | return artists[0] 37 | else: 38 | self.logger.warning(f"No artist found for {artist_name}") 39 | return None 40 | 41 | except requests.exceptions.RequestException as e: 42 | self.logger.error(f"Error: {e}") 43 | return None 44 | 45 | def _search_album_by_name(self, artist_name: str, album_name: str) -> Optional[Dict[str, Any]]: 46 | """ 47 | Search for an album by name and artist. 48 | 49 | Args: 50 | artist_name (str): The name of the artist. 51 | album_name (str): The name of the album. 52 | 53 | Returns: 54 | Optional[Dict[str, Any]]: A dictionary containing the album information if found, otherwise None. 55 | """ 56 | self.logger.info(f"Searching album by name: {album_name}") 57 | try: 58 | response = requests.get(f"{self.url_base}searchalbum.php?s={artist_name}&a={album_name}") 59 | if response.status_code == 200: 60 | raw = response.json() 61 | albums = raw.get("album") 62 | if albums and isinstance(albums, list) and len(albums) > 0: 63 | return albums[0] 64 | else: 65 | self.logger.warning(f"No album found for {artist_name} - {album_name}") 66 | return None 67 | 68 | except requests.exceptions.RequestException as e: 69 | self.logger.error(f"Error: {e}") 70 | return None 71 | 72 | def _search_track_by_name(self, artist_name: str, track_name: str) -> Optional[Dict[str, Any]]: 73 | """ 74 | Search for a track by name and artist. 75 | 76 | Args: 77 | artist_name (str): The name of the artist. 78 | track_name (str): The name of the track. 79 | 80 | Returns: 81 | Optional[Dict[str, Any]]: A dictionary containing the track information if found, otherwise None. 82 | """ 83 | self.logger.info(f"Searching track by name: {track_name}") 84 | try: 85 | response = requests.get(f"{self.url_base}searchtrack.php?s={artist_name}&t={track_name}") 86 | if response.status_code == 200: 87 | raw = response.json() 88 | tracks = raw.get("track") 89 | if tracks and isinstance(tracks, list) and len(tracks) > 0: 90 | return tracks[0] 91 | else: 92 | self.logger.warning(f"No track found for {artist_name} - {track_name}") 93 | return None 94 | except requests.exceptions.RequestException as e: 95 | self.logger.error(f"Error: {e}") 96 | return None 97 | 98 | except requests.exceptions.RequestException as e: 99 | self.logger.error(f"Error: {e}") 100 | return None 101 | 102 | def _get_tracks_from_an_album(self, album_id: str) -> Optional[List[Dict[str, Any]]]: 103 | """ 104 | Get tracks from an album. 105 | 106 | Args: 107 | album_id (str): The ID of the album. 108 | 109 | Returns: 110 | Optional[List[Dict[str, Any]]]: A dictionary containing the tracks information if found, otherwise None. 111 | """ 112 | self.logger.info(f"Getting tracks from album: {album_id}") 113 | try: 114 | response = requests.get(f"{self.url_base}track.php?m={album_id}") 115 | if response.status_code == 200: 116 | raw = response.json() 117 | tracks = raw.get("track") 118 | if tracks and isinstance(tracks, list) and len(tracks) > 0: 119 | return tracks 120 | else: 121 | self.logger.warning(f"No tracks found for album: {album_id}") 122 | return None 123 | 124 | except requests.exceptions.RequestException as e: 125 | self.logger.error(f"Error: {e}") 126 | return None 127 | 128 | def _search_track_by_id(self, track_id: str) -> Optional[Dict[str, Any]]: 129 | """ 130 | Search for a track by ID. 131 | 132 | Args: 133 | track_id (str): The ID of the track. 134 | 135 | Returns: 136 | Optional[Dict[str, Any]]: A dictionary containing the track information if found, otherwise None. 137 | """ 138 | self.logger.info(f"Searching track by ID: {track_id}") 139 | try: 140 | response = requests.get(f"{self.url_base}track.php?i={track_id}") 141 | if response.status_code == 200: 142 | raw = response.json() 143 | tracks = raw.get("track") 144 | if tracks and isinstance(tracks, list) and len(tracks) > 0: 145 | return tracks[0] 146 | else: 147 | self.logger.warning(f"No track found for ID: {track_id}") 148 | return None 149 | 150 | except requests.exceptions.RequestException as e: 151 | self.logger.error(f"Error: {e}") 152 | return None 153 | 154 | def get_track_metadata( 155 | self, 156 | track_name: str, 157 | artist_name: Optional[str] = None, 158 | ) -> TrackADBResponse: 159 | """ 160 | Get the metadata of a track. 161 | 162 | Args: 163 | track_name (str): The name of the track. 164 | artist_name (Optional[str]): The name of the artist. 165 | album_name (Optional[str]): The name of the album. 166 | 167 | Returns: 168 | TrackADBResponse: A dictionary containing the track metadata. 169 | """ 170 | raw_data = self._search_track_by_name(artist_name, track_name) 171 | if not raw_data: 172 | return None 173 | 174 | return self.parser.parse_track(raw_data) 175 | 176 | def get_album_metadata( 177 | self, 178 | artist_name: str, 179 | album_name: str, 180 | ) -> AlbumADBResponse: 181 | """ 182 | Get the metadata of an album. 183 | 184 | Args: 185 | artist_name (str): The name of the artist 186 | album_name (str): The name of the album. 187 | 188 | Returns: 189 | AlbumADBResponse: A dictionary containing the album metadata. 190 | """ 191 | raw_data = self._search_album_by_name(artist_name, album_name) 192 | if not raw_data: 193 | return None 194 | 195 | return self.parser.parse_album(raw_data) 196 | 197 | def get_artist_metadata( 198 | self, 199 | artist_name: str, 200 | ) -> ArtistADBResponse: 201 | """ 202 | Get the metadata of an artist. 203 | 204 | Args: 205 | artist_name (str): The name of the artist. 206 | 207 | Returns: 208 | ArtistADBResponse: A dictionary containing the artist metadata. 209 | """ 210 | raw_data = self._search_artist_by_name(artist_name) 211 | if not raw_data: 212 | return None 213 | 214 | return self.parser.parse_artist(raw_data) --------------------------------------------------------------------------------