📋 Pla Professional de Millora - Importació YouTube
Data: 2026-03-14
Versió: 1.0
Estat: Planificació
📌 Resum Executiu
Aquest document defineix un pla rigorós per estabilitzar i millorar la funcionalitat d'importació de balls des de YouTube (/admin/import/youtube). L'objectiu és transformar-la d'una eina tècnica a una interfície de producció per a usuaris que importin balls de forma sistemàtica i controlada.
Objectius Clau
- Registre de canals YouTube amb traçabilitat d'importacions
- Interfície d'usuari de qualitat clara i professional
- Estabilitat i robustesa en el procés d'importació
- Visibilitat del progrés per saber què s'ha importat i què falta
🔍 Anàlisi de l'Estat Actual
Funcionalitat Existent
| Component | Estat | Observacions |
|---|---|---|
YouTubeImportPage.tsx | ✅ Funcional | ~700 línies, flux complet input→review→confirm |
DanceImportController.java | ✅ Funcional | 3 endpoints: status, analyze, confirm |
DanceImportService.java | ✅ Funcional | ~500 línies, lògica completa |
YouTubeService.java | ✅ Funcional | Només getVideoMetadata(), falten mètodes per llistar canals |
GeminiAiService.java | ✅ Funcional | Parsing AI via OpenRouter |
MusicImportModal.tsx | ✅ Funcional | Modal iTunes/Spotify independent |
ChoreographerSelector.tsx | ✅ Funcional | Selector múltiple amb candidats |
Flux Actual (Un a Un)
[URL YouTube] → Analitza → Revisa draft → Selecciona cançó → Confirma → Ball creat
Dades Actuals (14/03/2026)
- 4.132 balls importats
- 1 canal processat: Catalan Honky Tonk Friends
- 868 vídeos d'aquest canal (fins 2026-02-02)
Mancances Identificades
- ❌ No hi ha registre de canals - No es pot saber fins on s'ha importat
- ❌ No hi ha llistat de vídeos nous - Cal anar un a un manualment
- ❌ No hi ha visibilitat del progrés - No es veu què falta per importar
- ❌ UI poc professional - Funciona però no és per a "producció"
- ❌ No hi ha historial - No es pot auditar què s'ha fet
🎯 Arquitectura Proposada
Visió General
┌─────────────────────────────────────────────────────────────────┐
│ YouTubeImportPage.tsx │
├─────────────────────────────────────────────────────────────────┤
│ ┌──────────────────┐ ┌──────────────────────────────────────┐│
│ │ ChannelListPanel │ │ ImportWorkspacePanel ││
│ │ ─────────────────│ │ ─────────────────────────────────────││
│ │ • CHTF (868) │ │ Mode: Manual / Canal ││
│ │ └─ 12 pendents │ │ ┌─────────────────────────────────┐ ││
│ │ • Afegir canal │ │ │ VideoQueueList (si mode canal) │ ││
│ │ │ │ │ • Video 1 ⏳ │ ││
│ │ │ │ │ • Video 2 ✅ │ ││
│ │ │ │ │ • Video 3 ❌ error │ ││
│ │ │ │ └─────────────────────────────────┘ ││
│ │ │ │ ┌─────────────────────────────────┐ ││
│ │ │ │ │ ImportForm (un vídeo actiu) │ ││
│ │ │ │ │ • Dades extretes │ ││
│ │ │ │ │ • Coreògrafs detectats │ ││
│ │ │ │ │ • Selector música │ ││
│ │ │ │ │ • Duplicats detectats │ ││
│ │ │ │ └─────────────────────────────────┘ ││
│ └──────────────────┘ └──────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
📦 Fase 1: Gestió de Canals (Backend)
Durada estimada: 2-3 dies
Prioritat: ALTA - És la base de tot
1.1 Migració SQL
Fitxer: V58__youtube_channel_imports.sql
-- Registre d'importacions per canal YouTube
CREATE TABLE youtube_channel_imports (
id SERIAL PRIMARY KEY,
channel_id VARCHAR(50) NOT NULL UNIQUE,
channel_name VARCHAR(200) NOT NULL,
channel_url VARCHAR(500),
thumbnail_url VARCHAR(500),
-- Control d'importació
last_video_published_at TIMESTAMPTZ,
last_import_at TIMESTAMPTZ,
videos_imported_count INTEGER DEFAULT 0,
videos_total_count INTEGER,
-- Configuració
auto_import_enabled BOOLEAN DEFAULT FALSE,
default_video_type VARCHAR(50) DEFAULT 'YOUTUBE_TEACH',
notes TEXT,
-- Auditoria
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(100),
CONSTRAINT chk_channel_id_format CHECK (channel_id ~ '^UC[a-zA-Z0-9_-]{22}$')
);
-- Índexs
CREATE INDEX idx_channel_imports_channel_id ON youtube_channel_imports(channel_id);
CREATE INDEX idx_channel_imports_last_import ON youtube_channel_imports(last_import_at);
-- Dades inicials
INSERT INTO youtube_channel_imports
(channel_id, channel_name, channel_url, last_video_published_at, last_import_at, videos_imported_count, created_by)
VALUES
('UCi-EHWXp4Dh2RhE3krdbUew',
'Catalan Honky Tonk Friends',
'https://www.youtube.com/@CatalanHonkyTonkFriends',
'2026-02-02T10:34:51Z',
'2026-02-16T23:08:16Z',
868,
'system-migration');
-- Taula d'historial d'importacions (opcional però recomanat)
CREATE TABLE youtube_import_history (
id SERIAL PRIMARY KEY,
channel_import_id INTEGER REFERENCES youtube_channel_imports(id),
video_id VARCHAR(20) NOT NULL,
video_title VARCHAR(500),
video_published_at TIMESTAMPTZ,
dance_id INTEGER REFERENCES dances(id),
import_status VARCHAR(50) NOT NULL, -- PENDING, SUCCESS, DUPLICATE, ERROR, SKIPPED
error_message TEXT,
imported_at TIMESTAMPTZ DEFAULT NOW(),
imported_by VARCHAR(100),
CONSTRAINT uk_import_history_video UNIQUE(video_id)
);
CREATE INDEX idx_import_history_channel ON youtube_import_history(channel_import_id);
CREATE INDEX idx_import_history_status ON youtube_import_history(import_status);
1.2 Entitat JPA
Fitxer: domain/YouTubeChannelImport.java
@Entity
@Table(name = "youtube_channel_imports")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class YouTubeChannelImport {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "channel_id", nullable = false, unique = true, length = 50)
private String channelId;
@Column(name = "channel_name", nullable = false, length = 200)
private String channelName;
@Column(name = "channel_url", length = 500)
private String channelUrl;
@Column(name = "thumbnail_url", length = 500)
private String thumbnailUrl;
@Column(name = "last_video_published_at")
private Instant lastVideoPublishedAt;
@Column(name = "last_import_at")
private Instant lastImportAt;
@Column(name = "videos_imported_count")
private Integer videosImportedCount = 0;
@Column(name = "videos_total_count")
private Integer videosTotalCount;
@Column(name = "auto_import_enabled")
private Boolean autoImportEnabled = false;
@Column(name = "default_video_type", length = 50)
private String defaultVideoType = "YOUTUBE_TEACH";
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;
@Column(name = "created_at")
private Instant createdAt;
@Column(name = "updated_at")
private Instant updatedAt;
@Column(name = "created_by", length = 100)
private String createdBy;
@PrePersist
void prePersist() {
createdAt = Instant.now();
updatedAt = createdAt;
}
@PreUpdate
void preUpdate() {
updatedAt = Instant.now();
}
}
1.3 Repository
Fitxer: repo/YouTubeChannelImportRepository.java
@Repository
public interface YouTubeChannelImportRepository extends JpaRepository<YouTubeChannelImport, Long> {
Optional<YouTubeChannelImport> findByChannelId(String channelId);
List<YouTubeChannelImport> findAllByOrderByLastImportAtDesc();
@Query("SELECT c FROM YouTubeChannelImport c WHERE c.autoImportEnabled = true")
List<YouTubeChannelImport> findAutoImportEnabled();
boolean existsByChannelId(String channelId);
}
1.4 Ampliació YouTubeService
Fitxer: service/YouTubeService.java (afegir mètodes)
/**
* Obté informació d'un canal de YouTube.
*/
public Optional<ChannelInfo> getChannelInfo(String channelId) {
if (!enabled) return Optional.empty();
String url = String.format(
"https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics&id=%s&key=%s",
channelId, apiKey
);
// ... implementació similar a getVideoMetadata
}
/**
* Llista vídeos d'un canal publicats després d'una data.
* Ordena per data de publicació (més antic primer per processar en ordre).
*/
public List<VideoListItem> getChannelVideos(String channelId, Instant publishedAfter, int maxResults) {
if (!enabled) return List.of();
List<VideoListItem> allVideos = new ArrayList<>();
String pageToken = null;
do {
String url = String.format(
"https://www.googleapis.com/youtube/v3/search" +
"?channelId=%s&type=video&order=date&maxResults=%d" +
"&publishedAfter=%s&part=snippet&key=%s%s",
channelId,
Math.min(maxResults - allVideos.size(), 50),
publishedAfter.toString(),
apiKey,
pageToken != null ? "&pageToken=" + pageToken : ""
);
// Fer petició i parsejar resposta
// ... implementació
pageToken = extractNextPageToken(response);
} while (pageToken != null && allVideos.size() < maxResults);
// Ordenar per data (més antic primer)
allVideos.sort(Comparator.comparing(VideoListItem::publishedAt));
return allVideos;
}
/**
* Obté el nombre total de vídeos d'un canal.
*/
public Optional<Integer> getChannelVideoCount(String channelId) {
return getChannelInfo(channelId)
.map(ChannelInfo::videoCount);
}
// Records per les respostes
public record ChannelInfo(
String channelId,
String name,
String description,
String thumbnailUrl,
Integer videoCount,
Integer subscriberCount
) {}
public record VideoListItem(
String videoId,
String title,
String description,
String thumbnailUrl,
Instant publishedAt,
String channelTitle
) {}
1.5 Nous Endpoints
Fitxer: api/controller/DanceImportController.java (ampliar)
// === GESTIÓ DE CANALS ===
@GetMapping("/channels")
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@Operation(summary = "Llista tots els canals registrats")
public ResponseEntity<List<ChannelSummaryDto>> listChannels() {
return ResponseEntity.ok(channelService.listAllChannels());
}
@GetMapping("/channels/{channelId}")
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@Operation(summary = "Obté detalls d'un canal")
public ResponseEntity<ChannelDetailDto> getChannel(@PathVariable String channelId) {
return ResponseEntity.ok(channelService.getChannelDetails(channelId));
}
@PostMapping("/channels")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Registra un nou canal")
public ResponseEntity<ChannelSummaryDto> registerChannel(
@Valid @RequestBody RegisterChannelRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(channelService.registerChannel(request));
}
@PutMapping("/channels/{channelId}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Actualitza configuració d'un canal")
public ResponseEntity<ChannelSummaryDto> updateChannel(
@PathVariable String channelId,
@Valid @RequestBody UpdateChannelRequest request) {
return ResponseEntity.ok(channelService.updateChannel(channelId, request));
}
// === VÍDEOS PENDENTS ===
@GetMapping("/channels/{channelId}/pending-videos")
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@Operation(summary = "Llista vídeos nous pendents d'importar")
public ResponseEntity<PendingVideosResponse> getPendingVideos(
@PathVariable String channelId,
@RequestParam(defaultValue = "50") int limit) {
return ResponseEntity.ok(channelService.getPendingVideos(channelId, limit));
}
@PostMapping("/channels/{channelId}/refresh")
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@Operation(summary = "Actualitza la llista de vídeos pendents del canal")
public ResponseEntity<RefreshResult> refreshChannel(@PathVariable String channelId) {
return ResponseEntity.ok(channelService.refreshChannelData(channelId));
}
// === IMPORTACIÓ ===
@PostMapping("/channels/{channelId}/import-video")
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@Operation(summary = "Importa un vídeo específic del canal")
public ResponseEntity<DanceImportResultDto> importChannelVideo(
@PathVariable String channelId,
@Valid @RequestBody ImportChannelVideoRequest request) {
return ResponseEntity.ok(channelService.importVideo(channelId, request));
}
@PostMapping("/channels/{channelId}/mark-imported")
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@Operation(summary = "Marca un vídeo com a importat sense crear ball (ja existeix)")
public ResponseEntity<Void> markAsImported(
@PathVariable String channelId,
@RequestBody MarkImportedRequest request) {
channelService.markVideoAsImported(channelId, request);
return ResponseEntity.ok().build();
}
1.6 DTOs Nous
// ChannelSummaryDto.java
public record ChannelSummaryDto(
Long id,
String channelId,
String channelName,
String channelUrl,
String thumbnailUrl,
Instant lastVideoPublishedAt,
Instant lastImportAt,
Integer videosImportedCount,
Integer videosTotalCount,
Integer pendingVideosCount,
Boolean autoImportEnabled
) {}
// ChannelDetailDto.java
public record ChannelDetailDto(
ChannelSummaryDto summary,
List<RecentImportDto> recentImports,
ChannelStatsDto stats
) {}
// PendingVideosResponse.java
public record PendingVideosResponse(
String channelId,
String channelName,
List<PendingVideoDto> videos,
Integer totalPending,
Instant lastCheckedAt
) {}
// PendingVideoDto.java
public record PendingVideoDto(
String videoId,
String title,
String thumbnailUrl,
Instant publishedAt,
String channelTitle,
ImportStatusHint statusHint // READY, LIKELY_DUPLICATE, ALREADY_IMPORTED
) {}
// RegisterChannelRequest.java
public record RegisterChannelRequest(
@NotBlank String channelId,
String channelName, // Opcional, s'obté de l'API si no es proporciona
Instant importFromDate // Data des de quan importar (opcional)
) {}
🎨 Fase 2: Interfície d'Usuari (Frontend)
Durada estimada: 3-4 dies
Prioritat: ALTA
2.1 Reestructuració YouTubeImportPage
Nova estructura de fitxers:
frontend/src/
├── pages/
│ └── YouTubeImportPage.tsx # Contenidor principal
├── components/
│ └── youtube-import/
│ ├── ChannelListPanel.tsx # Panel lateral amb canals
│ ├── ChannelCard.tsx # Targeta d'un canal
│ ├── AddChannelModal.tsx # Modal per afegir canal
│ ├── PendingVideosList.tsx # Llista de vídeos pendents
│ ├── VideoQueueItem.tsx # Item de la cua
│ ├── ImportWorkspace.tsx # Àrea principal d'importació
│ ├── ImportDraftForm.tsx # Formulari de revisió (existent)
│ ├── ImportProgress.tsx # Indicador de progrés
│ ├── ImportHistory.tsx # Historial recent
│ └── types.ts # Types compartits
└── api/
└── youtubeImport.ts # API client (ampliat)
2.2 Layout Principal
YouTubeImportPage.tsx (redisseny)
export default function YouTubeImportPage() {
// Estats principals
const [selectedChannel, setSelectedChannel] = useState<string | null>(null);
const [importMode, setImportMode] = useState<'manual' | 'channel'>('manual');
const [activeVideo, setActiveVideo] = useState<PendingVideo | null>(null);
return (
<Container size="xl" py="xl">
<Grid gutter="md">
{/* Panel lateral: Canals */}
<Grid.Col span={3}>
<ChannelListPanel
selectedChannel={selectedChannel}
onSelectChannel={(id) => {
setSelectedChannel(id);
setImportMode('channel');
}}
/>
</Grid.Col>
{/* Àrea principal */}
<Grid.Col span={9}>
<Stack gap="md">
{/* Header amb mode switch */}
<ImportModeHeader
mode={importMode}
onModeChange={setImportMode}
channel={selectedChannel}
/>
{/* Contingut segons mode */}
{importMode === 'manual' ? (
<ManualImportSection />
) : (
<ChannelImportSection
channelId={selectedChannel!}
activeVideo={activeVideo}
onVideoSelect={setActiveVideo}
/>
)}
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}
2.3 Panel de Canals
ChannelListPanel.tsx
export function ChannelListPanel({ selectedChannel, onSelectChannel }) {
const { data: channels, isLoading } = useQuery({
queryKey: ['import-channels'],
queryFn: fetchChannels,
});
const [addModalOpen, setAddModalOpen] = useState(false);
return (
<Card withBorder h="100%">
<Stack gap="md">
<Group justify="space-between">
<Title order={5}>📺 Canals</Title>
<ActionIcon onClick={() => setAddModalOpen(true)}>
<IconPlus size={16} />
</ActionIcon>
</Group>
<Divider />
{isLoading ? (
<Center><Loader size="sm" /></Center>
) : (
<Stack gap="xs">
{channels?.map(channel => (
<ChannelCard
key={channel.channelId}
channel={channel}
isSelected={selectedChannel === channel.channelId}
onClick={() => onSelectChannel(channel.channelId)}
/>
))}
</Stack>
)}
{/* Mode manual sempre disponible */}
<Divider label="Altres" />
<Button
variant="subtle"
leftSection={<IconLink size={16} />}
onClick={() => onSelectChannel(null)}
>
Importació manual (URL)
</Button>
</Stack>
<AddChannelModal
opened={addModalOpen}
onClose={() => setAddModalOpen(false)}
/>
</Card>
);
}
2.4 Targeta de Canal
ChannelCard.tsx
export function ChannelCard({ channel, isSelected, onClick }) {
const pendingCount = channel.pendingVideosCount ?? 0;
return (
<Card
withBorder
padding="sm"
className={isSelected ? 'selected' : ''}
onClick={onClick}
style={{ cursor: 'pointer' }}
>
<Group gap="sm" wrap="nowrap">
{channel.thumbnailUrl && (
<Avatar src={channel.thumbnailUrl} radius="sm" size="md" />
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{channel.channelName}
</Text>
<Group gap="xs">
<Badge size="xs" color="blue" variant="light">
{channel.videosImportedCount} importats
</Badge>
{pendingCount > 0 && (
<Badge size="xs" color="orange" variant="filled">
{pendingCount} pendents
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
Última: {formatRelativeDate(channel.lastImportAt)}
</Text>
</Stack>
</Group>
</Card>
);
}
2.5 Llista de Vídeos Pendents
PendingVideosList.tsx
export function PendingVideosList({ channelId, onVideoSelect, activeVideoId }) {
const { data, isLoading, refetch } = useQuery({
queryKey: ['pending-videos', channelId],
queryFn: () => fetchPendingVideos(channelId),
});
return (
<Card withBorder>
<Stack gap="sm">
<Group justify="space-between">
<Title order={5}>
🎬 Vídeos pendents ({data?.totalPending ?? 0})
</Title>
<Group gap="xs">
<Text size="xs" c="dimmed">
Última comprovació: {formatRelativeDate(data?.lastCheckedAt)}
</Text>
<ActionIcon onClick={() => refetch()} size="sm">
<IconRefresh size={14} />
</ActionIcon>
</Group>
</Group>
<ScrollArea h={300}>
<Stack gap="xs">
{data?.videos.map((video, index) => (
<VideoQueueItem
key={video.videoId}
video={video}
index={index + 1}
isActive={activeVideoId === video.videoId}
onClick={() => onVideoSelect(video)}
/>
))}
</Stack>
</ScrollArea>
{data?.totalPending > data?.videos.length && (
<Text size="xs" c="dimmed" ta="center">
Mostrant {data.videos.length} de {data.totalPending} vídeos
</Text>
)}
</Stack>
</Card>
);
}
2.6 Item de la Cua de Vídeos
VideoQueueItem.tsx
export function VideoQueueItem({ video, index, isActive, onClick }) {
const statusIcon = {
READY: <IconCircle size={14} color="green" />,
LIKELY_DUPLICATE: <IconAlertTriangle size={14} color="orange" />,
ALREADY_IMPORTED: <IconCheck size={14} color="gray" />,
}[video.statusHint];
return (
<Card
withBorder
padding="xs"
className={isActive ? 'active' : ''}
onClick={onClick}
style={{ cursor: 'pointer' }}
>
<Group gap="sm" wrap="nowrap">
<Text size="xs" c="dimmed" w={24} ta="center">
{index}
</Text>
{video.thumbnailUrl && (
<Image src={video.thumbnailUrl} w={80} h={45} radius="sm" />
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" lineClamp={1}>
{video.title}
</Text>
<Text size="xs" c="dimmed">
{formatDate(video.publishedAt)}
</Text>
</Stack>
{statusIcon}
</Group>
</Card>
);
}
2.7 Indicador de Progrés
ImportProgress.tsx
export function ImportProgress({ channel, session }) {
if (!session) return null;
const { imported, skipped, errors, total } = session;
const completed = imported + skipped + errors;
const progress = (completed / total) * 100;
return (
<Card withBorder padding="sm" bg="blue.0">
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" fw={500}>Progrés de la sessió</Text>
<Text size="sm">{completed} / {total}</Text>
</Group>
<Progress.Root size="lg">
<Progress.Section value={(imported / total) * 100} color="green">
<Progress.Label>{imported} importats</Progress.Label>
</Progress.Section>
<Progress.Section value={(skipped / total) * 100} color="gray">
<Progress.Label>{skipped} omesos</Progress.Label>
</Progress.Section>
<Progress.Section value={(errors / total) * 100} color="red">
<Progress.Label>{errors} errors</Progress.Label>
</Progress.Section>
</Progress.Root>
<Group gap="lg">
<Badge color="green" size="sm">✓ {imported} nous</Badge>
<Badge color="gray" size="sm">○ {skipped} ja existien</Badge>
<Badge color="red" size="sm">✗ {errors} errors</Badge>
</Group>
</Stack>
</Card>
);
}
🔒 Fase 3: Estabilitat i Robustesa
Durada estimada: 2-3 dies
Prioritat: ALTA
3.1 Gestió d'Errors
Backend: Errors específics
public class ImportException extends RuntimeException {
private final ImportErrorCode code;
private final Map<String, Object> details;
public enum ImportErrorCode {
VIDEO_NOT_FOUND,
VIDEO_ALREADY_IMPORTED,
YOUTUBE_API_ERROR,
AI_PARSING_FAILED,
DUPLICATE_DANCE_DETECTED,
SONG_NOT_SELECTED,
CHOREOGRAPHER_AMBIGUOUS,
VALIDATION_FAILED
}
}
Frontend: Toast amb context
const handleImportError = (error: ImportError) => {
const messages = {
VIDEO_ALREADY_IMPORTED: 'Aquest vídeo ja està importat',
YOUTUBE_API_ERROR: 'Error connectant amb YouTube. Reintenta més tard.',
AI_PARSING_FAILED: 'No s\'ha pogut analitzar el vídeo. Revisa manualment.',
DUPLICATE_DANCE_DETECTED: 'S\'ha detectat un ball similar. Revisa els duplicats.',
SONG_NOT_SELECTED: 'Has de seleccionar una cançó abans de confirmar.',
};
notifications.show({
title: 'Error d\'importació',
message: messages[error.code] || error.message,
color: 'red',
autoClose: 10000,
});
};
3.2 Validacions i Guards
Validacions crítiques:
- Abans d'analitzar: URL vàlida, no importat prèviament
- Abans de confirmar: Nom ball, coreògraf, cançó seleccionats
- Després d'importar: Actualitzar comptadors del canal
Implementació:
@Transactional
public DanceImportResultDto confirmImport(DanceImportConfirmRequest request) {
// 1. Validar input
validateRequest(request);
// 2. Verificar no duplicat (per videoId)
if (linkRepository.existsByVideoId(request.videoId())) {
throw new ImportException(VIDEO_ALREADY_IMPORTED);
}
// 3. Crear entitats amb savepoints
try {
var result = doImport(request);
// 4. Actualitzar canal si procedeix
if (request.channelId() != null) {
updateChannelStats(request.channelId(), request.videoPublishedAt());
}
return result;
} catch (Exception e) {
log.error("Import failed for video {}: {}", request.videoId(), e.getMessage());
throw new ImportException(IMPORT_FAILED, e);
}
}
3.3 Retry i Recuperació
Per crides a APIs externes (YouTube, AI):
@Retryable(
value = {YouTubeApiException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public VideoMetadata getVideoMetadata(String videoId) {
// ... implementació
}
Frontend: Retry manual:
<Button
onClick={() => analyzeMutation.mutate(request)}
loading={analyzeMutation.isPending}
disabled={analyzeMutation.failureCount >= 3}
>
{analyzeMutation.failureCount > 0 ?
`Reintentar (${3 - analyzeMutation.failureCount} intents restants)` :
'Analitzar'}
</Button>
📊 Fase 4: Monitorització i Auditoria
Durada estimada: 1-2 dies
Prioritat: MITJANA
4.1 Logs Estructurats
@Slf4j
public class DanceImportService {
public DanceImportResultDto confirm(DanceImportConfirmRequest request) {
var importId = UUID.randomUUID().toString().substring(0, 8);
log.info("[IMPORT-{}] START dance='{}' video='{}' channel='{}'",
importId, request.danceTitle(), request.videoId(), request.channelId());
try {
var result = doImport(request);
log.info("[IMPORT-{}] SUCCESS danceId={} choreographerCreated={} specCreated={}",
importId, result.danceId(), result.choreographerCreated(), result.specCreated());
return result;
} catch (Exception e) {
log.error("[IMPORT-{}] FAILED error='{}'", importId, e.getMessage(), e);
throw e;
}
}
}
4.2 Mètriques (Opcional)
@Component
@RequiredArgsConstructor
public class ImportMetrics {
private final MeterRegistry registry;
public void recordImport(String channel, boolean success) {
Counter.builder("dance_import_total")
.tag("channel", channel)
.tag("status", success ? "success" : "failure")
.register(registry)
.increment();
}
public void recordAnalyzeTime(String method, long durationMs) {
Timer.builder("dance_import_analyze_duration")
.tag("method", method) // "ai" o "regex"
.register(registry)
.record(durationMs, TimeUnit.MILLISECONDS);
}
}
4.3 Historial d'Importacions a la UI
function ImportHistoryPanel({ channelId }) {
const { data } = useQuery({
queryKey: ['import-history', channelId],
queryFn: () => fetchImportHistory(channelId, { limit: 10 }),
});
return (
<Card withBorder>
<Title order={6} mb="sm">📋 Últimes importacions</Title>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Vídeo</Table.Th>
<Table.Th>Estat</Table.Th>
<Table.Th>Data</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map(item => (
<Table.Tr key={item.videoId}>
<Table.Td>
<Anchor href={`/dances/${item.danceId}`}>
{item.videoTitle}
</Anchor>
</Table.Td>
<Table.Td>
<StatusBadge status={item.status} />
</Table.Td>
<Table.Td>
{formatDateTime(item.importedAt)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
);
}
🧪 Fase 5: Testing
Durada estimada: 2 dies
Prioritat: ALTA
5.1 Tests Backend
// YouTubeChannelImportServiceTest.java
@SpringBootTest
@Transactional
class YouTubeChannelImportServiceTest {
@Test
void registerChannel_ShouldCreateImportRecord() {
var request = new RegisterChannelRequest(
"UCi-EHWXp4Dh2RhE3krdbUew",
"Test Channel",
Instant.parse("2026-01-01T00:00:00Z")
);
var result = service.registerChannel(request);
assertThat(result.channelId()).isEqualTo("UCi-EHWXp4Dh2RhE3krdbUew");
assertThat(result.videosImportedCount()).isZero();
}
@Test
void getPendingVideos_ShouldExcludeAlreadyImported() {
// Setup: Un canal amb 5 vídeos, 2 ja importats
// Assert: Només retorna els 3 pendents
}
@Test
void confirmImport_ShouldUpdateChannelStats() {
// Assert: Després d'importar, videosImportedCount incrementa
// Assert: lastVideoPublishedAt s'actualitza
}
}
5.2 Tests Frontend
// YouTubeImportPage.test.tsx
describe('YouTubeImportPage', () => {
it('should show channels list on load', async () => {
render(<YouTubeImportPage />);
await waitFor(() => {
expect(screen.getByText('Catalan Honky Tonk Friends')).toBeInTheDocument();
});
});
it('should show pending videos when channel selected', async () => {
render(<YouTubeImportPage />);
fireEvent.click(screen.getByText('Catalan Honky Tonk Friends'));
await waitFor(() => {
expect(screen.getByText(/vídeos pendents/i)).toBeInTheDocument();
});
});
it('should validate required fields before confirm', async () => {
// Test que no es pot confirmar sense cançó seleccionada
});
});
📅 Cronograma Proposat
| Setmana | Fase | Tasques |
|---|---|---|
| 1 | Fase 1 | Migració SQL, Entitats, Repository, YouTubeService ampliació |
| 1 | Fase 1 | Nous endpoints, DTOs, tests unitaris backend |
| 2 | Fase 2 | Reestructuració frontend, ChannelListPanel, ChannelCard |
| 2 | Fase 2 | PendingVideosList, ImportWorkspace, integració |
| 3 | Fase 3 | Gestió d'errors, validacions, retry logic |
| 3 | Fase 4 | Logs, historial, mètriques (opcional) |
| 3 | Fase 5 | Testing complet, QA manual |
Total estimat: 2-3 setmanes
✅ Criteris d'Acceptació
Funcionals
- Puc veure els canals registrats amb el nombre de vídeos importats i pendents
- Puc veure la llista de vídeos nous d'un canal (des de l'última importació)
- Puc importar un vídeo de la llista amb el flux existent (analyze → review → confirm)
- Després d'importar, el vídeo desapareix de la llista de pendents
- Si un vídeo ja existeix, puc marcar-lo com a "omès" sense crear duplicat
- L'historial mostra les últimes importacions amb l'estat
No Funcionals
- El temps de càrrega de la llista de canals és < 500ms
- El temps d'anàlisi d'un vídeo és < 10 segons
- La UI és responsive i funciona bé en pantalles ≥ 1024px
- Els errors es mostren clarament amb missatges comprensibles
Qualitat
- Cobertura de tests backend > 70%
- Els logs permeten traçar problemes d'importació
- No hi ha regressions en la funcionalitat existent
🚀 Següents Passos
- Revisió del pla amb l'equip
- Priorització de funcionalitats (MVP vs Nice-to-have)
- Creació de tasques detallades (GitHub Issues)
- Implementació seguint el cronograma
- QA i feedback d'usuaris pilot
Document creat el 2026-03-14 per GitHub Copilot