Add cumulative selectable Suno track list

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-02 01:37:00 +09:00
parent a60e75b745
commit 73f409a01b
+204 -37
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name ChatGPT to Suno Prompt Copier
// @namespace local.suno-helper
// @version 0.8.8
// @version 0.8.9
// @description Copy structured ChatGPT song prompt versions into Suno fields.
// @author local
// @match https://chatgpt.com/*
@@ -30,7 +30,7 @@
(function () {
"use strict";
const SCRIPT_VERSION = "0.8.8";
const SCRIPT_VERSION = "0.8.9";
const STORAGE_KEY = "chatgptSunoPromptSlots";
const SLOT_COUNT = 2;
const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com");
@@ -115,7 +115,8 @@
right: 16px;
bottom: 16px;
z-index: 2147483647;
width: min(360px, calc(100vw - 32px));
width: min(560px, calc(100vw - 32px));
max-height: min(86vh, 860px);
color: #f5f7fb;
background: rgba(16, 18, 24, 0.94);
border: 1px solid rgba(255, 255, 255, 0.14);
@@ -124,6 +125,9 @@
font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow: hidden;
}
#cgs-panel.cgs-suno {
width: min(620px, calc(100vw - 32px));
}
#cgs-panel.cgs-chatgpt {
right: 18px;
top: 72px;
@@ -144,7 +148,11 @@
background: rgba(255, 255, 255, 0.06);
font-weight: 700;
}
.cgs-body { padding: 10px 12px 12px; }
.cgs-body {
padding: 10px 12px 12px;
max-height: calc(min(86vh, 860px) - 48px);
overflow: auto;
}
.cgs-row {
display: flex;
gap: 6px;
@@ -197,6 +205,47 @@
padding: 4px 7px;
font: inherit;
}
.cgs-track-list {
margin-top: 8px;
max-height: 300px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
background: rgba(0, 0, 0, 0.18);
}
.cgs-track-item {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 38px;
padding: 7px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
cursor: default;
user-select: none;
}
.cgs-track-item:last-child { border-bottom: 0; }
.cgs-track-item:hover { background: rgba(255, 255, 255, 0.08); }
.cgs-track-item.cgs-selected {
background: rgba(47, 124, 246, 0.28);
outline: 1px solid rgba(91, 154, 255, 0.55);
outline-offset: -1px;
}
.cgs-track-index { color: #93a4bd; text-align: right; font-variant-numeric: tabular-nums; }
.cgs-track-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #f5f7fb;
font-weight: 650;
}
.cgs-track-meta { color: #94a3b8; font-size: 11px; }
.cgs-track-badge {
color: #b7c9ff;
font-size: 11px;
white-space: nowrap;
}
`);
function createPanel(title, mode = "") {
@@ -718,11 +767,15 @@
}
function trackMatchesVisibleRows(track, rows) {
if (!rows.length) return false;
return getTrackVisibleIndex(track, rows) >= 0;
}
function getTrackVisibleIndex(track, rows) {
if (!rows.length) return -1;
const title = normalizeMatchText(track.title);
const style = normalizeMatchText(track.style || track.lyrics || "");
if (!title || title.length > 160) return false;
return rows.some((row) => {
if (!title || title.length > 160) return -1;
return rows.findIndex((row) => {
if (!row.normalized.includes(title)) return false;
if (!style) return true;
const styleNeedle = style.slice(0, Math.min(42, style.length));
@@ -772,7 +825,7 @@
.filter((track) => track.id || track.audioUrl || track.imageUrl)
.filter((track) => trackMatchesCurrentUser(track, currentUser))
.filter((track) => trackMatchesVisibleRows(track, visibleRows))
.sort((a, b) => String(a.title).localeCompare(String(b.title), "ko"));
.sort((a, b) => getTrackVisibleIndex(a, visibleRows) - getTrackVisibleIndex(b, visibleRows));
}
function visibleSunoRowsHint() {
@@ -1505,7 +1558,7 @@
}
async function bootSuno() {
const body = createPanel("Suno Prompt Paste");
const body = createPanel("Suno Prompt Paste", "cgs-suno");
const slots = await readSlots();
body.innerHTML = `
<div class="cgs-muted" data-cgs-summary></div>
@@ -1521,13 +1574,16 @@
<div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-scan-library>곡 감지</button>
</div>
<select class="cgs-select" data-cgs-library-track>
<option value="">감지된 곡 없음</option>
</select>
<div class="cgs-muted" data-cgs-selection-summary></div>
<div class="cgs-track-list" data-cgs-track-list></div>
<div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-process-one-mp3>선택 1곡 MP3</button>
<button class="cgs-btn cgs-wide" type="button" data-cgs-process-one-wav>선택 1곡 WAV</button>
</div>
<div class="cgs-row">
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-selected-mp3>선택항목 MP3</button>
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-selected-wav>선택항목 WAV</button>
</div>
<div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-test-companion>연결 테스트</button>
</div>
@@ -1535,27 +1591,81 @@
<button class="cgs-btn cgs-wide" type="button" data-cgs-copy-diagnostics>진단 복사</button>
</div>
<div class="cgs-row">
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-library-mp3>MP3 일괄 + 메타데이터</button>
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-library-mp3>누적 전체 MP3</button>
</div>
<div class="cgs-row">
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-library-wav>WAV 일괄 + FLAC 메타</button>
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-library-wav>누적 전체 WAV</button>
</div>
<div class="cgs-status"></div>
`;
const summary = body.querySelector("[data-cgs-summary]");
const selectionSummary = body.querySelector("[data-cgs-selection-summary]");
const slotSelect = body.querySelector("[data-cgs-slot]");
const libraryTrackSelect = body.querySelector("[data-cgs-library-track]");
const trackList = body.querySelector("[data-cgs-track-list]");
let libraryTracks = [];
const renderLibraryTrackSelect = () => {
const libraryTrackMap = new Map();
const selectedTrackIds = new Set();
let lastSelectedIndex = -1;
let autoScanTimer = 0;
const getSelectedTracks = () => libraryTracks.filter((track) => selectedTrackIds.has(track.id));
const mergeLibraryTracks = (tracks) => {
for (const track of tracks) {
if (!track.id) continue;
if (libraryTrackMap.has(track.id)) {
libraryTrackMap.set(track.id, mergeTrack(libraryTrackMap.get(track.id), track));
} else {
libraryTrackMap.set(track.id, track);
}
}
libraryTracks = [...libraryTrackMap.values()];
};
const renderLibraryTrackList = () => {
const selectedCount = selectedTrackIds.size;
selectionSummary.textContent = libraryTracks.length
? `누적 ${libraryTracks.length}곡 / 선택 ${selectedCount}`
: "누적된 곡 없음. 스크롤하면서 곡 감지를 눌러줘.";
if (!libraryTracks.length) {
libraryTrackSelect.innerHTML = `<option value="">감지된 곡 없음</option>`;
trackList.innerHTML = `<div class="cgs-track-item"><div></div><div class="cgs-track-meta">감지된 곡 없음</div><div></div></div>`;
return;
}
libraryTrackSelect.innerHTML = libraryTracks
.map((track, index) => `<option value="${index}">${escapeHtml(trackOptionLabel(track, index))}</option>`)
trackList.innerHTML = libraryTracks
.map((track, index) => {
const selected = selectedTrackIds.has(track.id);
const badge = [
track.audioUrl ? "MP3" : "",
track.wavUrl ? "WAV" : "",
track.imageUrl ? "IMG" : "",
].filter(Boolean).join(" ");
return `
<div class="cgs-track-item ${selected ? "cgs-selected" : ""}" data-cgs-track-index="${index}" title="${escapeHtml(track.id || "")}">
<div class="cgs-track-index">${index + 1}</div>
<div>
<div class="cgs-track-title">${escapeHtml(track.title || track.id || "제목 없음")}</div>
<div class="cgs-track-meta">${escapeHtml((track.style || track.lyrics || track.id || "").slice(0, 110))}</div>
</div>
<div class="cgs-track-badge">${escapeHtml(badge || "ID")}</div>
</div>
`;
})
.join("");
};
const setSelectedRange = (from, to) => {
const start = Math.max(0, Math.min(from, to));
const end = Math.min(libraryTracks.length - 1, Math.max(from, to));
for (let index = start; index <= end; index += 1) {
if (libraryTracks[index]?.id) selectedTrackIds.add(libraryTracks[index].id);
}
};
const scanAndMergeVisibleTracks = () => {
const scannedTracks = scanSunoLibraryTracks();
const before = libraryTracks.length;
mergeLibraryTracks(scannedTracks);
renderLibraryTrackList();
updateSummary();
return { scannedTracks, added: libraryTracks.length - before };
};
const updateSummary = () => {
const payload = slots[Number(slotSelect.value)];
const track = findCurrentSunoTrack();
@@ -1564,7 +1674,7 @@
const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지";
summary.textContent = promptText + trackText + libraryText;
};
renderLibraryTrackSelect();
renderLibraryTrackList();
updateSummary();
slotSelect.addEventListener("change", updateSummary);
@@ -1593,13 +1703,11 @@
}
body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => {
libraryTracks = scanSunoLibraryTracks();
renderLibraryTrackSelect();
updateSummary();
const sample = libraryTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
if (libraryTracks.length) {
const { scannedTracks, added } = scanAndMergeVisibleTracks();
const sample = scannedTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
if (scannedTracks.length) {
const visibleCount = getVisibleLibraryRows().length;
setStatus(body, `${libraryTracks.length}곡 감지: ${sample} / 화면 행 ${visibleCount}개 기준`);
setStatus(body, `이번 화면 ${scannedTracks.length}곡 감지, 누적 ${libraryTracks.length}곡 (+${added}): ${sample} / 화면 행 ${visibleCount}개 기준`);
return;
}
const visibleHints = visibleSunoRowsHint();
@@ -1607,14 +1715,46 @@
setStatus(body, `${networkHint} 목록 새로고침이나 스크롤 후 다시 눌러봐. 화면 텍스트: ${visibleHints.join(", ")}`);
});
const scheduleAutoScan = () => {
clearTimeout(autoScanTimer);
autoScanTimer = setTimeout(() => {
const result = scanAndMergeVisibleTracks();
if (result.added > 0) {
setStatus(body, `스크롤 감지로 ${result.added}곡 추가, 누적 ${libraryTracks.length}`);
}
}, 450);
};
window.addEventListener("scroll", scheduleAutoScan, { passive: true });
document.addEventListener("scroll", scheduleAutoScan, { passive: true, capture: true });
trackList.addEventListener("click", (event) => {
const item = event.target.closest("[data-cgs-track-index]");
if (!item) return;
const index = Number(item.getAttribute("data-cgs-track-index"));
const track = libraryTracks[index];
if (!track?.id) return;
if (event.shiftKey && lastSelectedIndex >= 0) {
setSelectedRange(lastSelectedIndex, index);
} else if (event.ctrlKey || event.metaKey) {
if (selectedTrackIds.has(track.id)) selectedTrackIds.delete(track.id);
else selectedTrackIds.add(track.id);
lastSelectedIndex = index;
} else {
selectedTrackIds.clear();
selectedTrackIds.add(track.id);
lastSelectedIndex = index;
}
renderLibraryTrackList();
updateSummary();
});
const processSelectedTrack = async (format) => {
if (!libraryTracks.length) {
libraryTracks = scanSunoLibraryTracks();
renderLibraryTrackSelect();
mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackList();
}
updateSummary();
const index = Number(libraryTrackSelect.value || 0);
const track = libraryTracks[index];
const track = getSelectedTracks()[0] || libraryTracks[0];
if (!track) {
setStatus(body, "선택할 감지 곡이 없어. 곡 감지를 먼저 눌러줘.");
return;
@@ -1635,10 +1775,18 @@
await processSelectedTrack("wav");
});
body.querySelector("[data-cgs-process-selected-mp3]").addEventListener("click", async () => {
await processSelectedTracks("mp3");
});
body.querySelector("[data-cgs-process-selected-wav]").addEventListener("click", async () => {
await processSelectedTracks("wav");
});
const processLibraryTracks = async (format) => {
if (!libraryTracks.length) {
libraryTracks = scanSunoLibraryTracks();
renderLibraryTrackSelect();
mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackList();
}
updateSummary();
try {
@@ -1648,6 +1796,24 @@
}
};
const processSelectedTracks = async (format) => {
if (!libraryTracks.length) {
mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackList();
}
const tracks = getSelectedTracks();
if (!tracks.length) {
setStatus(body, "선택된 곡이 없어. 목록에서 Ctrl/Shift로 고른 뒤 눌러줘.");
return;
}
updateSummary();
try {
await uploadTracksToCompanion(tracks, body, format);
} catch (error) {
setStatus(body, `선택항목 ${format.toUpperCase()} 처리 실패: ${error.message || error}`);
}
};
body.querySelector("[data-cgs-process-library-mp3]").addEventListener("click", async () => {
await processLibraryTracks("mp3");
});
@@ -1662,11 +1828,11 @@
body.querySelector("[data-cgs-copy-diagnostics]").addEventListener("click", async () => {
if (!libraryTracks.length) {
libraryTracks = scanSunoLibraryTracks();
renderLibraryTrackSelect();
mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackList();
}
const selectedIndex = Number(libraryTrackSelect.value || 0);
const selectedTrack = libraryTracks[selectedIndex];
const selectedTracks = getSelectedTracks();
const selectedTrack = selectedTracks[0] || libraryTracks[0];
let health = {};
let healthError = "";
try {
@@ -1679,7 +1845,8 @@
location: location.href,
detectedTracks: libraryTracks.length,
visibleRows: getVisibleLibraryRows().length,
selectedIndex,
selectedCount: selectedTracks.length,
selectedIds: selectedTracks.map((track) => track.id),
selectedTrack: selectedTrack ? {
id: selectedTrack.id || "",
title: selectedTrack.title || "",