feat(sync): sélecteur de chaîne YouTube (multi-chaînes/marque) via PageId
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
-- Chaîne YouTube sélectionnée pour la sync (comptes multi-chaînes / marque).
|
||||||
|
-- Colonnes ajoutées si absentes (compatibles avec ensureOAuthTables()).
|
||||||
|
ALTER TABLE oauth_connections ADD COLUMN yt_channel_id TEXT;
|
||||||
|
ALTER TABLE oauth_connections ADD COLUMN yt_page_id TEXT;
|
||||||
@@ -1397,6 +1397,12 @@ function ensureOAuthTables() {
|
|||||||
PRIMARY KEY (user_id, provider)
|
PRIMARY KEY (user_id, provider)
|
||||||
);`);
|
);`);
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_connections_user ON oauth_connections(user_id);`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_connections_user ON oauth_connections(user_id);`);
|
||||||
|
// Chaîne YouTube sélectionnée (multi-chaînes / marque) : ajout idempotent.
|
||||||
|
try {
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(oauth_connections)`).all().map((c) => c.name);
|
||||||
|
if (!cols.includes('yt_channel_id')) db.exec(`ALTER TABLE oauth_connections ADD COLUMN yt_channel_id TEXT`);
|
||||||
|
if (!cols.includes('yt_page_id')) db.exec(`ALTER TABLE oauth_connections ADD COLUMN yt_page_id TEXT`);
|
||||||
|
} catch {}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
ensureOAuthTables();
|
ensureOAuthTables();
|
||||||
@@ -1417,9 +1423,19 @@ function oauthRowToDto(row, withTokens = false) {
|
|||||||
dto.accessToken = row.access_token || null;
|
dto.accessToken = row.access_token || null;
|
||||||
dto.refreshToken = row.refresh_token || null;
|
dto.refreshToken = row.refresh_token || null;
|
||||||
}
|
}
|
||||||
|
dto.ytChannelId = row.yt_channel_id || null;
|
||||||
|
dto.ytPageId = row.yt_page_id || null;
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setOAuthChannel(userId, provider, { channelId, pageId }) {
|
||||||
|
ensureOAuthTables();
|
||||||
|
db.prepare(`UPDATE oauth_connections SET yt_channel_id = ?, yt_page_id = ?, updated_at = ?
|
||||||
|
WHERE user_id = ? AND provider = ?`)
|
||||||
|
.run(channelId || null, pageId || null, Date.now(), userId, provider);
|
||||||
|
return getOAuthConnection(userId, provider);
|
||||||
|
}
|
||||||
|
|
||||||
export function upsertOAuthConnection({ userId, provider, externalUserId, displayName, avatarUrl, accessToken, refreshToken, expiresAt, scopes }) {
|
export function upsertOAuthConnection({ userId, provider, externalUserId, displayName, avatarUrl, accessToken, refreshToken, expiresAt, scopes }) {
|
||||||
ensureOAuthTables();
|
ensureOAuthTables();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import {
|
|||||||
getOAuthConnection,
|
getOAuthConnection,
|
||||||
updateOAuthTokens,
|
updateOAuthTokens,
|
||||||
deleteOAuthConnection,
|
deleteOAuthConnection,
|
||||||
|
setOAuthChannel,
|
||||||
upsertChannelRow,
|
upsertChannelRow,
|
||||||
insertDownloadJob,
|
insertDownloadJob,
|
||||||
getDownloadJob,
|
getDownloadJob,
|
||||||
@@ -107,6 +108,7 @@ import {
|
|||||||
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope,
|
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope,
|
||||||
fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory,
|
fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory,
|
||||||
fetchInnerTubeWatchLaterViaLib, pushInnerTubeWatchLater, fetchGoogleWatchLaterId,
|
fetchInnerTubeWatchLaterViaLib, pushInnerTubeWatchLater, fetchGoogleWatchLaterId,
|
||||||
|
fetchAccountChannels,
|
||||||
} from './oauth.mjs';
|
} from './oauth.mjs';
|
||||||
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
|
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
|
||||||
import { ytScrapeCacheStats } from './providers/youtube.mjs';
|
import { ytScrapeCacheStats } from './providers/youtube.mjs';
|
||||||
@@ -559,6 +561,39 @@ r.delete('/oauth/:provider', authMiddlewareCookieAware, oauthLimiter, (req, res)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------- Google : choix de la chaîne (multi-chaînes / marque) --------------------
|
||||||
|
// La chaîne par défaut peut être une coquille vide (pas d'historique ni WL)
|
||||||
|
// alors que l'activité est sur une chaîne secondaire : l'utilisateur la
|
||||||
|
// choisit ici, et les appels InnerTube la ciblent via X-Goog-PageId.
|
||||||
|
|
||||||
|
r.get('/oauth/google/yt-channels', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const conn = await freshOAuthToken(req.user.id, 'google');
|
||||||
|
const { channels } = await fetchAccountChannels(conn);
|
||||||
|
try {
|
||||||
|
console.log(`[oauth] yt-channels user=${req.user?.id} count=${channels.length}`);
|
||||||
|
} catch {}
|
||||||
|
return res.json({ channels, selected: { channelId: conn.ytChannelId || null, pageId: conn.ytPageId || null } });
|
||||||
|
} catch (error) {
|
||||||
|
const out = { error: error?.message || 'yt_channels_failed' };
|
||||||
|
if (error?.detail) out.detail = error.detail;
|
||||||
|
if (error?.hint) out.hint = error.hint;
|
||||||
|
return res.status(error?.status || 502).json(out);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
r.put('/oauth/google/yt-channel', authMiddlewareCookieAware, oauthLimiter, (req, res) => {
|
||||||
|
try {
|
||||||
|
const channelId = String(req.body?.channelId || '').trim().slice(0, 64) || null;
|
||||||
|
const pageId = String(req.body?.pageId || '').trim().slice(0, 64) || null;
|
||||||
|
const conn = setOAuthChannel(req.user.id, 'google', { channelId, pageId });
|
||||||
|
if (!conn) return res.status(404).json({ error: 'oauth_not_connected' });
|
||||||
|
return res.json({ selected: { channelId: conn.ytChannelId || null, pageId: conn.ytPageId || null } });
|
||||||
|
} catch {
|
||||||
|
return res.status(500).json({ error: 'yt_channel_save_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Aperçu distant (sans rien importer) : abonnements + favoris.
|
// Aperçu distant (sans rien importer) : abonnements + favoris.
|
||||||
r.get('/oauth/:provider/preview', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
r.get('/oauth/:provider/preview', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+61
-3
@@ -606,9 +606,10 @@ async function silenceYoutubeiNoise() {
|
|||||||
* Session youtubei.js authentifiée avec les tokens OAuth stockés
|
* Session youtubei.js authentifiée avec les tokens OAuth stockés
|
||||||
* (même Bearer que l'app, mais contexte client officiel complet).
|
* (même Bearer que l'app, mais contexte client officiel complet).
|
||||||
*/
|
*/
|
||||||
async function getAuthedInnertube(conn) {
|
async function getAuthedInnertube(conn, { pageId } = {}) {
|
||||||
const accessToken = String(conn?.accessToken || '');
|
const accessToken = String(conn?.accessToken || '');
|
||||||
const refreshToken = String(conn?.refreshToken || '');
|
const refreshToken = String(conn?.refreshToken || '');
|
||||||
|
const targetPageId = String(pageId || conn?.ytPageId || '').trim() || null;
|
||||||
if (!accessToken) throw Object.assign(new Error('oauth_not_connected'), { status: 404 });
|
if (!accessToken) throw Object.assign(new Error('oauth_not_connected'), { status: 404 });
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw Object.assign(new Error('google_reconnect_required'), {
|
throw Object.assign(new Error('google_reconnect_required'), {
|
||||||
@@ -618,7 +619,13 @@ async function getAuthedInnertube(conn) {
|
|||||||
}
|
}
|
||||||
await silenceYoutubeiNoise();
|
await silenceYoutubeiNoise();
|
||||||
const { Innertube } = await import('youtubei.js');
|
const { Innertube } = await import('youtubei.js');
|
||||||
const innertube = await Innertube.create({ lang: 'fr', location: 'FR' });
|
// targetPageId = chaîne/brand secondaire : on_behalf_of_user fait envoyer
|
||||||
|
// X-Goog-PageId par youtubei.js (sélecteur de chaîne façon SmartTube).
|
||||||
|
const innertube = await Innertube.create({
|
||||||
|
lang: 'fr',
|
||||||
|
location: 'FR',
|
||||||
|
...(targetPageId ? { on_behalf_of_user: targetPageId } : {}),
|
||||||
|
});
|
||||||
const expiry = Number(conn?.expiresAt || 0);
|
const expiry = Number(conn?.expiresAt || 0);
|
||||||
await innertube.session.oauth.init({
|
await innertube.session.oauth.init({
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -687,9 +694,60 @@ function mapHistoryNode(node, mapVideoNode) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textOf(t) {
|
||||||
|
if (!t) return '';
|
||||||
|
if (typeof t === 'string') return t;
|
||||||
|
if (Array.isArray(t?.runs)) return t.runs.map((r) => r?.text || '').join('');
|
||||||
|
return String(t?.simpleText || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chaînes YouTube du compte (sélecteur façon SmartTube) via account_menu.
|
||||||
|
* La chaîne par défaut peut être une coquille vide (relatedPlaylists sans
|
||||||
|
* watchLater/history) alors que l'activité est sur une chaîne secondaire.
|
||||||
|
*/
|
||||||
|
export async function fetchAccountChannels(conn) {
|
||||||
|
try {
|
||||||
|
const innertube = await getAuthedInnertube(conn);
|
||||||
|
const data = await innertube.session.actions.execute('/account/account_menu', {});
|
||||||
|
const found = new Map();
|
||||||
|
const visit = (node, depth = 0) => {
|
||||||
|
if (!node || typeof node !== 'object' || depth > 12) return;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const e of node) visit(e, depth + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// accountItem : nom + avatar (+ handle/canal à proximité).
|
||||||
|
const name = textOf(node.accountName).trim();
|
||||||
|
if (name) {
|
||||||
|
const blob = JSON.stringify(node).slice(0, 4000);
|
||||||
|
const ch = /UC[A-Za-z0-9_-]{20,}/.exec(blob)?.[0] || '';
|
||||||
|
const handle = /@[A-Za-z0-9._-]{3,}/.exec(textOf(node.accountByline) + ' ' + blob)?.[0] || '';
|
||||||
|
const key = ch || name.toLowerCase();
|
||||||
|
if (!found.has(key)) {
|
||||||
|
found.set(key, {
|
||||||
|
channelId: ch || null,
|
||||||
|
title: name,
|
||||||
|
handle: handle || null,
|
||||||
|
selected: Boolean(node.isSelected || node.isDefault),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const v of Object.values(node)) {
|
||||||
|
if (v && typeof v === 'object') visit(v, depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(data?.data || data);
|
||||||
|
return { channels: [...found.values()].slice(0, 10) };
|
||||||
|
} catch (e) {
|
||||||
|
throwLibHistoryError(e, 'yt_channels');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Historique YouTube via youtubei.js authentifié (getHistory + continuations).
|
* Historique YouTube via youtubei.js authentifié (getHistory + continuations).
|
||||||
* `conn` = { accessToken, refreshToken, expiresAt } (jeton déjà rafraîchi).
|
* `conn` = { accessToken, refreshToken, expiresAt, ytPageId? } (jeton déjà rafraîchi).
|
||||||
*/
|
*/
|
||||||
export async function fetchInnerTubeHistory(conn, max = 200) {
|
export async function fetchInnerTubeHistory(conn, max = 200) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -89,6 +89,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== CHAÎNE 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>Chaîne YouTube utilisée</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">
|
||||||
|
Comptes multi-chaînes / marque : la chaîne par défaut est parfois vide (pas d’historique ni de Regarder plus tard).
|
||||||
|
Choisissez la chaîne où se trouve votre activité réelle.
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button type="button" (click)="loadYtChannels()" [disabled]="ytChannelsLoading()"
|
||||||
|
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">
|
||||||
|
{{ ytChannelsLoading() ? 'Chargement…' : (ytChannels().length ? 'Recharger' : 'Lister mes chaînes') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="ytChannelsError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ ytChannelsError() }}</div>
|
||||||
|
<div *ngIf="ytChannels().length" class="mt-3 space-y-2">
|
||||||
|
<label *ngFor="let c of ytChannels()" class="flex cursor-pointer items-center gap-3 rounded-lg border border-slate-700 bg-slate-800/60 px-3 py-2 text-sm transition hover:border-slate-500">
|
||||||
|
<input type="radio" name="yt-channel" [value]="c.channelId || ''" [ngModel]="ytSelectedChannelId()" (ngModelChange)="ytSelectedChannelId.set($event)" class="accent-red-600" />
|
||||||
|
<span class="flex-1">
|
||||||
|
<span class="block font-medium text-slate-100">{{ c.title }}</span>
|
||||||
|
<span *ngIf="c.handle" class="block text-xs text-slate-400">{{ c.handle }}</span>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="c.selected" class="rounded-full bg-slate-700 px-2 py-0.5 text-xs text-slate-300">actuelle</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" (click)="saveYtChannel()" [disabled]="ytSavingChannel()"
|
||||||
|
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">
|
||||||
|
{{ ytSavingChannel() ? 'Enregistrement…' : 'Utiliser cette chaîne' }}
|
||||||
|
</button>
|
||||||
|
<span *ngIf="ytChannelSaved()" class="text-xs text-green-200">{{ ytChannelSaved() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ==================== HISTORIQUE TAKEOUT ==================== -->
|
<!-- ==================== HISTORIQUE TAKEOUT ==================== -->
|
||||||
<div *ngIf="!loading()" class="mt-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
<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>
|
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">🕘</span>Historique YouTube (Takeout)</h3>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { OAuthImportService, parseTakeoutHistory, type OAuthConnection, type OAuthPreview, type OAuthImportResult } from '../../../services/oauth-import.service';
|
import { OAuthImportService, parseTakeoutHistory, type OAuthConnection, type OAuthPreview, type OAuthImportResult, type YtChannel } from '../../../services/oauth-import.service';
|
||||||
import { PlaylistsService, type Playlist } from '../../../services/playlists.service';
|
import { PlaylistsService, type Playlist } from '../../../services/playlists.service';
|
||||||
|
|
||||||
interface ProviderCard {
|
interface ProviderCard {
|
||||||
@@ -241,6 +241,53 @@ export class ImportComponent implements OnInit {
|
|||||||
userPlaylists = signal<Playlist[]>([]);
|
userPlaylists = signal<Playlist[]>([]);
|
||||||
wlExportPlaylistId = signal<string>('');
|
wlExportPlaylistId = signal<string>('');
|
||||||
|
|
||||||
|
// -------------------- Chaîne YouTube (multi-chaînes / marque) --------------------
|
||||||
|
|
||||||
|
ytChannels = signal<YtChannel[]>([]);
|
||||||
|
ytChannelsLoading = signal(false);
|
||||||
|
ytChannelsError = signal<string | null>(null);
|
||||||
|
ytSelectedChannelId = signal<string>('');
|
||||||
|
ytSavingChannel = signal(false);
|
||||||
|
ytChannelSaved = signal<string | null>(null);
|
||||||
|
|
||||||
|
loadYtChannels(): void {
|
||||||
|
if (!this.isConnected('google') || this.ytChannelsLoading()) return;
|
||||||
|
this.ytChannelsLoading.set(true);
|
||||||
|
this.ytChannelsError.set(null);
|
||||||
|
this.api.ytChannels().subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.ytChannels.set(res?.channels || []);
|
||||||
|
const sel = res?.selected?.channelId || this.connectionOf('google')?.ytChannelId || '';
|
||||||
|
this.ytSelectedChannelId.set(sel);
|
||||||
|
this.ytChannelsLoading.set(false);
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
const code = e?.error?.error || e?.message || 'yt_channels_failed';
|
||||||
|
const detail = e?.error?.detail;
|
||||||
|
this.ytChannelsError.set(detail ? `${code} — ${detail}` : code);
|
||||||
|
this.ytChannelsLoading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveYtChannel(): void {
|
||||||
|
const id = this.ytSelectedChannelId();
|
||||||
|
if (this.ytSavingChannel()) return;
|
||||||
|
this.ytSavingChannel.set(true);
|
||||||
|
this.ytChannelSaved.set(null);
|
||||||
|
this.api.saveYtChannel(id || null, id || null).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.ytChannelSaved.set('Chaîne mémorisée : relancez la sync auto / Regarder plus tard.');
|
||||||
|
this.ytSavingChannel.set(false);
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.ytChannelsError.set(e?.error?.error || e?.message || 'yt_channel_save_failed');
|
||||||
|
this.ytSavingChannel.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
loadWatchLater(): void {
|
loadWatchLater(): void {
|
||||||
this.wlLoading.set(true);
|
this.wlLoading.set(true);
|
||||||
this.wlError.set(null);
|
this.wlError.set(null);
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export interface OAuthConnection {
|
|||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
connectedAt: number | null;
|
connectedAt: number | null;
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
|
ytChannelId?: string | null;
|
||||||
|
ytPageId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YtChannel {
|
||||||
|
channelId: string | null;
|
||||||
|
title: string;
|
||||||
|
handle: string | null;
|
||||||
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OAuthPreview {
|
export interface OAuthPreview {
|
||||||
@@ -82,6 +91,16 @@ export class OAuthImportService {
|
|||||||
ytHistory(max = 200): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string; watchedAt?: string }>; count: number; hasMore: boolean }> {
|
ytHistory(max = 200): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string; watchedAt?: string }>; count: number; hasMore: boolean }> {
|
||||||
return this.http.get<any>(`/api/oauth/google/yt-history?max=${max}`);
|
return this.http.get<any>(`/api/oauth/google/yt-history?max=${max}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Chaînes YouTube du compte (sélecteur multi-chaînes / marque). */
|
||||||
|
ytChannels(): Observable<{ channels: YtChannel[]; selected: { channelId: string | null; pageId: string | null } }> {
|
||||||
|
return this.http.get<any>(`/api/oauth/google/yt-channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mémorise la chaîne à cibler pour l'historique et Regarder plus tard. */
|
||||||
|
saveYtChannel(channelId: string | null, pageId: string | null): Observable<{ selected: { channelId: string | null; pageId: string | null } }> {
|
||||||
|
return this.http.put<any>(`/api/oauth/google/yt-channel`, { channelId, pageId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse local du `watch-history.json` de Google Takeout (confidentiel : rien n'est uploadé tel quel). */
|
/** Parse local du `watch-history.json` de Google Takeout (confidentiel : rien n'est uploadé tel quel). */
|
||||||
|
|||||||
Reference in New Issue
Block a user