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