feat(route): mixcloud: refactor & add route playlists (#19457)

* feat(route): mixcloud: refactor & add route playlists

* fix: flatten route path

---------
This commit is contained in:
欠陥電気
2025-07-01 02:47:54 +08:00
committed by GitHub
parent 6617f9803d
commit 79b3e0d6dd
4 changed files with 323 additions and 2381 deletions
+129
View File
@@ -0,0 +1,129 @@
export const MIXCLOUD_CONFIG = {
host: 'https://www.mixcloud.com',
imageBaseURL: 'https://thumbnailer.mixcloud.com/unsafe/480x480/',
graphqlURL: 'https://app.mixcloud.com/graphql',
decryptionKey: 'IFYOUWANTTHEARTISTSTOGETPAIDDONOTDOWNLOADFROMMIXCLOUD',
headers: {
Referer: 'https://www.mixcloud.com',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
};
export const TYPE_CONFIG = {
uploads: 'uploads',
reposts: 'reposted',
favorites: 'favorites',
listens: 'listeningHistory',
stream: 'stream',
playlist: 'items',
};
export const TYPE_NAMES = {
uploads: 'Shows',
reposts: 'Reposts',
favorites: 'Favorites',
listens: 'History',
stream: 'Stream',
playlist: 'Playlist',
};
export const CLOUDCAST_FIELDS = `
id
slug
name
description
publishDate
audioLength
picture(width: 1024, height: 1024) {
url
}
owner {
displayName
username
url
}
streamInfo {
url
hlsUrl
dashUrl
}
favorites {
totalCount
}
reposts {
totalCount
}
plays
tags {
tag {
name
}
}
featuringArtistList
isExclusive
restrictedReason
comments(first: 100) {
edges {
node {
comment
created
user {
displayName
username
}
}
}
totalCount
}
`;
export function getObjectFields(type: string): { objectType: string; objectFields: string } {
if (type === 'playlist') {
return {
objectType: 'playlist',
objectFields: `
name
description
picture {
urlRoot
}
items(first: 100) {
edges {
node {
cloudcast {
${CLOUDCAST_FIELDS}
}
}
}
pageInfo {
endCursor
hasNextPage
}
}
`,
};
} else {
const nodeTemplate = type === 'listens' ? `node { cloudcast { ${CLOUDCAST_FIELDS} } }` : `node { ${CLOUDCAST_FIELDS} }`;
return {
objectType: 'user',
objectFields: `
displayName
biog
picture {
urlRoot
}
${TYPE_CONFIG[type]}(first: 100) {
edges {
${nodeTemplate}
}
pageInfo {
endCursor
hasNextPage
}
}
`,
};
}
}
+163 -107
View File
@@ -2,14 +2,17 @@ import { Route } from '@/types';
import got from '@/utils/got';
import CryptoJS from 'crypto-js';
import { parseDate } from '@/utils/parse-date';
import { queries } from './queries';
import InvalidParameterError from '@/errors/types/invalid-parameter';
import { MIXCLOUD_CONFIG, TYPE_CONFIG, TYPE_NAMES, getObjectFields } from './config';
export const route: Route = {
path: '/:username/:type?',
categories: ['multimedia'],
example: '/mixcloud/dholbach/uploads',
parameters: { username: 'Username, can be found in URL', type: 'Type, see below, uploads by default' },
parameters: {
username: 'Username, can be found in URL',
type: 'Type, see below, uploads by default',
},
features: {
requireConfig: false,
requirePuppeteer: false,
@@ -34,123 +37,176 @@ export const route: Route = {
| uploads | reposts | favorites | listens | stream |`,
};
async function handler(ctx) {
const host = 'https://www.mixcloud.com';
const imageBaseURL = 'https://thumbnailer.mixcloud.com/unsafe/480x480/';
const graphqlURL = 'https://app.mixcloud.com/graphql';
const headers = {
Referer: host,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
};
async function callApi(objectType: string, objectFields: string, username: string, slug?: string) {
const lookupKey = objectType + 'Lookup';
const headers = MIXCLOUD_CONFIG.headers;
const type = ctx.req.param('type') ?? 'uploads';
const lookupParams = slug ? `, slug: "${slug}"` : '';
const query = `{
${lookupKey}(lookup: {username: "${username}"${lookupParams}}) {
${objectFields}
}
}`;
const response = await got({
method: 'post',
url: MIXCLOUD_CONFIG.graphqlURL,
headers,
json: { query },
});
return response.data.data[lookupKey];
}
// https://github.com/yt-dlp/yt-dlp/commits/master/yt_dlp/extractor/mixcloud.py
function decryptXorCipher(key: string, ciphertext: string): string {
const decoded = CryptoJS.enc.Base64.parse(ciphertext).toString(CryptoJS.enc.Utf8);
return [...decoded].map((ch, i) => String.fromCodePoint((ch.codePointAt(0) || 0) ^ (key.codePointAt(i % key.length) || 0))).join('');
}
function tryDecrypt(ciphertext: string | undefined): string {
if (!ciphertext) {
return '';
}
try {
return decryptXorCipher(MIXCLOUD_CONFIG.decryptionKey, ciphertext);
} catch {
return ciphertext;
}
}
function getCloudcast(node: any, type: string): any {
if (type === 'playlist' || type === 'listens') {
return node.cloudcast;
}
return node;
}
function getPlaylistTitle(displayName: string, type: string, playlistName?: string): string {
if (type === 'playlist' && playlistName) {
return `Mixcloud - ${displayName}'s Playlist: ${playlistName}`;
}
return `Mixcloud - ${displayName}'s ${TYPE_NAMES[type] || type}`;
}
function getPlaylistLink(username: string, type: string, playlistSlug?: string): string {
const host = MIXCLOUD_CONFIG.host;
if (type === 'playlist' && playlistSlug) {
return `${host}/${username}/playlists/${playlistSlug}/`;
}
return `${host}/${username}/${type === 'uploads' ? '' : type + '/'}`;
}
export async function handler(ctx) {
const username = ctx.req.param('username');
const playlistSlug = ctx.req.param('playlist');
const type = ctx.req.param('type') ?? (playlistSlug ? 'playlist' : 'uploads');
const config = {
stream: { name: 'Stream', node: 'stream' },
uploads: { name: 'Shows', node: 'uploads' },
reposts: { name: 'Reposts', node: 'reposted' },
favorites: { name: 'Favorites', node: 'favorites' },
listens: { name: 'History', node: 'listeningHistory' },
};
if (!config[type]) {
if (!TYPE_CONFIG[type]) {
throw new InvalidParameterError(`Invalid type: ${type}`);
}
const payloads = {
stream: {
query: queries.stream.query,
variables: {
lookup: {
username: ctx.req.param('username'),
},
},
},
uploads: {
query: queries.uploads.query,
variables: {
lookup: {
username: ctx.req.param('username'),
},
orderBy: 'LATEST',
onlyAttributedTo: '',
hasAttributedTo: false,
},
},
reposts: {
query: queries.reposts.query,
variables: {
lookup: {
username: ctx.req.param('username'),
},
},
},
favorites: {
query: queries.favorites.query,
variables: {
lookup: {
username: ctx.req.param('username'),
},
},
},
listens: {
query: queries.listens.query,
variables: {
lookup: {
username: ctx.req.param('username'),
},
},
},
};
const { objectType, objectFields } = getObjectFields(type);
const data = (
await got({
method: 'post',
url: graphqlURL,
json: payloads[type],
headers,
const data = await callApi(objectType, objectFields, username, playlistSlug);
if (!data) {
throw new Error(`${type === 'playlist' ? 'Playlist' : 'User'} not found`);
}
const isPlaylist = type === 'playlist';
const displayName = isPlaylist ? username : data.displayName;
const description = isPlaylist ? data.description : data.biog;
const picture = data.picture;
const image = picture && picture.urlRoot ? `${MIXCLOUD_CONFIG.imageBaseURL}${picture.urlRoot}` : '';
const itemsData = data[TYPE_CONFIG[type]];
const edges = itemsData?.edges || [];
const items = edges
.map((edge: any) => {
const cloudcast = getCloudcast(edge.node, type);
if (!cloudcast) {
return null;
}
const streamInfo = cloudcast.streamInfo || {};
const enclosureUrl = tryDecrypt(streamInfo.url);
const tags = cloudcast.tags?.map((t: any) => t.tag?.name).filter(Boolean) || [];
let richDescription = cloudcast.description?.replaceAll('\n', '<br>') || '';
if (cloudcast.featuringArtistList && cloudcast.featuringArtistList.length > 0) {
const artists = cloudcast.featuringArtistList.slice(0, 5).join(', ');
richDescription += richDescription ? '<br><br>' : '';
richDescription += `<strong>Featured Artists:</strong> ${artists}`;
if (cloudcast.featuringArtistList.length > 5) {
richDescription += ` and ${cloudcast.featuringArtistList.length - 5} more...`;
}
}
const stats: string[] = [];
if (cloudcast.plays) {
stats.push(`${cloudcast.plays} plays`);
}
if (cloudcast.favorites?.totalCount) {
stats.push(`${cloudcast.favorites.totalCount} favorites`);
}
if (cloudcast.reposts?.totalCount) {
stats.push(`${cloudcast.reposts.totalCount} reposts`);
}
if (cloudcast.comments?.totalCount) {
stats.push(`${cloudcast.comments.totalCount} comments`);
}
if (stats.length > 0) {
richDescription += richDescription ? '<br><br>' : '';
richDescription += `<em>${stats.join(' • ')}</em>`;
}
return {
title: cloudcast.name,
author: cloudcast.owner?.displayName || username,
description: richDescription,
pubDate: parseDate(cloudcast.publishDate),
guid: Buffer.from(cloudcast.id, 'base64').toString(),
link: `${MIXCLOUD_CONFIG.host}/${cloudcast.owner?.username || username}/${cloudcast.slug}`,
itunes_item_image: cloudcast.picture?.url || '',
itunes_duration: cloudcast.audioLength,
enclosure_url: enclosureUrl,
enclosure_type: 'audio/x-m4a',
itunes_author: cloudcast.owner?.displayName || username,
category: tags,
comments: cloudcast.comments?.totalCount || 0,
upvotes: cloudcast.favorites?.totalCount || 0,
media: enclosureUrl
? {
content: {
url: enclosureUrl,
type: 'audio/x-m4a',
duration: cloudcast.audioLength,
},
thumbnail: cloudcast.picture?.url
? {
url: cloudcast.picture.url,
}
: undefined,
}
: undefined,
};
})
).data.data;
.filter(Boolean);
const biog = data.user.biog;
const image = `${imageBaseURL}${data.user.picture.urlRoot}`;
// https://github.com/ytdl-org/youtube-dl/blob/f1487d4fca40fd37d735753e24a7bae53a1b1513/youtube_dl/extractor/mixcloud.py#L72-L79
const decryptionKey = 'IFYOUWANTTHEARTISTSTOGETPAIDDONOTDOWNLOADFROMMIXCLOUD';
const decryptXorCipher = (key, cipherText) => {
const cipher = CryptoJS.enc.Base64.parse(cipherText);
const decrypted = cipher.toString(CryptoJS.enc.Utf8);
let result = '';
for (let i = 0; i < decrypted.length; i++) {
result += String.fromCharCode(decrypted.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return result;
};
const items = data.user[config[type].node].edges.map((edge) => {
const item = type === 'listens' ? edge.node.cloudcast : edge.node;
return {
title: item.name,
author: item.owner.displayName,
description: item.description.replaceAll('\n', '<br>'),
pubDate: parseDate(item.publishDate),
guid: Buffer.from(item.id, 'base64').toString('utf8'),
link: `${host}/${username}/${item.slug}`,
itunes_item_image: `${imageBaseURL}${item.picture.urlRoot}`,
itunes_duration: item.audioLength,
enclosure_url: decryptXorCipher(decryptionKey, item.streamInfo.url),
enclosure_type: 'audio/x-m4a',
upvotes: item.favorites.totalCount,
};
});
const title = getPlaylistTitle(displayName, type, data.name);
const link = getPlaylistLink(username, type, playlistSlug);
return {
title: `Mixcloud - ${data.user.displayName}'s ${config[type].name}`,
description: biog.replaceAll('\n', '<br>'),
itunes_author: data.user.displayName,
title,
description: description?.replaceAll('\n', '<br>') || '',
itunes_author: displayName,
image,
link: `${host}/${username}/${type}/`,
link,
item: items,
};
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import { Route } from '@/types';
import { handler } from './index';
export const route: Route = {
path: '/:username/playlists/:playlist',
categories: ['multimedia'],
example: '/mixcloud/dholbach/playlists/ecclectic-dance',
parameters: {
username: 'Username, can be found in URL',
playlist: 'Playlist slug, can be found in URL',
},
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: true,
supportScihub: false,
},
radar: [
{
source: ['mixcloud.com/:username/playlists/:playlist'],
},
{
source: ['www.mixcloud.com/:username/playlists/:playlist'],
},
],
name: 'Playlist',
maintainers: ['Misaka13514'],
handler,
};