Add source field, UI filter, and import mapping

Introduce a 'source' property across types and API models, include it in convertApiToMedia, and add a Source input to AddMediaView. Add source-based filtering in BrowseView (dropdown + tag icon) and ensure Clear Filters resets source. Update playnite, stashapp, and xbvr importers to set source conditionally using a new SOURCE_CATEGORY_MAPPING constant (added to types) so sources are only applied for appropriate media categories.
This commit is contained in:
Lars Behrends
2026-04-11 00:28:43 +02:00
parent f482807387
commit 53c6f5c555
7 changed files with 61 additions and 6 deletions

View File

@@ -43,6 +43,7 @@ export interface ApiMediaItem {
director: string | null; director: string | null;
writer: string | null; writer: string | null;
releaseDate: string | null; releaseDate: string | null;
source?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
genres?: string[]; genres?: string[];
@@ -53,7 +54,6 @@ export interface ApiMediaItem {
platforms?: string[]; platforms?: string[];
developers?: string[]; developers?: string[];
completionStatus?: string; completionStatus?: string;
source?: string;
playCount?: number; playCount?: number;
lastActivity?: string | null; lastActivity?: string | null;
playtime?: number; playtime?: number;
@@ -87,6 +87,7 @@ export interface CreateMediaInput {
director?: string | null; director?: string | null;
writer?: string | null; writer?: string | null;
releaseDate?: string | null; releaseDate?: string | null;
source?: string | null;
genres?: string[]; genres?: string[];
tags?: string[]; tags?: string[];
studios?: string[]; studios?: string[];
@@ -309,6 +310,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
tags: apiItem.tags || [], tags: apiItem.tags || [],
studios: apiItem.studios, studios: apiItem.studios,
type: mediaType, type: mediaType,
source: apiItem.source || undefined,
status: mediaStatus, status: mediaStatus,
staff: staff.length > 0 ? staff : undefined, staff: staff.length > 0 ? staff : undefined,
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
@@ -316,7 +318,6 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
platforms: apiItem.platforms, platforms: apiItem.platforms,
developers: apiItem.developers, developers: apiItem.developers,
completionStatus: apiItem.completionStatus, completionStatus: apiItem.completionStatus,
source: apiItem.source,
playCount: apiItem.playCount, playCount: apiItem.playCount,
lastActivity: apiItem.lastActivity, lastActivity: apiItem.lastActivity,
playtime: apiItem.playtime playtime: apiItem.playtime

View File

@@ -31,6 +31,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
director: '', director: '',
writer: '', writer: '',
releaseDate: '', releaseDate: '',
source: '' as string,
genres: '' as string, genres: '' as string,
tags: '' as string, tags: '' as string,
studios: '' as string studios: '' as string
@@ -129,6 +130,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
director: newMedia.director || null, director: newMedia.director || null,
writer: newMedia.writer || null, writer: newMedia.writer || null,
releaseDate: newMedia.releaseDate || null, releaseDate: newMedia.releaseDate || null,
source: newMedia.source || null,
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [], genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [], tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [], studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : [],
@@ -163,6 +165,7 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
director: '', director: '',
writer: '', writer: '',
releaseDate: '', releaseDate: '',
source: '',
genres: '', genres: '',
tags: '', tags: '',
studios: '' studios: ''
@@ -439,6 +442,16 @@ export default function AddMediaView({ activeCategory, enabledCategories, onAddC
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]" className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
/> />
</div> </div>
<div className="grid gap-2">
<Label htmlFor="source" className="text-sm font-black text-foreground">Source / Quelle</Label>
<Input
id="source"
value={newMedia.source}
onChange={e => setNewMedia(prev => ({ ...prev, source: e.target.value }))}
placeholder="e.g. username, xbvr, stashapp"
className="bg-muted border-border rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
{/* Cast/Staff Section */} {/* Cast/Staff Section */}
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && ( {(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (

View File

@@ -1,7 +1,7 @@
import { Media, MediaCategory } from '@/types'; import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard'; import MediaCard from './MediaCard';
import MediaListItem from './MediaListItem'; import MediaListItem from './MediaListItem';
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree } from 'lucide-react'; import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { import {
@@ -49,6 +49,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null); const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null); const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
// Extract unique values for filters // Extract unique values for filters
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]); const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
@@ -56,6 +57,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]); const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]); const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]); const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
const filteredMedia = useMemo(() => { const filteredMedia = useMemo(() => {
return mediaList.filter(media => { return mediaList.filter(media => {
@@ -64,9 +66,10 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false; if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false; if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false; if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
if (selectedSource && media.source !== selectedSource) return false;
return true; return true;
}); });
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]); }, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory, selectedSource]);
// Reset to first page when mediaList or filters change // Reset to first page when mediaList or filters change
useEffect(() => { useEffect(() => {
@@ -208,7 +211,25 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</DropdownMenu> </DropdownMenu>
)} )}
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && ( {/* Source Filter */}
{allSources.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-muted-foreground")}>
<Tag size={16} />
{selectedSource || 'Source'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
{allSources.sort().map(source => (
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
@@ -219,6 +240,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
setSelectedPlatform(null); setSelectedPlatform(null);
setSelectedDeveloper(null); setSelectedDeveloper(null);
setSelectedCategory(null); setSelectedCategory(null);
setSelectedSource(null);
}} }}
> >
Clear Filters Clear Filters

View File

@@ -1,5 +1,8 @@
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
export interface PlayniteConfig { export interface PlayniteConfig {
ip: string; ip: string;
apiToken: string; apiToken: string;
@@ -241,7 +244,7 @@ export async function importFromPlaynite(
series: game.series ? [game.series] : [], series: game.series ? [game.series] : [],
ageRatings: game.ageRatings || [], ageRatings: game.ageRatings || [],
regions: game.regions || [], regions: game.regions || [],
source: game.source || null, source: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : null,
gameId: game.id, gameId: game.id,
pluginId: null, pluginId: null,
completionStatus: game.completionStatus || 'Not Played', completionStatus: game.completionStatus || 'Not Played',

View File

@@ -1,5 +1,8 @@
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
export interface StashAPPConfig { export interface StashAPPConfig {
url: string; url: string;
apiKey?: string; apiKey?: string;
@@ -702,6 +705,7 @@ export async function importFromStashAPP(
director: null, director: null,
writer: null, writer: null,
releaseDate: releaseDate, releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['stashapp']?.includes('Adult') ? 'stashapp' : null,
genres: [], genres: [],
tags: [], tags: [],
studios: [], studios: [],

View File

@@ -1,5 +1,8 @@
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
export interface XBVRConfig { export interface XBVRConfig {
url: string; url: string;
apiKey?: string; apiKey?: string;
@@ -308,6 +311,7 @@ export async function importFromXBVR(
director: null, director: null,
writer: null, writer: null,
releaseDate: releaseDate, releaseDate: releaseDate,
source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null,
genres: categories, genres: categories,
tags: categories, tags: categories,
studios: video.paysite?.name ? [video.paysite.name] : [], studios: video.paysite?.name ? [video.paysite.name] : [],

View File

@@ -109,3 +109,11 @@ export interface UserSettings {
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
// Source to Category mapping - ensures sources are only used with appropriate categories
export const SOURCE_CATEGORY_MAPPING: Record<string, MediaCategory[]> = {
'xbvr': ['Adult'],
'stashapp': ['Adult'],
'playnite': ['Games'],
'manual': ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Adult', 'Consoles', 'Games'],
};