nextav/docs/active/library-clusters/LIBRARY_CLUSTER_IMPLEMENTAT...

801 lines
20 KiB
Markdown

# Library Cluster Implementation Guide
## 📋 Task Breakdown & Implementation Order
This guide provides step-by-step instructions for implementing the Library Cluster feature.
---
## Phase 1: Database & Backend Foundation
### Task 1.1: Database Schema Migration
**Estimated Time**: 1 hour
**Files to Create/Modify**:
- `/src/db/migrations/001_add_library_clusters.sql` (create)
- `/src/db/index.ts` (modify)
#### Steps:
1. Create migration SQL file with cluster tables
2. Update `initializeDatabase()` function to run migrations
3. Add TypeScript interfaces for Cluster and mapping types
4. Create database helper functions for cluster operations
#### Code Snippets:
**Migration SQL**:
```sql
-- Create clusters table
CREATE TABLE IF NOT EXISTS clusters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT 'folder',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Create mapping table
CREATE TABLE IF NOT EXISTS library_cluster_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL,
cluster_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (library_id) REFERENCES libraries(id) ON DELETE CASCADE,
FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE,
UNIQUE(library_id, cluster_id)
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_library_cluster_mapping_library ON library_cluster_mapping(library_id);
CREATE INDEX IF NOT EXISTS idx_library_cluster_mapping_cluster ON library_cluster_mapping(cluster_id);
CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name);
```
**TypeScript Interfaces**:
```typescript
export interface Cluster {
id: number;
name: string;
description?: string;
color: string;
icon: string;
created_at: string;
updated_at: string;
}
export interface ClusterWithStats extends Cluster {
library_count: number;
media_count: number;
video_count: number;
photo_count: number;
text_count: number;
total_size: number;
}
export interface LibraryClusterMapping {
id: number;
library_id: number;
cluster_id: number;
created_at: string;
}
```
**Verification**:
- [ ] Tables created successfully
- [ ] Indexes created
- [ ] Foreign keys working
- [ ] No errors in console
---
### Task 1.2: Cluster Management APIs
**Estimated Time**: 2 hours
**Files to Create**:
- `/src/app/api/clusters/route.ts`
- `/src/app/api/clusters/[id]/route.ts`
#### GET /api/clusters
Returns all clusters with statistics.
```typescript
// /src/app/api/clusters/route.ts
import { NextResponse } from 'next/server';
import { getDatabase } from '@/db';
export async function GET() {
const db = getDatabase();
const clusters = db.prepare(`
SELECT
c.*,
COUNT(DISTINCT lcm.library_id) as library_count,
COUNT(DISTINCT CASE WHEN m.type = 'video' THEN m.id END) as video_count,
COUNT(DISTINCT CASE WHEN m.type = 'photo' THEN m.id END) as photo_count,
COUNT(DISTINCT CASE WHEN m.type = 'text' THEN m.id END) as text_count,
COALESCE(SUM(m.size), 0) as total_size
FROM clusters c
LEFT JOIN library_cluster_mapping lcm ON c.id = lcm.cluster_id
LEFT JOIN libraries l ON lcm.library_id = l.id
LEFT JOIN media m ON l.id = m.library_id
GROUP BY c.id
ORDER BY c.name
`).all();
return NextResponse.json(clusters);
}
```
#### POST /api/clusters
Creates a new cluster.
```typescript
export async function POST(request: Request) {
const db = getDatabase();
const { name, description, color, icon } = await request.json();
// Validation
if (!name || name.trim().length === 0) {
return NextResponse.json(
{ error: 'Cluster name is required' },
{ status: 400 }
);
}
if (name.length > 100) {
return NextResponse.json(
{ error: 'Cluster name must be 100 characters or less' },
{ status: 400 }
);
}
try {
const result = db.prepare(`
INSERT INTO clusters (name, description, color, icon)
VALUES (?, ?, ?, ?)
`).run(
name.trim(),
description || null,
color || '#6366f1',
icon || 'folder'
);
const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?')
.get(result.lastInsertRowid);
return NextResponse.json(cluster, { status: 201 });
} catch (error: any) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return NextResponse.json(
{ error: 'A cluster with this name already exists' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: error.message },
{ status: 500 }
);
}
}
```
#### GET /api/clusters/[id]
Get cluster details with libraries.
```typescript
// /src/app/api/clusters/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
const clusterId = parseInt(id);
if (isNaN(clusterId)) {
return NextResponse.json(
{ error: 'Invalid cluster ID' },
{ status: 400 }
);
}
const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?')
.get(clusterId);
if (!cluster) {
return NextResponse.json(
{ error: 'Cluster not found' },
{ status: 404 }
);
}
const libraries = db.prepare(`
SELECT l.*, lcm.created_at as assigned_at
FROM libraries l
INNER JOIN library_cluster_mapping lcm ON l.id = lcm.library_id
WHERE lcm.cluster_id = ?
ORDER BY l.path
`).all(clusterId);
return NextResponse.json({ cluster, libraries });
}
```
#### PUT /api/clusters/[id]
Update cluster metadata.
```typescript
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
const clusterId = parseInt(id);
const { name, description, color, icon } = await request.json();
if (isNaN(clusterId)) {
return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 });
}
try {
const updates: string[] = [];
const values: any[] = [];
if (name !== undefined) {
updates.push('name = ?');
values.push(name.trim());
}
if (description !== undefined) {
updates.push('description = ?');
values.push(description);
}
if (color !== undefined) {
updates.push('color = ?');
values.push(color);
}
if (icon !== undefined) {
updates.push('icon = ?');
values.push(icon);
}
updates.push('updated_at = CURRENT_TIMESTAMP');
values.push(clusterId);
const result = db.prepare(`
UPDATE clusters
SET ${updates.join(', ')}
WHERE id = ?
`).run(...values);
if (result.changes === 0) {
return NextResponse.json({ error: 'Cluster not found' }, { status: 404 });
}
const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?').get(clusterId);
return NextResponse.json(cluster);
} catch (error: any) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return NextResponse.json(
{ error: 'A cluster with this name already exists' },
{ status: 409 }
);
}
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
```
#### DELETE /api/clusters/[id]
Delete a cluster (cascade deletes mappings).
```typescript
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
const clusterId = parseInt(id);
if (isNaN(clusterId)) {
return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 });
}
const result = db.prepare('DELETE FROM clusters WHERE id = ?').run(clusterId);
if (result.changes === 0) {
return NextResponse.json({ error: 'Cluster not found' }, { status: 404 });
}
return NextResponse.json({
message: 'Cluster deleted successfully',
deleted_mappings: result.changes
});
}
```
**Verification**:
- [ ] Can create cluster via API
- [ ] Can list all clusters
- [ ] Can get single cluster details
- [ ] Can update cluster
- [ ] Can delete cluster
- [ ] Error handling works
---
### Task 1.3: Library-Cluster Mapping APIs
**Estimated Time**: 1 hour
**Files to Create**:
- `/src/app/api/clusters/[id]/libraries/route.ts`
- `/src/app/api/clusters/[id]/libraries/[libraryId]/route.ts`
#### POST /api/clusters/[id]/libraries
Assign libraries to a cluster.
```typescript
// /src/app/api/clusters/[id]/libraries/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/db';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { libraryIds } = await request.json();
const db = getDatabase();
const clusterId = parseInt(id);
if (isNaN(clusterId)) {
return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 });
}
if (!Array.isArray(libraryIds) || libraryIds.length === 0) {
return NextResponse.json(
{ error: 'libraryIds must be a non-empty array' },
{ status: 400 }
);
}
// Check cluster exists
const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?').get(clusterId);
if (!cluster) {
return NextResponse.json({ error: 'Cluster not found' }, { status: 404 });
}
let added = 0;
const errors: string[] = [];
for (const libraryId of libraryIds) {
try {
db.prepare(`
INSERT INTO library_cluster_mapping (library_id, cluster_id)
VALUES (?, ?)
`).run(libraryId, clusterId);
added++;
} catch (error: any) {
if (error.code !== 'SQLITE_CONSTRAINT_UNIQUE') {
errors.push(`Library ${libraryId}: ${error.message}`);
}
// Skip if already mapped
}
}
return NextResponse.json({
added,
total: libraryIds.length,
errors: errors.length > 0 ? errors : undefined
});
}
```
#### GET /api/clusters/[id]/libraries
Get libraries for a cluster.
```typescript
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
const clusterId = parseInt(id);
if (isNaN(clusterId)) {
return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 });
}
const libraries = db.prepare(`
SELECT l.*, lcm.created_at as assigned_at
FROM libraries l
INNER JOIN library_cluster_mapping lcm ON l.id = lcm.library_id
WHERE lcm.cluster_id = ?
ORDER BY l.path
`).all(clusterId);
return NextResponse.json(libraries);
}
```
#### DELETE /api/clusters/[id]/libraries/[libraryId]
Remove library from cluster.
```typescript
// /src/app/api/clusters/[id]/libraries/[libraryId]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/db';
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string, libraryId: string }> }
) {
const { id, libraryId } = await params;
const db = getDatabase();
const clusterId = parseInt(id);
const libId = parseInt(libraryId);
if (isNaN(clusterId) || isNaN(libId)) {
return NextResponse.json({ error: 'Invalid IDs' }, { status: 400 });
}
const result = db.prepare(`
DELETE FROM library_cluster_mapping
WHERE cluster_id = ? AND library_id = ?
`).run(clusterId, libId);
if (result.changes === 0) {
return NextResponse.json(
{ error: 'Mapping not found' },
{ status: 404 }
);
}
return NextResponse.json({ message: 'Library removed from cluster' });
}
```
**Verification**:
- [ ] Can assign libraries to cluster
- [ ] Can list cluster libraries
- [ ] Can remove library from cluster
- [ ] Duplicate assignments handled gracefully
---
### Task 1.4: Cluster Media Query APIs
**Estimated Time**: 2 hours
**Files to Create**:
- `/src/app/api/clusters/[id]/videos/route.ts`
- `/src/app/api/clusters/[id]/photos/route.ts`
- `/src/app/api/clusters/[id]/texts/route.ts`
- `/src/app/api/clusters/[id]/stats/route.ts`
#### GET /api/clusters/[id]/videos
Get all videos from cluster libraries.
```typescript
// /src/app/api/clusters/[id]/videos/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const db = getDatabase();
const clusterId = parseInt(id);
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
const search = searchParams.get('search');
if (isNaN(clusterId)) {
return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 });
}
// Check cluster exists
const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?').get(clusterId);
if (!cluster) {
return NextResponse.json({ error: 'Cluster not found' }, { status: 404 });
}
let query = `
SELECT m.*, l.path as library_path
FROM media m
INNER JOIN libraries l ON m.library_id = l.id
INNER JOIN library_cluster_mapping lcm ON l.id = lcm.library_id
WHERE lcm.cluster_id = ? AND m.type = 'video'
`;
const params: any[] = [clusterId];
if (search) {
query += ' AND (m.title LIKE ? OR m.path LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
}
query += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const videos = db.prepare(query).all(...params);
// Get total count
let countQuery = `
SELECT COUNT(*) as count
FROM media m
INNER JOIN libraries l ON m.library_id = l.id
INNER JOIN library_cluster_mapping lcm ON l.id = lcm.library_id
WHERE lcm.cluster_id = ? AND m.type = 'video'
`;
const countParams: any[] = [clusterId];
if (search) {
countQuery += ' AND (m.title LIKE ? OR m.path LIKE ?)';
countParams.push(`%${search}%`, `%${search}%`);
}
const { count } = db.prepare(countQuery).get(...countParams) as { count: number };
return NextResponse.json({
videos,
total: count,
limit,
offset,
hasMore: offset + videos.length < count
});
}
```
#### GET /api/clusters/[id]/stats
Get cluster statistics.
```typescript
// /src/app/api/clusters/[id]/stats/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
const clusterId = parseInt(id);
if (isNaN(clusterId)) {
return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 });
}
const stats = db.prepare(`
SELECT
COUNT(DISTINCT l.id) as library_count,
COUNT(DISTINCT CASE WHEN m.type = 'video' THEN m.id END) as video_count,
COUNT(DISTINCT CASE WHEN m.type = 'photo' THEN m.id END) as photo_count,
COUNT(DISTINCT CASE WHEN m.type = 'text' THEN m.id END) as text_count,
COUNT(DISTINCT m.id) as total_media,
COALESCE(SUM(m.size), 0) as total_size
FROM library_cluster_mapping lcm
LEFT JOIN libraries l ON lcm.library_id = l.id
LEFT JOIN media m ON l.id = m.library_id
WHERE lcm.cluster_id = ?
`).get(clusterId);
return NextResponse.json(stats);
}
```
**Similar implementations for**:
- `/api/clusters/[id]/photos/route.ts`
- `/api/clusters/[id]/texts/route.ts`
**Verification**:
- [ ] Can query videos by cluster
- [ ] Can query photos by cluster
- [ ] Can query texts by cluster
- [ ] Statistics accurate
- [ ] Pagination works
- [ ] Search filtering works
---
## Phase 2: Settings UI Implementation
### Task 2.1: Cluster Management Component
**Estimated Time**: 3 hours
**Files to Create/Modify**:
- `/src/components/cluster-management.tsx` (create)
- `/src/app/settings/page.tsx` (modify)
#### Steps:
1. Create cluster list component
2. Create cluster form (create/edit)
3. Add color picker
4. Add icon selector
5. Integrate into settings page
#### Component Structure:
```typescript
// /src/components/cluster-management.tsx
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
interface Cluster {
id: number;
name: string;
description?: string;
color: string;
icon: string;
library_count?: number;
video_count?: number;
photo_count?: number;
}
const COLORS = [
{ name: 'Indigo', value: '#6366f1' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Green', value: '#10b981' },
{ name: 'Red', value: '#ef4444' },
{ name: 'Purple', value: '#a855f7' },
{ name: 'Pink', value: '#ec4899' },
];
const ICONS = ['folder', 'film', 'tv', 'image', 'book'];
export function ClusterManagement() {
const [clusters, setClusters] = useState<Cluster[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [newCluster, setNewCluster] = useState({
name: '',
description: '',
color: '#6366f1',
icon: 'folder'
});
// Implementation here...
}
```
**Verification**:
- [ ] Can create clusters via UI
- [ ] Can edit cluster details
- [ ] Can delete clusters
- [ ] Color picker works
- [ ] Icon selector works
- [ ] Form validation works
---
### Task 2.2: Library Assignment UI
**Estimated Time**: 2 hours
**Files to Create**:
- `/src/components/library-cluster-assignment.tsx`
#### Features:
- Multi-select library picker
- Show current assignments
- Bulk assign/unassign
**Verification**:
- [ ] Can select multiple libraries
- [ ] Can assign to cluster
- [ ] Can remove from cluster
- [ ] Visual feedback for assignments
---
### Task 2.3: Update Library Cards
**Estimated Time**: 1 hour
**Files to Modify**:
- `/src/app/settings/page.tsx`
#### Changes:
- Show cluster badges on library cards
- Quick cluster assignment button
**Verification**:
- [ ] Cluster badges visible
- [ ] Quick assignment works
---
## Phase 3: Navigation & Viewing
### Task 3.1: Update Sidebar
**Estimated Time**: 2 hours
**Files to Modify**:
- `/src/components/sidebar.tsx`
#### Features:
- Add "Clusters" section
- List all clusters
- Color-coded indicators
- Click to navigate
**Verification**:
- [ ] Clusters section visible
- [ ] Clusters listed correctly
- [ ] Colors displayed
- [ ] Navigation works
---
### Task 3.2: Cluster View Page
**Estimated Time**: 3 hours
**Files to Create**:
- `/src/app/clusters/[id]/page.tsx`
#### Features:
- Display cluster header
- Tabbed interface (Videos/Photos/Stats)
- Reuse existing media grid components
- Pagination
**Verification**:
- [ ] Page loads correctly
- [ ] Media displays from all libraries
- [ ] Tabs work
- [ ] Pagination works
---
## Testing Checklist
### Manual Testing
- [ ] Create cluster
- [ ] Assign libraries to cluster
- [ ] View cluster media
- [ ] Edit cluster
- [ ] Delete cluster
- [ ] Remove library from cluster
- [ ] Navigate via sidebar
### Edge Cases
- [ ] Empty cluster (no libraries)
- [ ] Cluster with no media
- [ ] Very long cluster names
- [ ] Special characters in names
- [ ] Concurrent modifications
### Performance Testing
- [ ] 10+ libraries per cluster
- [ ] 1000+ media items
- [ ] Multiple clusters loaded
---
## Deployment Checklist
- [ ] Database migration tested locally
- [ ] All API endpoints tested
- [ ] UI components tested
- [ ] Documentation updated
- [ ] Migration script ready
- [ ] Rollback plan prepared
- [ ] Monitoring configured
---
## Documentation Updates
- [ ] Update README.md
- [ ] Create user guide
- [ ] Update API documentation
- [ ] Add troubleshooting guide
---
**Next Steps**: Begin with Phase 1, Task 1.1 (Database Schema Migration)