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:
Arno
2026-04-05 17:18:56 +02:00
committed by GitHub
parent e84f2d6127
commit 70809b3d7d
26 changed files with 564 additions and 367 deletions
+3
View File
@@ -53,3 +53,6 @@ ehthumbs.db
AGENTS.md
AGENTS.md.bak
Docs/
# Local dev app directory
backend/app-dev/
+2
View File
@@ -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
```
+1 -34
View File
@@ -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.
---
+11
View File
@@ -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 = []
+2
View File
@@ -0,0 +1,2 @@
ROOT_APP_DIR=./app-dev
DEBUG=true
+4 -1
View File
@@ -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()
+18
View File
@@ -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():
+6 -2
View File
@@ -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,
+4 -2
View File
@@ -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()
+9 -5
View File
@@ -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}")
+17 -12
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
PUBLIC_API_URL=http://localhost:8688
+24 -9
View File
@@ -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)
};
}
+16 -10
View File
@@ -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 {
+49 -19
View File
@@ -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) {
+6 -14
View File
@@ -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>
+2 -1
View File
@@ -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';
+22
View File
@@ -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;
}
+3 -2
View File
@@ -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`);
}
+115 -112
View File
@@ -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>
<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 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 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>
<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 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}
<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
>
<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 {
+8 -4
View File
@@ -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'
+96 -72
View File
@@ -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>