Skip to main content

📋 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

  1. Registre de canals YouTube amb traçabilitat d'importacions
  2. Interfície d'usuari de qualitat clara i professional
  3. Estabilitat i robustesa en el procés d'importació
  4. Visibilitat del progrés per saber què s'ha importat i què falta

🔍 Anàlisi de l'Estat Actual

Funcionalitat Existent

ComponentEstatObservacions
YouTubeImportPage.tsx✅ Funcional~700 línies, flux complet input→review→confirm
DanceImportController.java✅ Funcional3 endpoints: status, analyze, confirm
DanceImportService.java✅ Funcional~500 línies, lògica completa
YouTubeService.java✅ FuncionalNomés getVideoMetadata(), falten mètodes per llistar canals
GeminiAiService.java✅ FuncionalParsing AI via OpenRouter
MusicImportModal.tsx✅ FuncionalModal iTunes/Spotify independent
ChoreographerSelector.tsx✅ FuncionalSelector 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

  1. No hi ha registre de canals - No es pot saber fins on s'ha importat
  2. No hi ha llistat de vídeos nous - Cal anar un a un manualment
  3. No hi ha visibilitat del progrés - No es veu què falta per importar
  4. UI poc professional - Funciona però no és per a "producció"
  5. 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:

  1. Abans d'analitzar: URL vàlida, no importat prèviament
  2. Abans de confirmar: Nom ball, coreògraf, cançó seleccionats
  3. 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

SetmanaFaseTasques
1Fase 1Migració SQL, Entitats, Repository, YouTubeService ampliació
1Fase 1Nous endpoints, DTOs, tests unitaris backend
2Fase 2Reestructuració frontend, ChannelListPanel, ChannelCard
2Fase 2PendingVideosList, ImportWorkspace, integració
3Fase 3Gestió d'errors, validacions, retry logic
3Fase 4Logs, historial, mètriques (opcional)
3Fase 5Testing 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

  1. Revisió del pla amb l'equip
  2. Priorització de funcionalitats (MVP vs Nice-to-have)
  3. Creació de tasques detallades (GitHub Issues)
  4. Implementació seguint el cronograma
  5. QA i feedback d'usuaris pilot

Document creat el 2026-03-14 per GitHub Copilot