Skip to content

Commit 5adcfb1

Browse files
feat: expanded rule conditions, new templates for quality and codec filtering
- Add resolution, codec, year, unique watchers, file path as rule condition fields - Add not_equals, contains, not_contains operators to advanced mode - New templates: Low Quality Files, Old Codec Cleanup, Classic Movies Never Rewatched, Large 4K Watched Once - Sentence builder conditions for resolution, codec, year, and watcher count - Dynamic input types in advanced mode (text for strings, number for numerics)
1 parent ee977e4 commit 5adcfb1

2 files changed

Lines changed: 187 additions & 7 deletions

File tree

client/src/components/Rules/SmartRuleBuilder.tsx

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Clock,
88
Film,
99
Tv,
10+
Monitor,
1011
Sparkles,
1112
ChevronRight,
1213
Plus,
@@ -31,6 +32,7 @@ const iconMap: Record<string, React.ElementType> = {
3132
clock: Clock,
3233
film: Film,
3334
tv: Tv,
35+
monitor: Monitor,
3436
};
3537

3638
// Sentence builder options
@@ -100,6 +102,42 @@ const SENTENCE_CONDITIONS = [
100102
inputSuffix: 'GB',
101103
defaultValue: 1,
102104
},
105+
{
106+
id: 'low_resolution',
107+
label: 'have a resolution lower than',
108+
field: 'resolution_number',
109+
operator: 'less_than',
110+
hasInput: true,
111+
inputSuffix: 'p',
112+
defaultValue: 1080,
113+
},
114+
{
115+
id: 'old_codec',
116+
label: 'use codec',
117+
field: 'codec',
118+
operator: 'contains',
119+
hasInput: true,
120+
inputSuffix: '',
121+
defaultValue: 'h264' as any,
122+
},
123+
{
124+
id: 'released_before',
125+
label: 'were released before',
126+
field: 'year',
127+
operator: 'less_than',
128+
hasInput: true,
129+
inputSuffix: '',
130+
defaultValue: 2015,
131+
},
132+
{
133+
id: 'no_watchers',
134+
label: 'have been watched by fewer than',
135+
field: 'watched_by_count',
136+
operator: 'less_than',
137+
hasInput: true,
138+
inputSuffix: 'users',
139+
defaultValue: 2,
140+
},
103141
];
104142

105143
// Derive rule type from conditions for backend validation
@@ -123,7 +161,7 @@ type BuilderMode = 'templates' | 'sentence' | 'advanced';
123161

124162
interface SentenceCondition {
125163
id: string;
126-
value?: number;
164+
value?: number | string;
127165
}
128166

129167
export function SmartRuleBuilder({
@@ -264,7 +302,7 @@ export function SmartRuleBuilder({
264302
}
265303
};
266304

267-
const handleUpdateSentenceConditionValue = (conditionId: string, value: number) => {
305+
const handleUpdateSentenceConditionValue = (conditionId: string, value: number | string) => {
268306
setSentenceConditions(
269307
sentenceConditions.map(c => c.id === conditionId ? { ...c, value } : c)
270308
);
@@ -474,10 +512,10 @@ export function SmartRuleBuilder({
474512
{condDef.hasInput && (
475513
<>
476514
<input
477-
type="number"
515+
type={['codec'].includes(condDef.field) ? 'text' : 'number'}
478516
value={sc.value ?? condDef.defaultValue}
479-
onChange={(e) => handleUpdateSentenceConditionValue(sc.id, Number(e.target.value))}
480-
className="inline-block w-16 mx-2 px-2 py-1 bg-surface-700 border border-surface-600 rounded text-white text-center focus:outline-none focus:ring-2 focus:ring-accent-500"
517+
onChange={(e) => handleUpdateSentenceConditionValue(sc.id, ['codec'].includes(condDef.field) ? e.target.value as any : Number(e.target.value))}
518+
className={`inline-block mx-2 px-2 py-1 bg-surface-700 border border-surface-600 rounded text-white text-center focus:outline-none focus:ring-2 focus:ring-accent-500 ${['codec'].includes(condDef.field) ? 'w-24' : 'w-16'}`}
481519
/>
482520
<span className="text-surface-400">{condDef.inputSuffix}</span>
483521
</>
@@ -577,6 +615,11 @@ export function SmartRuleBuilder({
577615
<option value="days_since_added">Days Since Added</option>
578616
<option value="play_count">Play Count</option>
579617
<option value="size_gb">File Size (GB)</option>
618+
<option value="resolution_number">Resolution</option>
619+
<option value="codec">Codec</option>
620+
<option value="year">Release Year</option>
621+
<option value="watched_by_count">Unique Watchers</option>
622+
<option value="file_path">File Path</option>
580623
</select>
581624
<select
582625
value={condition.operator || 'greater_than'}
@@ -593,9 +636,12 @@ export function SmartRuleBuilder({
593636
<option value="greater_than">Greater than</option>
594637
<option value="less_than">Less than</option>
595638
<option value="equals">Equals</option>
639+
<option value="not_equals">Not Equals</option>
640+
<option value="contains">Contains</option>
641+
<option value="not_contains">Does Not Contain</option>
596642
</select>
597643
<Input
598-
type="number"
644+
type={['codec', 'file_path'].includes(condition.field || '') ? 'text' : 'number'}
599645
value={String(condition.value)}
600646
onChange={(e) => {
601647
const updated = [...conditions];

server/src/routes/rules.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,30 @@ function getFieldValue(item: MediaItem, field: string): string | number | boolea
121121
if (!item.file_size) return null;
122122
return item.file_size / (1024 * 1024 * 1024);
123123
}
124+
case 'resolution_number': {
125+
// Parse resolution string to number: "1080p" → 1080, "4K" → 2160, "720p" → 720, "480p" → 480
126+
const res = String(item.resolution || '');
127+
if (res.toLowerCase().includes('4k') || res.includes('2160')) return 2160;
128+
const match = res.match(/(\d+)/);
129+
return match && match[1] ? parseInt(match[1], 10) : 0;
130+
}
131+
case 'year':
132+
return item.year || 0;
133+
case 'codec':
134+
return (item.codec || '').toLowerCase();
135+
case 'library_key':
136+
return (item as any).library_key || '';
137+
case 'file_path':
138+
return item.file_path || '';
139+
case 'watched_by_count': {
140+
// Parse watched_by JSON array and count unique watchers
141+
try {
142+
const wb = item.watched_by;
143+
if (!wb) return 0;
144+
const parsed = typeof wb === 'string' ? JSON.parse(wb) : wb;
145+
return Array.isArray(parsed) ? parsed.length : 0;
146+
} catch { return 0; }
147+
}
124148
default:
125149
// Direct field access
126150
if (field in item) {
@@ -357,7 +381,117 @@ router.get('/suggestions', async (_req: Request, res: Response) => {
357381
});
358382
}
359383

360-
// 7. Low play count (watched 2 or fewer times, 90+ days ago)
384+
// 7. Low Quality Files (SD and 720p content)
385+
const lowQuality = items.filter((item) => {
386+
const res = String(item.resolution || '');
387+
let resNum = 0;
388+
if (res.toLowerCase().includes('4k') || res.includes('2160')) resNum = 2160;
389+
else {
390+
const m = res.match(/(\d+)/);
391+
resNum = m && m[1] ? parseInt(m[1], 10) : 0;
392+
}
393+
return resNum > 0 && resNum < 1080;
394+
});
395+
if (lowQuality.length > 0) {
396+
const size = lowQuality.reduce((sum, i) => sum + (i.file_size || 0), 0);
397+
suggestions.push({
398+
id: 'low-quality',
399+
name: 'Low Quality Files',
400+
description: 'Remove SD and 720p content to save space',
401+
icon: 'monitor',
402+
matchCount: lowQuality.length,
403+
totalSize: size,
404+
totalSizeFormatted: formatBytes(size),
405+
conditions: [
406+
{ field: 'resolution_number', operator: 'less_than', value: 1080 },
407+
],
408+
mediaType: 'all',
409+
});
410+
}
411+
412+
// 8. Old Codec Cleanup (H.264 or similar)
413+
const oldCodec = items.filter((item) => {
414+
if (item.type !== 'movie') return false;
415+
const codec = (item.codec || '').toLowerCase();
416+
return codec.includes('h264') || codec.includes('h.264');
417+
});
418+
if (oldCodec.length > 0) {
419+
const size = oldCodec.reduce((sum, i) => sum + (i.file_size || 0), 0);
420+
suggestions.push({
421+
id: 'old-codec',
422+
name: 'Old Codec Cleanup',
423+
description: 'Remove files using older codecs like H.264 or MPEG',
424+
icon: 'film',
425+
matchCount: oldCodec.length,
426+
totalSize: size,
427+
totalSizeFormatted: formatBytes(size),
428+
conditions: [
429+
{ field: 'codec', operator: 'contains', value: 'h264' },
430+
],
431+
mediaType: 'movie',
432+
});
433+
}
434+
435+
// 9. Classic Movies Never Rewatched (before 2015, not watched in 180+ days)
436+
const classicNeverRewatched = items.filter((item) => {
437+
if (item.type !== 'movie') return false;
438+
if (!item.year || item.year >= 2015) return false;
439+
if (!item.last_watched_at) return true; // Never watched counts
440+
const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24));
441+
return days > 180;
442+
});
443+
if (classicNeverRewatched.length > 0) {
444+
const size = classicNeverRewatched.reduce((sum, i) => sum + (i.file_size || 0), 0);
445+
suggestions.push({
446+
id: 'classic-never-rewatched',
447+
name: 'Classic Movies Never Rewatched',
448+
description: "Movies released before 2015 that haven't been watched recently",
449+
icon: 'clock',
450+
matchCount: classicNeverRewatched.length,
451+
totalSize: size,
452+
totalSizeFormatted: formatBytes(size),
453+
conditions: [
454+
{ field: 'year', operator: 'less_than', value: 2015 },
455+
{ field: 'days_since_watched', operator: 'greater_than', value: 180 },
456+
],
457+
mediaType: 'movie',
458+
});
459+
}
460+
461+
// 10. Large 4K Files Watched Once (4K, play_count = 1, 20GB+)
462+
const large4kWatchedOnce = items.filter((item) => {
463+
if (item.type !== 'movie') return false;
464+
if (item.play_count !== 1) return false;
465+
if (!item.file_size || item.file_size < 20 * 1024 * 1024 * 1024) return false; // 20GB
466+
const res = String(item.resolution || '');
467+
let resNum = 0;
468+
if (res.toLowerCase().includes('4k') || res.includes('2160')) resNum = 2160;
469+
else {
470+
const m = res.match(/(\d+)/);
471+
resNum = m && m[1] ? parseInt(m[1], 10) : 0;
472+
}
473+
return resNum > 2000;
474+
});
475+
if (large4kWatchedOnce.length > 0) {
476+
const size = large4kWatchedOnce.reduce((sum, i) => sum + (i.file_size || 0), 0);
477+
suggestions.push({
478+
id: 'large-4k-watched-once',
479+
name: 'Large 4K Files Watched Once',
480+
description: 'Large 4K files that have only been watched once',
481+
icon: 'hard-drive',
482+
matchCount: large4kWatchedOnce.length,
483+
totalSize: size,
484+
totalSizeFormatted: formatBytes(size),
485+
conditions: [
486+
{ field: 'resolution_number', operator: 'greater_than', value: 2000 },
487+
{ field: 'play_count', operator: 'equals', value: 1 },
488+
{ field: 'size_gb', operator: 'greater_than', value: 20 },
489+
],
490+
mediaType: 'movie',
491+
});
492+
}
493+
494+
// 11. Low play count (watched 2 or fewer times, 90+ days ago)
361495
const lowPlayCount = items.filter((item) => {
362496
if (item.play_count > 2) return false;
363497
if (!item.last_watched_at && !item.added_at) return false;

0 commit comments

Comments
 (0)