mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Creating Collection reorganization conflict resolution.
This commit is contained in:
parent
8a4f9201f6
commit
63c370b073
12 changed files with 2212 additions and 197 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<Table.Row key={idx}>
|
||||
<Table.Cell>{collection.collection_name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{collection.files_needing_move} / {collection.total_files}
|
||||
{collection.total_files}
|
||||
</Table.Cell>
|
||||
<Table.Cell style={{wordBreak: 'break-all', fontSize: '0.9em'}}>
|
||||
{collection.sample_move ? (
|
||||
|
|
@ -313,21 +313,6 @@ export function BatchReorganizeModal({
|
|||
</Grid.Row>
|
||||
)}
|
||||
|
||||
{status.completed && status.completed.length > 0 && (
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<strong>Completed:</strong>
|
||||
<div style={{maxHeight: '150px', overflowY: 'auto'}}>
|
||||
{status.completed.map((c, idx) => (
|
||||
<Label key={idx} color='green' style={{margin: '2px'}}>
|
||||
<Icon name='check'/> {c.name}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
)}
|
||||
|
||||
{status.status === 'complete' && (
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
|
|
@ -347,7 +332,19 @@ export function BatchReorganizeModal({
|
|||
<p>
|
||||
Failed on {kindLabel.toLowerCase()}: <strong>{status.failed_collection.name}</strong>
|
||||
</p>
|
||||
{status.error && <p>Error: {status.error}</p>}
|
||||
<SButton
|
||||
as={Link}
|
||||
to={kind === 'channel'
|
||||
? `/videos/channel/${status.failed_collection.channel_id || status.failed_collection.id}/edit`
|
||||
: `/archive/domain/${status.failed_collection.id}/edit`
|
||||
}
|
||||
size='small'
|
||||
>
|
||||
<Icon name='eye'/> View {kindLabel}
|
||||
</SButton>
|
||||
{status.error && (
|
||||
<p style={{whiteSpace: 'pre-wrap'}}>Error: {status.error}</p>
|
||||
)}
|
||||
</Message>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<Message.Header>Collection Appears Organized</Message.Header>
|
||||
<p>
|
||||
This collection's format matches the current configuration, but
|
||||
{' '}<strong>{preview.files_needing_move}</strong> files don't match the expected layout.
|
||||
{' '}<strong>{preview.files_needing_move}</strong> files don't match the expected
|
||||
layout.
|
||||
This can happen if a previous reorganization was interrupted.
|
||||
</p>
|
||||
</Message>
|
||||
|
|
@ -204,6 +305,33 @@ export function CollectionReorganizeModal({
|
|||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{preview.has_conflicts && (
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
<Message error>
|
||||
<Message.Header>
|
||||
<Icon name='warning sign'/> Conflicts Detected
|
||||
</Message.Header>
|
||||
<p>
|
||||
<strong>{preview.conflicts.length}</strong> destination{' '}
|
||||
{preview.conflicts.length === 1 ? 'path has' : 'paths have'}{' '}
|
||||
multiple files that would collide.
|
||||
You must resolve these conflicts before reorganizing.
|
||||
</p>
|
||||
<SButton
|
||||
color='yellow'
|
||||
onClick={handleResolveConflicts}
|
||||
loading={loadingConflicts}
|
||||
disabled={loadingConflicts}
|
||||
>
|
||||
<Icon name='wrench'/> Resolve Conflicts
|
||||
</SButton>
|
||||
</Message>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
)}
|
||||
|
||||
{preview.sample_moves && preview.sample_moves.length > 0 && (
|
||||
<Grid.Row>
|
||||
<Grid.Column>
|
||||
|
|
@ -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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
closeIcon={true}
|
||||
closeOnDimmerClick={!isInProgress}
|
||||
closeOnEscape={!isInProgress}
|
||||
>
|
||||
<Modal.Header>
|
||||
Reorganize Files: {collectionName}
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
{loading && (
|
||||
<Message icon>
|
||||
<Icon name='circle notched' loading/>
|
||||
<Message.Content>
|
||||
<Message.Header>Loading Preview</Message.Header>
|
||||
Analyzing files...
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)}
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
closeIcon={true}
|
||||
closeOnDimmerClick={!isInProgress}
|
||||
closeOnEscape={!isInProgress}
|
||||
size='fullscreen'
|
||||
>
|
||||
<Modal.Header>
|
||||
Reorganize Files: {collectionName}
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
{loading && (
|
||||
<Message icon>
|
||||
<Icon name='circle notched' loading/>
|
||||
<Message.Content>
|
||||
<Message.Header>Loading Preview</Message.Header>
|
||||
Analyzing files...
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Message negative>
|
||||
<Message.Header>Error</Message.Header>
|
||||
<p>{error}</p>
|
||||
</Message>
|
||||
)}
|
||||
{error && (
|
||||
<Message negative>
|
||||
<Message.Header>Error</Message.Header>
|
||||
<p style={{whiteSpace: 'pre-wrap'}}>{error}</p>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{!loading && !jobId && renderPreview()}
|
||||
{jobId && renderProgress()}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={handleClose}>
|
||||
{status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
|
||||
</Button>
|
||||
{canReorganize && (
|
||||
<APIButton
|
||||
color='violet'
|
||||
onClick={handleReorganize}
|
||||
obeyWROLMode={true}
|
||||
>
|
||||
Reorganize Files
|
||||
</APIButton>
|
||||
)}
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
{!loading && !status && renderPreview()}
|
||||
{status && renderProgress()}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={handleClose}>
|
||||
{status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
|
||||
</Button>
|
||||
{canReorganize && (
|
||||
<APIButton
|
||||
color='violet'
|
||||
onClick={handleReorganize}
|
||||
obeyWROLMode={true}
|
||||
>
|
||||
Reorganize Files
|
||||
</APIButton>
|
||||
)}
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
|
||||
{/* Conflict Resolution Modal */}
|
||||
<ConflictResolutionModal
|
||||
open={showConflictModal}
|
||||
onClose={() => setShowConflictModal(false)}
|
||||
conflicts={preview?.conflicts || []}
|
||||
collectionKind={collectionKind}
|
||||
onResolved={handleConflictResolved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
465
app/src/components/collections/CollectionReorganizeModal.test.js
Normal file
465
app/src/components/collections/CollectionReorganizeModal.test.js
Normal file
|
|
@ -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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
// 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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<CollectionReorganizeModal {...defaultProps} needsReorganization={false} />
|
||||
);
|
||||
|
||||
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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
// 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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<CollectionReorganizeModal {...defaultProps} onComplete={mockOnComplete} />
|
||||
);
|
||||
|
||||
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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<CollectionReorganizeModal {...defaultProps} onClose={mockOnClose} />
|
||||
);
|
||||
|
||||
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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
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(<CollectionReorganizeModal {...defaultProps} open={false} />);
|
||||
|
||||
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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
// 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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
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(<CollectionReorganizeModal {...defaultProps} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
279
app/src/components/collections/ConflictResolutionModal.js
Normal file
279
app/src/components/collections/ConflictResolutionModal.js
Normal file
|
|
@ -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 (
|
||||
<Card fluid>
|
||||
<Card.Content>
|
||||
{localError && (
|
||||
<Message negative size='small' style={{marginBottom: '0.5em'}}>
|
||||
<Icon name='warning'/> {localError}
|
||||
</Message>
|
||||
)}
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
{/* Poster/Thumbnail column */}
|
||||
<Grid.Column width={4}>
|
||||
{file.poster_path ? (
|
||||
<Image
|
||||
src={`/media/${file.poster_path}`}
|
||||
size='small'
|
||||
style={{maxHeight: '100px', objectFit: 'cover'}}
|
||||
/>
|
||||
) : (
|
||||
<Segment placeholder style={{height: '80px'}}>
|
||||
<Icon
|
||||
name={file.model_type === 'video' ? 'video' : 'file'}
|
||||
size='large'
|
||||
disabled
|
||||
/>
|
||||
</Segment>
|
||||
)}
|
||||
</Grid.Column>
|
||||
|
||||
{/* File info column */}
|
||||
<Grid.Column width={10}>
|
||||
<Card.Header style={{marginBottom: '0.5em'}}>
|
||||
{file.title || 'Untitled'}
|
||||
{isRecommended && (
|
||||
<Label color='green' size='small' style={{marginLeft: '0.5em'}}>
|
||||
<Icon name='star'/> Recommended to Keep
|
||||
</Label>
|
||||
)}
|
||||
</Card.Header>
|
||||
<Card.Meta style={{wordBreak: 'break-all'}}>
|
||||
<Icon name='folder open outline'/> {file.current_path}
|
||||
</Card.Meta>
|
||||
<Card.Description>
|
||||
<div style={{marginTop: '0.5em'}}>
|
||||
{file.size > 0 && (
|
||||
<Label size='small'>
|
||||
<Icon name='hdd'/> {humanFileSize(file.size)}
|
||||
</Label>
|
||||
)}
|
||||
{file.published_datetime && (
|
||||
<Label size='small'>
|
||||
<Icon name='calendar'/> {isoDatetimeToString(file.published_datetime)}
|
||||
</Label>
|
||||
)}
|
||||
{file.source_id && (
|
||||
<Label size='small'>
|
||||
<Icon name='linkify'/> {file.source_id}
|
||||
</Label>
|
||||
)}
|
||||
{file.quality_rank !== null && file.quality_rank !== undefined && (
|
||||
<Label size='small' color='purple'>
|
||||
<Icon name='star outline'/> {file.quality_rank}
|
||||
</Label>
|
||||
)}
|
||||
<Label size='small' color={file.model_type === 'video' ? 'blue' : 'orange'}>
|
||||
{file.model_type}
|
||||
</Label>
|
||||
</div>
|
||||
</Card.Description>
|
||||
</Grid.Column>
|
||||
|
||||
{/* Delete button column */}
|
||||
<Grid.Column width={2} textAlign='center' verticalAlign='middle'>
|
||||
<SButton
|
||||
color='red'
|
||||
icon
|
||||
loading={isDeleting}
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
title='Delete this file'
|
||||
>
|
||||
<Icon name='trash'/>
|
||||
</SButton>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
closeIcon={true}
|
||||
size='fullscreen'
|
||||
>
|
||||
<Modal.Header>
|
||||
<Icon name='warning sign' color='yellow'/> Resolve Conflicts Before Reorganizing
|
||||
</Modal.Header>
|
||||
<Modal.Content scrolling>
|
||||
<Message warning>
|
||||
<Message.Header>Destination Path Conflicts Detected</Message.Header>
|
||||
<p>
|
||||
{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.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Delete the duplicates you don't want to keep</strong> before reorganizing.
|
||||
For each conflict, keep only one file.
|
||||
</p>
|
||||
</Message>
|
||||
|
||||
{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 (
|
||||
<Segment key={idx}>
|
||||
<Label attached='top' color='grey'>
|
||||
<Icon name='folder'/> Destination: {conflict.destination_path}
|
||||
<Label.Detail>{conflict.conflicting_files.length} files</Label.Detail>
|
||||
</Label>
|
||||
|
||||
<div style={{marginTop: '1em'}}>
|
||||
{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 (
|
||||
<ConflictFileCard
|
||||
key={file.file_group_id}
|
||||
file={file}
|
||||
onDelete={handleFileDelete}
|
||||
isRecommended={isRecommended}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Segment>
|
||||
);
|
||||
})}
|
||||
|
||||
{localConflicts.length === 0 && (
|
||||
<Message success>
|
||||
<Message.Header>All Conflicts Resolved</Message.Header>
|
||||
<p>You can now proceed with the reorganization.</p>
|
||||
</Message>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={handleClose}>
|
||||
{localConflicts.length === 0 ? 'Done' : 'Close'}
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
360
app/src/components/collections/ConflictResolutionModal.test.js
Normal file
360
app/src/components/collections/ConflictResolutionModal.test.js
Normal file
|
|
@ -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(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
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(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/videos\/2024-01-15\/My Video.mp4/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows file details for conflicting files', () => {
|
||||
renderWithProviders(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
// 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(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
// 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(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
// 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(
|
||||
<ConflictResolutionModal
|
||||
{...defaultProps}
|
||||
conflicts={archiveConflicts}
|
||||
collectionKind='domain'
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ConflictResolutionModal {...defaultProps} conflicts={[]} />
|
||||
);
|
||||
|
||||
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(
|
||||
<ConflictResolutionModal {...defaultProps} conflicts={[]} />
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', {name: /Done/i})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Close button when conflicts exist', () => {
|
||||
renderWithProviders(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('button', {name: /Close/i})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when Close button is clicked (without changes)', () => {
|
||||
renderWithProviders(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<ConflictResolutionModal {...defaultProps} conflicts={multipleConflicts} />
|
||||
);
|
||||
|
||||
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(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
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(
|
||||
<ConflictResolutionModal {...defaultProps} conflicts={archiveConflicts} collectionKind='domain' />
|
||||
);
|
||||
|
||||
expect(screen.getByText('archive')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
renderWithProviders(
|
||||
<ConflictResolutionModal {...defaultProps} open={false} />
|
||||
);
|
||||
|
||||
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(
|
||||
<ConflictResolutionModal {...defaultProps} conflicts={conflictsWithRank} />
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ConflictResolutionModal {...defaultProps} conflicts={conflictsWithRank} />
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ConflictResolutionModal {...defaultProps} conflicts={archiveConflicts} collectionKind='domain' />
|
||||
);
|
||||
|
||||
// Should NOT show "Recommended to Keep" badge for archives
|
||||
expect(screen.queryByText(/Recommended to Keep/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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('/<collection_id:int>/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('/<collection_id:int>/reorganize')
|
||||
@openapi.summary('Execute collection reorganization')
|
||||
@openapi.response(HTTPStatus.OK, schema.ReorganizationExecuteResponse, description="Reorganization started")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue