- Ambient background in the app shell: three drifting blurred blobs,
the third one tinted with the current track's cover; can be turned off
in settings and stays still with reduced motion.
- Island player variant (floating glass panel) next to the bar, chosen
in settings; player source line ("Album · …", "My Wave"), reactions
around the transport controls, queue drawer.
- Sidebar: like / dislike counters, pending uploads badge on Queue,
admin Settings page holding the upload queue limits.
- Collection: header tile with kicker, underline tabs, time / number
lead column with an equalizer for the current track, album column,
dimmed dislikes with undo, Play all / Shuffle pills.
- Album: cover-tinted header and Play / Shuffle; artist: artist wave,
Play and popular tracks (new GET /artists/{id}/top-tracks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
533 lines
20 KiB
TypeScript
533 lines
20 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { ApiError, unwrap } from '@pipu/core';
|
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession } from '../../app/session';
|
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
|
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
|
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
|
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
|
|
import s from '../page.module.css';
|
|
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
|
|
import u from '../uploads/uploads.module.css';
|
|
|
|
type Meta = components['schemas']['UploadMeta'];
|
|
type Settings = components['schemas']['UploadSettings'];
|
|
|
|
const TABS = {
|
|
review: ['pending_review'],
|
|
failed: ['import_failed'],
|
|
progress: ['uploading', 'processing', 'approved', 'importing'],
|
|
imported: ['imported'],
|
|
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
|
} satisfies Record<string, UploadStatus[]>;
|
|
type Tab = keyof typeof TABS;
|
|
|
|
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
|
|
export function UploadQueuePage() {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const queryClient = useQueryClient();
|
|
const errorText = useErrorText();
|
|
const [tab, setTab] = useState<Tab>('review');
|
|
const [onlyPossible, setOnlyPossible] = useState(false);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [editing, setEditing] = useState<UploadFile | null>(null);
|
|
const [rejecting, setRejecting] = useState(false);
|
|
|
|
const list = useInfiniteQuery({
|
|
queryKey: ['admin-uploads', tab, onlyPossible],
|
|
initialPageParam: undefined as string | undefined,
|
|
queryFn: async ({ pageParam }) =>
|
|
unwrap(
|
|
await session.client.GET('/api/v1/admin/uploads', {
|
|
params: {
|
|
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
|
|
},
|
|
}),
|
|
),
|
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
|
refetchInterval: (q) =>
|
|
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
|
|
});
|
|
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
|
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
|
|
|
|
const refresh = async () => {
|
|
setSelected(new Set());
|
|
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
|
};
|
|
const review = useMutation({
|
|
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
|
|
unwrap(
|
|
await session.client.POST('/api/v1/admin/uploads/review', {
|
|
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
|
|
}),
|
|
),
|
|
onSuccess: refresh,
|
|
});
|
|
const retry = useMutation({
|
|
mutationFn: async (id: string) =>
|
|
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
|
|
onSuccess: refresh,
|
|
});
|
|
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
|
|
|
|
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
|
|
const batches = useMemo(() => {
|
|
const m = new Map<string, UploadFile[]>();
|
|
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
|
|
return [...m.values()];
|
|
}, [items]);
|
|
const toggle = (ids: string[], on: boolean) =>
|
|
setSelected((cur) => {
|
|
const next = new Set(cur);
|
|
for (const id of ids) {
|
|
if (on) next.add(id);
|
|
else next.delete(id);
|
|
}
|
|
return next;
|
|
});
|
|
const selectable = tab === 'review' || tab === 'failed';
|
|
const ids = [...selected];
|
|
|
|
return (
|
|
<div className={s.page}>
|
|
<div className={s.head}>
|
|
<div>
|
|
<h1>{t('uploads.queue_title')}</h1>
|
|
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
|
|
</div>
|
|
<QuotaMeter />
|
|
</div>
|
|
<div className={s.toolbar}>
|
|
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
|
{(Object.keys(TABS) as Tab[]).map((k) => (
|
|
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
|
|
{t(`uploads.tab_${k}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span style={{ flex: 1 }} />
|
|
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
|
|
{t('uploads.only_possible')}
|
|
</label>
|
|
</div>
|
|
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
|
|
{failed.length > 0 && (
|
|
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
|
)}
|
|
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
|
|
|
|
{batches.map((files) => (
|
|
<BatchCard
|
|
key={files[0]!.batch_id}
|
|
files={files}
|
|
selectable={selectable}
|
|
selected={selected}
|
|
onToggle={toggle}
|
|
onEdit={setEditing}
|
|
onRetry={(id) => retry.mutate(id)}
|
|
/>
|
|
))}
|
|
{list.hasNextPage && (
|
|
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
|
{t('uploads.load_more')}
|
|
</Button>
|
|
)}
|
|
|
|
{ids.length > 0 && (
|
|
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
|
|
<span>{t('uploads.selected', { count: ids.length })}</span>
|
|
<span style={{ flex: 1 }} />
|
|
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
|
{t('common:form.cancel')}
|
|
</Button>
|
|
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
|
|
{t('uploads.reject')}
|
|
</Button>
|
|
{tab === 'review' && (
|
|
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
|
|
{t('uploads.approve')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{editing && (
|
|
<EditDialog
|
|
file={editing}
|
|
onClose={() => setEditing(null)}
|
|
onSaved={() => {
|
|
review.reset();
|
|
void refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
{rejecting && (
|
|
<RejectDialog
|
|
count={ids.length}
|
|
busy={review.isPending}
|
|
onClose={() => setRejecting(false)}
|
|
onReject={(reason) => {
|
|
setRejecting(false);
|
|
review.mutate({ decision: 'reject', ids, reason });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Problem из ответа массового решения → объект, понятный useErrorText. */
|
|
function problemError(p: components['schemas']['Problem']) {
|
|
return new ApiError(p.status, p.code, p.params ?? {});
|
|
}
|
|
|
|
function BatchCard({
|
|
files,
|
|
selectable,
|
|
selected,
|
|
onToggle,
|
|
onEdit,
|
|
onRetry,
|
|
}: {
|
|
files: UploadFile[];
|
|
selectable: boolean;
|
|
selected: Set<string>;
|
|
onToggle: (ids: string[], on: boolean) => void;
|
|
onEdit: (f: UploadFile) => void;
|
|
onRetry: (id: string) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation('admin');
|
|
const first = files[0]!;
|
|
const ids = files.map((f) => f.id);
|
|
const all = ids.every((id) => selected.has(id));
|
|
const size = files.reduce((n, f) => n + f.size_bytes, 0);
|
|
return (
|
|
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
|
<div className={u.batchHead}>
|
|
{selectable && (
|
|
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
|
)}
|
|
<b>{first.author.username}</b>
|
|
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
|
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
|
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
|
</div>
|
|
{files.map((f) => (
|
|
<QueueRow
|
|
key={f.id}
|
|
file={f}
|
|
selectable={selectable}
|
|
checked={selected.has(f.id)}
|
|
onCheck={(on) => onToggle([f.id], on)}
|
|
onEdit={() => onEdit(f)}
|
|
onRetry={() => onRetry(f.id)}
|
|
/>
|
|
))}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function QueueRow({
|
|
file: f,
|
|
selectable,
|
|
checked,
|
|
onCheck,
|
|
onEdit,
|
|
onRetry,
|
|
}: {
|
|
file: UploadFile;
|
|
selectable: boolean;
|
|
checked: boolean;
|
|
onCheck: (on: boolean) => void;
|
|
onEdit: () => void;
|
|
onRetry: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation('admin');
|
|
const reason = useReasonText()(f.status_reason);
|
|
const m = f.metadata;
|
|
const quality = [
|
|
f.format?.toUpperCase(),
|
|
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
|
|
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
|
|
f.bit_depth ? `${f.bit_depth} bit` : null,
|
|
f.duration_ms ? formatDuration(f.duration_ms) : null,
|
|
formatBytes(f.size_bytes, i18n.language),
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
const editable = f.status === 'pending_review' || f.status === 'import_failed';
|
|
return (
|
|
<div className={u.queueRow}>
|
|
{selectable ? (
|
|
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
|
|
) : (
|
|
<span />
|
|
)}
|
|
<div className={u.meta}>
|
|
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
|
|
{m && (
|
|
<span className={u.sub}>
|
|
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
{f.metadata_edited && ` · ${t('uploads.edited')}`}
|
|
</span>
|
|
)}
|
|
<span className={u.sub}>{quality}</span>
|
|
<span className={u.sub} title={f.client_rel_path ?? undefined}>
|
|
{f.client_rel_path ?? f.original_name}
|
|
</span>
|
|
</div>
|
|
<div className={u.meta}>
|
|
<div className={u.flags}>
|
|
<UploadStatusBadge status={f.status} />
|
|
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
|
|
{f.matched_album_id ? (
|
|
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
|
|
) : (
|
|
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
|
|
)}
|
|
</div>
|
|
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
|
|
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
|
|
{f.duplicate_level && f.status === 'duplicate' && (
|
|
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
|
|
)}
|
|
</div>
|
|
<div className={u.rowActions}>
|
|
{editable && (
|
|
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
|
<Icon src={editIcon} size={18} />
|
|
</Button>
|
|
)}
|
|
{f.status === 'import_failed' && (
|
|
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
|
|
{t('uploads.retry')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
|
|
|
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const errorText = useErrorText();
|
|
const fieldError = useFieldError();
|
|
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
|
|
const [source, setSource] = useState(file.target_source_id ?? '');
|
|
const sources = useQuery({
|
|
queryKey: ['sources'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
|
});
|
|
const save = useMutation({
|
|
mutationFn: async () =>
|
|
unwrap(
|
|
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
|
|
params: { path: { id: file.id } },
|
|
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
|
|
}),
|
|
),
|
|
onSuccess: () => {
|
|
onSaved();
|
|
onClose();
|
|
},
|
|
});
|
|
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
|
|
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
|
|
);
|
|
const num = (k: 'year' | 'track' | 'disc') => (
|
|
<Field
|
|
label={t(`uploads.meta_${k}`)}
|
|
value={m[k] === null ? '' : String(m[k])}
|
|
inputMode="numeric"
|
|
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
|
|
/>
|
|
);
|
|
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
|
|
return (
|
|
<Dialog
|
|
title={t('uploads.edit_title')}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('common:form.cancel')}
|
|
</Button>
|
|
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
|
|
{t('uploads.save')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className={s.sessions}>
|
|
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
|
|
<div className={u.grid}>
|
|
{text('artist')}
|
|
{text('albumartist')}
|
|
{text('album')}
|
|
{text('title')}
|
|
{text('genre')}
|
|
{num('year')}
|
|
{num('track')}
|
|
{num('disc')}
|
|
</div>
|
|
<label className={s.sessions}>
|
|
<span>{t('uploads.target_source')}</span>
|
|
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
|
|
<option value="">{t('uploads.target_default')}</option>
|
|
{accepting.map((x) => (
|
|
<option key={x.id} value={x.id}>
|
|
{x.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<p className={s.note}>{t('uploads.tags_note')}</p>
|
|
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function RejectDialog({
|
|
count,
|
|
busy,
|
|
onClose,
|
|
onReject,
|
|
}: {
|
|
count: number;
|
|
busy: boolean;
|
|
onClose: () => void;
|
|
onReject: (reason: string) => void;
|
|
}) {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const [reason, setReason] = useState('');
|
|
return (
|
|
<Dialog
|
|
title={t('uploads.reject_title', { count })}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('common:form.cancel')}
|
|
</Button>
|
|
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
|
|
{t('uploads.reject')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<label className={s.sessions}>
|
|
<span>{t('uploads.reject_reason')}</span>
|
|
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
|
|
</label>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
const GB = 1024 ** 3;
|
|
|
|
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
|
export function UploadSettingsSection() {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const queryClient = useQueryClient();
|
|
const errorText = useErrorText();
|
|
const settings = useQuery({
|
|
queryKey: ['upload-settings'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
|
|
});
|
|
const sources = useQuery({
|
|
queryKey: ['sources'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
|
});
|
|
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
|
|
useEffect(() => {
|
|
if (settings.data) setForm(toForm(settings.data));
|
|
}, [settings.data]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: async () => {
|
|
const f = form!;
|
|
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
|
|
return unwrap(
|
|
await session.client.PATCH('/api/v1/admin/settings/uploads', {
|
|
body: {
|
|
queue_limit_bytes: gb(f.queue),
|
|
max_file_bytes: gb(f.maxFile),
|
|
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
|
|
default_source_id: f.source || null,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
onSuccess: async (data) => {
|
|
queryClient.setQueryData(['upload-settings'], data);
|
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
|
},
|
|
});
|
|
if (!form) return null;
|
|
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
|
|
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
|
|
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
|
|
return (
|
|
<section className={s.section}>
|
|
<h2>{t('uploads.settings_title')}</h2>
|
|
<div className={u.grid}>
|
|
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
|
|
<Field
|
|
label={t('uploads.set_per_user')}
|
|
value={form.perUser}
|
|
hint={t('uploads.set_per_user_hint')}
|
|
onChange={(v) => setForm({ ...form, perUser: v })}
|
|
inputMode="decimal"
|
|
/>
|
|
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
|
<label className={s.sessions}>
|
|
<span>{t('uploads.set_source')}</span>
|
|
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
|
<option value="">{t('uploads.set_source_auto')}</option>
|
|
{sources.data
|
|
?.filter((x) => x.accepts_uploads)
|
|
.map((x) => (
|
|
<option key={x.id} value={x.id}>
|
|
{x.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<p className={s.note}>{t('uploads.set_note')}</p>
|
|
<div className={s.toolbar}>
|
|
<span style={{ flex: 1 }} />
|
|
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
|
|
{t('uploads.save')}
|
|
</Button>
|
|
</div>
|
|
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
|
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function toForm(st: Settings) {
|
|
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
|
|
return {
|
|
queue: gb(st.queue_limit_bytes),
|
|
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
|
|
maxFile: gb(st.max_file_bytes),
|
|
source: st.default_source_id ?? '',
|
|
};
|
|
}
|