update
This commit is contained in:
23
app/main.py
Normal file
23
app/main.py
Normal 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
28
app/routers/auth.py
Normal 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
40
app/routers/download.py
Normal 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
14
app/routers/search.py
Normal 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
17
app/routers/system.py
Normal 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)}
|
||||
309
app/services/download_manager.py
Normal file
309
app/services/download_manager.py
Normal 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
|
||||
)
|
||||
|
||||
|
||||
161
app/services/tidal_wrapper.py
Normal file
161
app/services/tidal_wrapper.py
Normal 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
123
app/static/style.css
Normal 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
200
app/templates/index.html
Normal 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
61
app/templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user