Compare commits

...

9 Commits

9 changed files with 290 additions and 69 deletions

View File

@@ -2,10 +2,11 @@ from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.models import Library, MediaItem from app.models import Library, MediaItem
from app.schemas import LibraryCreate, LibraryOut, BrowseResult, BrowseEntry from app.schemas import LibraryCreate, LibraryOut, MediaItemOut, BrowseResult, BrowseEntry
from app.services import scanner, watcher as watcher_service from app.services import scanner, watcher as watcher_service
router = APIRouter(prefix="/libraries", tags=["libraries"]) router = APIRouter(prefix="/libraries", tags=["libraries"])
@@ -63,6 +64,28 @@ async def rescan_library(
return {"scanning": True} return {"scanning": True}
@router.get("/{library_id}/doom-scroll", response_model=list[MediaItemOut])
async def doom_scroll(
library_id: int,
path: str = "",
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Library).where(Library.id == library_id))
if not result.scalars().first():
raise HTTPException(404, "Library not found")
stmt = (
select(MediaItem)
.options(selectinload(MediaItem.tags))
.where(MediaItem.library_id == library_id, MediaItem.missing == False) # noqa: E712
)
if path:
stmt = stmt.where(MediaItem.rel_path.like(path + "/%"))
result = await db.execute(stmt)
return result.scalars().all()
@router.delete("/{library_id}", status_code=204) @router.delete("/{library_id}", status_code=204)
async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)): async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Library).where(Library.id == library_id)) result = await db.execute(select(Library).where(Library.id == library_id))

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.models import MediaItem from app.models import MediaItem
from app.database import SessionLocal from app.database import SessionLocal
from app.services.thumbnails import thumbnail_path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -86,6 +87,11 @@ async def _do_scan(library_id: int, library_path: str, db: AsyncSession) -> None
if rel in db_items: if rel in db_items:
item = db_items[rel] item = db_items[rel]
new_hash = await loop.run_in_executor(None, hash_file, file_path)
if item.file_hash != new_hash:
item.file_hash = new_hash
item.updated_at = datetime.utcnow()
thumbnail_path(item.id).unlink(missing_ok=True)
if item.missing: if item.missing:
item.missing = False item.missing = False
item.updated_at = datetime.utcnow() item.updated_at = datetime.utcnow()
@@ -93,6 +99,7 @@ async def _do_scan(library_id: int, library_path: str, db: AsyncSession) -> None
file_hash = await loop.run_in_executor(None, hash_file, file_path) file_hash = await loop.run_in_executor(None, hash_file, file_path)
moved = await _find_by_hash(library_id, file_hash, db) moved = await _find_by_hash(library_id, file_hash, db)
if moved: if moved:
thumbnail_path(moved.id).unlink(missing_ok=True)
moved.rel_path = rel moved.rel_path = rel
moved.filename = file_path.name moved.filename = file_path.name
moved.missing = False moved.missing = False
@@ -131,4 +138,5 @@ async def _find_by_hash(library_id: int, file_hash: str, db: AsyncSession) -> Me
MediaItem.missing == True, # noqa: E712 MediaItem.missing == True, # noqa: E712
) )
) )
return result.scalars().first() rows = result.scalars().all()
return rows[0] if len(rows) == 1 else None

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select
from app.database import SessionLocal from app.database import SessionLocal
from app.models import Library, MediaItem from app.models import Library, MediaItem
from app.services.scanner import classify, hash_file from app.services.scanner import classify, hash_file
from app.services.thumbnails import thumbnail_path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -59,6 +60,7 @@ class LibraryEventHandler(FileSystemEventHandler):
) )
item = result.scalars().first() item = result.scalars().first()
if item: if item:
thumbnail_path(item.id).unlink(missing_ok=True)
item.rel_path = dest_rel item.rel_path = dest_rel
item.filename = Path(event.dest_path).name item.filename = Path(event.dest_path).name
item.missing = False item.missing = False

View File

@@ -4,13 +4,11 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./data:/data - ./data:/data
- ${MEDIA_ROOT:-/media}:/media:ro - /data/smb/adult/Images:/media/Images
- /data/smb/adult/Video Clips:/media/Video Clips
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////data/medialore.db - DATABASE_URL=sqlite+aiosqlite:////data/medialore.db
- THUMBNAIL_DIR=/data/thumbnails - THUMBNAIL_DIR=/data/thumbnails
env_file:
- .env
frontend: frontend:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped

View File

@@ -71,6 +71,8 @@ export const api = {
request<{ scanning: boolean }>(`/libraries/${id}/scan-status`), request<{ scanning: boolean }>(`/libraries/${id}/scan-status`),
rescan: (id: number) => rescan: (id: number) =>
request<{ scanning: boolean }>(`/libraries/${id}/rescan`, { method: "POST" }), request<{ scanning: boolean }>(`/libraries/${id}/rescan`, { method: "POST" }),
doomScroll: (id: number, path = "") =>
request<MediaItem[]>(`/libraries/${id}/doom-scroll?path=${encodeURIComponent(path)}`),
}, },
media: { media: {

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from "react";
import { api, type MediaItem } from "../../api/client";
interface Props {
items: MediaItem[];
onClose: () => void;
onViewInLibrary: (item: MediaItem) => void;
}
export default function DoomScrollViewer({ items, onClose, onViewInLibrary }: Props) {
const [index, setIndex] = useState(0);
const [fading, setFading] = useState(false);
const wheelLock = useRef(false);
const item = items[index];
function go(delta: 1 | -1) {
if (wheelLock.current) return;
const next = index + delta;
if (next < 0 || next >= items.length) return;
wheelLock.current = true;
setFading(true);
setTimeout(() => {
setIndex(next);
setFading(false);
wheelLock.current = false;
}, 200);
}
useEffect(() => {
const onWheel = (e: WheelEvent) => { e.deltaY > 0 ? go(1) : go(-1); };
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === " ") { e.preventDefault(); go(1); }
if (e.key === "ArrowUp") { e.preventDefault(); go(-1); }
if (e.key === "Escape") onClose();
};
window.addEventListener("wheel", onWheel, { passive: true });
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("keydown", onKey);
};
}, [index, fading]);
return (
<>
{/* Backdrop */}
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.95)", zIndex: 200 }} />
{/* Media area */}
<div
style={{
position: "fixed", inset: 0, zIndex: 201,
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: 12, paddingBottom: 56,
transition: "opacity 0.2s ease, transform 0.2s ease",
opacity: fading ? 0 : 1,
transform: fading ? "translateY(-12px)" : "translateY(0)",
}}
>
<div style={{ color: "#ccc", fontSize: 13 }}>{item?.filename}</div>
{item?.media_type === "image" && (
<img
key={item.id}
src={api.media.fileUrl(item.id)}
alt={item.filename}
style={{ maxWidth: "90vw", maxHeight: "82vh", objectFit: "contain" }}
/>
)}
{item?.media_type === "video" && (
<video
key={item.id}
src={api.media.fileUrl(item.id)}
controls
autoPlay
style={{ maxWidth: "90vw", maxHeight: "82vh" }}
/>
)}
</div>
{/* Bottom bar */}
<div
style={{
position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 202,
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "12px 20px", background: "rgba(0,0,0,0.6)",
}}
>
<button
onClick={onClose}
style={{ background: "none", border: "none", color: "#fff", fontSize: 15, cursor: "pointer" }}
>
Close
</button>
<span style={{ color: "#888", fontSize: 13 }}>
{index + 1} / {items.length}
</span>
<button
onClick={() => onViewInLibrary(item)}
style={{ background: "none", border: "none", color: "var(--accent, #60a5fa)", fontSize: 15, cursor: "pointer" }}
>
View in Library
</button>
</div>
</>
);
}

View File

@@ -1,15 +1,21 @@
import { useState } from "react"; import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type BrowseResult } from "../../api/client"; import { api, type BrowseResult, type MediaItem } from "../../api/client";
import MediaViewer from "../MediaViewer/MediaViewer"; import MediaViewer from "../MediaViewer/MediaViewer";
import DoomScrollViewer from "../DoomScrollViewer/DoomScrollViewer";
interface Props { interface Props {
libraryId: number; libraryId: number;
} }
export default function FileBrowser({ libraryId }: Props) { export default function FileBrowser({ libraryId }: Props) {
const [libraryPaths, setLibraryPaths] = useState<Record<number, string>>({}); const [searchParams] = useSearchParams();
const [libraryPaths, setLibraryPaths] = useState<Record<number, string>>({
[libraryId]: searchParams.get("path") ?? "",
});
const [viewingId, setViewingId] = useState<number | null>(null); const [viewingId, setViewingId] = useState<number | null>(null);
const [doomScrollItems, setDoomScrollItems] = useState<MediaItem[] | null>(null);
const currentPath = libraryPaths[libraryId] ?? ""; const currentPath = libraryPaths[libraryId] ?? "";
@@ -25,6 +31,17 @@ export default function FileBrowser({ libraryId }: Props) {
setViewingId(null); setViewingId(null);
} }
async function startDoomScroll() {
const items = await api.libraries.doomScroll(libraryId, currentPath);
setDoomScrollItems([...items].sort(() => Math.random() - 0.5));
}
function handleViewInLibrary(item: MediaItem) {
const dirPath = item.rel_path.split("/").slice(0, -1).join("/");
navigate(dirPath);
setDoomScrollItems(null);
}
return ( return (
<div style={{ padding: "1rem" }}> <div style={{ padding: "1rem" }}>
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -43,6 +60,9 @@ export default function FileBrowser({ libraryId }: Props) {
</span> </span>
); );
})} })}
<button onClick={startDoomScroll} style={{ marginLeft: "auto", background: "var(--accent)", color: "#fff", border: "none", borderRadius: 4, padding: "4px 10px", cursor: "pointer", fontSize: 13 }}>
Doom Scroll
</button>
</nav> </nav>
{isLoading && <p style={{ color: "var(--text-secondary)" }}>Loading</p>} {isLoading && <p style={{ color: "var(--text-secondary)" }}>Loading</p>}
@@ -94,6 +114,14 @@ export default function FileBrowser({ libraryId }: Props) {
onNavigate={setViewingId} onNavigate={setViewingId}
/> />
)} )}
{doomScrollItems && (
<DoomScrollViewer
items={doomScrollItems}
onClose={() => setDoomScrollItems(null)}
onViewInLibrary={handleViewInLibrary}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type BrowseEntry, type MediaItem } from "../../api/client"; import { api, type BrowseEntry, type MediaItem } from "../../api/client";
import TagPanel from "../TagPanel/TagPanel"; import TagPanel from "../TagPanel/TagPanel";
@@ -11,6 +11,8 @@ interface Props {
} }
export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }: Props) { export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }: Props) {
const [showTags, setShowTags] = useState(true);
const { data: item } = useQuery<MediaItem>({ const { data: item } = useQuery<MediaItem>({
queryKey: ["media", mediaId], queryKey: ["media", mediaId],
queryFn: () => api.media.get(mediaId), queryFn: () => api.media.get(mediaId),
@@ -32,31 +34,36 @@ export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }:
}, [prevId, nextId, onClose, onNavigate]); }, [prevId, nextId, onClose, onNavigate]);
return ( return (
<>
{/* Backdrop */}
<div <div
onClick={onClose} onClick={onClose}
style={{ style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)", zIndex: 100 }}
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)", />
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
display: "flex", gap: 16, background: "#1a1a1a", borderRadius: 8,
padding: 16, maxWidth: "95vw", maxHeight: "95vh", overflow: "auto",
}}
>
{/* Prev */} {/* Prev */}
<button <button
onClick={() => prevId && onNavigate(prevId)} onClick={() => prevId && onNavigate(prevId)}
disabled={!prevId} disabled={!prevId}
style={{ alignSelf: "center", fontSize: 24, background: "none", border: "none", color: prevId ? "#fff" : "#444", cursor: prevId ? "pointer" : "default" }} style={{ position: "fixed", left: 16, top: "50%", transform: "translateY(-50%)", zIndex: 102, fontSize: 36, background: "none", border: "none", color: prevId ? "#fff" : "#444", cursor: prevId ? "pointer" : "default" }}
> >
</button> </button>
{/* Media */} {/* Next */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}> <button
onClick={() => nextId && onNavigate(nextId)}
disabled={!nextId}
style={{ position: "fixed", right: showTags ? 276 : 16, top: "50%", transform: "translateY(-50%)", zIndex: 102, fontSize: 36, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
>
</button>
{/* Media card */}
<div
onClick={(e) => e.stopPropagation()}
style={{ position: "fixed", top: "50%", left: "50%", transform: "translate(-50%, -50%)", zIndex: 101, background: "#1a1a1a", borderRadius: 8, padding: 16, display: "flex", flexDirection: "column", alignItems: "center", gap: 12, maxWidth: "80vw", maxHeight: "90vh", overflow: "auto" }}
>
{item?.filename && ( {item?.filename && (
<div style={{ color: "#ccc", fontSize: 13 }}>{item.filename}</div> <div style={{ color: "#ccc", fontSize: 13 }}>{item.filename}</div>
)} )}
@@ -64,42 +71,43 @@ export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }:
<img <img
src={api.media.fileUrl(mediaId)} src={api.media.fileUrl(mediaId)}
alt={item.filename} alt={item.filename}
style={{ maxWidth: "70vw", maxHeight: "80vh", objectFit: "contain" }} style={{ maxWidth: "70vw", maxHeight: "82vh", objectFit: "contain" }}
/> />
)} )}
{item?.media_type === "video" && ( {item?.media_type === "video" && (
<video <video
src={api.media.fileUrl(mediaId)} src={api.media.fileUrl(mediaId)}
controls controls
style={{ maxWidth: "70vw", maxHeight: "80vh" }} style={{ maxWidth: "70vw", maxHeight: "82vh" }}
/> />
)} )}
</div> </div>
{/* Next */} {/* Top-right controls */}
<div style={{ position: "fixed", top: 16, right: 16, zIndex: 103, display: "flex", gap: 8 }}>
<button <button
onClick={() => nextId && onNavigate(nextId)} onClick={() => setShowTags((v) => !v)}
disabled={!nextId} style={{ background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }}
style={{ alignSelf: "center", fontSize: 24, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
> >
</button> </button>
{/* Tag panel */}
{item && (
<div style={{ color: "#fff", borderLeft: "1px solid #333", paddingLeft: 16, minWidth: 200 }}>
<TagPanel item={item} />
</div>
)}
{/* Close */}
<button <button
onClick={onClose} onClick={onClose}
style={{ position: "absolute", top: 16, right: 16, background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }} style={{ background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }}
> >
</button> </button>
</div> </div>
{/* Tag panel */}
{showTags && item && (
<div
onClick={(e) => e.stopPropagation()}
style={{ position: "fixed", top: 0, right: 0, height: "100%", width: 260, background: "#1a1a1a", borderLeft: "1px solid #333", padding: "48px 16px 16px", zIndex: 101, overflowY: "auto" }}
>
<TagPanel item={item} />
</div> </div>
)}
</>
); );
} }

View File

@@ -1,11 +1,22 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type MediaItem, type TagsByCategory } from "../api/client"; import { api, type MediaItem, type TagsByCategory, type BrowseEntry } from "../api/client";
import MediaViewer from "../components/MediaViewer/MediaViewer";
import DoomScrollViewer from "../components/DoomScrollViewer/DoomScrollViewer";
export default function SearchPage() { export default function SearchPage() {
const routerNavigate = useNavigate();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [selectedTags, setSelectedTags] = useState<number[]>([]); const [selectedTags, setSelectedTags] = useState<number[]>([]);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [viewingId, setViewingId] = useState<number | null>(null);
const [doomScrollItems, setDoomScrollItems] = useState<MediaItem[] | null>(null);
function handleViewInLibrary(item: MediaItem) {
const dirPath = item.rel_path.split("/").slice(0, -1).join("/");
routerNavigate(`/library/${item.library_id}?path=${encodeURIComponent(dirPath)}`);
}
const { data: grouped = [] } = useQuery<TagsByCategory[]>({ const { data: grouped = [] } = useQuery<TagsByCategory[]>({
queryKey: ["tags"], queryKey: ["tags"],
@@ -76,9 +87,18 @@ export default function SearchPage() {
{isFetching && <p style={{ color: "var(--text-secondary)" }}>Searching</p>} {isFetching && <p style={{ color: "var(--text-secondary)" }}>Searching</p>}
{results.length > 0 && (
<button
onClick={() => setDoomScrollItems([...results].sort(() => Math.random() - 0.5))}
style={{ background: "var(--accent)", color: "#fff", border: "none", borderRadius: 4, padding: "4px 10px", cursor: "pointer", fontSize: 13, marginBottom: 16 }}
>
Doom Scroll
</button>
)}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{results.map((item) => ( {results.map((item) => (
<div key={item.id} style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)" }}> <div key={item.id} onClick={() => setViewingId(item.id)} style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)", cursor: "pointer" }}>
<img <img
src={api.media.thumbnailUrl(item.id)} src={api.media.thumbnailUrl(item.id)}
alt={item.filename} alt={item.filename}
@@ -104,6 +124,31 @@ export default function SearchPage() {
{submitted && !isFetching && results.length === 0 && ( {submitted && !isFetching && results.length === 0 && (
<p style={{ color: "var(--text-secondary)" }}>No results found.</p> <p style={{ color: "var(--text-secondary)" }}>No results found.</p>
)} )}
{doomScrollItems && (
<DoomScrollViewer
items={doomScrollItems}
onClose={() => setDoomScrollItems(null)}
onViewInLibrary={handleViewInLibrary}
/>
)}
{viewingId !== null && (() => {
const siblings: BrowseEntry[] = results.map((item) => ({
name: item.filename,
type: item.media_type,
rel_path: item.rel_path,
media_item_id: item.id,
}));
return (
<MediaViewer
mediaId={viewingId}
siblings={siblings}
onClose={() => setViewingId(null)}
onNavigate={(id) => setViewingId(id)}
/>
);
})()}
</div> </div>
); );
} }