feat(sync): phase 1 - import Takeout + Watch Later YouTube bidirectionnel
- Takeout: POST /user/history/takeout (lots <=1000, validation videoId, upsert sans doublons), parsing local watch-history.json cote client (vie privee, dedup derniere date), progression par lots de 500 - Watch Later: GET/POST /oauth/google/watchlater (lecture readonly, push force-ssl), import vers playlist NewTube 'Watch Later (YouTube)', export playlist NewTube -> WL YouTube, writeGranted + invite reconnect - Scopes Google elargis (youtube.force-ssl), aide /library/import a jour (limite historique API documentee)
This commit is contained in:
+78
-1
@@ -104,7 +104,8 @@ import { fetchChannelContent } from './providers/channel-content.mjs';
|
||||
import {
|
||||
oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode,
|
||||
refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions,
|
||||
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows,
|
||||
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope,
|
||||
fetchGoogleWatchLater, pushGoogleWatchLater,
|
||||
} from './oauth.mjs';
|
||||
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
|
||||
import { ytScrapeCacheStats } from './providers/youtube.mjs';
|
||||
@@ -641,6 +642,82 @@ r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------- Google : Watch Later (lecture + écriture) --------------------
|
||||
// Lecture (youtube.readonly OK). `writeGranted` = le jeton stocké a le scope
|
||||
// force-ssl ; sinon le push répond 403 et l'UI propose de reconnecter.
|
||||
|
||||
r.get('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||||
try {
|
||||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||||
const { items } = await fetchGoogleWatchLater(conn.accessToken, 50);
|
||||
return res.json({
|
||||
items, count: items.length,
|
||||
writeGranted: hasGoogleWriteScope(conn.scopes),
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(error?.status || 502).json({ error: error?.message || 'watchlater_fetch_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
r.post('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||||
try {
|
||||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||||
if (!hasGoogleWriteScope(conn.scopes)) {
|
||||
return res.status(403).json({ error: 'youtube_write_scope_missing', hint: 'Reconnectez Google pour autoriser l’écriture (Watch Later).' });
|
||||
}
|
||||
const ids = Array.isArray(req.body?.videoIds) ? req.body.videoIds : [];
|
||||
const clean = [...new Set(ids.map((v) => String(v || '').trim()).filter(Boolean))].slice(0, 50);
|
||||
if (!clean.length) return res.status(400).json({ error: 'videoIds_required' });
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const videoId of clean) {
|
||||
try {
|
||||
await pushGoogleWatchLater(conn.accessToken, videoId);
|
||||
added++;
|
||||
} catch { skipped++; }
|
||||
}
|
||||
return res.json({ added, skipped, total: clean.length });
|
||||
} catch (error) {
|
||||
const status = error?.status || 502;
|
||||
if (status === 403) {
|
||||
return res.status(403).json({ error: 'youtube_write_scope_missing', hint: 'Reconnectez Google pour autoriser l’écriture (Watch Later).' });
|
||||
}
|
||||
return res.status(status).json({ error: error?.message || 'watchlater_push_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------- Import historique Takeout (Google) --------------------
|
||||
// Le client parse le fichier `watch-history.json` localement (confidentialité,
|
||||
// pas de limite d'upload) et envoie des lots { videoId, title, watchedAt }.
|
||||
// L'historique YouTube n'est pas lisible par API (limite Google), d'où Takeout.
|
||||
|
||||
r.post('/user/history/takeout', authMiddleware, oauthLimiter, (req, res) => {
|
||||
try {
|
||||
const items = Array.isArray(req.body?.items) ? req.body.items : [];
|
||||
if (!items.length) return res.status(400).json({ error: 'items_required' });
|
||||
if (items.length > 1000) return res.status(400).json({ error: 'batch_too_large' });
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
for (const it of items) {
|
||||
const videoId = String(it?.videoId || '').trim().slice(0, 64);
|
||||
if (!/^[A-Za-z0-9_-]{6,64}$/.test(videoId)) { skipped++; continue; }
|
||||
const title = String(it?.title || '').slice(0, 300) || videoId;
|
||||
let watchedAt = new Date().toISOString();
|
||||
if (it?.watchedAt) {
|
||||
const d = new Date(String(it.watchedAt));
|
||||
if (!Number.isNaN(d.getTime())) watchedAt = d.toISOString();
|
||||
}
|
||||
try {
|
||||
upsertWatchHistory({ userId: req.user.id, provider: 'youtube', videoId, title, watchedAt });
|
||||
imported++;
|
||||
} catch { skipped++; }
|
||||
}
|
||||
return res.json({ imported, skipped, total: items.length });
|
||||
} catch {
|
||||
return res.status(500).json({ error: 'takeout_import_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
|
||||
r.get('/playlists/:id/view', (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -21,8 +21,17 @@ const GOOGLE_SCOPES = [
|
||||
'openid',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/youtube.readonly',
|
||||
// Écriture Watch Later (playlistItems.insert/delete). Lecture seule
|
||||
// (abos, likes, WL) fonctionne sans lui ; le push exige un re-consentement.
|
||||
'https://www.googleapis.com/auth/youtube.force-ssl',
|
||||
].join(' ');
|
||||
|
||||
/** Le jeton stocké permet-il l'écriture (push Watch Later) ? */
|
||||
export function hasGoogleWriteScope(scopes) {
|
||||
const tokens = String(scopes || '').split(/\s+/).filter(Boolean);
|
||||
return tokens.some((t) => t === 'https://www.googleapis.com/auth/youtube.force-ssl' || t === 'https://www.googleapis.com/auth/youtube');
|
||||
}
|
||||
|
||||
const TWITCH_SCOPES = ['user:read:follows', 'user:read:subscriptions'].join(' ');
|
||||
|
||||
// state -> { userId, provider, createdAt } (mémoire, 10 min).
|
||||
@@ -298,6 +307,83 @@ export async function fetchGoogleLiked(accessToken, max = 25) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// -------------------- Google : Watch Later --------------------
|
||||
|
||||
async function googleApiGet(accessToken, path, params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
|
||||
}
|
||||
return getJson(`https://www.googleapis.com/youtube/v3/${path}?${qs.toString()}`, accessToken);
|
||||
}
|
||||
|
||||
async function googleApiPost(accessToken, path, body) {
|
||||
const resp = await fetch(`https://www.googleapis.com/youtube/v3/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = new Error(`oauth_api_failed_${resp.status}`);
|
||||
err.status = resp.status === 401 ? 401 : resp.status === 403 ? 403 : 502;
|
||||
err.details = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** ID de la playlist "Regarder plus tard" du compte (lecture seule OK). */
|
||||
export async function fetchGoogleWatchLaterId(accessToken) {
|
||||
const data = await googleApiGet(accessToken, 'channels', { part: 'contentDetails', mine: 'true' });
|
||||
const item = Array.isArray(data?.items) ? data.items[0] : null;
|
||||
const id = item?.contentDetails?.relatedPlaylists?.watchLater || '';
|
||||
if (!id) throw Object.assign(new Error('watchlater_not_found'), { status: 502 });
|
||||
return String(id);
|
||||
}
|
||||
|
||||
/** Contenu de "Regarder plus tard" (lecture seule OK). */
|
||||
export async function fetchGoogleWatchLater(accessToken, max = 50) {
|
||||
const playlistId = await fetchGoogleWatchLaterId(accessToken);
|
||||
const out = [];
|
||||
let pageToken = '';
|
||||
while (out.length < max) {
|
||||
const data = await googleApiGet(accessToken, 'playlistItems', {
|
||||
part: 'snippet,contentDetails',
|
||||
playlistId,
|
||||
maxResults: Math.min(50, max - out.length),
|
||||
...(pageToken ? { pageToken } : {}),
|
||||
});
|
||||
for (const item of Array.isArray(data?.items) ? data.items : []) {
|
||||
const vid = item?.contentDetails?.videoId || item?.snippet?.resourceId?.videoId || '';
|
||||
if (!vid) continue;
|
||||
const sn = item?.snippet || {};
|
||||
out.push({
|
||||
provider: 'youtube',
|
||||
videoId: String(vid),
|
||||
title: sn?.title || '',
|
||||
thumbnail: sn?.thumbnails?.medium?.url || sn?.thumbnails?.default?.url || '',
|
||||
playlistItemId: item?.id || null,
|
||||
});
|
||||
}
|
||||
pageToken = data?.nextPageToken || '';
|
||||
if (!pageToken) break;
|
||||
}
|
||||
return { playlistId, items: out };
|
||||
}
|
||||
|
||||
/** Ajoute une vidéo à "Regarder plus tard" (exige le scope force-ssl). */
|
||||
export async function pushGoogleWatchLater(accessToken, videoId) {
|
||||
const playlistId = await fetchGoogleWatchLaterId(accessToken);
|
||||
const data = await googleApiPost(accessToken, 'playlistItems?part=snippet', {
|
||||
snippet: {
|
||||
playlistId,
|
||||
resourceId: { kind: 'youtube#video', videoId: String(videoId) },
|
||||
},
|
||||
});
|
||||
return { playlistItemId: data?.id || null };
|
||||
}
|
||||
|
||||
// -------------------- Twitch --------------------
|
||||
|
||||
function twitchClientId() {
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
<details class="mb-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4 text-sm text-slate-300">
|
||||
<summary class="cursor-pointer font-semibold text-slate-100">Comment ça marche ? (scopes, URLs, ré-import)</summary>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5 text-slate-400">
|
||||
<li><strong class="text-slate-200">Google</strong> : abonnements + favoris YouTube, scopes lecture seule <code>openid userinfo.profile youtube.readonly</code>. Redirect URI à enregistrer dans Google Cloud : <code>/api/oauth/google/callback</code>.</li>
|
||||
<li><strong class="text-slate-200">Google</strong> : abonnements + favoris YouTube, scopes <code>openid userinfo.profile youtube.readonly</code> (+ <code>youtube.force-ssl</code> pour pousser vers Regarder plus tard). Redirect URI à enregistrer dans Google Cloud : <code>/api/oauth/google/callback</code>.</li>
|
||||
<li><strong class="text-slate-200">Twitch</strong> : chaînes suivies uniquement (<strong>pas de likes côté Twitch</strong>), scopes <code>user:read:follows user:read:subscriptions</code>. Redirect URI à enregistrer sur dev.twitch.tv : <code>/api/oauth/twitch/callback</code>.</li>
|
||||
<li><strong class="text-slate-200">Prévisualiser</strong> ne change rien ; <strong class="text-slate-200">Importer</strong> ajoute abonnements / favoris dans NewTube.</li>
|
||||
<li>Ré-importer est <strong class="text-slate-200">sans doublons</strong> : relancez sans crainte après de nouveaux abos/likes.</li>
|
||||
<li><strong class="text-slate-200">Historique YouTube</strong> : non lisible par API (limite Google) — utilisez l’export <strong class="text-slate-200">Takeout</strong> ci-dessous (<code>takeout.google.com → YouTube → watch-history.json</code>).</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
@@ -86,4 +87,79 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== HISTORIQUE TAKEOUT ==================== -->
|
||||
<div *ngIf="!loading()" class="mt-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">🕘</span>Historique YouTube (Takeout)</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
Google n’expose pas l’historique par API : exportez-le via
|
||||
<code>takeout.google.com → YouTube → watch-history.json</code>, puis importez le fichier ici.
|
||||
Parsé <strong class="text-slate-200">localement dans votre navigateur</strong>, sans doublons.
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<label class="cursor-pointer rounded-md bg-slate-700 px-3 py-1.5 text-sm text-white transition hover:bg-slate-600">
|
||||
Choisir watch-history.json
|
||||
<input type="file" accept=".json,application/json" class="hidden" (change)="onTakeoutFile($event)" />
|
||||
</label>
|
||||
<button *ngIf="takeoutItems().length" type="button" (click)="importTakeout()" [disabled]="takeoutImporting()"
|
||||
class="rounded-md bg-green-700 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-green-600 disabled:opacity-40">
|
||||
{{ takeoutImporting() ? 'Import…' : 'Importer dans l’historique' }}
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="takeoutParsing()" class="mt-2 text-sm text-slate-400">Analyse du fichier…</div>
|
||||
<div *ngIf="takeoutItems().length && takeoutRange() as rg" class="mt-2 text-sm text-slate-300">
|
||||
{{ takeoutItems().length }} vidéo(s) — du {{ rg.from }} au {{ rg.to }}<span *ngIf="takeoutFileName()"> ({{ takeoutFileName() }})</span>
|
||||
</div>
|
||||
<div *ngIf="takeoutProgress() as pg" class="mt-2 text-sm text-slate-300">
|
||||
Progression : {{ pg.done }}/{{ pg.total }}
|
||||
<div class="mt-1 h-2 w-full max-w-md overflow-hidden rounded bg-slate-700">
|
||||
<div class="h-full bg-green-600 transition-all" [style.width.%]="pg.total ? (pg.done / pg.total * 100) : 0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="takeoutError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ takeoutError() }}</div>
|
||||
<div *ngIf="takeoutResult() as r" class="mt-2 rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">
|
||||
{{ r.imported }} entrée(s) importée(s){{ r.skipped ? `, ${r.skipped} ignorée(s)` : '' }}.
|
||||
<a [routerLink]="['/account/history']" class="underline hover:text-green-100">Voir l’historique</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== WATCH LATER YOUTUBE ==================== -->
|
||||
<div *ngIf="!loading() && isConnected('google')" class="mt-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">⏱️</span>Regarder plus tard (YouTube)</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Tirez votre liste YouTube vers une playlist NewTube, ou poussez une playlist NewTube vers YouTube (écriture).</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" (click)="loadWatchLater(); loadUserPlaylists()" [disabled]="wlLoading()"
|
||||
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500">
|
||||
{{ wlLoading() ? 'Chargement…' : (wlItems().length ? 'Recharger' : 'Voir ma liste') }}
|
||||
</button>
|
||||
<button *ngIf="wlItems().length" type="button" (click)="importWatchLaterToPlaylist()" [disabled]="wlImporting()"
|
||||
class="rounded-md bg-green-700 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-green-600 disabled:opacity-40">
|
||||
{{ wlImporting() ? 'Import…' : '→ Playlist NewTube' }}
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="wlError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ wlError() }}</div>
|
||||
<div *ngIf="wlItems().length" class="mt-2 text-sm text-slate-300">
|
||||
{{ wlItems().length }} vidéo(s) dans « Regarder plus tard » YouTube.
|
||||
<span *ngIf="!wlWriteGranted()" class="text-amber-200">Écriture non autorisée : déconnectez puis reconnectez Google pour l’activer.</span>
|
||||
<ul class="mt-2 max-h-40 space-y-1 overflow-auto text-xs text-slate-400">
|
||||
<li *ngFor="let v of wlItems().slice(0, 20)" class="flex items-center gap-2">
|
||||
<img *ngIf="v.thumbnail" [src]="v.thumbnail" alt="" class="h-8 w-14 rounded object-cover" />
|
||||
<span class="truncate">{{ v.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div *ngIf="wlImportResult()" class="mt-2 rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">{{ wlImportResult() }}</div>
|
||||
<div *ngIf="userPlaylists().length" class="mt-3 flex flex-wrap items-center gap-2 border-t border-slate-800 pt-3">
|
||||
<label class="text-sm text-slate-300">Exporter vers YouTube :</label>
|
||||
<select [ngModel]="wlExportPlaylistId()" (ngModelChange)="wlExportPlaylistId.set($event)" class="rounded-md border border-slate-600 bg-slate-700 px-2 py-1.5 text-sm text-white">
|
||||
<option *ngFor="let pl of userPlaylists()" [value]="pl.id">{{ pl.title }}</option>
|
||||
</select>
|
||||
<button type="button" (click)="exportPlaylistToWatchLater()" [disabled]="wlPushing() || !wlWriteGranted()"
|
||||
title="{{ wlWriteGranted() ? '' : 'Reconnectez Google pour autoriser l’écriture' }}"
|
||||
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-red-500 disabled:opacity-40">
|
||||
{{ wlPushing() ? 'Envoi…' : '→ YouTube' }}
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="wlPushResult()" class="mt-2 rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">{{ wlPushResult() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { OAuthImportService, type OAuthConnection, type OAuthPreview, type OAuthImportResult } from '../../../services/oauth-import.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { OAuthImportService, parseTakeoutHistory, type OAuthConnection, type OAuthPreview, type OAuthImportResult } from '../../../services/oauth-import.service';
|
||||
import { PlaylistsService, type Playlist } from '../../../services/playlists.service';
|
||||
|
||||
interface ProviderCard {
|
||||
id: 'google' | 'twitch';
|
||||
@@ -13,12 +16,13 @@ interface ProviderCard {
|
||||
@Component({
|
||||
selector: 'app-library-import',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
templateUrl: './import.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ImportComponent implements OnInit {
|
||||
private api = inject(OAuthImportService);
|
||||
private playlists = inject(PlaylistsService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
readonly providers: ProviderCard[] = [
|
||||
@@ -113,4 +117,166 @@ export class ImportComponent implements OnInit {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Takeout (historique YouTube) --------------------
|
||||
// L'historique n'est pas lisible par API (limite Google) : on importe le
|
||||
// `watch-history.json` de Takeout, parsé localement puis envoyé par lots.
|
||||
|
||||
takeoutItems = signal<Array<{ videoId: string; title: string; watchedAt: string }>>([]);
|
||||
takeoutFileName = signal<string>('');
|
||||
takeoutParsing = signal(false);
|
||||
takeoutImporting = signal(false);
|
||||
takeoutProgress = signal<{ done: number; total: number } | null>(null);
|
||||
takeoutResult = signal<{ imported: number; skipped: number } | null>(null);
|
||||
takeoutError = signal<string | null>(null);
|
||||
|
||||
takeoutRange(): { from: string; to: string } | null {
|
||||
const items = this.takeoutItems();
|
||||
if (!items.length) return null;
|
||||
const dates = items.map((i) => i.watchedAt).sort();
|
||||
return { from: dates[0].slice(0, 10), to: dates[dates.length - 1].slice(0, 10) };
|
||||
}
|
||||
|
||||
onTakeoutFile(ev: Event): void {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input?.files?.[0];
|
||||
if (!file) return;
|
||||
this.takeoutError.set(null);
|
||||
this.takeoutResult.set(null);
|
||||
this.takeoutProgress.set(null);
|
||||
this.takeoutParsing.set(true);
|
||||
this.takeoutFileName.set(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const json = JSON.parse(String(reader.result || '[]'));
|
||||
const items = parseTakeoutHistory(json);
|
||||
this.takeoutItems.set(items);
|
||||
if (!items.length) this.takeoutError.set('Aucune vidéo trouvée : vérifiez qu’il s’agit bien du watch-history.json de Takeout.');
|
||||
} catch {
|
||||
this.takeoutError.set('Fichier illisible : JSON invalide.');
|
||||
this.takeoutItems.set([]);
|
||||
}
|
||||
this.takeoutParsing.set(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
this.takeoutError.set('Lecture du fichier impossible.');
|
||||
this.takeoutParsing.set(false);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
try { input.value = ''; } catch {}
|
||||
}
|
||||
|
||||
async importTakeout(): Promise<void> {
|
||||
const items = this.takeoutItems();
|
||||
if (!items.length || this.takeoutImporting()) return;
|
||||
this.takeoutImporting.set(true);
|
||||
this.takeoutError.set(null);
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
try {
|
||||
for (let i = 0; i < items.length; i += 500) {
|
||||
const batch = items.slice(i, i + 500);
|
||||
const res = await firstValueFrom(this.api.importTakeoutBatch(batch));
|
||||
imported += res?.imported || 0;
|
||||
skipped += res?.skipped || 0;
|
||||
this.takeoutProgress.set({ done: Math.min(i + batch.length, items.length), total: items.length });
|
||||
}
|
||||
this.takeoutResult.set({ imported, skipped });
|
||||
} catch (e: any) {
|
||||
this.takeoutError.set(e?.error?.error || e?.message || 'takeout_import_failed');
|
||||
} finally {
|
||||
this.takeoutImporting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- Watch Later YouTube --------------------
|
||||
|
||||
wlItems = signal<Array<{ videoId: string; title: string; thumbnail?: string }>>([]);
|
||||
wlLoading = signal(false);
|
||||
wlWriteGranted = signal(false);
|
||||
wlError = signal<string | null>(null);
|
||||
wlImporting = signal(false);
|
||||
wlImportResult = signal<string | null>(null);
|
||||
wlPushing = signal(false);
|
||||
wlPushResult = signal<string | null>(null);
|
||||
userPlaylists = signal<Playlist[]>([]);
|
||||
wlExportPlaylistId = signal<string>('');
|
||||
|
||||
loadWatchLater(): void {
|
||||
this.wlLoading.set(true);
|
||||
this.wlError.set(null);
|
||||
this.api.watchLater().subscribe({
|
||||
next: (res) => {
|
||||
this.wlItems.set(res?.items || []);
|
||||
this.wlWriteGranted.set(!!res?.writeGranted);
|
||||
this.wlLoading.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.wlError.set(e?.error?.error || e?.message || 'watchlater_fetch_failed');
|
||||
this.wlLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Importe la WL YouTube vers une playlist NewTube (créée si besoin). */
|
||||
async importWatchLaterToPlaylist(): Promise<void> {
|
||||
const items = this.wlItems();
|
||||
if (!items.length || this.wlImporting()) return;
|
||||
this.wlImporting.set(true);
|
||||
this.wlError.set(null);
|
||||
try {
|
||||
const existing = await firstValueFrom(this.playlists.list(100));
|
||||
let pl = (existing || []).find((p) => p.title === 'Watch Later (YouTube)');
|
||||
if (!pl) pl = await firstValueFrom(this.playlists.create('Watch Later (YouTube)', 'Importée depuis YouTube', undefined, true));
|
||||
let added = 0;
|
||||
for (const v of items) {
|
||||
try {
|
||||
await firstValueFrom(this.playlists.addVideo(pl.id, 'youtube', v.videoId, v.title, v.thumbnail));
|
||||
added++;
|
||||
} catch {}
|
||||
}
|
||||
this.wlImportResult.set(`${added}/${items.length} vidéo(s) dans « ${pl.title} ».`);
|
||||
} catch (e: any) {
|
||||
this.wlError.set(e?.error?.error || e?.message || 'watchlater_import_failed');
|
||||
} finally {
|
||||
this.wlImporting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadUserPlaylists(): void {
|
||||
this.playlists.list(100).subscribe({
|
||||
next: (list) => {
|
||||
this.userPlaylists.set(list || []);
|
||||
if (!this.wlExportPlaylistId() && list?.length) this.wlExportPlaylistId.set(list[0].id);
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
/** Exporte une playlist NewTube (vidéos YouTube) vers "Regarder plus tard" YouTube. */
|
||||
async exportPlaylistToWatchLater(): Promise<void> {
|
||||
const pid = this.wlExportPlaylistId();
|
||||
if (!pid || this.wlPushing()) return;
|
||||
this.wlPushing.set(true);
|
||||
this.wlError.set(null);
|
||||
this.wlPushResult.set(null);
|
||||
try {
|
||||
const detail = await firstValueFrom(this.playlists.get(pid, 200));
|
||||
const ids = [...new Set((detail?.items || []).filter((i) => i.provider === 'youtube').map((i) => i.videoId))];
|
||||
if (!ids.length) {
|
||||
this.wlError.set('Cette playlist ne contient aucune vidéo YouTube.');
|
||||
return;
|
||||
}
|
||||
const res = await firstValueFrom(this.api.pushWatchLater(ids.slice(0, 50)));
|
||||
this.wlPushResult.set(`${res?.added || 0} ajoutée(s)${res?.skipped ? `, ${res.skipped} ignorée(s)` : ''} dans « Regarder plus tard » YouTube.`);
|
||||
} catch (e: any) {
|
||||
const code = e?.error?.error || e?.message || 'watchlater_push_failed';
|
||||
this.wlError.set(code === 'youtube_write_scope_missing'
|
||||
? 'Écriture non autorisée : déconnectez puis reconnectez Google pour autoriser l’accès en écriture.'
|
||||
: code);
|
||||
} finally {
|
||||
this.wlPushing.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +59,42 @@ export class OAuthImportService {
|
||||
importAll(provider: string, types: string[]): Observable<OAuthImportResult> {
|
||||
return this.http.post<OAuthImportResult>(`/api/oauth/${provider}/import`, { types });
|
||||
}
|
||||
|
||||
/** Contenu "Regarder plus tard" YouTube (lecture seule OK). */
|
||||
watchLater(): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string }>; count: number; writeGranted: boolean }> {
|
||||
return this.http.get<any>(`/api/oauth/google/watchlater`);
|
||||
}
|
||||
|
||||
/** Pousse des vidéos vers "Regarder plus tard" YouTube (scope écriture requis). */
|
||||
pushWatchLater(videoIds: string[]): Observable<{ added: number; skipped: number; total: number }> {
|
||||
return this.http.post<any>(`/api/oauth/google/watchlater`, { videoIds });
|
||||
}
|
||||
|
||||
/** Lot d'entrées d'historique Takeout (max 1000/lot côté serveur). */
|
||||
importTakeoutBatch(items: Array<{ videoId: string; title?: string; watchedAt?: string }>): Observable<{ imported: number; skipped: number; total: number }> {
|
||||
return this.http.post<any>(`/api/user/history/takeout`, { items });
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse local du `watch-history.json` de Google Takeout (confidentiel : rien n'est uploadé tel quel). */
|
||||
export function parseTakeoutHistory(json: unknown): Array<{ videoId: string; title: string; watchedAt: string }> {
|
||||
const arr = Array.isArray(json) ? json : [];
|
||||
const byId = new Map<string, { videoId: string; title: string; watchedAt: string }>();
|
||||
for (const e of arr) {
|
||||
const url = String((e as any)?.titleUrl || '');
|
||||
const m = /(?:[?&]v=|youtu\.be\/|\/(?:shorts|live|embed)\/)([A-Za-z0-9_-]{11})/.exec(url);
|
||||
if (!m) continue;
|
||||
const videoId = m[1];
|
||||
const rawTitle = String((e as any)?.title || '').replace(/^Watched\s+/i, '').trim();
|
||||
let watchedAt = new Date().toISOString();
|
||||
try {
|
||||
const d = new Date(String((e as any)?.time || ''));
|
||||
if (!Number.isNaN(d.getTime())) watchedAt = d.toISOString();
|
||||
} catch {}
|
||||
const prev = byId.get(videoId);
|
||||
if (!prev || watchedAt > prev.watchedAt) {
|
||||
byId.set(videoId, { videoId, title: (rawTitle || videoId).slice(0, 300), watchedAt });
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => (a.watchedAt < b.watchedAt ? 1 : -1));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user