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== // ==UserScript==
// @name ChatGPT to Suno Prompt Copier // @name ChatGPT to Suno Prompt Copier
// @namespace local.suno-helper // @namespace local.suno-helper
// @version 0.8.8 // @version 0.8.9
// @description Copy structured ChatGPT song prompt versions into Suno fields. // @description Copy structured ChatGPT song prompt versions into Suno fields.
// @author local // @author local
// @match https://chatgpt.com/* // @match https://chatgpt.com/*
@@ -30,7 +30,7 @@
(function () { (function () {
"use strict"; "use strict";
const SCRIPT_VERSION = "0.8.8"; const SCRIPT_VERSION = "0.8.9";
const STORAGE_KEY = "chatgptSunoPromptSlots"; const STORAGE_KEY = "chatgptSunoPromptSlots";
const SLOT_COUNT = 2; const SLOT_COUNT = 2;
const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com"); const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com");
@@ -115,7 +115,8 @@
right: 16px; right: 16px;
bottom: 16px; bottom: 16px;
z-index: 2147483647; z-index: 2147483647;
width: min(360px, calc(100vw - 32px)); width: min(560px, calc(100vw - 32px));
max-height: min(86vh, 860px);
color: #f5f7fb; color: #f5f7fb;
background: rgba(16, 18, 24, 0.94); background: rgba(16, 18, 24, 0.94);
border: 1px solid rgba(255, 255, 255, 0.14); 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; font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow: hidden; overflow: hidden;
} }
#cgs-panel.cgs-suno {
width: min(620px, calc(100vw - 32px));
}
#cgs-panel.cgs-chatgpt { #cgs-panel.cgs-chatgpt {
right: 18px; right: 18px;
top: 72px; top: 72px;
@@ -144,7 +148,11 @@
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
font-weight: 700; 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 { .cgs-row {
display: flex; display: flex;
gap: 6px; gap: 6px;
@@ -197,6 +205,47 @@
padding: 4px 7px; padding: 4px 7px;
font: inherit; 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 = "") { function createPanel(title, mode = "") {
@@ -718,11 +767,15 @@
} }
function trackMatchesVisibleRows(track, rows) { 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 title = normalizeMatchText(track.title);
const style = normalizeMatchText(track.style || track.lyrics || ""); const style = normalizeMatchText(track.style || track.lyrics || "");
if (!title || title.length > 160) return false; if (!title || title.length > 160) return -1;
return rows.some((row) => { return rows.findIndex((row) => {
if (!row.normalized.includes(title)) return false; if (!row.normalized.includes(title)) return false;
if (!style) return true; if (!style) return true;
const styleNeedle = style.slice(0, Math.min(42, style.length)); const styleNeedle = style.slice(0, Math.min(42, style.length));
@@ -772,7 +825,7 @@
.filter((track) => track.id || track.audioUrl || track.imageUrl) .filter((track) => track.id || track.audioUrl || track.imageUrl)
.filter((track) => trackMatchesCurrentUser(track, currentUser)) .filter((track) => trackMatchesCurrentUser(track, currentUser))
.filter((track) => trackMatchesVisibleRows(track, visibleRows)) .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() { function visibleSunoRowsHint() {
@@ -1505,7 +1558,7 @@
} }
async function bootSuno() { async function bootSuno() {
const body = createPanel("Suno Prompt Paste"); const body = createPanel("Suno Prompt Paste", "cgs-suno");
const slots = await readSlots(); const slots = await readSlots();
body.innerHTML = ` body.innerHTML = `
<div class="cgs-muted" data-cgs-summary></div> <div class="cgs-muted" data-cgs-summary></div>
@@ -1521,13 +1574,16 @@
<div class="cgs-row"> <div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-scan-library>곡 감지</button> <button class="cgs-btn cgs-wide" type="button" data-cgs-scan-library>곡 감지</button>
</div> </div>
<select class="cgs-select" data-cgs-library-track> <div class="cgs-muted" data-cgs-selection-summary></div>
<option value="">감지된 곡 없음</option> <div class="cgs-track-list" data-cgs-track-list></div>
</select>
<div class="cgs-row"> <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-mp3>선택 1곡 MP3</button>
<button class="cgs-btn cgs-wide" type="button" data-cgs-process-one-wav>선택 1곡 WAV</button> <button class="cgs-btn cgs-wide" type="button" data-cgs-process-one-wav>선택 1곡 WAV</button>
</div> </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"> <div class="cgs-row">
<button class="cgs-btn cgs-wide" type="button" data-cgs-test-companion>연결 테스트</button> <button class="cgs-btn cgs-wide" type="button" data-cgs-test-companion>연결 테스트</button>
</div> </div>
@@ -1535,27 +1591,81 @@
<button class="cgs-btn cgs-wide" type="button" data-cgs-copy-diagnostics>진단 복사</button> <button class="cgs-btn cgs-wide" type="button" data-cgs-copy-diagnostics>진단 복사</button>
</div> </div>
<div class="cgs-row"> <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>
<div class="cgs-row"> <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>
<div class="cgs-status"></div> <div class="cgs-status"></div>
`; `;
const summary = body.querySelector("[data-cgs-summary]"); const summary = body.querySelector("[data-cgs-summary]");
const selectionSummary = body.querySelector("[data-cgs-selection-summary]");
const slotSelect = body.querySelector("[data-cgs-slot]"); 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 = []; 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) { 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; return;
} }
libraryTrackSelect.innerHTML = libraryTracks trackList.innerHTML = libraryTracks
.map((track, index) => `<option value="${index}">${escapeHtml(trackOptionLabel(track, index))}</option>`) .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(""); .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 updateSummary = () => {
const payload = slots[Number(slotSelect.value)]; const payload = slots[Number(slotSelect.value)];
const track = findCurrentSunoTrack(); const track = findCurrentSunoTrack();
@@ -1564,7 +1674,7 @@
const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지"; const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지";
summary.textContent = promptText + trackText + libraryText; summary.textContent = promptText + trackText + libraryText;
}; };
renderLibraryTrackSelect(); renderLibraryTrackList();
updateSummary(); updateSummary();
slotSelect.addEventListener("change", updateSummary); slotSelect.addEventListener("change", updateSummary);
@@ -1593,13 +1703,11 @@
} }
body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => { body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => {
libraryTracks = scanSunoLibraryTracks(); const { scannedTracks, added } = scanAndMergeVisibleTracks();
renderLibraryTrackSelect(); const sample = scannedTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
updateSummary(); if (scannedTracks.length) {
const sample = libraryTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
if (libraryTracks.length) {
const visibleCount = getVisibleLibraryRows().length; const visibleCount = getVisibleLibraryRows().length;
setStatus(body, `${libraryTracks.length}곡 감지: ${sample} / 화면 행 ${visibleCount}개 기준`); setStatus(body, `이번 화면 ${scannedTracks.length}곡 감지, 누적 ${libraryTracks.length}곡 (+${added}): ${sample} / 화면 행 ${visibleCount}개 기준`);
return; return;
} }
const visibleHints = visibleSunoRowsHint(); const visibleHints = visibleSunoRowsHint();
@@ -1607,14 +1715,46 @@
setStatus(body, `${networkHint} 목록 새로고침이나 스크롤 후 다시 눌러봐. 화면 텍스트: ${visibleHints.join(", ")}`); 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) => { const processSelectedTrack = async (format) => {
if (!libraryTracks.length) { if (!libraryTracks.length) {
libraryTracks = scanSunoLibraryTracks(); mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackSelect(); renderLibraryTrackList();
} }
updateSummary(); updateSummary();
const index = Number(libraryTrackSelect.value || 0); const track = getSelectedTracks()[0] || libraryTracks[0];
const track = libraryTracks[index];
if (!track) { if (!track) {
setStatus(body, "선택할 감지 곡이 없어. 곡 감지를 먼저 눌러줘."); setStatus(body, "선택할 감지 곡이 없어. 곡 감지를 먼저 눌러줘.");
return; return;
@@ -1635,10 +1775,18 @@
await processSelectedTrack("wav"); 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) => { const processLibraryTracks = async (format) => {
if (!libraryTracks.length) { if (!libraryTracks.length) {
libraryTracks = scanSunoLibraryTracks(); mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackSelect(); renderLibraryTrackList();
} }
updateSummary(); updateSummary();
try { 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 () => { body.querySelector("[data-cgs-process-library-mp3]").addEventListener("click", async () => {
await processLibraryTracks("mp3"); await processLibraryTracks("mp3");
}); });
@@ -1662,11 +1828,11 @@
body.querySelector("[data-cgs-copy-diagnostics]").addEventListener("click", async () => { body.querySelector("[data-cgs-copy-diagnostics]").addEventListener("click", async () => {
if (!libraryTracks.length) { if (!libraryTracks.length) {
libraryTracks = scanSunoLibraryTracks(); mergeLibraryTracks(scanSunoLibraryTracks());
renderLibraryTrackSelect(); renderLibraryTrackList();
} }
const selectedIndex = Number(libraryTrackSelect.value || 0); const selectedTracks = getSelectedTracks();
const selectedTrack = libraryTracks[selectedIndex]; const selectedTrack = selectedTracks[0] || libraryTracks[0];
let health = {}; let health = {};
let healthError = ""; let healthError = "";
try { try {
@@ -1679,7 +1845,8 @@
location: location.href, location: location.href,
detectedTracks: libraryTracks.length, detectedTracks: libraryTracks.length,
visibleRows: getVisibleLibraryRows().length, visibleRows: getVisibleLibraryRows().length,
selectedIndex, selectedCount: selectedTracks.length,
selectedIds: selectedTracks.map((track) => track.id),
selectedTrack: selectedTrack ? { selectedTrack: selectedTrack ? {
id: selectedTrack.id || "", id: selectedTrack.id || "",
title: selectedTrack.title || "", title: selectedTrack.title || "",