Skip to content

Instantly share code, notes, and snippets.

@M-rcus
Last active November 16, 2024 16:12
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 Aug 19, 2024

@M-rcus
I'm not sure if this is just in-browser downloading or if the .sh script would show it too, but when downloading at least one .gif it found an .mp4 as well, which would be preferred, but it was censored, unlike the gif.

@M-rcus
Copy link
Author

M-rcus commented Aug 20, 2024

@schleeb

@M-rcus I'm not sure if this is just in-browser downloading or if the .sh script would show it too, but when downloading at least one .gif it found an .mp4 as well, which would be preferred, but it was censored, unlike the gif.

I am aware that this is a thing, but I am not sure there's much that can be done. The GIF is likely what the creator uploaded, while the MP4 is just generated by Fansly as the censored preview. In other words, there's no already-existing MP4 version of the (uncensored) GIF.

At least that's how I think that works. I may very well be wrong.

@schleeb
Copy link

schleeb commented Aug 21, 2024

@M-rcus, the video had audio, and was otherwise superior quality, I believe you have it backwards. The creator uploaded a video (which makes more sense anyway, most people don't have tools to create gifs as easily as video), Fansly converted it to a gif, but also created a censored MP4. I suppose it's possible they deleted the uncensored MP4, but that seems unlikely.
It seems weird to me GIFs are even allowed, given video is superior in every way.

@M-rcus
Copy link
Author

M-rcus commented Aug 21, 2024

@schleeb

the video had audio, and was otherwise superior quality, I believe you have it backwards

You didn't exactly mention this the first time. I've only had the script give me a GIF like once or twice before (from the same creator), so I made the assumption based on that experience.

I'm not 100% sure what happened then. It's possible this (arguably crappy) logic triggers and picks a bad option, instead of the intended "highest quality" option - again, not certain: https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7#file-fansly-download-user-js-L221-L239

You could enable SCRIPT_DOWNLOAD and see if the resulting script gives you the proper video, since it uses slightly different logic for picking out the video (mainly this: https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7#file-fansly-download-user-js-L150)

@schleeb
Copy link

schleeb commented Aug 24, 2024

@M-rcus
Apologies.
To be clear, the actual post was a GIF, the creator for some reason wanted it to be a GIF, not an MP4. Only once or twice did I happen to pick up the censored video as well, out of maybe 6-10 GIFs. I'm not sure if it's even technically supposed to show anywhere, I just happened to notice it had downloaded, which implies there is technically an uncensored MP4 original, though it may not be accessible. I convert GIFs to MP4s, but it would be preferable to get the original MP4, if possible.
I'll poke around with the logic, see if there's something there to mess with or not.

@schleeb
Copy link

schleeb commented Aug 24, 2024

@M-rcus
After looking at the code, I think it's just a weird thing on their side, I saw it surface 2 jpegs as alternatives, and one MP4, and then 4 GIFs, so I don't think they provide the original MP4, sadly.
I've tried to enable SCRIPT_DOWNLOAD but for some reason ViolentMonkey doesn't seem to retain the value.

@M-rcus
Copy link
Author

M-rcus commented Aug 25, 2024

@schleeb Yeah, that's kind of what I figured it'd be. Not really sure what their logic is and why they don't just upload it as a video.

SCRIPT_DOWNLOAD

Not sure what that's all about. I'm using Violentmonkey too (on Firefox) and clicking on the "Values" tab for the script persists for me at least (just make sure to also hit the "OK" top-right).

image

@M-rcus
Copy link
Author

M-rcus commented Oct 9, 2024

Some quick update here, for anyone reading - I guess maybe @schleeb might be interested.

I attempted to get video downloads working and I did... kind of. The way most new Fansly videos are served up is essentially in two different parts: Video and audio. They're both in a separate MP4 container, so downloading them will give you files that play in most media players.

The problem is that they're in two separate files lol. They are otherwise complete files, but basically: You can play the video, and have no audio - or you can play the audio file and have no video.
With different types of software (e.g. ffmpeg CLI: ffmpeg -i video.mp4 -i audio.mp4 -c copy complete.mp4), there are ways of merging them together without some re-encoding or anything, but via a userscript I have so far not been able to achieve this.

I did look for some potential libraries, such as: mux.js and mp4-muxer. Granted, I am a bit out of my depth, I am not sure these would let me achieve what I want.

If anyone ends up reading this and actually has any idea if this is possible (or not), feel free to let me know. Either tag me here in the comments (@M-rcus) or shoot me an email: [email protected]

@schleeb
Copy link

schleeb commented Oct 10, 2024

@M-rcus
That's a pretty cool update! I've got no issues using ffmpeg, though I might stick to cat-catch.
I'd probably be even more out of my depth, but I might try poking around anyway, to just see if I can figure anything out.

@schleeb
Copy link

schleeb commented Nov 16, 2024

@M-rcus I never did get a chance to look into this, but I had an interesting question.
Let's say you're subbed to Tier A, and that tier unlocks all the content in certain DMs and posts. If you subbed after a DM for that tier had been sent, but you had the ID for that DM, do you think the content could be downloaded? If so, how? I did try changing the "option" value in the DM downloader, and that didn't work, but I suspect it would involve more.

@M-rcus
Copy link
Author

M-rcus commented Nov 16, 2024

@schleeb

Don't think it would be that simple. Here's my understanding of it:

The general media URL for a specific media (image/video) is always the same, so you'll have: https://cdn3.fansly.com/123456789/987654321.jpeg for an image.
However, these URLs need to be "presigned" by the API, which appends extra parameters to the URL. These presigned parameters are unique to the media URL, so can't just copy from one working URL to one missing. They also expire, but I'm not entirely sure how long. I believe it to be a few days.

Assuming that the Fansly DM isn't showing as unlocked when you browse the website normally, then I don't think there's any way to get around that. For media that is "locked" (even if it was previously unlocked), they don't really send the URLs at all.

@schleeb
Copy link

schleeb commented Nov 16, 2024

@M-rcus Damn! I was hoping the checking was done entire server-side, and not tied to additional parameters.

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