feat: blur sensitive images with user opt-in (#471)

* feat: blur explicit images with user opt-in to show

- add explicit_images table to track flagged image URLs/IDs
- add show_explicit_artwork user preference (default: hidden)
- add /moderation/explicit-images endpoint to fetch flagged images
- add ExplicitImage component that blurs flagged images
- hide explicit images from og:image/twitter meta tags
- add portal toggle for explicit artwork preference

images can be flagged by image_id (R2) or full URL (external avatars).
frontend fetches the list once and checks all rendered images.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: use 'sensitive' instead of 'explicit' in tooltip

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: rename explicit to sensitive throughout codebase

- Rename table: explicit_images โ†’ sensitive_images
- Rename model: ExplicitImage โ†’ SensitiveImage
- Rename preference: show_explicit_artwork โ†’ show_sensitive_artwork
- Rename component: ExplicitImage.svelte โ†’ SensitiveImage.svelte
- Update all references, endpoints, and UI text

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 2a17485a 46345ca1

+55
backend/alembic/versions/2025_12_05_101709_effe28dd977b_add_sensitive_images_table_and_show_.py
··· 1 + """add sensitive_images table and show_sensitive_artwork preference 2 + 3 + Revision ID: effe28dd977b 4 + Revises: d4e6457a0fe3 5 + Create Date: 2025-12-05 10:17:09.747708 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "effe28dd977b" 17 + down_revision: str | Sequence[str] | None = "d4e6457a0fe3" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + # create sensitive_images table 25 + op.create_table( 26 + "sensitive_images", 27 + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), 28 + sa.Column("image_id", sa.String, nullable=True, index=True), 29 + sa.Column("url", sa.Text, nullable=True, index=True), 30 + sa.Column("reason", sa.String, nullable=True), 31 + sa.Column( 32 + "flagged_at", 33 + sa.DateTime(timezone=True), 34 + nullable=False, 35 + server_default=sa.func.now(), 36 + ), 37 + sa.Column("flagged_by", sa.String, nullable=True), 38 + ) 39 + 40 + # add show_sensitive_artwork preference 41 + op.add_column( 42 + "user_preferences", 43 + sa.Column( 44 + "show_sensitive_artwork", 45 + sa.Boolean, 46 + nullable=False, 47 + server_default=sa.text("false"), 48 + ), 49 + ) 50 + 51 + 52 + def downgrade() -> None: 53 + """Downgrade schema.""" 54 + op.drop_column("user_preferences", "show_sensitive_artwork") 55 + op.drop_table("sensitive_images")
+2
backend/src/backend/api/__init__.py
··· 5 5 from backend.api.audio import router as audio_router 6 6 from backend.api.auth import router as auth_router 7 7 from backend.api.exports import router as exports_router 8 + from backend.api.moderation import router as moderation_router 8 9 from backend.api.now_playing import router as now_playing_router 9 10 from backend.api.oembed import router as oembed_router 10 11 from backend.api.preferences import router as preferences_router ··· 19 20 "audio_router", 20 21 "auth_router", 21 22 "exports_router", 23 + "moderation_router", 22 24 "now_playing_router", 23 25 "oembed_router", 24 26 "preferences_router",
+39
backend/src/backend/api/moderation.py
··· 1 + """content moderation api endpoints.""" 2 + 3 + from typing import Annotated 4 + 5 + from fastapi import APIRouter, Depends 6 + from pydantic import BaseModel 7 + from sqlalchemy import select 8 + from sqlalchemy.ext.asyncio import AsyncSession 9 + 10 + from backend.models import SensitiveImage, get_db 11 + 12 + router = APIRouter(prefix="/moderation", tags=["moderation"]) 13 + 14 + 15 + class SensitiveImagesResponse(BaseModel): 16 + """list of sensitive image identifiers.""" 17 + 18 + # R2 image IDs (for track/album artwork) 19 + image_ids: list[str] 20 + # full URLs (for external images like avatars) 21 + urls: list[str] 22 + 23 + 24 + @router.get("/sensitive-images") 25 + async def get_sensitive_images( 26 + db: Annotated[AsyncSession, Depends(get_db)], 27 + ) -> SensitiveImagesResponse: 28 + """get all flagged sensitive images. 29 + 30 + returns both image_ids (for R2-stored images) and full URLs 31 + (for external images like avatars). clients should check both. 32 + """ 33 + result = await db.execute(select(SensitiveImage)) 34 + images = result.scalars().all() 35 + 36 + image_ids = [img.image_id for img in images if img.image_id] 37 + urls = [img.url for img in images if img.url] 38 + 39 + return SensitiveImagesResponse(image_ids=image_ids, urls=urls)
+9
backend/src/backend/api/preferences.py
··· 25 25 enable_teal_scrobbling: bool 26 26 # indicates if user needs to re-login to activate teal scrobbling 27 27 teal_needs_reauth: bool = False 28 + show_sensitive_artwork: bool = False 28 29 29 30 30 31 class PreferencesUpdate(BaseModel): ··· 35 36 allow_comments: bool | None = None 36 37 hidden_tags: list[str] | None = None 37 38 enable_teal_scrobbling: bool | None = None 39 + show_sensitive_artwork: bool | None = None 38 40 39 41 40 42 def _has_teal_scope(session: Session) -> bool: ··· 78 80 hidden_tags=prefs.hidden_tags or [], 79 81 enable_teal_scrobbling=prefs.enable_teal_scrobbling, 80 82 teal_needs_reauth=teal_needs_reauth, 83 + show_sensitive_artwork=prefs.show_sensitive_artwork, 81 84 ) 82 85 83 86 ··· 110 113 enable_teal_scrobbling=update.enable_teal_scrobbling 111 114 if update.enable_teal_scrobbling is not None 112 115 else False, 116 + show_sensitive_artwork=update.show_sensitive_artwork 117 + if update.show_sensitive_artwork is not None 118 + else False, 113 119 ) 114 120 db.add(prefs) 115 121 else: ··· 124 130 prefs.hidden_tags = update.hidden_tags 125 131 if update.enable_teal_scrobbling is not None: 126 132 prefs.enable_teal_scrobbling = update.enable_teal_scrobbling 133 + if update.show_sensitive_artwork is not None: 134 + prefs.show_sensitive_artwork = update.show_sensitive_artwork 127 135 128 136 await db.commit() 129 137 await db.refresh(prefs) ··· 139 147 hidden_tags=prefs.hidden_tags or [], 140 148 enable_teal_scrobbling=prefs.enable_teal_scrobbling, 141 149 teal_needs_reauth=teal_needs_reauth, 150 + show_sensitive_artwork=prefs.show_sensitive_artwork, 142 151 )
+2
backend/src/backend/main.py
··· 30 30 audio_router, 31 31 auth_router, 32 32 exports_router, 33 + moderation_router, 33 34 now_playing_router, 34 35 oembed_router, 35 36 preferences_router, ··· 202 203 app.include_router(now_playing_router) 203 204 app.include_router(migration_router) 204 205 app.include_router(exports_router) 206 + app.include_router(moderation_router) 205 207 app.include_router(oembed_router) 206 208 app.include_router(stats_router) 207 209
+2
backend/src/backend/models/__init__.py
··· 4 4 from backend.models.artist import Artist 5 5 from backend.models.copyright_scan import CopyrightScan, ScanResolution 6 6 from backend.models.database import Base 7 + from backend.models.sensitive_image import SensitiveImage 7 8 from backend.models.exchange_token import ExchangeToken 8 9 from backend.models.job import Job 9 10 from backend.models.oauth_state import OAuthStateModel ··· 28 29 "PendingDevToken", 29 30 "QueueState", 30 31 "ScanResolution", 32 + "SensitiveImage", 31 33 "Tag", 32 34 "Track", 33 35 "TrackComment",
+6
backend/src/backend/models/preferences.py
··· 46 46 Boolean, nullable=False, default=False, server_default=text("false") 47 47 ) 48 48 49 + # content preferences 50 + # when enabled, sensitive artwork is shown unblurred 51 + show_sensitive_artwork: Mapped[bool] = mapped_column( 52 + Boolean, nullable=False, default=False, server_default=text("false") 53 + ) 54 + 49 55 # metadata 50 56 created_at: Mapped[datetime] = mapped_column( 51 57 DateTime(timezone=True),
+38
backend/src/backend/models/sensitive_image.py
··· 1 + """sensitive image tracking for content moderation.""" 2 + 3 + from datetime import UTC, datetime 4 + 5 + from sqlalchemy import DateTime, String, Text 6 + from sqlalchemy.orm import Mapped, mapped_column 7 + 8 + from backend.models.database import Base 9 + 10 + 11 + class SensitiveImage(Base): 12 + """tracks images flagged as sensitive. 13 + 14 + images can be identified by: 15 + - image_id: R2 storage ID (for track/album artwork) 16 + - url: full URL (for external images like avatars) 17 + 18 + at least one must be set. if both match, the image is sensitive. 19 + """ 20 + 21 + __tablename__ = "sensitive_images" 22 + 23 + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 24 + 25 + # one or both of these identify the image 26 + image_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True) 27 + url: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) 28 + 29 + # metadata 30 + reason: Mapped[str | None] = mapped_column(String, nullable=True) 31 + flagged_at: Mapped[datetime] = mapped_column( 32 + DateTime(timezone=True), 33 + default=lambda: datetime.now(UTC), 34 + nullable=False, 35 + ) 36 + flagged_by: Mapped[str | None] = mapped_column( 37 + String, nullable=True 38 + ) # admin who flagged it
+91
frontend/src/lib/components/SensitiveImage.svelte
··· 1 + <script lang="ts"> 2 + import { preferences } from '$lib/preferences.svelte'; 3 + import { moderation } from '$lib/moderation.svelte'; 4 + 5 + interface Props { 6 + /** image URL to check for sensitive content */ 7 + src: string | null | undefined; 8 + /** content to render (should include the img element) */ 9 + children: import('svelte').Snippet; 10 + /** tooltip position - 'above' for small images, 'center' for large */ 11 + tooltipPosition?: 'above' | 'center'; 12 + } 13 + 14 + let { src, children, tooltipPosition = 'above' }: Props = $props(); 15 + 16 + let isSensitive = $derived(moderation.isSensitive(src)); 17 + let shouldBlur = $derived(isSensitive && !preferences.showSensitiveArtwork); 18 + </script> 19 + 20 + <div class="sensitive-wrapper" class:blur={shouldBlur} class:tooltip-center={tooltipPosition === 'center'}> 21 + {@render children()} 22 + {#if shouldBlur} 23 + <div class="sensitive-tooltip"> 24 + <span>sensitive - enable in portal</span> 25 + </div> 26 + {/if} 27 + </div> 28 + 29 + <style> 30 + .sensitive-wrapper { 31 + position: relative; 32 + display: contents; 33 + } 34 + 35 + .sensitive-wrapper.blur { 36 + display: block; 37 + position: relative; 38 + } 39 + 40 + .sensitive-wrapper.blur :global(img) { 41 + filter: blur(12px); 42 + transition: filter 0.2s; 43 + } 44 + 45 + .sensitive-wrapper.blur:hover :global(img) { 46 + filter: blur(6px); 47 + } 48 + 49 + /* larger blur for centered tooltip (detail pages) */ 50 + .sensitive-wrapper.blur.tooltip-center :global(img) { 51 + filter: blur(20px); 52 + } 53 + 54 + .sensitive-wrapper.blur.tooltip-center:hover :global(img) { 55 + filter: blur(10px); 56 + } 57 + 58 + /* default: tooltip appears above the image, aligned left (for player/small images) */ 59 + .sensitive-tooltip { 60 + position: absolute; 61 + bottom: 100%; 62 + left: 0; 63 + margin-bottom: 4px; 64 + background: var(--bg-primary); 65 + border: 1px solid var(--border-default); 66 + border-radius: 4px; 67 + padding: 0.25rem 0.5rem; 68 + font-size: 0.7rem; 69 + color: var(--text-tertiary); 70 + white-space: nowrap; 71 + opacity: 0; 72 + pointer-events: none; 73 + transition: opacity 0.2s; 74 + z-index: 100; 75 + } 76 + 77 + /* centered tooltip for large images (detail pages) */ 78 + .tooltip-center .sensitive-tooltip { 79 + top: 50%; 80 + bottom: auto; 81 + left: 50%; 82 + transform: translate(-50%, -50%); 83 + margin-bottom: 0; 84 + padding: 0.5rem 0.75rem; 85 + font-size: 0.8rem; 86 + } 87 + 88 + .sensitive-wrapper.blur:hover .sensitive-tooltip { 89 + opacity: 1; 90 + } 91 + </style>
+30 -25
frontend/src/lib/components/TrackItem.svelte
··· 3 3 import LikeButton from './LikeButton.svelte'; 4 4 import TrackActionsMenu from './TrackActionsMenu.svelte'; 5 5 import LikersTooltip from './LikersTooltip.svelte'; 6 + import SensitiveImage from './SensitiveImage.svelte'; 6 7 import type { Track } from '$lib/types'; 7 8 import { queue } from '$lib/queue.svelte'; 8 9 import { toast } from '$lib/toast.svelte'; ··· 110 111 }} 111 112 > 112 113 {#if track.image_url && !trackImageError} 113 - <div class="track-image"> 114 - <img 115 - src={track.image_url} 116 - alt="{track.title} artwork" 117 - width="48" 118 - height="48" 119 - loading={imageLoading} 120 - fetchpriority={imageFetchPriority} 121 - onerror={() => trackImageError = true} 122 - /> 123 - </div> 114 + <SensitiveImage src={track.image_url}> 115 + <div class="track-image"> 116 + <img 117 + src={track.image_url} 118 + alt="{track.title} artwork" 119 + width="48" 120 + height="48" 121 + loading={imageLoading} 122 + fetchpriority={imageFetchPriority} 123 + onerror={() => trackImageError = true} 124 + /> 125 + </div> 126 + </SensitiveImage> 124 127 {:else if track.artist_avatar_url && !avatarError} 125 - <a 126 - href="/u/{track.artist_handle}" 127 - class="track-avatar" 128 - > 129 - <img 130 - src={track.artist_avatar_url} 131 - alt={track.artist} 132 - width="48" 133 - height="48" 134 - loading={imageLoading} 135 - fetchpriority={imageFetchPriority} 136 - onerror={() => avatarError = true} 137 - /> 138 - </a> 128 + <SensitiveImage src={track.artist_avatar_url}> 129 + <a 130 + href="/u/{track.artist_handle}" 131 + class="track-avatar" 132 + > 133 + <img 134 + src={track.artist_avatar_url} 135 + alt={track.artist} 136 + width="48" 137 + height="48" 138 + loading={imageLoading} 139 + fetchpriority={imageFetchPriority} 140 + onerror={() => avatarError = true} 141 + /> 142 + </a> 143 + </SensitiveImage> 139 144 {:else} 140 145 <div class="track-image-placeholder"> 141 146 <svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
+20 -17
frontend/src/lib/components/player/TrackInfo.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Track } from '$lib/types'; 3 3 import { onMount } from 'svelte'; 4 + import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 4 5 5 6 interface Props { 6 7 track: Track; ··· 54 55 </script> 55 56 56 57 <div class="player-track"> 57 - <a href="/track/{track.id}" class="player-artwork" aria-label={`view ${track.title}`}> 58 - {#if (track.image_url || track.album?.image_url) && !imageError} 59 - <img 60 - src={track.image_url || track.album?.image_url} 61 - alt="{track.title} artwork" 62 - onerror={() => imageError = true} 63 - /> 64 - {:else} 65 - <div class="player-artwork-placeholder"> 66 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="24" height="24"> 67 - <path d="M9 18V5l12-2v13"></path> 68 - <circle cx="6" cy="18" r="3"></circle> 69 - <circle cx="18" cy="16" r="3"></circle> 70 - </svg> 71 - </div> 72 - {/if} 73 - </a> 58 + <SensitiveImage src={track.image_url || track.album?.image_url}> 59 + <a href="/track/{track.id}" class="player-artwork" aria-label={`view ${track.title}`}> 60 + {#if (track.image_url || track.album?.image_url) && !imageError} 61 + <img 62 + src={track.image_url || track.album?.image_url} 63 + alt="{track.title} artwork" 64 + onerror={() => imageError = true} 65 + /> 66 + {:else} 67 + <div class="player-artwork-placeholder"> 68 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" width="24" height="24"> 69 + <path d="M9 18V5l12-2v13"></path> 70 + <circle cx="6" cy="18" r="3"></circle> 71 + <circle cx="18" cy="16" r="3"></circle> 72 + </svg> 73 + </div> 74 + {/if} 75 + </a> 76 + </SensitiveImage> 74 77 <div class="player-info"> 75 78 {#if isOnTrackDetailPage} 76 79 <div class="player-title" class:scrolling={titleOverflows} bind:this={titleEl}>
+60
frontend/src/lib/moderation.svelte.ts
··· 1 + // content moderation state - tracks sensitive images 2 + import { browser } from '$app/environment'; 3 + import { API_URL } from '$lib/config'; 4 + 5 + interface SensitiveImages { 6 + image_ids: Set<string>; 7 + urls: Set<string>; 8 + } 9 + 10 + class ModerationManager { 11 + private data = $state<SensitiveImages>({ image_ids: new Set(), urls: new Set() }); 12 + private initialized = false; 13 + loading = $state(false); 14 + 15 + /** 16 + * check if an image URL is flagged as sensitive. 17 + * checks both the full URL and extracts image_id from R2 URLs. 18 + */ 19 + isSensitive(url: string | null | undefined): boolean { 20 + if (!url) return false; 21 + 22 + // check full URL match 23 + if (this.data.urls.has(url)) return true; 24 + 25 + // extract image_id from R2 URL pattern and check 26 + // R2 URLs look like: https://cdn.plyr.fm/images/{image_id}.webp 27 + const match = url.match(/\/images\/([^/.]+)\./); 28 + if (match && this.data.image_ids.has(match[1])) return true; 29 + 30 + return false; 31 + } 32 + 33 + async initialize(): Promise<void> { 34 + if (!browser || this.initialized || this.loading) return; 35 + this.initialized = true; 36 + await this.fetch(); 37 + } 38 + 39 + async fetch(): Promise<void> { 40 + if (!browser) return; 41 + 42 + this.loading = true; 43 + try { 44 + const response = await fetch(`${API_URL}/moderation/sensitive-images`); 45 + if (response.ok) { 46 + const data = await response.json(); 47 + this.data = { 48 + image_ids: new Set(data.image_ids || []), 49 + urls: new Set(data.urls || []) 50 + }; 51 + } 52 + } catch (error) { 53 + console.error('failed to fetch sensitive images:', error); 54 + } finally { 55 + this.loading = false; 56 + } 57 + } 58 + } 59 + 60 + export const moderation = new ModerationManager();
+9 -2
frontend/src/lib/preferences.svelte.ts
··· 13 13 theme: Theme; 14 14 enable_teal_scrobbling: boolean; 15 15 teal_needs_reauth: boolean; 16 + show_sensitive_artwork: boolean; 16 17 } 17 18 18 19 const DEFAULT_PREFERENCES: Preferences = { ··· 22 23 hidden_tags: ['ai'], 23 24 theme: 'dark', 24 25 enable_teal_scrobbling: false, 25 - teal_needs_reauth: false 26 + teal_needs_reauth: false, 27 + show_sensitive_artwork: false 26 28 }; 27 29 28 30 class PreferencesManager { ··· 62 64 return this.data?.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth; 63 65 } 64 66 67 + get showSensitiveArtwork(): boolean { 68 + return this.data?.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork; 69 + } 70 + 65 71 setTheme(theme: Theme): void { 66 72 if (browser) { 67 73 localStorage.setItem('theme', theme); ··· 107 113 hidden_tags: data.hidden_tags ?? DEFAULT_PREFERENCES.hidden_tags, 108 114 theme: data.theme ?? DEFAULT_PREFERENCES.theme, 109 115 enable_teal_scrobbling: data.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling, 110 - teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth 116 + teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth, 117 + show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork 111 118 }; 112 119 } else { 113 120 this.data = { ...DEFAULT_PREFERENCES };
+3
frontend/src/routes/+layout.svelte
··· 14 14 import { afterNavigate } from '$app/navigation'; 15 15 import { auth } from '$lib/auth.svelte'; 16 16 import { preferences } from '$lib/preferences.svelte'; 17 + import { moderation } from '$lib/moderation.svelte'; 17 18 import { player } from '$lib/player.svelte'; 18 19 import { queue } from '$lib/queue.svelte'; 19 20 import { search } from '$lib/search.svelte'; ··· 41 42 auth.isAuthenticated = data.isAuthenticated; 42 43 auth.loading = false; 43 44 preferences.data = data.preferences; 45 + // fetch explicit images list (public, no auth needed) 46 + moderation.initialize(); 44 47 } 45 48 }); 46 49
+4 -2
frontend/src/routes/+layout.ts
··· 17 17 hidden_tags: ['ai'], 18 18 theme: 'dark', 19 19 enable_teal_scrobbling: false, 20 - teal_needs_reauth: false 20 + teal_needs_reauth: false, 21 + show_sensitive_artwork: false 21 22 }; 22 23 23 24 export async function load({ fetch }: LoadEvent): Promise<LayoutData> { ··· 52 53 hidden_tags: data.hidden_tags ?? ['ai'], 53 54 theme: data.theme ?? 'dark', 54 55 enable_teal_scrobbling: data.enable_teal_scrobbling ?? false, 55 - teal_needs_reauth: data.teal_needs_reauth ?? false 56 + teal_needs_reauth: data.teal_needs_reauth ?? false, 57 + show_sensitive_artwork: data.show_sensitive_artwork ?? false 56 58 }; 57 59 } 58 60 } catch (e) {
+30
frontend/src/routes/portal/+page.svelte
··· 36 36 let allowComments = $derived(preferences.allowComments); 37 37 let enableTealScrobbling = $derived(preferences.enableTealScrobbling); 38 38 let tealNeedsReauth = $derived(preferences.tealNeedsReauth); 39 + let showSensitiveArtwork = $derived(preferences.showSensitiveArtwork); 39 40 let savingProfile = $state(false); 40 41 let profileSuccess = $state(''); 41 42 let profileError = $state(''); ··· 214 215 await preferences.update({ enable_teal_scrobbling: enabled }); 215 216 await preferences.fetch(); // refetch to get updated teal_needs_reauth status 216 217 toast.success(enabled ? 'teal.fm scrobbling enabled' : 'teal.fm scrobbling disabled'); 218 + } catch (_e) { 219 + console.error('failed to save preference:', _e); 220 + toast.error('failed to update preference'); 221 + } 222 + } 223 + 224 + async function saveShowSensitiveArtwork(enabled: boolean) { 225 + try { 226 + await preferences.update({ show_sensitive_artwork: enabled }); 227 + toast.success(enabled ? 'sensitive artwork shown' : 'sensitive artwork hidden'); 217 228 } catch (_e) { 218 229 console.error('failed to save preference:', _e); 219 230 toast.error('failed to update preference'); ··· 1055 1066 /> 1056 1067 <span class="toggle-slider"></span> 1057 1068 <span class="toggle-label">{allowComments ? 'enabled' : 'disabled'}</span> 1069 + </label> 1070 + </div> 1071 + 1072 + <div class="data-control"> 1073 + <div class="control-info"> 1074 + <h3>sensitive artwork</h3> 1075 + <p class="control-description"> 1076 + show artwork that has been flagged as sensitive (nudity, etc.) 1077 + </p> 1078 + </div> 1079 + <label class="toggle-switch"> 1080 + <input 1081 + type="checkbox" 1082 + aria-label="Show sensitive artwork" 1083 + checked={showSensitiveArtwork} 1084 + onchange={(e) => saveShowSensitiveArtwork((e.target as HTMLInputElement).checked)} 1085 + /> 1086 + <span class="toggle-slider"></span> 1087 + <span class="toggle-label">{showSensitiveArtwork ? 'shown' : 'hidden'}</span> 1058 1088 </label> 1059 1089 </div> 1060 1090
+19 -15
frontend/src/routes/track/[id]/+page.svelte
··· 8 8 import LikeButton from '$lib/components/LikeButton.svelte'; 9 9 import ShareButton from '$lib/components/ShareButton.svelte'; 10 10 import TagEffects from '$lib/components/TagEffects.svelte'; 11 + import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 12 + import { moderation } from '$lib/moderation.svelte'; 11 13 import { player } from '$lib/player.svelte'; 12 14 import { queue } from '$lib/queue.svelte'; 13 15 import { auth } from '$lib/auth.svelte'; ··· 318 320 {#if track.album} 319 321 <meta property="music:album" content="{track.album.title}" /> 320 322 {/if} 321 - {#if track.image_url} 323 + {#if track.image_url && !moderation.isSensitive(track.image_url)} 322 324 <meta property="og:image" content="{track.image_url}" /> 323 325 <meta property="og:image:secure_url" content="{track.image_url}" /> 324 326 <meta property="og:image:width" content="1200" /> ··· 337 339 name="twitter:description" 338 340 content="{track.artist}{track.album ? ` โ€ข ${track.album.title}` : ''}" 339 341 /> 340 - {#if track.image_url} 342 + {#if track.image_url && !moderation.isSensitive(track.image_url)} 341 343 <meta name="twitter:image" content="{track.image_url}" /> 342 344 {/if} 343 345 ··· 359 361 <main> 360 362 <div class="track-detail"> 361 363 <!-- cover art --> 362 - <div class="cover-art-container"> 363 - {#if track.image_url} 364 - <img src={track.image_url} alt="{track.title} artwork" class="cover-art" /> 365 - {:else} 366 - <div class="cover-art-placeholder"> 367 - <svg width="120" height="120" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"> 368 - <path d="M9 18V5l12-2v13"></path> 369 - <circle cx="6" cy="18" r="3"></circle> 370 - <circle cx="18" cy="16" r="3"></circle> 371 - </svg> 372 - </div> 373 - {/if} 374 - </div> 364 + <SensitiveImage src={track.image_url} tooltipPosition="center"> 365 + <div class="cover-art-container"> 366 + {#if track.image_url} 367 + <img src={track.image_url} alt="{track.title} artwork" class="cover-art" /> 368 + {:else} 369 + <div class="cover-art-placeholder"> 370 + <svg width="120" height="120" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"> 371 + <path d="M9 18V5l12-2v13"></path> 372 + <circle cx="6" cy="18" r="3"></circle> 373 + <circle cx="18" cy="16" r="3"></circle> 374 + </svg> 375 + </div> 376 + {/if} 377 + </div> 378 + </SensitiveImage> 375 379 376 380 <!-- track info wrapper --> 377 381 <div class="track-info-wrapper">
+7 -3
frontend/src/routes/u/[handle]/+page.svelte
··· 7 7 import TrackItem from '$lib/components/TrackItem.svelte'; 8 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 9 import Header from '$lib/components/Header.svelte'; 10 + import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 + import { moderation } from '$lib/moderation.svelte'; 10 12 import { player } from '$lib/player.svelte'; 11 13 import { queue } from '$lib/queue.svelte'; 12 14 import { auth } from '$lib/auth.svelte'; ··· 167 169 /> 168 170 <meta property="og:site_name" content={APP_NAME} /> 169 171 <meta property="profile:username" content="{data.artist.handle}" /> 170 - {#if data.artist.avatar_url} 172 + {#if data.artist.avatar_url && !moderation.isSensitive(data.artist.avatar_url)} 171 173 <meta property="og:image" content="{data.artist.avatar_url}" /> 172 174 <meta property="og:image:secure_url" content="{data.artist.avatar_url}" /> 173 175 <meta property="og:image:width" content="400" /> ··· 182 184 name="twitter:description" 183 185 content="@{data.artist.handle} on {APP_NAME}" 184 186 /> 185 - {#if data.artist.avatar_url} 187 + {#if data.artist.avatar_url && !moderation.isSensitive(data.artist.avatar_url)} 186 188 <meta name="twitter:image" content="{data.artist.avatar_url}" /> 187 189 {/if} 188 190 {/if} ··· 194 196 <main> 195 197 <section class="artist-header"> 196 198 {#if artist.avatar_url} 197 - <img src={artist.avatar_url} alt={artist.display_name} class="artist-avatar" /> 199 + <SensitiveImage src={artist.avatar_url}> 200 + <img src={artist.avatar_url} alt={artist.display_name} class="artist-avatar" /> 201 + </SensitiveImage> 198 202 {/if} 199 203 <div class="artist-details"> 200 204 <div class="artist-info">