diff --git a/CMakeLists.txt b/CMakeLists.txt index d73e1ac..3a64412 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,8 @@ project(YTMusicDL26) add_executable(YTMusicDL26 src/main.cpp + src/playlist.cpp + src/utils.cpp ) target_include_directories(YTMusicDL26 PUBLIC diff --git a/src/config.hpp b/src/config.hpp new file mode 100644 index 0000000..5e50167 --- /dev/null +++ b/src/config.hpp @@ -0,0 +1,4 @@ +#pragma once + +#define TMP_DOWNLOAD_FOLDER "tmp/download" +#define MAX_THREADS 4 diff --git a/src/main.cpp b/src/main.cpp index 41d72f6..567542a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,249 +1,16 @@ #include #include -#include #include -#include #include #include -#include -#include #include #include #include -#include -#define TMP_DOWNLOAD_FOLDER "tmp/download" -#define MAX_THREADS 4 - -using json = nlohmann::json; - -// This runs in shell, if put through a website interface it would allow for injection -auto ytdlp(const std::string& args) -> bool { - system(std::format("bin/linux/yt-dlp {}", args).c_str()); - return true; -} - -auto metadata(const std::string& file, const std::string& artist, const std::string& album, size_t index) { - std::string name = std::filesystem::path(file).filename().string(); - name = std::regex_replace(name, std::regex(".mp3"), ""); - - system(std::format("bin/linux/ffmpeg " - "-hide_banner -loglevel error " - "-i \"{}\" " - "-i \"{}/cover.jpg\" " - "-map 0:0 " - "-map 1:0 " - "-c copy " - "-id3v2_version 3 -metadata:s:v title=\"Album cover\" -metadata:s:v comment=\"Cover (front)\" " - "-metadata title=\"{}\" " - "-metadata artist=\"{}\" " - "-metadata album=\"{}\" " - "-metadata track=\"{}\" " - "\"{}/{}_{}.mp3\"", - file, TMP_DOWNLOAD_FOLDER, name, artist, album, index, TMP_DOWNLOAD_FOLDER, index, name) - .c_str()); -} - -auto cleanTmpDownloadFolder() -> bool { - for (const auto& entry : std::filesystem::directory_iterator(TMP_DOWNLOAD_FOLDER)) { - std::filesystem::remove_all(entry.path()); - } - - return true; -} - -auto downloadImageToTmpFolder(const std::string& url) -> bool { - CURL* curl = curl_easy_init(); - if (!curl) { - printf("ERROR: Failed to initialize curl.\n"); - return false; - } - - FILE* file = std::fopen(std::format("{}/cover.jpg", TMP_DOWNLOAD_FOLDER).c_str(), "wb"); - if (!file) { - curl_easy_cleanup(curl); - printf("ERROR: Failed to create cover file.\n"); - return false; - } - - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, nullptr); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, file); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - - CURLcode res = curl_easy_perform(curl); - - std::fclose(file); - curl_easy_cleanup(curl); - - if (res != CURLE_OK) { - printf("ERROR: %s\n", curl_easy_strerror(res)); - return false; - } - - return true; -} - -auto urlsFromPlaylist(const std::string& url) -> std::vector { - std::vector urls; - - std::filesystem::remove("urls.txt"); - ytdlp( - std::format("--js-runtimes \"deno:bin/linux/deno\" " - "--quiet " - "--flat-playlist " - "--print-to-file \"%(webpage_url)s\" " - "urls.txt \"{}\"", - url)); - - std::ifstream f("urls.txt"); - if (!f.is_open()) { - printf("ERROR: Couldn't open urls.txt\n"); - return urls; - } - - std::string line; - while (std::getline(f, line)) { - urls.push_back(line); - } - - if (urls.empty()) printf("ERROR: Couldn't find any urls for playlist %s\n", url.c_str()); - return urls; -} - -auto dataFromYtdlpPlaylist(const std::string& playlistUrl, const std::string& data) -> std::string { - ytdlp( - std::format("--quiet " - "--no-warnings " - "--flat-playlist " - "--playlist-items 1 " - "--print-to-file \"%({})s\" data.txt \"{}\"", - data, playlistUrl)); - - std::ifstream f("data.txt"); - if (!f.is_open()) { - printf("ERROR: Couldn't open data.txt\n"); - } - - std::string s; - std::string line; - while (std::getline(f, line)) { - s = line; - } - - return s; -}; - -auto playlistTitle(const std::string& playlistUrl) { - std::string s = dataFromYtdlpPlaylist(playlistUrl, "playlist_title"); - s = std::regex_replace(s, std::regex("Album - "), ""); - return s; -} - -auto channelNamePlaylist(const std::string& playlistUrl) { - std::string s = dataFromYtdlpPlaylist(playlistUrl, "channel"); - s = std::regex_replace(s, std::regex(" - Topic"), ""); - return s; -} - -auto downloadVideo(const std::string& url) -> bool { - if (url == "") { - printf("ERROR: Cannot download video with empty url\n"); - return false; - } - - ytdlp( - std::format("--js-runtimes \"deno:bin/linux/deno\" " - "--quiet " - "-f \"bestaudio/best\" " - "-x --audio-format mp3 " - "-o \"{}/%(title)s.%(ext)s\" " - "{}", - TMP_DOWNLOAD_FOLDER, url)); - - printf("Finished downloading video %s.\n", url.c_str()); - return true; -} - -auto downloadPlaylist(std::string playlistUrl) -> bool { - std::vector videoUrls = urlsFromPlaylist(playlistUrl); - - if (videoUrls.empty()) { - printf("ERROR: Tried to download empty playlist, %s\n", playlistUrl.c_str()); - return false; - } - - if (!cleanTmpDownloadFolder()) { - printf("ERROR: Playlist download couldn't clean tmp download folder\n"); - return false; - } - - playlistUrl = std::regex_replace(playlistUrl, std::regex("music.youtube"), "www.youtube"); - - int count = 0; - - for (size_t i = 0; i < videoUrls.size(); i += MAX_THREADS) { - std::vector> futures; - - size_t end = std::min(i + MAX_THREADS, videoUrls.size()); - - for (size_t j = i; j < end; ++j) { - futures.push_back(std::async(std::launch::async, [&videoUrls, j]() { downloadVideo(videoUrls[j]); })); - } - - for (auto& future : futures) { - future.get(); - count++; - printf("Finished [%d/%d] in playlist %s\n", count, (int)videoUrls.size(), playlistUrl.c_str()); - } - } - - printf("Finished downloading playlist.\n"); - - return true; -} - -auto writeAlbumMetadataToTmpFolder(const std::string& playlistUrl) -> bool { - std::string artist = channelNamePlaylist(playlistUrl); - std::string album = playlistTitle(playlistUrl); - printf("Starting to write metadata for %s by %s.\n", album.c_str(), artist.c_str()); - - printf("Finding album cover.\n"); - std::filesystem::remove("tmp/data.json"); - ytdlp( - std::format("--js-runtimes \"deno:bin/linux/deno\" " - "--dump-single-json " - "\"{}\" " - "> tmp/data.json", - playlistUrl)); - - std::ifstream f("tmp/data.json"); - if (!f) { - printf("ERROR: Couldn't open data.json\n"); - return false; - } - - json data = json::parse(f); - std::string coverUrl = data["thumbnails"][1]["url"]; - downloadImageToTmpFolder(coverUrl); - - printf("Writting data to files.\n"); - - size_t index = 1; - for (const auto& entry : std::filesystem::directory_iterator(TMP_DOWNLOAD_FOLDER)) { - if (std::filesystem::path(entry.path()).filename().string() == "cover.jpg") continue; - - metadata(entry.path(), artist, album, index); - printf("Finished writting metadata for %s.\n", entry.path().c_str()); - std::filesystem::remove(entry.path()); - index++; - } - - printf("Finished writting metadata for %s by %s.\n", album.c_str(), artist.c_str()); - - return true; -} +#include "config.hpp" +#include "playlist.hpp" +#include "utils.hpp" auto main(int argc, char* argv[]) -> int { auto musicDir = "/home/ethanscharlie/Downloads/tmp"; @@ -252,6 +19,7 @@ auto main(int argc, char* argv[]) -> int { std::filesystem::create_directories(TMP_DOWNLOAD_FOLDER); url = std::regex_replace(url, std::regex("music.youtube"), "www.youtube"); - downloadPlaylist(url); - writeAlbumMetadataToTmpFolder(url); + auto playlist = Playlist(url); + playlist.download(); + playlist.writeMetadata(); } diff --git a/src/playlist.cpp b/src/playlist.cpp new file mode 100644 index 0000000..af0a665 --- /dev/null +++ b/src/playlist.cpp @@ -0,0 +1,161 @@ +#include "playlist.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "config.hpp" +#include "utils.hpp" + +using json = nlohmann::json; + +Playlist::Playlist(const std::string& playlistUrl) : playlistUrl(playlistUrl) {} + +auto Playlist::download() -> bool { + std::vector videoUrls = urlsFromPlaylist(playlistUrl); + + if (videoUrls.empty()) { + printf("ERROR: Tried to download empty playlist, %s\n", playlistUrl.c_str()); + return false; + } + + if (!cleanTmpDownloadFolder()) { + printf("ERROR: Playlist download couldn't clean tmp download folder\n"); + return false; + } + + playlistUrl = std::regex_replace(playlistUrl, std::regex("music.youtube"), "www.youtube"); + + int count = 0; + + for (size_t i = 0; i < videoUrls.size(); i += MAX_THREADS) { + std::vector> futures; + + size_t end = std::min(i + MAX_THREADS, videoUrls.size()); + + for (size_t j = i; j < end; ++j) { + futures.push_back(std::async(std::launch::async, [&videoUrls, j]() { downloadVideo(videoUrls[j]); })); + } + + for (auto& future : futures) { + future.get(); + count++; + printf("Finished [%d/%d] in playlist %s\n", count, (int)videoUrls.size(), playlistUrl.c_str()); + } + } + + printf("Finished downloading playlist.\n"); + + return true; +} + +auto Playlist::writeMetadata() -> bool { + std::string artist = channelNamePlaylist(); + std::string album = playlistTitle(); + printf("Starting to write metadata for %s by %s.\n", album.c_str(), artist.c_str()); + + printf("Finding album cover.\n"); + std::filesystem::remove("tmp/data.json"); + ytdlp( + std::format("--js-runtimes \"deno:bin/linux/deno\" " + "--dump-single-json " + "\"{}\" " + "> tmp/data.json", + playlistUrl)); + + std::ifstream f("tmp/data.json"); + if (!f) { + printf("ERROR: Couldn't open data.json\n"); + return false; + } + + json data = json::parse(f); + std::string coverUrl = data["thumbnails"][1]["url"]; + downloadImageToTmpFolder(coverUrl); + + printf("Writting data to files.\n"); + + size_t index = 1; + for (const auto& entry : std::filesystem::directory_iterator(TMP_DOWNLOAD_FOLDER)) { + if (std::filesystem::path(entry.path()).filename().string() == "cover.jpg") continue; + + metadata(entry.path(), artist, album, index); + printf("Finished writting metadata for %s.\n", entry.path().c_str()); + std::filesystem::remove(entry.path()); + index++; + } + + printf("Finished writting metadata for %s by %s.\n", album.c_str(), artist.c_str()); + + return true; +} + +auto Playlist::urlsFromPlaylist(const std::string& url) -> std::vector { + std::vector urls; + + std::filesystem::remove("urls.txt"); + ytdlp( + std::format("--js-runtimes \"deno:bin/linux/deno\" " + "--quiet " + "--flat-playlist " + "--print-to-file \"%(webpage_url)s\" " + "urls.txt \"{}\"", + url)); + + std::ifstream f("urls.txt"); + if (!f.is_open()) { + printf("ERROR: Couldn't open urls.txt\n"); + return urls; + } + + std::string line; + while (std::getline(f, line)) { + urls.push_back(line); + } + + if (urls.empty()) printf("ERROR: Couldn't find any urls for playlist %s\n", url.c_str()); + return urls; +} + +auto Playlist::dataFromYtdlpPlaylist(const std::string& data) -> std::string { + ytdlp( + std::format("--quiet " + "--no-warnings " + "--flat-playlist " + "--playlist-items 1 " + "--print-to-file \"%({})s\" data.txt \"{}\"", + data, playlistUrl)); + + std::ifstream f("data.txt"); + if (!f.is_open()) { + printf("ERROR: Couldn't open data.txt\n"); + } + + std::string s; + std::string line; + while (std::getline(f, line)) { + s = line; + } + + return s; +}; + +auto Playlist::playlistTitle() -> std::string { + std::string s = dataFromYtdlpPlaylist("playlist_title"); + s = std::regex_replace(s, std::regex("Album - "), ""); + return s; +} + +auto Playlist::channelNamePlaylist() -> std::string { + std::string s = dataFromYtdlpPlaylist("channel"); + s = std::regex_replace(s, std::regex(" - Topic"), ""); + return s; +} diff --git a/src/playlist.hpp b/src/playlist.hpp new file mode 100644 index 0000000..4167651 --- /dev/null +++ b/src/playlist.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +class Playlist { + std::string playlistUrl; + + public: + Playlist(const std::string& playlistUrl); + auto download() -> bool; + auto writeMetadata() -> bool; + + private: + auto urlsFromPlaylist(const std::string& url) -> std::vector; + auto dataFromYtdlpPlaylist(const std::string& data) -> std::string; + auto playlistTitle() -> std::string; + auto channelNamePlaylist() -> std::string; +}; diff --git a/src/utils.cpp b/src/utils.cpp new file mode 100644 index 0000000..a1348cd --- /dev/null +++ b/src/utils.cpp @@ -0,0 +1,99 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "config.hpp" + +// This runs in shell, if put through a website interface it would allow for injection +auto ytdlp(const std::string& args) -> bool { + system(std::format("bin/linux/yt-dlp {}", args).c_str()); + return true; +} + +auto metadata(const std::string& file, const std::string& artist, const std::string& album, size_t index) -> void { + std::string name = std::filesystem::path(file).filename().string(); + name = std::regex_replace(name, std::regex(".mp3"), ""); + + system(std::format("bin/linux/ffmpeg " + "-hide_banner -loglevel error " + "-i \"{}\" " + "-i \"{}/cover.jpg\" " + "-map 0:0 " + "-map 1:0 " + "-c copy " + "-id3v2_version 3 -metadata:s:v title=\"Album cover\" -metadata:s:v comment=\"Cover (front)\" " + "-metadata title=\"{}\" " + "-metadata artist=\"{}\" " + "-metadata album=\"{}\" " + "-metadata track=\"{}\" " + "\"{}/{}_{}.mp3\"", + file, TMP_DOWNLOAD_FOLDER, name, artist, album, index, TMP_DOWNLOAD_FOLDER, index, name) + .c_str()); +} + +auto cleanTmpDownloadFolder() -> bool { + for (const auto& entry : std::filesystem::directory_iterator(TMP_DOWNLOAD_FOLDER)) { + std::filesystem::remove_all(entry.path()); + } + + return true; +} + +auto downloadImageToTmpFolder(const std::string& url) -> bool { + CURL* curl = curl_easy_init(); + if (!curl) { + printf("ERROR: Failed to initialize curl.\n"); + return false; + } + + FILE* file = std::fopen(std::format("{}/cover.jpg", TMP_DOWNLOAD_FOLDER).c_str(), "wb"); + if (!file) { + curl_easy_cleanup(curl); + printf("ERROR: Failed to create cover file.\n"); + return false; + } + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, nullptr); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, file); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + + CURLcode res = curl_easy_perform(curl); + + std::fclose(file); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + printf("ERROR: %s\n", curl_easy_strerror(res)); + return false; + } + + return true; +} + +auto downloadVideo(const std::string& url) -> bool { + if (url == "") { + printf("ERROR: Cannot download video with empty url\n"); + return false; + } + + ytdlp( + std::format("--js-runtimes \"deno:bin/linux/deno\" " + "--quiet " + "-f \"bestaudio/best\" " + "-x --audio-format mp3 " + "-o \"{}/%(title)s.%(ext)s\" " + "{}", + TMP_DOWNLOAD_FOLDER, url)); + + printf("Finished downloading video %s.\n", url.c_str()); + return true; +} diff --git a/src/utils.hpp b/src/utils.hpp new file mode 100644 index 0000000..32cac02 --- /dev/null +++ b/src/utils.hpp @@ -0,0 +1,12 @@ +#pragma once +#include + +auto ytdlp(const std::string& args) -> bool; + +auto metadata(const std::string& file, const std::string& artist, const std::string& album, size_t index) -> void; + +auto cleanTmpDownloadFolder() -> bool; + +auto downloadImageToTmpFolder(const std::string& url) -> bool; + +auto downloadVideo(const std::string& url) -> bool;