Skip to content

Instantly share code, notes, and snippets.

@M-rcus
Last active March 3, 2026 18:24
Show Gist options
  • Select an option

  • Save M-rcus/a29673a5fcf22afd0e67d549b36496a7 to your computer and use it in GitHub Desktop.

Select an option

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 and usage

  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:

Messages

For messages, go to your Fansly messages and select a "message thread" on the sidebar from the creator you wish to download from. Depending on how many messages have been received/sent, it may take a short while to get all the messages. Eventually you should see this button and icon:

Once you click that, you should get this popup with a dropdown of available media in that message thread:

This image is a screenshot from v0.8.3, so any newer versions may look slightly different.

Please note that the message download feature still isn't perfect. Sometimes it lists media you don't actually have access to.
If you notice media that's missing, but that you definitely have access to, feel free to let me know.

Bug reports and issues

If you encounter any bugs or issues, feel free to comment here about them or send me an email: m@rcus.dev

[Experimental] Downloading the highest-quality videos via in-browser M3U8 fetching and transmuxing

As of v0.8.0 there's an experimental feature that uses the M3U8 playlist for downloading videos (available for video uploads in the past few years, only the earliest videos don't have this).
These "playlists" have effectively split one video file into multiple small chunks. Effective to save bandwidth costs on Fansly's side, a pain to work with for userscripts. With the help of Claude, I've implemented in-browser fetching of the M3U8 playlist and transmuxing of the chunks into a single MP4 file via mux.js. This should allow for higher-quality video downloads without needing to use the old "script download" method.

This feature needs to be explicitly enabled via the userscript manager's context menu (at least on Violentmonkey). The reason for this is that for large videos (long livestreams), the playlist method can be quite resource-intensive and may even crash the browser (though unconfirmed). The old method of fetching the direct video URL (which is usually a lower-quality MP4) is much more lightweight, so you can toggle between the two methods based on your needs.

Click your userscript manager's icon, and you should see an option like these:

If it says "M3U8 in-browser download: OFF", click it to turn it on. You should see a console log confirming the change: M3U8 in-browser download set to true
Click it again to toggle it off if you want to switch back to the old method.

During a download there won't be anything on the Fansly website visually telling you progress, but you can open the browser console to see logs for when the M3U8 playlist is being fetched, when each chunk is being downloaded, and when the final MP4 is being generated.

Changelog

v0.9.2 - 2026-03-02

  • Improved the handling of "Download media" button in the dropdown of posts. From my testing this works better than before, but if you notice any issues with it please let me know.

v0.9.1 - 2026-03-02

  • The message media download modal now shows how many messages have been retrieved from Fansly, as well as how many media items have been found in those messages.
    • This should be better feedback to you when you click the "Load more messages" button, so you can see how many messages have been loaded and how many media items have been found in those messages. Especially if you have a lot of messages where there's no media (chatting back and forth with the creator but no media is being sent).

v0.9.0 - 2026-02-27

  • Bugfix: Message conversations (DMs) are no longer fetched every time you switch conversations. This should avoid hitting too many rate limits with Fansly's API.
  • When you open the modal for downloading message media, it only retrieves the 100 most recent messages.
    • To compensate there's now a "Load more messages" button that fetches the next 100 messages, then another 100 messages, and so on until there are no more messages to fetch.
    • You also have the option to toggle "Load all messages" and then press the button, which will fetch all messages in the conversation. For creators where you've been yapping back and forth a lot over time, this can take some time to finish. Use it at your own discretion. :)
  • Message media listed in the download modal no longer show media you have sent. Only media sent from the creator.
    • Currently this cannot be changed, but if someone wants to be able to download media they have sent themselves, then I can add a toggle.

v0.8.3

  • Fixes a very minor issue with the download icon for the "Download media" on regular posts (not messages).

v0.8.2

  • Fixes the video duration / length reported by Windows Explorer.
  • Implements rate limit handling for API calls, so that it doesn't break when fetching a lot of messages etc.

v0.8.1

  • Fixes some MP4 metadata generated by the M3U8 transmuxing process, such as the creation date.
  • No longer downloads preview media when you have access to the original media.

v0.8.0

  • Introduces in-browser M3U8 fetching and transmuxing to MP4 via mux.js, which should allow for higher-quality video downloads without needing to use the script download method.
  • Toggleable via the userscript manager's context menu (at least on Violentmonkey).

v0.7.0

  • Works around a "fix" implemented by Fansly, since they now limit to 50 entries per API request. The script now makes multiple requests if needed to get all media.

v0.6.0

  • Fixes image downloading.
  • Note: video downloading is still kind of low resolution 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.

v0.2.0

// ==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
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @require https://m.leak.fans/ujs/violentmonkey-dom-v1.0.9.js
// @require https://cdnjs.cloudflare.com/ajax/libs/mux.js/6.3.0/mux.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
// @homepageURL https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7
// @icon https://m.leak.fans/ujs/fansly-icon.png
// @version 0.9.2
// @author M
// @description Work in progress userscript for download media of single posts & message media on Fansly.
// ==/UserScript==
/**
* Usage, changelog & other information - Please read the README on the GitHub Gist page: https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7
*/
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);
/**
* When enabled, m3u8 playlists are fetched and transmuxed to MP4 in-browser via mux.js.
* Disable on lower-end devices to fall back to direct download (lower quality static file).
* Toggled via the Violentmonkey context menu.
*/
let m3u8Download = GM_getValue('M3U8_DOWNLOAD', true);
let m3u8MenuCommandId = null;
function registerMenuCommands()
{
if (m3u8MenuCommandId !== null) {
GM_unregisterMenuCommand(m3u8MenuCommandId);
}
m3u8MenuCommandId = GM_registerMenuCommand(
`M3U8 in-browser download: ${m3u8Download ? 'ON' : 'OFF'}`,
function() {
m3u8Download = !m3u8Download;
console.log(`M3U8 in-browser download set to ${m3u8Download}`);
GM_setValue('M3U8_DOWNLOAD', m3u8Download);
registerMenuCommands();
},
{
autoClose: true,
}
);
}
registerMenuCommands();
/**
* 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 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 = {};
/**
* Extracts the highest-quality M3U8 URL and raw CloudFront cookies from a media object.
* Returns null if the media has no M3U8 playlist variant.
*
* @param {Object} media
* @returns {{ url: String, cookies: Object }|null}
*/
function getM3u8Info(media)
{
const { variants } = media;
// Type 302 = HLS (application/vnd.apple.mpegurl)
const playlist = variants.find(file => file.type === 302);
if (!playlist || playlist.locations.length === 0) {
return null;
}
const location = playlist.locations[0];
// location.location is the master playlist URL; downloadM3u8AsMP4 will
// resolve the highest-quality variant stream from it at download time.
return { url: location.location, cookies: location.metadata };
}
function getVideoDownloadCommand(media, filename, asCurl)
{
const info = getM3u8Info(media);
if (!info) {
return null;
}
const { url, cookies } = info;
const cookieHeader = Object.entries(cookies).map(([k, v]) => `CloudFront-${k}=${v}`).join('; ');
if (asCurl) {
return `curl -L -o "${filename}" -H "Origin: https://fansly.com" -H "Referer: https://fansly.com/" -H "Cookie: ${cookieHeader}" "${url}"`;
}
return `yt-dlp -o "${filename}" --add-header "Origin:https://fansly.com" --add-header "Referer:https://fansly.com/" --add-header "Cookie:${cookieHeader}" "${url}"`;
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
/**
* Promise wrapper around GM_xmlhttpRequest.
* Used to fetch m3u8 playlists and TS segments without CORS restrictions.
* Automatically retries on 429 (rate limit), respecting Retry-After if present.
*
* @param {String} url
* @param {Object} headers Key/value pairs to send as request headers.
* @param {'text'|'arraybuffer'} responseType
* @returns {Promise}
*/
async function gmFetch(url, headers = {}, responseType = 'text')
{
const MAX_RETRIES = 5;
let delay = 2000;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers,
responseType,
onload: resolve,
onerror: reject,
ontimeout: reject,
});
});
if (response.status !== 429) {
return response;
}
if (attempt === MAX_RETRIES) {
console.error(`[gmFetch] 429 after ${MAX_RETRIES} retries: ${url}`);
return response;
}
// Parse Retry-After header from raw response header string
const retryAfterMatch = response.responseHeaders?.match(/retry-after:\s*(\d+)/i);
const waitMs = retryAfterMatch ? parseInt(retryAfterMatch[1], 10) * 1000 : delay;
console.warn(`[gmFetch] 429 rate limited. Retrying in ${waitMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`);
await sleep(waitMs);
delay = Math.min(delay * 2, 30000);
}
}
/**
* Fetches an M3U8 playlist, downloads all TS segments, transmuxes them to MP4
* using mux.js, and triggers a browser download of the resulting file.
*
* @param {String} m3u8Url URL of the M3U8 playlist (highest quality variant).
* @param {Object} cookies Key/value pairs for CloudFront cookies (without the CloudFront- prefix).
* @param {String} filename Output filename (without extension).
* @param {Number} createdAt Unix timestamp in seconds from the API, used to set the file's modified date.
*/
async function downloadM3u8AsMP4(m3u8Url, cookies, filename, createdAt)
{
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `CloudFront-${k}=${v}`)
.join('; ');
const sharedHeaders = {
'Origin': 'https://fansly.com',
'Referer': 'https://fansly.com/',
'Cookie': cookieHeader,
};
console.log(`[m3u8] Fetching master playlist: ${m3u8Url}`);
const masterRes = await gmFetch(m3u8Url, sharedHeaders, 'text');
const masterText = masterRes.responseText;
const masterBase = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);
// If this is a master playlist, pick the highest-bandwidth variant stream.
let variantUrl = m3u8Url;
if (masterText.includes('#EXT-X-STREAM-INF')) {
const lines = masterText.split('\n').map(l => l.trim());
let bestBandwidth = -1;
for (let i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#EXT-X-STREAM-INF')) continue;
const bwMatch = lines[i].match(/BANDWIDTH=(\d+)/);
const bandwidth = bwMatch ? parseInt(bwMatch[1], 10) : 0;
const uri = lines[i + 1];
if (uri && !uri.startsWith('#') && bandwidth > bestBandwidth) {
bestBandwidth = bandwidth;
variantUrl = uri.startsWith('http') ? uri : masterBase + uri;
}
}
console.log(`[m3u8] Selected variant stream (bandwidth ${bestBandwidth}): ${variantUrl}`);
}
// Fetch the variant (media) playlist to get the segment list.
const playlistRes = variantUrl === m3u8Url
? { responseText: masterText }
: await gmFetch(variantUrl, sharedHeaders, 'text');
const playlistText = playlistRes.responseText;
// Resolve segment URLs (may be relative or absolute).
const variantBase = variantUrl.substring(0, variantUrl.lastIndexOf('/') + 1);
const segmentUrls = playlistText
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0 && !line.startsWith('#'))
.map(line => line.startsWith('http') ? line : variantBase + line);
if (segmentUrls.length === 0) {
console.error('[m3u8] No segments found in playlist.');
return;
}
console.log(`[m3u8] Found ${segmentUrls.length} segments. Transmuxing to MP4...`);
const transmuxer = new muxjs.mp4.Transmuxer();
const mp4Chunks = [];
// initSegment is only emitted on the first flush. Prepend it once, then
// append only the data portion from subsequent flushes to avoid duplicate
// MOOV atoms (one per segment) that confuse players and tools like ffprobe.
let initSegmentWritten = false;
// mux.js does not include duration on the 'data' segment object.
// Instead, accumulate from videoSegmentTimingInfo (end.pts in 90kHz ticks).
// Fall back to audioSegmentTimingInfo if there is no video track.
let videoDuration90k = 0;
let audioDuration90k = 0;
transmuxer.on('videoSegmentTimingInfo', info => {
videoDuration90k = Math.max(videoDuration90k, info.end.pts);
});
transmuxer.on('audioSegmentTimingInfo', info => {
audioDuration90k = Math.max(audioDuration90k, info.end.pts);
});
transmuxer.on('data', segment => {
if (!initSegmentWritten && segment.initSegment.byteLength > 0) {
mp4Chunks.push(new Uint8Array(segment.initSegment));
initSegmentWritten = true;
}
mp4Chunks.push(new Uint8Array(segment.data));
});
for (let i = 0; i < segmentUrls.length; i++) {
const segUrl = segmentUrls[i];
console.log(`[m3u8] Fetching segment ${i + 1}/${segmentUrls.length}`);
const segRes = await gmFetch(segUrl, sharedHeaders, 'arraybuffer');
transmuxer.push(new Uint8Array(segRes.response));
}
transmuxer.flush();
// Concatenate all chunks into a single Uint8Array.
const totalLength = mp4Chunks.reduce((sum, c) => sum + c.byteLength, 0);
const mp4Data = new Uint8Array(totalLength);
let writeOffset = 0;
for (const chunk of mp4Chunks) {
mp4Data.set(chunk, writeOffset);
writeOffset += chunk.byteLength;
}
const totalDuration90k = videoDuration90k || audioDuration90k;
console.log(`[m3u8] Transmux complete. Total size: ${(totalLength / 1024 / 1024).toFixed(2)} MB. Duration: ${(totalDuration90k / 90000).toFixed(2)}s. Triggering download...`);
patchMp4Timestamps(mp4Data, createdAt, totalDuration90k);
const file = new File([mp4Data], `${filename}.mp4`, {
type: 'video/mp4',
lastModified: createdAt * 1000,
});
const blobUrl = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${filename}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}
/**
* Recursively walks MP4 boxes within [start, end) and patches creation_time
* and modification_time in mvhd and tkhd boxes.
*
* @param {DataView} view
* @param {Number} start Byte offset of first child box
* @param {Number} end Byte offset of end of parent box
* @param {Number} macTimestamp Seconds since Mac epoch (Jan 1 1904)
* @param {Number} duration90k Total duration in 90kHz ticks (mvhd/tkhd timescale)
*/
function patchBoxes(view, start, end, macTimestamp, duration90k)
{
let i = start;
while (i + 8 <= end) {
const boxSize = view.getUint32(i, false);
const boxType = view.getUint32(i + 4, false);
if (boxSize < 8) break;
if (boxType === 0x6D766864) { // 'mvhd'
// version(1)+flags(3)+creation(4)+modification(4)+timescale(4)+duration(4)
view.setUint32(i + 12, macTimestamp, false); // creation_time
view.setUint32(i + 16, macTimestamp, false); // modification_time
view.setUint32(i + 24, duration90k >>> 0, false); // duration (after timescale)
} else if (boxType === 0x746B6864) { // 'tkhd'
// version(1)+flags(3)+creation(4)+modification(4)+track_id(4)+reserved(4)+duration(4)
view.setUint32(i + 12, macTimestamp, false); // creation_time
view.setUint32(i + 16, macTimestamp, false); // modification_time
view.setUint32(i + 28, duration90k >>> 0, false); // duration (after track_id+reserved)
} else if (boxType === 0x74726163 || boxType === 0x6D646961) { // 'trak' or 'mdia'
patchBoxes(view, i + 8, i + boxSize, macTimestamp, duration90k);
}
i += boxSize;
}
}
/**
* Patches the creation_time and modification_time fields in the mvhd and tkhd
* MP4 boxes of a transmuxed Uint8Array in-place, so tools like ffprobe report
* the correct date instead of a pre-1970 timestamp emitted by mux.js.
*
* @param {Uint8Array} mp4Data
* @param {Number} createdAt Unix timestamp in seconds
* @param {Number} duration90k Total duration in 90kHz ticks from transmuxer
*/
function patchMp4Timestamps(mp4Data, createdAt, duration90k)
{
// MP4 stores time as seconds since Mac epoch (Jan 1 1904), not Unix epoch
const macTimestamp = (createdAt + 2082844800) >>> 0;
const view = new DataView(mp4Data.buffer, mp4Data.byteOffset, mp4Data.byteLength);
let i = 0;
while (i + 8 <= mp4Data.byteLength) {
const boxSize = view.getUint32(i, false);
const boxType = view.getUint32(i + 4, false);
if (boxSize < 8) break;
if (boxType === 0x6D6F6F76) { // 'moov'
patchBoxes(view, i + 8, i + boxSize, macTimestamp, duration90k);
break;
}
i += boxSize;
}
}
/**
* Returns true if the media object has a resolvable download URL,
* meaning we have access to the real file and don't need the preview.
*
* @param {Object} media
* @returns {Boolean}
*/
function mediaIsAccessible(media)
{
const { locations, variants } = media;
if (locations && locations.length > 0 && locations[0].location) {
return true;
}
if (variants && variants.length > 0) {
return variants.some(v => v.locations && v.locations.length > 0 && v.locations[0].location);
}
return false;
}
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) {
// For mp4s backed by an M3U8 playlist, transmux in-browser via mux.js (if enabled).
const m3u8Info = m3u8Download && filetype === 'mp4' && media.variants ? getM3u8Info(media) : null;
if (m3u8Info) {
const filenameNoExt = finalFilename.replace(/\.mp4$/, '');
downloadM3u8AsMP4(m3u8Info.url, m3u8Info.cookies, filenameNoExt, createdAt);
} else {
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 = [];
fileIncrements = {};
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 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 || mediaIsAccessible(media)) {
continue;
}
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;
function buildApiUrl(path)
{
if (path.includes('https://')) return path;
if (path[0] !== '/') path = '/' + path;
return `https://apiv3.fansly.com/api/v1${path}`;
}
async function apiFetch(path, method = 'GET', body = null)
{
if (!path) {
console.error('No path specified in apiFetch!');
return;
}
const options = {
headers: {
accept: 'application/json',
authorization: getToken(),
},
referrer: 'https://fansly.com/',
referrerPolicy: 'strict-origin-when-cross-origin',
method,
mode: 'cors',
credentials: 'include',
};
if (body !== null) {
options.body = JSON.stringify(body);
}
const MAX_RETRIES = 5;
let delay = 2000;
const url = buildApiUrl(path);
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429) {
return response;
}
if (attempt === MAX_RETRIES) {
console.error(`[apiFetch] 429 after ${MAX_RETRIES} retries: ${url}`);
return response;
}
const retryAfter = response.headers.get('Retry-After');
const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay;
console.warn(`[apiFetch] 429 rate limited. Retrying in ${waitMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`);
await sleep(waitMs);
delay = Math.min(delay * 2, 30000);
}
}
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 BATCH_SIZE = 50;
let allData = [];
let allAccounts = [];
let allGroups = [];
let offset = 0;
while (true) {
const url = `/messaging/groups?limit=${BATCH_SIZE}&offset=${offset}`;
const request = await apiFetch(url);
const apiResponse = await request.json();
if (!apiResponse.success) {
console.error(apiResponse);
return null;
}
const { response } = apiResponse;
const batch = response.data ?? [];
allData = [...allData, ...batch];
allAccounts = [...allAccounts, ...(response.aggregationData?.accounts ?? [])];
allGroups = [...allGroups, ...(response.aggregationData?.groups ?? [])];
if (batch.length < BATCH_SIZE) {
break;
}
offset += BATCH_SIZE;
console.log('Getting message groups with offset', offset);
}
for (const groupMeta of allData)
{
const { groupId, partnerAccountId } = groupMeta;
const accountMeta = allAccounts.find(x => x.id === partnerAccountId) || null;
const messageMeta = allGroups.find(x => x.createdBy === partnerAccountId) || null;
cachedMessageGroups[groupId] = {
group: groupMeta,
account: accountMeta,
messageMeta,
};
}
return { data: allData, aggregationData: { accounts: allAccounts, groups: allGroups } };
}
/**
* Insert 'Download media' entry in the post dropdown
*/
async function handleSinglePost(dropdown, postId)
{
const BUTTON_ID = 'fansly-dl-post-btn';
if (dropdown.querySelector(`#${BUTTON_ID}`)) {
return;
}
const btn = document.createElement('div');
btn.classList.add('dropdown-item');
btn.setAttribute('id', BUTTON_ID);
btn.innerHTML = `<i class="${downloadIconClasses}"></i>Download media`;
// Copy the Angular scoped-CSS attribute from a sibling item so the button
// inherits the same styles as the other dropdown entries.
const sibling = dropdown.querySelector('.dropdown-item');
if (sibling) {
const ngAttr = Array.from(sibling.attributes).find(a => a.name.startsWith('_ngcontent'));
if (ngAttr) {
btn.setAttribute(ngAttr.name, '');
}
}
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';
const MESSAGE_PAGE_SIZE = 100;
const dedupeById = (arr) => [...new Map(arr.map(x => [x.id, x])).values()];
async function handleMessages(groupId, force)
{
if (!force && cachedMessages[groupId]) {
addDownloadMessageMediaButton();
return;
}
if (Object.keys(cachedMessageGroups).length === 0) {
fetchAllMessageGroups();
}
const url = `/message?groupId=${groupId}&limit=${MESSAGE_PAGE_SIZE}`;
const request = await apiFetch(url);
const data = await request.json();
const messages = data.response?.messages ?? [];
cachedMessages[groupId] = {
response: {
...data.response,
messages,
accountMedia: data.response?.accountMedia ?? [],
accountMediaBundles: data.response?.accountMediaBundles ?? [],
},
// Cursor for the next page: oldest message ID (API returns newest-first)
beforeId: messages.length >= MESSAGE_PAGE_SIZE ? messages[messages.length - 1].id : null,
hasMore: messages.length >= MESSAGE_PAGE_SIZE,
};
addDownloadMessageMediaButton();
console.log('Messages (initial page)', cachedMessages[groupId]);
}
/**
* Fetches the next page of messages for a group and merges them into the cache.
* Returns true if there may be more pages, false if we've reached the end.
*/
async function fetchMoreMessages(groupId)
{
const cached = cachedMessages[groupId];
if (!cached?.hasMore) {
return false;
}
const url = `/message?groupId=${groupId}&limit=${MESSAGE_PAGE_SIZE}&before=${cached.beforeId}`;
const request = await apiFetch(url);
const data = await request.json();
const newMessages = data.response?.messages ?? [];
const newMedia = data.response?.accountMedia ?? [];
const newBundles = data.response?.accountMediaBundles ?? [];
const allMessages = [...cached.response.messages, ...newMessages];
const allMedia = dedupeById([...cached.response.accountMedia, ...newMedia]);
const allBundles = dedupeById([...cached.response.accountMediaBundles, ...newBundles]);
const hasMore = newMessages.length >= MESSAGE_PAGE_SIZE;
cachedMessages[groupId] = {
response: {
...cached.response,
messages: allMessages,
accountMedia: allMedia,
accountMediaBundles: allBundles,
},
beforeId: hasMore ? newMessages[newMessages.length - 1].id : null,
hasMore,
};
console.log(`fetchMoreMessages: fetched ${newMessages.length} more for group ${groupId}. hasMore=${hasMore}`);
return hasMore;
}
async function getMessageMedia(groupId, messageId)
{
if (!cachedMessages[groupId]) {
await handleMessages(groupId, true);
}
const cached = cachedMessages[groupId];
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 creatorId = cachedMessageGroups[groupId]?.account?.id;
if (creatorId && message.senderId !== creatorId) {
return { medias: [], bundles: [], mediaCount: 0 };
}
const data = cached.response;
let medias = [];
let bundles = [];
let mediaCount = 0;
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);
// Use the bundle's declared ID list for the true count, as some
// items may not yet be in the local cache (fetched lazily by filterMedia).
mediaCount += mediaIds.length;
} else {
mediaCount += messageMedias.length;
}
medias = [...medias, ...messageMedias];
}
return {
medias,
bundles,
mediaCount,
};
}
/**
* Adds download button in the message view
*/
function addDownloadMessageMediaButton()
{
if (getDownloadMessageMediaButton()) {
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() {
cloned.setAttribute('disabled', '1');
const groupId = getCurrentUrlPaths()[1] || null;
if (!groupId) {
cloned.removeAttribute('disabled');
return;
}
const modalWrapper = document.querySelector('.modal-wrapper');
if (!modalWrapper) {
cloned.removeAttribute('disabled');
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 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>
<p class="introduction" id="messageStatsText" style="margin-top: 0.5em;"></p>
<select><option value="">-- No selection --</option></select>
<div class="btn large outline-dark-blue" style="margin-top: 1em;" id="loadMoreMessagesButton">Load more messages</div>
<div style="margin-top: 0.75em; align-self: center;">
<label style="cursor: pointer; user-select: none;">
<input type="checkbox" id="loadAllMessagesCheckbox" style="margin-right: 0.4em;">
Load complete message history
</label>
</div>
<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 messageStatsText = modalElem.querySelector('#messageStatsText');
const loadMoreButton = modalElem.querySelector('#loadMoreMessagesButton');
const loadAllCheckbox = modalElem.querySelector('#loadAllMessagesCheckbox');
const loadAllCheckboxWrapper = loadAllCheckbox.closest('div');
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');
}
let statsTotalMessages = 0;
let statsMessagesWithMedia = 0;
let statsTotalMediaCount = 0;
function updateStats()
{
messageStatsText.textContent = `Fetched ${statsTotalMessages} messages — ${statsMessagesWithMedia} with media (${statsTotalMediaCount} files total)`;
}
async function appendMessageOptions(messages)
{
statsTotalMessages += messages.length;
for (const message of messages)
{
const messageMedia = await getMessageMedia(groupId, message.id);
if (messageMedia.medias.length === 0) {
continue;
}
statsMessagesWithMedia++;
statsTotalMediaCount += messageMedia.mediaCount;
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);
selectElem.appendChild(option);
}
updateStats();
}
// Populate the select with the initially-fetched messages.
await appendMessageOptions(cachedMessages[groupId].response.messages);
if (!cachedMessages[groupId].hasMore) {
loadMoreButton.style.display = 'none';
loadAllCheckboxWrapper.remove();
}
loadAllCheckbox.addEventListener('change', function() {
loadMoreButton.textContent = loadAllCheckbox.checked ? 'Load all messages' : 'Load more messages';
});
loadMoreButton.addEventListener('click', async function() {
loadMoreButton.textContent = 'Loading...';
loadMoreButton.classList.add('disabled');
loadAllCheckbox.disabled = true;
let hasMore;
do {
const previousCount = cachedMessages[groupId].response.messages.length;
hasMore = await fetchMoreMessages(groupId);
const newMessages = cachedMessages[groupId].response.messages.slice(previousCount);
await appendMessageOptions(newMessages);
} while (hasMore && loadAllCheckbox.checked);
if (!hasMore) {
loadMoreButton.style.display = 'none';
loadAllCheckboxWrapper.remove();
} else {
loadMoreButton.textContent = loadAllCheckbox.checked ? 'Load all messages' : 'Load more messages';
loadMoreButton.classList.remove('disabled');
loadAllCheckbox.disabled = false;
}
});
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.mediaCount} 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);
cloned.removeAttribute('disabled');
});
parent.insertAdjacentElement('afterend', cloned);
}
/**
* Helpers for getting the download media button (if it already exists)
*/
function getDownloadMessageMediaButton()
{
return document.querySelector('#downloadMessageBundles');
}
/**
* 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;
}
const postDropdownSelector = 'div.feed-item-title > div.feed-item-actions.dropdown-trigger.more-dropdown > div.dropdown-list';
async function handleLoad()
{
const paths = getCurrentUrlPaths();
const root = paths[0] || '';
const secondary = paths[1] || null;
if (root === 'messages' && secondary) {
await handleMessages(secondary);
}
if (root !== '' && secondary === 'posts') {
// await fetchProfile(root);
}
}
let oldUrl = '';
async function checkNewUrl()
{
const newUrl = window.location.href;
if (oldUrl !== newUrl) {
oldUrl = newUrl;
if (getDownloadMessageMediaButton()) {
getDownloadMessageMediaButton().remove();
}
handleLoad();
}
// Handle the post dropdown independently of URL changes — the dropdown element
// is created and destroyed by Angular each time the user opens it, so we poll
// for it directly rather than relying on a one-shot observer that can mis-fire
// during Angular's initial render on a direct page load.
const paths = getCurrentUrlPaths();
if (paths[0] === 'post' && paths[1]) {
const dropdown = document.querySelector(postDropdownSelector);
if (dropdown) {
await handleSinglePost(dropdown, paths[1]);
}
}
}
let interval;
function init()
{
if (!interval) {
oldUrl = window.location.href;
handleLoad();
interval = setInterval(checkNewUrl, 100);
}
}
init();
@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: m@rcus.dev

@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: m@rcus.dev
@M-rcus cool, I've sent you an email to avoid clogging this thread up.

@schleeb
Copy link

schleeb commented Sep 27, 2025

I decided to give this a shot on mobile - Firefox w/ ViolentMonkey extension. The download menu item shows up, but I can't seem to get it to do anything when I click on it. Any thoughts? Could be I'm just going something stupid.

Additionally, also on mobile, I can't seem to get the DM download ability to work at all. I think the problem there is that, even in Desktop mode, the resolution is so low that it uses a compact view, so you can't see the message list (where the download button appears) at the same time as the DM itself (which is what triggers the option to show). Any ideas here?

@schleeb
Copy link

schleeb commented Sep 27, 2025

I will throw out that I've found a convoluted mode to get around the DM button problem, but the download issue is still there. I'm going to experiment to see if I can see JS errors or something. I figure the more people able to archive their content the better.

@M-rcus
Copy link
Author

M-rcus commented Sep 27, 2025

@schleeb I have no idea about Firefox Android on mobile to be honest. Could be some prevention in the app due to the way it triggers downloads? Maybe even due to multiple files, not sure.

@schleeb
Copy link

schleeb commented Sep 27, 2025

I'll see if I can figure out more. There's a way to access the console, but it either requires annoying PC interfacing, or I think I read there was an add-on.
I'll let you know what I figure out.

@schleeb
Copy link

schleeb commented Jan 13, 2026

So I just had an issue where, in DMs, the script couldn't grab messages further up in the conversation. Any ideas, or suggestions on fixes? I'm wondering if it's tied to needing the content to load first, unfortunately, I don't see a way to re-trigger the grab, at least with the script as is (I might poke around on my own)

@M-rcus
Copy link
Author

M-rcus commented Jan 13, 2026

@schleeb

So I just had an issue where, in DMs, the script couldn't grab messages further up in the conversation. Any ideas, or suggestions on fixes? I'm wondering if it's tied to needing the content to load first, unfortunately, I don't see a way to re-trigger the grab, at least with the script as is (I might poke around on my own)

They recently (ish) did a change where APIs that normally allowed any limit are now limited to 50 per "batch". I'll see if I can push some kind of fix for this in the near future, but the gist of it is that the limit=20000 I did no longer works: https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7#file-fansly-download-user-js-L571

The short version is:

  • Grab 50 latest messages
  • Find the oldest message (last message in array), get the message ID.
  • Do the same request, but append the before=OLDEST_MESSAGE_ID_HERE parameter to the request.
  • Repeat until no more messages (or to a certain limit, otherwise it'll take quite some time for those with a lot of DM history).

@schleeb
Copy link

schleeb commented Jan 13, 2026

A dropdown for # of messages might help.

I wish my brain wasn't so fried I don't think I'll be able to sort out a solution on my own, even though I understand the gist. I'll poke around, anyway, and look forward to a proper fix from you!

Thanks for the prompt response!

@schleeb
Copy link

schleeb commented Jan 14, 2026

Maybe this is what you meant, but I basically manually did what you said, even exporting the last returned ID, and repeating, until I got to the one I wanted. Thanks again!

@M-rcus
Copy link
Author

M-rcus commented Jan 14, 2026

Maybe this is what you meant, but I basically manually did what you said, even exporting the last returned ID, and repeating, until I got to the one I wanted. Thanks again!

Yep, that sounds like what I meant at least.

@M-rcus
Copy link
Author

M-rcus commented Feb 25, 2026

I did these changes a few weeks ago and forgot to commit/push them out, whoops.

Anyway, they've been pushed out now.

I've also done v0.8.0 which adds experimental downloads for the m3u8 video format, it should allow people to download the highest quality videos available using the userscript. Please read the section in the README: https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7#experimental-downloading-the-highest-quality-videos-via-in-browser-m3u8-fetching-and-transmuxing

@schleeb If you don't mind - could you take a look and do some testing of your own to see if it works well for you?

@schleeb
Copy link

schleeb commented Feb 25, 2026

@M-rcus thanks for the update, as always, greatly appreciate your hard work!

First, grabbing more media from DMs is working! In some cases, it went all the way back, at least to creators I'd followed in 2024. The only case that didn't go all the way back was one in which a ton of media had been sent - I'm guessing you still included a cutoff (not sure if it's tied to media, or number of messages), which I think is reasonable. If people really want to go further back, they can use the trick we'd discussed earlier. I wonder if using the "show media from [creator]" might be a more effective place to grab from, though it probably would require a bit of a rewrite for a feature with limited gains.

Good news, I can confirm the highest quality videos now appear to be grabbed, and it plays just fine! It also doesn't require having to manually select the highest quality setting in the dropdown, it just grabs it for you (this was one of the downsides of using something like cat-catch).

That's the important part, and really should satisfy 99% of users, the rest is kind of nitpicking, and a little bizarre, but I figured I'd share it.

The video length, and other pieces of metadata, is being misreported. I download a video from one creator, and it grabbed both the preview and final videos (as expected). The preview is 3 s 167 ms and the full video is 21 s 167 ms, but Windows is reporting that each of the files are 13h 15m 21s (no idea how many ms, Windows doesn't report on that, at least not directly). It also doesn't seem to report the correct Data rate or Total bitrate, instead saying 0kbps. It also applies a "Media created" day of 12/31/1903 7:00 PM.

Aside from some superficial anomalies (e.g. location), MediaInfo, which I imagine actually processes the file, reports were as follows:

Value Script Cat-catch
Codec ID isom (isom/avc1) isom (isom/iso2/avc1/mp41)
ID 258 1
Maximum bit rate 3 000 kb/s (didn't exist)

No idea if any of that could cause the differences in reported Windows metadata.

Of all that, the only one I personally am a bit peevish about is the file length. I sometimes sort videos by length, and these would never sort right.

My guess is that this is probably something happening on the mux.js end of things. I suppose it's also possible that Claude gave you something that wasn't quite correct - it's still impressive how good AI is at kind of gathering information. Even if it's not the best code, for stuff like this, I imagine it's a huge help.

Anyway, reiterating my initial comment, appreciate the hard work, it's an awesome update!

@M-rcus
Copy link
Author

M-rcus commented Feb 25, 2026

@schleeb Thanks for the feedback. I see at least some of the issues you're looking at, at least the creation date issue. ffprobe also reports some... oddities about the metadata. It's possible these are just limitations of mux.js, but maybe not. I'll take another look tomorrow and see if I can do some fixes/tweaks to it.

@M-rcus
Copy link
Author

M-rcus commented Feb 26, 2026

@schleeb Okay, can you test with latest version v0.8.2 v0.8.3+? It should fix both the issue of creation date (Media created) and the video length, at least from the tests I've done.

@schleeb
Copy link

schleeb commented Feb 26, 2026

@M-rcus awesome update!

Video metadata appears to be correct this time around! I'd briefly looked into it yesterday and it looked like it wasn't possible, so that's great you figured it out.

Also checked, and, looks like you're grabbing all messages, even when there are a ton (it took a second, but I think that was due to the rate limiting).

Really awesome work.

I've got a follow up message coming, when I get more time to write it, but I wanted to get this out there, since you've managed to succeed at everything, and I figured you deserved to know and get praise for your efforts lol

@M-rcus
Copy link
Author

M-rcus commented Feb 26, 2026

@schleeb

@M-rcus awesome update!

Video metadata appears to be correct this time around! I'd briefly looked into it yesterday and it looked like it wasn't possible, so that's great you figured it out.

Also checked, and, looks like you're grabbing all messages, even when there are a ton (it took a second, but I think that was due to the rate limiting).

Really awesome work.

I've got a follow up message coming, when I get more time to write it, but I wanted to get this out there, since you've managed to succeed at everything, and I figured you deserved to know and get praise for your efforts lol

Haha, appreciate it. Granted, the changes for the creation date and video length is 99% Claude-generated as I'm not too familiar with the intricate details of the MP4 container and whatnot. I only "guided" it by describing the problem basically.

Yeah, messages can take a while to fetch if you have a lot of history or chatting with the creator. At some point I'll look into adding a configurable "cutoff age" for messages. Say only fetch messages from the past 6 months or so by default. Maybe even add a "Load older messages" button on the popup.

@schleeb
Copy link

schleeb commented Feb 27, 2026

I feel bad throwing more out there, but I do have a concern that didn't occur to me the other day. (Apologies if it's a little unclear, I'm getting tired)

After watching all the fetching go by in the network tab, now I'm worried it could trigger an account flag or something, if every time you're visiting DMs from a creator it makes 100+ fetches to grab media. A cutoff age, or maybe a variable you can select for the number of fetches, like the M3U8 setting, or in the modal, let people click a button to fetch more sound like viable solutions? Maybe I'm being paranoid. I've temporarily disabled the script for now, and will turn it on/off as needed, just in case.

I did notice the DM download script grabs all media, regardless of who sent it. Limiting it to creator only content (or a toggle to do so) would be the only thing left that I think this script doesn't do.

A potential alternative way to address both problems
Something I'd mentioned previously was that grabbing DM content from the "Show Media from [user]" page from the dropdown might potentially be a better way to handle DM media. It appears to make far fewer API calls, and it restricts to only media the user sent. In case you aren't familiar with it, it's in the kebab menu at the upper right of DMs. It made a total of 4 calls to load all media (and the last two were technically empty), whereas the current method took probably a hundred or more. The biggest downside to this method, that I can think of, is the loss of any text message sent with the media that might make it easier to find something specific.

Everything about the image is also in the response, creation date and time, and the links to the media files.

It does look like one difference between the call to message (the current method) and location (the show media page) is that location also requires the ID of the creator, but I imagine that's easily obtainable.

The size of the returned result is larger than each message call, but, I imagine it's substantially smaller than all of the individual calls combined. It would require having to parse a different set of JSON, but I'd imagine that would actually be pretty easy to do.

Digging through it, it looks like the JSON results from the "Show Media" and the full message page both contain accountMedia which is formatted the same.

One final thought
The existing code might actually be useful for archiving DM conversations, something I've actually tried doing in a handful of cases, but I mostly have been doing it "manually", which is way too much effort initially, and still a hassle to update - it also drops any media in the chat. Not that you're looking for additional side-projects!

If you're confused by anything, let me know, and I'll try to clarify in the morning!

@M-rcus
Copy link
Author

M-rcus commented Feb 27, 2026

@schleeb

I feel bad throwing more out there, but I do have a concern that didn't occur to me the other day. (Apologies if it's a little unclear, I'm getting tired)

After watching all the fetching go by in the network tab, now I'm worried it could trigger an account flag or something, if every time you're visiting DMs from a creator it makes 100+ fetches to grab media. A cutoff age, or maybe a variable you can select for the number of fetches, like the M3U8 setting, or in the modal, let people click a button to fetch more sound like viable solutions? Maybe I'm being paranoid. I've temporarily disabled the script for now, and will turn it on/off as needed, just in case.

You're bringing up a very fair point, even though I'm not sure it's too much of a concern. I have some very ghetto scripts running on my server that do frequent fetching of the whole timeline / message history.

Regardless I have updated the script to v0.9.0, I'll just refer to the changelog so I don't have to repeat myself unnecessarily: https://gist.github.com/M-rcus/a29673a5fcf22afd0e67d549b36496a7#v090---2026-02-27

I did notice the DM download script grabs all media, regardless of who sent it. Limiting it to creator only content (or a toggle to do so) would be the only thing left that I think this script doesn't do.

I also fixed this in v0.9.0. There's no toggle for this, because I don't think anyone actually wants to download the stuff they've sent themselves (but I guess that can change if a single person actually finds a need for this).

The biggest downside to this method, that I can think of, is the loss of any text message sent with the media that might make it easier to find something specific.

I knew about this section, but the reason I never used it is basically for this reason.
At one point - and I still might implement this - I wanted to use the message text as a base for the downloaded filenames (for those that wanted it as a toggle). I just never got around to implementing it (like a lot of things prior to the past few days).

The idea would be to the use up to X amount of characters/words in the message (if any) and simply append that to the filename. I still wanted to keep the prefix (especially with the "position"), since it helps keeps the set in order when you sort by filename.

The existing code might actually be useful for archiving DM conversations, something I've actually tried doing in a handful of cases, but I mostly have been doing it "manually", which is way too much effort initially, and still a hassle to update - it also drops any media in the chat. Not that you're looking for additional side-projects!

I wouldn't mind adding a feature that exports a DM conversion as a JSON (or maybe CSV, though JSON would be easiest I guess). Honestly, I could just add it as a separate button on the download modal. It really wouldn't be too much extra work.

@schleeb
Copy link

schleeb commented Feb 27, 2026

@M-rcus I'm not sure what's going on, but nothing seems to be working correctly, now.

I tried a regular page download, first, and nothing I did would bring up the download link.

Next, I tried the message downloading, the button and modal show up just fine, but nothing populates in the dropdown itself. The API calls appear to be working fine, and returning results, but something in the population of the dropdown seems to be working.

That said, once it gets working, this definitely seems like a good solution.

When it comes to the message JSON, I think the bigger hassle would be presenting the information in a more readable fashion, but I suspect that's actually not that hard to do (I've technically stripped out the necessary CSS to basically present the text already, it's just that it's manually pasting in all the html for each message, so it's very bulky).

I'll poke around myself, to see if I can figure out what's at fault.

@schleeb
Copy link

schleeb commented Feb 27, 2026

@M-rcus My apologies, I stand corrected! DMs are working, I just didn't go far enough back in one conversation, since there was a lot of messaging with only text.

@schleeb
Copy link

schleeb commented Feb 27, 2026

@M-rcus The regular download media on posts button is also working now, so there must have been something on my end going wrong.

Everything is working great, sorry about that!

@M-rcus
Copy link
Author

M-rcus commented Feb 28, 2026

@schleeb

When it comes to the message JSON, I think the bigger hassle would be presenting the information in a more readable fashion, but I suspect that's actually not that hard to do (I've technically stripped out the necessary CSS to basically present the text already, it's just that it's manually pasting in all the html for each message, so it's very bulky).

I mean if you don't need it exactly like Fansly's UI, you just need to sort them by createdAt and display them as chat bubbles. Save some additional metadata like your user ID and the creator's user ID, then match them with the senderId on the message data.

@M-rcus My apologies, I stand corrected! DMs are working, I just didn't go far enough back in one conversation, since there was a lot of messaging with only text.

Right, I suppose this can be a problem with this solution. Didn't think of that (I don't do too much chatting back and forth 😂 )
I think what I'll do for v0.9.1, is to add some text to the modal that mentions how many messages + medias have been fetched/found. Should hopefully avoid that confusion.

@M-rcus The regular download media on posts button is also working now, so there must have been something on my end going wrong.

Everything is working great, sorry about that!

I know the "Download media" button on posts can be a bit finicky. I need to improve the logic that adds said button I think. Hopefully also for v0.9.1.

@M-rcus
Copy link
Author

M-rcus commented Mar 2, 2026

@schleeb I pushed out v0.9.2 (and v0.9.1 for that matter) which adds the message count to the download popup, and an improvement (hopefully) to the "Download media" on posts

Let me know if you have any issues

@schleeb
Copy link

schleeb commented Mar 3, 2026

@M-rcus everything seemed to be working good for me! Great work, as always!

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