Skip to content

Instantly share code, notes, and snippets.

@M-rcus
Last active January 26, 2025 17:08
Show Gist options
  • 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();
@schleeb
Copy link

schleeb commented Dec 4, 2024

@M-rcus I'm not sure if it's a fluke or what, but I've suddenly noticed videos aren't downloading in the highest resolution anymore (they had been after the last update, I think).

@M-rcus
Copy link
Author

M-rcus commented Dec 4, 2024

@schleeb

@M-rcus any ideas how difficult it would be to grab banner images and story posts?

Banner images should be fairly trivial. I believe banners are public (no login required), just like avatars, but will have to confirm that.
Story posts I haven't looked too much into, but I assume it's basically the same as the timeline/messages - just a different API endpoint to actually retrieve the data needed (media setup is most likely the same as anything else).

@M-rcus I'm not sure if it's a fluke or what, but I've suddenly noticed videos aren't downloading in the highest resolution anymore (they had been after the last update, I think).

Hmm, I'll take a look. I haven't been subscribed to anyone lately (trying to save some money, economy's rough lol), but I'll take a look and see if I can figure out what it is.
I assume then it's all videos? Is this with or without SCRIPT_DOWNLOAD? Maybe they're putting more resolutions behind MPD playlists vs. regular MP4s, but again, I'll have to look into it to know for sure.

@pbull11
Copy link

pbull11 commented Dec 4, 2024

Cool script. I'm running into one issue though. It only downloads the short preview video even on videos I own through a monthly subscription. Any advice? Thanks!

@schleeb
Copy link

schleeb commented Dec 4, 2024

I assume then it's all videos? Is this with or without SCRIPT_DOWNLOAD? Maybe they're putting more resolutions behind MPD playlists vs. regular MP4s, but again, I'll have to look into it to know for sure.

@M-rcus that's correct, it does appear to be all videos. I am not using SCRIPT_DOWNLOAD, I gotta give that a try again. Taking a look at the MPD, there definitely is a 1080p, but all I get with the script when downloading is 720p. I dunno if it makes a difference or not, but I'm also making sure to select the highest resolution from the video controls.

@M-rcus
Copy link
Author

M-rcus commented Dec 6, 2024

@pbull11

Cool script. I'm running into one issue though. It only downloads the short preview video even on videos I own through a monthly subscription. Any advice? Thanks!

That's a bit odd, I'll see if I can reproduce that issue. Depends a bit on how much time I have this weekend :)


@schleeb

I assume then it's all videos? Is this with or without SCRIPT_DOWNLOAD? Maybe they're putting more resolutions behind MPD playlists vs. regular MP4s, but again, I'll have to look into it to know for sure.

@M-rcus that's correct, it does appear to be all videos. I am not using SCRIPT_DOWNLOAD, I gotta give that a try again. Taking a look at the MPD, there definitely is a 1080p, but all I get with the script when downloading is 720p. I dunno if it makes a difference or not, but I'm also making sure to select the highest resolution from the video controls.

The script doesn't care about the video controls, so it shouldn't matter. Though if 1080p is locked behind MPD that's probably why. I never managed to pull off a proper merged download of video+audio in a single file.

Script needs a cleanup though. The way it picks "best resolution" isn't exactly smart. I'm sure it can be done in a better way. As mentioned in my reply to the pbull11, I'll see how much time I have this weekend.

Thanks for the feedback though!

@Zero3K
Copy link

Zero3K commented Dec 27, 2024

It doesn't work for me under the latest Supermium.

@schleeb
Copy link

schleeb commented Dec 28, 2024

@M-rcus I just encountered an issue where 2 images didn't download, and instead I got xml files saying "Access Denied". I suspect it may be on their end, as I'd also had a similar issue downloading the 4k video a second prior via cat-catch, but I thought I'd mention it. Newer and older images downloaded without issue.

@Zero3K what is Supermium?

@Zero3K
Copy link

Zero3K commented Dec 28, 2024

https://github.com/win32ss/supermium

EDIT: It might've been some extensions that I had enabled since disabling them gets it to work. Now I have an issue where it downloads video(s) twice when downloading them via the Download media option. The console mentions the following:

Found file: 2024-12-23_727680282055946240_727680177089294336_727680177089294336.mp4 - Triggering download...
userscript.html?name=Fansly-Download-single-posts-%2526-messages.user.js&id=70a32be0-4b0d-4008-b5e6-1c24f23cccf2:308
Found file: 2024-12-23_727680282055946240_727680177089294336_727680177089294336.mp4 - Triggering download...

@M-rcus
Copy link
Author

M-rcus commented Dec 28, 2024

@M-rcus I just encountered an issue where 2 images didn't download, and instead I got xml files saying "Access Denied". I suspect it may be on their end, as I'd also had a similar issue downloading the 4k video a second prior via cat-catch, but I thought I'd mention it. Newer and older images downloaded without issue.

@schleeb Is this a fairly recent post? Have you tried it again a few hours later? I assume the images load normally when you view the post?
Haven't really run into that issue myself, but only been testing sporadically every once in a while (to ensure the script works).
An XML giving "Access Denied" (or similar errors) usually implies that the presigned URL has expired (mentioned it briefly in a previous comment).

EDIT: It might've been some extensions that I had enabled since disabling them gets it to work. Now I have an issue where it downloads video(s) twice when downloading them via the Download media option. The console mentions the following:
Found file: 2024-12-23_727680282055946240_727680177089294336_727680177089294336.mp4 - Triggering download...
userscript.html?name=Fansly-Download-single-posts-%2526-messages.user.js&id=70a32be0-4b0d-4008-b5e6-1c24f23cccf2:308
Found file: 2024-12-23_727680282055946240_727680177089294336_727680177089294336.mp4 - Triggering download...

@Zero3K Glad you got it working. I don't have a Windows setup right now to really test Supermium.
Video downloads can take some time, depending on filesize (and your transfer speed). One of the things I wanted to do at some point was have some feedback in terms of download progress, but it hasn't been prioritized. So for now you're kind of just waiting with no information as to when downloads are done. Typically not a problem for images though since they're fairly small in comparison.
Eventually your browser should either automatically download, or ask you to choose where to save the video(s). Do note that the quality of the videos is probably not the highest resolution, due to some limitations with the script right now (see my back and forth with schleeb if you're interested in the technical details).

@Zero3K
Copy link

Zero3K commented Dec 28, 2024

What about my issue where the .mp4 is downloaded twice?

@M-rcus
Copy link
Author

M-rcus commented Dec 28, 2024

@Zero3K Sorry, I must have misread your issue.

I'm not sure what's going on there to be honest. I just tested it on Brave (granted, on Linux) and it seems to be only triggering once per file. Does it happen on every post?

@Zero3K
Copy link

Zero3K commented Dec 28, 2024

@M-rcus
Copy link
Author

M-rcus commented Dec 28, 2024

@Zero3K If it only happens on that specific post, then I honestly don't have a clue. Possible the API is somehow including the entry for the video twice when the script retrieves that data.

@Zero3K
Copy link

Zero3K commented Dec 28, 2024

  1. I see mention of a .mpd when I use the Cat Catch extension.
  2. Is there any way to just limit it to download one of the same named files?

@Zero3K
Copy link

Zero3K commented Dec 28, 2024

Here's what the API gave me:

https://pastefs.com/pid/270886

@schleeb
Copy link

schleeb commented Dec 28, 2024

@schleeb Is this a fairly recent post? Have you tried it again a few hours later? I assume the images load normally when you view the post? Haven't really run into that issue myself, but only been testing sporadically every once in a while (to ensure the script works). An XML giving "Access Denied" (or similar errors) usually implies that the presigned URL has expired (mentioned it briefly in a previous comment).

@M-rcus It is fairly recent, however even more recent content had no issues, and the images did load normally when viewed. I haven't had a go at it again, but I will eventually. Likely temporary

@Zero3K
Copy link

Zero3K commented Dec 29, 2024

Can it be improved or not?

@Zero3K
Copy link

Zero3K commented Jan 4, 2025

@M-rcus
Copy link
Author

M-rcus commented Jan 5, 2025

@Zero3K Sorry for the late reply. I don't think this is an issue with the script, because you're the first one to report the issue (I haven't run into it myself). Neither with videos, nor images.

Do you don't have two versions of the script installed or something, right? I don't think even that would trigger the it without causing other weird issues (e.g. you'd see two download buttons). If I have time tomorrow (Sunday), I'll set up a Windows VM and test on Supermium.

@Zero3K
Copy link

Zero3K commented Jan 5, 2025

It just happens on that post.

@M-rcus
Copy link
Author

M-rcus commented Jan 5, 2025

Then I genuinely have no clue.

@Zero3K
Copy link

Zero3K commented Jan 5, 2025

Did you see what the API gave me?

@Wolfiee76
Copy link

Bro I thought this shit will let me download full locked videos but ig not

@M-rcus
Copy link
Author

M-rcus commented Jan 6, 2025

@Zero3K I did take a brief look at it when you posted, but didn't spot anything that looked off. I didn't really have time to look too much into it though. Paste seems to be gone now (or rather, seems the paste site itself went down), but feel free to repost if you think there's something there.

@Wolfiee76 Good luck finding something that does that for this platform (or OF for that matter). If something like that ever exists, I can't imagine it'll take long to get patched by the platform respectively, because that would be a huge problem for them lol

@schleeb
Copy link

schleeb commented Jan 25, 2025

@M-rcus This isn't actually related to the script, but I'm hoping maybe you have an idea on how to solve it, and I saw no way to DM.

My PC BSOD'd on me, and afterwards images on Fansly are completely corrupted (see the attached image). This only appears to effect Fansly, or at least it's the only site I've seen the issue on. To my alarm, this is across browsers, and not exclusive to the browsers that were open at the time. This leads me to suspect some sort of system file may be corrupted. Do you know if Fansly uses some sort of encryption on the images that relies on a system file or something?

Any help would be greatly appreciated!

@Zero3K
Copy link

Zero3K commented Jan 25, 2025

Try running Chkdsk C: /F in a Command Prompt window. It might find corrupted files. If not, then you'll have to do a reinstall.

@schleeb
Copy link

schleeb commented Jan 26, 2025

@M-rcus To my alarm, this is across browsers, and not exclusive to the browsers that were open at the time.

I found one exception, Tor loads them just fine. I'm not sure exactly how it's loading them compared to the others, though I'd imagine it's got something to do with the unique way it handles everything.

@schleeb
Copy link

schleeb commented Jan 26, 2025

Try running Chkdsk C: /F in a Command Prompt window. It might find corrupted files. If not, then you'll have to do a reinstall.

I did this, as well as sfc /scannow and DISM.exe /Online /Cleanup-image /scanhealth && DISM.exe /Online /Cleanup-image /checkhealth

I even ran the "Fix Problems with Windows Update" Reinstall option, though I haven't tried the "Reset this PC" option, since I'd prefer to avoid having to reinstall everything... but I will if I have to...

As a temporary fix, I tried running the script in Tor, which took some effort to work, but then I encountered that issue I had before with the "Access Denied" xml files. Not sure if it's related or not, since the previous time it happened, it did resolve itself.

@M-rcus
Copy link
Author

M-rcus commented Jan 26, 2025

@schleeb Nah, there's no encryption involved with Fansly, so it shouldn't be in relation to that.

If Tor works, have you tried creating fresh browser profiles in your regular browsers? I know you've tested different browsers, but it's possible the profiles themselves are corrupt if they already existed before your BSOD, hence maybe fresh browser profiles would work.

GitHub doesn't support any form of DMs, but if needed in the future, it is possible to email me: [email protected]

@schleeb
Copy link

schleeb commented Jan 26, 2025

GitHub doesn't support any form of DMs, but if needed in the future, it is possible to email me: [email protected]
@M-rcus cool, I've sent you an email to avoid clogging this thread up.

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