Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions client/src/components/ChannelPage/VideoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { formatDuration } from '../../utils';
import { ChannelVideo } from '../../types/ChannelVideo';
import { decodeHtml, formatAddedDateTime } from '../../utils/formatters';
import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus';
import { describeRow } from '../../utils/videoRemoval';
import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription';
import StillLiveDot from './StillLiveDot';
import RatingBadge from '../shared/RatingBadge';
import DownloadFormatIndicator from '../shared/DownloadFormatIndicator';
Expand Down Expand Up @@ -391,7 +391,11 @@ function VideoCard({
style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE, flexShrink: 0 }}
/>
<WatchedChip watchedBy={video.watchedBy || []} />
<Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}>
<Tooltip
title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''}
enterTouchDelay={0}
className={REMOVAL_TOOLTIP_CLASS}
>
<Chip
icon={getStatusIcon(status)}
label={statusLabel}
Expand Down
8 changes: 6 additions & 2 deletions client/src/components/ChannelPage/VideoListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { decodeHtml, formatAddedDate } from '../../utils/formatters';
import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_THEMED_CHIP_SMALL_STYLE } from '../shared/chipStyles';
import { getPublishedDateDisplay } from './publishedDateDisplay';
import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus';
import { describeRow } from '../../utils/videoRemoval';
import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription';
import StillLiveDot from './StillLiveDot';
import DownloadFormatIndicator from '../shared/DownloadFormatIndicator';
import ProtectionShieldButton from '../shared/ProtectionShieldButton';
Expand Down Expand Up @@ -371,7 +371,11 @@ function VideoListItem({
style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE }}
/>
<WatchedChip watchedBy={video.watchedBy || []} />
<Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}>
<Tooltip
title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''}
enterTouchDelay={0}
className={REMOVAL_TOOLTIP_CLASS}
>
<Chip
icon={getStatusIcon(status)}
label={statusLabel}
Expand Down
8 changes: 6 additions & 2 deletions client/src/components/ChannelPage/VideoTableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { formatDuration } from '../../utils';
import { ChannelVideo } from '../../types/ChannelVideo';
import { decodeHtml, formatAddedDateParts } from '../../utils/formatters';
import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus';
import { describeRow } from '../../utils/videoRemoval';
import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription';
import StillLiveDot from './StillLiveDot';
import RatingBadge from '../shared/RatingBadge';
import DownloadFormatIndicator from '../shared/DownloadFormatIndicator';
Expand Down Expand Up @@ -354,7 +354,11 @@ function VideoTableView({
/>
)}
<WatchedChip watchedBy={video.watchedBy || []} />
<Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}>
<Tooltip
title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''}
enterTouchDelay={0}
className={REMOVAL_TOOLTIP_CLASS}
>
<Chip
icon={getStatusIcon(status)}
label={statusLabel}
Expand Down
7 changes: 4 additions & 3 deletions client/src/components/DownloadManager/DownloadHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import VideoModal from '../shared/VideoModal';
import { VideoModalData } from '../shared/VideoModal/types';
import VideoThumbnail from './VideoThumbnail';
import AvailabilityChip from '../shared/AvailabilityChip';
import { describeRow, getRemovalState, removalFromRow, removedState } from '../../utils/videoRemoval';
import { getRemovalState, removalFromRow, removedState } from '../../utils/videoRemoval';
import RemovalDescription from '../shared/RemovalDescription';
import FailedVideoChip from './FailedVideoChip';
import FailedDownloadsDetail from './FailedDownloadsDetail';
import { jobTypeLabel } from '../../utils/jobTypeLabel';
Expand Down Expand Up @@ -401,7 +402,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({
{video.youTubeChannelName}
</Typography>
<VideoActivityChip youtubeId={video.youtubeId} />
{video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={describeRow(video)} />}
{video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={<RemovalDescription row={video} />} />}
</Box>
</Box>
))}
Expand Down Expand Up @@ -571,7 +572,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({
{video.youTubeVideoName}
</Link>
<VideoActivityChip youtubeId={video.youtubeId} />
{video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={describeRow(video)} />}
{video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={<RemovalDescription row={video} />} />}
</Box>
<Typography variant="caption" color="secondary" className="block">{video.youTubeChannelName}</Typography>
</TableCell>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { Alert, Box, Button, FormControlLabel, Switch, Typography } from '../../ui';
import { Refresh as RefreshIcon, Download as DownloadIcon } from '../../../lib/icons';
import InfoHint from './InfoHint';
import InfoHint from '../../shared/InfoHint';
import { Playlist } from '../../../types/playlist';
import { MAX_PLAYLIST_VIDEOS } from '../playlistConstants';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '../../ui';
import { Refresh as RefreshIcon, MoreHorizontal } from '../../../lib/icons';
import { MediaServerStatus, MediaServerType, Playlist } from '../../../types/playlist';
import InfoHint from './InfoHint';
import InfoHint from '../../shared/InfoHint';
import PlaylistSyncChips from './PlaylistSyncChips';

interface MediaServerSyncGroupProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { formatDurationClock } from '../../../utils';
import { isSelectable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers';
import DownloadFormatIndicator from '../../shared/DownloadFormatIndicator';
import WatchedChip from '../../shared/WatchedChip';
import { REMOVAL_TOOLTIP_CLASS } from '../../shared/RemovalDescription';
import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from '../../shared/chipStyles';

const compactStatusChipStyle: React.CSSProperties = {
Expand Down Expand Up @@ -89,7 +90,7 @@ const PlaylistVideoCard: React.FC<PlaylistVideoCardProps> = ({
)}
</>
) : status.tooltip ? (
<Tooltip title={status.tooltip} enterTouchDelay={0}>
<Tooltip title={status.tooltip} enterTouchDelay={0} className={REMOVAL_TOOLTIP_CLASS}>
<Chip label={status.label} color={status.color} size="small" style={compactStatusChipStyle} />
</Tooltip>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Block as BlockIcon, CheckCircleOutline as CheckCircleOutlineIcon } from
import { isSelectable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers';
import DownloadFormatIndicator from '../../shared/DownloadFormatIndicator';
import WatchedChip from '../../shared/WatchedChip';
import { REMOVAL_TOOLTIP_CLASS } from '../../shared/RemovalDescription';

const THUMB_WIDTH = 120;
const THUMB_HEIGHT = 67;
Expand Down Expand Up @@ -171,7 +172,7 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({
)}
</>
) : status.tooltip ? (
<Tooltip title={status.tooltip} enterTouchDelay={0}>
<Tooltip title={status.tooltip} enterTouchDelay={0} className={REMOVAL_TOOLTIP_CLASS}>
<Chip label={status.label} color={status.color} size="small" />
</Tooltip>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,26 +90,28 @@ describe('statusLabel', () => {
color: 'error',
});
});
test('returns Missing for a previously-downloaded video whose file was not found, explaining it', () => {
expect(statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'not_found', file_path: '/d/v.mp4' }))).toEqual({
test('returns Missing for a previously-downloaded video whose file was not found', () => {
expect(statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'not_found', file_path: '/d/v.mp4' }))).toMatchObject({
label: 'Missing',
color: 'error',
tooltip: 'Youtarr expected a file at /d/v.mp4 and could not find it. If the drive is mounted and the file is really gone, mark it as deleted.',
});
});
test('returns a neutral Deleted for a previously-downloaded video Youtarr removed', () => {
expect(statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'auto_removal', removed_detail: 'older than 30 days' }))).toEqual({
label: 'Deleted',
color: 'default',
tooltip: 'Deleted by automatic cleanup (older than 30 days). It can be downloaded again.',
});
test('explains a missing video with the path Youtarr looked at', () => {
const { tooltip } = statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'not_found', file_path: '/d/v.mp4' }));
render(<>{tooltip}</>);
expect(screen.getByRole('code')).toHaveTextContent('/d/v.mp4');
});
test('returns a neutral Deleted for a previously-downloaded video Youtarr removed, explaining it', () => {
const status = statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'auto_removal', removed_detail: 'older than 30 days' }));
expect(status).toMatchObject({ label: 'Deleted', color: 'default' });
const { container } = render(<>{status.tooltip}</>);
expect(container).toHaveTextContent('Deleted by automatic cleanup (older than 30 days). It can be downloaded again.');
});
test('treats a previously-downloaded video without a recorded reason as Deleted', () => {
expect(statusLabel(makeVideo({ previously_downloaded: true }))).toEqual({
label: 'Deleted',
color: 'default',
tooltip: 'Removed before Youtarr recorded why. It can be downloaded again.',
});
const status = statusLabel(makeVideo({ previously_downloaded: true }));
expect(status).toMatchObject({ label: 'Deleted', color: 'default' });
const { container } = render(<>{status.tooltip}</>);
expect(container).toHaveTextContent('Removed before Youtarr recorded why. It can be downloaded again.');
});
test('returns Tracked for a plain tracked video', () => {
expect(statusLabel(makeVideo())).toEqual({ label: 'Tracked', color: 'default' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons';
import { PlaylistSortOrder } from '../../../hooks/usePlaylistDetail';
import { PlaylistVideo } from '../../../types/playlist';
import { VideoModalData } from '../../shared/VideoModal/types';
import { describeRemoval, removalFromRow, removedState } from '../../../utils/videoRemoval';
import { removalFromRow, removedState } from '../../../utils/videoRemoval';
import RemovalDescription from '../../shared/RemovalDescription';

export type PlaylistVideoStatusColor = 'success' | 'default' | 'warning' | 'error' | 'info';

export interface PlaylistVideoStatus {
label: string;
color: PlaylistVideoStatusColor;
// Plain-language explanation for a removed video, shown on hover or tap.
tooltip?: string;
tooltip?: React.ReactNode;
}

// Ignored videos stay downloadable: explicitly selecting one is an intentional
Expand Down Expand Up @@ -89,7 +90,17 @@ export function statusLabel(v: PlaylistVideo): PlaylistVideoStatus {
if (v.previously_downloaded) {
// Missing (file not found) is a warning; deleted (removed by Youtarr) is not.
const state = removedState(v.removed_reason);
const tooltip = describeRemoval(state, removalFromRow(v), v.file_path);
const tooltip = (
<RemovalDescription
row={{
removed: true,
removed_reason: v.removed_reason,
removed_at: v.removed_at,
removed_detail: v.removed_detail,
filePath: v.file_path,
}}
/>
);
return state === 'missing'
? { label: 'Missing', color: 'error', tooltip }
: { label: 'Deleted', color: 'default', tooltip };
Expand Down
23 changes: 17 additions & 6 deletions client/src/components/Settings/MaintenanceSection.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { ScheduleSummary } from '../Configuration/sections/components/ScheduleSummary';
import { ConfigState } from '../Configuration/types';
import React from 'react';
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Alert, Button, CircularProgress, Typography } from '../ui';
import { ConfigurationCard } from '../Configuration/common/ConfigurationCard';
import { useRescanStatus } from '../../hooks/useRescanStatus';
import { formatDateTime } from '../../utils/formatters';
import { REMOVED_VIDEOS_ANCHOR } from '../../utils/videoRemoval';
import RemovedVideosCard from './components/RemovedVideosCard';

interface MaintenanceSectionProps {
Expand All @@ -16,6 +18,13 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) {
const { running, lastRun, loading, error, triggerRescan } = useRescanStatus(token);
const persistentError = !running && lastRun?.status === 'error' ? lastRun.errorMessage : null;
const transientError = error && error !== persistentError ? error : null;
const { hash } = useLocation();

// Links from the Videos page land on the deleted and missing videos card.
useEffect(() => {
if (hash !== `#${REMOVED_VIDEOS_ANCHOR}`) return;
document.getElementById(REMOVED_VIDEOS_ANCHOR)?.scrollIntoView?.({ block: 'start' });
}, [hash]);

let statusLine: React.ReactNode;
if (running) {
Expand Down Expand Up @@ -80,11 +89,13 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) {
</div>
</ConfigurationCard>

<RemovedVideosCard
token={token}
onRescanAnyway={() => triggerRescan({ ignoreLibraryCheck: true })}
rescanRunning={running}
/>
<section id={REMOVED_VIDEOS_ANCHOR} aria-label="Deleted and missing videos" className="scroll-mt-24">
<RemovedVideosCard
token={token}
onRescanAnyway={() => triggerRescan({ ignoreLibraryCheck: true })}
rescanRunning={running}
/>
</section>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const { useRemovedVideos } = require('../hooks/useRemovedVideos') as {

function setup(
overrides: Partial<UseRescanStatusReturn> = {},
libraryCheck: RemovedVideosLibraryCheck = { ok: true, reason: null, path: null, message: null }
libraryCheck: RemovedVideosLibraryCheck = { ok: true, reason: null, path: null, message: null },
route = '/settings/maintenance'
) {
const triggerRescan = jest.fn().mockResolvedValue({ started: true });
useRescanStatus.mockReturnValue({
Expand All @@ -45,7 +46,7 @@ function setup(
actionPending: false,
actionError: null,
});
renderWithProviders(<MaintenanceSection token="tok" config={DEFAULT_CONFIG} />);
renderWithProviders(<MaintenanceSection token="tok" config={DEFAULT_CONFIG} />, { initialEntries: [route] });
return { triggerRescan };
}

Expand All @@ -64,6 +65,40 @@ describe('MaintenanceSection', () => {
expect(screen.getByText('Deleted: 2. Missing: 1.')).toBeInTheDocument();
});

describe('linked from the Videos page', () => {
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView');
let scrolledTo: Element[];

beforeEach(() => {
scrolledTo = [];
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
writable: true,
value: function scrollIntoView(this: Element) {
scrolledTo.push(this);
},
});
});

afterEach(() => {
if (original) {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', original);
} else {
delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView;
}
});

test('scrolls to the deleted and missing videos card', () => {
setup({}, undefined, '/settings/maintenance#deleted-and-missing');
expect(scrolledTo).toEqual([screen.getByRole('region', { name: 'Deleted and missing videos' })]);
});

test('stays at the top of the page without the link', () => {
setup();
expect(scrolledTo).toEqual([]);
});
});

test('wires Rescan anyway to a rescan that skips the downloads folder check', async () => {
const user = userEvent.setup();
const { triggerRescan } = setup({}, { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder is empty.' });
Expand Down
34 changes: 34 additions & 0 deletions client/src/components/VideosPage/components/HiddenDeletedNote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Typography } from '../../ui';
import InfoHint from '../../shared/InfoHint';
import { REMOVED_VIDEOS_SETTINGS_PATH } from '../../../utils/videoRemoval';

interface HiddenDeletedNoteProps {
// Deleted videos the current filters hide; null when deleted videos are not hidden.
hiddenDeletedCount: number | null;
onShowDeleted: () => void;
}

// "N deleted videos hidden." with Show, Clean up (Settings > Maintenance) and
// an explanation. Sits beside the Library heading on wide screens and under it
// on phones, so it annotates the total without taking a line of its own.
function HiddenDeletedNote({ hiddenDeletedCount, onShowDeleted }: HiddenDeletedNoteProps) {
const navigate = useNavigate();
if (hiddenDeletedCount === null || hiddenDeletedCount <= 0) return null;

return (
<Typography variant="caption" color="text.secondary" component="p" className="flex flex-wrap items-center justify-center gap-1">
<span>{hiddenDeletedCount} deleted {hiddenDeletedCount === 1 ? 'video' : 'videos'} hidden.</span>
<Button variant="text" size="sm" onClick={onShowDeleted}>Show</Button>
<span aria-hidden="true">·</span>
<Button variant="text" size="sm" onClick={() => navigate(REMOVED_VIDEOS_SETTINGS_PATH)}>Clean up</Button>
<InfoHint label="About deleted videos">
Deleted videos stay in your library so they can be re-downloaded. In Settings &gt; Maintenance you can
mark missing videos as deleted, or empty the trash to remove deleted videos for good.
</InfoHint>
</Typography>
);
}

export default HiddenDeletedNote;
Loading
Loading