diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py
index 8d4c7e2c..877fa6b0 100644
--- a/apps/media_library/api_views.py
+++ b/apps/media_library/api_views.py
@@ -1,4 +1,6 @@
import logging
+import os
+from pathlib import Path
from django.conf import settings
from django.core.signing import TimestampSigner
@@ -34,6 +36,74 @@ class LibraryViewSet(viewsets.ModelViewSet):
ordering_fields = ["name", "created_at", "updated_at", "last_scan_at"]
ordering = ["name"]
+ @action(detail=False, methods=["get"], url_path="browse")
+ def browse(self, request):
+ raw_path = request.query_params.get("path")
+
+ if not raw_path:
+ if os.name == "nt":
+ import string
+
+ entries = []
+ for letter in string.ascii_uppercase:
+ drive = Path(f"{letter}:/")
+ if drive.exists():
+ entries.append(
+ {
+ "name": f"{letter}:",
+ "path": str(drive.resolve()),
+ }
+ )
+ return Response({"path": "", "parent": None, "entries": entries})
+
+ root = Path("/").resolve()
+ entries = []
+ try:
+ for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
+ if child.is_dir():
+ entries.append(
+ {
+ "name": child.name or str(child),
+ "path": str(child),
+ }
+ )
+ except PermissionError:
+ entries = []
+
+ return Response({"path": str(root), "parent": None, "entries": entries})
+
+ try:
+ target = Path(raw_path).expanduser()
+ if not target.exists():
+ raise ValidationError({"detail": "Directory not found."})
+ if not target.is_dir():
+ target = target.parent
+ target = target.resolve()
+ except (ValueError, OSError, RuntimeError):
+ raise ValidationError({"detail": "Invalid path."})
+
+ entries = []
+ try:
+ for child in sorted(target.iterdir(), key=lambda p: p.name.lower()):
+ if child.is_dir():
+ entries.append(
+ {
+ "name": child.name or str(child),
+ "path": str(child),
+ }
+ )
+ except PermissionError:
+ entries = []
+
+ parent = str(target.parent) if target != target.parent else None
+ return Response(
+ {
+ "path": str(target),
+ "parent": parent,
+ "entries": entries,
+ }
+ )
+
def perform_create(self, serializer):
library = serializer.save()
if library.auto_scan_enabled:
diff --git a/apps/media_library/tasks.py b/apps/media_library/tasks.py
index fb508d0c..d241f651 100644
--- a/apps/media_library/tasks.py
+++ b/apps/media_library/tasks.py
@@ -207,6 +207,9 @@ def scan_library_task(
matched = 0
unmatched = 0
media_item_ids: Set[int] = set()
+ last_progress_emit = timezone.now()
+ progress_step = 5
+ progress_interval = 0.4 # seconds
_send_scan_event(
{
"status": "started",
@@ -244,6 +247,7 @@ def scan_library_task(
scanner.mark_missing_files()
total_files = len(discoveries)
+ progress_step = max(1, total_files // 200)
for result in discoveries:
identify_result = _identify_media_file(
@@ -257,25 +261,32 @@ def scan_library_task(
parent_media_id = identify_result.get("parent_media_item_id")
candidate_ids = {
candidate_id
- for candidate_id in (media_id, parent_media_id)
- if candidate_id
- }
- for candidate_id in candidate_ids:
- is_new_media = candidate_id not in media_item_ids
- media_item_ids.add(candidate_id)
- if is_new_media:
- try:
- media_obj = MediaItem.objects.select_related("library").get(pk=candidate_id)
- except MediaItem.DoesNotExist:
- continue
- else:
- _send_media_item_update(media_obj, status="progress")
+ for candidate_id in (media_id, parent_media_id)
+ if candidate_id
+ }
+ for candidate_id in candidate_ids:
+ is_new_media = candidate_id not in media_item_ids
+ media_item_ids.add(candidate_id)
+ if is_new_media:
+ try:
+ media_obj = MediaItem.objects.select_related("library").get(pk=candidate_id)
+ except MediaItem.DoesNotExist:
+ continue
+ else:
+ _send_media_item_update(media_obj, status="progress")
- if result.requires_probe:
- probe_media_task.delay(result.file_id)
+ if result.requires_probe:
+ probe_media_task.delay(result.file_id)
- processed += 1
- scan.record_progress(processed=processed, matched=matched, unmatched=unmatched)
+ processed += 1
+ scan.record_progress(processed=processed, matched=matched, unmatched=unmatched)
+ now = timezone.now()
+ if (
+ processed == total_files
+ or processed % progress_step == 0
+ or (now - last_progress_emit).total_seconds() >= progress_interval
+ ):
+ last_progress_emit = now
_send_scan_event(
{
"status": "progress",
@@ -289,6 +300,12 @@ def scan_library_task(
}
)
+ summary = (
+ f"Processed {scan.total_files} files; "
+ f"new={scan.new_files}, updated={scan.updated_files}, "
+ f"removed={scan.removed_files}, matched={matched}, "
+ f"unmatched={unmatched}"
+ )
if media_item_ids:
metadata_qs = MediaItem.objects.filter(pk__in=media_item_ids).filter(
Q(metadata_last_synced_at__isnull=True)
@@ -298,12 +315,6 @@ def scan_library_task(
for item in metadata_qs:
sync_metadata_task.delay(item.id)
- summary = (
- f"Processed {scan.total_files} files; "
- f"new={scan.new_files}, updated={scan.updated_files}, "
- f"removed={scan.removed_files}, matched={matched}, "
- f"unmatched={unmatched}"
- )
scanner.finalize(matched=matched, unmatched=unmatched, summary=summary)
logger.info("Completed scan for library %s", library.name)
_send_scan_event(
@@ -417,9 +428,6 @@ def _identify_media_file(
else:
unmatched = 1
- if not file_record.checksum:
- compute_checksum_task.delay(file_record.id)
-
return {
"file_id": file_id,
"media_item_id": media_item.id if media_item else None,
@@ -446,30 +454,15 @@ def _probe_media_file(*, file_id: int) -> None:
probe_data = probe_media_file(file_record.absolute_path)
apply_probe_metadata(file_record, probe_data)
-
-@shared_task(name="media_library.probe_media")
-def probe_media_task(file_id: int):
- _probe_media_file(file_id=file_id)
-
-
-def _compute_checksum(file_id: int) -> None:
- try:
- file_record = MediaFile.objects.get(pk=file_id)
- except MediaFile.DoesNotExist:
- return
-
checksum = file_record.calculate_checksum()
- if not checksum:
- return
-
- if file_record.checksum != checksum:
+ if checksum and checksum != file_record.checksum:
file_record.checksum = checksum
file_record.save(update_fields=["checksum", "updated_at"])
-@shared_task(name="media_library.compute_checksum")
-def compute_checksum_task(file_id: int):
- _compute_checksum(file_id)
+@shared_task(name="media_library.probe_media")
+def probe_media_task(file_id: int):
+ _probe_media_file(file_id=file_id)
def _sync_metadata(media_item_id: int) -> None:
diff --git a/apps/media_library/utils.py b/apps/media_library/utils.py
index f21fe062..9f4bc32a 100644
--- a/apps/media_library/utils.py
+++ b/apps/media_library/utils.py
@@ -52,6 +52,32 @@ def _json_safe(value):
return str(value)
+def _first_numeric(value):
+ """Extract the first integer-like value from guessit responses."""
+ if value is None:
+ return None
+ if isinstance(value, (list, tuple, set)):
+ for item in value:
+ normalized = _first_numeric(item)
+ if normalized is not None:
+ return normalized
+ return None
+ if isinstance(value, dict):
+ # Some guessit versions wrap values (e.g. {"season": 1})
+ for key in ("season", "episode", "number"):
+ if key in value:
+ normalized = _first_numeric(value[key])
+ if normalized is not None:
+ return normalized
+ return None
+ if isinstance(value, (int, float)):
+ return int(value)
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+
@dataclass
class DiscoveredFile:
file_id: int
@@ -290,9 +316,9 @@ def classify_media_file(file_name: str) -> ClassificationResult:
classification = ClassificationResult(
detected_type=detected_type,
title=title,
- year=data.get("year"),
- season=data.get("season"),
- episode=data.get("episode"),
+ year=_first_numeric(data.get("year")),
+ season=_first_numeric(data.get("season")),
+ episode=_first_numeric(data.get("episode")),
episode_title=data.get("episode_title"),
data=data,
)
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 40aa7800..ccb1cf27 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -236,11 +236,16 @@ export const WebsocketProvider = ({ children }) => {
if (parsedEvent.data.status === 'completed') {
const { library_id: libraryId } = parsedEvent.data;
- if (
- libraryId &&
- useMediaLibraryStore.getState().selectedLibraryId === libraryId
- ) {
- useMediaLibraryStore.getState().fetchItems(libraryId);
+ const mediaStore = useMediaLibraryStore.getState();
+ const activeLibraryIds = mediaStore.activeLibraryIds || [];
+ const shouldRefresh =
+ activeLibraryIds.length === 0 ||
+ (libraryId != null &&
+ activeLibraryIds.includes(Number(libraryId)));
+ if (shouldRefresh) {
+ mediaStore.fetchItems(
+ activeLibraryIds.length > 0 ? activeLibraryIds : undefined
+ );
}
notifications.show({
title: 'Library scan complete',
diff --git a/frontend/src/api.js b/frontend/src/api.js
index b7a2b8b2..ad169e48 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -2432,6 +2432,23 @@ export default class API {
}
}
+ static async browseLibraryPath(path = '') {
+ try {
+ const params = new URLSearchParams();
+ if (path) {
+ params.append('path', path);
+ }
+ const query = params.toString();
+ const response = await request(
+ `${host}/api/media/libraries/browse/${query ? `?${query}` : ''}`
+ );
+ return response;
+ } catch (e) {
+ errorNotification('Failed to browse server directories', e);
+ throw e;
+ }
+ }
+
static async triggerLibraryScan(id, { full = false } = {}) {
try {
const response = await request(
diff --git a/frontend/src/components/library/LibraryFormModal.jsx b/frontend/src/components/library/LibraryFormModal.jsx
index c1e95898..5852f9b9 100644
--- a/frontend/src/components/library/LibraryFormModal.jsx
+++ b/frontend/src/components/library/LibraryFormModal.jsx
@@ -1,8 +1,9 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
import {
ActionIcon,
Button,
Checkbox,
+ Loader,
Group,
Modal,
NumberInput,
@@ -12,15 +13,15 @@ import {
Text,
TextInput,
Textarea,
+ ScrollArea,
} from '@mantine/core';
import { useForm } from '@mantine/form';
-import { Plus, Trash2 } from 'lucide-react';
+import { ArrowUp, FolderOpen, Plus, Trash2 } from 'lucide-react';
+import API from '../../api';
const LIBRARY_TYPES = [
{ value: 'movies', label: 'Movies' },
{ value: 'shows', label: 'TV Shows' },
- { value: 'mixed', label: 'Mixed' },
- { value: 'other', label: 'Other' },
];
const defaultLocation = () => ({
@@ -37,7 +38,7 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
initialValues: {
name: '',
description: '',
- library_type: 'mixed',
+ library_type: 'movies',
metadata_language: 'en',
metadata_country: 'US',
scan_interval_minutes: 1440,
@@ -47,12 +48,25 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
},
});
+ const [browser, setBrowser] = useState({
+ open: false,
+ index: null,
+ path: '',
+ parent: null,
+ entries: [],
+ loading: false,
+ error: null,
+ });
+
useEffect(() => {
if (library) {
form.setValues({
name: library.name || '',
description: library.description || '',
- library_type: library.library_type || 'mixed',
+ library_type:
+ LIBRARY_TYPES.some((option) => option.value === library.library_type)
+ ? library.library_type
+ : 'movies',
metadata_language: library.metadata_language || 'en',
metadata_country: library.metadata_country || 'US',
scan_interval_minutes: library.scan_interval_minutes || 1440,
@@ -75,6 +89,12 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
}
}, [library, opened]);
+ useEffect(() => {
+ if (!opened) {
+ closeBrowser();
+ }
+ }, [opened]);
+
const addLocation = () => {
form.insertListItem('locations', defaultLocation());
};
@@ -88,6 +108,68 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
form.removeListItem('locations', index);
};
+ const loadDirectory = async (targetPath) => {
+ const normalizedPath = targetPath ?? '';
+ setBrowser((prev) => ({ ...prev, loading: true, error: null }));
+ try {
+ const response = await API.browseLibraryPath(normalizedPath);
+ setBrowser((prev) => ({
+ ...prev,
+ path: response.path ?? normalizedPath,
+ parent: response.parent || null,
+ entries: Array.isArray(response.entries) ? response.entries : [],
+ loading: false,
+ }));
+ } catch (error) {
+ console.error('Failed to browse directories', error);
+ setBrowser((prev) => ({
+ ...prev,
+ loading: false,
+ error: 'Unable to load directories. Check permissions and try again.',
+ }));
+ }
+ };
+
+ const openDirectoryBrowser = (index) => {
+ const current = form.values.locations?.[index]?.path || '';
+ setBrowser({
+ open: true,
+ index,
+ path: current,
+ parent: null,
+ entries: [],
+ loading: true,
+ error: null,
+ });
+ void loadDirectory(current);
+ };
+
+ const closeBrowser = () => {
+ setBrowser({
+ open: false,
+ index: null,
+ path: '',
+ parent: null,
+ entries: [],
+ loading: false,
+ error: null,
+ });
+ };
+
+ const handleSelectDirectory = (path) => {
+ void loadDirectory(path ?? '');
+ };
+
+ const handleUseDirectory = () => {
+ if (browser.index == null) {
+ closeBrowser();
+ return;
+ }
+ const resolvedPath = browser.path || '';
+ form.setFieldValue(`locations.${browser.index}.path`, resolvedPath);
+ closeBrowser();
+ };
+
const submit = (values) => {
const payload = {
...values,
@@ -100,15 +182,16 @@ const LibraryFormModal = ({ opened, onClose, library, onSubmit, submitting }) =>
};
return (
-