LastFMDownloader/index.js

297 lines
8.6 KiB
JavaScript
Raw Normal View History

2024-02-19 22:36:23 +00:00
require('dotenv').config();
2024-02-21 16:25:06 +00:00
const { Readable, PassThrough } = require("stream");
const { finished } = require("stream/promises");
const { searchMusics } = require('fix-esm').require("node-youtube-music");
const { GetListByKeyword, GetVideoDetails } = require("youtube-search-api");
2024-02-19 22:36:23 +00:00
const skewered = require("skewered");
2024-02-19 23:16:29 +00:00
const { createClient } = require("fix-esm").require("webdav");
2024-02-19 22:36:23 +00:00
const path = require("path");
2024-02-19 23:16:29 +00:00
const { readFileSync, writeFileSync } = require("fs");
const io = require('socket.io-client');
2024-02-21 16:25:06 +00:00
const NodeID3 = require('node-id3');
const xpath = require("xpath-html");
2024-02-19 22:36:23 +00:00
function pathGenerator({ url, name, channelName }) {
2024-02-21 16:25:06 +00:00
return `${url}-${skewered(`${name} - ${channelName}`)}.mp3`;
2024-02-19 22:36:23 +00:00
}
2024-02-19 23:16:29 +00:00
function checkLastSong({ name, channelName }) {
const curSong = readFileSync(path.join(__dirname, "last_song.txt"), 'utf8');
return (curSong === `${name} - ${channelName}`);
}
function writeLastSong({ name, channelName }) {
2024-02-19 23:23:47 +00:00
writeFileSync(path.join(__dirname, "last_song.txt"), `${name} - ${channelName}`, { recursive: true, encoding: 'utf-8' });
2024-02-19 23:16:29 +00:00
return null;
}
async function scrapeLastFMWebsiteForVideo(trackData) {
const lastFMSite = await fetch(trackData.url).then(x => x.text());
// classes: image-overlay-playlink-link
// attrs: data-youtube-id
const attr = xpath.fromPageSource(lastFMSite).findElement("//a[contains(@class, 'image-overlay-playlink-link')]/@data-youtube-id").getAttribute("data-youtube-id");
if (attr == null) return;
const youtubeVideo = await GetVideoDetails(attr);
return {
...youtubeVideo,
id: attr
};
}
2024-02-21 16:27:05 +00:00
async function getVideo() {
const trackData = await fetch(
`https://${process.env.LASTFM_INSTANCE}/2.0/?method=user.getrecenttracks&user=${process.env.LASTFM_USERNAME}&api_key=${process.env.LASTFM_API_KEY}&format=json&limit=1&extended=1`
).then(x => x.json()).then(data => data.recenttracks.track[0]);
const tags = {
title: trackData.name,
artist: trackData.artist.name,
album: trackData.album["#text"] || trackData.name,
2024-02-21 16:27:05 +00:00
APIC: "./cover.png",
userDefinedUrl: [{
description: "Last.FM page",
url: trackData.url
}]
};
2024-02-19 22:36:23 +00:00
2024-02-19 23:16:29 +00:00
if (checkLastSong({
name: trackData.name,
channelName: trackData.artist.name
})) {
console.log("Last song check failed");
return null;
} else {
console.log("Last song check succeeded, writing file");
writeLastSong({
name: trackData.name,
channelName: trackData.artist.name
});
}
2024-02-19 22:36:23 +00:00
let selectedVideo = {
url: "",
name: "",
channelName: ""
};
const musicList = await searchMusics(`${trackData.artist.name} - ${trackData.name}`);
const youtubeMusicVideo = musicList.find((song) => {
return skewered(song.title).includes(skewered(trackData.name))
&& song.artists.some(artist => skewered(trackData.artist.name).includes(artist.name));
});
2024-02-19 22:36:23 +00:00
if (youtubeMusicVideo == null) {
const lastFMVideo = await scrapeLastFMWebsiteForVideo(trackData);
if (lastFMVideo == null) {
const videoList = await GetListByKeyword(`${trackData.artist.name} - ${trackData.name}`, false, 1, [
{type: "video"}
]);
const musicVideo = videoList.items[0];
selectedVideo = {
url: musicVideo.id,
name: musicVideo.title,
channelName: musicVideo.channelTitle
};
} else selectedVideo = {
url: lastFMVideo.id,
name: lastFMVideo.title,
channelName: lastFMVideo.channel
};
} else selectedVideo = {
url: youtubeMusicVideo.youtubeId,
name: youtubeMusicVideo.title,
channelName: youtubeMusicVideo.artists[0].name
}
selectedVideo.channelName = selectedVideo.channelName.replace(/\s-\sTopic/ig, "");
2024-02-21 16:25:06 +00:00
return {
tags,
...selectedVideo,
albumCover: trackData.image?.at(-1)["#text"]
2024-02-21 16:25:06 +00:00
};
2024-02-19 22:36:23 +00:00
}
2024-02-21 18:07:18 +00:00
async function checkNextcloud({ url, name, channelName, tags }, nextcloudClient) {
2024-02-19 22:36:23 +00:00
const fileName = pathGenerator({ url, name, channelName });
2024-02-21 18:07:18 +00:00
return await nextcloudClient.exists(path.join(process.env.NEXTCLOUD_FOLDER, tags.album, fileName));
}
async function checkNextcloudAlbum({ tags }, nextcloudClient) {
2024-02-21 18:07:18 +00:00
return await nextcloudClient.exists(path.join(process.env.NEXTCLOUD_FOLDER, tags.album, "cover.png"));
2024-02-19 22:36:23 +00:00
}
2024-02-21 16:25:06 +00:00
async function downloadFromCobalt({ url }) {
2024-02-19 22:36:23 +00:00
const processor = await fetch(`https://${process.env.COBALT_INSTANCE}/api/json`, {
method: "POST",
body: JSON.stringify({
url: `https://youtu.be/${url}`,
vCodec: "h264",
aFormat: "mp3",
filenamePattern: "basic",
isAudioOnly: true,
disableMetadata: false
}),
headers: [
["Accept", "application/json"],
["Content-Type", "application/json"],
]
}).then(x => x.json());
let processorURL;
switch (processor.status) {
case 'error':
case 'rate-limit':
throw new Error(`Error! (${processor.text})`);
case 'redirect':
processorURL = processor.audio || processor.url;
break;
case 'picker':
throw new Error("Can't handle picker!");
case 'stream':
const streamProcessor = await fetch(`${processor.url}&p=1`).then(x => x.json());
if (streamProcessor.status === "continue")
processorURL = processor.audio || processor.url;
else processorURL = processor.audio || processor.url;
break;
default:
processorURL = processor.audio || processor.url;
break;
}
2024-02-21 16:25:06 +00:00
const body = Buffer.from(await fetch(processorURL).then(x => x.arrayBuffer()));
2024-02-19 22:36:23 +00:00
2024-02-21 16:25:06 +00:00
return body;
}
async function downloadAlbumCover({ albumCover }) {
const body = Buffer.from(await fetch(albumCover).then(x => x.arrayBuffer()));
return body;
}
function applyTags({ fileStream, tags }) {
const body = NodeID3.update(tags, fileStream);
return body;
2024-02-19 22:36:23 +00:00
}
2024-02-21 16:25:06 +00:00
async function uploadToNextcloud({ fileStream, url, name, channelName, tags, albumCover }, nextcloudClient) {
await nextcloudClient.createDirectory(path.join(process.env.NEXTCLOUD_FOLDER, tags.album), {
2024-02-19 22:36:23 +00:00
recursive: true,
});
2024-02-21 16:25:06 +00:00
const fileBufferStream = new PassThrough().end(fileStream);
2024-02-19 22:36:23 +00:00
const fileName = pathGenerator({ url, name, channelName });
2024-02-21 16:25:06 +00:00
await finished(fileBufferStream.pipe(nextcloudClient.createWriteStream(path.join(process.env.NEXTCLOUD_FOLDER, tags.album, fileName))));
if (albumCover) {
const albumBufferStream = new PassThrough().end(albumCover);
2024-02-21 18:07:18 +00:00
await finished(albumBufferStream.pipe(nextcloudClient.createWriteStream(path.join(process.env.NEXTCLOUD_FOLDER, tags.album, "cover.png"))));
}
2024-02-21 16:25:06 +00:00
2024-02-19 22:36:23 +00:00
return fileName;
}
async function main() {
2024-02-21 16:27:05 +00:00
const video = await getVideo();
2024-02-19 22:36:23 +00:00
2024-02-21 22:19:09 +00:00
if (video == null) return dismantle();
2024-02-19 23:16:29 +00:00
2024-02-19 22:36:23 +00:00
const nextcloudClient = createClient(
`https://${process.env.NEXTCLOUD_INSTANCE}/remote.php/dav/files/${process.env.NEXTCLOUD_USERNAME}/`,
{
username: process.env.NEXTCLOUD_USERNAME,
password: process.env.NEXTCLOUD_PASSWORD,
}
);
2024-02-21 22:19:09 +00:00
if (await checkNextcloud(video, nextcloudClient)) return dismantle();
socket.emit('nodeMessage', {
2024-02-21 22:19:09 +00:00
message: `New song found - ${video.tags.title} by ${video.tags.artist}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
})
2024-02-19 22:36:23 +00:00
let albumCover;
if (!(await checkNextcloudAlbum(video, nextcloudClient)) && video.albumCover != null) {
albumCover = await downloadAlbumCover(video);
}
2024-02-21 16:25:06 +00:00
socket.emit('nodeMessage', {
2024-02-21 22:19:09 +00:00
message: `Cobalt download starting - ${video.tags.title} by ${video.tags.artist}`,
2024-02-21 16:25:06 +00:00
password: process.env.NOTIFICATION_SERVER_PASSWORD
})
2024-02-19 22:36:23 +00:00
const cobalt = await downloadFromCobalt(video);
socket.emit('nodeMessage', {
2024-02-21 22:19:09 +00:00
message: `Cobalt download finished - ${video.tags.title} by ${video.tags.artist}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
});
2024-02-21 16:25:06 +00:00
const taggedMusic = applyTags({
fileStream: cobalt,
tags: video.tags
});
socket.emit('nodeMessage', {
2024-02-21 22:19:09 +00:00
message: `Nextcloud upload running - ${video.tags.title} by ${video.tags.artist}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
2024-02-21 16:25:06 +00:00
});
2024-02-21 16:25:06 +00:00
const nextcloud = await uploadToNextcloud(
{
...video,
fileStream: taggedMusic,
albumCover: albumCover
},
nextcloudClient
);
socket.emit('nodeMessage', {
2024-02-21 22:19:09 +00:00
message: `Nextcloud upload finished - ${video.tags.title} by ${video.tags.artist}`,
2024-02-21 16:25:06 +00:00
password: process.env.NOTIFICATION_SERVER_PASSWORD
})
2024-02-21 22:19:09 +00:00
return dismantle();
2024-02-19 22:36:23 +00:00
}
2024-02-21 22:19:09 +00:00
const socket = io.connect(`http://localhost:${process.env.NOTIFICATION_SERVER_PORT}`, {reconnect: true});
socket.on('connect', function (s) {
console.log('Successfully connected to notification server');
try {
main();
} catch (err) {
socket.emit('nodeMessage', {
message: `LastFMDownloader error - ${err}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
})
throw err;
}
});
2024-02-21 22:19:09 +00:00
function dismantle() {
socket.disconnect();
return null;
}