diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b3c4fa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +__pycache__ +*.pyc +.cache-* +.cache.db +.session.yml +auto_sync_status.json +config.yml +session.json +songs not found.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7027c89 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV STREAMLIT_SERVER_ADDRESS=0.0.0.0 +ENV STREAMLIT_SERVER_PORT=8501 +ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false + +WORKDIR /app + +COPY pyproject.toml readme.md LICENSE ./ +COPY src ./src +COPY app.py ./ + +RUN python -m pip install --no-cache-dir --upgrade pip \ + && python -m pip install --no-cache-dir -e . + +EXPOSE 8501 + +CMD ["python", "-m", "streamlit", "run", "app.py"] diff --git a/Launch Spotify→Tidal.command b/Launch Spotify→Tidal.command index 477ccef..651433f 100755 --- a/Launch Spotify→Tidal.command +++ b/Launch Spotify→Tidal.command @@ -4,8 +4,5 @@ # Change to the project directory (same folder as this script) cd "$(dirname "$0")" -# Activate the virtual environment -source .venv/bin/activate - -# Launch Streamlit (opens browser automatically) -streamlit run app.py +# Launch Streamlit from the project virtual environment (opens browser automatically) +.venv/bin/python -m streamlit run app.py diff --git a/app.py b/app.py index eb4ea3a..378ae1c 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ import streamlit as st from pathlib import Path import spotipy +from spotify_to_tidal import auto_sync as _auto_sync from spotify_to_tidal import auth as _auth from spotify_to_tidal import sync as _sync from spotify_to_tidal.tidalapi_patch import get_all_playlists @@ -14,6 +15,7 @@ from spotify_to_tidal.tidalapi_patch import get_all_playlists # ── Constants ───────────────────────────────────────────────────────────────── SESSION_FILE = Path("session.json") NOT_FOUND_FILE = Path("songs not found.txt") +AUTO_SYNC_STATUS_FILE = Path("auto_sync_status.json") # ── Session persistence ─────────────────────────────────────────────────────── def load_session() -> dict: @@ -38,6 +40,14 @@ def save_watched(ids: set): def load_external_playlists() -> list: return load_session().get("external_playlists", []) +def load_auto_sync_status() -> dict: + if AUTO_SYNC_STATUS_FILE.exists(): + try: + return json.loads(AUTO_SYNC_STATUS_FILE.read_text()) + except Exception: + pass + return {} + def upsert_external_playlist(info: dict): data = load_session() ext = [p for p in data.get("external_playlists", []) if p.get("id") != info["id"]] @@ -93,6 +103,28 @@ def get_sessions(): config, spotify_session, tidal_session = get_sessions() +@st.cache_resource +def start_auto_sync_scheduler(config: dict): + return _auto_sync.start_scheduler( + config, + session_file=SESSION_FILE, + status_file=AUTO_SYNC_STATUS_FILE, + ) + +start_auto_sync_scheduler(config) + +if (config.get("auto_sync") or {}).get("enabled", False): + auto_status = load_auto_sync_status() + with st.sidebar: + st.caption("Daily auto-sync") + st.write(auto_status.get("state", "starting")) + if auto_status.get("next_run_at"): + st.caption(f"Next: {auto_status['next_run_at']}") + if auto_status.get("last_run_at"): + st.caption(f"Last: {auto_status['last_run_at']}") + if auto_status.get("error"): + st.error(auto_status["error"]) + # ── Data fetchers ───────────────────────────────────────────────────────────── @st.cache_data def fetch_my_playlists(): diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..056b0b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + spotify-to-tidal: + build: . + container_name: spotify-to-tidal + restart: unless-stopped + ports: + - "8501:8501" + environment: + TZ: Europe/Berlin + volumes: + - ./:/app diff --git a/example_config.yml b/example_config.yml index e03971d..6a6b2f2 100644 --- a/example_config.yml +++ b/example_config.yml @@ -20,6 +20,16 @@ spotify: # - when false: favorites can only be synced manually via --sync-favorites argument sync_favorites_default: true +# unattended Streamlit/Docker sync +# The daily job syncs playlists marked with "Watch" in the Streamlit app. +auto_sync: + enabled: false + daily_at: "03:00" + timezone: Europe/Berlin + run_on_startup: false + sync_external: false + sync_favorites: false + # increasing these parameters should increase the search speed, while decreasing reduces likelihood of 429 errors max_concurrency: 10 # max concurrent connections at any given time rate_limit: 10 # max sustained connections per second diff --git a/pyproject.toml b/pyproject.toml index 23705e5..0e1438b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "spotipy~=2.24", "tidalapi~=0.8.10", "pyyaml~=6.0", + "streamlit~=1.55", + "pandas~=2.3", "tqdm~=4.64", "sqlalchemy~=2.0", "pytest~=8.0", diff --git a/readme.md b/readme.md index 38f9d2d..ba2fbfe 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,33 @@ spotify_to_tidal --sync-favorites See example_config.yml for more configuration options, and `spotify_to_tidal --help` for more options. +Streamlit + Docker auto-sync +---------------------------- +The Streamlit app can run a daily background sync while it is open in Docker. +It syncs the playlists marked with "Watch" in the app. + +Enable it in `config.yml`: + +```yaml +auto_sync: + enabled: true + daily_at: "03:00" + timezone: Europe/Berlin + run_on_startup: false + sync_external: false + sync_favorites: false +``` + +Then start the local container: + +```bash +docker compose up -d --build +``` + +Open the app at http://localhost:8501. The container mounts the project folder +so `config.yml`, `session.json`, `.session.yml`, `.cache-*`, and the auto-sync +status file persist on your machine. + --- #### Join our amazing community as a code contributor diff --git a/session.json b/session.json index 1160e2e..e366490 100644 --- a/session.json +++ b/session.json @@ -5,9 +5,11 @@ "3Sjb6ZlsPxIpzOMZVoq1pW", "3eNcChQ2KssR8QjuV7KUEc", "44Shnwiw8xk3UmKf7Unp6o", + "44WFxhrnNsa31xnaNul8OK", "4ISU1ED9Vx8SH8o3JQVttw", "4iVWLq7tdRfLzTbKsYhM56", "5d49VR3snvfwyAJxJgB20s", + "74jNACuelmkmwkBNn6E463", "7KG4kWpAQMhJxudcO42YkG" ], "external_playlists": [ diff --git a/src/spotify_to_tidal/auto_sync.py b/src/spotify_to_tidal/auto_sync.py new file mode 100644 index 0000000..7aa82f7 --- /dev/null +++ b/src/spotify_to_tidal/auto_sync.py @@ -0,0 +1,174 @@ +import asyncio +import datetime as dt +import json +import threading +import time +import traceback +from pathlib import Path +from zoneinfo import ZoneInfo + +from . import auth as _auth +from . import sync as _sync + + +DEFAULT_SESSION_FILE = Path("session.json") +DEFAULT_STATUS_FILE = Path("auto_sync_status.json") + + +def _load_json(path: Path, fallback: dict) -> dict: + if not path.exists(): + return fallback + try: + return json.loads(path.read_text()) + except Exception: + return fallback + + +def _write_status(path: Path, **updates): + status = _load_json(path, {}) + status.update(updates) + status["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat() + path.write_text(json.dumps(status, indent=2)) + + +def _get_timezone(name: str | None): + if not name: + return dt.datetime.now().astimezone().tzinfo + try: + return ZoneInfo(name) + except Exception: + return dt.datetime.now().astimezone().tzinfo + + +def _parse_daily_time(value: str | None) -> dt.time: + if not value: + return dt.time(hour=3) + hour, minute = value.split(":", 1) + return dt.time(hour=int(hour), minute=int(minute)) + + +def _next_daily_run(now: dt.datetime, daily_at: dt.time) -> dt.datetime: + run_at = now.replace( + hour=daily_at.hour, + minute=daily_at.minute, + second=0, + microsecond=0, + ) + if run_at <= now: + run_at += dt.timedelta(days=1) + return run_at + + +def _sleep_until(target: dt.datetime, stop_event: threading.Event): + while not stop_event.is_set(): + remaining = (target - dt.datetime.now(target.tzinfo)).total_seconds() + if remaining <= 0: + return + stop_event.wait(min(remaining, 60)) + + +async def _sync_configured_targets(config: dict, session_file: Path) -> dict: + auto_config = config.get("auto_sync") or {} + state = _load_json(session_file, {"watched": [], "external_playlists": []}) + + spotify_session = _auth.open_spotify_session(config["spotify"]) + tidal_session = _auth.open_tidal_session() + if not tidal_session.check_login(): + raise RuntimeError("Could not connect to Tidal") + + watched_ids = set(state.get("watched") or []) + all_spotify_playlists = await _sync.get_playlists_from_spotify(spotify_session, config) + playlists = [p for p in all_spotify_playlists if p["id"] in watched_ids] + + if auto_config.get("sync_external", False): + for item in state.get("external_playlists") or []: + pid = item.get("id") + if pid and pid not in {p["id"] for p in playlists}: + playlists.append(spotify_session.playlist(pid)) + + tidal_map = {p.name: p for p in await _sync.get_all_playlists(tidal_session.user)} + skipped = [] + for playlist in playlists: + try: + await _sync.sync_playlist( + spotify_session, + tidal_session, + playlist, + tidal_map.get(playlist["name"]), + config, + ) + except RuntimeError as exc: + skipped.append({"playlist": playlist["name"], "error": str(exc)}) + + favorites_synced = False + if auto_config.get("sync_favorites", False): + await _sync.sync_favorites(spotify_session, tidal_session, config) + favorites_synced = True + + return { + "playlist_count": len(playlists), + "skipped": skipped, + "favorites_synced": favorites_synced, + } + + +def run_once(config: dict, session_file: Path = DEFAULT_SESSION_FILE, status_file: Path = DEFAULT_STATUS_FILE): + started_at = dt.datetime.now(dt.timezone.utc).isoformat() + _write_status(status_file, state="running", started_at=started_at, error=None) + try: + result = asyncio.run(_sync_configured_targets(config, session_file)) + except Exception as exc: + _write_status( + status_file, + state="failed", + last_run_at=started_at, + error=str(exc), + traceback=traceback.format_exc(), + ) + raise + _write_status( + status_file, + state="idle", + last_run_at=started_at, + error=None, + **result, + ) + + +def start_scheduler( + config: dict, + session_file: Path = DEFAULT_SESSION_FILE, + status_file: Path = DEFAULT_STATUS_FILE, +) -> threading.Event | None: + auto_config = config.get("auto_sync") or {} + if not auto_config.get("enabled", False): + return None + + stop_event = threading.Event() + timezone = _get_timezone(auto_config.get("timezone")) + daily_at = _parse_daily_time(auto_config.get("daily_at", "03:00")) + run_on_startup = auto_config.get("run_on_startup", False) + + def _loop(): + _write_status(status_file, state="idle", error=None) + if run_on_startup and not stop_event.is_set(): + try: + run_once(config, session_file, status_file) + except Exception: + pass + + while not stop_event.is_set(): + now = dt.datetime.now(timezone) + next_run = _next_daily_run(now, daily_at) + _write_status(status_file, state="idle", next_run_at=next_run.isoformat()) + _sleep_until(next_run, stop_event) + if stop_event.is_set(): + break + try: + run_once(config, session_file, status_file) + except Exception: + time.sleep(60) + + thread = threading.Thread(target=_loop, name="spotify-to-tidal-auto-sync", daemon=True) + thread.start() + return stop_event