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.bak
|
||||
Docs/
|
||||
|
||||
# Local dev app directory
|
||||
backend/app-dev/
|
||||
|
||||
@@ -27,6 +27,7 @@ Backend:
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements-dev.txt
|
||||
cp env.dev.example .env
|
||||
uvicorn main:app --reload --port 8688
|
||||
```
|
||||
|
||||
@@ -34,6 +35,7 @@ Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp env.dev.example .env
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@@ -265,40 +265,7 @@ A health check endpoint is at `/health`.
|
||||
|
||||
## Development
|
||||
|
||||
The backend is Python 3.13 with FastAPI. The frontend is SvelteKit with Svelte 5, Tailwind CSS, and DaisyUI.
|
||||
|
||||
### 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
|
||||
```
|
||||
See the [CONTRIBUTING](CONTRIBUTING.md) guide for instructions on setting up a development environment, running tests, and submitting contributions.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ class Settings(BaseSettings):
|
||||
cache_ttl_covers: int = Field(default=86400, description="Cover cache TTL in seconds (default: 24 hours)")
|
||||
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")
|
||||
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")
|
||||
@@ -82,6 +83,16 @@ class Settings(BaseSettings):
|
||||
|
||||
@model_validator(mode='after')
|
||||
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 = []
|
||||
warnings = []
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ROOT_APP_DIR=./app-dev
|
||||
DEBUG=true
|
||||
@@ -7,7 +7,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._write_lock = threading.Lock()
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import FastAPI, APIRouter, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from core.dependencies import (
|
||||
get_request_queue,
|
||||
get_cache,
|
||||
@@ -279,6 +280,23 @@ app.add_middleware(
|
||||
)
|
||||
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")
|
||||
def health_check():
|
||||
|
||||
@@ -44,11 +44,15 @@ def _record_degradation(msg: str) -> None:
|
||||
ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg))
|
||||
|
||||
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_MEMORY_MAX_ENTRIES = 128
|
||||
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(
|
||||
failure_threshold=5,
|
||||
success_threshold=2,
|
||||
@@ -175,7 +179,7 @@ class CoverArtRepository:
|
||||
lidarr_repo: Optional['LidarrRepository'] = None,
|
||||
jellyfin_repo: Optional['JellyfinRepository'] = 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_memory_cache_max_entries: int = COVER_MEMORY_MAX_ENTRIES,
|
||||
cover_memory_cache_max_bytes: int = COVER_MEMORY_MAX_BYTES,
|
||||
|
||||
@@ -9,7 +9,6 @@ from uuid import uuid4
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_DB_PATH = Path("/app/cache/library.db")
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
@@ -105,9 +104,12 @@ class PlaylistTrackRecord:
|
||||
self.duration = duration
|
||||
self.created_at = created_at
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
from core.config import get_settings
|
||||
return get_settings().library_db_path
|
||||
|
||||
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._local = threading.local()
|
||||
self._write_lock = threading.Lock()
|
||||
|
||||
@@ -13,8 +13,10 @@ YOUTUBE_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
|
||||
DEFAULT_DAILY_QUOTA_LIMIT = 80
|
||||
SEARCH_COST = 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):
|
||||
date: str = ""
|
||||
@@ -56,9 +58,10 @@ class YouTubeRepository:
|
||||
self._load_quota()
|
||||
|
||||
def _load_quota(self) -> None:
|
||||
quota_file = get_quota_file_path()
|
||||
try:
|
||||
if QUOTA_FILE.exists():
|
||||
data = msgspec.json.decode(QUOTA_FILE.read_bytes(), type=YouTubeQuotaState)
|
||||
if quota_file.exists():
|
||||
data = msgspec.json.decode(quota_file.read_bytes(), type=YouTubeQuotaState)
|
||||
saved_date = data.date
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
if saved_date == today:
|
||||
@@ -72,9 +75,10 @@ class YouTubeRepository:
|
||||
logger.warning(f"Failed to load YouTube quota state: {e}")
|
||||
|
||||
def _save_quota(self) -> None:
|
||||
quota_file = get_quota_file_path()
|
||||
try:
|
||||
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.parent.mkdir(parents=True, exist_ok=True)
|
||||
quota_file.write_bytes(msgspec.json.encode(YouTubeQuotaState(date=self._quota_date, count=self._daily_count)))
|
||||
except Exception as e: # noqa: BLE001
|
||||
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__)
|
||||
|
||||
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:
|
||||
|
||||
@@ -36,6 +37,7 @@ class CacheService:
|
||||
return 0
|
||||
|
||||
async def get_stats(self) -> CacheStats:
|
||||
covers_cache_dir = get_covers_cache_dir()
|
||||
async with self._stats_lock:
|
||||
now = time.time()
|
||||
if self._cached_stats and (now - self._stats_cache_time) < self._stats_cache_ttl:
|
||||
@@ -53,13 +55,13 @@ class CacheService:
|
||||
disk_count = 0
|
||||
disk_bytes = 0
|
||||
|
||||
if CACHE_DIR.exists():
|
||||
if covers_cache_dir.exists():
|
||||
du_available = shutil.which('du') is not None
|
||||
if du_available:
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
['du', '-sb', str(CACHE_DIR)],
|
||||
['du', '-sb', str(covers_cache_dir)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5.0,
|
||||
@@ -68,7 +70,7 @@ class CacheService:
|
||||
disk_bytes = int(result.stdout.split()[0])
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
['find', str(CACHE_DIR), '-type', 'f'],
|
||||
['find', str(covers_cache_dir), '-type', 'f'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5.0,
|
||||
@@ -87,7 +89,7 @@ class CacheService:
|
||||
def _python_scan() -> tuple[int, int]:
|
||||
count = 0
|
||||
total = 0
|
||||
for file_path in CACHE_DIR.rglob("*"):
|
||||
for file_path in covers_cache_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
count += 1
|
||||
total += file_path.stat().st_size
|
||||
@@ -154,14 +156,15 @@ class CacheService:
|
||||
)
|
||||
|
||||
async def clear_disk_cache(self) -> CacheClearResponse:
|
||||
covers_cache_dir = get_covers_cache_dir()
|
||||
try:
|
||||
metadata_stats = self._disk_cache.get_stats()
|
||||
metadata_count = metadata_stats['total_count']
|
||||
await self._disk_cache.clear_all()
|
||||
|
||||
files_cleared = 0
|
||||
if CACHE_DIR.exists():
|
||||
for file_path in CACHE_DIR.rglob("*"):
|
||||
if covers_cache_dir.exists():
|
||||
for file_path in covers_cache_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
file_path.unlink()
|
||||
files_cleared += 1
|
||||
@@ -187,6 +190,7 @@ class CacheService:
|
||||
)
|
||||
|
||||
async def clear_all_cache(self) -> CacheClearResponse:
|
||||
covers_cache_dir = get_covers_cache_dir()
|
||||
try:
|
||||
memory_entries = self._cache.size()
|
||||
await self._cache.clear()
|
||||
@@ -196,8 +200,8 @@ class CacheService:
|
||||
await self._disk_cache.clear_all()
|
||||
|
||||
disk_files = 0
|
||||
if CACHE_DIR.exists():
|
||||
for file_path in CACHE_DIR.rglob("*"):
|
||||
if covers_cache_dir.exists():
|
||||
for file_path in covers_cache_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
file_path.unlink()
|
||||
disk_files += 1
|
||||
@@ -223,10 +227,11 @@ class CacheService:
|
||||
)
|
||||
|
||||
async def clear_covers_cache(self) -> CacheClearResponse:
|
||||
covers_cache_dir = get_covers_cache_dir()
|
||||
try:
|
||||
files_cleared = 0
|
||||
if CACHE_DIR.exists():
|
||||
for file_path in CACHE_DIR.rglob("*"):
|
||||
if covers_cache_dir.exists():
|
||||
for file_path in covers_cache_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
file_path.unlink()
|
||||
files_cleared += 1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
PUBLIC_API_URL=http://localhost:8688
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pageFetch } from '$lib/utils/navigationAbort';
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -70,7 +71,12 @@ interface 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 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;
|
||||
return handleResponse<T>(res);
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T = unknown>(url: string, opts?: RequestOptions) => request<T>('GET', url, undefined, opts),
|
||||
post: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) => request<T>('POST', url, body, opts),
|
||||
put: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) => request<T>('PUT', 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),
|
||||
get: <T = unknown>(url: string, opts?: RequestOptions) =>
|
||||
request<T>('GET', url, undefined, opts),
|
||||
post: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) =>
|
||||
request<T>('POST', url, body, opts),
|
||||
put: <T = unknown>(url: string, body?: unknown, opts?: RequestOptions) =>
|
||||
request<T>('PUT', 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 BackButton from './BackButton.svelte';
|
||||
import HeroBackdrop from './HeroBackdrop.svelte';
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
|
||||
export let artist: ArtistInfo;
|
||||
export let showBackButton: boolean = false;
|
||||
@@ -15,8 +16,7 @@
|
||||
let heroImageLoaded = false;
|
||||
let avatarRemoteError = false;
|
||||
|
||||
$: useRemoteAvatar =
|
||||
artist.thumb_url && $imageSettingsStore.directRemoteImagesEnabled;
|
||||
$: useRemoteAvatar = artist.thumb_url && $imageSettingsStore.directRemoteImagesEnabled;
|
||||
$: resolvedRemoteAvatar = artist.thumb_url
|
||||
? appendAudioDBSizeSuffix(artist.thumb_url, 'hero')
|
||||
: null;
|
||||
@@ -30,16 +30,18 @@
|
||||
if (artist.wide_thumb_url) return artist.wide_thumb_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;
|
||||
})();
|
||||
|
||||
$: hasDistinctBackdrop = $imageSettingsStore.directRemoteImagesEnabled &&
|
||||
$: hasDistinctBackdrop =
|
||||
$imageSettingsStore.directRemoteImagesEnabled &&
|
||||
!!(artist.banner_url || artist.wide_thumb_url || artist.fanart_url);
|
||||
|
||||
function onHeroImageLoad() {
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -47,7 +49,9 @@
|
||||
$: validLinks = artist.external_links.filter((link) => link.url && link.url.trim() !== '');
|
||||
</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>
|
||||
|
||||
<HeroBackdrop
|
||||
@@ -106,7 +110,7 @@
|
||||
/>
|
||||
{:else}
|
||||
<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}
|
||||
class="w-full h-full object-cover transition-opacity duration-300 {heroImageLoaded
|
||||
? 'opacity-100'
|
||||
@@ -123,7 +127,7 @@
|
||||
</div>
|
||||
{#if artist.in_library}
|
||||
<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
|
||||
</div>
|
||||
{/if}
|
||||
@@ -132,7 +136,9 @@
|
||||
|
||||
<div class="flex-1 text-center sm:text-left min-w-0">
|
||||
{#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}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -161,7 +167,7 @@
|
||||
animation: hero-glow 4s ease-in-out infinite;
|
||||
}
|
||||
.artist-hero:hover {
|
||||
border-color: rgb(var(--brand-hero) / 0.20);
|
||||
border-color: rgb(var(--brand-hero) / 0.2);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.artist-hero {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { isValidMbid } from '$lib/utils/formatting';
|
||||
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
||||
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
|
||||
export let mbid: string;
|
||||
export let alt: string = 'Image';
|
||||
@@ -73,20 +74,22 @@
|
||||
|
||||
$: canonicalAlbumCoverUrl =
|
||||
imageType === 'album' && isValidMbid(mbid)
|
||||
? `/api/v1/covers/release-group/${mbid}?size=${apiSizes[size]}`
|
||||
? getApiUrl(`/api/v1/covers/release-group/${mbid}?size=${apiSizes[size]}`)
|
||||
: null;
|
||||
$: validMbid = imageType === 'artist' ? isValidMbid(mbid) : true;
|
||||
$: hasSource =
|
||||
(useRemoteUrl && resolvedRemoteUrl) ||
|
||||
(imageType === 'album' ? (canonicalAlbumCoverUrl || customUrl || mbid) : validMbid);
|
||||
(imageType === 'album' ? canonicalAlbumCoverUrl || customUrl || mbid : validMbid);
|
||||
$: apiEndpoint = imageType === 'album' ? 'release-group' : 'artist';
|
||||
$: fallbackCoverUrl = `/api/v1/covers/${apiEndpoint}/${mbid}?size=${apiSizes[size]}`;
|
||||
$: coverUrl = imageType === 'album'
|
||||
? (canonicalAlbumCoverUrl ?? customUrl ?? fallbackCoverUrl)
|
||||
: fallbackCoverUrl;
|
||||
$: retryCoverUrl = retryCount > 0
|
||||
? coverUrl + (coverUrl.includes('?') ? '&' : '?') + `_r=${retryCount}`
|
||||
: coverUrl;
|
||||
$: fallbackCoverUrl = getApiUrl(`/api/v1/covers/${apiEndpoint}/${mbid}?size=${apiSizes[size]}`);
|
||||
$: coverUrl =
|
||||
imageType === 'album'
|
||||
? (canonicalAlbumCoverUrl ?? customUrl ?? fallbackCoverUrl)
|
||||
: fallbackCoverUrl;
|
||||
$: retryCoverUrl =
|
||||
retryCount > 0
|
||||
? coverUrl + (coverUrl.includes('?') ? '&' : '?') + `_r=${retryCount}`
|
||||
: coverUrl;
|
||||
$: sizeClasses = imageType === 'album' ? albumSizeClasses : artistSizeClasses;
|
||||
$: sizeClass = sizeClasses[size];
|
||||
$: roundedClass = roundedClasses[rounded];
|
||||
@@ -95,7 +98,10 @@
|
||||
const newKey = coverUrl;
|
||||
if (newKey !== retrySourceKey) {
|
||||
retrySourceKey = newKey;
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
retryCount = 0;
|
||||
if (imgError) {
|
||||
imgError = false;
|
||||
@@ -168,18 +174,42 @@
|
||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center">
|
||||
{#if imageType === 'album'}
|
||||
<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"/>
|
||||
<circle cx="100" cy="100" r="70" 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}/>
|
||||
<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
|
||||
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>
|
||||
{:else}
|
||||
<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"/>
|
||||
<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}/>
|
||||
<rect fill={PLACEHOLDER_COLORS.DARK} width="200" height="200" />
|
||||
<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}
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
import type { BecauseYouListenTo } from '$lib/types';
|
||||
import HomeSection from './HomeSection.svelte';
|
||||
import HeroBackdrop from './HeroBackdrop.svelte';
|
||||
@@ -13,26 +14,30 @@
|
||||
|
||||
let hasDirectBackdrop = $derived(
|
||||
$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((() => {
|
||||
if ($imageSettingsStore.directRemoteImagesEnabled) {
|
||||
if (entry.banner_url) return entry.banner_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`
|
||||
: null;
|
||||
})());
|
||||
let backdropUrl = $derived(
|
||||
(() => {
|
||||
if ($imageSettingsStore.directRemoteImagesEnabled) {
|
||||
if (entry.banner_url) return entry.banner_url;
|
||||
if (entry.wide_thumb_url) return entry.wide_thumb_url;
|
||||
if (entry.fanart_url) return entry.fanart_url;
|
||||
}
|
||||
return entry.seed_artist_mbid
|
||||
? getApiUrl(`/api/v1/covers/artist/${entry.seed_artist_mbid}?size=500`)
|
||||
: null;
|
||||
})()
|
||||
);
|
||||
</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
|
||||
imageUrl={backdropUrl}
|
||||
opacity={hasDirectBackdrop ? 0.20 : 0.12}
|
||||
hoverOpacity={hasDirectBackdrop ? 0.30 : 0.18}
|
||||
opacity={hasDirectBackdrop ? 0.2 : 0.12}
|
||||
hoverOpacity={hasDirectBackdrop ? 0.3 : 0.18}
|
||||
blur={hasDirectBackdrop ? 0 : 2}
|
||||
hoverBlur={hasDirectBackdrop ? 0 : 1}
|
||||
position="full"
|
||||
@@ -41,7 +46,9 @@
|
||||
<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-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>
|
||||
|
||||
@@ -50,8 +57,10 @@
|
||||
{entry.seed_artist}
|
||||
</h2>
|
||||
{#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"
|
||||
style="background: rgb(var(--brand-discover) / 0.12); color: rgb(var(--brand-discover));">
|
||||
<span
|
||||
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" />
|
||||
{entry.listen_count} listens this week
|
||||
</span>
|
||||
@@ -82,7 +91,7 @@
|
||||
animation: hero-glow 4s ease-in-out infinite;
|
||||
}
|
||||
.discover-hero:hover {
|
||||
border-color: rgb(var(--brand-discover) / 0.20);
|
||||
border-color: rgb(var(--brand-discover) / 0.2);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
||||
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
||||
|
||||
@@ -57,9 +58,7 @@
|
||||
{@const artistMbid = genreArtists?.[genre.name]}
|
||||
{@const cdnUrl = genreArtistImages?.[genre.name] ?? null}
|
||||
{@const useCdn =
|
||||
cdnUrl &&
|
||||
$imageSettingsStore.directRemoteImagesEnabled &&
|
||||
!cdnFailedSet.has(genre.name)}
|
||||
cdnUrl && $imageSettingsStore.directRemoteImagesEnabled && !cdnFailedSet.has(genre.name)}
|
||||
{@const hasImage = useCdn || artistMbid}
|
||||
{@const isLoaded = loadedSet.has(genre.name)}
|
||||
<a
|
||||
@@ -74,10 +73,7 @@
|
||||
></div>
|
||||
|
||||
{#if hasImage && !isLoaded}
|
||||
<div
|
||||
class="absolute inset-0 animate-pulse bg-white/5"
|
||||
style="z-index: 4;"
|
||||
></div>
|
||||
<div class="absolute inset-0 animate-pulse bg-white/5" style="z-index: 4;"></div>
|
||||
{/if}
|
||||
|
||||
{#if useCdn}
|
||||
@@ -95,7 +91,7 @@
|
||||
/>
|
||||
{:else if artistMbid}
|
||||
<img
|
||||
src="/api/v1/covers/artist/{artistMbid}?size=250"
|
||||
src={getApiUrl(`/api/v1/covers/artist/${artistMbid}?size=250`)}
|
||||
alt=""
|
||||
class="pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-500 {isLoaded
|
||||
? 'opacity-40'
|
||||
@@ -116,15 +112,11 @@
|
||||
style="z-index: 10;"
|
||||
>
|
||||
{#if genre.listen_count}
|
||||
<span
|
||||
class="mb-1 text-[10px] font-medium tracking-wide text-white/70 sm:text-xs"
|
||||
>
|
||||
<span class="mb-1 text-[10px] font-medium tracking-wide text-white/70 sm:text-xs">
|
||||
{formatCount(genre.listen_count)} plays
|
||||
</span>
|
||||
{/if}
|
||||
<h3
|
||||
class="line-clamp-2 text-sm font-bold leading-tight drop-shadow-md sm:text-base"
|
||||
>
|
||||
<h3 class="line-clamp-2 text-sm font-bold leading-tight drop-shadow-md sm:text-base">
|
||||
{genre.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
import { Search } from 'lucide-svelte';
|
||||
import type { SuggestResult } from '$lib/types';
|
||||
import { API } from '$lib/constants';
|
||||
@@ -37,15 +38,13 @@
|
||||
let fetchGeneration = 0;
|
||||
|
||||
const activeDescendant = $derived(
|
||||
activeIndex >= 0 && activeIndex < suggestions.length
|
||||
? `${id}-option-${activeIndex}`
|
||||
: undefined
|
||||
activeIndex >= 0 && activeIndex < suggestions.length ? `${id}-option-${activeIndex}` : undefined
|
||||
);
|
||||
|
||||
function coverUrl(result: SuggestResult): string {
|
||||
return result.type === 'artist'
|
||||
? `/api/v1/covers/artist/${result.musicbrainz_id}?size=250`
|
||||
: `/api/v1/covers/release-group/${result.musicbrainz_id}?size=250`;
|
||||
? getApiUrl(`/api/v1/covers/artist/${result.musicbrainz_id}?size=250`)
|
||||
: getApiUrl(`/api/v1/covers/release-group/${result.musicbrainz_id}?size=250`);
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
@@ -69,9 +68,12 @@
|
||||
const generation = ++fetchGeneration;
|
||||
|
||||
try {
|
||||
const data = await api.get<{ results?: SuggestResult[] }>(API.search.suggest(query.trim(), 5), {
|
||||
signal: abortController.signal
|
||||
});
|
||||
const data = await api.get<{ results?: SuggestResult[] }>(
|
||||
API.search.suggest(query.trim(), 5),
|
||||
{
|
||||
signal: abortController.signal
|
||||
}
|
||||
);
|
||||
if (generation !== fetchGeneration) return;
|
||||
suggestions = data.results ?? [];
|
||||
showDropdown = suggestions.length > 0 || loading;
|
||||
@@ -133,10 +135,16 @@
|
||||
activeIndex = activeIndex > 0 ? activeIndex - 1 : suggestions.length - 1;
|
||||
break;
|
||||
case 'Home':
|
||||
if (activeIndex >= 0) { e.preventDefault(); activeIndex = 0; }
|
||||
if (activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
activeIndex = 0;
|
||||
}
|
||||
break;
|
||||
case 'End':
|
||||
if (activeIndex >= 0) { e.preventDefault(); activeIndex = suggestions.length - 1; }
|
||||
if (activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
activeIndex = suggestions.length - 1;
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
if (activeIndex >= 0 && activeIndex < suggestions.length) {
|
||||
@@ -214,7 +222,10 @@
|
||||
role="option"
|
||||
id="{id}-option-{i}"
|
||||
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)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handleSelect(result);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
interface CacheStats {
|
||||
memory_entries: number;
|
||||
memory_size_bytes: number;
|
||||
@@ -29,7 +30,7 @@
|
||||
loading = true;
|
||||
message = '';
|
||||
try {
|
||||
const response = await fetch('/api/v1/cache/stats');
|
||||
const response = await fetch(getApiUrl('/api/v1/cache/stats'));
|
||||
if (response.ok) {
|
||||
cacheStats = await response.json();
|
||||
} else {
|
||||
@@ -43,7 +44,16 @@
|
||||
}
|
||||
|
||||
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?`)) {
|
||||
return;
|
||||
}
|
||||
@@ -51,7 +61,7 @@
|
||||
clearing = true;
|
||||
message = '';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/cache/clear/${type}`, {
|
||||
const response = await fetch(getApiUrl(`/api/v1/cache/clear/${type}`), {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
@@ -59,7 +69,9 @@
|
||||
const result = await response.json();
|
||||
message = result.message;
|
||||
await load();
|
||||
setTimeout(() => { message = ''; }, 5000);
|
||||
setTimeout(() => {
|
||||
message = '';
|
||||
}, 5000);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
message = error.error?.message || "Couldn't clear the cache";
|
||||
@@ -80,7 +92,8 @@
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">Cache Management</h2>
|
||||
<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>
|
||||
|
||||
{#if loading}
|
||||
@@ -98,7 +111,9 @@
|
||||
<div class="stat">
|
||||
<div class="stat-title">Disk Metadata</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 class="stat">
|
||||
@@ -109,36 +124,71 @@
|
||||
|
||||
<div class="stat">
|
||||
<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-desc">{cacheStats.library_db_artist_count ?? 0} artists, {cacheStats.library_db_album_count ?? 0} albums</div>
|
||||
<div class="stat-value">
|
||||
{(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 class="stat">
|
||||
<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-desc">{cacheStats.disk_audiodb_artist_count ?? 0} artists, {cacheStats.disk_audiodb_album_count ?? 0} albums</div>
|
||||
<div class="stat-value text-info">
|
||||
{(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 class="space-y-4">
|
||||
<h3 class="text-xl font-semibold">Clear Cache</h3>
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</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}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
@@ -148,7 +198,11 @@
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
import { browser } from '$app/environment';
|
||||
import { preferencesStore } from '$lib/stores/preferences';
|
||||
import { integrationStore } from '$lib/stores/integration';
|
||||
@@ -6,7 +7,7 @@
|
||||
UserPreferences,
|
||||
ReleaseTypeOption,
|
||||
LidarrMetadataProfilePreferences,
|
||||
MetadataProfile,
|
||||
MetadataProfile
|
||||
} from '$lib/types';
|
||||
|
||||
let preferences: UserPreferences = $state({
|
||||
@@ -50,13 +51,20 @@
|
||||
];
|
||||
|
||||
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: 'bootleg', title: 'Bootleg', description: 'Unofficial bootleg recordings' },
|
||||
{ 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);
|
||||
if (index > -1) {
|
||||
preferences[category] = preferences[category].filter((t) => t !== id);
|
||||
@@ -101,7 +109,7 @@
|
||||
lidarrError = '';
|
||||
try {
|
||||
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) {
|
||||
lidarrProfiles = await profilesRes.json();
|
||||
}
|
||||
@@ -109,7 +117,7 @@
|
||||
|
||||
const params = selectedProfileId != null ? `?profile_id=${selectedProfileId}` : '';
|
||||
const response = await fetch(
|
||||
`/api/v1/settings/lidarr/metadata-profile/preferences${params}`
|
||||
getApiUrl(`/api/v1/settings/lidarr/metadata-profile/preferences${params}`)
|
||||
);
|
||||
if (response.ok) {
|
||||
lidarrPrefs = await response.json();
|
||||
@@ -134,11 +142,11 @@
|
||||
try {
|
||||
const params = selectedProfileId != null ? `?profile_id=${selectedProfileId}` : '';
|
||||
const response = await fetch(
|
||||
`/api/v1/settings/lidarr/metadata-profile/preferences${params}`,
|
||||
getApiUrl(`/api/v1/settings/lidarr/metadata-profile/preferences${params}`),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(preferences),
|
||||
body: JSON.stringify(preferences)
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
@@ -170,7 +178,9 @@
|
||||
release_statuses: [...lidarrPrefs.release_statuses]
|
||||
};
|
||||
lidarrMessage = 'Imported from Lidarr — remember to save your settings';
|
||||
setTimeout(() => { lidarrMessage = ''; }, 5000);
|
||||
setTimeout(() => {
|
||||
lidarrMessage = '';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function onProfileChange(event: Event) {
|
||||
@@ -320,26 +330,18 @@
|
||||
<span class="font-semibold">{lidarrPrefs.profile_name}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if mismatchCount > 0}
|
||||
<span class="badge badge-warning badge-sm">
|
||||
{mismatchCount} difference{mismatchCount !== 1 ? 's' : ''}
|
||||
{#if mismatchCount > 0}
|
||||
<span class="badge badge-warning badge-sm">
|
||||
{mismatchCount} difference{mismatchCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="badge badge-success badge-sm">In sync</span>
|
||||
{/if}
|
||||
<div class="ml-auto flex gap-2">
|
||||
<button
|
||||
class="btn btn-soft btn-sm"
|
||||
onclick={importFromLidarr}
|
||||
disabled={lidarrSyncing}
|
||||
>
|
||||
<button class="btn btn-soft btn-sm" onclick={importFromLidarr} disabled={lidarrSyncing}>
|
||||
Import from Lidarr
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={pushToLidarr}
|
||||
disabled={lidarrSyncing}
|
||||
>
|
||||
<button class="btn btn-primary btn-sm" onclick={pushToLidarr} disabled={lidarrSyncing}>
|
||||
{#if lidarrSyncing}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{/if}
|
||||
@@ -352,7 +354,8 @@
|
||||
class="w-full mt-2 alert text-sm"
|
||||
class:alert-success={lidarrMessage.includes('success')}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { api } from '$lib/api/client';
|
||||
import { libraryStore } from '$lib/stores/library';
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
|
||||
export type SyncStatus = {
|
||||
is_syncing: boolean;
|
||||
@@ -128,7 +129,7 @@ function createSyncStatusStore() {
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/api/v1/cache/sync/stream');
|
||||
eventSource = new EventSource(getApiUrl('/api/v1/cache/sync/stream'));
|
||||
|
||||
eventSource.onopen = () => {
|
||||
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 { getApiUrl } from '$lib/utils/api';
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
@@ -9,7 +10,7 @@ export function isAbortError(error: unknown): boolean {
|
||||
|
||||
export function getCoverUrl(coverUrl: string | null | undefined, albumId: string): string {
|
||||
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">
|
||||
import type { AlbumBasicInfo, AlbumTracksInfo } from '$lib/types';
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
import { colors } from '$lib/colors';
|
||||
import AlbumImage from '$lib/components/AlbumImage.svelte';
|
||||
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
||||
@@ -33,14 +34,18 @@
|
||||
}: Props = $props();
|
||||
|
||||
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>
|
||||
|
||||
<div class="album-hero group relative overflow-hidden rounded-2xl transition-all duration-500">
|
||||
<HeroBackdrop
|
||||
imageUrl={backdropUrl}
|
||||
opacity={0.10}
|
||||
opacity={0.1}
|
||||
hoverOpacity={0.15}
|
||||
blur={3}
|
||||
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="w-full lg:w-64 xl:w-80 flex-shrink-0">
|
||||
<AlbumImage
|
||||
mbid={album.musicbrainz_id}
|
||||
customUrl={album.cover_url}
|
||||
remoteUrl={album.album_thumb_url ?? null}
|
||||
alt={album.title}
|
||||
size="hero"
|
||||
lazy={false}
|
||||
rounded="xl"
|
||||
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 class="w-full lg:w-64 xl:w-80 flex-shrink-0">
|
||||
<AlbumImage
|
||||
mbid={album.musicbrainz_id}
|
||||
customUrl={album.cover_url}
|
||||
remoteUrl={album.album_thumb_url ?? null}
|
||||
alt={album.title}
|
||||
size="hero"
|
||||
lazy={false}
|
||||
rounded="xl"
|
||||
className="w-full aspect-square shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold leading-tight">
|
||||
{album.title}
|
||||
</h1>
|
||||
<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>
|
||||
|
||||
{#if album.disambiguation}
|
||||
<p class="text-sm opacity-60 italic">({album.disambiguation})</p>
|
||||
{/if}
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold leading-tight">
|
||||
{album.title}
|
||||
</h1>
|
||||
|
||||
<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 album.disambiguation}
|
||||
<p class="text-sm opacity-60 italic">({album.disambiguation})</p>
|
||||
{/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}
|
||||
<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 tracksInfo?.total_length}
|
||||
<span class="opacity-50">•</span>
|
||||
<span>{formatTotalDuration(tracksInfo.total_length)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if album.year}
|
||||
<span class="opacity-50">•</span>
|
||||
<span>{album.year}</span>
|
||||
{/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}
|
||||
{#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" />
|
||||
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>
|
||||
{: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>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
.album-hero {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
@@ -51,9 +52,12 @@
|
||||
heroArtistMbid = null;
|
||||
heroImageLoaded = false;
|
||||
try {
|
||||
const data = await api.get<{ artist_mbid: string }>(`/api/v1/home/genre-artist/${encodeURIComponent(genreName)}`, {
|
||||
signal: genreRequestAbortable.signal
|
||||
});
|
||||
const data = await api.get<{ artist_mbid: string }>(
|
||||
`/api/v1/home/genre-artist/${encodeURIComponent(genreName)}`,
|
||||
{
|
||||
signal: genreRequestAbortable.signal
|
||||
}
|
||||
);
|
||||
heroArtistMbid = data.artist_mbid;
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
@@ -190,7 +194,7 @@
|
||||
style="z-index: 0;"
|
||||
>
|
||||
<img
|
||||
src="/api/v1/covers/artist/{heroArtistMbid}?size=500"
|
||||
src={getApiUrl(`/api/v1/covers/artist/${heroArtistMbid}?size=500`)}
|
||||
alt=""
|
||||
class="w-full h-full object-cover object-top transition-opacity duration-700 {heroImageLoaded
|
||||
? 'opacity-20'
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
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 { playerStore } from '$lib/stores/player.svelte';
|
||||
import { toastStore } from '$lib/stores/toast';
|
||||
import { getCacheTTL } from '$lib/stores/cacheTtl';
|
||||
import { extractDominantColor, DEFAULT_GRADIENT } from '$lib/utils/colors';
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
import { Music } from 'lucide-svelte';
|
||||
import BackButton from '$lib/components/BackButton.svelte';
|
||||
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
||||
@@ -51,13 +57,17 @@
|
||||
SOURCES_CACHE_PREFIX + playlistId,
|
||||
JSON.stringify({ ts: Date.now(), data })
|
||||
);
|
||||
} catch { /* storage full — non-critical */ }
|
||||
} catch {
|
||||
/* storage full — non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateSourcesCache(playlistId: string) {
|
||||
try {
|
||||
localStorage.removeItem(SOURCES_CACHE_PREFIX + playlistId);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function applySourcesMap(sources: Record<string, string[]>) {
|
||||
@@ -188,8 +198,8 @@
|
||||
|
||||
let heroBgUrl = $derived.by(() => {
|
||||
if (!playlist) return null;
|
||||
if (playlist.custom_cover_url) return playlist.custom_cover_url;
|
||||
if (playlist.cover_urls.length > 0) return playlist.cover_urls[0];
|
||||
if (playlist.custom_cover_url) return getApiUrl(playlist.custom_cover_url);
|
||||
if (playlist.cover_urls.length > 0) return getApiUrl(playlist.cover_urls[0]);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -214,82 +224,96 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full px-2 sm:px-4 lg:px-8 py-4 sm:py-8 max-w-7xl mx-auto">
|
||||
{#if loading}
|
||||
<div class="space-y-6 sm:space-y-8">
|
||||
<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="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="skeleton h-4 w-20"></div>
|
||||
<div class="skeleton h-12 w-3/4"></div>
|
||||
<div class="skeleton h-6 w-1/2"></div>
|
||||
<div class="flex gap-4 mt-6">
|
||||
<div class="skeleton h-12 w-32"></div>
|
||||
<div class="skeleton h-12 w-32"></div>
|
||||
{#if loading}
|
||||
<div class="space-y-6 sm:space-y-8">
|
||||
<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="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="skeleton h-4 w-20"></div>
|
||||
<div class="skeleton h-12 w-3/4"></div>
|
||||
<div class="skeleton h-6 w-1/2"></div>
|
||||
<div class="flex gap-4 mt-6">
|
||||
<div class="skeleton h-12 w-32"></div>
|
||||
<div class="skeleton h-12 w-32"></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 class="space-y-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="skeleton h-14 w-full"></div>
|
||||
{/each}
|
||||
{:else if loadError}
|
||||
<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" />
|
||||
<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>
|
||||
{:else if loadError}
|
||||
<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" />
|
||||
<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>
|
||||
{:else if !playlist}
|
||||
<div class="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Music class="h-16 w-16 text-base-content/20" />
|
||||
<h2 class="text-lg font-semibold text-base-content/60">Playlist not found</h2>
|
||||
<BackButton fallback="/playlists" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if !playlist}
|
||||
<div class="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Music class="h-16 w-16 text-base-content/20" />
|
||||
<h2 class="text-lg font-semibold text-base-content/60">Playlist not found</h2>
|
||||
<BackButton fallback="/playlists" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6 sm:space-y-8">
|
||||
<div class="group relative rounded-box playlist-hero" style="--hero-glow-color: var(--brand-hero);">
|
||||
<div class="absolute inset-0 bg-gradient-to-b {heroGradient} transition-all duration-1000 rounded-box"></div>
|
||||
<HeroBackdrop imageUrl={heroBgUrl} opacity={0.15} hoverOpacity={0.20} blur={3} hoverBlur={2} position="full" />
|
||||
<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}
|
||||
{:else}
|
||||
<div class="space-y-6 sm:space-y-8">
|
||||
<div
|
||||
class="group relative rounded-box playlist-hero"
|
||||
style="--hero-glow-color: var(--brand-hero);"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b {heroGradient} transition-all duration-1000 rounded-box"
|
||||
></div>
|
||||
<HeroBackdrop
|
||||
imageUrl={heroBgUrl}
|
||||
opacity={0.15}
|
||||
hoverOpacity={0.2}
|
||||
blur={3}
|
||||
hoverBlur={2}
|
||||
position="full"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<PlaylistTrackList
|
||||
bind:this={trackList}
|
||||
{playlist}
|
||||
ontrackchange={() => {}}
|
||||
onsourcechange={handleSourceChange}
|
||||
onplaytrack={playFromTrack}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PlaylistTrackList
|
||||
bind:this={trackList}
|
||||
{playlist}
|
||||
ontrackchange={() => {}}
|
||||
onsourcechange={handleSourceChange}
|
||||
onplaytrack={playFromTrack}
|
||||
<DeletePlaylistModal
|
||||
bind:this={deleteModal}
|
||||
playlistName={playlist.name}
|
||||
{deleting}
|
||||
onconfirm={() => void confirmDelete()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeletePlaylistModal
|
||||
bind:this={deleteModal}
|
||||
playlistName={playlist.name}
|
||||
{deleting}
|
||||
onconfirm={() => void confirmDelete()}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user