chore: adjustments for local development without containers (#21)
* chore: adjustments for local development without containers * update Contributing.md * remove dev section from readme and link to contributing doc * move settings import to runtime
This commit is contained in:
@@ -53,3 +53,6 @@ ehthumbs.db
|
|||||||
AGENTS.md
|
AGENTS.md
|
||||||
AGENTS.md.bak
|
AGENTS.md.bak
|
||||||
Docs/
|
Docs/
|
||||||
|
|
||||||
|
# Local dev app directory
|
||||||
|
backend/app-dev/
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ Backend:
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
cp env.dev.example .env
|
||||||
uvicorn main:app --reload --port 8688
|
uvicorn main:app --reload --port 8688
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ Frontend:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
cp env.dev.example .env
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -265,40 +265,7 @@ A health check endpoint is at `/health`.
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The backend is Python 3.13 with FastAPI. The frontend is SvelteKit with Svelte 5, Tailwind CSS, and DaisyUI.
|
See the [CONTRIBUTING](CONTRIBUTING.md) guide for instructions on setting up a development environment, running tests, and submitting contributions.
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
uvicorn main:app --reload --port 8688
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
A root Makefile wraps the test commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make backend-test # full backend suite
|
|
||||||
make frontend-test # full frontend suite
|
|
||||||
make test # both
|
|
||||||
make ci # tests + linting + type checks
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend browser tests use Playwright. Install the browser with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make frontend-browser-install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class Settings(BaseSettings):
|
|||||||
cache_ttl_covers: int = Field(default=86400, description="Cover cache TTL in seconds (default: 24 hours)")
|
cache_ttl_covers: int = Field(default=86400, description="Cover cache TTL in seconds (default: 24 hours)")
|
||||||
cache_cleanup_interval: int = Field(default=300)
|
cache_cleanup_interval: int = Field(default=300)
|
||||||
|
|
||||||
|
root_app_dir: Path = Field(default=Path("/app"), description="Root application directory")
|
||||||
cache_dir: Path = Field(default=Path("/app/cache"), description="Root directory for all cache files")
|
cache_dir: Path = Field(default=Path("/app/cache"), description="Root directory for all cache files")
|
||||||
library_db_path: Path = Field(default=Path("/app/cache/library.db"), description="SQLite library database path")
|
library_db_path: Path = Field(default=Path("/app/cache/library.db"), description="SQLite library database path")
|
||||||
cover_cache_max_size_mb: int = Field(default=500, description="Maximum cover cache size in MB")
|
cover_cache_max_size_mb: int = Field(default=500, description="Maximum cover cache size in MB")
|
||||||
@@ -82,6 +83,16 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode='after')
|
@model_validator(mode='after')
|
||||||
def validate_config(self) -> Self:
|
def validate_config(self) -> Self:
|
||||||
|
# Dynamically resolve paths relative to root_app_dir
|
||||||
|
if self.cache_dir == Path("/app/cache"):
|
||||||
|
self.cache_dir = self.root_app_dir / "cache"
|
||||||
|
if self.library_db_path == Path("/app/cache/library.db"):
|
||||||
|
self.library_db_path = self.cache_dir / "library.db"
|
||||||
|
if self.queue_db_path == Path("/app/cache/queue.db"):
|
||||||
|
self.queue_db_path = self.cache_dir / "queue.db"
|
||||||
|
if self.config_file_path == Path("/app/config/config.json"):
|
||||||
|
self.config_file_path = self.root_app_dir / "config" / "config.json"
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
warnings = []
|
warnings = []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ROOT_APP_DIR=./app-dev
|
||||||
|
DEBUG=true
|
||||||
@@ -7,7 +7,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class QueueStore:
|
class QueueStore:
|
||||||
def __init__(self, db_path: Path = Path("/app/cache/queue.db")) -> None:
|
def __init__(self, db_path: Path | None = None) -> None:
|
||||||
|
if db_path is None:
|
||||||
|
from core.config import get_settings
|
||||||
|
db_path = get_settings().queue_db_path
|
||||||
self.db_path = Path(db_path)
|
self.db_path = Path(db_path)
|
||||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._write_lock = threading.Lock()
|
self._write_lock = threading.Lock()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import FastAPI, APIRouter, HTTPException
|
|||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from core.dependencies import (
|
from core.dependencies import (
|
||||||
get_request_queue,
|
get_request_queue,
|
||||||
get_cache,
|
get_cache,
|
||||||
@@ -279,6 +280,23 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=6)
|
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=6)
|
||||||
|
|
||||||
|
app_settings = get_settings()
|
||||||
|
if app_settings.debug:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
"http://127.0.0.1:4173",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
|
|||||||
@@ -44,11 +44,15 @@ def _record_degradation(msg: str) -> None:
|
|||||||
ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg))
|
ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg))
|
||||||
|
|
||||||
COVER_ART_ARCHIVE_BASE = "https://coverartarchive.org"
|
COVER_ART_ARCHIVE_BASE = "https://coverartarchive.org"
|
||||||
DEFAULT_CACHE_DIR = Path("/app/cache/covers")
|
from core.config import get_settings
|
||||||
COVER_NEGATIVE_TTL_SECONDS = 4 * 3600
|
COVER_NEGATIVE_TTL_SECONDS = 4 * 3600
|
||||||
COVER_MEMORY_MAX_ENTRIES = 128
|
COVER_MEMORY_MAX_ENTRIES = 128
|
||||||
COVER_MEMORY_MAX_BYTES = 16 * 1024 * 1024
|
COVER_MEMORY_MAX_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
def _default_cache_dir() -> Path:
|
||||||
|
from core.config import get_settings
|
||||||
|
return get_settings().cache_dir / "covers"
|
||||||
|
|
||||||
_coverart_circuit_breaker = CircuitBreaker(
|
_coverart_circuit_breaker = CircuitBreaker(
|
||||||
failure_threshold=5,
|
failure_threshold=5,
|
||||||
success_threshold=2,
|
success_threshold=2,
|
||||||
@@ -175,7 +179,7 @@ class CoverArtRepository:
|
|||||||
lidarr_repo: Optional['LidarrRepository'] = None,
|
lidarr_repo: Optional['LidarrRepository'] = None,
|
||||||
jellyfin_repo: Optional['JellyfinRepository'] = None,
|
jellyfin_repo: Optional['JellyfinRepository'] = None,
|
||||||
audiodb_service: Optional['AudioDBImageService'] = None,
|
audiodb_service: Optional['AudioDBImageService'] = None,
|
||||||
cache_dir: Path = DEFAULT_CACHE_DIR,
|
cache_dir: Path = _default_cache_dir(),
|
||||||
cover_cache_max_size_mb: Optional[int] = None,
|
cover_cache_max_size_mb: Optional[int] = None,
|
||||||
cover_memory_cache_max_entries: int = COVER_MEMORY_MAX_ENTRIES,
|
cover_memory_cache_max_entries: int = COVER_MEMORY_MAX_ENTRIES,
|
||||||
cover_memory_cache_max_bytes: int = COVER_MEMORY_MAX_BYTES,
|
cover_memory_cache_max_bytes: int = COVER_MEMORY_MAX_BYTES,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from uuid import uuid4
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CACHE_DB_PATH = Path("/app/cache/library.db")
|
|
||||||
|
|
||||||
_UNSET = object()
|
_UNSET = object()
|
||||||
|
|
||||||
@@ -105,9 +104,12 @@ class PlaylistTrackRecord:
|
|||||||
self.duration = duration
|
self.duration = duration
|
||||||
self.created_at = created_at
|
self.created_at = created_at
|
||||||
|
|
||||||
|
def get_cache_dir() -> Path:
|
||||||
|
from core.config import get_settings
|
||||||
|
return get_settings().library_db_path
|
||||||
|
|
||||||
class PlaylistRepository:
|
class PlaylistRepository:
|
||||||
def __init__(self, db_path: Path = CACHE_DB_PATH):
|
def __init__(self, db_path: Path = get_cache_dir()):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self._local = threading.local()
|
self._local = threading.local()
|
||||||
self._write_lock = threading.Lock()
|
self._write_lock = threading.Lock()
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ YOUTUBE_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
|
|||||||
DEFAULT_DAILY_QUOTA_LIMIT = 80
|
DEFAULT_DAILY_QUOTA_LIMIT = 80
|
||||||
SEARCH_COST = 100
|
SEARCH_COST = 100
|
||||||
PREVIEW_CACHE_MAX = 100
|
PREVIEW_CACHE_MAX = 100
|
||||||
QUOTA_FILE = Path("/app/cache/youtube_quota.json")
|
|
||||||
|
|
||||||
|
def get_quota_file_path() -> Path:
|
||||||
|
from core.config import get_settings
|
||||||
|
return get_settings().cache_dir / "youtube_quota.json"
|
||||||
|
|
||||||
class YouTubeQuotaState(msgspec.Struct):
|
class YouTubeQuotaState(msgspec.Struct):
|
||||||
date: str = ""
|
date: str = ""
|
||||||
@@ -56,9 +58,10 @@ class YouTubeRepository:
|
|||||||
self._load_quota()
|
self._load_quota()
|
||||||
|
|
||||||
def _load_quota(self) -> None:
|
def _load_quota(self) -> None:
|
||||||
|
quota_file = get_quota_file_path()
|
||||||
try:
|
try:
|
||||||
if QUOTA_FILE.exists():
|
if quota_file.exists():
|
||||||
data = msgspec.json.decode(QUOTA_FILE.read_bytes(), type=YouTubeQuotaState)
|
data = msgspec.json.decode(quota_file.read_bytes(), type=YouTubeQuotaState)
|
||||||
saved_date = data.date
|
saved_date = data.date
|
||||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
if saved_date == today:
|
if saved_date == today:
|
||||||
@@ -72,9 +75,10 @@ class YouTubeRepository:
|
|||||||
logger.warning(f"Failed to load YouTube quota state: {e}")
|
logger.warning(f"Failed to load YouTube quota state: {e}")
|
||||||
|
|
||||||
def _save_quota(self) -> None:
|
def _save_quota(self) -> None:
|
||||||
|
quota_file = get_quota_file_path()
|
||||||
try:
|
try:
|
||||||
QUOTA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
quota_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
QUOTA_FILE.write_bytes(msgspec.json.encode(YouTubeQuotaState(date=self._quota_date, count=self._daily_count)))
|
quota_file.write_bytes(msgspec.json.encode(YouTubeQuotaState(date=self._quota_date, count=self._daily_count)))
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning(f"Failed to save YouTube quota state: {e}")
|
logger.warning(f"Failed to save YouTube quota state: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ from api.v1.schemas.cache import CacheStats, CacheClearResponse
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CACHE_DIR = Path("/app/cache/covers")
|
def get_covers_cache_dir() -> Path:
|
||||||
|
from core.config import get_settings
|
||||||
|
return get_settings().cache_dir / "covers"
|
||||||
|
|
||||||
class CacheService:
|
class CacheService:
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ class CacheService:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
async def get_stats(self) -> CacheStats:
|
async def get_stats(self) -> CacheStats:
|
||||||
|
covers_cache_dir = get_covers_cache_dir()
|
||||||
async with self._stats_lock:
|
async with self._stats_lock:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if self._cached_stats and (now - self._stats_cache_time) < self._stats_cache_ttl:
|
if self._cached_stats and (now - self._stats_cache_time) < self._stats_cache_ttl:
|
||||||
@@ -53,13 +55,13 @@ class CacheService:
|
|||||||
disk_count = 0
|
disk_count = 0
|
||||||
disk_bytes = 0
|
disk_bytes = 0
|
||||||
|
|
||||||
if CACHE_DIR.exists():
|
if covers_cache_dir.exists():
|
||||||
du_available = shutil.which('du') is not None
|
du_available = shutil.which('du') is not None
|
||||||
if du_available:
|
if du_available:
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
subprocess.run,
|
subprocess.run,
|
||||||
['du', '-sb', str(CACHE_DIR)],
|
['du', '-sb', str(covers_cache_dir)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5.0,
|
timeout=5.0,
|
||||||
@@ -68,7 +70,7 @@ class CacheService:
|
|||||||
disk_bytes = int(result.stdout.split()[0])
|
disk_bytes = int(result.stdout.split()[0])
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
subprocess.run,
|
subprocess.run,
|
||||||
['find', str(CACHE_DIR), '-type', 'f'],
|
['find', str(covers_cache_dir), '-type', 'f'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5.0,
|
timeout=5.0,
|
||||||
@@ -87,7 +89,7 @@ class CacheService:
|
|||||||
def _python_scan() -> tuple[int, int]:
|
def _python_scan() -> tuple[int, int]:
|
||||||
count = 0
|
count = 0
|
||||||
total = 0
|
total = 0
|
||||||
for file_path in CACHE_DIR.rglob("*"):
|
for file_path in covers_cache_dir.rglob("*"):
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
count += 1
|
count += 1
|
||||||
total += file_path.stat().st_size
|
total += file_path.stat().st_size
|
||||||
@@ -154,14 +156,15 @@ class CacheService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def clear_disk_cache(self) -> CacheClearResponse:
|
async def clear_disk_cache(self) -> CacheClearResponse:
|
||||||
|
covers_cache_dir = get_covers_cache_dir()
|
||||||
try:
|
try:
|
||||||
metadata_stats = self._disk_cache.get_stats()
|
metadata_stats = self._disk_cache.get_stats()
|
||||||
metadata_count = metadata_stats['total_count']
|
metadata_count = metadata_stats['total_count']
|
||||||
await self._disk_cache.clear_all()
|
await self._disk_cache.clear_all()
|
||||||
|
|
||||||
files_cleared = 0
|
files_cleared = 0
|
||||||
if CACHE_DIR.exists():
|
if covers_cache_dir.exists():
|
||||||
for file_path in CACHE_DIR.rglob("*"):
|
for file_path in covers_cache_dir.rglob("*"):
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
files_cleared += 1
|
files_cleared += 1
|
||||||
@@ -187,6 +190,7 @@ class CacheService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def clear_all_cache(self) -> CacheClearResponse:
|
async def clear_all_cache(self) -> CacheClearResponse:
|
||||||
|
covers_cache_dir = get_covers_cache_dir()
|
||||||
try:
|
try:
|
||||||
memory_entries = self._cache.size()
|
memory_entries = self._cache.size()
|
||||||
await self._cache.clear()
|
await self._cache.clear()
|
||||||
@@ -196,8 +200,8 @@ class CacheService:
|
|||||||
await self._disk_cache.clear_all()
|
await self._disk_cache.clear_all()
|
||||||
|
|
||||||
disk_files = 0
|
disk_files = 0
|
||||||
if CACHE_DIR.exists():
|
if covers_cache_dir.exists():
|
||||||
for file_path in CACHE_DIR.rglob("*"):
|
for file_path in covers_cache_dir.rglob("*"):
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
disk_files += 1
|
disk_files += 1
|
||||||
@@ -223,10 +227,11 @@ class CacheService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def clear_covers_cache(self) -> CacheClearResponse:
|
async def clear_covers_cache(self) -> CacheClearResponse:
|
||||||
|
covers_cache_dir = get_covers_cache_dir()
|
||||||
try:
|
try:
|
||||||
files_cleared = 0
|
files_cleared = 0
|
||||||
if CACHE_DIR.exists():
|
if covers_cache_dir.exists():
|
||||||
for file_path in CACHE_DIR.rglob("*"):
|
for file_path in covers_cache_dir.rglob("*"):
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
files_cleared += 1
|
files_cleared += 1
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
PUBLIC_API_URL=http://localhost:8688
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pageFetch } from '$lib/utils/navigationAbort';
|
import { pageFetch } from '$lib/utils/navigationAbort';
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
@@ -70,7 +71,12 @@ interface ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createClient(fetchFn: FetchFn): ApiClient {
|
function createClient(fetchFn: FetchFn): ApiClient {
|
||||||
async function request<T>(method: string, url: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
body?: unknown,
|
||||||
|
opts?: RequestOptions
|
||||||
|
): Promise<T> {
|
||||||
const { raw, ...fetchOpts } = opts ?? {};
|
const { raw, ...fetchOpts } = opts ?? {};
|
||||||
const init: RequestInit = { method, ...fetchOpts };
|
const init: RequestInit = { method, ...fetchOpts };
|
||||||
|
|
||||||
@@ -85,20 +91,29 @@ function createClient(fetchFn: FetchFn): ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetchFn(url, init);
|
const requestUrl = getApiUrl(url);
|
||||||
|
|
||||||
|
const res = await fetchFn(requestUrl, init);
|
||||||
|
|
||||||
if (raw) return res as unknown as T;
|
if (raw) return res as unknown as T;
|
||||||
return handleResponse<T>(res);
|
return handleResponse<T>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: <T = unknown>(url: string, opts?: RequestOptions) => request<T>('GET', url, undefined, opts),
|
get: <T = unknown>(url: string, opts?: RequestOptions) =>
|
||||||
post: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) => request<T>('POST', url, body, opts),
|
request<T>('GET', url, undefined, opts),
|
||||||
put: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) => request<T>('PUT', url, body, opts),
|
post: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) =>
|
||||||
patch: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) => request<T>('PATCH', url, body, opts),
|
request<T>('POST', url, body, opts),
|
||||||
delete: <T = void>(url: string, opts?: RequestOptions) => request<T>('DELETE', url, undefined, opts),
|
put: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) =>
|
||||||
head: (url: string, opts?: RequestOptions) => request<Response>('HEAD', url, undefined, { ...opts, raw: true }),
|
request<T>('PUT', url, body, opts),
|
||||||
upload: <T = unknown>(url: string, body: FormData, opts?: RequestOptions) => request<T>('POST', url, body, opts),
|
patch: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) =>
|
||||||
|
request<T>('PATCH', url, body, opts),
|
||||||
|
delete: <T = void>(url: string, opts?: RequestOptions) =>
|
||||||
|
request<T>('DELETE', url, undefined, opts),
|
||||||
|
head: (url: string, opts?: RequestOptions) =>
|
||||||
|
request<Response>('HEAD', url, undefined, { ...opts, raw: true }),
|
||||||
|
upload: <T = unknown>(url: string, body: FormData, opts?: RequestOptions) =>
|
||||||
|
request<T>('POST', url, body, opts)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import ArtistLinks from './ArtistLinks.svelte';
|
import ArtistLinks from './ArtistLinks.svelte';
|
||||||
import BackButton from './BackButton.svelte';
|
import BackButton from './BackButton.svelte';
|
||||||
import HeroBackdrop from './HeroBackdrop.svelte';
|
import HeroBackdrop from './HeroBackdrop.svelte';
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
|
|
||||||
export let artist: ArtistInfo;
|
export let artist: ArtistInfo;
|
||||||
export let showBackButton: boolean = false;
|
export let showBackButton: boolean = false;
|
||||||
@@ -15,8 +16,7 @@
|
|||||||
let heroImageLoaded = false;
|
let heroImageLoaded = false;
|
||||||
let avatarRemoteError = false;
|
let avatarRemoteError = false;
|
||||||
|
|
||||||
$: useRemoteAvatar =
|
$: useRemoteAvatar = artist.thumb_url && $imageSettingsStore.directRemoteImagesEnabled;
|
||||||
artist.thumb_url && $imageSettingsStore.directRemoteImagesEnabled;
|
|
||||||
$: resolvedRemoteAvatar = artist.thumb_url
|
$: resolvedRemoteAvatar = artist.thumb_url
|
||||||
? appendAudioDBSizeSuffix(artist.thumb_url, 'hero')
|
? appendAudioDBSizeSuffix(artist.thumb_url, 'hero')
|
||||||
: null;
|
: null;
|
||||||
@@ -30,16 +30,18 @@
|
|||||||
if (artist.wide_thumb_url) return artist.wide_thumb_url;
|
if (artist.wide_thumb_url) return artist.wide_thumb_url;
|
||||||
if (artist.fanart_url) return artist.fanart_url;
|
if (artist.fanart_url) return artist.fanart_url;
|
||||||
}
|
}
|
||||||
if (heroImageLoaded) return `/api/v1/covers/artist/${artist.musicbrainz_id}?size=500`;
|
if (heroImageLoaded)
|
||||||
|
return getApiUrl(`/api/v1/covers/artist/${artist.musicbrainz_id}?size=500`);
|
||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$: hasDistinctBackdrop = $imageSettingsStore.directRemoteImagesEnabled &&
|
$: hasDistinctBackdrop =
|
||||||
|
$imageSettingsStore.directRemoteImagesEnabled &&
|
||||||
!!(artist.banner_url || artist.wide_thumb_url || artist.fanart_url);
|
!!(artist.banner_url || artist.wide_thumb_url || artist.fanart_url);
|
||||||
|
|
||||||
function onHeroImageLoad() {
|
function onHeroImageLoad() {
|
||||||
heroImageLoaded = true;
|
heroImageLoaded = true;
|
||||||
extractDominantColor(`/api/v1/covers/artist/${artist.musicbrainz_id}?size=250`).then(
|
extractDominantColor(getApiUrl(`/api/v1/covers/artist/${artist.musicbrainz_id}?size=250`)).then(
|
||||||
(gradient) => (heroGradient = gradient)
|
(gradient) => (heroGradient = gradient)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,7 +49,9 @@
|
|||||||
$: validLinks = artist.external_links.filter((link) => link.url && link.url.trim() !== '');
|
$: validLinks = artist.external_links.filter((link) => link.url && link.url.trim() !== '');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="artist-hero group relative -mx-2 sm:-mx-4 lg:-mx-8 -mt-4 sm:-mt-8 overflow-hidden rounded-2xl transition-all duration-500">
|
<div
|
||||||
|
class="artist-hero group relative -mx-2 sm:-mx-4 lg:-mx-8 -mt-4 sm:-mt-8 overflow-hidden rounded-2xl transition-all duration-500"
|
||||||
|
>
|
||||||
<div class="absolute inset-0 bg-gradient-to-b {heroGradient} transition-all duration-1000"></div>
|
<div class="absolute inset-0 bg-gradient-to-b {heroGradient} transition-all duration-1000"></div>
|
||||||
|
|
||||||
<HeroBackdrop
|
<HeroBackdrop
|
||||||
@@ -106,7 +110,7 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img
|
||||||
src="/api/v1/covers/artist/{artist.musicbrainz_id}?size=500"
|
src={getApiUrl(`/api/v1/covers/artist/${artist.musicbrainz_id}?size=500`)}
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
class="w-full h-full object-cover transition-opacity duration-300 {heroImageLoaded
|
class="w-full h-full object-cover transition-opacity duration-300 {heroImageLoaded
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
@@ -123,7 +127,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if artist.in_library}
|
{#if artist.in_library}
|
||||||
<div class="absolute -bottom-2 -right-2 badge badge-success badge-lg gap-1 shadow-lg">
|
<div class="absolute -bottom-2 -right-2 badge badge-success badge-lg gap-1 shadow-lg">
|
||||||
<Check class="h-4 w-4" />
|
<Check class="h-4 w-4" />
|
||||||
In Library
|
In Library
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -132,7 +136,9 @@
|
|||||||
|
|
||||||
<div class="flex-1 text-center sm:text-left min-w-0">
|
<div class="flex-1 text-center sm:text-left min-w-0">
|
||||||
{#if artist.type}
|
{#if artist.type}
|
||||||
<span class="text-xs sm:text-sm font-medium text-base-content/70 uppercase tracking-wider">
|
<span
|
||||||
|
class="text-xs sm:text-sm font-medium text-base-content/70 uppercase tracking-wider"
|
||||||
|
>
|
||||||
{artist.type === 'Group' ? 'Band' : artist.type === 'Person' ? 'Artist' : artist.type}
|
{artist.type === 'Group' ? 'Band' : artist.type === 'Person' ? 'Artist' : artist.type}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -161,7 +167,7 @@
|
|||||||
animation: hero-glow 4s ease-in-out infinite;
|
animation: hero-glow 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.artist-hero:hover {
|
.artist-hero:hover {
|
||||||
border-color: rgb(var(--brand-hero) / 0.20);
|
border-color: rgb(var(--brand-hero) / 0.2);
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.artist-hero {
|
.artist-hero {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { isValidMbid } from '$lib/utils/formatting';
|
import { isValidMbid } from '$lib/utils/formatting';
|
||||||
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
||||||
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
|
|
||||||
export let mbid: string;
|
export let mbid: string;
|
||||||
export let alt: string = 'Image';
|
export let alt: string = 'Image';
|
||||||
@@ -73,20 +74,22 @@
|
|||||||
|
|
||||||
$: canonicalAlbumCoverUrl =
|
$: canonicalAlbumCoverUrl =
|
||||||
imageType === 'album' && isValidMbid(mbid)
|
imageType === 'album' && isValidMbid(mbid)
|
||||||
? `/api/v1/covers/release-group/${mbid}?size=${apiSizes[size]}`
|
? getApiUrl(`/api/v1/covers/release-group/${mbid}?size=${apiSizes[size]}`)
|
||||||
: null;
|
: null;
|
||||||
$: validMbid = imageType === 'artist' ? isValidMbid(mbid) : true;
|
$: validMbid = imageType === 'artist' ? isValidMbid(mbid) : true;
|
||||||
$: hasSource =
|
$: hasSource =
|
||||||
(useRemoteUrl && resolvedRemoteUrl) ||
|
(useRemoteUrl && resolvedRemoteUrl) ||
|
||||||
(imageType === 'album' ? (canonicalAlbumCoverUrl || customUrl || mbid) : validMbid);
|
(imageType === 'album' ? canonicalAlbumCoverUrl || customUrl || mbid : validMbid);
|
||||||
$: apiEndpoint = imageType === 'album' ? 'release-group' : 'artist';
|
$: apiEndpoint = imageType === 'album' ? 'release-group' : 'artist';
|
||||||
$: fallbackCoverUrl = `/api/v1/covers/${apiEndpoint}/${mbid}?size=${apiSizes[size]}`;
|
$: fallbackCoverUrl = getApiUrl(`/api/v1/covers/${apiEndpoint}/${mbid}?size=${apiSizes[size]}`);
|
||||||
$: coverUrl = imageType === 'album'
|
$: coverUrl =
|
||||||
? (canonicalAlbumCoverUrl ?? customUrl ?? fallbackCoverUrl)
|
imageType === 'album'
|
||||||
: fallbackCoverUrl;
|
? (canonicalAlbumCoverUrl ?? customUrl ?? fallbackCoverUrl)
|
||||||
$: retryCoverUrl = retryCount > 0
|
: fallbackCoverUrl;
|
||||||
? coverUrl + (coverUrl.includes('?') ? '&' : '?') + `_r=${retryCount}`
|
$: retryCoverUrl =
|
||||||
: coverUrl;
|
retryCount > 0
|
||||||
|
? coverUrl + (coverUrl.includes('?') ? '&' : '?') + `_r=${retryCount}`
|
||||||
|
: coverUrl;
|
||||||
$: sizeClasses = imageType === 'album' ? albumSizeClasses : artistSizeClasses;
|
$: sizeClasses = imageType === 'album' ? albumSizeClasses : artistSizeClasses;
|
||||||
$: sizeClass = sizeClasses[size];
|
$: sizeClass = sizeClasses[size];
|
||||||
$: roundedClass = roundedClasses[rounded];
|
$: roundedClass = roundedClasses[rounded];
|
||||||
@@ -95,7 +98,10 @@
|
|||||||
const newKey = coverUrl;
|
const newKey = coverUrl;
|
||||||
if (newKey !== retrySourceKey) {
|
if (newKey !== retrySourceKey) {
|
||||||
retrySourceKey = newKey;
|
retrySourceKey = newKey;
|
||||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
}
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
if (imgError) {
|
if (imgError) {
|
||||||
imgError = false;
|
imgError = false;
|
||||||
@@ -168,18 +174,42 @@
|
|||||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center">
|
<div class="absolute inset-0 w-full h-full flex items-center justify-center">
|
||||||
{#if imageType === 'album'}
|
{#if imageType === 'album'}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" class="w-full h-full">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" class="w-full h-full">
|
||||||
<rect fill={PLACEHOLDER_COLORS.DARK} width="200" height="200"/>
|
<rect fill={PLACEHOLDER_COLORS.DARK} width="200" height="200" />
|
||||||
<circle cx="100" cy="100" r="70" fill={PLACEHOLDER_COLORS.MEDIUM} stroke={PLACEHOLDER_COLORS.LIGHT} stroke-width="2"/>
|
<circle
|
||||||
<circle cx="100" cy="100" r="50" fill="none" stroke={PLACEHOLDER_COLORS.LIGHT} stroke-width="1"/>
|
cx="100"
|
||||||
<circle cx="100" cy="100" r="30" fill="none" stroke={PLACEHOLDER_COLORS.LIGHT} stroke-width="1"/>
|
cy="100"
|
||||||
<circle cx="100" cy="100" r="12" fill={PLACEHOLDER_COLORS.LIGHT}/>
|
r="70"
|
||||||
<circle cx="100" cy="100" r="4" fill={PLACEHOLDER_COLORS.DARK}/>
|
fill={PLACEHOLDER_COLORS.MEDIUM}
|
||||||
|
stroke={PLACEHOLDER_COLORS.LIGHT}
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="100"
|
||||||
|
cy="100"
|
||||||
|
r="50"
|
||||||
|
fill="none"
|
||||||
|
stroke={PLACEHOLDER_COLORS.LIGHT}
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="100"
|
||||||
|
cy="100"
|
||||||
|
r="30"
|
||||||
|
fill="none"
|
||||||
|
stroke={PLACEHOLDER_COLORS.LIGHT}
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<circle cx="100" cy="100" r="12" fill={PLACEHOLDER_COLORS.LIGHT} />
|
||||||
|
<circle cx="100" cy="100" r="4" fill={PLACEHOLDER_COLORS.DARK} />
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" class="w-full h-full">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" class="w-full h-full">
|
||||||
<rect fill={PLACEHOLDER_COLORS.DARK} width="200" height="200"/>
|
<rect fill={PLACEHOLDER_COLORS.DARK} width="200" height="200" />
|
||||||
<circle cx="100" cy="80" r="30" fill={PLACEHOLDER_COLORS.LIGHT}/>
|
<circle cx="100" cy="80" r="30" fill={PLACEHOLDER_COLORS.LIGHT} />
|
||||||
<path d="M60 120 Q100 140 140 120 L140 160 Q100 180 60 160 Z" fill={PLACEHOLDER_COLORS.LIGHT}/>
|
<path
|
||||||
|
d="M60 120 Q100 140 140 120 L140 160 Q100 180 60 160 Z"
|
||||||
|
fill={PLACEHOLDER_COLORS.LIGHT}
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
import type { BecauseYouListenTo } from '$lib/types';
|
import type { BecauseYouListenTo } from '$lib/types';
|
||||||
import HomeSection from './HomeSection.svelte';
|
import HomeSection from './HomeSection.svelte';
|
||||||
import HeroBackdrop from './HeroBackdrop.svelte';
|
import HeroBackdrop from './HeroBackdrop.svelte';
|
||||||
@@ -13,26 +14,30 @@
|
|||||||
|
|
||||||
let hasDirectBackdrop = $derived(
|
let hasDirectBackdrop = $derived(
|
||||||
$imageSettingsStore.directRemoteImagesEnabled &&
|
$imageSettingsStore.directRemoteImagesEnabled &&
|
||||||
!!(entry.banner_url || entry.wide_thumb_url || entry.fanart_url)
|
!!(entry.banner_url || entry.wide_thumb_url || entry.fanart_url)
|
||||||
);
|
);
|
||||||
|
|
||||||
let backdropUrl = $derived((() => {
|
let backdropUrl = $derived(
|
||||||
if ($imageSettingsStore.directRemoteImagesEnabled) {
|
(() => {
|
||||||
if (entry.banner_url) return entry.banner_url;
|
if ($imageSettingsStore.directRemoteImagesEnabled) {
|
||||||
if (entry.wide_thumb_url) return entry.wide_thumb_url;
|
if (entry.banner_url) return entry.banner_url;
|
||||||
if (entry.fanart_url) return entry.fanart_url;
|
if (entry.wide_thumb_url) return entry.wide_thumb_url;
|
||||||
}
|
if (entry.fanart_url) return entry.fanart_url;
|
||||||
return entry.seed_artist_mbid
|
}
|
||||||
? `/api/v1/covers/artist/${entry.seed_artist_mbid}?size=500`
|
return entry.seed_artist_mbid
|
||||||
: null;
|
? getApiUrl(`/api/v1/covers/artist/${entry.seed_artist_mbid}?size=500`)
|
||||||
})());
|
: null;
|
||||||
|
})()
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="discover-hero group relative overflow-hidden rounded-2xl transition-all duration-500 hover:shadow-[0_0_40px_rgb(var(--brand-discover)/0.12)]">
|
<article
|
||||||
|
class="discover-hero group relative overflow-hidden rounded-2xl transition-all duration-500 hover:shadow-[0_0_40px_rgb(var(--brand-discover)/0.12)]"
|
||||||
|
>
|
||||||
<HeroBackdrop
|
<HeroBackdrop
|
||||||
imageUrl={backdropUrl}
|
imageUrl={backdropUrl}
|
||||||
opacity={hasDirectBackdrop ? 0.20 : 0.12}
|
opacity={hasDirectBackdrop ? 0.2 : 0.12}
|
||||||
hoverOpacity={hasDirectBackdrop ? 0.30 : 0.18}
|
hoverOpacity={hasDirectBackdrop ? 0.3 : 0.18}
|
||||||
blur={hasDirectBackdrop ? 0 : 2}
|
blur={hasDirectBackdrop ? 0 : 2}
|
||||||
hoverBlur={hasDirectBackdrop ? 0 : 1}
|
hoverBlur={hasDirectBackdrop ? 0 : 1}
|
||||||
position="full"
|
position="full"
|
||||||
@@ -41,7 +46,9 @@
|
|||||||
<div class="relative px-4 pt-5 pb-1 sm:px-6 sm:pt-6">
|
<div class="relative px-4 pt-5 pb-1 sm:px-6 sm:pt-6">
|
||||||
<div class="flex items-center gap-3 mb-1">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-xs font-semibold uppercase tracking-widest text-base-content/40">Because You Listen To</span>
|
<span class="text-xs font-semibold uppercase tracking-widest text-base-content/40"
|
||||||
|
>Because You Listen To</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,8 +57,10 @@
|
|||||||
{entry.seed_artist}
|
{entry.seed_artist}
|
||||||
</h2>
|
</h2>
|
||||||
{#if entry.listen_count > 0}
|
{#if entry.listen_count > 0}
|
||||||
<span class="inline-flex items-center gap-1.5 shrink-0 rounded-full px-3 py-1 text-xs font-medium"
|
<span
|
||||||
style="background: rgb(var(--brand-discover) / 0.12); color: rgb(var(--brand-discover));">
|
class="inline-flex items-center gap-1.5 shrink-0 rounded-full px-3 py-1 text-xs font-medium"
|
||||||
|
style="background: rgb(var(--brand-discover) / 0.12); color: rgb(var(--brand-discover));"
|
||||||
|
>
|
||||||
<Headphones class="w-3 h-3" />
|
<Headphones class="w-3 h-3" />
|
||||||
{entry.listen_count} listens this week
|
{entry.listen_count} listens this week
|
||||||
</span>
|
</span>
|
||||||
@@ -82,7 +91,7 @@
|
|||||||
animation: hero-glow 4s ease-in-out infinite;
|
animation: hero-glow 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.discover-hero:hover {
|
.discover-hero:hover {
|
||||||
border-color: rgb(var(--brand-discover) / 0.20);
|
border-color: rgb(var(--brand-discover) / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
||||||
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
||||||
|
|
||||||
@@ -57,9 +58,7 @@
|
|||||||
{@const artistMbid = genreArtists?.[genre.name]}
|
{@const artistMbid = genreArtists?.[genre.name]}
|
||||||
{@const cdnUrl = genreArtistImages?.[genre.name] ?? null}
|
{@const cdnUrl = genreArtistImages?.[genre.name] ?? null}
|
||||||
{@const useCdn =
|
{@const useCdn =
|
||||||
cdnUrl &&
|
cdnUrl && $imageSettingsStore.directRemoteImagesEnabled && !cdnFailedSet.has(genre.name)}
|
||||||
$imageSettingsStore.directRemoteImagesEnabled &&
|
|
||||||
!cdnFailedSet.has(genre.name)}
|
|
||||||
{@const hasImage = useCdn || artistMbid}
|
{@const hasImage = useCdn || artistMbid}
|
||||||
{@const isLoaded = loadedSet.has(genre.name)}
|
{@const isLoaded = loadedSet.has(genre.name)}
|
||||||
<a
|
<a
|
||||||
@@ -74,10 +73,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
{#if hasImage && !isLoaded}
|
{#if hasImage && !isLoaded}
|
||||||
<div
|
<div class="absolute inset-0 animate-pulse bg-white/5" style="z-index: 4;"></div>
|
||||||
class="absolute inset-0 animate-pulse bg-white/5"
|
|
||||||
style="z-index: 4;"
|
|
||||||
></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if useCdn}
|
{#if useCdn}
|
||||||
@@ -95,7 +91,7 @@
|
|||||||
/>
|
/>
|
||||||
{:else if artistMbid}
|
{:else if artistMbid}
|
||||||
<img
|
<img
|
||||||
src="/api/v1/covers/artist/{artistMbid}?size=250"
|
src={getApiUrl(`/api/v1/covers/artist/${artistMbid}?size=250`)}
|
||||||
alt=""
|
alt=""
|
||||||
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-500 {isLoaded
|
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-500 {isLoaded
|
||||||
? 'opacity-40'
|
? 'opacity-40'
|
||||||
@@ -116,15 +112,11 @@
|
|||||||
style="z-index: 10;"
|
style="z-index: 10;"
|
||||||
>
|
>
|
||||||
{#if genre.listen_count}
|
{#if genre.listen_count}
|
||||||
<span
|
<span class="mb-1 text-[10px] font-medium tracking-wide text-white/70 sm:text-xs">
|
||||||
class="mb-1 text-[10px] font-medium tracking-wide text-white/70 sm:text-xs"
|
|
||||||
>
|
|
||||||
{formatCount(genre.listen_count)} plays
|
{formatCount(genre.listen_count)} plays
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<h3
|
<h3 class="line-clamp-2 text-sm font-bold leading-tight drop-shadow-md sm:text-base">
|
||||||
class="line-clamp-2 text-sm font-bold leading-tight drop-shadow-md sm:text-base"
|
|
||||||
>
|
|
||||||
{genre.name}
|
{genre.name}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
import { Search } from 'lucide-svelte';
|
import { Search } from 'lucide-svelte';
|
||||||
import type { SuggestResult } from '$lib/types';
|
import type { SuggestResult } from '$lib/types';
|
||||||
import { API } from '$lib/constants';
|
import { API } from '$lib/constants';
|
||||||
@@ -37,15 +38,13 @@
|
|||||||
let fetchGeneration = 0;
|
let fetchGeneration = 0;
|
||||||
|
|
||||||
const activeDescendant = $derived(
|
const activeDescendant = $derived(
|
||||||
activeIndex >= 0 && activeIndex < suggestions.length
|
activeIndex >= 0 && activeIndex < suggestions.length ? `${id}-option-${activeIndex}` : undefined
|
||||||
? `${id}-option-${activeIndex}`
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function coverUrl(result: SuggestResult): string {
|
function coverUrl(result: SuggestResult): string {
|
||||||
return result.type === 'artist'
|
return result.type === 'artist'
|
||||||
? `/api/v1/covers/artist/${result.musicbrainz_id}?size=250`
|
? getApiUrl(`/api/v1/covers/artist/${result.musicbrainz_id}?size=250`)
|
||||||
: `/api/v1/covers/release-group/${result.musicbrainz_id}?size=250`;
|
: getApiUrl(`/api/v1/covers/release-group/${result.musicbrainz_id}?size=250`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
@@ -69,9 +68,12 @@
|
|||||||
const generation = ++fetchGeneration;
|
const generation = ++fetchGeneration;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ results?: SuggestResult[] }>(API.search.suggest(query.trim(), 5), {
|
const data = await api.get<{ results?: SuggestResult[] }>(
|
||||||
signal: abortController.signal
|
API.search.suggest(query.trim(), 5),
|
||||||
});
|
{
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
if (generation !== fetchGeneration) return;
|
if (generation !== fetchGeneration) return;
|
||||||
suggestions = data.results ?? [];
|
suggestions = data.results ?? [];
|
||||||
showDropdown = suggestions.length > 0 || loading;
|
showDropdown = suggestions.length > 0 || loading;
|
||||||
@@ -133,10 +135,16 @@
|
|||||||
activeIndex = activeIndex > 0 ? activeIndex - 1 : suggestions.length - 1;
|
activeIndex = activeIndex > 0 ? activeIndex - 1 : suggestions.length - 1;
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
if (activeIndex >= 0) { e.preventDefault(); activeIndex = 0; }
|
if (activeIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = 0;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
if (activeIndex >= 0) { e.preventDefault(); activeIndex = suggestions.length - 1; }
|
if (activeIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = suggestions.length - 1;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
if (activeIndex >= 0 && activeIndex < suggestions.length) {
|
if (activeIndex >= 0 && activeIndex < suggestions.length) {
|
||||||
@@ -214,7 +222,10 @@
|
|||||||
role="option"
|
role="option"
|
||||||
id="{id}-option-{i}"
|
id="{id}-option-{i}"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
class="flex items-center gap-3 p-3 cursor-pointer hover:bg-base-300 transition-colors {i === activeIndex ? 'bg-base-300' : ''}"
|
class="flex items-center gap-3 p-3 cursor-pointer hover:bg-base-300 transition-colors {i ===
|
||||||
|
activeIndex
|
||||||
|
? 'bg-base-300'
|
||||||
|
: ''}"
|
||||||
onclick={() => handleSelect(result)}
|
onclick={() => handleSelect(result)}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') handleSelect(result);
|
if (e.key === 'Enter' || e.key === ' ') handleSelect(result);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
memory_entries: number;
|
memory_entries: number;
|
||||||
memory_size_bytes: number;
|
memory_size_bytes: number;
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
message = '';
|
message = '';
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/cache/stats');
|
const response = await fetch(getApiUrl('/api/v1/cache/stats'));
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
cacheStats = await response.json();
|
cacheStats = await response.json();
|
||||||
} else {
|
} else {
|
||||||
@@ -43,7 +44,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearCache(type: 'all' | 'memory' | 'disk' | 'library' | 'covers' | 'audiodb') {
|
async function clearCache(type: 'all' | 'memory' | 'disk' | 'library' | 'covers' | 'audiodb') {
|
||||||
const typeLabel = type === 'library' ? 'library database' : type === 'covers' ? 'cover images' : type === 'all' ? 'entire' : type === 'audiodb' ? 'AudioDB' : type;
|
const typeLabel =
|
||||||
|
type === 'library'
|
||||||
|
? 'library database'
|
||||||
|
: type === 'covers'
|
||||||
|
? 'cover images'
|
||||||
|
: type === 'all'
|
||||||
|
? 'entire'
|
||||||
|
: type === 'audiodb'
|
||||||
|
? 'AudioDB'
|
||||||
|
: type;
|
||||||
if (!confirm(`Are you sure you want to clear the ${typeLabel} cache?`)) {
|
if (!confirm(`Are you sure you want to clear the ${typeLabel} cache?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,7 +61,7 @@
|
|||||||
clearing = true;
|
clearing = true;
|
||||||
message = '';
|
message = '';
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/cache/clear/${type}`, {
|
const response = await fetch(getApiUrl(`/api/v1/cache/clear/${type}`), {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +69,9 @@
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
message = result.message;
|
message = result.message;
|
||||||
await load();
|
await load();
|
||||||
setTimeout(() => { message = ''; }, 5000);
|
setTimeout(() => {
|
||||||
|
message = '';
|
||||||
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
message = error.error?.message || "Couldn't clear the cache";
|
message = error.error?.message || "Couldn't clear the cache";
|
||||||
@@ -80,7 +92,8 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-4">Cache Management</h2>
|
<h2 class="card-title text-2xl mb-4">Cache Management</h2>
|
||||||
<p class="text-base-content/70 mb-6">
|
<p class="text-base-content/70 mb-6">
|
||||||
View cache usage and clear stored data. Frequently used items stay in memory, and the rest stay on disk.
|
View cache usage and clear stored data. Frequently used items stay in memory, and the rest
|
||||||
|
stay on disk.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -98,7 +111,9 @@
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Disk Metadata</div>
|
<div class="stat-title">Disk Metadata</div>
|
||||||
<div class="stat-value text-secondary">{cacheStats.disk_metadata_count}</div>
|
<div class="stat-value text-secondary">{cacheStats.disk_metadata_count}</div>
|
||||||
<div class="stat-desc">{cacheStats.disk_metadata_albums} albums, {cacheStats.disk_metadata_artists} artists</div>
|
<div class="stat-desc">
|
||||||
|
{cacheStats.disk_metadata_albums} albums, {cacheStats.disk_metadata_artists} artists
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -109,36 +124,71 @@
|
|||||||
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">Library</div>
|
<div class="stat-title">Library</div>
|
||||||
<div class="stat-value">{(cacheStats.library_db_artist_count ?? 0) + (cacheStats.library_db_album_count ?? 0)}</div>
|
<div class="stat-value">
|
||||||
<div class="stat-desc">{cacheStats.library_db_artist_count ?? 0} artists, {cacheStats.library_db_album_count ?? 0} albums</div>
|
{(cacheStats.library_db_artist_count ?? 0) + (cacheStats.library_db_album_count ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div class="stat-desc">
|
||||||
|
{cacheStats.library_db_artist_count ?? 0} artists, {cacheStats.library_db_album_count ??
|
||||||
|
0} albums
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-title">AudioDB Cache</div>
|
<div class="stat-title">AudioDB Cache</div>
|
||||||
<div class="stat-value text-info">{(cacheStats.disk_audiodb_artist_count ?? 0) + (cacheStats.disk_audiodb_album_count ?? 0)}</div>
|
<div class="stat-value text-info">
|
||||||
<div class="stat-desc">{cacheStats.disk_audiodb_artist_count ?? 0} artists, {cacheStats.disk_audiodb_album_count ?? 0} albums</div>
|
{(cacheStats.disk_audiodb_artist_count ?? 0) +
|
||||||
|
(cacheStats.disk_audiodb_album_count ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div class="stat-desc">
|
||||||
|
{cacheStats.disk_audiodb_artist_count ?? 0} artists, {cacheStats.disk_audiodb_album_count ??
|
||||||
|
0} albums
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h3 class="text-xl font-semibold">Clear Cache</h3>
|
<h3 class="text-xl font-semibold">Clear Cache</h3>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button class="btn btn-outline btn-sm" onclick={() => clearCache('memory')} disabled={clearing}>
|
<button
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
onclick={() => clearCache('memory')}
|
||||||
|
disabled={clearing}
|
||||||
|
>
|
||||||
Clear Memory
|
Clear Memory
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick={() => clearCache('disk')} disabled={clearing}>
|
<button
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
onclick={() => clearCache('disk')}
|
||||||
|
disabled={clearing}
|
||||||
|
>
|
||||||
Clear Disk Metadata
|
Clear Disk Metadata
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick={() => clearCache('covers')} disabled={clearing}>
|
<button
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
onclick={() => clearCache('covers')}
|
||||||
|
disabled={clearing}
|
||||||
|
>
|
||||||
Clear Covers
|
Clear Covers
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick={() => clearCache('library')} disabled={clearing}>
|
<button
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
onclick={() => clearCache('library')}
|
||||||
|
disabled={clearing}
|
||||||
|
>
|
||||||
Clear Library
|
Clear Library
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline btn-sm" onclick={() => clearCache('audiodb')} disabled={clearing}>
|
<button
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
onclick={() => clearCache('audiodb')}
|
||||||
|
disabled={clearing}
|
||||||
|
>
|
||||||
Clear AudioDB
|
Clear AudioDB
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-error btn-sm" onclick={() => clearCache('all')} disabled={clearing}>
|
<button
|
||||||
|
class="btn btn-error btn-sm"
|
||||||
|
onclick={() => clearCache('all')}
|
||||||
|
disabled={clearing}
|
||||||
|
>
|
||||||
{#if clearing}
|
{#if clearing}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -148,7 +198,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if message}
|
{#if message}
|
||||||
<div class="alert mt-4" class:alert-success={message.includes('success') || message.includes('Cleared')} class:alert-error={message.includes('Failed')}>
|
<div
|
||||||
|
class="alert mt-4"
|
||||||
|
class:alert-success={message.includes('success') || message.includes('Cleared')}
|
||||||
|
class:alert-error={message.includes('Failed')}
|
||||||
|
>
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { preferencesStore } from '$lib/stores/preferences';
|
import { preferencesStore } from '$lib/stores/preferences';
|
||||||
import { integrationStore } from '$lib/stores/integration';
|
import { integrationStore } from '$lib/stores/integration';
|
||||||
@@ -6,7 +7,7 @@
|
|||||||
UserPreferences,
|
UserPreferences,
|
||||||
ReleaseTypeOption,
|
ReleaseTypeOption,
|
||||||
LidarrMetadataProfilePreferences,
|
LidarrMetadataProfilePreferences,
|
||||||
MetadataProfile,
|
MetadataProfile
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
|
||||||
let preferences: UserPreferences = $state({
|
let preferences: UserPreferences = $state({
|
||||||
@@ -50,13 +51,20 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const releaseStatuses: ReleaseTypeOption[] = [
|
const releaseStatuses: ReleaseTypeOption[] = [
|
||||||
{ id: 'official', title: 'Official', description: 'Officially released by the artist or label' },
|
{
|
||||||
|
id: 'official',
|
||||||
|
title: 'Official',
|
||||||
|
description: 'Officially released by the artist or label'
|
||||||
|
},
|
||||||
{ id: 'promotion', title: 'Promotion', description: 'Promotional releases' },
|
{ id: 'promotion', title: 'Promotion', description: 'Promotional releases' },
|
||||||
{ id: 'bootleg', title: 'Bootleg', description: 'Unofficial bootleg recordings' },
|
{ id: 'bootleg', title: 'Bootleg', description: 'Unofficial bootleg recordings' },
|
||||||
{ id: 'pseudo-release', title: 'Pseudo-Release', description: 'Placeholder or meta releases' }
|
{ id: 'pseudo-release', title: 'Pseudo-Release', description: 'Placeholder or meta releases' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function toggleType(category: 'primary_types' | 'secondary_types' | 'release_statuses', id: string) {
|
function toggleType(
|
||||||
|
category: 'primary_types' | 'secondary_types' | 'release_statuses',
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
const index = preferences[category].indexOf(id);
|
const index = preferences[category].indexOf(id);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
preferences[category] = preferences[category].filter((t) => t !== id);
|
preferences[category] = preferences[category].filter((t) => t !== id);
|
||||||
@@ -101,7 +109,7 @@
|
|||||||
lidarrError = '';
|
lidarrError = '';
|
||||||
try {
|
try {
|
||||||
if (lidarrProfiles.length === 0) {
|
if (lidarrProfiles.length === 0) {
|
||||||
const profilesRes = await fetch('/api/v1/settings/lidarr/metadata-profiles');
|
const profilesRes = await fetch(getApiUrl('/api/v1/settings/lidarr/metadata-profiles'));
|
||||||
if (profilesRes.ok) {
|
if (profilesRes.ok) {
|
||||||
lidarrProfiles = await profilesRes.json();
|
lidarrProfiles = await profilesRes.json();
|
||||||
}
|
}
|
||||||
@@ -109,7 +117,7 @@
|
|||||||
|
|
||||||
const params = selectedProfileId != null ? `?profile_id=${selectedProfileId}` : '';
|
const params = selectedProfileId != null ? `?profile_id=${selectedProfileId}` : '';
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/settings/lidarr/metadata-profile/preferences${params}`
|
getApiUrl(`/api/v1/settings/lidarr/metadata-profile/preferences${params}`)
|
||||||
);
|
);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
lidarrPrefs = await response.json();
|
lidarrPrefs = await response.json();
|
||||||
@@ -134,11 +142,11 @@
|
|||||||
try {
|
try {
|
||||||
const params = selectedProfileId != null ? `?profile_id=${selectedProfileId}` : '';
|
const params = selectedProfileId != null ? `?profile_id=${selectedProfileId}` : '';
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/settings/lidarr/metadata-profile/preferences${params}`,
|
getApiUrl(`/api/v1/settings/lidarr/metadata-profile/preferences${params}`),
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(preferences),
|
body: JSON.stringify(preferences)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -170,7 +178,9 @@
|
|||||||
release_statuses: [...lidarrPrefs.release_statuses]
|
release_statuses: [...lidarrPrefs.release_statuses]
|
||||||
};
|
};
|
||||||
lidarrMessage = 'Imported from Lidarr — remember to save your settings';
|
lidarrMessage = 'Imported from Lidarr — remember to save your settings';
|
||||||
setTimeout(() => { lidarrMessage = ''; }, 5000);
|
setTimeout(() => {
|
||||||
|
lidarrMessage = '';
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onProfileChange(event: Event) {
|
async function onProfileChange(event: Event) {
|
||||||
@@ -320,26 +330,18 @@
|
|||||||
<span class="font-semibold">{lidarrPrefs.profile_name}</span>
|
<span class="font-semibold">{lidarrPrefs.profile_name}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
{#if mismatchCount > 0}
|
{#if mismatchCount > 0}
|
||||||
<span class="badge badge-warning badge-sm">
|
<span class="badge badge-warning badge-sm">
|
||||||
{mismatchCount} difference{mismatchCount !== 1 ? 's' : ''}
|
{mismatchCount} difference{mismatchCount !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge badge-success badge-sm">In sync</span>
|
<span class="badge badge-success badge-sm">In sync</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="ml-auto flex gap-2">
|
<div class="ml-auto flex gap-2">
|
||||||
<button
|
<button class="btn btn-soft btn-sm" onclick={importFromLidarr} disabled={lidarrSyncing}>
|
||||||
class="btn btn-soft btn-sm"
|
|
||||||
onclick={importFromLidarr}
|
|
||||||
disabled={lidarrSyncing}
|
|
||||||
>
|
|
||||||
Import from Lidarr
|
Import from Lidarr
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="btn btn-primary btn-sm" onclick={pushToLidarr} disabled={lidarrSyncing}>
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
onclick={pushToLidarr}
|
|
||||||
disabled={lidarrSyncing}
|
|
||||||
>
|
|
||||||
{#if lidarrSyncing}
|
{#if lidarrSyncing}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -352,7 +354,8 @@
|
|||||||
class="w-full mt-2 alert text-sm"
|
class="w-full mt-2 alert text-sm"
|
||||||
class:alert-success={lidarrMessage.includes('success')}
|
class:alert-success={lidarrMessage.includes('success')}
|
||||||
class:alert-warning={lidarrMessage.includes('remember')}
|
class:alert-warning={lidarrMessage.includes('remember')}
|
||||||
class:alert-error={!lidarrMessage.includes('success') && !lidarrMessage.includes('remember')}
|
class:alert-error={!lidarrMessage.includes('success') &&
|
||||||
|
!lidarrMessage.includes('remember')}
|
||||||
>
|
>
|
||||||
<span>{lidarrMessage}</span>
|
<span>{lidarrMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { libraryStore } from '$lib/stores/library';
|
import { libraryStore } from '$lib/stores/library';
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
|
|
||||||
export type SyncStatus = {
|
export type SyncStatus = {
|
||||||
is_syncing: boolean;
|
is_syncing: boolean;
|
||||||
@@ -128,7 +129,7 @@ function createSyncStatusStore() {
|
|||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource = new EventSource('/api/v1/cache/sync/stream');
|
eventSource = new EventSource(getApiUrl('/api/v1/cache/sync/stream'));
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
connectionMode = 'sse';
|
connectionMode = 'sse';
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes an API path by prepending the PUBLIC_API_URL if it's set.
|
||||||
|
* Useful for <img> src tags, Background image URLs, and other places where
|
||||||
|
* the API client isn't automatically resolving the absolute URL.
|
||||||
|
*
|
||||||
|
* @param path The API path (e.g., '/api/v1/covers/...')
|
||||||
|
* @returns The fully qualified API URL or the original path if PUBLIC_API_URL is unset.
|
||||||
|
*/
|
||||||
|
export function getApiUrl(path: string): string {
|
||||||
|
if (!path.startsWith('/')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.PUBLIC_API_URL) {
|
||||||
|
const baseUrl = env.PUBLIC_API_URL.replace(/\/$/, '');
|
||||||
|
return `${baseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isValidMbid } from '$lib/utils/formatting';
|
import { isValidMbid } from '$lib/utils/formatting';
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
|
|
||||||
export function isAbortError(error: unknown): boolean {
|
export function isAbortError(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -9,7 +10,7 @@ export function isAbortError(error: unknown): boolean {
|
|||||||
|
|
||||||
export function getCoverUrl(coverUrl: string | null | undefined, albumId: string): string {
|
export function getCoverUrl(coverUrl: string | null | undefined, albumId: string): string {
|
||||||
if (isValidMbid(albumId)) {
|
if (isValidMbid(albumId)) {
|
||||||
return `/api/v1/covers/release-group/${albumId}?size=250`;
|
return getApiUrl(`/api/v1/covers/release-group/${albumId}?size=250`);
|
||||||
}
|
}
|
||||||
return coverUrl || `/api/v1/covers/release-group/${albumId}?size=250`;
|
return coverUrl || getApiUrl(`/api/v1/covers/release-group/${albumId}?size=250`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AlbumBasicInfo, AlbumTracksInfo } from '$lib/types';
|
import type { AlbumBasicInfo, AlbumTracksInfo } from '$lib/types';
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
import { colors } from '$lib/colors';
|
import { colors } from '$lib/colors';
|
||||||
import AlbumImage from '$lib/components/AlbumImage.svelte';
|
import AlbumImage from '$lib/components/AlbumImage.svelte';
|
||||||
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
||||||
@@ -33,14 +34,18 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let backdropUrl = $derived(
|
let backdropUrl = $derived(
|
||||||
album.cover_url || album.album_thumb_url || (album.musicbrainz_id ? `/api/v1/covers/release-group/${album.musicbrainz_id}?size=250` : null)
|
album.cover_url ||
|
||||||
|
album.album_thumb_url ||
|
||||||
|
(album.musicbrainz_id
|
||||||
|
? getApiUrl(`/api/v1/covers/release-group/${album.musicbrainz_id}?size=250`)
|
||||||
|
: null)
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="album-hero group relative overflow-hidden rounded-2xl transition-all duration-500">
|
<div class="album-hero group relative overflow-hidden rounded-2xl transition-all duration-500">
|
||||||
<HeroBackdrop
|
<HeroBackdrop
|
||||||
imageUrl={backdropUrl}
|
imageUrl={backdropUrl}
|
||||||
opacity={0.10}
|
opacity={0.1}
|
||||||
hoverOpacity={0.15}
|
hoverOpacity={0.15}
|
||||||
blur={3}
|
blur={3}
|
||||||
hoverBlur={2}
|
hoverBlur={2}
|
||||||
@@ -48,124 +53,122 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative z-10 flex flex-col lg:flex-row gap-6 lg:gap-8 p-4 sm:p-6 lg:p-8">
|
<div class="relative z-10 flex flex-col lg:flex-row gap-6 lg:gap-8 p-4 sm:p-6 lg:p-8">
|
||||||
<div class="w-full lg:w-64 xl:w-80 flex-shrink-0">
|
<div class="w-full lg:w-64 xl:w-80 flex-shrink-0">
|
||||||
<AlbumImage
|
<AlbumImage
|
||||||
mbid={album.musicbrainz_id}
|
mbid={album.musicbrainz_id}
|
||||||
customUrl={album.cover_url}
|
customUrl={album.cover_url}
|
||||||
remoteUrl={album.album_thumb_url ?? null}
|
remoteUrl={album.album_thumb_url ?? null}
|
||||||
alt={album.title}
|
alt={album.title}
|
||||||
size="hero"
|
size="hero"
|
||||||
lazy={false}
|
lazy={false}
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
className="w-full aspect-square shadow-2xl"
|
className="w-full aspect-square shadow-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col lg:justify-end space-y-4">
|
|
||||||
<div class="text-xs sm:text-sm font-semibold uppercase tracking-wider opacity-70">
|
|
||||||
{album.type || 'Album'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold leading-tight">
|
|
||||||
{album.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{#if album.disambiguation}
|
|
||||||
<p class="text-sm opacity-60 italic">({album.disambiguation})</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
|
||||||
<button
|
|
||||||
onclick={onartistclick}
|
|
||||||
class="font-semibold hover:underline cursor-pointer"
|
|
||||||
>
|
|
||||||
{album.artist_name}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if album.year}
|
|
||||||
<span class="opacity-50">•</span>
|
|
||||||
<span>{album.year}</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if tracksInfo && tracksInfo.total_tracks > 0}
|
|
||||||
<span class="opacity-50">•</span>
|
|
||||||
<span>{tracksInfo.total_tracks} {tracksInfo.total_tracks === 1 ? 'track' : 'tracks'}</span>
|
|
||||||
{:else if loadingTracks}
|
|
||||||
<span class="opacity-50">•</span>
|
|
||||||
<span class="skeleton w-16 h-4 inline-block"></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if tracksInfo?.total_length}
|
|
||||||
<span class="opacity-50">•</span>
|
|
||||||
<span>{formatTotalDuration(tracksInfo.total_length)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-x-4 gap-y-2 text-xs sm:text-sm opacity-70">
|
<div class="flex-1 flex flex-col lg:justify-end space-y-4">
|
||||||
{#if tracksInfo?.label}
|
<div class="text-xs sm:text-sm font-semibold uppercase tracking-wider opacity-70">
|
||||||
<div>
|
{album.type || 'Album'}
|
||||||
<span class="font-semibold">Label:</span> {tracksInfo.label}
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if tracksInfo?.country}
|
|
||||||
<div>
|
|
||||||
<span class="font-semibold">Country:</span> {tracksInfo.country}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if tracksInfo?.barcode}
|
|
||||||
<div>
|
|
||||||
<span class="font-semibold">Barcode:</span> {tracksInfo.barcode}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if lidarrConfigured}
|
<h1 class="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold leading-tight">
|
||||||
<div class="pt-4 flex flex-wrap items-start gap-3">
|
{album.title}
|
||||||
{#if inLibrary}
|
</h1>
|
||||||
<div class="badge badge-lg gap-2" style="background-color: {colors.accent}; color: {colors.secondary};">
|
|
||||||
<Check class="h-4 w-4" />
|
{#if album.disambiguation}
|
||||||
In Library
|
<p class="text-sm opacity-60 italic">({album.disambiguation})</p>
|
||||||
</div>
|
{/if}
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-error btn-outline gap-1"
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
onclick={ondelete}
|
<button onclick={onartistclick} class="font-semibold hover:underline cursor-pointer">
|
||||||
|
{album.artist_name}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if album.year}
|
||||||
|
<span class="opacity-50">•</span>
|
||||||
|
<span>{album.year}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if tracksInfo && tracksInfo.total_tracks > 0}
|
||||||
|
<span class="opacity-50">•</span>
|
||||||
|
<span>{tracksInfo.total_tracks} {tracksInfo.total_tracks === 1 ? 'track' : 'tracks'}</span
|
||||||
>
|
>
|
||||||
<Trash2 class="h-4 w-4" />
|
{:else if loadingTracks}
|
||||||
Remove
|
<span class="opacity-50">•</span>
|
||||||
</button>
|
<span class="skeleton w-16 h-4 inline-block"></span>
|
||||||
{:else if isRequested}
|
{/if}
|
||||||
<div class="badge badge-lg badge-warning gap-2">
|
|
||||||
<Clock class="h-4 w-4" />
|
{#if tracksInfo?.total_length}
|
||||||
Requested
|
<span class="opacity-50">•</span>
|
||||||
</div>
|
<span>{formatTotalDuration(tracksInfo.total_length)}</span>
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-error btn-outline gap-1"
|
|
||||||
onclick={ondelete}
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4" />
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="btn btn-lg gap-2"
|
|
||||||
style="background-color: {colors.accent}; color: {colors.secondary}; border: none;"
|
|
||||||
onclick={onrequest}
|
|
||||||
disabled={requesting}
|
|
||||||
>
|
|
||||||
{#if requesting}
|
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
|
||||||
Requesting...
|
|
||||||
{:else}
|
|
||||||
<Plus class="h-5 w-5" />
|
|
||||||
Add to Library
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-2 text-xs sm:text-sm opacity-70">
|
||||||
|
{#if tracksInfo?.label}
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">Label:</span>
|
||||||
|
{tracksInfo.label}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if tracksInfo?.country}
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">Country:</span>
|
||||||
|
{tracksInfo.country}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if tracksInfo?.barcode}
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">Barcode:</span>
|
||||||
|
{tracksInfo.barcode}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if lidarrConfigured}
|
||||||
|
<div class="pt-4 flex flex-wrap items-start gap-3">
|
||||||
|
{#if inLibrary}
|
||||||
|
<div
|
||||||
|
class="badge badge-lg gap-2"
|
||||||
|
style="background-color: {colors.accent}; color: {colors.secondary};"
|
||||||
|
>
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
In Library
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-error btn-outline gap-1" onclick={ondelete}>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{:else if isRequested}
|
||||||
|
<div class="badge badge-lg badge-warning gap-2">
|
||||||
|
<Clock class="h-4 w-4" />
|
||||||
|
Requested
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-error btn-outline gap-1" onclick={ondelete}>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="btn btn-lg gap-2"
|
||||||
|
style="background-color: {colors.accent}; color: {colors.secondary}; border: none;"
|
||||||
|
onclick={onrequest}
|
||||||
|
disabled={requesting}
|
||||||
|
>
|
||||||
|
{#if requesting}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Requesting...
|
||||||
|
{:else}
|
||||||
|
<Plus class="h-5 w-5" />
|
||||||
|
Add to Library
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.album-hero {
|
.album-hero {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { beforeNavigate } from '$app/navigation';
|
import { beforeNavigate } from '$app/navigation';
|
||||||
@@ -51,9 +52,12 @@
|
|||||||
heroArtistMbid = null;
|
heroArtistMbid = null;
|
||||||
heroImageLoaded = false;
|
heroImageLoaded = false;
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ artist_mbid: string }>(`/api/v1/home/genre-artist/${encodeURIComponent(genreName)}`, {
|
const data = await api.get<{ artist_mbid: string }>(
|
||||||
signal: genreRequestAbortable.signal
|
`/api/v1/home/genre-artist/${encodeURIComponent(genreName)}`,
|
||||||
});
|
{
|
||||||
|
signal: genreRequestAbortable.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
heroArtistMbid = data.artist_mbid;
|
heroArtistMbid = data.artist_mbid;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAbortError(e)) return;
|
if (isAbortError(e)) return;
|
||||||
@@ -190,7 +194,7 @@
|
|||||||
style="z-index: 0;"
|
style="z-index: 0;"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/api/v1/covers/artist/{heroArtistMbid}?size=500"
|
src={getApiUrl(`/api/v1/covers/artist/${heroArtistMbid}?size=500`)}
|
||||||
alt=""
|
alt=""
|
||||||
class="w-full h-full object-cover object-top transition-opacity duration-700 {heroImageLoaded
|
class="w-full h-full object-cover object-top transition-opacity duration-700 {heroImageLoaded
|
||||||
? 'opacity-20'
|
? 'opacity-20'
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import { deletePlaylist, fetchPlaylist, resolvePlaylistSources, type PlaylistDetail } from '$lib/api/playlists';
|
import {
|
||||||
|
deletePlaylist,
|
||||||
|
fetchPlaylist,
|
||||||
|
resolvePlaylistSources,
|
||||||
|
type PlaylistDetail
|
||||||
|
} from '$lib/api/playlists';
|
||||||
import { playlistTrackToQueueItem } from '$lib/player/queueHelpers';
|
import { playlistTrackToQueueItem } from '$lib/player/queueHelpers';
|
||||||
import { playerStore } from '$lib/stores/player.svelte';
|
import { playerStore } from '$lib/stores/player.svelte';
|
||||||
import { toastStore } from '$lib/stores/toast';
|
import { toastStore } from '$lib/stores/toast';
|
||||||
import { getCacheTTL } from '$lib/stores/cacheTtl';
|
import { getCacheTTL } from '$lib/stores/cacheTtl';
|
||||||
import { extractDominantColor, DEFAULT_GRADIENT } from '$lib/utils/colors';
|
import { extractDominantColor, DEFAULT_GRADIENT } from '$lib/utils/colors';
|
||||||
|
import { getApiUrl } from '$lib/utils/api';
|
||||||
import { Music } from 'lucide-svelte';
|
import { Music } from 'lucide-svelte';
|
||||||
import BackButton from '$lib/components/BackButton.svelte';
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
||||||
@@ -51,13 +57,17 @@
|
|||||||
SOURCES_CACHE_PREFIX + playlistId,
|
SOURCES_CACHE_PREFIX + playlistId,
|
||||||
JSON.stringify({ ts: Date.now(), data })
|
JSON.stringify({ ts: Date.now(), data })
|
||||||
);
|
);
|
||||||
} catch { /* storage full — non-critical */ }
|
} catch {
|
||||||
|
/* storage full — non-critical */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateSourcesCache(playlistId: string) {
|
function invalidateSourcesCache(playlistId: string) {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(SOURCES_CACHE_PREFIX + playlistId);
|
localStorage.removeItem(SOURCES_CACHE_PREFIX + playlistId);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySourcesMap(sources: Record<string, string[]>) {
|
function applySourcesMap(sources: Record<string, string[]>) {
|
||||||
@@ -188,8 +198,8 @@
|
|||||||
|
|
||||||
let heroBgUrl = $derived.by(() => {
|
let heroBgUrl = $derived.by(() => {
|
||||||
if (!playlist) return null;
|
if (!playlist) return null;
|
||||||
if (playlist.custom_cover_url) return playlist.custom_cover_url;
|
if (playlist.custom_cover_url) return getApiUrl(playlist.custom_cover_url);
|
||||||
if (playlist.cover_urls.length > 0) return playlist.cover_urls[0];
|
if (playlist.cover_urls.length > 0) return getApiUrl(playlist.cover_urls[0]);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,82 +224,96 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="w-full px-2 sm:px-4 lg:px-8 py-4 sm:py-8 max-w-7xl mx-auto">
|
<div class="w-full px-2 sm:px-4 lg:px-8 py-4 sm:py-8 max-w-7xl mx-auto">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="space-y-6 sm:space-y-8">
|
<div class="space-y-6 sm:space-y-8">
|
||||||
<div class="skeleton h-10 w-10 rounded-full"></div>
|
<div class="skeleton h-10 w-10 rounded-full"></div>
|
||||||
<div class="flex flex-col lg:flex-row gap-6 lg:gap-8">
|
<div class="flex flex-col lg:flex-row gap-6 lg:gap-8">
|
||||||
<div class="skeleton w-full lg:w-64 xl:w-80 aspect-square rounded-box flex-shrink-0"></div>
|
<div class="skeleton w-full lg:w-64 xl:w-80 aspect-square rounded-box flex-shrink-0"></div>
|
||||||
<div class="flex-1 flex flex-col justify-end space-y-4">
|
<div class="flex-1 flex flex-col justify-end space-y-4">
|
||||||
<div class="skeleton h-4 w-20"></div>
|
<div class="skeleton h-4 w-20"></div>
|
||||||
<div class="skeleton h-12 w-3/4"></div>
|
<div class="skeleton h-12 w-3/4"></div>
|
||||||
<div class="skeleton h-6 w-1/2"></div>
|
<div class="skeleton h-6 w-1/2"></div>
|
||||||
<div class="flex gap-4 mt-6">
|
<div class="flex gap-4 mt-6">
|
||||||
<div class="skeleton h-12 w-32"></div>
|
<div class="skeleton h-12 w-32"></div>
|
||||||
<div class="skeleton h-12 w-32"></div>
|
<div class="skeleton h-12 w-32"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each Array(4) as _}
|
||||||
|
<div class="skeleton h-14 w-full"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
{:else if loadError}
|
||||||
{#each Array(4) as _}
|
<div class="flex flex-col items-center justify-center py-20 gap-4 text-center">
|
||||||
<div class="skeleton h-14 w-full"></div>
|
<Music class="h-16 w-16 text-base-content/20" />
|
||||||
{/each}
|
<h2 class="text-lg font-semibold text-base-content/80">Couldn't load this playlist</h2>
|
||||||
|
<p class="text-sm text-base-content/60">{loadError}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="btn btn-sm btn-accent" onclick={() => void loadPlaylist(data.playlistId)}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<BackButton fallback="/playlists" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else if !playlist}
|
||||||
{:else if loadError}
|
<div class="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
<div class="flex flex-col items-center justify-center py-20 gap-4 text-center">
|
<Music class="h-16 w-16 text-base-content/20" />
|
||||||
<Music class="h-16 w-16 text-base-content/20" />
|
<h2 class="text-lg font-semibold text-base-content/60">Playlist not found</h2>
|
||||||
<h2 class="text-lg font-semibold text-base-content/80">Couldn't load this playlist</h2>
|
|
||||||
<p class="text-sm text-base-content/60">{loadError}</p>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button class="btn btn-sm btn-accent" onclick={() => void loadPlaylist(data.playlistId)}>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<BackButton fallback="/playlists" />
|
<BackButton fallback="/playlists" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
{:else if !playlist}
|
<div class="space-y-6 sm:space-y-8">
|
||||||
<div class="flex flex-col items-center justify-center py-20 gap-4">
|
<div
|
||||||
<Music class="h-16 w-16 text-base-content/20" />
|
class="group relative rounded-box playlist-hero"
|
||||||
<h2 class="text-lg font-semibold text-base-content/60">Playlist not found</h2>
|
style="--hero-glow-color: var(--brand-hero);"
|
||||||
<BackButton fallback="/playlists" />
|
>
|
||||||
</div>
|
<div
|
||||||
{:else}
|
class="absolute inset-0 bg-gradient-to-b {heroGradient} transition-all duration-1000 rounded-box"
|
||||||
<div class="space-y-6 sm:space-y-8">
|
></div>
|
||||||
<div class="group relative rounded-box playlist-hero" style="--hero-glow-color: var(--brand-hero);">
|
<HeroBackdrop
|
||||||
<div class="absolute inset-0 bg-gradient-to-b {heroGradient} transition-all duration-1000 rounded-box"></div>
|
imageUrl={heroBgUrl}
|
||||||
<HeroBackdrop imageUrl={heroBgUrl} opacity={0.15} hoverOpacity={0.20} blur={3} hoverBlur={2} position="full" />
|
opacity={0.15}
|
||||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-base-100/50 to-base-100/80 rounded-box pointer-events-none"></div>
|
hoverOpacity={0.2}
|
||||||
|
blur={3}
|
||||||
<div class="relative z-10 p-4 sm:p-6 lg:p-8">
|
hoverBlur={2}
|
||||||
<div class="mb-4">
|
position="full"
|
||||||
<BackButton fallback="/playlists" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PlaylistHeader
|
|
||||||
bind:this={header}
|
|
||||||
{playlist}
|
|
||||||
onplayall={playAll}
|
|
||||||
onshuffleall={shuffleAll}
|
|
||||||
ondeleteclick={() => deleteModal?.showModal()}
|
|
||||||
onplaylistupdate={handlePlaylistUpdate}
|
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-b from-transparent via-base-100/50 to-base-100/80 rounded-box pointer-events-none"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="relative z-10 p-4 sm:p-6 lg:p-8">
|
||||||
|
<div class="mb-4">
|
||||||
|
<BackButton fallback="/playlists" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlaylistHeader
|
||||||
|
bind:this={header}
|
||||||
|
{playlist}
|
||||||
|
onplayall={playAll}
|
||||||
|
onshuffleall={shuffleAll}
|
||||||
|
ondeleteclick={() => deleteModal?.showModal()}
|
||||||
|
onplaylistupdate={handlePlaylistUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PlaylistTrackList
|
||||||
|
bind:this={trackList}
|
||||||
|
{playlist}
|
||||||
|
ontrackchange={() => {}}
|
||||||
|
onsourcechange={handleSourceChange}
|
||||||
|
onplaytrack={playFromTrack}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlaylistTrackList
|
<DeletePlaylistModal
|
||||||
bind:this={trackList}
|
bind:this={deleteModal}
|
||||||
{playlist}
|
playlistName={playlist.name}
|
||||||
ontrackchange={() => {}}
|
{deleting}
|
||||||
onsourcechange={handleSourceChange}
|
onconfirm={() => void confirmDelete()}
|
||||||
onplaytrack={playFromTrack}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<DeletePlaylistModal
|
|
||||||
bind:this={deleteModal}
|
|
||||||
playlistName={playlist.name}
|
|
||||||
{deleting}
|
|
||||||
onconfirm={() => void confirmDelete()}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user