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)
|
# Change to the project directory (same folder as this script)
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# Activate the virtual environment
|
# Launch Streamlit from the project virtual environment (opens browser automatically)
|
||||||
source .venv/bin/activate
|
.venv/bin/python -m streamlit run app.py
|
||||||
|
|
||||||
# Launch Streamlit (opens browser automatically)
|
|
||||||
streamlit run app.py
|
|
||||||
|
|||||||
32
app.py
32
app.py
@@ -7,6 +7,7 @@ import streamlit as st
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import spotipy
|
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 auth as _auth
|
||||||
from spotify_to_tidal import sync as _sync
|
from spotify_to_tidal import sync as _sync
|
||||||
from spotify_to_tidal.tidalapi_patch import get_all_playlists
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
SESSION_FILE = Path("session.json")
|
SESSION_FILE = Path("session.json")
|
||||||
NOT_FOUND_FILE = Path("songs not found.txt")
|
NOT_FOUND_FILE = Path("songs not found.txt")
|
||||||
|
AUTO_SYNC_STATUS_FILE = Path("auto_sync_status.json")
|
||||||
|
|
||||||
# ── Session persistence ───────────────────────────────────────────────────────
|
# ── Session persistence ───────────────────────────────────────────────────────
|
||||||
def load_session() -> dict:
|
def load_session() -> dict:
|
||||||
@@ -38,6 +40,14 @@ def save_watched(ids: set):
|
|||||||
def load_external_playlists() -> list:
|
def load_external_playlists() -> list:
|
||||||
return load_session().get("external_playlists", [])
|
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):
|
def upsert_external_playlist(info: dict):
|
||||||
data = load_session()
|
data = load_session()
|
||||||
ext = [p for p in data.get("external_playlists", []) if p.get("id") != info["id"]]
|
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()
|
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 ─────────────────────────────────────────────────────────────
|
# ── Data fetchers ─────────────────────────────────────────────────────────────
|
||||||
@st.cache_data
|
@st.cache_data
|
||||||
def fetch_my_playlists():
|
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
|
# - when false: favorites can only be synced manually via --sync-favorites argument
|
||||||
sync_favorites_default: true
|
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
|
# 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
|
max_concurrency: 10 # max concurrent connections at any given time
|
||||||
rate_limit: 10 # max sustained connections per second
|
rate_limit: 10 # max sustained connections per second
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ dependencies = [
|
|||||||
"spotipy~=2.24",
|
"spotipy~=2.24",
|
||||||
"tidalapi~=0.8.10",
|
"tidalapi~=0.8.10",
|
||||||
"pyyaml~=6.0",
|
"pyyaml~=6.0",
|
||||||
|
"streamlit~=1.55",
|
||||||
|
"pandas~=2.3",
|
||||||
"tqdm~=4.64",
|
"tqdm~=4.64",
|
||||||
"sqlalchemy~=2.0",
|
"sqlalchemy~=2.0",
|
||||||
"pytest~=8.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.
|
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
|
#### Join our amazing community as a code contributor
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
"3Sjb6ZlsPxIpzOMZVoq1pW",
|
"3Sjb6ZlsPxIpzOMZVoq1pW",
|
||||||
"3eNcChQ2KssR8QjuV7KUEc",
|
"3eNcChQ2KssR8QjuV7KUEc",
|
||||||
"44Shnwiw8xk3UmKf7Unp6o",
|
"44Shnwiw8xk3UmKf7Unp6o",
|
||||||
|
"44WFxhrnNsa31xnaNul8OK",
|
||||||
"4ISU1ED9Vx8SH8o3JQVttw",
|
"4ISU1ED9Vx8SH8o3JQVttw",
|
||||||
"4iVWLq7tdRfLzTbKsYhM56",
|
"4iVWLq7tdRfLzTbKsYhM56",
|
||||||
"5d49VR3snvfwyAJxJgB20s",
|
"5d49VR3snvfwyAJxJgB20s",
|
||||||
|
"74jNACuelmkmwkBNn6E463",
|
||||||
"7KG4kWpAQMhJxudcO42YkG"
|
"7KG4kWpAQMhJxudcO42YkG"
|
||||||
],
|
],
|
||||||
"external_playlists": [
|
"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