This commit is contained in:
2025-12-02 14:07:35 +01:00
commit 9b84566eb4
62 changed files with 12861 additions and 0 deletions

23
app/main.py Normal file
View File

@@ -0,0 +1,23 @@
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from app.routers import auth, search, download, system
from app.services.tidal_wrapper import TidalWrapper
app = FastAPI(title="Tidal DL Web")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
app.include_router(auth.router)
app.include_router(search.router)
app.include_router(download.router)
app.include_router(system.router)
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
wrapper = TidalWrapper()
if not wrapper.is_authenticated():
return templates.TemplateResponse("login.html", {"request": request})
return templates.TemplateResponse("index.html", {"request": request})

28
app/routers/auth.py Normal file
View File

@@ -0,0 +1,28 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from app.services.tidal_wrapper import TidalWrapper
router = APIRouter(prefix="/auth", tags=["auth"])
templates = Jinja2Templates(directory="app/templates")
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse("login.html", {"request": request})
@router.post("/start")
async def start_login():
wrapper = TidalWrapper()
result = wrapper.start_device_login()
return JSONResponse(result)
@router.get("/status")
async def check_status():
wrapper = TidalWrapper()
return JSONResponse(wrapper.get_auth_status())
@router.post("/logout")
async def logout():
# wrapper = TidalWrapper()
# wrapper.logout()
return JSONResponse({"status": "logged_out"})

40
app/routers/download.py Normal file
View File

@@ -0,0 +1,40 @@
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from app.services.download_manager import DownloadManager
router = APIRouter(prefix="/download", tags=["download"])
download_manager = DownloadManager()
@router.post("/")
async def add_download(request: Request):
data = await request.json()
item_type = data.get("type")
item_id = data.get("id")
if not item_type or not item_id:
raise HTTPException(status_code=400, detail="Missing type or id")
task = download_manager.add_to_queue(item_type, item_id)
return JSONResponse(task)
@router.get("/queue")
async def get_queue():
return download_manager.get_queue()
@router.post("/cancel/{task_id}")
async def cancel_download(task_id: str):
if download_manager.cancel_task(task_id):
return {"status": "success", "message": "Task cancelled"}
raise HTTPException(status_code=404, detail="Task not found")
@router.post("/pause/{task_id}")
async def pause_download(task_id: str):
if download_manager.pause_task(task_id):
return {"status": "success", "message": "Task paused"}
raise HTTPException(status_code=404, detail="Task not found")
@router.post("/resume/{task_id}")
async def resume_download(task_id: str):
if download_manager.resume_task(task_id):
return {"status": "success", "message": "Task resumed"}
raise HTTPException(status_code=404, detail="Task not found")

14
app/routers/search.py Normal file
View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from app.services.tidal_wrapper import TidalWrapper
router = APIRouter(prefix="/search", tags=["search"])
@router.get("/")
async def search(query: str, type: str = "track"):
wrapper = TidalWrapper()
try:
results = wrapper.search(query, type)
return JSONResponse(results)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

17
app/routers/system.py Normal file
View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter
import requests
router = APIRouter(
prefix="/system",
tags=["system"],
responses={404: {"description": "Not found"}},
)
@router.get("/ip")
async def get_public_ip():
try:
response = requests.get("https://api.ipify.org?format=json", timeout=5)
response.raise_for_status()
return response.json()
except Exception as e:
return {"ip": "Error fetching IP", "error": str(e)}

View File

@@ -0,0 +1,309 @@
import threading
import queue
import logging
import sys
from typing import Dict, List
from tidal_dl_ng.download import Download
from tidal_dl_ng.config import Settings, Tidal
class MockTask:
def __init__(self, total):
self.finished = False
self.percentage = 0
self.total = total
self.completed = 0
class MockProgressRich:
def __init__(self):
self.tasks = []
def add_task(self, *args, **kwargs):
total = kwargs.get("total", 100)
self.tasks.append(MockTask(total))
return len(self.tasks) - 1
def update(self, task_id, *args, **kwargs):
task = self.tasks[task_id]
if "completed" in kwargs:
task.completed = kwargs["completed"]
if "advance" in kwargs:
task.completed += kwargs["advance"]
if task.total and task.completed >= task.total:
task.finished = True
def advance(self, task_id, advance=1):
self.update(task_id, advance=advance)
def remove_task(self, *args, **kwargs):
pass
def start(self):
pass
def stop(self):
pass
from tidal_dl_ng.model.gui_data import ProgressBars
from .tidal_wrapper import TidalWrapper
logger = logging.getLogger(__name__)
class DownloadManager:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(DownloadManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self.queue = queue.Queue()
self.active_downloads: Dict[str, Dict] = {}
self.history: List[Dict] = []
self.worker_thread = threading.Thread(target=self._worker, daemon=True)
self.worker_thread.start()
self.tidal_wrapper = TidalWrapper()
self._initialized = True
def add_to_queue(self, item_type: str, item_id: str):
task_id = f"{item_type}_{item_id}"
task = {
"id": task_id,
"type": item_type,
"item_id": item_id,
"status": "queued",
"progress": 0,
"name": "Fetching metadata..."
}
self.active_downloads[task_id] = task
self.queue.put(task)
return task
def get_queue(self):
return list(self.active_downloads.values()) + self.history
def _worker(self):
while True:
task = self.queue.get()
if task is None:
break
task_id = task["id"]
self.active_downloads[task_id]["status"] = "downloading"
try:
self._process_download(task)
self.active_downloads[task_id]["status"] = "completed"
self.active_downloads[task_id]["progress"] = 100
except Exception as e:
logger.error(f"Download failed: {e}")
self.active_downloads[task_id]["status"] = "failed"
self.active_downloads[task_id]["error"] = str(e)
finally:
# Move to history
self.history.append(self.active_downloads.pop(task_id))
self.queue.task_done()
def cancel_task(self, task_id: str):
if task_id in self.active_downloads:
self.active_downloads[task_id]["control"] = "cancel"
return True
return False
def pause_task(self, task_id: str):
if task_id in self.active_downloads:
self.active_downloads[task_id]["control"] = "pause"
return True
return False
def resume_task(self, task_id: str):
if task_id in self.active_downloads:
self.active_downloads[task_id]["control"] = "resume"
return True
return False
def _check_control(self, task):
# Check for control flags
while task.get("control") == "pause":
time.sleep(1)
# If cancelled while paused
if task.get("control") == "cancel":
break
if task.get("control") == "cancel":
task["status"] = "cancelled"
raise Exception("Download cancelled by user")
def _process_download(self, task):
# Mock Signal class to capture emit calls
class MockSignal:
def __init__(self, callback):
self.callback = callback
def emit(self, value):
self.callback(value)
# Mock ProgressBars class
class MockProgressBars:
def __init__(self, task_updater):
self.item = MockSignal(lambda p: task_updater("progress", p))
self.item_name = MockSignal(lambda n: task_updater("name", n))
self.list_item = MockSignal(lambda p: task_updater("list_progress", p))
self.list_name = MockSignal(lambda n: task_updater("list_name", n))
def update_task(key, value):
if key == "progress":
# For single track, this is the main progress
if task["type"] == "track":
self.active_downloads[task["id"]]["progress"] = value
elif key == "name":
# Update status with current track name
self.active_downloads[task["id"]]["current_item"] = value
elif key == "list_progress":
# For albums/playlists, this is the main progress
if task["type"] != "track":
self.active_downloads[task["id"]]["progress"] = value
mock_progress = MockProgressBars(update_task)
settings = Settings()
tidal = Tidal(settings)
# Attempt to load token from storage
tidal.login_token()
# Ensure we are logged in
if not tidal.session.check_login():
raise Exception("Not logged in")
# Use environment variable for download path, default to /app/downloads
import os
settings.data.download_base_path = os.getenv("DOWNLOAD_PATH", "/app/downloads")
settings.data.path_binary_ffmpeg = "ffmpeg"
# Configure logger
logger_tidal = logging.getLogger("tidal_dl_ng")
logger_tidal.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
if not logger_tidal.handlers:
logger_tidal.addHandler(handler)
mock_progress_rich = MockProgressRich()
event_abort = threading.Event()
event_run = threading.Event()
event_run.set() # Allow running
downloader = Download(
tidal_obj=tidal,
path_base=settings.data.download_base_path,
fn_logger=logger_tidal,
skip_existing=False,
progress_gui=mock_progress,
progress=mock_progress_rich,
event_abort=event_abort,
event_run=event_run
)
# Fetch media object
media = None
if task["type"] == "track":
media = tidal.session.track(task["item_id"])
elif task["type"] == "album":
media = tidal.session.album(task["item_id"])
elif task["type"] == "video":
media = tidal.session.video(task["item_id"])
elif task["type"] == "playlist":
media = tidal.session.playlist(task["item_id"])
elif task["type"] == "artist":
media = tidal.session.artist(task["item_id"])
if not media:
raise Exception("Media not found")
task["name"] = f"{media.name}"
if hasattr(media, "artist"):
task["name"] += f" - {media.artist.name}"
self._check_control(task)
if task["type"] == "track":
result, path = downloader.item(
file_template=settings.data.format_track,
media=media,
quality_audio=settings.data.quality_audio
)
if not result:
raise Exception("Download failed (downloader returned False)")
logger.info(f"Download successful: {path}")
elif task["type"] == "album":
tracks = media.tracks()
total = len(tracks)
task["total_items"] = total
for i, track in enumerate(tracks):
self._check_control(task)
task["current_index"] = i + 1
task["progress"] = int((i / total) * 100)
task["current_item"] = track.name
downloader.item(
file_template=settings.data.format_track,
media=track,
quality_audio=settings.data.quality_audio,
is_parent_album=True,
list_position=i+1,
list_total=total
)
elif task["type"] == "playlist":
tracks = media.tracks()
total = len(tracks)
task["total_items"] = total
for i, track in enumerate(tracks):
self._check_control(task)
task["current_index"] = i + 1
task["progress"] = int((i / total) * 100)
task["current_item"] = track.name
downloader.item(
file_template=settings.data.format_track,
media=track,
quality_audio=settings.data.quality_audio,
is_parent_album=False, # Playlist tracks are treated as individual tracks usually, or we can use playlist format
list_position=i+1,
list_total=total
)
elif task["type"] == "artist":
# For artist, download all albums
albums = media.get_albums()
total_albums = len(albums)
# We can't easily know total tracks upfront without fetching all albums first.
# Let's track progress by Album for the top level, and maybe tracks within?
# Or just flatten everything. Flattening is better for "Track X of Y".
all_tracks = []
for album in albums:
all_tracks.extend(album.tracks())
total = len(all_tracks)
task["total_items"] = total
for i, track in enumerate(all_tracks):
self._check_control(task)
task["current_index"] = i + 1
task["progress"] = int((i / total) * 100)
task["current_item"] = track.name
downloader.item(
file_template=settings.data.format_track,
media=track,
quality_audio=settings.data.quality_audio,
is_parent_album=True, # Treat as album tracks to keep folder structure
list_position=track.track_num, # Use original track num
list_total=track.album.num_tracks
)

View File

@@ -0,0 +1,161 @@
import logging
import threading
import time
from typing import Optional, Callable, Dict, Any
from tidal_dl_ng.config import Tidal, Settings
from tidalapi import Session, Track, Album, Artist, Playlist
logger = logging.getLogger(__name__)
class TidalWrapper:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(TidalWrapper, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self.settings = Settings()
self.tidal = Tidal(self.settings)
self.session = self.tidal.session
self._initialized = True
self.auth_future = None
self.auth_status = {"status": "idle", "message": "", "link": "", "code": ""}
def is_authenticated(self) -> bool:
return self.session.check_login()
def start_device_login(self) -> Dict[str, str]:
"""
Starts the device login process.
Returns the verification info (link and code) immediately if possible,
or starts a thread to handle the blocking login_oauth_simple if needed.
Since tidalapi's login_oauth_simple is blocking and takes a print callback,
we might need to run it in a thread and capture the output.
However, a better approach is to use the underlying tidalapi methods if available.
For now, let's try to use the session's internal methods if we can figure them out,
or wrap the blocking call.
"""
if self.is_authenticated():
return {"status": "success", "message": "Already logged in"}
# Reset status
self.auth_status = {"status": "pending", "message": "Initializing login...", "link": "", "code": ""}
# Run login in a separate thread to avoid blocking
thread = threading.Thread(target=self._login_thread)
thread.daemon = True
thread.start()
return self.auth_status
def _login_thread(self):
def print_callback(msg: str):
logger.info(f"Tidal Login Callback: {msg}")
# Parse the message to extract link and code if possible
# Typical msg: "Visit https://link.tidal.com/AAAAA and enter code AAAAA"
self.auth_status["message"] = msg
if "http" in msg:
parts = msg.split()
for part in parts:
if part.startswith("http"):
self.auth_status["link"] = part
# This is a bit hacky, but tidalapi 0.7+ might behave differently.
# If we can't parse it easily, the user just sees the message.
try:
# This will block until user logs in or times out
self.tidal.login(print_callback)
if self.is_authenticated():
self.auth_status["status"] = "success"
self.auth_status["message"] = "Login successful!"
else:
self.auth_status["status"] = "failed"
self.auth_status["message"] = "Login failed."
except Exception as e:
self.auth_status["status"] = "error"
self.auth_status["message"] = str(e)
def get_auth_status(self):
return self.auth_status
def search(self, query: str, type: str = "track", limit: int = 10):
if not self.is_authenticated():
raise Exception("Not authenticated")
# Map type string to tidalapi models if needed, or just pass string
# tidalapi.Session.search(query, models=None, limit=10, offset=0)
# models can be [Track, Album, Artist, Playlist]
models = []
if type == "track":
models = [Track]
elif type == "album":
models = [Album]
elif type == "artist":
models = [Artist]
elif type == "playlist":
models = [Playlist]
results = self.session.search(query, models=models, limit=limit)
# Parse results into a JSON-friendly format
output = []
if type == "track":
for track in results["tracks"]:
output.append({
"id": track.id,
"title": track.name,
"artist": track.artist.name,
"album": track.album.name,
"duration": track.duration,
"cover": self._get_image_url(track.album.cover),
"type": "track"
})
elif type == "album":
for album in results["albums"]:
output.append({
"id": album.id,
"title": album.name,
"artist": album.artist.name,
"tracks": album.num_tracks,
"release_date": str(album.release_date),
"cover": self._get_image_url(album.cover),
"type": "album"
})
elif type == "artist":
for artist in results["artists"]:
output.append({
"id": artist.id,
"title": artist.name,
"type": "artist",
"cover": self._get_image_url(artist.picture)
})
elif type == "playlist":
for playlist in results["playlists"]:
output.append({
"id": playlist.id,
"title": playlist.name,
"type": "playlist",
"cover": self._get_image_url(playlist.cover)
})
return output
def _get_image_url(self, uuid: str | None, width: int = 320, height: int = 320) -> str | None:
if not uuid:
return None
return f"https://resources.tidal.com/images/{uuid.replace('-', '/')}/{width}x{height}.jpg"
def get_track(self, track_id: str):
return self.session.track(track_id)
def get_album(self, album_id: str):
return self.session.album(album_id)

123
app/static/style.css Normal file
View File

@@ -0,0 +1,123 @@
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #121212;
color: #e0e0e0;
margin: 0;
padding: 0;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1,
h2 {
color: #00ffff;
}
.card {
background-color: #1e1e1e;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
input[type="text"] {
width: 100%;
padding: 10px;
margin-bottom: 10px;
background-color: #2d2d2d;
border: 1px solid #333;
color: #fff;
border-radius: 4px;
}
button {
background-color: #00ffff;
color: #000;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
button:hover {
background-color: #00cccc;
}
.result-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #333;
}
.result-item img {
width: 50px;
height: 50px;
margin-right: 15px;
border-radius: 4px;
}
.result-info {
flex-grow: 1;
}
.queue-item {
padding: 10px;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
}
.status-completed {
color: #00ff00;
}
.status-downloading {
color: #ffff00;
}
.status-failed {
color: #ff0000;
}
/* Collapsible Styles */
details {
margin-bottom: 10px;
background-color: #252525;
border-radius: 4px;
overflow: hidden;
}
summary {
padding: 10px;
cursor: pointer;
background-color: #333;
font-weight: bold;
color: #00ffff;
list-style: none;
/* Hide default triangle */
}
summary::-webkit-details-marker {
display: none;
}
summary::after {
content: '+';
float: right;
font-weight: bold;
}
details[open] summary::after {
content: '-';
}
.group-list {
padding: 0 10px;
}

200
app/templates/index.html Normal file
View File

@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tidal DL Web</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Tidal DL Web</h1>
<div class="card">
<h2>Search</h2>
<input type="text" id="query" placeholder="Search for tracks, albums..." onkeypress="handleEnter(event)">
<select id="type"
style="padding: 10px; background: #2d2d2d; color: white; border: 1px solid #333; border-radius: 4px;">
<option value="track">Track</option>
<option value="album">Album</option>
<option value="artist">Artist</option>
<option value="playlist">Playlist</option>
</select>
<button onclick="search()">Search</button>
</div>
<div id="results" class="card" style="display:none;">
<h2>Results</h2>
<div id="results-list"></div>
</div>
<div class="card">
<h2>Download Queue</h2>
<div id="queue-list"></div>
</div>
</div>
<div class="container">
<div class="card" style="margin-top: 20px; text-align: center; color: #888;">
<small>System IP: <span id="system-ip">Loading...</span></small>
</div>
</div>
<script>
async function checkIp() {
try {
const response = await fetch('/system/ip');
const data = await response.json();
document.getElementById('system-ip').textContent = data.ip;
} catch (e) {
document.getElementById('system-ip').textContent = 'Error';
}
}
checkIp();
function handleEnter(e) {
if (e.key === 'Enter') search();
}
async function search() {
const query = document.getElementById('query').value;
const type = document.getElementById('type').value;
if (!query) return;
const response = await fetch(`/search/?query=${encodeURIComponent(query)}&type=${type}`);
const results = await response.json();
const list = document.getElementById('results-list');
list.innerHTML = '';
document.getElementById('results').style.display = 'block';
if (results.length === 0) {
list.innerHTML = '<p>No results found.</p>';
return;
}
// Grouping Logic
let grouped = {};
let groupKey = '';
if (type === 'track') {
groupKey = 'album'; // Group tracks by Album
} else if (type === 'album') {
groupKey = 'artist'; // Group albums by Artist
} else {
// No grouping for artist/playlist
renderFlatList(results, list);
return;
}
results.forEach(item => {
const key = item[groupKey] || 'Unknown';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(item);
});
for (const [groupName, items] of Object.entries(grouped)) {
const details = document.createElement('details');
details.open = true; // Open by default
const summary = document.createElement('summary');
summary.textContent = `${groupName} (${items.length})`;
details.appendChild(summary);
const groupList = document.createElement('div');
groupList.className = 'group-list';
items.forEach(item => {
const div = createResultItem(item);
groupList.appendChild(div);
});
details.appendChild(groupList);
list.appendChild(details);
}
}
function renderFlatList(items, container) {
items.forEach(item => {
const div = createResultItem(item);
container.appendChild(div);
});
}
function createResultItem(item) {
const div = document.createElement('div');
div.className = 'result-item';
div.innerHTML = `
<img src="${item.cover || '/static/placeholder.png'}" alt="Cover">
<div class="result-info">
<strong>${item.title}</strong><br>
${item.artist ? item.artist + ' - ' : ''}${item.album || ''}
</div>
<button onclick="download('${item.type}', '${item.id}')">Download</button>
`;
return div;
}
async function download(type, id) {
await fetch('/download/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, id })
});
updateQueue();
}
async function updateQueue() {
const response = await fetch('/download/queue');
const queue = await response.json();
const list = document.getElementById('queue-list');
list.innerHTML = '';
queue.forEach(item => {
const div = document.createElement('div');
div.className = 'queue-item';
let controls = '';
if (item.status === 'downloading' || item.status === 'queued') {
if (item.control === 'pause') {
controls += `<button onclick="controlTask('${item.id}', 'resume')">Resume</button>`;
} else {
controls += `<button onclick="controlTask('${item.id}', 'pause')">Pause</button>`;
}
controls += `<button onclick="controlTask('${item.id}', 'cancel')" style="background-color: #ff4444;">Cancel</button>`;
}
div.innerHTML = `
<div>
<strong>${item.name}</strong><br>
${item.type}
${item.current_item ? '<br><small>' + item.current_item + '</small>' : ''}
${item.total_items ? '<br><small>Track ' + item.current_index + ' of ' + item.total_items + '</small>' : ''}
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<div class="status-${item.status}">
${item.status} ${item.progress ? Math.round(item.progress) + '%' : ''}
</div>
<div class="controls">
${controls}
</div>
</div>
`;
list.appendChild(div);
});
}
async function controlTask(id, action) {
await fetch(`/download/${action}/${id}`, { method: 'POST' });
updateQueue();
}
// Poll queue
setInterval(updateQueue, 2000);
updateQueue();
</script>
</body>
</html>

61
app/templates/login.html Normal file
View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tidal DL - Login</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Tidal DL Web</h1>
<div class="card">
<h2>Login Required</h2>
<p>Please login to your Tidal account to continue.</p>
<div id="login-step-1">
<button onclick="startLogin()">Start Device Login</button>
</div>
<div id="login-step-2" style="display:none;">
<p id="login-message">Initializing...</p>
<p>Link: <a id="login-link" href="#" target="_blank">Open Tidal Login</a></p>
<p>Code: <strong id="login-code"></strong></p>
<div id="spinner">Waiting for you to login...</div>
</div>
</div>
</div>
<script>
async function startLogin() {
document.getElementById('login-step-1').style.display = 'none';
document.getElementById('login-step-2').style.display = 'block';
const response = await fetch('/auth/start', { method: 'POST' });
const data = await response.json();
updateStatus(data);
// Poll status
setInterval(checkStatus, 2000);
}
async function checkStatus() {
const response = await fetch('/auth/status');
const data = await response.json();
updateStatus(data);
if (data.status === 'success') {
window.location.href = '/';
}
}
function updateStatus(data) {
document.getElementById('login-message').innerText = data.message;
if (data.link) {
document.getElementById('login-link').href = data.link;
document.getElementById('login-link').innerText = data.link;
}
// If code is part of message or separate, handle it.
// Our backend might not parse code perfectly yet, but message usually contains it.
}
</script>
</body>
</html>