diff --git a/app/src/api.js b/app/src/api.js
index 183474ce..b8fc7255 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -267,6 +267,7 @@ export async function deleteVideos(videoIds) {
description: message,
time: 5000,
});
+ throw Error(message);
}
}
@@ -1033,6 +1034,22 @@ export async function getBatchReorganizationStatus(batchJobId) {
}
}
+export async function fetchCollectionConflicts(collectionId) {
+ const response = await apiGet(`${COLLECTIONS_API}/${collectionId}/reorganize/conflicts`);
+ if (response.ok) {
+ return await response.json();
+ } else {
+ const message = await getErrorMessage(response, 'Unable to fetch conflicts. See server logs.');
+ toast({
+ type: 'error',
+ title: 'Conflict Fetch Error',
+ description: message,
+ time: 5000,
+ });
+ throw new Error(message);
+ }
+}
+
export async function getArchive(archiveId) {
const response = await apiGet(`${ARCHIVES_API}/${archiveId}`);
if (response.ok) {
diff --git a/app/src/components/collections/BatchReorganizeModal.js b/app/src/components/collections/BatchReorganizeModal.js
index c6455736..a7d52863 100644
--- a/app/src/components/collections/BatchReorganizeModal.js
+++ b/app/src/components/collections/BatchReorganizeModal.js
@@ -1,5 +1,6 @@
import React, {useCallback, useEffect, useState} from 'react';
-import {Grid, Icon, Label, Progress} from 'semantic-ui-react';
+import {Link} from 'react-router';
+import {Grid, Icon, Label, Progress, Button as SButton} from 'semantic-ui-react';
import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
import {Button, Modal, Table} from '../Theme';
import {APIButton} from '../Common';
@@ -180,7 +181,6 @@ export function BatchReorganizeModal({
completed_collections: 0,
current_collection: null,
overall_percent: 0,
- completed: [],
failed_collection: null,
error: null,
});
@@ -230,7 +230,7 @@ export function BatchReorganizeModal({
{collection.collection_name}
- {collection.files_needing_move} / {collection.total_files}
+ {collection.total_files}
{collection.sample_move ? (
@@ -313,21 +313,6 @@ export function BatchReorganizeModal({
)}
- {status.completed && status.completed.length > 0 && (
-
-
- Completed:
-
- {status.completed.map((c, idx) => (
-
- {c.name}
-
- ))}
-
-
-
- )}
-
{status.status === 'complete' && (
@@ -347,7 +332,19 @@ export function BatchReorganizeModal({
Failed on {kindLabel.toLowerCase()}: {status.failed_collection.name}
- {status.error && Error: {status.error}
}
+
+ View {kindLabel}
+
+ {status.error && (
+ Error: {status.error}
+ )}
diff --git a/app/src/components/collections/BatchReorganizeModal.test.js b/app/src/components/collections/BatchReorganizeModal.test.js
index 53904828..df344796 100644
--- a/app/src/components/collections/BatchReorganizeModal.test.js
+++ b/app/src/components/collections/BatchReorganizeModal.test.js
@@ -159,7 +159,6 @@ describe('BatchReorganizeModal', () => {
completed_collections: 1,
current_collection: null,
overall_percent: 100,
- completed: [{id: 1, name: 'Test', status: 'complete'}],
failed_collection: null,
error: null,
});
@@ -199,7 +198,6 @@ describe('BatchReorganizeModal', () => {
completed_collections: 0,
current_collection: {id: 1, name: 'Test', status: 'running', total: 5, completed: 2, percent: 40},
overall_percent: 40,
- completed: [],
failed_collection: null,
error: null,
});
@@ -237,7 +235,6 @@ describe('BatchReorganizeModal', () => {
completed_collections: 0,
current_collection: {id: 1, name: 'Test Channel', status: 'running', total: 5, completed: 2, percent: 40},
overall_percent: 20,
- completed: [],
failed_collection: null,
error: null,
});
@@ -277,7 +274,6 @@ describe('BatchReorganizeModal', () => {
completed_collections: 1,
current_collection: null,
overall_percent: 100,
- completed: [{id: 1, name: 'Test', status: 'complete'}],
failed_collection: null,
error: null,
});
diff --git a/app/src/components/collections/CollectionReorganizeModal.js b/app/src/components/collections/CollectionReorganizeModal.js
index 03d11ccb..628a24e0 100644
--- a/app/src/components/collections/CollectionReorganizeModal.js
+++ b/app/src/components/collections/CollectionReorganizeModal.js
@@ -1,14 +1,16 @@
import React, {useCallback, useEffect, useState} from 'react';
-import {Grid, Icon, Label, Progress} from 'semantic-ui-react';
+import {Button as SButton, Grid, Icon, Label, Progress} from 'semantic-ui-react';
import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
import {Button, Modal, Table} from '../Theme';
import {APIButton} from '../Common';
import {
executeCollectionReorganization,
+ fetchCollectionConflicts,
getReorganizationStatus,
previewCollectionReorganization
} from '../../api';
import {useReorganizationStatus} from '../../contexts/FileWorkerStatusContext';
+import {ConflictResolutionModal} from './ConflictResolutionModal';
/**
* Modal component for previewing and executing collection file reorganization.
@@ -22,6 +24,7 @@ import {useReorganizationStatus} from '../../contexts/FileWorkerStatusContext';
* @param {string} collectionName - Name of the collection for display
* @param {Function} onComplete - Optional callback when reorganization completes
* @param {boolean} needsReorganization - Whether the collection.needs_reorganization flag is true
+ * @param {string} collectionKind - 'channel' or 'domain' for conflict resolution modal
*/
export function CollectionReorganizeModal({
open,
@@ -30,6 +33,7 @@ export function CollectionReorganizeModal({
collectionName,
onComplete,
needsReorganization,
+ collectionKind,
}) {
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState(null);
@@ -37,6 +41,8 @@ export function CollectionReorganizeModal({
const [jobId, setJobId] = useState(null);
const [status, setStatus] = useState(null);
const [polling, setPolling] = useState(false);
+ const [showConflictModal, setShowConflictModal] = useState(false);
+ const [loadingConflicts, setLoadingConflicts] = useState(false);
// Check if this collection is currently being reorganized
const {
@@ -51,21 +57,22 @@ export function CollectionReorganizeModal({
if (open && collectionId) {
// Check if THIS collection is currently being reorganized
const isThisActive = isReorganizing &&
- taskType === 'reorganize' &&
- activeCollectionId === collectionId;
+ taskType === 'reorganize' &&
+ activeCollectionId === collectionId;
if (isThisActive) {
// Resume: show current progress from worker status
+ // Don't set a fake job ID - we'll watch context instead
setLoading(false);
setPreview(null);
- setJobId('active'); // Marker for active job
+ setJobId(null);
setStatus({
status: workerStatus.status,
total: workerStatus.operation_total,
completed: workerStatus.operation_processed,
percent: workerStatus.operation_percent,
});
- setPolling(true);
+ setPolling(false); // Don't poll job endpoint - watch context instead
} else if (!polling && !jobId) {
// Normal: fetch preview (only if not already polling/resuming)
setLoading(true);
@@ -137,6 +144,57 @@ export function CollectionReorganizeModal({
};
}, [jobId, polling, collectionId, onComplete]);
+ // Watch context for status updates when resuming an active reorganization
+ useEffect(() => {
+ const isThisActive = isReorganizing &&
+ taskType === 'reorganize' &&
+ activeCollectionId === collectionId;
+
+ // Only watch when: modal is open, no real job ID, and this collection is active
+ if (!open || jobId || !isThisActive) return;
+
+ // Update status from context (only when values actually change)
+ const newStatus = workerStatus?.status || 'running';
+ const newTotal = workerStatus?.operation_total || 0;
+ const newCompleted = workerStatus?.operation_processed || 0;
+ const newPercent = workerStatus?.operation_percent || 0;
+
+ setStatus(prev => {
+ // Only update if values actually changed to prevent infinite loops
+ if (prev &&
+ prev.status === newStatus &&
+ prev.total === newTotal &&
+ prev.completed === newCompleted &&
+ prev.percent === newPercent) {
+ return prev; // Return same object to prevent re-render
+ }
+ return {
+ status: newStatus,
+ total: newTotal,
+ completed: newCompleted,
+ percent: newPercent,
+ };
+ });
+ }, [open, jobId, isReorganizing, taskType, activeCollectionId, workerStatus, collectionId]);
+
+ // Detect completion when context indicates reorganization stopped for this collection
+ useEffect(() => {
+ // Only relevant when we have status but no job ID (resumed from context)
+ if (!status || jobId) return;
+
+ const isThisActive = isReorganizing &&
+ taskType === 'reorganize' &&
+ activeCollectionId === collectionId;
+
+ if (!isThisActive && status.status !== 'complete') {
+ // Reorganization completed
+ setStatus(prev => ({...prev, status: 'complete', percent: 100}));
+ if (onComplete) {
+ onComplete();
+ }
+ }
+ }, [isReorganizing, taskType, activeCollectionId, collectionId, status, jobId, onComplete]);
+
const handleClose = useCallback(() => {
// Allow closing - user can reopen to resume if needed
setPreview(null);
@@ -144,9 +202,51 @@ export function CollectionReorganizeModal({
setJobId(null);
setStatus(null);
setPolling(false);
+ setShowConflictModal(false);
onClose();
}, [onClose]);
+ // Reload preview (used after conflict resolution)
+ const reloadPreview = useCallback(() => {
+ setLoading(true);
+ setError(null);
+
+ previewCollectionReorganization(collectionId)
+ .then(data => {
+ setPreview(data);
+ setLoading(false);
+ })
+ .catch(err => {
+ setError(err.message);
+ setLoading(false);
+ });
+ }, [collectionId]);
+
+ const handleConflictResolved = useCallback(() => {
+ // Reload preview to get updated conflicts
+ // Note: The conflict modal calls onClose() after onResolved(), which handles closing
+ reloadPreview();
+ }, [reloadPreview]);
+
+ const handleResolveConflicts = useCallback(async () => {
+ setLoadingConflicts(true);
+ setError(null);
+ try {
+ const data = await fetchCollectionConflicts(collectionId);
+ // Update preview with fresh conflicts (including updated quality rankings)
+ setPreview(prev => ({
+ ...prev,
+ conflicts: data.conflicts,
+ has_conflicts: data.conflicts.length > 0,
+ }));
+ setShowConflictModal(true);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoadingConflicts(false);
+ }
+ }, [collectionId]);
+
const handleReorganize = useCallback(async () => {
try {
setError(null);
@@ -180,7 +280,8 @@ export function CollectionReorganizeModal({
Collection Appears Organized
This collection's format matches the current configuration, but
- {' '}{preview.files_needing_move} files don't match the expected layout.
+ {' '}{preview.files_needing_move} files don't match the expected
+ layout.
This can happen if a previous reorganization was interrupted.
@@ -204,6 +305,33 @@ export function CollectionReorganizeModal({
+ {/* Conflict warning */}
+ {preview.has_conflicts && (
+
+
+
+
+ Conflicts Detected
+
+
+ {preview.conflicts.length} destination{' '}
+ {preview.conflicts.length === 1 ? 'path has' : 'paths have'}{' '}
+ multiple files that would collide.
+ You must resolve these conflicts before reorganizing.
+
+
+ Resolve Conflicts
+
+
+
+
+ )}
+
{preview.sample_moves && preview.sample_moves.length > 0 && (
@@ -292,54 +420,67 @@ export function CollectionReorganizeModal({
};
const isInProgress = polling || (status && status.status !== 'complete' && status.status !== 'error');
- const canReorganize = preview && preview.files_needing_move > 0 && !isInProgress && !jobId;
+ const hasConflicts = preview && preview.has_conflicts;
+ const canReorganize = preview && preview.files_needing_move > 0 && !isInProgress && !jobId && !hasConflicts;
return (
-
-
- Reorganize Files: {collectionName}
-
-
- {loading && (
-
-
-
- Loading Preview
- Analyzing files...
-
-
- )}
+ <>
+
+
+ Reorganize Files: {collectionName}
+
+
+ {loading && (
+
+
+
+ Loading Preview
+ Analyzing files...
+
+
+ )}
- {error && (
-
- Error
- {error}
-
- )}
+ {error && (
+
+ Error
+ {error}
+
+ )}
- {!loading && !jobId && renderPreview()}
- {jobId && renderProgress()}
-
-
-
- {status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
-
- {canReorganize && (
-
- Reorganize Files
-
- )}
-
-
+ {!loading && !status && renderPreview()}
+ {status && renderProgress()}
+
+
+
+ {status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
+
+ {canReorganize && (
+
+ Reorganize Files
+
+ )}
+
+
+
+ {/* Conflict Resolution Modal */}
+ setShowConflictModal(false)}
+ conflicts={preview?.conflicts || []}
+ collectionKind={collectionKind}
+ onResolved={handleConflictResolved}
+ />
+ >
);
}
diff --git a/app/src/components/collections/CollectionReorganizeModal.test.js b/app/src/components/collections/CollectionReorganizeModal.test.js
new file mode 100644
index 00000000..3e6e825b
--- /dev/null
+++ b/app/src/components/collections/CollectionReorganizeModal.test.js
@@ -0,0 +1,465 @@
+import React from 'react';
+import {screen, waitFor, fireEvent} from '@testing-library/react';
+import {renderWithProviders} from '../../test-utils';
+import {CollectionReorganizeModal} from './CollectionReorganizeModal';
+import * as api from '../../api';
+
+// Mock the API functions
+jest.mock('../../api', () => ({
+ previewCollectionReorganization: jest.fn(),
+ executeCollectionReorganization: jest.fn(),
+ getReorganizationStatus: jest.fn(),
+}));
+
+// Mock react-semantic-toasts-2
+jest.mock('react-semantic-toasts-2', () => ({
+ toast: jest.fn(),
+}));
+
+// Mock the FileWorkerStatusContext - default mock returns inactive state
+jest.mock('../../contexts/FileWorkerStatusContext', () => ({
+ useReorganizationStatus: () => ({
+ isReorganizing: false,
+ taskType: null,
+ collectionId: null,
+ collectionKind: null,
+ batchStatus: null,
+ workerStatus: null,
+ refresh: jest.fn(),
+ }),
+ useFileWorkerStatus: () => ({
+ status: null,
+ error: null,
+ refresh: jest.fn(),
+ setFastPolling: jest.fn(),
+ }),
+}));
+
+describe('CollectionReorganizeModal', () => {
+ const defaultProps = {
+ open: true,
+ onClose: jest.fn(),
+ collectionId: 1,
+ collectionName: 'Test Channel',
+ onComplete: jest.fn(),
+ needsReorganization: true,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('loads preview when opened', async () => {
+ const mockPreview = {
+ files_needing_move: 45,
+ total_files: 50,
+ current_file_format: '%(title)s.%(ext)s',
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [
+ {old_path: 'video.mp4', new_path: '2024-01-15/video.mp4'},
+ ],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+
+ renderWithProviders( );
+
+ // Should show loading initially
+ expect(screen.getByText(/Loading Preview/i)).toBeInTheDocument();
+
+ // Wait for preview to load
+ await waitFor(() => {
+ expect(screen.getByText(/45/)).toBeInTheDocument();
+ expect(screen.getByText(/50/)).toBeInTheDocument();
+ });
+
+ expect(api.previewCollectionReorganization).toHaveBeenCalledWith(1);
+ });
+
+ it('shows sample moves in preview', async () => {
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ current_file_format: '%(title)s.%(ext)s',
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [
+ {old_path: 'old/video1.mp4', new_path: 'new/video1.mp4'},
+ {old_path: 'old/video2.mp4', new_path: 'new/video2.mp4'},
+ ],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByText('old/video1.mp4')).toBeInTheDocument();
+ expect(screen.getByText('new/video1.mp4')).toBeInTheDocument();
+ });
+ });
+
+ it('shows "No Files Need Reorganization" message when no files need moving', async () => {
+ const mockPreview = {
+ files_needing_move: 0,
+ total_files: 50,
+ current_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/No Files Need Reorganization/i)).toBeInTheDocument();
+ });
+ });
+
+ it('shows warning when collection appears organized but has files to move', async () => {
+ const mockPreview = {
+ files_needing_move: 5,
+ total_files: 50,
+ current_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [{old_path: 'a.mp4', new_path: 'b.mp4'}],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+
+ // needsReorganization is false, but there are files to move
+ renderWithProviders(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Collection Appears Organized/i)).toBeInTheDocument();
+ });
+ });
+
+ it('executes reorganization on button click', async () => {
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+ api.executeCollectionReorganization.mockResolvedValue({
+ job_id: 'reorganize-abc123',
+ message: 'Reorganization started',
+ });
+ api.getReorganizationStatus.mockResolvedValue({
+ status: 'complete',
+ total: 10,
+ completed: 10,
+ percent: 100,
+ });
+
+ renderWithProviders( );
+
+ // Wait for preview to load
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
+ });
+
+ // Click the reorganize button
+ fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
+
+ await waitFor(() => {
+ expect(api.executeCollectionReorganization).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('shows progress bar during execution', async () => {
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+ api.executeCollectionReorganization.mockResolvedValue({
+ job_id: 'reorganize-abc123',
+ message: 'Reorganization started',
+ });
+ api.getReorganizationStatus.mockResolvedValue({
+ status: 'running',
+ total: 10,
+ completed: 5,
+ percent: 50,
+ });
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
+
+ await waitFor(() => {
+ expect(screen.getByText(/5 of 10 files processed/i)).toBeInTheDocument();
+ });
+ });
+
+ it('calls onComplete when reorganization finishes', async () => {
+ const mockOnComplete = jest.fn();
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+ api.executeCollectionReorganization.mockResolvedValue({
+ job_id: 'reorganize-abc123',
+ message: 'Reorganization started',
+ });
+ api.getReorganizationStatus.mockResolvedValue({
+ status: 'complete',
+ total: 10,
+ completed: 10,
+ percent: 100,
+ });
+
+ renderWithProviders(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
+
+ await waitFor(() => {
+ expect(mockOnComplete).toHaveBeenCalled();
+ });
+ });
+
+ it('displays error message on API failure', async () => {
+ api.previewCollectionReorganization.mockRejectedValue(new Error('Network error'));
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Network error/i)).toBeInTheDocument();
+ });
+ });
+
+ it('calls onClose when cancel button is clicked', async () => {
+ const mockOnClose = jest.fn();
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+
+ renderWithProviders(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Cancel/i})).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', {name: /Cancel/i}));
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it('shows completion message when reorganization completes', async () => {
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+ api.executeCollectionReorganization.mockResolvedValue({
+ job_id: 'reorganize-abc123',
+ message: 'Reorganization started',
+ });
+ api.getReorganizationStatus.mockResolvedValue({
+ status: 'complete',
+ total: 10,
+ completed: 10,
+ percent: 100,
+ });
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Reorganization Complete/i)).toBeInTheDocument();
+ });
+ });
+
+ it('handles execution error gracefully', async () => {
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+ api.executeCollectionReorganization.mockRejectedValue(new Error('Permission denied'));
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Permission denied/i)).toBeInTheDocument();
+ });
+ });
+
+ it('does not fetch preview when modal is closed', () => {
+ renderWithProviders( );
+
+ expect(api.previewCollectionReorganization).not.toHaveBeenCalled();
+ });
+
+ it('changes button text based on state', async () => {
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+ api.executeCollectionReorganization.mockResolvedValue({
+ job_id: 'reorganize-abc123',
+ message: 'Reorganization started',
+ });
+ api.getReorganizationStatus.mockResolvedValue({
+ status: 'complete',
+ total: 10,
+ completed: 10,
+ percent: 100,
+ });
+
+ renderWithProviders( );
+
+ // Wait for preview to load and button to appear
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
+ });
+
+ // Should show "Cancel" initially (when not in progress)
+ expect(screen.getByRole('button', {name: /Cancel/i})).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
+
+ // Should show "Close" after completion
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Close/i})).toBeInTheDocument();
+ });
+ });
+
+ it('polls for status with real job ID, not with "active"', async () => {
+ // This test verifies the bug fix: the component should poll using
+ // the real job_id returned from executeCollectionReorganization,
+ // not a hardcoded string like 'active'
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+ api.executeCollectionReorganization.mockResolvedValue({
+ job_id: 'reorganize-real-job-id-123',
+ message: 'Reorganization started',
+ });
+ api.getReorganizationStatus.mockResolvedValue({
+ status: 'running',
+ total: 10,
+ completed: 3,
+ percent: 30,
+ });
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
+
+ // Wait for status polling to start
+ await waitFor(() => {
+ expect(api.getReorganizationStatus).toHaveBeenCalled();
+ });
+
+ // CRITICAL: Verify that getReorganizationStatus was called with the real job ID
+ // NOT with 'active' which was the bug
+ expect(api.getReorganizationStatus).toHaveBeenCalledWith(1, 'reorganize-real-job-id-123');
+ expect(api.getReorganizationStatus).not.toHaveBeenCalledWith(1, 'active');
+ });
+
+ it('handles status polling error and retries', async () => {
+ const mockPreview = {
+ files_needing_move: 10,
+ total_files: 20,
+ new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
+ sample_moves: [],
+ };
+
+ api.previewCollectionReorganization.mockResolvedValue(mockPreview);
+ api.executeCollectionReorganization.mockResolvedValue({
+ job_id: 'reorganize-abc123',
+ message: 'Reorganization started',
+ });
+
+ // First call fails, second call succeeds
+ api.getReorganizationStatus
+ .mockRejectedValueOnce(new Error('Network error'))
+ .mockResolvedValue({
+ status: 'complete',
+ total: 10,
+ completed: 10,
+ percent: 100,
+ });
+
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ renderWithProviders( );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
+
+ // Wait for retry and completion
+ await waitFor(() => {
+ expect(screen.getByText(/Reorganization Complete/i)).toBeInTheDocument();
+ }, {timeout: 3000});
+
+ // Should have retried after initial error
+ expect(api.getReorganizationStatus.mock.calls.length).toBeGreaterThanOrEqual(2);
+
+ consoleSpy.mockRestore();
+ });
+});
diff --git a/app/src/components/collections/ConflictResolutionModal.js b/app/src/components/collections/ConflictResolutionModal.js
new file mode 100644
index 00000000..3c5da129
--- /dev/null
+++ b/app/src/components/collections/ConflictResolutionModal.js
@@ -0,0 +1,279 @@
+import React, {useEffect, useState} from 'react';
+import {Card, Grid, Icon, Image, Label, Segment, Button as SButton} from 'semantic-ui-react';
+import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
+import {Button, Modal} from '../Theme';
+import {deleteArchives, deleteVideos} from '../../api';
+import {humanFileSize, isoDatetimeToString} from '../Common';
+
+/**
+ * Card component for a single conflicting file.
+ *
+ * Manages its own loading and error state for deletion.
+ * Only calls onDelete when deletion succeeds.
+ *
+ * @param {Object} file - File info object from backend containing:
+ * - file_group_id: number - ID of the FileGroup
+ * - current_path: string - Current file path
+ * - title: string - File title
+ * - model_type: 'video'|'archive' - Type of content
+ * - size: number - File size in bytes
+ * - video_id: number|null - Video ID if model_type is 'video'
+ * - archive_id: number|null - Archive ID if model_type is 'archive'
+ * - poster_path: string|null - Path to thumbnail/poster image
+ * - published_datetime: string|null - ISO datetime string
+ * - source_id: string|null - External source identifier
+ * - quality_rank: number|null - Quality score for recommendations
+ * @param {Function} onDelete - Called with (file) when deletion succeeds
+ * @param {boolean} isRecommended - Whether this file is recommended to keep
+ */
+function ConflictFileCard({file, onDelete, isRecommended}) {
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [localError, setLocalError] = useState(null);
+
+ const handleDelete = async () => {
+ setIsDeleting(true);
+ setLocalError(null);
+
+ try {
+ if (file.model_type === 'video' && file.video_id) {
+ await deleteVideos([file.video_id]);
+ } else if (file.model_type === 'archive' && file.archive_id) {
+ await deleteArchives([file.archive_id]);
+ }
+ onDelete(file);
+ } catch (err) {
+ setLocalError(err.message || 'Failed to delete');
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+
+
+ {localError && (
+
+ {localError}
+
+ )}
+
+
+ {/* Poster/Thumbnail column */}
+
+ {file.poster_path ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* File info column */}
+
+
+ {file.title || 'Untitled'}
+ {isRecommended && (
+
+ Recommended to Keep
+
+ )}
+
+
+ {file.current_path}
+
+
+
+ {file.size > 0 && (
+
+ {humanFileSize(file.size)}
+
+ )}
+ {file.published_datetime && (
+
+ {isoDatetimeToString(file.published_datetime)}
+
+ )}
+ {file.source_id && (
+
+ {file.source_id}
+
+ )}
+ {file.quality_rank !== null && file.quality_rank !== undefined && (
+
+ {file.quality_rank}
+
+ )}
+
+ {file.model_type}
+
+
+
+
+
+ {/* Delete button column */}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Modal component for resolving reorganization conflicts.
+ *
+ * Shows files that would have the same destination path and allows users
+ * to delete duplicates before proceeding with reorganization.
+ *
+ * @param {boolean} open - Whether the modal is open
+ * @param {Function} onClose - Callback when modal is closed
+ * @param {Array} conflicts - Array of ConflictDetail objects from the backend
+ * @param {string} collectionKind - 'channel' or 'domain'
+ * @param {Function} onResolved - Callback when a file is deleted (to refresh preview)
+ */
+export function ConflictResolutionModal({
+ open,
+ onClose,
+ conflicts = [],
+ collectionKind,
+ onResolved,
+ }) {
+ const [localConflicts, setLocalConflicts] = useState(conflicts);
+ const [hasChanges, setHasChanges] = useState(false);
+
+ // Sync local conflicts with props when they change (e.g., when modal opens with new data)
+ useEffect(() => {
+ setLocalConflicts(conflicts);
+ setHasChanges(false);
+ }, [conflicts]);
+
+ /**
+ * Handle successful file deletion from a ConflictFileCard.
+ * Only called when deletion succeeds (errors are handled in the card).
+ * @param {Object} file - The file that was deleted
+ */
+ const handleFileDelete = (file) => {
+ // Update local conflicts: remove the deleted file
+ setLocalConflicts(prev => {
+ const updated = prev.map(conflict => ({
+ ...conflict,
+ conflicting_files: conflict.conflicting_files.filter(
+ f => f.file_group_id !== file.file_group_id
+ ),
+ })).filter(conflict => conflict.conflicting_files.length > 1);
+ // Keep conflicts that still have 2+ files
+ return updated;
+ });
+ setHasChanges(true);
+ };
+
+ const handleClose = () => {
+ if (hasChanges && onResolved) {
+ onResolved();
+ }
+ onClose();
+ };
+
+ const totalConflicts = localConflicts.length;
+ const totalFiles = localConflicts.reduce((sum, c) => sum + c.conflicting_files.length, 0);
+
+ return (
+
+
+ Resolve Conflicts Before Reorganizing
+
+
+
+ Destination Path Conflicts Detected
+
+ {totalConflicts} destination {totalConflicts === 1 ? 'path has' : 'paths have'}{' '}
+ multiple files that would be moved there. This typically happens when the same{' '}
+ {collectionKind === 'channel' ? 'video' : 'page'} was downloaded multiple times.
+
+
+ Delete the duplicates you don't want to keep before reorganizing.
+ For each conflict, keep only one file.
+
+
+
+ {localConflicts.map((conflict, idx) => {
+ // Find the highest quality rank in this conflict (for video recommendations)
+ const hasRanks = conflict.conflicting_files.some(
+ f => f.quality_rank !== null && f.quality_rank !== undefined
+ );
+ const highestRank = hasRanks
+ ? Math.max(...conflict.conflicting_files.map(f => f.quality_rank || 0))
+ : null;
+ // Check if there are multiple files with the same highest rank
+ const highestRankCount = hasRanks
+ ? conflict.conflicting_files.filter(f => f.quality_rank === highestRank).length
+ : 0;
+
+ return (
+
+
+ Destination: {conflict.destination_path}
+ {conflict.conflicting_files.length} files
+
+
+
+ {conflict.conflicting_files.map((file, fileIdx) => {
+ // Only show "Recommended" if there's a clear winner (one file with highest rank)
+ const isRecommended = hasRanks &&
+ highestRankCount === 1 &&
+ file.quality_rank === highestRank;
+
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
+ {localConflicts.length === 0 && (
+
+ All Conflicts Resolved
+ You can now proceed with the reorganization.
+
+ )}
+
+
+
+ {localConflicts.length === 0 ? 'Done' : 'Close'}
+
+
+
+ );
+}
diff --git a/app/src/components/collections/ConflictResolutionModal.test.js b/app/src/components/collections/ConflictResolutionModal.test.js
new file mode 100644
index 00000000..4a608077
--- /dev/null
+++ b/app/src/components/collections/ConflictResolutionModal.test.js
@@ -0,0 +1,360 @@
+import React from 'react';
+import {screen, waitFor, fireEvent} from '@testing-library/react';
+import {renderWithProviders} from '../../test-utils';
+import {ConflictResolutionModal} from './ConflictResolutionModal';
+import * as api from '../../api';
+
+// Mock the API functions
+jest.mock('../../api', () => ({
+ deleteVideos: jest.fn(),
+ deleteArchives: jest.fn(),
+}));
+
+// Mock react-semantic-toasts-2
+jest.mock('react-semantic-toasts-2', () => ({
+ toast: jest.fn(),
+}));
+
+describe('ConflictResolutionModal', () => {
+ const mockConflicts = [
+ {
+ destination_path: 'videos/2024-01-15/My Video.mp4',
+ conflicting_files: [
+ {
+ file_group_id: 1,
+ current_path: 'videos/old/My Video.mp4',
+ title: 'My Video',
+ model_type: 'video',
+ size: 1024000,
+ video_id: 101,
+ archive_id: null,
+ poster_path: 'videos/old/My Video.jpg',
+ published_datetime: '2024-01-15T10:00:00Z',
+ source_id: 'abc123',
+ },
+ {
+ file_group_id: 2,
+ current_path: 'videos/new/My Video.mp4',
+ title: 'My Video',
+ model_type: 'video',
+ size: 2048000,
+ video_id: 102,
+ archive_id: null,
+ poster_path: null,
+ published_datetime: '2024-01-15T10:00:00Z',
+ source_id: 'abc123',
+ },
+ ],
+ },
+ ];
+
+ const defaultProps = {
+ open: true,
+ onClose: jest.fn(),
+ conflicts: mockConflicts,
+ collectionKind: 'channel',
+ onResolved: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the modal with conflicts', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText(/Resolve Conflicts Before Reorganizing/i)).toBeInTheDocument();
+ expect(screen.getByText(/Destination Path Conflicts Detected/i)).toBeInTheDocument();
+ expect(screen.getByText(/1 destination path has/i)).toBeInTheDocument();
+ });
+
+ it('shows conflict destination path', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText(/videos\/2024-01-15\/My Video.mp4/)).toBeInTheDocument();
+ });
+
+ it('shows file details for conflicting files', () => {
+ renderWithProviders( );
+
+ // Should show both file paths
+ expect(screen.getByText('videos/old/My Video.mp4')).toBeInTheDocument();
+ expect(screen.getByText('videos/new/My Video.mp4')).toBeInTheDocument();
+
+ // Should show title
+ const titles = screen.getAllByText('My Video');
+ expect(titles.length).toBeGreaterThanOrEqual(2);
+
+ // Should show source_id
+ const sourceIds = screen.getAllByText('abc123');
+ expect(sourceIds.length).toBe(2);
+ });
+
+ it('shows file size in human-readable format', () => {
+ renderWithProviders( );
+
+ // 1024000 bytes = ~1000 kB (uses humanFileSize from Common.js)
+ expect(screen.getByText('1000.0 kB')).toBeInTheDocument();
+ // 2048000 bytes = ~2 MB
+ expect(screen.getByText('2.0 MB')).toBeInTheDocument();
+ });
+
+ it('calls deleteVideos when delete button is clicked for video', async () => {
+ api.deleteVideos.mockResolvedValue({});
+
+ renderWithProviders( );
+
+ // Find and click the first delete button
+ const deleteButtons = screen.getAllByTitle('Delete this file');
+ fireEvent.click(deleteButtons[0]);
+
+ await waitFor(() => {
+ expect(api.deleteVideos).toHaveBeenCalledWith([101]);
+ });
+
+ // File should be removed from UI (conflict resolved since only 1 file remains)
+ await waitFor(() => {
+ expect(screen.getByText(/All Conflicts Resolved/i)).toBeInTheDocument();
+ });
+
+ // onResolved is NOT called until modal is closed
+ expect(defaultProps.onResolved).not.toHaveBeenCalled();
+
+ // Click Close/Done button - now onResolved should be called
+ fireEvent.click(screen.getByRole('button', {name: /Done/i}));
+ expect(defaultProps.onResolved).toHaveBeenCalled();
+ });
+
+ it('calls deleteArchives when delete button is clicked for archive', async () => {
+ const archiveConflicts = [
+ {
+ destination_path: 'archives/example.com/page.html',
+ conflicting_files: [
+ {
+ file_group_id: 10,
+ current_path: 'archives/old/page.html',
+ title: 'Example Page',
+ model_type: 'archive',
+ size: 50000,
+ video_id: null,
+ archive_id: 201,
+ poster_path: null,
+ published_datetime: null,
+ source_id: null,
+ },
+ ],
+ },
+ ];
+
+ api.deleteArchives.mockResolvedValue({});
+
+ renderWithProviders(
+
+ );
+
+ const deleteButton = screen.getByTitle('Delete this file');
+ fireEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(api.deleteArchives).toHaveBeenCalledWith([201]);
+ });
+ });
+
+ it('shows success message when no conflicts remain', () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText(/All Conflicts Resolved/i)).toBeInTheDocument();
+ expect(screen.getByText(/You can now proceed with the reorganization/i)).toBeInTheDocument();
+ });
+
+ it('shows Done button when no conflicts', () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByRole('button', {name: /Done/i})).toBeInTheDocument();
+ });
+
+ it('shows Close button when conflicts exist', () => {
+ renderWithProviders( );
+
+ expect(screen.getByRole('button', {name: /Close/i})).toBeInTheDocument();
+ });
+
+ it('calls onClose when Close button is clicked (without changes)', () => {
+ renderWithProviders( );
+
+ fireEvent.click(screen.getByRole('button', {name: /Close/i}));
+
+ // onClose is called, but onResolved is NOT called since no changes were made
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ expect(defaultProps.onResolved).not.toHaveBeenCalled();
+ });
+
+ it('displays multiple conflicts', () => {
+ const multipleConflicts = [
+ {
+ destination_path: 'videos/path1.mp4',
+ conflicting_files: [
+ {file_group_id: 1, current_path: 'a.mp4', title: 'Video 1', model_type: 'video', video_id: 1},
+ {file_group_id: 2, current_path: 'b.mp4', title: 'Video 1', model_type: 'video', video_id: 2},
+ ],
+ },
+ {
+ destination_path: 'videos/path2.mp4',
+ conflicting_files: [
+ {file_group_id: 3, current_path: 'c.mp4', title: 'Video 2', model_type: 'video', video_id: 3},
+ {file_group_id: 4, current_path: 'd.mp4', title: 'Video 2', model_type: 'video', video_id: 4},
+ ],
+ },
+ ];
+
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText(/2 destination paths have/i)).toBeInTheDocument();
+ expect(screen.getByText(/videos\/path1.mp4/)).toBeInTheDocument();
+ expect(screen.getByText(/videos\/path2.mp4/)).toBeInTheDocument();
+ });
+
+ it('shows video type label for videos', () => {
+ renderWithProviders( );
+
+ const videoLabels = screen.getAllByText('video');
+ expect(videoLabels.length).toBe(2);
+ });
+
+ it('shows archive type label for archives', () => {
+ const archiveConflicts = [
+ {
+ destination_path: 'archives/page.html',
+ conflicting_files: [
+ {file_group_id: 1, current_path: 'old.html', title: 'Page', model_type: 'archive', archive_id: 1},
+ ],
+ },
+ ];
+
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText('archive')).toBeInTheDocument();
+ });
+
+ it('does not render when closed', () => {
+ renderWithProviders(
+
+ );
+
+ expect(screen.queryByText(/Resolve Conflicts Before Reorganizing/i)).not.toBeInTheDocument();
+ });
+
+ it('displays quality rank for videos', () => {
+ const conflictsWithRank = [
+ {
+ destination_path: 'videos/2024-01-15/My Video.mp4',
+ conflicting_files: [
+ {
+ file_group_id: 1,
+ current_path: 'videos/old/My Video.mp4',
+ title: 'My Video',
+ model_type: 'video',
+ size: 1024000,
+ video_id: 101,
+ quality_rank: 12,
+ },
+ {
+ file_group_id: 2,
+ current_path: 'videos/new/My Video.mp4',
+ title: 'My Video',
+ model_type: 'video',
+ size: 2048000,
+ video_id: 102,
+ quality_rank: 5,
+ },
+ ],
+ },
+ ];
+
+ renderWithProviders(
+
+ );
+
+ // Should show quality ranks
+ expect(screen.getByText('12')).toBeInTheDocument();
+ expect(screen.getByText('5')).toBeInTheDocument();
+ });
+
+ it('shows recommended badge on highest ranked video', () => {
+ const conflictsWithRank = [
+ {
+ destination_path: 'videos/2024-01-15/My Video.mp4',
+ conflicting_files: [
+ {
+ file_group_id: 1,
+ current_path: 'videos/old/My Video.mp4',
+ title: 'My Video',
+ model_type: 'video',
+ video_id: 101,
+ quality_rank: 15,
+ },
+ {
+ file_group_id: 2,
+ current_path: 'videos/new/My Video.mp4',
+ title: 'My Video',
+ model_type: 'video',
+ video_id: 102,
+ quality_rank: 8,
+ },
+ ],
+ },
+ ];
+
+ renderWithProviders(
+
+ );
+
+ // Should show "Recommended to Keep" badge (only on the first/highest ranked)
+ expect(screen.getByText(/Recommended to Keep/i)).toBeInTheDocument();
+ });
+
+ it('does not show rank display for archives', () => {
+ const archiveConflicts = [
+ {
+ destination_path: 'archives/page.html',
+ conflicting_files: [
+ {
+ file_group_id: 1,
+ current_path: 'old.html',
+ title: 'Page',
+ model_type: 'archive',
+ archive_id: 1,
+ quality_rank: null,
+ },
+ {
+ file_group_id: 2,
+ current_path: 'new.html',
+ title: 'Page',
+ model_type: 'archive',
+ archive_id: 2,
+ quality_rank: null,
+ },
+ ],
+ },
+ ];
+
+ renderWithProviders(
+
+ );
+
+ // Should NOT show "Recommended to Keep" badge for archives
+ expect(screen.queryByText(/Recommended to Keep/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/wrolpi/collections/api.py b/wrolpi/collections/api.py
index 7c497ca8..b8f27667 100644
--- a/wrolpi/collections/api.py
+++ b/wrolpi/collections/api.py
@@ -275,10 +275,11 @@ async def search_collections_endpoint(request: Request, body: schema.CollectionS
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
async def preview_reorganization_endpoint(request: Request, collection_id: int):
"""
- Get preview with sample of files to be renamed.
+ Get preview with sample of files to be renamed and conflict detection.
- Returns a preview of what would happen if the collection is reorganized.
- This is useful for showing users what files would move before they commit.
+ Returns a preview of what would happen if the collection is reorganized,
+ including any destination conflicts that need to be resolved before
+ reorganization can proceed.
Returns:
- collection_id: The collection ID
@@ -288,13 +289,14 @@ async def preview_reorganization_endpoint(request: Request, collection_id: int):
- sample_moves: ~10 example moves [{old_path, new_path}, ...]
- new_file_format: The format template that will be applied
- current_file_format: The format currently stored on collection
+ - conflicts: List of destination conflicts with file details
+ - has_conflicts: Boolean flag indicating if conflicts exist
"""
from wrolpi.collections.reorganize import get_reorganization_preview
session = request.ctx.session
try:
- # Use exact_count=True for individual collection preview to get accurate count
- preview = get_reorganization_preview(collection_id, session, exact_count=True)
+ preview = get_reorganization_preview(collection_id, session)
return json_response({
'collection_id': preview.collection_id,
'collection_name': preview.collection_name,
@@ -303,6 +305,8 @@ async def preview_reorganization_endpoint(request: Request, collection_id: int):
'sample_moves': preview.sample_moves,
'new_file_format': preview.new_file_format,
'current_file_format': preview.current_file_format,
+ 'conflicts': preview.conflicts or [],
+ 'has_conflicts': preview.has_conflicts,
})
except UnknownCollection as e:
return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)
@@ -310,6 +314,34 @@ async def preview_reorganization_endpoint(request: Request, collection_id: int):
return json_response({'error': str(e)}, status=HTTPStatus.BAD_REQUEST)
+@collection_bp.get('//reorganize/conflicts')
+@openapi.summary('Get conflict details with ffprobe processing')
+@openapi.response(HTTPStatus.OK, description="Conflict details for resolution UI")
+@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
+@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
+async def get_conflicts_endpoint(request: Request, collection_id: int):
+ """
+ Get detailed conflict information for resolution UI.
+
+ Processes ffprobe for any videos missing it to ensure accurate
+ quality rankings (including audio stream bonus).
+
+ Returns:
+ - conflicts: List of conflict details with updated quality rankings
+ """
+ from wrolpi.collections.reorganize import get_conflict_details_with_ffprobe
+
+ session = request.ctx.session
+ try:
+ conflicts = await get_conflict_details_with_ffprobe(collection_id, session)
+ session.commit()
+ return json_response({'conflicts': conflicts})
+ except UnknownCollection as e:
+ return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)
+ except ValueError as e:
+ return json_response({'error': str(e)}, status=HTTPStatus.BAD_REQUEST)
+
+
@collection_bp.post('//reorganize')
@openapi.summary('Execute collection reorganization')
@openapi.response(HTTPStatus.OK, schema.ReorganizationExecuteResponse, description="Reorganization started")
diff --git a/wrolpi/collections/reorganize.py b/wrolpi/collections/reorganize.py
index ec8130ee..8c5b4576 100644
--- a/wrolpi/collections/reorganize.py
+++ b/wrolpi/collections/reorganize.py
@@ -24,14 +24,23 @@ from .models import Collection
logger = logger.getChild(__name__)
__all__ = [
+ 'BatchReorganizationPreview',
'ReorganizationPreview',
- 'get_reorganization_preview',
- 'execute_reorganization',
- 'get_collections_needing_reorganization',
'execute_batch_reorganization',
+ 'execute_reorganization',
+ 'get_batch_reorganization_preview',
'get_batch_reorganization_status',
+ 'get_collections_needing_reorganization',
+ 'get_conflict_details_with_ffprobe',
+ 'get_reorganization_preview',
]
+# Quality ranking weights for conflict resolution
+# High-value metadata indicators (more rare/valuable)
+RANK_HIGH_VALUE = 2
+# Standard metadata indicators (more common)
+RANK_STANDARD = 1
+
@dataclass
class ReorganizationPreview:
@@ -48,6 +57,28 @@ class ReorganizationPreview:
sample_moves: List[dict] # Sample of moves: [{old_path, new_path}, ...]
new_file_format: str # The format that will be applied
current_file_format: Optional[str] # The format currently stored on collection
+ conflicts: List[dict] = None # Destination conflicts with file details
+ has_conflicts: bool = False # Quick flag indicating if conflicts exist
+
+
+@dataclass
+class BatchReorganizationPreview:
+ """Lightweight preview for batch overview - no conflict detection.
+
+ Used by get_collections_needing_reorganization() for fast batch previews.
+ For individual collection modals where conflict details are needed,
+ use ReorganizationPreview via get_reorganization_preview() instead.
+
+ Includes one sample_move to show users what the reorganization will do,
+ but skips expensive full-mapping computation and conflict detection.
+ """
+ collection_id: int
+ collection_name: str
+ total_files: int
+ files_needing_move: int
+ new_file_format: str
+ current_file_format: Optional[str] = None
+ sample_move: Optional[dict] = None # Single sample: {old_path, new_path}
def _get_format_required_variables(file_format: str) -> set:
@@ -186,28 +217,25 @@ def get_reorganization_preview(
collection_id: int,
session: Session = None,
sample_size: int = 10,
- exact_count: bool = False,
) -> ReorganizationPreview:
- """Get preview with sample of files to be renamed.
+ """Get preview with sample of files to be renamed and detect conflicts.
- Does NOT compute all moves by default - only samples for preview display.
+ Builds full move mappings to detect destination conflicts.
Args:
collection_id: The collection to preview reorganization for
session: Database session (optional, will create one if not provided)
sample_size: Number of sample moves to include in preview
- exact_count: If True, compute the actual count of files needing moves
- (more expensive but accurate for individual collection preview)
Returns:
- ReorganizationPreview with counts and sample moves
+ ReorganizationPreview with counts, sample moves, and conflict details
Raises:
UnknownCollection: If collection not found
"""
if session is None:
with get_db_session() as session:
- return get_reorganization_preview(collection_id, session, sample_size, exact_count)
+ return get_reorganization_preview(collection_id, session, sample_size)
collection = session.query(Collection).filter_by(id=collection_id).one_or_none()
if not collection:
@@ -216,10 +244,6 @@ def get_reorganization_preview(
if not collection.directory:
raise ValueError(f"Collection '{collection.name}' has no directory. Cannot reorganize.")
- collection_directory = pathlib.Path(collection.directory)
- if not collection_directory.is_absolute():
- collection_directory = get_media_directory() / collection_directory
-
# Get the current file format from config
current_config_format = collection._get_current_file_format()
@@ -227,6 +251,8 @@ def get_reorganization_preview(
files_needing_move = 0
total_files = 0
+ move_mappings = []
+
if collection.kind == 'channel':
from modules.videos.models import Video, Channel
@@ -241,49 +267,33 @@ def get_reorganization_preview(
sample_moves=[],
new_file_format=current_config_format or '',
current_file_format=collection.file_format,
+ conflicts=[],
+ has_conflicts=False,
)
# Use COUNT for total_files (fast)
total_files = session.query(func.count(Video.id)).filter_by(channel_id=channel.id).scalar()
- if exact_count:
- # Compute actual count by building full move mappings
- move_mappings = _build_move_mappings_for_channel(collection, session)
- files_needing_move = len(move_mappings)
- else:
- # Assume all files need moving (computing actual count is expensive)
- files_needing_move = total_files
+ # Always build full move mappings (needed for conflict detection)
+ move_mappings = _build_move_mappings_for_channel(collection, session)
+ files_needing_move = len(move_mappings)
- # Only load limited videos for sample search
- videos = session.query(Video).options(
- joinedload(Video.file_group)
- ).filter_by(channel_id=channel.id).limit(10).all()
+ # Build sample moves from the first N mappings
+ media_dir = get_media_directory()
+ for source_path, dest_path in move_mappings[:sample_size]:
+ try:
+ old_relative = str(source_path.relative_to(media_dir))
+ except ValueError:
+ old_relative = str(source_path)
+ try:
+ new_relative = str(dest_path.relative_to(media_dir))
+ except ValueError:
+ new_relative = str(dest_path)
- for video in videos:
- if not video.file_group or not video.file_group.primary_path:
- continue
-
- current_path = pathlib.Path(video.file_group.primary_path)
- new_path = _compute_new_path_for_video(video, collection_directory)
-
- if new_path and current_path != new_path:
- # Get relative paths for display
- media_dir = get_media_directory()
- try:
- old_relative = str(current_path.relative_to(media_dir))
- except ValueError:
- old_relative = str(current_path)
- try:
- new_relative = str(new_path.relative_to(media_dir))
- except ValueError:
- new_relative = str(new_path)
-
- sample_moves.append({
- 'old_path': old_relative,
- 'new_path': new_relative,
- })
- if len(sample_moves) >= sample_size:
- break
+ sample_moves.append({
+ 'old_path': old_relative,
+ 'new_path': new_relative,
+ })
elif collection.kind == 'domain':
from modules.archive.models import Archive
@@ -291,44 +301,29 @@ def get_reorganization_preview(
# Use COUNT for total_files (fast)
total_files = session.query(func.count(Archive.id)).filter_by(collection_id=collection_id).scalar()
- if exact_count:
- # Compute actual count by building full move mappings
- move_mappings = _build_move_mappings_for_domain(collection, session)
- files_needing_move = len(move_mappings)
- else:
- # Assume all files need moving (computing actual count is expensive)
- files_needing_move = total_files
+ # Always build full move mappings (needed for conflict detection)
+ move_mappings = _build_move_mappings_for_domain(collection, session)
+ files_needing_move = len(move_mappings)
- # Only load limited archives for sample search
- archives = session.query(Archive).options(
- joinedload(Archive.file_group)
- ).filter_by(collection_id=collection_id).limit(10).all()
+ # Build sample moves from the first N mappings
+ media_dir = get_media_directory()
+ for source_path, dest_path in move_mappings[:sample_size]:
+ try:
+ old_relative = str(source_path.relative_to(media_dir))
+ except ValueError:
+ old_relative = str(source_path)
+ try:
+ new_relative = str(dest_path.relative_to(media_dir))
+ except ValueError:
+ new_relative = str(dest_path)
- for archive in archives:
- if not archive.file_group or not archive.file_group.primary_path:
- continue
+ sample_moves.append({
+ 'old_path': old_relative,
+ 'new_path': new_relative,
+ })
- current_path = pathlib.Path(archive.file_group.primary_path)
- new_path = _compute_new_path_for_archive(archive, collection_directory)
-
- if new_path and current_path != new_path:
- # Get relative paths for display
- media_dir = get_media_directory()
- try:
- old_relative = str(current_path.relative_to(media_dir))
- except ValueError:
- old_relative = str(current_path)
- try:
- new_relative = str(new_path.relative_to(media_dir))
- except ValueError:
- new_relative = str(new_path)
-
- sample_moves.append({
- 'old_path': old_relative,
- 'new_path': new_relative,
- })
- if len(sample_moves) >= sample_size:
- break
+ # Detect conflicts
+ conflicts = _detect_conflicts_with_details(move_mappings, collection, session)
return ReorganizationPreview(
collection_id=collection_id,
@@ -338,6 +333,116 @@ def get_reorganization_preview(
sample_moves=sample_moves,
new_file_format=current_config_format or '',
current_file_format=collection.file_format,
+ conflicts=conflicts,
+ has_conflicts=len(conflicts) > 0,
+ )
+
+
+def get_batch_reorganization_preview(
+ collection_id: int,
+ session: Session,
+) -> BatchReorganizationPreview:
+ """Get a lightweight preview for batch context - skips expensive conflict detection.
+
+ Use this for the batch overview page where we need fast counts for many collections.
+ For individual collection modals where conflict details are needed,
+ use get_reorganization_preview() instead.
+
+ Computes ONE sample move to show users what will happen, but avoids computing
+ moves for ALL files (which is the expensive part).
+
+ Args:
+ collection_id: The collection to preview
+ session: Database session
+
+ Returns:
+ BatchReorganizationPreview with counts and one sample move (no conflicts)
+
+ Raises:
+ UnknownCollection: If collection not found
+ """
+ from sqlalchemy.orm import joinedload
+
+ collection = session.query(Collection).filter_by(id=collection_id).one_or_none()
+ if not collection:
+ raise UnknownCollection(f"Collection with ID {collection_id} not found")
+
+ # Get the current file format from config
+ current_config_format = collection._get_current_file_format()
+
+ collection_directory = pathlib.Path(collection.directory)
+ if not collection_directory.is_absolute():
+ collection_directory = get_media_directory() / collection_directory
+
+ total_files = 0
+ sample_move = None
+ media_dir = get_media_directory()
+
+ if collection.kind == 'channel':
+ from modules.videos.models import Video, Channel
+
+ # Get the channel for this collection
+ channel = session.query(Channel).filter_by(collection_id=collection_id).one_or_none()
+ if channel:
+ # Fast COUNT query - no joins, no loading of related objects
+ total_files = session.query(func.count(Video.id)).filter_by(channel_id=channel.id).scalar() or 0
+
+ # Get ONE video for sample move (cheap - just one query with limit 1)
+ sample_video = session.query(Video).options(
+ joinedload(Video.file_group)
+ ).filter_by(channel_id=channel.id).first()
+
+ if sample_video and sample_video.file_group and sample_video.file_group.primary_path:
+ current_path = pathlib.Path(sample_video.file_group.primary_path)
+ new_path = _compute_new_path_for_video(sample_video, collection_directory)
+ if new_path and current_path != new_path:
+ try:
+ old_relative = str(current_path.relative_to(media_dir))
+ except ValueError:
+ old_relative = str(current_path)
+ try:
+ new_relative = str(new_path.relative_to(media_dir))
+ except ValueError:
+ new_relative = str(new_path)
+ sample_move = {'old_path': old_relative, 'new_path': new_relative}
+
+ elif collection.kind == 'domain':
+ from modules.archive.models import Archive
+
+ # Fast COUNT query
+ total_files = session.query(func.count(Archive.id)).filter_by(collection_id=collection_id).scalar() or 0
+
+ # Get ONE archive for sample move (cheap - just one query with limit 1)
+ sample_archive = session.query(Archive).options(
+ joinedload(Archive.file_group)
+ ).filter_by(collection_id=collection_id).first()
+
+ if sample_archive and sample_archive.file_group and sample_archive.file_group.primary_path:
+ current_path = pathlib.Path(sample_archive.file_group.primary_path)
+ new_path = _compute_new_path_for_archive(sample_archive, collection_directory)
+ if new_path and current_path != new_path:
+ try:
+ old_relative = str(current_path.relative_to(media_dir))
+ except ValueError:
+ old_relative = str(current_path)
+ try:
+ new_relative = str(new_path.relative_to(media_dir))
+ except ValueError:
+ new_relative = str(new_path)
+ sample_move = {'old_path': old_relative, 'new_path': new_relative}
+
+ # For batch preview, assume all files need moving if collection needs reorganization
+ # This avoids expensive _build_move_mappings calls
+ files_needing_move = total_files if collection.needs_reorganization else 0
+
+ return BatchReorganizationPreview(
+ collection_id=collection_id,
+ collection_name=collection.name,
+ total_files=total_files,
+ files_needing_move=files_needing_move,
+ new_file_format=current_config_format or '',
+ current_file_format=collection.file_format,
+ sample_move=sample_move,
)
@@ -403,6 +508,163 @@ def _build_move_mappings_for_domain(
return move_mappings
+def _get_largest_sizes_by_source_id(videos: list) -> dict:
+ """Get the largest file size for each source_id among a set of videos.
+
+ Videos that are larger are probably better quality, so larger videos get a rank bonus.
+ """
+ sizes = {}
+ for video in videos:
+ source_id = video.source_id
+ if not source_id:
+ continue
+ old_size = sizes.get(source_id, 0)
+ file_size = video.file_group.size if video.file_group else 0
+ sizes[source_id] = max(old_size, file_size)
+ return sizes
+
+
+def _rank_video_quality(video, sizes_by_source_id: dict) -> int:
+ """Returns a higher integer the more data and metadata the Video record is.
+
+ A video with info_json is more valuable than one without, etc.
+ Adapted from scripts/delete_similarly_named_videos.py.
+
+ Uses RANK_HIGH_VALUE for rare/valuable metadata and RANK_STANDARD for common metadata.
+ """
+ from modules.videos.lib import parse_video_file_name
+
+ rank = 0
+ file_group = video.file_group
+
+ # High-value metadata (rare/valuable indicators)
+ if file_group and file_group.url:
+ rank += RANK_HIGH_VALUE
+ if video.source_id:
+ rank += RANK_HIGH_VALUE
+ if video.info_json_path and video.info_json_path.is_file():
+ try:
+ if video.info_json_path.stat().st_size > 0:
+ rank += RANK_HIGH_VALUE
+ except (OSError, IOError):
+ pass
+ # Check if video file matches channel info_json
+ if video.source_id and video.channel:
+ try:
+ if entry := video.get_channel_entry():
+ if entry.get('title') and entry['title'] in str(video.video_path):
+ rank += RANK_HIGH_VALUE
+ except Exception:
+ pass
+ if video.get_comments():
+ rank += RANK_HIGH_VALUE
+
+ # Standard metadata (more common indicators)
+ if video.channel_id:
+ rank += RANK_STANDARD
+ if video.caption_paths:
+ rank += RANK_STANDARD
+ if video.poster_path and video.poster_path.is_file():
+ try:
+ if video.poster_path.stat().st_size > 0:
+ rank += RANK_STANDARD
+ except (OSError, IOError):
+ pass
+ if video.ffprobe_json:
+ rank += RANK_STANDARD
+
+ # Largest file for source_id gets a bonus
+ if video.source_id:
+ largest_size = sizes_by_source_id.get(video.source_id)
+ if largest_size and file_group and file_group.size == largest_size:
+ rank += RANK_STANDARD
+
+ # Title from JSON matches filename
+ if video.video_path:
+ try:
+ _, _, _, video_title = parse_video_file_name(video.video_path)
+ video_title = video_title.strip() if video_title else ''
+ if file_group and file_group.title and file_group.title == video_title:
+ rank += RANK_STANDARD
+ except Exception:
+ pass
+
+ # Channel name in video path
+ if video.channel_id and video.channel:
+ try:
+ channel_name = None
+ if video.channel.info_json:
+ channel_name = video.channel.info_json.get('channel')
+ if channel_name and channel_name in str(video.video_path):
+ rank += RANK_HIGH_VALUE
+ except Exception:
+ pass
+
+ # Precise published datetime (not midnight) indicates better metadata
+ if file_group and file_group.published_datetime:
+ dt = file_group.published_datetime
+ # Check if time is not midnight (has actual time precision)
+ if dt.hour != 0 or dt.minute != 0 or dt.second != 0:
+ rank += RANK_STANDARD
+
+ # Bonus for videos with audio
+ if video.ffprobe_json:
+ if video.get_streams_by_codec_type('audio'):
+ rank += RANK_HIGH_VALUE
+
+ return rank
+
+
+def _rank_archive_quality(archive) -> int:
+ """Returns a higher integer the more quality the Archive record is.
+
+ Archives with more complete metadata (readability files, timestamps,
+ extracted content) receive higher ranks.
+
+ Uses RANK_HIGH_VALUE for rare/valuable metadata and RANK_STANDARD for common metadata.
+ """
+ rank = 0
+ file_group = archive.file_group
+
+ # High-value metadata (rare/valuable indicators)
+ if archive.readability_json_path and archive.readability_json_path.is_file():
+ rank += RANK_HIGH_VALUE
+ if file_group and file_group.url:
+ rank += RANK_HIGH_VALUE
+
+ # Standard metadata (more common indicators)
+ if archive.readability_path and archive.readability_path.is_file():
+ rank += RANK_STANDARD
+ if archive.readability_txt_path and archive.readability_txt_path.is_file():
+ rank += RANK_STANDARD
+ if archive.screenshot_path and archive.screenshot_path.is_file():
+ rank += RANK_STANDARD
+ if file_group:
+ if file_group.title:
+ rank += RANK_STANDARD
+ if file_group.author:
+ rank += RANK_STANDARD
+ if file_group.published_datetime:
+ rank += RANK_STANDARD
+ if file_group.download_datetime:
+ rank += RANK_STANDARD
+ if file_group.published_modified_datetime:
+ rank += RANK_STANDARD
+ if file_group.b_text: # description
+ rank += RANK_STANDARD
+ if file_group.d_text: # extracted article text
+ rank += RANK_STANDARD
+ if file_group.length and file_group.length > 0:
+ rank += RANK_STANDARD
+ # Precise published datetime (not midnight)
+ if file_group.published_datetime:
+ dt = file_group.published_datetime
+ if dt.hour != 0 or dt.minute != 0 or dt.second != 0:
+ rank += RANK_STANDARD
+
+ return rank
+
+
def _check_for_destination_conflicts(
move_mappings: List[Tuple[pathlib.Path, pathlib.Path]],
collection_name: str,
@@ -435,6 +697,176 @@ def _check_for_destination_conflicts(
)
+def _detect_conflicts_with_details(
+ move_mappings: List[Tuple[pathlib.Path, pathlib.Path]],
+ collection: Collection,
+ session: Session,
+) -> List[dict]:
+ """Detect destination conflicts and return detailed info for UI display.
+
+ Returns a list of conflict details, where each conflict contains:
+ - destination_path: The path where multiple files would end up
+ - conflicting_files: List of file info dicts with metadata for comparison
+
+ For video conflicts, includes quality_rank based on metadata completeness
+ and sorts files by rank (highest first).
+
+ Args:
+ move_mappings: List of (source_path, dest_path) tuples
+ collection: The collection being reorganized
+ session: Database session
+
+ Returns:
+ List of conflict detail dicts (empty if no conflicts)
+ """
+ from wrolpi.files.models import FileGroup
+
+ # Build destination -> source paths mapping
+ dest_to_sources = {}
+ for source, dest in move_mappings:
+ if dest in dest_to_sources:
+ dest_to_sources[dest].append(source)
+ else:
+ dest_to_sources[dest] = [source]
+
+ # Find conflicts (destinations with multiple sources)
+ conflicts = {dest: sources for dest, sources in dest_to_sources.items() if len(sources) > 1}
+
+ if not conflicts:
+ return []
+
+ media_dir = get_media_directory()
+ conflict_details = []
+
+ for dest_path, source_paths in conflicts.items():
+ # Get relative destination path for display
+ try:
+ dest_relative = str(dest_path.relative_to(media_dir))
+ except ValueError:
+ dest_relative = str(dest_path)
+
+ conflicting_files = []
+ # Collect videos for ranking (only for channel collections)
+ videos_for_ranking = []
+ # Collect archives for ranking (only for domain collections)
+ archives_for_ranking = []
+
+ for source_path in source_paths:
+ # Look up the FileGroup by its primary_path
+ try:
+ source_relative = str(source_path.relative_to(media_dir))
+ except ValueError:
+ source_relative = str(source_path)
+
+ file_group = session.query(FileGroup).filter(
+ FileGroup.primary_path == str(source_path)
+ ).one_or_none()
+
+ if not file_group:
+ # FileGroup not found, add minimal info
+ conflicting_files.append({
+ 'file_group_id': None,
+ 'current_path': source_relative,
+ 'title': source_path.stem,
+ 'model_type': collection.kind,
+ 'size': 0,
+ 'video_id': None,
+ 'archive_id': None,
+ 'poster_path': None,
+ 'published_datetime': None,
+ 'source_id': None,
+ 'quality_rank': None,
+ })
+ continue
+
+ # Get model-specific metadata
+ file_info = {
+ 'file_group_id': file_group.id,
+ 'current_path': source_relative,
+ 'title': file_group.title or source_path.stem,
+ 'model_type': file_group.model or collection.kind,
+ 'size': file_group.size or 0,
+ 'video_id': None,
+ 'archive_id': None,
+ 'poster_path': None,
+ 'published_datetime': None,
+ 'source_id': None,
+ 'quality_rank': None,
+ }
+
+ # Add model-specific details
+ if collection.kind == 'channel':
+ from modules.videos.models import Video
+ video = session.query(Video).filter_by(file_group_id=file_group.id).one_or_none()
+ if video:
+ file_info['video_id'] = video.id
+ file_info['source_id'] = video.source_id
+ # Date is stored on file_group
+ if file_group.published_datetime:
+ file_info['published_datetime'] = file_group.published_datetime.isoformat()
+ # Get poster path
+ if video.poster_path:
+ try:
+ poster_relative = str(pathlib.Path(video.poster_path).relative_to(media_dir))
+ except ValueError:
+ poster_relative = video.poster_path
+ file_info['poster_path'] = poster_relative
+ # Collect video for ranking calculation
+ videos_for_ranking.append((file_info, video))
+
+ elif collection.kind == 'domain':
+ from modules.archive.models import Archive
+ archive = session.query(Archive).filter_by(file_group_id=file_group.id).one_or_none()
+ if archive:
+ file_info['archive_id'] = archive.id
+ # Archives don't have source_id, use file_group.url
+ if file_group.url:
+ file_info['source_id'] = file_group.url
+ if file_group.download_datetime:
+ file_info['published_datetime'] = file_group.download_datetime.isoformat()
+ # Get screenshot path for archives
+ if archive.screenshot_path:
+ try:
+ screenshot_relative = str(pathlib.Path(archive.screenshot_path).relative_to(media_dir))
+ except ValueError:
+ screenshot_relative = archive.screenshot_path
+ file_info['poster_path'] = screenshot_relative
+ # Collect archive for ranking calculation
+ archives_for_ranking.append((file_info, archive))
+
+ conflicting_files.append(file_info)
+
+ # Calculate quality rankings for videos
+ if collection.kind == 'channel' and videos_for_ranking:
+ # Get largest sizes by source_id for ranking bonus
+ videos_only = [v for _, v in videos_for_ranking]
+ sizes_by_source_id = _get_largest_sizes_by_source_id(videos_only)
+
+ # Calculate rank for each video
+ for file_info, video in videos_for_ranking:
+ file_info['quality_rank'] = _rank_video_quality(video, sizes_by_source_id)
+
+ # Calculate quality rankings for archives
+ elif collection.kind == 'domain' and archives_for_ranking:
+ # Calculate rank for each archive
+ for file_info, archive in archives_for_ranking:
+ file_info['quality_rank'] = _rank_archive_quality(archive)
+
+ # Sort by quality_rank (highest first), then by size (works for both videos and archives)
+ if videos_for_ranking or archives_for_ranking:
+ conflicting_files.sort(
+ key=lambda f: (f.get('quality_rank') or 0, f.get('size') or 0),
+ reverse=True
+ )
+
+ conflict_details.append({
+ 'destination_path': dest_relative,
+ 'conflicting_files': conflicting_files,
+ })
+
+ return conflict_details
+
+
def execute_reorganization(
collection_id: int,
session: Session = None,
@@ -572,14 +1004,15 @@ def get_reorganization_status(job_id: str) -> dict:
def get_collections_needing_reorganization(
kind: str,
session: Session = None,
- sample_size: int = 1,
) -> dict:
- """Get all collections of a kind that need reorganization with sample previews.
+ """Get all collections of a kind that need reorganization with lightweight previews.
+
+ Uses get_batch_reorganization_preview() for fast counts without expensive
+ conflict detection or sample move generation.
Args:
kind: 'channel' or 'domain'
session: Database session (optional)
- sample_size: Number of sample moves per collection (default 1)
Returns:
Dict with:
@@ -590,7 +1023,7 @@ def get_collections_needing_reorganization(
"""
if session is None:
with get_db_session() as session:
- return get_collections_needing_reorganization(kind, session, sample_size)
+ return get_collections_needing_reorganization(kind, session)
# Query all collections of this kind with directories
collections = session.query(Collection).filter(
@@ -614,16 +1047,15 @@ def get_collections_needing_reorganization(
total_files_needing_move = 0
for collection in collections_needing_reorg:
- # Get preview for each collection
+ # Use lightweight batch preview (skips expensive conflict detection)
try:
- preview = get_reorganization_preview(collection.id, session, sample_size=sample_size)
- sample_move = preview.sample_moves[0] if preview.sample_moves else None
+ preview = get_batch_reorganization_preview(collection.id, session)
result_collections.append({
- 'collection_id': collection.id,
- 'collection_name': collection.name,
+ 'collection_id': preview.collection_id,
+ 'collection_name': preview.collection_name,
'total_files': preview.total_files,
'files_needing_move': preview.files_needing_move,
- 'sample_move': sample_move,
+ 'sample_move': preview.sample_move,
})
total_files_needing_move += preview.files_needing_move
except Exception as e:
@@ -668,7 +1100,7 @@ def execute_batch_reorganization(kind: str, session: Session = None) -> dict:
return execute_batch_reorganization(kind, session)
# Get collections needing reorganization
- reorg_info = get_collections_needing_reorganization(kind, session, sample_size=0)
+ reorg_info = get_collections_needing_reorganization(kind, session)
collections_to_process = reorg_info['collections']
if not collections_to_process:
@@ -796,3 +1228,52 @@ def get_batch_reorganization_status(batch_job_id: str) -> dict:
'failed_collection': None,
'error': None,
}
+
+
+async def get_conflict_details_with_ffprobe(
+ collection_id: int,
+ session: Session,
+) -> List[dict]:
+ """Get conflict details, processing ffprobe for any videos missing it.
+
+ This ensures accurate quality rankings (including audio stream bonus)
+ when resolving conflicts. The +2 audio bonus in _rank_video_quality()
+ only applies if video.ffprobe_json exists.
+
+ Args:
+ collection_id: The collection to get conflicts for
+ session: Database session
+
+ Returns:
+ List of conflict detail dicts (empty if no conflicts)
+
+ Raises:
+ UnknownCollection: If collection not found
+ """
+ preview = get_reorganization_preview(collection_id, session)
+
+ if not preview.conflicts:
+ return []
+
+ # Process ffprobe for videos missing it (only for channel collections)
+ from modules.videos.models import Video
+
+ videos_processed = 0
+ for conflict in preview.conflicts:
+ for file_info in conflict['conflicting_files']:
+ if file_info.get('video_id'):
+ video = session.query(Video).get(file_info['video_id'])
+ if video and not video.ffprobe_json:
+ try:
+ await video.get_ffprobe_json()
+ videos_processed += 1
+ except Exception as e:
+ logger.warning(f'Failed to process ffprobe for video {video.id}: {e}')
+
+ # Flush ffprobe data if any was processed (preserves caller's transaction control)
+ if videos_processed > 0:
+ session.flush()
+ # Re-fetch preview to get updated quality rankings
+ preview = get_reorganization_preview(collection_id, session)
+
+ return preview.conflicts
diff --git a/wrolpi/collections/schema.py b/wrolpi/collections/schema.py
index 56c3f10d..9a20a487 100644
--- a/wrolpi/collections/schema.py
+++ b/wrolpi/collections/schema.py
@@ -54,6 +54,29 @@ class CollectionTagInfoResponse:
conflict_message: Optional[str] = None
+@dataclass
+class ConflictFileInfo:
+ """Info about a single file in a reorganization conflict."""
+ file_group_id: int
+ current_path: str
+ title: str
+ model_type: str # 'video' or 'archive'
+ size: int = 0
+ video_id: Optional[int] = None
+ archive_id: Optional[int] = None
+ poster_path: Optional[str] = None
+ published_datetime: Optional[str] = None
+ source_id: Optional[str] = None
+ quality_rank: Optional[int] = None # Metadata quality score (videos only)
+
+
+@dataclass
+class ConflictDetail:
+ """Details about a single destination path conflict."""
+ destination_path: str
+ conflicting_files: List[ConflictFileInfo]
+
+
@dataclass
class ReorganizationPreviewResponse:
"""Response for reorganization preview."""
@@ -64,6 +87,8 @@ class ReorganizationPreviewResponse:
sample_moves: List[dict]
new_file_format: str
current_file_format: Optional[str] = None
+ conflicts: List[ConflictDetail] = field(default_factory=list)
+ has_conflicts: bool = False
@dataclass
diff --git a/wrolpi/collections/test/test_reorganize.py b/wrolpi/collections/test/test_reorganize.py
index f39c6c38..434a282e 100644
--- a/wrolpi/collections/test/test_reorganize.py
+++ b/wrolpi/collections/test/test_reorganize.py
@@ -468,7 +468,7 @@ def test_get_collections_needing_reorganization_includes_sample_move(
# Create a video in this channel using factory
video = video_factory(channel_id=channel.id)
- result = get_collections_needing_reorganization('channel', test_session, sample_size=1)
+ result = get_collections_needing_reorganization('channel', test_session)
assert len(result['collections']) == 1
collection_info = result['collections'][0]
@@ -1729,9 +1729,9 @@ async def test_domain_reorganization_moves_files_from_root_to_year_subdirectory(
assert old_path.parent == domain_dir, \
f"Archive should be in root, not year subdir. Got: {old_path.parent}"
- # Get preview with exact_count - should detect files need moving
+ # Get preview - should detect files need moving
with get_db_session() as session:
- preview = get_reorganization_preview(collection_id, session, exact_count=True)
+ preview = get_reorganization_preview(collection_id, session)
assert preview.files_needing_move > 0, \
f"Should detect files need moving. Got: {preview.files_needing_move}"
@@ -1814,9 +1814,9 @@ async def test_channel_reorganization_moves_files_from_root_to_year_subdirectory
assert old_path.parent == channel_dir, \
f"Video should be in root, not year subdir. Got: {old_path.parent}"
- # Get preview with exact_count - should detect files need moving
+ # Get preview - should detect files need moving
with get_db_session() as session:
- preview = get_reorganization_preview(collection_id, session, exact_count=True)
+ preview = get_reorganization_preview(collection_id, session)
assert preview.files_needing_move > 0, \
f"Should detect files need moving. Got: {preview.files_needing_move}"
@@ -1940,12 +1940,12 @@ async def test_channel_reorganization_preserves_root_level_files(
config._config['yt_dlp_options'] = yt_dlp_options
-def test_preview_exact_count_computes_actual_moves(test_session, test_directory, archive_factory):
- """With exact_count=True, preview should compute actual files needing move, not total files.
+def test_preview_computes_actual_moves(test_session, test_directory, archive_factory):
+ """Preview should compute actual files needing move by building move mappings.
This test verifies that:
- - exact_count=False returns total_files as files_needing_move (estimate)
- - exact_count=True computes the actual count by building move mappings
+ - Preview correctly identifies files in wrong location
+ - After reorganization, preview shows 0 files needing move
"""
# Create domain collection with a year-based format (different from default)
domain_dir = test_directory / 'archive' / 'exactcount.com'
@@ -1977,19 +1977,13 @@ def test_preview_exact_count_computes_actual_moves(test_session, test_directory,
archive_obj.collection_id = collection_id
session.commit()
- # Without exact_count, preview assumes all files need moving
+ # Preview should detect that the file needs moving (in wrong location)
with get_db_session() as session:
- preview_estimate = get_reorganization_preview(collection_id, session, exact_count=False)
- # This is the current behavior - assumes all need moving
- assert preview_estimate.files_needing_move == preview_estimate.total_files
- assert preview_estimate.files_needing_move == 1
-
- # With exact_count, preview also detects that files need moving (they're in wrong location)
- with get_db_session() as session:
- preview_exact = get_reorganization_preview(collection_id, session, exact_count=True)
+ preview = get_reorganization_preview(collection_id, session)
# Files are in root, but should be in year subdir, so 1 file needs moving
- assert preview_exact.files_needing_move == 1, \
- f"File in wrong location should need moving. Got: {preview_exact.files_needing_move}"
+ assert preview.files_needing_move == 1, \
+ f"File in wrong location should need moving. Got: {preview.files_needing_move}"
+ assert preview.total_files == 1
# Now execute the reorganization to move files to correct location
from wrolpi.files.worker import file_worker
@@ -2001,9 +1995,9 @@ def test_preview_exact_count_computes_actual_moves(test_session, test_directory,
import asyncio
asyncio.get_event_loop().run_until_complete(file_worker.process_queue())
- # After reorganization, with exact_count should show 0 files needing move
+ # After reorganization, should show 0 files needing move
with get_db_session() as session:
- preview_after = get_reorganization_preview(collection_id, session, exact_count=True)
+ preview_after = get_reorganization_preview(collection_id, session)
assert preview_after.files_needing_move == 0, \
f"After reorganization, no files should need moving. Got: {preview_after.files_needing_move}"
finally:
@@ -2076,6 +2070,225 @@ async def test_reorganize_fails_on_filename_conflicts(async_client, test_directo
assert 'Duplicate Title' in str(exc_info.value)
+@pytest.mark.asyncio
+async def test_reorganize_preview_returns_conflict_details(async_client, test_directory, video_factory):
+ """Preview should return detailed conflict information for UI display."""
+ from datetime import datetime, timezone
+
+ # Create channel collection
+ channel_dir = test_directory / 'videos' / 'ConflictPreviewChannel'
+ channel_dir.mkdir(parents=True, exist_ok=True)
+
+ with get_db_session(commit=True) as session:
+ collection = Collection(
+ name='ConflictPreviewChannel',
+ kind='channel',
+ directory=channel_dir,
+ file_format='%(old_format)s.%(ext)s' # Needs reorganization
+ )
+ session.add(collection)
+ session.flush()
+ channel = Channel(name='ConflictPreviewChannel', collection_id=collection.id, directory=channel_dir)
+ session.add(channel)
+ session.commit()
+ channel_id = channel.id
+ collection_id = collection.id
+
+ # Create two videos with DIFFERENT initial filenames but IDENTICAL destination metadata
+ same_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
+ same_source_id = 'preview_duplicate_id'
+
+ video1 = video_factory(
+ channel_id=channel_id,
+ title='video_one',
+ upload_date=same_date,
+ with_video_file=True,
+ with_info_json={'uploader': 'TestUploader', 'id': same_source_id}
+ )
+
+ video2 = video_factory(
+ channel_id=channel_id,
+ title='video_two',
+ upload_date=same_date,
+ with_video_file=True,
+ with_info_json={'uploader': 'TestUploader', 'id': same_source_id}
+ )
+
+ # Update both videos to have the same title and source_id
+ with get_db_session(commit=True) as session:
+ v1 = session.query(Video).get(video1.id)
+ v2 = session.query(Video).get(video2.id)
+ v1.file_group.title = 'Duplicate Preview Title'
+ v2.file_group.title = 'Duplicate Preview Title'
+ v1.source_id = same_source_id
+ v2.source_id = same_source_id
+
+ # Get preview - should show conflicts instead of raising an exception
+ with get_db_session() as session:
+ preview = get_reorganization_preview(collection_id, session)
+
+ # Verify preview has conflict information
+ assert preview.has_conflicts is True, "Preview should indicate conflicts exist"
+ assert len(preview.conflicts) == 1, f"Should have 1 conflict, got {len(preview.conflicts)}"
+
+ conflict = preview.conflicts[0]
+ assert 'destination_path' in conflict
+ assert 'conflicting_files' in conflict
+ assert len(conflict['conflicting_files']) == 2, "Should have 2 conflicting files"
+
+ # Verify file details are included
+ for file_info in conflict['conflicting_files']:
+ assert 'file_group_id' in file_info
+ assert 'video_id' in file_info
+ assert 'current_path' in file_info
+ assert 'title' in file_info
+ assert 'model_type' in file_info
+ assert file_info['model_type'] == 'video'
+ assert file_info['source_id'] == same_source_id
+
+
+@pytest.mark.asyncio
+async def test_reorganize_preview_no_conflicts(async_client, test_directory, video_factory):
+ """Preview should show no conflicts when files have unique destinations."""
+ from datetime import datetime, timezone
+
+ # Create channel collection
+ channel_dir = test_directory / 'videos' / 'NoConflictChannel'
+ channel_dir.mkdir(parents=True, exist_ok=True)
+
+ with get_db_session(commit=True) as session:
+ collection = Collection(
+ name='NoConflictChannel',
+ kind='channel',
+ directory=channel_dir,
+ file_format='%(old_format)s.%(ext)s'
+ )
+ session.add(collection)
+ session.flush()
+ channel = Channel(name='NoConflictChannel', collection_id=collection.id, directory=channel_dir)
+ session.add(channel)
+ session.commit()
+ channel_id = channel.id
+ collection_id = collection.id
+
+ # Create two videos with DIFFERENT titles (unique destinations)
+ video1 = video_factory(
+ channel_id=channel_id,
+ title='Unique Title One',
+ upload_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
+ with_video_file=True,
+ with_info_json={'uploader': 'TestUploader', 'id': 'unique_id_1'}
+ )
+
+ video2 = video_factory(
+ channel_id=channel_id,
+ title='Unique Title Two',
+ upload_date=datetime(2024, 1, 16, tzinfo=timezone.utc),
+ with_video_file=True,
+ with_info_json={'uploader': 'TestUploader', 'id': 'unique_id_2'}
+ )
+
+ # Get preview - should show no conflicts
+ with get_db_session() as session:
+ preview = get_reorganization_preview(collection_id, session)
+
+ assert preview.has_conflicts is False, "Preview should indicate no conflicts"
+ assert preview.conflicts == [], "Conflicts list should be empty"
+
+
+@pytest.mark.asyncio
+async def test_conflict_preview_includes_quality_rank(async_client, test_directory, video_factory):
+ """Conflict preview should include quality_rank for videos based on metadata completeness.
+
+ Videos with more metadata (info_json, captions, poster) should rank higher.
+ Conflicting files should be sorted by quality_rank (highest first).
+ """
+ from datetime import datetime, timezone
+
+ # Create channel collection
+ channel_dir = test_directory / 'videos' / 'QualityRankChannel'
+ channel_dir.mkdir(parents=True, exist_ok=True)
+
+ with get_db_session(commit=True) as session:
+ collection = Collection(
+ name='QualityRankChannel',
+ kind='channel',
+ directory=channel_dir,
+ file_format='%(old_format)s.%(ext)s' # Needs reorganization
+ )
+ session.add(collection)
+ session.flush()
+ channel = Channel(name='QualityRankChannel', collection_id=collection.id, directory=channel_dir)
+ session.add(channel)
+ session.commit()
+ channel_id = channel.id
+ collection_id = collection.id
+
+ same_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
+ same_source_id = 'quality_rank_test_id'
+
+ # video1 - MINIMAL metadata (lower rank expected)
+ video1 = video_factory(
+ channel_id=channel_id,
+ title='minimal_video',
+ upload_date=same_date,
+ with_video_file=True,
+ # No info_json, no poster, no captions
+ )
+
+ # video2 - RICH metadata (higher rank expected)
+ video2 = video_factory(
+ channel_id=channel_id,
+ title='rich_video',
+ upload_date=same_date,
+ with_video_file=True,
+ with_info_json={'uploader': 'TestUploader', 'id': same_source_id, 'title': 'Rich Video Title'},
+ with_poster_ext='jpg',
+ with_caption_file=True,
+ )
+
+ # Make both videos conflict (same title, same source_id → same destination)
+ with get_db_session(commit=True) as session:
+ v1 = session.query(Video).get(video1.id)
+ v2 = session.query(Video).get(video2.id)
+ v1.file_group.title = 'Same Title'
+ v2.file_group.title = 'Same Title'
+ v1.source_id = same_source_id
+ v2.source_id = same_source_id
+ # Give v1 a URL so it has SOME data, but still less than v2
+ v1.file_group.url = 'https://example.com/video1'
+
+ # Get preview - should detect conflicts with quality_rank
+ with get_db_session() as session:
+ preview = get_reorganization_preview(collection_id, session)
+
+ assert preview.has_conflicts is True, "Should detect conflicts"
+ assert len(preview.conflicts) == 1, "Should have 1 conflict"
+
+ conflict = preview.conflicts[0]
+ files = conflict['conflicting_files']
+ assert len(files) == 2, "Should have 2 conflicting files"
+
+ # Verify quality_rank is present on all video files
+ for file_info in files:
+ assert 'quality_rank' in file_info, f"quality_rank missing from {file_info}"
+ assert file_info['quality_rank'] is not None, f"quality_rank should not be None for videos"
+
+ # Verify files are sorted by quality_rank (highest first)
+ ranks = [f['quality_rank'] for f in files]
+ assert ranks == sorted(ranks, reverse=True), \
+ f"Files should be sorted by quality_rank descending. Got: {ranks}"
+
+ # The video with more metadata (video2) should rank higher
+ video2_info = next(f for f in files if f['video_id'] == video2.id)
+ video1_info = next(f for f in files if f['video_id'] == video1.id)
+ assert video2_info['quality_rank'] > video1_info['quality_rank'], \
+ f"Video with more metadata should rank higher. v2={video2_info['quality_rank']}, v1={video1_info['quality_rank']}"
+
+ # The highest ranked video should be first
+ assert files[0]['video_id'] == video2.id, "Highest ranked video should be first"
+
+
# ============================================================================
# Filename Parsing Fallback Tests
# ============================================================================
diff --git a/wrolpi/files/worker.py b/wrolpi/files/worker.py
index c489dacf..2846f1b0 100644
--- a/wrolpi/files/worker.py
+++ b/wrolpi/files/worker.py
@@ -2322,9 +2322,18 @@ class FileWorker:
except Exception as e:
# Collection failed - stop batch processing
logger.error(f'Batch reorganize: {collection_name} failed: {e}')
+
+ # Get the actual channel ID for the UI link (collection_id != channel_id)
+ channel_id = None
+ if kind == 'channel':
+ from modules.videos.models import Channel
+ channel = session.query(Channel).filter_by(collection_id=collection_id).one_or_none()
+ channel_id = channel.id if channel else None
+
batch_status['failed_collection'] = {
'id': collection_id,
'name': collection_name,
+ 'channel_id': channel_id,
}
batch_status['error'] = str(e)
batch_status['current_collection'] = None