Add Streamlit Docker auto-sync deployment

This commit is contained in:
2026-05-14 21:19:34 +02:00
parent d9f251b2fc
commit 9e4599718d
10 changed files with 291 additions and 5 deletions

11
.dockerignore Normal file
View File

@@ -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

20
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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

32
app.py
View File

@@ -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():

11
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -5,9 +5,11 @@
"3Sjb6ZlsPxIpzOMZVoq1pW",
"3eNcChQ2KssR8QjuV7KUEc",
"44Shnwiw8xk3UmKf7Unp6o",
"44WFxhrnNsa31xnaNul8OK",
"4ISU1ED9Vx8SH8o3JQVttw",
"4iVWLq7tdRfLzTbKsYhM56",
"5d49VR3snvfwyAJxJgB20s",
"74jNACuelmkmwkBNn6E463",
"7KG4kWpAQMhJxudcO42YkG"
],
"external_playlists": [

View File

@@ -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