LastFMDownloader/index.js

263 lines
No EOL
7.5 KiB
JavaScript

require('dotenv').config();
const { Readable, PassThrough } = require("stream");
const { finished } = require("stream/promises");
const { searchMusics } = require('fix-esm').require("node-youtube-music");
const { GetListByKeyword } = require("youtube-search-api");
const skewered = require("skewered");
const { createClient } = require("fix-esm").require("webdav");
const path = require("path");
const { readFileSync, writeFileSync } = require("fs");
const io = require('socket.io-client');
const NodeID3 = require('node-id3');
function pathGenerator({ url, name, channelName }) {
return `${url}-${skewered(`${name} - ${channelName}`)}.mp3`;
}
function checkLastSong({ name, channelName }) {
const curSong = readFileSync(path.join(__dirname, "last_song.txt"), 'utf8');
return (curSong === `${name} - ${channelName}`);
}
function writeLastSong({ name, channelName }) {
writeFileSync(path.join(__dirname, "last_song.txt"), `${name} - ${channelName}`, { recursive: true, encoding: 'utf-8' });
return null;
}
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,
APIC: "./cover.png",
userDefinedUrl: [{
description: "Last.FM page",
url: trackData.url
}]
};
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
});
}
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));
});
if (youtubeMusicVideo == 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: youtubeMusicVideo.youtubeId,
name: youtubeMusicVideo.title,
channelName: youtubeMusicVideo.artists[0].name
}
selectedVideo.channelName = selectedVideo.channelName.replace(/\s-\sTopic/ig, "");
return {
tags,
...selectedVideo,
albumCover: trackData.image?.at(-1)["#text"]
};
}
async function checkNextcloud({ url, name, channelName, tags }, nextcloudClient) {
const fileName = pathGenerator({ url, name, channelName });
return await nextcloudClient.exists(path.join(process.env.NEXTCLOUD_FOLDER, tags.album, fileName));
}
async function checkNextcloudAlbum({ tags }, nextcloudClient) {
return await nextcloudClient.exists(path.join(process.env.NEXTCLOUD_FOLDER, tags.album, "cover.png"));
}
async function downloadFromCobalt({ url }) {
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;
}
const body = Buffer.from(await fetch(processorURL).then(x => x.arrayBuffer()));
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;
}
async function uploadToNextcloud({ fileStream, url, name, channelName, tags, albumCover }, nextcloudClient) {
await nextcloudClient.createDirectory(path.join(process.env.NEXTCLOUD_FOLDER, tags.album), {
recursive: true,
});
const fileBufferStream = new PassThrough().end(fileStream);
const fileName = pathGenerator({ url, name, channelName });
await finished(fileBufferStream.pipe(nextcloudClient.createWriteStream(path.join(process.env.NEXTCLOUD_FOLDER, tags.album, fileName))));
if (albumCover) {
const albumBufferStream = new PassThrough().end(albumCover);
await finished(albumBufferStream.pipe(nextcloudClient.createWriteStream(path.join(process.env.NEXTCLOUD_FOLDER, tags.album, "cover.png"))));
}
return fileName;
}
async function main() {
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');
});
const video = await getVideo();
if (video == null) return dismantle(socket);
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,
}
);
if (await checkNextcloud(video, nextcloudClient)) return dismantle(socket);
socket.emit('nodeMessage', {
message: `New song found - ${video.name} from ${video.channelName}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
})
let albumCover;
if (!(await checkNextcloudAlbum(video, nextcloudClient)) && video.albumCover != null) {
albumCover = await downloadAlbumCover(video);
}
socket.emit('nodeMessage', {
message: `Cobalt download starting - ${video.name} from ${video.channelName}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
})
const cobalt = await downloadFromCobalt(video);
socket.emit('nodeMessage', {
message: `Cobalt download finished - ${video.name} from ${video.channelName}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
});
const taggedMusic = applyTags({
fileStream: cobalt,
tags: video.tags
});
socket.emit('nodeMessage', {
message: `Nextcloud upload running - ${video.name} from ${video.channelName}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
});
const nextcloud = await uploadToNextcloud(
{
...video,
fileStream: taggedMusic,
albumCover: albumCover
},
nextcloudClient
);
socket.emit('nodeMessage', {
message: `Nextcloud upload finished - ${video.name} from ${video.channelName}`,
password: process.env.NOTIFICATION_SERVER_PASSWORD
})
return dismantle(socket);
}
main();
function dismantle(socket) {
socket.disconnect();
return null;
}