// ==UserScript==
// @name GGn Unified OST Uploady
// @version 1.3.6
// @author SleepingGiant
// @description Uploady for multi-source OSTs on GGn (e.g. VGMdb, bandcamp, etc.)
// @namespace https://greasyfork.org/users/1395131
// @include https://gazellegames.net/upload.php*
// @match https://gazellegames.net/torrents.php?action=editgroup*
// @require https://code.jquery.com/jquery-3.4.1.min.js
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @grant GM_addStyle
// @grant GM.addStyle
// @downloadURL none
// ==/UserScript==
// Prior Authors: NeutronNoir, ZeDoCaixao, Wealth - do not reach out to them for support, but feel free to thank them for their work :)
var UPLOADY_FIELD = ``;
// tampermonkey intro
(function () {
if (window.location.href.includes("action=editgroup")) {
$("input[name='aliases']").after($(UPLOADY_FIELD));
url_parser_text_entry(editgroup_page_handler);
} else {
$("#categories").click(function () {
var el = $(this);
setTimeout(function () {
$("#catalog_number").remove();
if (el.find(":selected").text() == "OST") {
url_parser_text_entry(upload_page_handler);
}
}, 500);
});
}
// Separate from OST Uploady - may go to its own script. Adds group title case checking.
setInterval(() => {
const descField = document.querySelector("#album_desc") || document.querySelector("textarea[name='body']");
const existingButton = document.querySelector("#titleCaseResults");
if (descField && !existingButton) {
createCheckButtonsUpload();
createCheckButtonsEdit();
}
}, 500);
})();
function url_parser_text_entry(handler) {
$("#categories").after($(UPLOADY_FIELD));
$("#catalog_number").on("blur", function () {
let url = $(this).val();
let input = this;
if (url.includes("vgmdb.net")) {
$("input[name='vgmdburi'], input[name='weblink']").val(url);
}
handleURL(url).then(data => {
handler(data);
}).catch(() => {
$(input).val("Album not found");
});
});
}
function handleURL(url) {
if (url.includes('vgmdb.net')) {
return parseVGMdb(url);
} else if (url.includes('bandcamp.com')) {
return parseBandcamp(url);
} else if (url.includes('music.apple.com')) {
return parseAppleMusic(url);
} else if (url.includes('store.steampowered')) {
return parseSteamOST(url);
} else {
return Promise.reject('Unsupported URL');
}
}
// These two do the actual "uploading" of data to the GGn site by setting the values, `data` is pre-filled from the parser flows for each website.
// If adding a new site, follow the same style and read the comment over `parseVGMdb`
function upload_page_handler(data) {
$("#aliases").val(data.aliases);
$("#album_desc").val(data.album_desc);
$("#title").val(data.title);
$("#year").val(data.year);
$("#image").val(data.image);
}
function editgroup_page_handler(data) {
$("input[name='aliases']").val(data.aliases);
$("textarea[name='body']").val(data.album_desc);
$("input[name='name']").val(data.title);
$("input[name='year']").val(data.year);
$("input[name='image']").val(data.image);
}
/**
* Website parsing section. This comment applies to effectively all parseWebsite functions.
*
* Further below there is also a "xyz website helper method section" (should be commented at each separation)
* This is where all the logic for each individual site will be stored.
* To make scaling to more sites easier, you can think of each website as its own script - where the "master" script just handles the arbitrary data response
* from a website script and fills in the GGn fields with that response object.
*
* @param {*} url - the URL pasted into the textbox (that we will retrieve data from). That's it.
* @returns A standardized data object. The expected schema is:
title: The string to be put in 'Title by Artist' in GGn
aliases: The string to be put in 'Aliases' in GGn
year: The number (it's javascript so in string form normally) that is the year - e.g. 2025
image: Link to the cover image found on the page
album_desc: Pre-formatted description. This should contain EVERYTHING. Header, tracklist, notes, etc. - what each function returns just ends up in the textbox.
tags is purposefully omitted from here as sites that do have them (e.g. bandcamp) often have ones that will not apply to GGn and autofilling bad is worse than not autofilling.
*
*/
function parseVGMdb(url) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url: url,
onload: (response) => {
if (response.status === 200) {
let env = $(response.responseText);
resolve({
aliases: get_aliases_vgmdb(env),
album_desc: get_desc_vgmdb(env) + get_tracks_vgmdb(env) + get_notes_vgmdb(env),
title: get_title_vgmdb(env),
year: get_year_vgmdb(env),
image: get_cover_vgmdb(env)
});
} else {
reject('Error fetching VGMdb');
}
}
});
});
}
function parseBandcamp(url) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url: url,
onload: function (response) {
if (response.status !== 200) {
reject('Error fetching Bandcamp');
return;
}
const doc = new DOMParser().parseFromString(response.responseText, 'text/html');
const jsonLd = doc.querySelector('script[type="application/ld+json"]');
if (!jsonLd) {
reject('No JSON-LD found in the page.');
return;
}
const albumData = JSON.parse(jsonLd.textContent);
let title = get_title_bandcamp(albumData);
let aliases = get_aliases_bandcamp(albumData);
let albumDesc = get_album_desc_bandcamp(albumData);
let year = get_release_date_bandcamp(albumData);
let image = get_cover_art_bandcamp(albumData);
resolve({
aliases: aliases,
album_desc: albumDesc,
title: title,
year: year,
image: image
});
}
});
});
}
function parseAppleMusic(url) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url: url,
onload: (response) => {
if (response.status !== 200) {
reject('Error fetching Apple Music');
return;
}
const doc = new DOMParser().parseFromString(response.responseText, 'text/html');
const schemaScript = doc.querySelector('script[type="application/ld+json"]#schema\\:music-album');
if (!schemaScript) {
reject('Schema script not found');
return;
}
let jsonData;
try {
jsonData = JSON.parse(schemaScript.textContent);
} catch (error) {
reject('Error parsing JSON data');
return;
}
let title = get_title_applemusic(jsonData);
let aliases = get_aliases_applemusic(doc);
let albumDesc = get_album_desc_applemusic(jsonData);
let year = get_release_year_applemusic(doc);
let image = get_cover_art_applemusic(doc);
resolve({
aliases: aliases,
album_desc: albumDesc,
title: title,
year: year,
image: image
});
}
});
});
}
function parseSteamOST(url) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url: url,
onload: (response) => {
if (response.status !== 200) {
reject('Error fetching Steam page');
return;
}
const doc = new DOMParser().parseFromString(response.responseText, 'text/html');
const title = get_title_steam(doc) + " by " + get_artist_steam(doc);
const aliases = get_aliases_steam(doc);
const albumDesc = get_album_desc_steam(doc);
const year = get_release_year_steam(doc);
const image = get_cover_art_steam(doc);
resolve({
aliases: aliases,
album_desc: albumDesc,
title: title,
year: year,
image: image
});
}
});
});
}
// ===Global Helper Method Section===
// We want as much site specific logic to be within its own method, even if it results in code duplication
// This section is for TRULY generic methods that is global across all (e.g. turning totalSeconds -> release total duration format)
function formatTotalDuration_generic(totalSeconds) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0
? `${minutes}:${String(seconds).padStart(2, '0')}`
: `0:${String(seconds).padStart(2, '0')}`; // Format as m:ss or 0:ss
}
// ===VGMdb helper method section===
function get_cover_vgmdb(env) {
return env.find("#coverart").css("background-image").replace(/url\("([^"]*)"\)/, "$1").replace("medium-", "");
}
function get_year_vgmdb(env) {
var dateText = env.find("#album_infobit_large>tbody>tr>td>span>b:contains('Release Date')")
.closest("tr")
.find("td a").first().text().trim(); // Only get the first element's text
var year = new Date(Date.parse(dateText)).getFullYear();
return isNaN(year) ? null : year;
}
function get_title_vgmdb(env) {
return env.find(".albumtitle").first().text();
}
function get_aliases_vgmdb(env) {
var aliases = [];
env.find("#innermain .albumtitle:not(:first)[lang='en']").each(function() {
aliases.push($(this).text().trim());
});
return aliases.join(", ");
}
function get_desc_vgmdb(env) {
var desc = "[align=center][u][b]" + env.find(".albumtitle").first().text() + "[/b]\n[i][size=1]by[/i] [b]" + "" + "[/b][/u][/align]\n\n";
env.find("#album_infobit_large>tbody>tr").each(function () {
// Remove hyperlinks and non-visible fields. These will mess with parsing.
$(this).find("[style*='display:none']").remove();
$(this).find("script").remove();
var title = $(this).find("td>span>b").text();
var value = $(this).find("td").last().text().trim();
if (title && value) {
if (title == "Release Date") {
var dateText = $(this).find("td a").first().text().trim();
var rls_date = new Date(Date.parse(dateText));
desc += "[*][b]" + title + ":[/b] " + rls_date.getFullYear() + "-" + String(rls_date.getMonth() + 1).padStart(2, '0') + "-" + String(rls_date.getDate()).padStart(2, '0') + "\n";
} else {
desc += "[*][b]" + title + ":[/b] " + value + "\n";
}
}
});
return desc;
}
function get_tracks_vgmdb(env) {
let tracks = "\n[align=center][u][b]Tracklist[/b][/u][/align]\n";
// This removes non-visible tracklists to prevent multilanguage duplication
env.find("#tracklist").find("[style*='display: none']").remove();
const disc_count = env.find("#tracklist").text().match(/Disc [0-9]+/g)?.length || 1;
env.find("#tracklist>span>table>tbody").each(function (index) {
if (disc_count > 1) {
tracks += "[b]Disc " + (index + 1) + "[/b]\n";
}
$(this).find("tr").each(function () {
const tds = $(this).find("td");
const track_number = tds.eq(0).text().trim();
const track_title = tds.eq(1).text().trim();
const track_duration = tds.last().text().trim();
if (track_title) {
tracks += "[#] " + track_title + " [i](" + track_duration + ")[/i]\n";
}
});
});
let total_time = "";
if (disc_count > 1) {
total_time = env.find('#tracklist>span>h4>span:nth-child(2)>span:nth-child(4).time').text().trim();
} else {
total_time = env.find('#tracklist>span>span .time').text().trim();
}
if (total_time) {
tracks += "[b]Total Length[/b]: " + total_time;
}
return tracks;
}
function get_notes_vgmdb(env) {
var notes = env.find("#notes").html();
if (notes) {
notes = notes.replace(/
/g, "\n").trim();
notes = notes.replace(/ /g, "");
notes = notes.replace(/&/g, "&");
return "\n\n[quote][align=center][b][u]Notes[/u][/b][/align]\n" + notes + "[/quote]";
}
return "";
}
// Bandcamp helper method section
function get_title_bandcamp(albumData) {
return albumData.name + " by " + albumData.byArtist.name || "";
}
function get_aliases_bandcamp(albumData) {
return "";
}
function get_album_desc_bandcamp(albumData) {
let totalDurationSeconds = 0;
let trackDetails = [];
albumData.track.itemListElement.forEach((track, index) => {
const trackDurationSeconds = parseDuration_bandcamp(track.item.duration);
totalDurationSeconds += trackDurationSeconds;
trackDetails.push({
number: index + 1,
name: track.item.name,
duration: formatTrackDuration_bandcamp(trackDurationSeconds)
});
});
// Format release date as yyyy-mm-dd
let releaseDateFormatted = "";
if (albumData.datePublished) {
const rlsDate = new Date(Date.parse(albumData.datePublished));
releaseDateFormatted =
rlsDate.getFullYear() + "-" +
String(rlsDate.getMonth() + 1).padStart(2, '0') + "-" +
String(rlsDate.getDate()).padStart(2, '0');
}
// Format release price
let releasePriceFormatted = "";
const release = albumData.albumRelease?.[0];
const offer = release?.offers;
if (offer && offer.price != null && offer.priceCurrency) {
albumData.price = offer.price.toFixed(2); // Ensures "xyz.dd" format
albumData.currency = offer.priceCurrency; // "USD"
if (albumData.price && albumData.currency) {
releasePriceFormatted = albumData.price + " " + albumData.currency;
}
}
// Artist name
let artistName = albumData.byArtist?.name || "";
// Notes
let notesFormatted = "";
if (albumData.description) {
let cleanNotes = albumData.description.replace(/
/gi, '\n').trim();
notesFormatted = "\n\n[quote][align=center][b][u]Notes[/u][/b][/align]\n" + cleanNotes + "[/quote]";
}
// Build body
let bodyContent = "[align=center][u][b]" + albumData.name + "[/b]\n" +
"[i][size=1]by[/i] [b]" + artistName + "[/b][/u][/align]\n\n";
if (releaseDateFormatted) {
bodyContent += "[*][b]Release Date:[/b] " + releaseDateFormatted + "\n";
}
if (releasePriceFormatted) {
bodyContent += "[*][b]Release Price:[/b] " + releasePriceFormatted + "\n";
}
if (artistName) {
bodyContent += "[*][b]Artist:[/b] " + artistName + "\n";
}
bodyContent += '\n[align=center][u][b]Tracklist[/b][/u][/align]\n';
trackDetails.forEach(track => {
bodyContent += "[#] " + track.name + " [i](" + track.duration + ")[/i]\n";
});
bodyContent += "[b]Total Length:[/b] " + formatTotalDuration_generic(totalDurationSeconds);
// Append notes
bodyContent += notesFormatted;
return bodyContent;
}
// Adjust the total duration format for the album (without leading zero for total time over 10 minutes)
function get_release_date_bandcamp(albumData) {
if (!albumData.datePublished) return "";
const dateString = albumData.datePublished; // Example: "27 Mar 2025 09:04:37 GMT"
const yearMatch = dateString.match(/\b\d{4}\b/); // Extracts a 4-digit year
return yearMatch ? yearMatch[0] : ""; // Return the year or fallback
}
function get_cover_art_bandcamp(albumData) {
return albumData.image || "No Cover Art Available";
}
function get_tracks_bandcamp(albumData) {
let trackDetails = [];
albumData.track.itemListElement.forEach((track) => {
const trackDurationSeconds = parseDuration_bandcamp(track.item.duration);
trackDetails.push({
name: track.item.name,
duration: formatTrackDuration_bandcamp(trackDurationSeconds)
});
});
return trackDetails;
}
function formatTrackDuration_bandcamp(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${String(remainingSeconds).padStart(2, '0')}`;
}
function parseDuration_bandcamp(durationStr) {
const regex = /^P(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/;
const match = regex.exec(durationStr);
const hours = parseInt(match[1] || 0);
const minutes = parseInt(match[2] || 0);
const seconds = parseInt(match[3] || 0);
return (hours * 3600) + (minutes * 60) + seconds;
}
// Apple Music helper method section
function get_title_applemusic(jsonData) {
// artistName in AppleMusic is an array. This gets the values directly if it is length 1, and defaults to empty string if multiple as mistakes are likely to be made then.
let artistName = Array.isArray(jsonData.byArtist) && jsonData.byArtist.length === 1 ? jsonData.byArtist[0].name : "";
return `${jsonData.name} by ${artistName}`;
}
function get_aliases_applemusic(doc) {
return ""; // Apple Music doesn't typically provide alternate album names
}
function get_album_desc_applemusic(jsonData) {
// artistName in AppleMusic is an array. This gets the values directly if it is length 1, and defaults to empty string if multiple as mistakes are likely to be made then.
let artistName = Array.isArray(jsonData.byArtist) && jsonData.byArtist.length === 1 ? jsonData.byArtist[0].name : "";
let tracks = [];
let totalDuration = 0;
const trackList = jsonData.tracks || [];
trackList.forEach((track) => {
let trackName = track.name || "Unknown Track";
let trackDuration = track.duration || "PT0S";
totalDuration += parseDuration_applemusic(trackDuration);
tracks.push(`[#] ${trackName} [i](${formatDuration_applemusic(trackDuration)})[/i]`);
});
let albumDesc = `[align=center][u][b]${jsonData.name}[/b]
[i][size=1]by[/i] [b]${artistName}[/b][/u][/align]\n\n`;
albumDesc += `[align=center][u][b]Tracklist[/b][/u][/align]\n`;
albumDesc += tracks.join('\n') + `\n[b]Total Length[/b]: ${formatTotalDuration_generic(totalDuration)}`;
return albumDesc;
}
function get_release_year_applemusic(doc) {
let metadataElement = doc.querySelector(".headings__metadata-bottom");
let yearMatch = metadataElement ? metadataElement.textContent.match(/\b(\d{4})\b/) : null;
return yearMatch ? yearMatch[1] : "";
}
function get_cover_art_applemusic(doc) {
// Find the