Add Streamlit Docker auto-sync deployment
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
20
Dockerfile
Normal 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"]
|
||||
@@ -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
32
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():
|
||||
|
||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
27
readme.md
27
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
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
"3Sjb6ZlsPxIpzOMZVoq1pW",
|
||||
"3eNcChQ2KssR8QjuV7KUEc",
|
||||
"44Shnwiw8xk3UmKf7Unp6o",
|
||||
"44WFxhrnNsa31xnaNul8OK",
|
||||
"4ISU1ED9Vx8SH8o3JQVttw",
|
||||
"4iVWLq7tdRfLzTbKsYhM56",
|
||||
"5d49VR3snvfwyAJxJgB20s",
|
||||
"74jNACuelmkmwkBNn6E463",
|
||||
"7KG4kWpAQMhJxudcO42YkG"
|
||||
],
|
||||
"external_playlists": [
|
||||
|
||||
174
src/spotify_to_tidal/auto_sync.py
Normal file
174
src/spotify_to_tidal/auto_sync.py
Normal 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
|
||||
Reference in New Issue
Block a user