Add cumulative selectable Suno track list
This commit is contained in:
@@ -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 || "",
|
||||
|
||||
Reference in New Issue
Block a user