require('dotenv').config(); 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"); 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'); const xpath = require("xpath-html"); 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 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 }; } 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)) && song.artists.some(artist => skewered(trackData.artist.name).includes(artist.name)); }); 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, ""); 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 video = await getVideo(); if (video == null) return dismantle(); 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.emit('nodeMessage', { message: `New song found - ${video.tags.title} by ${video.tags.artist}`, 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.tags.title} by ${video.tags.artist}`, password: process.env.NOTIFICATION_SERVER_PASSWORD }) const cobalt = await downloadFromCobalt(video); socket.emit('nodeMessage', { message: `Cobalt download finished - ${video.tags.title} by ${video.tags.artist}`, password: process.env.NOTIFICATION_SERVER_PASSWORD }); const taggedMusic = applyTags({ fileStream: cobalt, tags: video.tags }); socket.emit('nodeMessage', { message: `Nextcloud upload running - ${video.tags.title} by ${video.tags.artist}`, password: process.env.NOTIFICATION_SERVER_PASSWORD }); const nextcloud = await uploadToNextcloud( { ...video, fileStream: taggedMusic, albumCover: albumCover }, nextcloudClient ); socket.emit('nodeMessage', { message: `Nextcloud upload finished - ${video.tags.title} by ${video.tags.artist}`, password: process.env.NOTIFICATION_SERVER_PASSWORD }) return dismantle(); } 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; } }); function dismantle() { socket.disconnect(); return null; }