mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-24 02:08:55 +08:00
feat(route): mixcloud: refactor & add route playlists (#19457)
* feat(route): mixcloud: refactor & add route playlists * fix: flatten route path ---------
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user