Skip to content

Instantly share code, notes, and snippets.

@M-rcus
Last active March 13, 2024 17:15
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save M-rcus/a29673a5fcf22afd0e67d549b36496a7 to your computer and use it in GitHub Desktop.
Save M-rcus/a29673a5fcf22afd0e67d549b36496a7 to your computer and use it in GitHub Desktop.
Userscript to allow you to download media from Fansly (no it doesn't work for media you normally wouldn't have access to).

Fansly Download

A work-in-progress userscript for downloading media from Fansly.

Installation:

  1. Install a userscript extension (such as Violentmonkey).
  2. Click on this link and your userscript extension should prompt you to install.
  3. Go on a Fansly post, make sure to click on the post so the URL looks something like: https://fansly.com/post/123456789...
  4. Click on the three dots top-right of the post. You should see a "Download media" option:

// ==UserScript==
// @name Fansly - Download single posts & messages
// @namespace github.com/M-rcus
// @match https://fansly.com/*
// @grant unsafeWindow
// @grant GM_download
// @grant GM_setValue
// @grant GM_getValue
// @require https://m.leak.fans/ujs/violentmonkey-dom-v1.0.9.js
// @downloadUrL https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7/raw/fansly-download.user.js
// @updateUrl https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7/raw/fansly-download.user.js
// @icon https://m.leak.fans/ujs/fansly-icon.png
// @version 0.6.0
// @author M
// @description Work in progress userscript for download media of single posts & message media on Fansly.
// ==/UserScript==
/**
* Usage:
* - Make sure to visit a singular "post" (url = fansly.com/post/111222333444). Click the three dots top-right of the post and there should be a "Download media" option.
* - v0.2.0 introduced experimental messages support as well:
* - Go to your Fansly messages and select a "message thread" on the sidebar.
* - Above the message thread list, there should be a download icon that pops up: https://i.im.ge/2022/08/17/OqDqtc.2022-08-17-nkWxV3.png
* - v0.6.0 should fix image downloading. Video downloading is still kind of low resolutions for newer posts, as Fansly uses M3U8 playlists (which can't really be merged into MP4s easily via a simple userscript).
* - Advanced users are recommended to set the `SCRIPT_DOWNLOAD` value to true, which will give you a Bash script that utilizes `curl` and `yt-dlp` to download images/videos.
*/
const downloadIconClasses = 'fal fa-fw fa-file-upload fa-rotate-180 pointer';
/**
* curl and yt-dlp (for m3u8 files) commands will be put into a .sh script and that will be downloaded instead.
* Alternative method, since browsers have a tendency to get a bit sluggish when you're downloading 30+ files all at once.
*
* For the time being, if you want this to work, you'll have to go on the "Values" tab at the top of this script and set `SCRIPT_DOWNLOAD` to true.
*/
const scriptDownload = GM_getValue('SCRIPT_DOWNLOAD', false);
/**
* Helper function to save text as a file (primarily for scriptDownload).
*/
const saveAs = (function () {
var a = document.createElement("a");
document.body.appendChild(a);
a.style = "display: none";
return function (data, fileName) {
var blob = new Blob([data], {type: "octet/stream"});
var url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
};
}());
/**
* Create a timestamp
*/
function formatTimestamp(timestamp)
{
const date = new Date(timestamp * 1000);
return date.toISOString().split('T')[0];
}
function copyToClipboard(str)
{
const el = document.createElement('textarea');
el.value = str;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
function getAngularAttribute(element)
{
const attributes = Array.from(element.attributes);
const relevantAttribute = attributes.find(x => x.name.includes('_ngcontent'));
if (!relevantAttribute) {
console.error('Has no relevant attributes', element, attributes);
return 'unable-to-find-it';
}
return relevantAttribute.name;
}
/**
* Extract token from localStorage
*/
function getToken()
{
const ls = unsafeWindow.localStorage;
const session = JSON.parse(ls.getItem('session_active_session'));
return session.token;
}
unsafeWindow.getAuthToken = getToken;
/**
* Gets the position of the current accountMedia
*
* @param {Object} input Full response of a "get posts" request
* @param {Object} accountMedia Current accountMedia object.
* @param {Boolean} asNumber Return the position as a number, instead of a formatted string. Default: false
*/
function getPosition(input, accountMedia, asNumber)
{
const accountMediaId = accountMedia.id;
const { accountMediaBundles } = input.response;
let position = null;
if (!accountMediaBundles) {
return position;
}
const bundle = accountMediaBundles.find(x => x.accountMediaIds.includes(accountMediaId));
if (bundle) {
const bundleContent = bundle.bundleContent;
const getPosition = bundleContent.find(x => x.accountMediaId === accountMediaId);
if (getPosition) {
// Positions start from 0, so we add 1.
position = getPosition.pos + 1;
}
}
if (asNumber || position === null) {
return position;
}
if (position < 10) {
position = `0${position}`;
}
return `${position}`;
}
let fileIncrements = {};
/**
* For handling M3U8 playlists
* @param {Object} media
* @param {String} filename
* @param {Boolean} asCurl
* @returns String|null Either a curl/yt-dlp command or null if no playlist is found.
*/
function getVideoDownloadCommand(media, filename, asCurl)
{
const { variants } = media;
const playlist = variants.find(file => file.type === 202);
if (!playlist || playlist.locations.length === 0) {
return null;
}
const metadata = JSON.parse(playlist.metadata);
let width = 360;
const resolutionVariants = metadata.variants || [];
for (const variant of resolutionVariants)
{
const w = variant.w;
if (w > width) {
width = w;
}
}
const location = playlist.locations[0];
const url = location.location.replace('.m3u8', `_${width}.m3u8`);
const cookies = location.metadata;
let cookieHeader = [];
for (const name in cookies)
{
const value = cookies[name];
cookieHeader.push(`CloudFront-${name}=${value}`);
}
if (asCurl) {
return `curl -L -o "${filename}" -H "Origin: https://fansly.com" -H "Referer: https://fansly.com/" -H "Cookie: ${cookieHeader.join('; ')}" "${url}"`
}
return `yt-dlp -o "${filename}" --add-header "Origin:https://fansly.com" --add-header "Referer:https://fansly.com/" --add-header "Cookie:${cookieHeader.join('; ')}" "${url}"`;
}
let cmds = [];
/**
* @param {Object} input The whole post API response
* @param {Object} accountMedia The `accountMedia` object
* @param {Number} createdAt Timestamp in seconds (not milliseconds)
* @param {Object} media The `media` key inside the `accountMedia` object (legacy)
* @param {Object} metaType Used for differentiating between "preview" and unlocked posts.
*/
function extractMediaAndPreview(input, accountMedia, createdAt, media, metaType)
{
let { filename, locations, id, variants, mimetype, post } = media;
let usesVariants = false;
if (!locations || locations.length === 0) {
if (!variants || variants.length === 0) {
return;
}
usesVariants = true;
locations = variants;
}
/**
* Download best quality of video even if the "original" quality currently isn't available
* Seems like Fansly isn't the quickest when it comes to processing videos.
*/
let url;
let fileId = id;
/**
* Variants aka... quality options? Rescaled/reencoded lower resolutions I believe.
* See if statement above.
*
* This handles the 'variants' section and retrieves file ID, mimetype etc. from the variant.
* The default/fallback `location` is basically the "root" media object.
*/
if (usesVariants) {
for (const variant of locations)
{
const loc = variant.locations;
if (!loc[0] || !loc[0].location) {
continue;
}
url = loc[0].location;
filename = variant.filename;
mimetype = variant.mimetype;
fileId = variant.id;
console.log('Variant', variant);
// End the loop on first match, or else it will overwrite with the worse qualities
break;
}
} else {
url = locations[0].location;
}
if (!url) {
console.log(`No file found for media: ${id}`);
return;
}
/**
* Remove the file extension from the filename
* And use the mimetype for the final file extension
*/
let fileIncrement = parseInt(fileIncrements[fileId], 10);
if (isNaN(fileIncrement)) {
fileIncrement = 0;
}
fileIncrement++;
fileIncrements[fileId] = fileIncrement;
if (filename) {
filename = filename.replace(/\.+[\w]+$/, '');
}
else {
filename = fileIncrement < 10 ? `0${fileIncrement}` : `${fileIncrement}`;
}
const filetype = mimetype.replace(/^[\w]+\//, '');
/**
* Make sure metaType is formatted properly for use in filename.
*/
if (!metaType) {
metaType = '';
} else {
metaType = metaType + '_';
}
let postId = createdAt;
if (post) {
postId = post.id;
}
const position = getPosition(input, accountMedia);
const date = formatTimestamp(createdAt);
let filenameSegments = [
date,
postId,
id,
fileId,
];
if (position !== null) {
filenameSegments.splice(2, 0, position);
}
const finalFilename = `${filenameSegments.join('_')}.${filetype}`;
let downloadCmd = `curl -Lo "${finalFilename}" -H "Origin: https://fansly.com" -H "Referer: https://fansly.com/" "${url}"`;
if (filetype === 'mp4' && scriptDownload) {
const newCmd = getVideoDownloadCommand(media, finalFilename);
if (newCmd) {
downloadCmd = newCmd;
}
}
console.log(`Found file: ${finalFilename} - Triggering download...`);
if (!scriptDownload) {
GM_download({
method: 'GET',
url: url,
name: finalFilename,
saveAs: false,
});
}
else {
cmds.push(downloadCmd);
}
}
async function getMediaByIds(mediaIds)
{
const response = await apiFetch(`/account/media?ids=${mediaIds.join(',')}&ngsw-bypass=true`);
const medias = await response.json();
return medias;
}
/**
* Filters media and attempts to download available media.
* Some posts are locked, but have open previews. Open previews will be downloaded.
*/
async function filterMedia(input, noPreview, maxCount)
{
cmds = [];
if (!input) {
if (!unsafeWindow.temp1) {
console.error('No temp1 var');
return;
}
input = unsafeWindow.temp1;
}
/**
* New in v0.6.0
*/
let mediaIds = [];
let medias = input.response.accountMedia || input.response.aggregationData.accountMedia;
const bundles = input.response.accountMediaBundles || [];
for (const bundle of bundles)
{
const bundleMediaIds = bundle.accountMediaIds || [];
mediaIds = [...mediaIds, ...bundleMediaIds];
}
// Get rid of dupes
mediaIds = [... new Set(mediaIds)];
// Get rid of any media objects we're about to fetch from the API.
medias = medias.filter(x => !mediaIds.includes(x.id));
const mediaResponse = await getMediaByIds(mediaIds);
medias = [...medias, ...mediaResponse.response];
const mediaCount = medias.length;
maxCount = maxCount || mediaCount;
let currentCount = 0;
for (const entry of medias)
{
currentCount++;
if (currentCount > maxCount) {
break;
}
const { createdAt, media, preview } = entry;
const mediaId = media.id;
const posts = input.response.posts || [];
let thePost = null;
if (posts.length === 1) {
thePost = posts[0];
}
media.post = thePost;
// Trigger download for `media` (unlocked)
extractMediaAndPreview(input, entry, createdAt, media);
if (!preview || noPreview) {
continue;
}
const previewId = preview.id;
const previewPost = posts.find((post) => {
const attachments = post.attachments || [];
if (attachments.length === 0) {
return false;
}
const attachment = attachments.find(att => att.contentId === mediaId);
return attachment !== undefined;
});
preview.post = thePost;
// Trigger download for locked media, with available previews.
extractMediaAndPreview(input, entry, createdAt, preview, 'preview_');
}
if (scriptDownload) {
saveAs(cmds.join('\n'), `fansly_${Date.now()}.sh`);
}
}
unsafeWindow.filterMedia = filterMedia;
async function apiFetch(path)
{
if (!path) {
console.error('No path specified in apiFetch!');
return;
}
let finalUrl = '';
/**
* If a complete URL is specified, we just request it directly.
*/
if (path.includes('https://')) {
finalUrl = path;
}
else {
if (path[0] !== '/') {
path = '/' + path;
}
finalUrl = `https://apiv3.fansly.com/api/v1${path}`;
}
const request = await fetch(finalUrl, {
'headers': {
'accept': 'application/json',
'authorization': getToken(),
},
'referrer': 'https://fansly.com/',
'referrerPolicy': 'strict-origin-when-cross-origin',
'method': 'GET',
'mode': 'cors',
'credentials': 'include',
});
return request;
}
async function apiPost(path, body = {})
{
if (!path) {
console.error('No path specified in apiFetch!');
return;
}
let finalUrl = '';
/**
* If a complete URL is specified, we just request it directly.
*/
if (path.includes('https://')) {
finalUrl = path;
}
else {
if (path[0] !== '/') {
path = '/' + path;
}
finalUrl = `https://apiv3.fansly.com/api/v1${path}`;
}
const request = await fetch(finalUrl, {
'headers': {
'accept': 'application/json',
'authorization': getToken(),
},
'referrer': 'https://fansly.com/',
'referrerPolicy': 'strict-origin-when-cross-origin',
'method': 'POST',
'body': JSON.stringify(body),
'mode': 'cors',
'credentials': 'include',
});
return request;
}
unsafeWindow.apiFetch = apiFetch;
/**
* Get post data for a post ID and print cURL commands.
*/
async function getPost(postId, returnValue)
{
const request = await apiFetch(`/post?ids=${postId}`);
const response = await request.json();
if (returnValue) {
console.log('Post response', response);
return response;
}
filterMedia(response);
}
unsafeWindow.getPost = getPost;
const cachedMessageGroups = {};
async function fetchAllMessageGroups()
{
const request = await apiFetch('/messaging/groups?limit=100000');
const apiResponse = await request.json();
if (!apiResponse.success) {
console.error(apiResponse);
return null;
}
const { response } = apiResponse;
for (const groupMeta of response.data)
{
const { groupId, partnerAccountId } = groupMeta;
const accountMeta = response.aggregationData.accounts.find(x => x.id === partnerAccountId) || null;
const messageMeta = response.aggregationData.groups.find(x => x.createdBy === partnerAccountId) || null;
cachedMessageGroups[groupId] = {
group: groupMeta,
account: accountMeta,
messageMeta,
};
}
return apiResponse;
}
/**
* Insert 'Download media' entry in the post dropdown
*/
async function handleSinglePost(dropdown, postId)
{
const btn = document.createElement('div');
btn.classList.add('dropdown-item');
btn.innerHTML = '<i class="fa-fw fal fa-download"></i>Download media';
btn.setAttribute('_ngcontent-yeo-c123', '');
btn.addEventListener('click', async () => {
await getPost(postId);
});
dropdown.insertAdjacentElement('beforeend', btn);
}
/**
* Fetch messages and cache them during navigation.
*/
const cachedMessages = {};
const messageSyncSelector = '.fal.fa-arrows-rotate';
async function handleMessages(groupId, force)
{
if (!force && cachedMessages[groupId]) {
addDownloadMessageMediaButton();
return;
}
fetchAllMessageGroups();
const request = await apiFetch(`/message?groupId=${groupId}&limit=200000`);
const messages = await request.json();
cachedMessages[groupId] = messages;
addDownloadMessageMediaButton();
console.log('Messages', messages);
}
async function getMessageMedia(groupId, messageId)
{
const cached = cachedMessages[groupId];
if (!cached) {
await handleMessages(groupId, true);
}
const messages = cached.response.messages;
const message = messages.find(x => x.id === messageId);
if (!message) {
console.error(`Could not find message ID ${messageId} for group ID ${groupId}`);
return;
}
const data = cached.response;
let medias = [];
let bundles = [];
for (const attachment of message.attachments)
{
const { contentId, contentType } = attachment;
let messageMedias = data.accountMedia.filter(x => x.id === contentId);
/**
* From what I know:
* contentType = 1 = accountMedia
* contentType = 2 = accountMediaBundle
*/
if (contentType === 2) {
const bundle = data.accountMediaBundles.find(x => x.id === contentId);
if (!bundle) {
continue;
}
const mediaIds = bundle.accountMediaIds;
const accountMedias = data.accountMedia.filter(x => mediaIds.includes(x.id));
messageMedias = [...messageMedias, ...accountMedias];
bundles.push(bundle);
}
medias = [...medias, ...messageMedias];
}
return {
medias,
bundles,
};
}
/**
* Adds download button in the message view
*/
function addDownloadMessageMediaButton()
{
if (hasDownloadMessageMediaButton()) {
return;
}
const sync = document.querySelector(messageSyncSelector);
if (!sync) {
console.log('Cannot find sync selector', messageSyncSelector);
return;
}
const parent = sync.parentElement;
let cloned = parent.cloneNode(false);
cloned.innerHTML = `<i _ngcontent-opw-c157="" class="${downloadIconClasses} blue-1"></i>`;
cloned.setAttribute('id', 'downloadMessageBundles');
cloned.addEventListener('click', async function() {
const groupId = getCurrentUrlPaths()[1] || null;
if (!groupId) {
return;
}
const modalWrapper = document.querySelector('.modal-wrapper');
if (!modalWrapper) {
return;
}
if (!cachedMessageGroups[groupId]) {
await fetchAllMessageGroups();
}
const messageGroup = cachedMessageGroups[groupId];
const { account } = messageGroup;
/**
* Set certain modal classes to other elements
*/
const body = document.querySelector('body');
const xdModal = modalWrapper.querySelector('.xdModal');
xdModal.classList.add('back-drop');
body.classList.add('modal-opened');
/**
* Add the modal to the page and allow for functionality.
*/
const messageOverview = cachedMessages[groupId].response;
const messages = messageOverview.messages;
let messageOptions = ``;
for (const message of messages)
{
let messageMedia = await getMessageMedia(groupId, message.id);
messageMedia = messageMedia.medias;
if (messageMedia.length === 0) {
continue;
}
const option = document.createElement('option');
const date = new Date(message.createdAt * 1000);
const text = message.content.trim();
option.textContent = `${date.toLocaleString()} | ${text.length > 83 ? text.slice(0, 80) : text}${text.length > 83 ? '...' : ''}`;
option.setAttribute('value', message.id);
messageOptions += option.outerHTML;
}
const username = account.username;
const displayName = account.displayName || username;
const modal = `<div class="active-modal" id="downloadModal">
<div class="modal">
<div class="modal-header">
<div class="title flex-1">
<p>Download media message from ${displayName} (@${username})</p>
</div>
<div class="actions"><i class="fa-fw fa fa-times pointer blue-1-hover-only hover-effect"></i></div>
</div>
<div class="modal-content">
<p class="introduction">Select the message you want to grab the media from:</p>
<select><option value="">-- No selection --</option>${messageOptions}</select>
<div class="btn large outline-dark-blue disabled" style="margin-top: 1.5em;" id="downloadModalButton" disabled="1"><i class="${downloadIconClasses}"></i> Download! <span></span></div>
<div style="margin-top: 1.5em;" class="introduction">
The file count shown on the download button assumes that the message media is unlocked for you.
<br />
It may be inaccurate if it is a PPV that hasn't been purchased yet. Messages with 0 media are not listed.
</div>
<div style="margin-top: 1.5em;" class="introduction">
If you wish to download message media from another creator, close this modal and select their message thread.
<br />
A new download icon should show up above the thread list, click it.
</div>
</div>
</div>
</div>`;
modalWrapper.insertAdjacentHTML('beforeend', modal);
// Get the modal element after adding it, so that we can add event listeners
const modalElem = document.querySelector('#downloadModal');
/**
* Handle selection and download
*/
const selectElem = modalElem.querySelector('select');
const downloadButton = modalElem.querySelector('#downloadModalButton');
const downloadCount = downloadButton.querySelector('span');
const downloadIcons = downloadButton.querySelector('.fal');
function disableDownload()
{
downloadButton.setAttribute('disabled', '1');
downloadButton.classList.add('disabled');
}
function enableDownload()
{
downloadButton.removeAttribute('disabled');
downloadButton.classList.remove('disabled');
}
selectElem.addEventListener('change', async function(ev) {
const selectedMessageId = selectElem.value;
if (!selectedMessageId) {
disableDownload();
downloadCount.textContent = '';
return;
}
const messageMedia = await getMessageMedia(groupId, selectedMessageId);
enableDownload();
downloadCount.textContent = `(${messageMedia.medias.length} files)`;
});
downloadButton.addEventListener('click', async function() {
if (downloadButton.hasAttribute('disabled')) {
return;
}
const selectedMessageId = selectElem.value;
console.log('Group ID', groupId, 'Selected Message ID', selectedMessageId);
const { bundles, medias } = await getMessageMedia(groupId, selectedMessageId);
// Disable the button and add spinner
disableDownload();
downloadIcons.classList.add('fa-circle-notch');
downloadIcons.classList.add('fa-spin');
downloadIcons.classList.remove('fa-download');
// Since `filterMedia` just triggers downloads in the background, we're just adding a small delay before re-enabling the button.
setTimeout(() => {
enableDownload();
downloadIcons.classList.remove('fa-circle-notch');
downloadIcons.classList.remove('fa-spin');
downloadIcons.classList.add('fa-download');
}, 1500);
const parameter = {
response: {
accountMediaBundles: bundles,
accountMedia: medias,
},
};
filterMedia(parameter);
});
/**
* Add handlers for closing the modal.
*/
const closeButton = modalElem.querySelector('.fa-times');
function removeModal() {
modalElem.remove();
xdModal.classList.remove('back-drop');
body.classList.remove('modal-opened');
}
closeButton.addEventListener('click', removeModal);
xdModal.addEventListener('click', removeModal);
});
parent.insertAdjacentElement('afterend', cloned);
}
/**
* Helpers for getting the download media button (if it already exists)
*/
function getDownloadMessageMediaButton()
{
return document.querySelector('#downloadMessageBundles');
}
function hasDownloadMessageMediaButton()
{
if (getDownloadMessageMediaButton()) {
return true;
}
return false;
}
/**
* Begin profile page handling
*
* TODO: This is very incomplete as of right now.
*/
async function fetchProfile(username)
{
const response = await apiFetch(`/account?usernames=${username}`);
const json = await response.json();
if (!json.success || json.response.length < 1) {
return;
}
const profile = json.response[0];
const neighborButton = document.querySelector('.dm-profile') || document.querySelector('.tip-profile') || document.querySelector('.follow-profile');
const relevantAttribute = getAngularAttribute(neighborButton);
// Don't add another button
const downloadButtonId = 'profile-dl';
if (document.getElementById(downloadButtonId)) {
return;
}
const downloadButton = document.createElement('div');
downloadButton.setAttribute(relevantAttribute, '');
downloadButton.setAttribute('class', 'dm-profile');
downloadButton.setAttribute('id', downloadButtonId);
downloadButton.innerHTML = '<i class="${downloadIconClasses}"></i>';
neighborButton.insertAdjacentElement('beforebegin', downloadButton);
console.log('Profile', profile);
}
/**
* Helpers for dealing with page load, page changing etc.
*/
function getCurrentUrlPaths()
{
const url = new URL(window.location.href);
const paths = url.pathname.split('/').slice(1);
return paths;
}
async function handleLoad()
{
const paths = getCurrentUrlPaths();
const root = paths[0] || '';
const secondary = paths[1] || null;
const selectors = {
dropdown: 'div.feed-item-title > div.feed-item-actions.dropdown-trigger.more-dropdown > div.dropdown-list',
};
if (root === 'post' && secondary) {
console.log('Found post - Post ID:', secondary);
VM.observe(document.body, async () => {
const dropdown = document.querySelector(selectors.dropdown);
if (dropdown) {
console.log('Found dropdown', dropdown);
await handleSinglePost(dropdown, secondary);
return true;
}
});
}
if (root === 'messages' && secondary) {
await handleMessages(secondary);
}
if (root !== '' && secondary === 'posts') {
await fetchProfile(root);
}
}
let oldUrl = '';
function checkNewUrl()
{
const newUrl = window.location.href;
if (oldUrl === newUrl) {
return;
}
oldUrl = newUrl;
if (hasDownloadMessageMediaButton()) {
const button = getDownloadMessageMediaButton();
button.remove();
}
handleLoad();
}
let interval;
function init()
{
setTimeout(handleLoad, 1500);
if (!interval) {
oldUrl = window.location.href;
interval = setInterval(checkNewUrl, 100);
}
}
init();
@oeks01
Copy link

oeks01 commented May 9, 2023

script not working at the moment. just a heads up.

@M-rcus
Copy link
Author

M-rcus commented May 10, 2023

@oeks01

script not working at the moment. just a heads up.

Yeah, I've been mostly working on it locally and just never pushed any updates for it. I've pushed all the changes I've done, so it should be working again, but there are likely quite a few changes to things like the filename format and such.

@xfeeefeee
Copy link

This is great actually. I'm working legitimately with a content creator to create promotional and artistic material and being able to get things from the site will make everything a lot easier for our workflow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment